Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b357ed9
docs: design spec for precompiled macOS keychain helper packages
WcaleNieWolny Jun 6, 2026
0a81ee7
docs: harden helper spec — strip env override from release, verify bi…
WcaleNieWolny Jun 6, 2026
b2bf229
docs: remove swiftc fallback from helper spec — verified binary or ha…
WcaleNieWolny Jun 6, 2026
640abfc
docs: implementation plan for precompiled keychain helper
WcaleNieWolny Jun 6, 2026
e36c1eb
docs: generic helper binary + subcommand, anti-footgun gate, SECURITY…
WcaleNieWolny Jun 8, 2026
67f6829
docs: record future native-UI/.app path + pin stable signing identifi…
WcaleNieWolny Jun 8, 2026
758cb9a
docs: switch helper release to deliberate workflow_dispatch (button),…
WcaleNieWolny Jun 8, 2026
f894a8c
feat(cli-helper): precompiled macOS keychain helper packages
WcaleNieWolny Jun 8, 2026
ee6f104
feat(cli): run precompiled helper with signature verification, drop s…
WcaleNieWolny Jun 8, 2026
cbfa23c
ci: workflow_dispatch helper publish + release strip assertion
WcaleNieWolny Jun 8, 2026
36bb529
docs: fix plan architecture line to workflow_dispatch (was stale 'tag…
WcaleNieWolny Jun 8, 2026
8ec2181
fix: drop stale compile-helper test + address review nits
claude Jun 8, 2026
41ecf9c
feat(cli-helper): ship helper as hidden Capgo.app bundle
WcaleNieWolny Jun 9, 2026
5630bf2
chore: address CodeRabbit review
WcaleNieWolny Jun 9, 2026
b3c8485
ci(helper): drop GITHUB_TOKEN to contents: read (release uses PAT)
WcaleNieWolny Jun 9, 2026
5f96c4c
ci(helper): also trigger on cli-helper-* tag (testable from a branch)
WcaleNieWolny Jun 9, 2026
57aa9ff
ci(helper): publish under the 'rc' dist-tag (keep new packages off 'l…
WcaleNieWolny Jun 9, 2026
c0c0c41
ci(helper): hardcode public team id UVTJ336J2D (not a secret)
WcaleNieWolny Jun 11, 2026
0d15569
ci(helper): decode base64 APPLE_KEY_CONTENT before notarytool (fixes …
WcaleNieWolny Jun 11, 2026
6030554
Merge remote-tracking branch 'origin/main' into feat/precompiled-keyc…
WcaleNieWolny Jun 11, 2026
4a1bd15
ci(helper): run precompiled-helper signature tests in the macOS TUI w…
WcaleNieWolny Jun 11, 2026
56842c3
test(helper): bump private suite to drift-fixed main (drops compiling…
WcaleNieWolny Jun 11, 2026
d19ec57
test(helper): pin private suite to the capgo-main-calibrated branch
WcaleNieWolny Jun 12, 2026
5f2d277
fix(helper): harden signing path per hostile review
WcaleNieWolny Jun 12, 2026
97795f7
ci(helper): fix gate smoke tests for stdin passphrase interface
WcaleNieWolny Jun 12, 2026
a692637
ci(helper): publish stable versions to npm 'latest', prereleases to 'rc'
WcaleNieWolny Jun 12, 2026
cd6c6e4
feat(cli): declare keychain helper packages as optionalDependencies
WcaleNieWolny Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/publish_cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ jobs:
run: bun install --frozen-lockfile
- name: Build CLI
run: bun run cli:build
- name: Assert dev-only env override is stripped from release bundle
run: |
test -f cli/dist/index.js || {
echo "::error::cli/dist/index.js not found; build output path changed or build failed"
exit 1
}
if grep -q "CAPGO_KEYCHAIN_HELPER_PATH" cli/dist/index.js; then
echo "::error::CAPGO_KEYCHAIN_HELPER_PATH leaked into the release bundle — dead-code elimination failed"
exit 1
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Generate AI changelog
id: changelog
uses: mistricky/ccc@v0.2.6
Expand Down
136 changes: 136 additions & 0 deletions .github/workflows/publish_cli_helper.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Build and publish CLI keychain helper

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

on:
workflow_dispatch:
inputs:
version:
description: "Helper version to publish, e.g. 1.0.0 (no 'cli-helper-' prefix)"
required: true

permissions: {}

jobs:
publish_cli_helper:
runs-on: macos-latest
name: Build, sign, notarize, publish keychain helper
timeout-minutes: 45
permissions:
# checkout only needs read; the tag + GitHub release run through
# PERSONAL_ACCESS_TOKEN, not the automatic GITHUB_TOKEN.
contents: read
# required for `npm publish --provenance` (mints the OIDC token).
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.x
registry-url: https://registry.npmjs.org
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Validate + capture version
id: version
run: |
v="${{ github.event.inputs.version }}"
if ! echo "$v" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then
echo "::error::version '$v' is not semver (e.g. 1.0.0)"; exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
- name: Build helper bundles
run: bash cli-helper/scripts/build.sh "${{ steps.version.outputs.version }}"
- name: Import Developer ID certificate into throwaway keychain
env:
DEVELOPER_ID_CERT_BASE64: ${{ secrets.DEVELOPER_ID_CERT_BASE64 }}
DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PWD="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
echo "$DEVELOPER_ID_CERT_BASE64" | base64 -d > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN_PATH" \
-P "$DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign
rm "$RUNNER_TEMP/cert.p12"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
if [ -z "$IDENTITY" ]; then
echo "::error::No Developer ID Application identity found in imported cert"
exit 1
fi
echo "DEVELOPER_ID_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
- name: Write App Store Connect API key
env:
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
run: |
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
echo "APPLE_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV"
- name: Sign and notarize
env:
CAPGO_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
run: bash cli-helper/scripts/sign-and-notarize.sh
- name: Smoke test signed binary
run: |
arch="$(uname -m)"
case "$arch" in
arm64) helper="./cli-helper/dist/arm64/Capgo.app/Contents/MacOS/capgo" ;;
x86_64) helper="./cli-helper/dist/x64/Capgo.app/Contents/MacOS/capgo" ;;
*) echo "::error::unsupported runner arch: $arch"; exit 1 ;;
esac
set +e
out=$("$helper")
code=$?
set -e
[ "$code" -ne 0 ] || { echo "::error::expected non-zero exit"; exit 1; }
echo "$out" | jq -e '.ok == false and .errorCode == "INVALID_ARGS"' > /dev/null \
|| { echo "::error::unexpected helper output: $out"; exit 1; }
- name: Gate test — keychain-export without handshake is FORBIDDEN_CALLER
run: |
arch="$(uname -m)"
case "$arch" in
arm64) helper="./cli-helper/dist/arm64/Capgo.app/Contents/MacOS/capgo" ;;
x86_64) helper="./cli-helper/dist/x64/Capgo.app/Contents/MacOS/capgo" ;;
*) echo "::error::unsupported runner arch: $arch"; exit 1 ;;
esac
set +e
out=$("$helper" keychain-export --sha1 "$(printf 'a%.0s' {1..40})" --output /tmp/x.p12 --passphrase p | cat)
set -e
echo "$out" | jq -e '.ok == false and .errorCode == "FORBIDDEN_CALLER"' > /dev/null \
|| { echo "::error::gate did not reject missing handshake: $out"; exit 1; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Prepare packages
run: node cli-helper/scripts/prepare-publish.mjs "${{ steps.version.outputs.version }}"
- name: Publish darwin-arm64
working-directory: cli-helper/npm/darwin-arm64
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --provenance --access public
- name: Publish darwin-x64
working-directory: cli-helper/npm/darwin-x64
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --provenance --access public
- name: Zip signed bundles for release assets
run: |
ditto -c -k --keepParent cli-helper/dist/arm64/Capgo.app cli-helper/dist/Capgo-arm64.zip
ditto -c -k --keepParent cli-helper/dist/x64/Capgo.app cli-helper/dist/Capgo-x64.zip
- name: Create tag + GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: cli-helper-${{ steps.version.outputs.version }}
target_commitish: ${{ github.sha }}
files: |
cli-helper/dist/Capgo-arm64.zip
cli-helper/dist/Capgo-x64.zip
make_latest: false
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@ graphify-out/*.html
graphify-out/*.svg
graphify-out/*.graphml
graphify-out/cypher.txt

# Capgo CLI keychain helper — locally built bundles
cli-helper/dist/
cli-helper/npm/*/Capgo.app
72 changes: 72 additions & 0 deletions cli-helper/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Capgo CLI keychain helper

Small Swift program (Security framework only), shipped inside a hidden macOS
app bundle, **`Capgo.app`**. The single binary uses subcommand dispatch — today
just `keychain-export`:

Capgo.app/Contents/MacOS/capgo keychain-export \
--sha1 <40-hex> --output <path.p12> --passphrase <wrap-pass> --invoked-by capgo-cli

It exports one code-signing identity from the macOS Keychain as a
passphrase-wrapped PKCS#12 and always emits one line of JSON on stdout
(`{"ok":true,...}` or `{"ok":false,"errorCode":...}`). Future helpers are new
subcommands of the same signed binary.

## Why a bundle (not a bare binary)

`Capgo.app` is an **`LSUIElement` agent** — no Dock icon, no Cmd-Tab entry, no
window; it runs headlessly and exits. The bundle gives two things a bare binary
can't:

- **Branded Keychain prompts.** Because the export runs from inside a signed
`Capgo.app`, the macOS Keychain "Allow / Always Allow" prompts show the
**Capgo name + icon** instead of a generic process name. (This requires the
bundle to be signed — see the dev note below.)
- **Stable ACL identity.** `CFBundleIdentifier = app.capgo.cli.helper` keys the
Keychain "Always Allow" grant, and it never changes across releases, so the
grant persists across CLI upgrades. The CLI also verifies the bundle's
Developer ID + Capgo Team ID code signature before running it.

The CLI execs `Capgo.app/Contents/MacOS/capgo` **directly** (never `open
Capgo.app`), so there is no Dock flash and no Gatekeeper "downloaded from the
internet" prompt (npm doesn't set the quarantine xattr; direct exec isn't a
LaunchServices launch).

