-
-
Notifications
You must be signed in to change notification settings - Fork 129
feat(cli): precompiled, signed & notarized macOS keychain helper packages #2458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
b357ed9
0a81ee7
b2bf229
640abfc
e36c1eb
67f6829
758cb9a
f894a8c
ee6f104
cbfa23c
36bb529
8ec2181
41ecf9c
5630bf2
b3c8485
5f96c4c
57aa9ff
c0c0c41
0d15569
6030554
4a1bd15
56842c3
d19ec57
5f2d277
97795f7
a692637
cd6c6e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| 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: | ||
| contents: write | ||
| id-token: write | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v6 | ||
| - name: Setup Node.js | ||
| uses: actions/setup-node@v6 | ||
| with: | ||
| node-version: 24.x | ||
| registry-url: https://registry.npmjs.org | ||
|
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 binaries | ||
| run: bash cli-helper/scripts/build.sh | ||
| - 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: | | ||
| set +e | ||
| out=$(./cli-helper/dist/helper-arm64) | ||
| 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: | | ||
| set +e | ||
| out=$(./cli-helper/dist/helper-arm64 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; } | ||
|
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: 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/helper-arm64 | ||
| cli-helper/dist/helper-x64 | ||
| make_latest: false | ||
| token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # Capgo CLI keychain helper | ||
|
|
||
| Small Swift program (Security framework only) shipped as one generic binary | ||
| named `helper`. Today it has a single subcommand: | ||
|
|
||
| helper 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. | ||
|
|
||
| Shipped as two precompiled, Developer-ID-signed, notarized 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. The | ||
| CLI verifies the binary's code signature (Developer ID + Capgo Team ID) before | ||
| every execution and refuses to run anything else. 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: | ||
|
|
||
| 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 subcommand wrapper, so point it at a | ||
| binary you built and trust. | ||
|
|
||
| ## 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`. |
| 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. |
| 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", | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| "os": ["darwin"], | ||
| "cpu": ["arm64"], | ||
| "files": ["helper"] | ||
| } | ||
| 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", | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| "os": ["darwin"], | ||
| "cpu": ["x64"], | ||
| "files": ["helper"] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| #!/usr/bin/env bash | ||
| # Compile helper for both macOS architectures into cli-helper/dist/. | ||
| # 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")/.." | ||
| mkdir -p dist | ||
| swiftc src/helper.swift -framework Security -O \ | ||
| -target arm64-apple-macos11 -o dist/helper-arm64 | ||
| swiftc src/helper.swift -framework Security -O \ | ||
| -target x86_64-apple-macos10.15 -o dist/helper-x64 | ||
| echo "Built:" | ||
| file dist/helper-arm64 dist/helper-x64 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| // Stamp the release version into both npm manifests and copy the signed | ||
| // binaries into their package dirs. | ||
| // Usage: node cli-helper/scripts/prepare-publish.mjs <semver> | ||
| // Fails fast on a malformed version or missing binary so a bad tag can | ||
| // never publish. | ||
| import { chmodSync, copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' | ||
| import { dirname, join } from 'node:path' | ||
| import process from 'node:process' | ||
| import { fileURLToPath } from 'node:url' | ||
|
|
||
| const root = join(dirname(fileURLToPath(import.meta.url)), '..') | ||
| const version = process.argv[2] | ||
| if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { | ||
| console.error(`Usage: node prepare-publish.mjs <semver> — got "${version ?? ''}"`) | ||
| process.exit(1) | ||
| } | ||
|
|
||
| for (const arch of ['arm64', 'x64']) { | ||
| const src = join(root, 'dist', `helper-${arch}`) | ||
| if (!existsSync(src)) { | ||
| console.error(`Missing binary ${src} — run build.sh + sign-and-notarize.sh first`) | ||
| process.exit(1) | ||
| } | ||
| const pkgDir = join(root, 'npm', `darwin-${arch}`) | ||
| const manifestPath = join(pkgDir, 'package.json') | ||
| const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) | ||
| const updated = { ...manifest, version } | ||
| writeFileSync(manifestPath, `${JSON.stringify(updated, null, 2)}\n`) | ||
| const dest = join(pkgDir, 'helper') | ||
| copyFileSync(src, dest) | ||
| chmodSync(dest, 0o755) | ||
| console.log(`Prepared ${manifest.name}@${version}`) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| #!/usr/bin/env bash | ||
| # Codesign (hardened runtime + timestamp) and notarize both helper binaries, | ||
| # then verify each against the same designated requirement the CLI enforces | ||
| # at runtime — a cert/team mismatch fails the release, not the user. | ||
| # | ||
| # Required env: | ||
| # DEVELOPER_ID_IDENTITY codesign identity, e.g. "Developer ID Application: <name> (<TEAMID>)" | ||
| # CAPGO_APPLE_TEAM_ID 10-char Apple Team ID (must match macos-signing.ts) | ||
| # APPLE_KEY_ID App Store Connect API key id | ||
| # APPLE_ISSUER_ID App Store Connect API key issuer | ||
| # APPLE_KEY_PATH path to the API key .p8 file | ||
| set -euo pipefail | ||
| cd "$(dirname "$0")/.." | ||
|
|
||
| : "${DEVELOPER_ID_IDENTITY:?}" "${CAPGO_APPLE_TEAM_ID:?}" "${APPLE_KEY_ID:?}" "${APPLE_ISSUER_ID:?}" "${APPLE_KEY_PATH:?}" | ||
|
|
||
| REQUIREMENT='=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "'"$CAPGO_APPLE_TEAM_ID"'"' | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| # Stable code-signing identifier. macOS keys the Keychain "Always Allow" grant | ||
| # to the code's designated requirement, which includes this identifier — so | ||
| # pinning it now keeps users' grants intact across future re-signs, including a | ||
| # possible migration to a `Capgo.app` bundle that reuses the SAME | ||
| # CFBundleIdentifier. Never change this value. See "Future: native | ||
| # notifications & UI" in the design spec. | ||
| HELPER_IDENTIFIER="app.capgo.cli.helper" | ||
|
|
||
| for arch in arm64 x64; do | ||
| bin="dist/helper-$arch" | ||
| echo "── Signing $bin" | ||
| codesign --force --sign "$DEVELOPER_ID_IDENTITY" --identifier "$HELPER_IDENTIFIER" --options runtime --timestamp "$bin" | ||
|
|
||
| echo "── Notarizing $bin" | ||
| ditto -c -k "$bin" "$bin.zip" | ||
| out=$(xcrun notarytool submit "$bin.zip" \ | ||
| --key "$APPLE_KEY_PATH" --key-id "$APPLE_KEY_ID" --issuer "$APPLE_ISSUER_ID" \ | ||
| --wait --timeout 30m --output-format json) || true | ||
| id=$(echo "$out" | jq -r '.id // empty') | ||
| status=$(echo "$out" | jq -r '.status // empty') | ||
| if [ "$status" != "Accepted" ]; then | ||
|
Check failure on line 39 in cli-helper/scripts/sign-and-notarize.sh
|
||
| echo "Notarization failed for $bin (status: ${status:-unknown})" >&2 | ||
| if [ -n "$id" ]; then | ||
|
Check failure on line 41 in cli-helper/scripts/sign-and-notarize.sh
|
||
| xcrun notarytool log "$id" \ | ||
| --key "$APPLE_KEY_PATH" --key-id "$APPLE_KEY_ID" --issuer "$APPLE_ISSUER_ID" >&2 || true | ||
| fi | ||
| exit 1 | ||
| fi | ||
| echo "── Notarization accepted ($id)" | ||
|
|
||
| echo "── Verifying $bin" | ||
| codesign --verify --strict "$bin" | ||
| codesign --verify --strict -R "$REQUIREMENT" "$bin" | ||
| done | ||
| echo "All binaries signed, notarized, and verified." | ||
Uh oh!
There was an error while loading. Please reload this page.