Shipped as two precompiled, Developer-ID-signed, notarized, **stapled** npm
packages:

- `@capgo/cli-keychain-darwin-arm64` (Apple Silicon, macOS 11+)
- `@capgo/cli-keychain-darwin-x64` (Intel, macOS 10.15+)

Both are `optionalDependencies` of `@capgo/cli`; npm installs at most one. Each
ships its own `Capgo.app`. See SECURITY.md for the threat model.

## Dev bootstrap (working on the Swift source)

The published CLI has no compile fallback. To test local Swift changes quickly:

swiftc cli-helper/src/helper.swift -framework Security -O -o /tmp/helper-dev
cd cli && NODE_ENV=development bun run build
CAPGO_KEYCHAIN_HELPER_PATH=/tmp/helper-dev node dist/index.js ...

`CAPGO_KEYCHAIN_HELPER_PATH` only exists in dev builds — it is dead-code-
eliminated from npm release builds (asserted in CI). The env-override path skips
both the signature check and the bundle, so point it at a binary you built and
trust. Note: a bare dev binary is **not** signed, so the Keychain prompt shows
the process name, not "Capgo" — to see the branded prompt, build + sign the
bundle (`bash cli-helper/scripts/build.sh` then `codesign … Capgo.app`).

## Release

1. Bump nothing in-repo — the version comes from the dispatch input.
2. Run the workflow from the GitHub Actions UI ("Run workflow" → enter the
version), or: `gh workflow run publish_cli_helper.yml -f version=X.Y.Z`
3. `.github/workflows/publish_cli_helper.yml` builds, signs, notarizes,
smoke-tests, publishes both packages with npm provenance, and creates the
`cli-helper-X.Y.Z` tag + GitHub release.
4. Release only when `src/helper.swift` actually changed.

Required GitHub secrets: `DEVELOPER_ID_CERT_BASE64`, `DEVELOPER_ID_CERT_PASSWORD`
(Developer ID Application cert as base64 .p12), `APPLE_TEAM_ID`, plus existing
`APPLE_KEY_ID`, `APPLE_ISSUER_ID`, `APPLE_KEY_CONTENT` (App Store Connect API
key, used by notarytool) and `NPM_TOKEN`.
40 changes: 40 additions & 0 deletions cli-helper/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Security model — Capgo CLI keychain helper

## The boundary is the macOS Keychain ACL, not this binary

Exporting a code-signing private key triggers an OS-level Keychain prompt
("Allow" / "Always Allow") that macOS enforces against the **calling binary's
code signature**. That prompt — not anything in this helper or in `@capgo/cli`
— is the security boundary.

## Invoking the helper grants no privilege

An attacker who can run this `helper` on a victim's machine already has local
code execution as that user, and can call Apple's own `SecItemExport` or
`/usr/bin/security export` directly. This helper is a worse-for-them version of
tools already present on every Mac. It is **not** a privilege escalation.

## Why we don't authenticate the caller

- The CLI runs as `node dist/index.js`; **node is signed by the user's Node
install, not by Capgo** — there is no Capgo signature on the parent to pin.
- A shared secret would live in readable JavaScript in the npm tarball.
- Parent-PID checks are TOCTOU-racy and subject to PID reuse.

## What we do instead

- The CLI verifies **this binary's** Developer ID + Capgo Team ID signature
before running it (protects the CLI from a swapped helper).
- The sensitive `keychain-export` subcommand has an **anti-footgun gate**
(requires an internal `--invoked-by capgo-cli` handshake and a non-TTY
stdout). This stops casual/accidental/naive-script misuse. **It is explicitly
not a security boundary** — a determined local attacker reads the handshake
out of the open-source CLI. It exists to keep honest software honest.

## Reporting expectation

Demonstrating that you can invoke this helper yourself, or that doing so exports
a key after the user grants the macOS prompt, is **out of scope by design** — it
is equivalent to calling Apple's keychain APIs, which any local process with the
user's privileges can already do. Reports must show a privilege boundary being
crossed that the OS would otherwise enforce.
Binary file added cli-helper/assets/Capgo.icns
Binary file not shown.
30 changes: 30 additions & 0 deletions cli-helper/assets/Info.plist.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Capgo</string>
<key>CFBundleDisplayName</key>
<string>Capgo</string>
<key>CFBundleIdentifier</key>
<string>app.capgo.cli.helper</string>
<key>CFBundleExecutable</key>
<string>capgo</string>
<key>CFBundleIconFile</key>
<string>Capgo</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>LSMinimumSystemVersion</key>
<string>__MINOS__</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© Capgo</string>
</dict>
</plist>
14 changes: 14 additions & 0 deletions cli-helper/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@capgo/cli-keychain-darwin-arm64",
"version": "0.0.0",
"description": "Precompiled macOS (Apple Silicon) keychain helper for @capgo/cli",
"repository": {
"type": "git",
"url": "git+https://github.com/Cap-go/capgo.git",
"directory": "cli-helper"
},
"license": "Apache-2.0",
"os": ["darwin"],
"cpu": ["arm64"],
"files": ["Capgo.app"]
}
14 changes: 14 additions & 0 deletions cli-helper/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@capgo/cli-keychain-darwin-x64",
"version": "0.0.0",
"description": "Precompiled macOS (Intel) keychain helper for @capgo/cli",
"repository": {
"type": "git",
"url": "git+https://github.com/Cap-go/capgo.git",
"directory": "cli-helper"
},
"license": "Apache-2.0",
"os": ["darwin"],
"cpu": ["x64"],
"files": ["Capgo.app"]
}
39 changes: 39 additions & 0 deletions cli-helper/scripts/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Build Capgo.app bundles (one per macOS arch) wrapping the keychain helper.
#
# Hidden agent app (LSUIElement = no Dock icon, not in Cmd-Tab) branded "Capgo",
# so the macOS Keychain prompts shown during export display the Capgo name +
# icon. The bundle identifier (app.capgo.cli.helper, from Info.plist) keys the
# Keychain "Always Allow" grant and is part of the codesign designated
# requirement the CLI enforces at runtime.
#
# Usage: build.sh [VERSION]
# VERSION is baked into Info.plist BEFORE signing (changing it after signing
# would break the seal). Defaults to 0.0.0 for local dev builds.
#
# arm64 targets macOS 11 (first Apple Silicon release); x64 targets 10.15
# (oldest macOS that can run Node 20, the CLI's floor).
set -euo pipefail
cd "$(dirname "$0")/.."

VERSION="${1:-0.0.0}"
ASSETS="assets"
rm -rf dist
mkdir -p dist

build_arch() {
local arch="$1" target="$2" minos="$3"
local app="dist/$arch/Capgo.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
swiftc src/helper.swift -framework Security -O -target "$target" \
-o "$app/Contents/MacOS/capgo"
chmod +x "$app/Contents/MacOS/capgo"
cp "$ASSETS/Capgo.icns" "$app/Contents/Resources/Capgo.icns"
sed -e "s/__VERSION__/$VERSION/g" -e "s/__MINOS__/$minos/g" \
"$ASSETS/Info.plist.template" > "$app/Contents/Info.plist"
echo "Built $app (v$VERSION):"
file "$app/Contents/MacOS/capgo"
}

build_arch arm64 arm64-apple-macos11 11.0
build_arch x64 x86_64-apple-macos10.15 10.15
Loading
Loading