From b357ed9dcf1a135d0b458c4cfa479cd7e21cc441 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 6 Jun 2026 12:20:14 +0200 Subject: [PATCH 01/26] docs: design spec for precompiled macOS keychain helper packages --- ...06-06-keychain-helper-precompile-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md new file mode 100644 index 0000000000..c911fcb9fd --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -0,0 +1,208 @@ +# Precompiled macOS Keychain-Export Helper — Design + +**Date:** 2026-06-06 +**Status:** Approved design, pending implementation plan + +## Problem + +The Capgo CLI's macOS signing onboarding exports a code-signing identity from the +user's Keychain via a Swift helper (`keychain-export.swift`). Today the helper +ships as **source** in the npm tarball and is compiled on the user's machine +with `swiftc` on first use. This: + +- requires Xcode Command Line Tools at runtime (hard failure without them), +- adds a first-run compile delay and a dedicated "compiling helper" UI step, +- produces a fresh ad-hoc-signed binary per CLI version, so macOS Keychain + "Always Allow" ACL decisions do **not** persist across CLI upgrades + (ACLs are tied to the calling binary's code signature). + +## Goal + +Ship precompiled, Developer-ID-signed, notarized helper binaries as separate +macOS-only npm packages, resolved at runtime by the CLI, with the existing +swiftc compile path retained as fallback. + +## Decisions (settled during brainstorming) + +| Decision | Choice | +| --- | --- | +| Source location | New top-level `cli-helper/` dir in the capgo monorepo; **Swift source moves there** (`cli-helper/src/keychain-export.swift`) as single source of truth | +| Package shape | Per-arch packages (esbuild style): `@capgo/cli-keychain-darwin-arm64`, `@capgo/cli-keychain-darwin-x64` | +| Install mechanism | Both listed in `@capgo/cli` `optionalDependencies` with `^` range; `os`/`cpu` fields make npm/bun/pnpm install at most one | +| Fallback | Keep existing chain: cached tmp binary → swiftc compile from bundled source (still requires Xcode CLT) | +| Min macOS | x64 slice: macOS 10.15 (oldest macOS that runs Node 20, the CLI's floor); arm64 slice: macOS 11.0 | +| Versioning | Independent semver, starting 1.0.0; release tag `cli-helper-X.Y.Z`; released **only when helper source changes**, not per CLI release | +| Pipeline | Tag-triggered GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance | +| Signing | Developer ID Application certificate; hardened runtime + secure timestamp; notarized via `notarytool` with existing App Store Connect API key secrets | + +## Architecture + +### Repository layout + +``` +cli-helper/ +├── src/ +│ └── keychain-export.swift # moved from cli/src/build/onboarding/ +├── npm/ +│ ├── darwin-arm64/package.json # @capgo/cli-keychain-darwin-arm64 +│ └── darwin-x64/package.json # @capgo/cli-keychain-darwin-x64 +├── scripts/ +│ ├── build.sh # swiftc per-arch builds +│ ├── sign-and-notarize.sh # codesign + notarytool submit --wait +│ └── prepare-publish.mjs # stamps tag version, copies binaries into npm/*/ +└── README.md +``` + +### Package manifests + +```json +{ + "name": "@capgo/cli-keychain-darwin-arm64", + "version": "1.0.0", + "description": "Precompiled macOS (Apple Silicon) keychain-export helper for @capgo/cli", + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["keychain-export"], + "license": "Apache 2.0" +} +``` + +(`-x64` variant identical with `"cpu": ["x64"]`.) + +- The binary ships as a plain executable `keychain-export` at the package root. + No `bin` entry — it is never on PATH; the CLI resolves it by path. + npm preserves the executable bit from the tarball. +- Both packages always publish at the same version in the same workflow run. + +### CLI integration + +`@capgo/cli` `package.json`: + +```json +"optionalDependencies": { + "@capgo/cli-keychain-darwin-arm64": "^1.0.0", + "@capgo/cli-keychain-darwin-x64": "^1.0.0" +} +``` + +Runtime resolution order in `cli/src/build/onboarding/macos-signing.ts`: + +1. `CAPGO_KEYCHAIN_HELPER_PATH` env override (new; debugging/tests). +2. Precompiled package: + `createRequire(import.meta.url).resolve('@capgo/cli-keychain-darwin-' + archSuffix + '/keychain-export')` + where `archSuffix` maps `process.arch` `arm64`→`arm64`, `x64`→`x64`; any + other arch skips this step. Wrapped in try/catch; verify the file exists + and is executable. On hit: use directly (no tmp copy, no compile); + `isHelperCached()` returns true so the UI skips the "compiling helper" step. +3. Cached tmp binary at `$TMPDIR/capgo-keychain-export-v{version}` (existing). +4. swiftc compile from bundled `.swift` source (existing; needs Xcode CLT). + +Build changes in `cli/build.mjs`: + +- Mark both helper packages as `external` in `Bun.build` so they resolve from + `node_modules` at runtime instead of being bundled. +- The `.swift` copy into `dist/` now sources from + `../cli-helper/src/keychain-export.swift`. + +Path updates for the source move: + +- `resolveSwiftSourcePath()` dev-mode candidate points at + `cli-helper/src/keychain-export.swift`. +- Tests referencing the old path are updated. + +The helper's stdout contract (one line of JSON: `ok`, `p12Path`, +`errorCode`, `osStatus`, …) is unchanged; `exportP12FromKeychain` parsing is +untouched. + +## CI pipeline — `.github/workflows/publish_cli_helper.yml` + +Trigger: push of tags matching `cli-helper-[0-9]*`. Single job on +`macos-latest` (both arches cross-compile on one runner; no artifact passing). + +1. **Build** (per arch): + - `swiftc src/keychain-export.swift -framework Security -O -target arm64-apple-macos11 -o keychain-export-arm64` + - `swiftc src/keychain-export.swift -framework Security -O -target x86_64-apple-macos10.15 -o keychain-export-x64` +2. **Sign**: create a throwaway keychain for the job; import the Developer ID + Application cert from secrets `DEVELOPER_ID_CERT_BASE64` (.p12) + + `DEVELOPER_ID_CERT_PASSWORD`; then per binary: + `codesign --sign "Developer ID Application: " --options runtime --timestamp `. + Hardened runtime and secure timestamp are notarization requirements. The + helper needs no entitlements (non-sandboxed CLI tool using Security + framework keychain APIs). +3. **Notarize** (per binary): `ditto -c -k` into a zip, then + `xcrun notarytool submit --key --key-id $APPLE_KEY_ID --issuer $APPLE_ISSUER_ID --wait` + with a timeout. On rejection, dump `notarytool log` into the job output. + Bare executables cannot be stapled — expected and acceptable: npm-installed + files carry no quarantine xattr, and the notarization ticket is available + online when Gatekeeper does evaluate. +4. **Verify**: `codesign --verify --strict` per binary; assert signing + authority is the Developer ID cert; smoke-run the arm64 binary with invalid + args and assert non-zero exit + `INVALID_ARGS` JSON envelope on stdout. +5. **Publish**: `prepare-publish.mjs` reads the version from the tag, stamps + both manifests (failing fast on mismatch), copies each binary into its + package dir, then `npm publish --provenance --access public` for both + packages back-to-back after all gates pass. +6. **GitHub release**: same `softprops/action-gh-release` pattern as + `publish_cli.yml`, with both binaries attached as release assets. + +Required workflow permissions: `contents: write`, `id-token: write` +(provenance). + +## Apple setup (one-time, user-guided) + +Already in place: Apple Developer team; App Store Connect API key secrets +(`APPLE_KEY_ID`, `APPLE_ISSUER_ID`, `APPLE_KEY_CONTENT`). + +To do: + +1. Create a **Developer ID Application** certificate in the Apple Developer + portal (Certificates → + → Developer ID Application). Requires the + **Account Holder** role (Apple policy for Developer ID certs). Export as + `.p12` with a password; add GitHub secrets `DEVELOPER_ID_CERT_BASE64` and + `DEVELOPER_ID_CERT_PASSWORD`. +2. Verify the existing App Store Connect API key has **Developer role or + higher** (required by `notarytool`); validate with a dry-run submission. +3. One full local sign + notarize cycle on a Mac before wiring CI, to validate + the cert, the key, and the exact command set. + +## Error handling + +- Each runtime resolution step falls through silently to the next. Only when + all steps fail does the user see an error — the existing swiftc/Xcode-CLT + message, extended to mention reinstalling with optional dependencies enabled. +- No partial publishes: both packages publish at the end of the workflow, + after both binaries pass signing, notarization, and verification gates. +- Notarization flakiness is contained to helper releases (rare), never blocks + CLI releases. + +## Testing + +- **Unit** (extend `cli/test/test-macos-signing.mjs`; cross-platform, no real + Keychain): resolution order — env override wins; fake package dir resolves + before swiftc path; missing package falls through; non-executable file falls + through. +- **CI smoke** (in helper workflow): signed arm64 binary runs with invalid + args → non-zero exit + `INVALID_ARGS` JSON envelope (proves the hardened- + runtime-signed binary executes). +- **Signature checks** (in workflow): `codesign --verify --strict`; authority + check; notarization "Accepted" status. +- **Manual acceptance** (once per first release): npm-install a release + candidate on a Mac, run the onboarding export flow, confirm no "compiling + helper" step and successful P12 export; cover x64 via Intel Mac or Rosetta. +- **Fallback regression**: install with `--no-optional`, confirm the swiftc + path still engages. + +## Benefits recap + +- No Xcode CLT requirement for the overwhelmingly common path. +- No first-run compile delay; "compiling helper" UI step disappears. +- Developer ID signature is stable across releases → Keychain "Always Allow" + decisions persist across CLI upgrades (UX improvement over today). +- npm provenance + notarization give a verifiable supply chain for a binary + that reads users' keychains. + +## Out of scope + +- Removing the swiftc fallback (revisit in a future major). +- Stapling (impossible for bare executables; not needed for npm distribution). +- Windows/Linux variants (helper is macOS-only by nature). From 0a81ee7cca88416ec9c69416e3de28f7bc7621c8 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 6 Jun 2026 12:38:42 +0200 Subject: [PATCH 02/26] =?UTF-8?q?docs:=20harden=20helper=20spec=20?= =?UTF-8?q?=E2=80=94=20strip=20env=20override=20from=20release,=20verify?= =?UTF-8?q?=20binary=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-06-keychain-helper-precompile-design.md | 79 ++++++++++++++++--- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index c911fcb9fd..01233af8a8 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -34,6 +34,8 @@ swiftc compile path retained as fallback. | Versioning | Independent semver, starting 1.0.0; release tag `cli-helper-X.Y.Z`; released **only when helper source changes**, not per CLI release | | Pipeline | Tag-triggered GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance | | Signing | Developer ID Application certificate; hardened runtime + secure timestamp; notarized via `notarytool` with existing App Store Connect API key secrets | +| Binary trust | CLI verifies the package-resolved binary's code signature (Developer ID + Capgo Team ID designated requirement) before executing it; failure falls through to swiftc with a warning | +| Env override | `CAPGO_KEYCHAIN_HELPER_PATH` exists in dev builds only — stripped from npm release builds via build-time define + dead-code elimination | ## Architecture @@ -87,20 +89,62 @@ cli-helper/ Runtime resolution order in `cli/src/build/onboarding/macos-signing.ts`: -1. `CAPGO_KEYCHAIN_HELPER_PATH` env override (new; debugging/tests). +1. `CAPGO_KEYCHAIN_HELPER_PATH` env override — **dev builds only**. The branch + is guarded by a build-time global (`__CAPGO_ALLOW_HELPER_ENV_OVERRIDE__`) + defined `false` in the npm release build, so the minifier removes the code + entirely: the published `dist/index.js` contains neither the branch nor the + string `CAPGO_KEYCHAIN_HELPER_PATH` (CI asserts this — see Testing). Dev + builds define it `true`. Rationale: an env-controlled executable path in + the release artifact is an arbitrary-binary-execution vector. 2. Precompiled package: `createRequire(import.meta.url).resolve('@capgo/cli-keychain-darwin-' + archSuffix + '/keychain-export')` where `archSuffix` maps `process.arch` `arm64`→`arm64`, `x64`→`x64`; any other arch skips this step. Wrapped in try/catch; verify the file exists - and is executable. On hit: use directly (no tmp copy, no compile); - `isHelperCached()` returns true so the UI skips the "compiling helper" step. + and is executable, **then verify its code signature** (below) before use. + On hit: use directly (no tmp copy, no compile); `isHelperCached()` returns + true so the UI skips the "compiling helper" step. 3. Cached tmp binary at `$TMPDIR/capgo-keychain-export-v{version}` (existing). 4. swiftc compile from bundled `.swift` source (existing; needs Xcode CLT). +### Signature verification of the precompiled binary + +Before executing a package-resolved binary (step 2), the CLI verifies it was +signed by the Capgo team using a `codesign` designated-requirement check: + +``` +codesign --verify --strict + -R '=anchor apple generic + and certificate leaf[field.1.2.840.113635.100.6.1.13] + and certificate leaf[subject.OU] = ""' + +``` + +This asserts, validated by macOS itself: (a) an Apple-rooted certificate +chain, (b) a Developer ID Application leaf certificate, and (c) Capgo's Apple +Team ID as the signing team. Because the code signature seals the binary's +contents, this also detects post-install tampering — a checksum pin is not +needed (and is impossible anyway: the CLI depends on a `^` range, so it cannot +know the exact binary hash). + +- The Team ID is baked into the CLI source as a constant + (`CAPGO_APPLE_TEAM_ID`), filled in during implementation from the Apple + Developer account. +- On verification failure (non-zero exit): log a warning naming the package + and path, skip the binary, and fall through to step 3/4 — local compilation + of the bundled, auditable source is the safe degradation. +- Cost: one `codesign` spawn (~tens of ms) per export invocation — negligible + against the Keychain prompts that follow. +- `helperPathOverride` (existing test-only option passed programmatically to + `exportP12FromKeychain`) bypasses the signature check; it is not reachable + from user input. + Build changes in `cli/build.mjs`: - Mark both helper packages as `external` in `Bun.build` so they resolve from `node_modules` at runtime instead of being bundled. +- Add `define: { __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__: 'false' }` to the + release build (and `'true'` under `NODE_ENV=development`) so the env + override is dead-code-eliminated from the npm artifact. - The `.swift` copy into `dist/` now sources from `../cli-helper/src/keychain-export.swift`. @@ -159,7 +203,9 @@ To do: portal (Certificates → + → Developer ID Application). Requires the **Account Holder** role (Apple policy for Developer ID certs). Export as `.p12` with a password; add GitHub secrets `DEVELOPER_ID_CERT_BASE64` and - `DEVELOPER_ID_CERT_PASSWORD`. + `DEVELOPER_ID_CERT_PASSWORD`. Record the team's **Apple Team ID** (the + `subject.OU` of the cert) — it becomes the `CAPGO_APPLE_TEAM_ID` constant + in the CLI source for runtime signature verification. 2. Verify the existing App Store Connect API key has **Developer role or higher** (required by `notarytool`); validate with a dry-run submission. 3. One full local sign + notarize cycle on a Mac before wiring CI, to validate @@ -167,9 +213,11 @@ To do: ## Error handling -- Each runtime resolution step falls through silently to the next. Only when - all steps fail does the user see an error — the existing swiftc/Xcode-CLT - message, extended to mention reinstalling with optional dependencies enabled. +- Each runtime resolution step falls through silently to the next — except a + **signature verification failure**, which falls through with a logged + warning (possible tampering is worth surfacing). Only when all steps fail + does the user see an error — the existing swiftc/Xcode-CLT message, + extended to mention reinstalling with optional dependencies enabled. - No partial publishes: both packages publish at the end of the workflow, after both binaries pass signing, notarization, and verification gates. - Notarization flakiness is contained to helper releases (rare), never blocks @@ -178,12 +226,21 @@ To do: ## Testing - **Unit** (extend `cli/test/test-macos-signing.mjs`; cross-platform, no real - Keychain): resolution order — env override wins; fake package dir resolves - before swiftc path; missing package falls through; non-executable file falls - through. + Keychain): resolution order — env override wins in dev builds; fake package + dir resolves before swiftc path; missing package falls through; + non-executable file falls through; signature-check failure (mocked codesign + exit ≠ 0) falls through with warning; signature-check pass executes the + package binary. +- **Release-artifact assertion** (in `publish_cli.yml` after the build step): + fail the CLI release if `dist/index.js` contains the string + `CAPGO_KEYCHAIN_HELPER_PATH` — proves the env-override branch was + dead-code-eliminated from the npm artifact. - **CI smoke** (in helper workflow): signed arm64 binary runs with invalid args → non-zero exit + `INVALID_ARGS` JSON envelope (proves the hardened- - runtime-signed binary executes). + runtime-signed binary executes). Additionally run the same + designated-requirement `codesign --verify -R` check the CLI will perform at + runtime, so a cert/team mismatch is caught at release time, not at user + runtime. - **Signature checks** (in workflow): `codesign --verify --strict`; authority check; notarization "Accepted" status. - **Manual acceptance** (once per first release): npm-install a release From b2bf2296c0b839e1c7ceccba42e86a94018c0881 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 6 Jun 2026 12:42:29 +0200 Subject: [PATCH 03/26] =?UTF-8?q?docs:=20remove=20swiftc=20fallback=20from?= =?UTF-8?q?=20helper=20spec=20=E2=80=94=20verified=20binary=20or=20hard=20?= =?UTF-8?q?error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-06-keychain-helper-precompile-design.md | 97 +++++++++++-------- 1 file changed, 56 insertions(+), 41 deletions(-) diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index 01233af8a8..f78f5d090a 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -12,6 +12,8 @@ with `swiftc` on first use. This: - requires Xcode Command Line Tools at runtime (hard failure without them), - adds a first-run compile delay and a dedicated "compiling helper" UI step, +- compiles and executes a binary from source at runtime — a large, security- + sensitive code path (`compileSwiftHelper`, tmp-dir caching, atomic renames), - produces a fresh ad-hoc-signed binary per CLI version, so macOS Keychain "Always Allow" ACL decisions do **not** persist across CLI upgrades (ACLs are tied to the calling binary's code signature). @@ -19,8 +21,9 @@ with `swiftc` on first use. This: ## Goal Ship precompiled, Developer-ID-signed, notarized helper binaries as separate -macOS-only npm packages, resolved at runtime by the CLI, with the existing -swiftc compile path retained as fallback. +macOS-only npm packages, resolved and signature-verified at runtime by the +CLI. The runtime swiftc compilation path is **removed entirely** — the CLI +either runs a verified Capgo-signed binary or fails with clear guidance. ## Decisions (settled during brainstorming) @@ -29,12 +32,12 @@ swiftc compile path retained as fallback. | Source location | New top-level `cli-helper/` dir in the capgo monorepo; **Swift source moves there** (`cli-helper/src/keychain-export.swift`) as single source of truth | | Package shape | Per-arch packages (esbuild style): `@capgo/cli-keychain-darwin-arm64`, `@capgo/cli-keychain-darwin-x64` | | Install mechanism | Both listed in `@capgo/cli` `optionalDependencies` with `^` range; `os`/`cpu` fields make npm/bun/pnpm install at most one | -| Fallback | Keep existing chain: cached tmp binary → swiftc compile from bundled source (still requires Xcode CLT) | +| Fallback | **None.** The runtime swiftc compile path and tmp-binary cache are deleted. Missing/unverifiable binary → hard error with install guidance | | Min macOS | x64 slice: macOS 10.15 (oldest macOS that runs Node 20, the CLI's floor); arm64 slice: macOS 11.0 | | Versioning | Independent semver, starting 1.0.0; release tag `cli-helper-X.Y.Z`; released **only when helper source changes**, not per CLI release | | Pipeline | Tag-triggered GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance | | Signing | Developer ID Application certificate; hardened runtime + secure timestamp; notarized via `notarytool` with existing App Store Connect API key secrets | -| Binary trust | CLI verifies the package-resolved binary's code signature (Developer ID + Capgo Team ID designated requirement) before executing it; failure falls through to swiftc with a warning | +| Binary trust | CLI verifies the package-resolved binary's code signature (Developer ID + Capgo Team ID designated requirement) before executing it; failure is a hard error | | Env override | `CAPGO_KEYCHAIN_HELPER_PATH` exists in dev builds only — stripped from npm release builds via build-time define + dead-code elimination | ## Architecture @@ -52,7 +55,7 @@ cli-helper/ │ ├── build.sh # swiftc per-arch builds │ ├── sign-and-notarize.sh # codesign + notarytool submit --wait │ └── prepare-publish.mjs # stamps tag version, copies binaries into npm/*/ -└── README.md +└── README.md # includes dev bootstrap instructions ``` ### Package manifests @@ -95,16 +98,18 @@ Runtime resolution order in `cli/src/build/onboarding/macos-signing.ts`: entirely: the published `dist/index.js` contains neither the branch nor the string `CAPGO_KEYCHAIN_HELPER_PATH` (CI asserts this — see Testing). Dev builds define it `true`. Rationale: an env-controlled executable path in - the release artifact is an arbitrary-binary-execution vector. + the release artifact is an arbitrary-binary-execution vector. This is also + the dev bootstrap path: before the npm packages exist (or when iterating on + the Swift source), developers compile locally with one documented `swiftc` + command and point the override at the result. 2. Precompiled package: `createRequire(import.meta.url).resolve('@capgo/cli-keychain-darwin-' + archSuffix + '/keychain-export')` - where `archSuffix` maps `process.arch` `arm64`→`arm64`, `x64`→`x64`; any - other arch skips this step. Wrapped in try/catch; verify the file exists - and is executable, **then verify its code signature** (below) before use. - On hit: use directly (no tmp copy, no compile); `isHelperCached()` returns - true so the UI skips the "compiling helper" step. -3. Cached tmp binary at `$TMPDIR/capgo-keychain-export-v{version}` (existing). -4. swiftc compile from bundled `.swift` source (existing; needs Xcode CLT). + where `archSuffix` maps `process.arch` `arm64`→`arm64`, `x64`→`x64`. + Wrapped in try/catch; verify the file exists and is executable, **then + verify its code signature** (below) before use. +3. Anything else — unsupported arch, package not installed, file missing, or + signature verification failure — is a **hard error** (see Error handling). + There is no compilation fallback. ### Signature verification of the precompiled binary @@ -129,15 +134,25 @@ know the exact binary hash). - The Team ID is baked into the CLI source as a constant (`CAPGO_APPLE_TEAM_ID`), filled in during implementation from the Apple Developer account. -- On verification failure (non-zero exit): log a warning naming the package - and path, skip the binary, and fall through to step 3/4 — local compilation - of the bundled, auditable source is the safe degradation. +- On verification failure (non-zero exit): hard error identifying the package, + path, and codesign output. A binary that fails verification is never + executed and there is nothing to fall back to — this is the desired + security posture (possible tampering must stop the flow, not degrade it). - Cost: one `codesign` spawn (~tens of ms) per export invocation — negligible against the Keychain prompts that follow. - `helperPathOverride` (existing test-only option passed programmatically to `exportP12FromKeychain`) bypasses the signature check; it is not reachable from user input. +### Code removed from the CLI + +- `compileSwiftHelper`, `ensureSwiftHelper`, `resolveSwiftSourcePath`, and the + tmp-dir binary cache (`compiledHelperPath`) in `macos-signing.ts`. +- `precompileSwiftHelper` and `isHelperCached` exports, and the + "compiling helper" step in the onboarding UI that calls them. +- The `.swift`-copy-into-`dist/` step in `cli/build.mjs` (source no longer + ships in the npm tarball). + Build changes in `cli/build.mjs`: - Mark both helper packages as `external` in `Bun.build` so they resolve from @@ -145,14 +160,6 @@ Build changes in `cli/build.mjs`: - Add `define: { __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__: 'false' }` to the release build (and `'true'` under `NODE_ENV=development`) so the env override is dead-code-eliminated from the npm artifact. -- The `.swift` copy into `dist/` now sources from - `../cli-helper/src/keychain-export.swift`. - -Path updates for the source move: - -- `resolveSwiftSourcePath()` dev-mode candidate points at - `cli-helper/src/keychain-export.swift`. -- Tests referencing the old path are updated. The helper's stdout contract (one line of JSON: `ok`, `p12Path`, `errorCode`, `osStatus`, …) is unchanged; `exportP12FromKeychain` parsing is @@ -213,11 +220,18 @@ To do: ## Error handling -- Each runtime resolution step falls through silently to the next — except a - **signature verification failure**, which falls through with a logged - warning (possible tampering is worth surfacing). Only when all steps fail - does the user see an error — the existing swiftc/Xcode-CLT message, - extended to mention reinstalling with optional dependencies enabled. +- Helper resolution failures are **hard errors** with specific, actionable + messages: + - Package missing (e.g. installed with `--no-optional`, or pnpm config + skipping optional deps): name the exact package for the user's arch and + instruct reinstalling with optional dependencies enabled (or + `npm i @capgo/cli-keychain-darwin-` directly). + - Unsupported arch (`process.arch` not `arm64`/`x64`): state that no helper + exists for this architecture (covers no real Mac today). + - Signature verification failure: report package, path, and codesign + output; instruct reinstalling. Never executes the binary. +- Release ordering: the CLI release that adds `optionalDependencies` and + removes the compile path ships **after** helper 1.0.0 is live on npm. - No partial publishes: both packages publish at the end of the workflow, after both binaries pass signing, notarization, and verification gates. - Notarization flakiness is contained to helper releases (rare), never blocks @@ -225,12 +239,12 @@ To do: ## Testing -- **Unit** (extend `cli/test/test-macos-signing.mjs`; cross-platform, no real +- **Unit** (rework `cli/test/test-macos-signing.mjs`; cross-platform, no real Keychain): resolution order — env override wins in dev builds; fake package - dir resolves before swiftc path; missing package falls through; - non-executable file falls through; signature-check failure (mocked codesign - exit ≠ 0) falls through with warning; signature-check pass executes the - package binary. + dir resolves; missing package → hard error naming the arch package; + non-executable file → hard error; signature-check failure (mocked codesign + exit ≠ 0) → hard error, binary never spawned; signature-check pass executes + the package binary. Tests covering the deleted compile path are removed. - **Release-artifact assertion** (in `publish_cli.yml` after the build step): fail the CLI release if `dist/index.js` contains the string `CAPGO_KEYCHAIN_HELPER_PATH` — proves the env-override branch was @@ -246,20 +260,21 @@ To do: - **Manual acceptance** (once per first release): npm-install a release candidate on a Mac, run the onboarding export flow, confirm no "compiling helper" step and successful P12 export; cover x64 via Intel Mac or Rosetta. -- **Fallback regression**: install with `--no-optional`, confirm the swiftc - path still engages. +- **Missing-package regression**: install with `--no-optional`, confirm the + export flow fails with the actionable install-guidance error (not a crash). ## Benefits recap -- No Xcode CLT requirement for the overwhelmingly common path. -- No first-run compile delay; "compiling helper" UI step disappears. +- No Xcode Command Line Tools requirement — at all. +- No first-run compile delay; "compiling helper" UI step deleted, along with + the entire runtime-compilation code path (less code, smaller attack surface). - Developer ID signature is stable across releases → Keychain "Always Allow" decisions persist across CLI upgrades (UX improvement over today). -- npm provenance + notarization give a verifiable supply chain for a binary - that reads users' keychains. +- The CLI only ever executes a binary whose Apple-validated signature chains + to Capgo's team — npm provenance + notarization + runtime requirement check + give a verifiable supply chain for a binary that reads users' keychains. ## Out of scope -- Removing the swiftc fallback (revisit in a future major). - Stapling (impossible for bare executables; not needed for npm distribution). - Windows/Linux variants (helper is macOS-only by nature). From 640abfcf99658e1f683bf894ead2b1ef3835f57e Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 6 Jun 2026 12:54:32 +0200 Subject: [PATCH 04/26] docs: implementation plan for precompiled keychain helper --- .../2026-06-06-keychain-helper-precompile.md | 1182 +++++++++++++++++ 1 file changed, 1182 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md new file mode 100644 index 0000000000..4012f78a1c --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -0,0 +1,1182 @@ +# Precompiled macOS Keychain Helper Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the keychain-export Swift helper as precompiled, Developer-ID-signed, notarized per-arch npm packages (`@capgo/cli-keychain-darwin-arm64` / `-x64`), verified at runtime by the CLI, with the runtime swiftc compilation path deleted. + +**Architecture:** A new `cli-helper/` monorepo dir owns the Swift source and two binary-only npm packages. A tag-triggered (`cli-helper-X.Y.Z`) GitHub Actions workflow on `macos-latest` builds both arch slices, codesigns with hardened runtime, notarizes via `notarytool`, and publishes with npm provenance. The CLI resolves the arch-matching package at runtime, verifies its code signature against Capgo's Apple Team ID via a `codesign` designated-requirement check, and hard-errors with install guidance when anything is missing — no compile fallback. A dev-only `CAPGO_KEYCHAIN_HELPER_PATH` env override is dead-code-eliminated from release builds via a `Bun.build` define. + +**Tech Stack:** Swift (Security framework), Bun build pipeline, Node `createRequire` resolution, GitHub Actions (macos-latest), `codesign`/`notarytool`, npm provenance. + +**Spec:** `docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md` + +**⚠️ Sequencing constraint:** Task 9 (adding `optionalDependencies` to `cli/package.json`) MUST NOT merge to main until helper 1.0.0 is live on npm (Task 12) — otherwise `bun install --frozen-lockfile` in every CI job fails resolving the not-yet-published packages. Tasks 1–8 and 10–11 are safe to merge any time (the workflow only fires on `cli-helper-*` tags). The CLI release tag (Task 13) comes last. + +**⚠️ User input needed during execution:** +- Task 5 / Task 10: Capgo's Apple **Team ID** (10-char, the `subject.OU` of the Developer ID cert). Likely `UVTJ336J2D` (appears in existing test fixtures as "digital shift oü (UVTJ336J2D)") — **confirm with the user before hardcoding**. +- Task 12: Apple Developer portal actions (cert creation) and GitHub secrets — user-performed, agent-guided. + +--- + +## File structure + +**Created:** + +| Path | Responsibility | +| --- | --- | +| `cli-helper/src/keychain-export.swift` | Swift source (git-moved from `cli/src/build/onboarding/`) | +| `cli-helper/npm/darwin-arm64/package.json` | arm64 npm package manifest | +| `cli-helper/npm/darwin-x64/package.json` | x64 npm package manifest | +| `cli-helper/scripts/build.sh` | swiftc per-arch builds → `cli-helper/dist/` | +| `cli-helper/scripts/sign-and-notarize.sh` | codesign + notarytool + verify, both binaries | +| `cli-helper/scripts/prepare-publish.mjs` | stamp tag version into manifests, copy binaries into packages | +| `cli-helper/README.md` | purpose, dev bootstrap, release runbook | +| `.github/workflows/publish_cli_helper.yml` | tag-triggered build/sign/notarize/publish | + +**Modified:** + +| Path | Change | +| --- | --- | +| `cli/src/build/onboarding/macos-signing.ts` | delete compile path; add `helperPackageName`, `helperSignatureRequirement`, `resolveHelperBinary`, `verifyHelperSignature` | +| `cli/src/build/onboarding/types.ts` | remove `'import-compiling-helper'` step (union L51, `STEP_PROGRESS` L257, `getPhaseLabel` case L341) | +| `cli/src/build/onboarding/ui/app.tsx` | remove compiling-helper effect (L1728-1744), `isHelperCached` branch (L4268), render line (L4282), imports (L50, L107) | +| `cli/src/build/onboarding/ui/steps/ios-import.tsx` | delete `ImportCompilingHelperStep` component + props (L412-~460) | +| `cli/build.mjs` | externals for helper packages; `__CAPGO_ALLOW_HELPER_ENV_OVERRIDE__` define; delete `.swift` copy (L408-415) | +| `cli/package.json` | add `optionalDependencies` (Task 9 — gated on helper publish) | +| `cli/test/test-macos-signing.mjs` | add resolution + signature-verification tests | +| `.github/workflows/publish_cli.yml` | assert env-override string absent from release bundle | + +All commands below run from the **repo root** unless stated otherwise. The CLI test runner is `bun` (`cd cli && bun test/test-macos-signing.mjs`); typecheck is `cd cli && bun run typecheck`; lint is `cd cli && bun run lint`. + +--- + +### Task 1: Create `cli-helper/` skeleton and move the Swift source + +**Files:** +- Move: `cli/src/build/onboarding/keychain-export.swift` → `cli-helper/src/keychain-export.swift` +- Create: `cli-helper/npm/darwin-arm64/package.json` +- Create: `cli-helper/npm/darwin-x64/package.json` +- Create: `cli-helper/README.md` + +- [ ] **Step 1: git-move the Swift source** + +```bash +mkdir -p cli-helper/src cli-helper/npm/darwin-arm64 cli-helper/npm/darwin-x64 cli-helper/scripts +git mv cli/src/build/onboarding/keychain-export.swift cli-helper/src/keychain-export.swift +``` + +Note: `cli/build.mjs:412-415` still references the old path — the CLI build is broken until Task 8. That's fine; tasks 2-8 don't run the CLI build, and Task 8 fixes it before anything needs it. + +- [ ] **Step 2: Write the arm64 package manifest** + +Create `cli-helper/npm/darwin-arm64/package.json`: + +```json +{ + "name": "@capgo/cli-keychain-darwin-arm64", + "version": "0.0.0", + "description": "Precompiled macOS (Apple Silicon) keychain-export 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": ["keychain-export"] +} +``` + +(`version` is `0.0.0` in-repo; `prepare-publish.mjs` stamps the real version from the release tag.) + +- [ ] **Step 3: Write the x64 package manifest** + +Create `cli-helper/npm/darwin-x64/package.json` — identical except: + +```json +{ + "name": "@capgo/cli-keychain-darwin-x64", + "version": "0.0.0", + "description": "Precompiled macOS (Intel) keychain-export 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": ["keychain-export"] +} +``` + +- [ ] **Step 4: Write `cli-helper/README.md`** + +```markdown +# Capgo CLI keychain-export helper + +Small Swift program (Security framework only) that exports a code-signing +identity from the macOS Keychain as a passphrase-wrapped PKCS#12. The Capgo +CLI's iOS onboarding invokes it; it always emits exactly one line of JSON on +stdout (`{"ok":true,...}` or `{"ok":false,"errorCode":...}`). + +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. + +## Dev bootstrap (working on the Swift source) + +The published CLI has no compile fallback. To test local Swift changes: + + swiftc cli-helper/src/keychain-export.swift -framework Security -O \ + -o /tmp/keychain-export-dev + cd cli && NODE_ENV=development bun run build + CAPGO_KEYCHAIN_HELPER_PATH=/tmp/keychain-export-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). + +## Release + +1. Bump nothing in-repo — versions are stamped from the tag. +2. `git tag cli-helper-X.Y.Z && git push origin cli-helper-X.Y.Z` +3. `.github/workflows/publish_cli_helper.yml` builds, signs, notarizes, + smoke-tests, and publishes both packages with npm provenance. +4. Release only when `src/keychain-export.swift` actually changed. + +Required GitHub secrets: `DEVELOPER_ID_CERT_BASE64`, `DEVELOPER_ID_CERT_PASSWORD` +(Developer ID Application cert as base64 .p12), plus existing `APPLE_KEY_ID`, +`APPLE_ISSUER_ID`, `APPLE_KEY_CONTENT` (App Store Connect API key, used by +notarytool) and `NPM_TOKEN`. +``` + +- [ ] **Step 5: Commit** + +```bash +git add cli-helper cli/src/build/onboarding +git commit -m "feat(cli-helper): scaffold precompiled keychain helper packages, move Swift source" +``` + +--- + +### Task 2: `cli-helper/scripts/build.sh` + local build verification + +**Files:** +- Create: `cli-helper/scripts/build.sh` + +- [ ] **Step 1: Write the build script** + +Create `cli-helper/scripts/build.sh`: + +```bash +#!/usr/bin/env bash +# Compile keychain-export 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/keychain-export.swift -framework Security -O \ + -target arm64-apple-macos11 -o dist/keychain-export-arm64 +swiftc src/keychain-export.swift -framework Security -O \ + -target x86_64-apple-macos10.15 -o dist/keychain-export-x64 +echo "Built:" +file dist/keychain-export-arm64 dist/keychain-export-x64 +``` + +```bash +chmod +x cli-helper/scripts/build.sh +``` + +- [ ] **Step 2: Run it locally (this machine is a Mac with Xcode CLT)** + +Run: `bash cli-helper/scripts/build.sh` +Expected output ends with: +``` +dist/keychain-export-arm64: Mach-O 64-bit executable arm64 +dist/keychain-export-x64: Mach-O 64-bit executable x86_64 +``` + +- [ ] **Step 3: Smoke-run the arm64 binary (invalid args → JSON error envelope)** + +Run: `./cli-helper/dist/keychain-export-arm64; echo "exit=$?"` +Expected: one line of JSON containing `"ok":false` and `"errorCode":"INVALID_ARGS"`, then a non-zero `exit=`. + +- [ ] **Step 4: Verify the deployment targets** + +Run: `otool -l cli-helper/dist/keychain-export-x64 | grep -A2 LC_BUILD_VERSION | grep minos` +Expected: `minos 10.15` +Run: `otool -l cli-helper/dist/keychain-export-arm64 | grep -A2 LC_BUILD_VERSION | grep minos` +Expected: `minos 11.0` + +- [ ] **Step 5: Add `dist/` to gitignore and commit** + +Append to the repo's `.gitignore` (check it doesn't already cover it): + +``` +cli-helper/dist/ +``` + +```bash +git add cli-helper/scripts/build.sh .gitignore +git commit -m "feat(cli-helper): per-arch swiftc build script" +``` + +--- + +### Task 3: `cli-helper/scripts/prepare-publish.mjs` + +**Files:** +- Create: `cli-helper/scripts/prepare-publish.mjs` + +- [ ] **Step 1: Write the script** + +```js +// 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 +// 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 — got "${version ?? ''}"`) + process.exit(1) +} + +for (const arch of ['arm64', 'x64']) { + const src = join(root, 'dist', `keychain-export-${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, 'keychain-export') + copyFileSync(src, dest) + chmodSync(dest, 0o755) + console.log(`Prepared ${manifest.name}@${version}`) +} +``` + +- [ ] **Step 2: Test it locally (binaries exist from Task 2)** + +Run: `node cli-helper/scripts/prepare-publish.mjs 1.0.0-test && node -e "console.log(JSON.parse(require('node:fs').readFileSync('cli-helper/npm/darwin-arm64/package.json','utf8')).version)" && ls -l cli-helper/npm/darwin-arm64/keychain-export` +Expected: `Prepared @capgo/cli-keychain-darwin-arm64@1.0.0-test`, `Prepared ...x64@1.0.0-test`, prints `1.0.0-test`, and the binary listed with `-rwxr-xr-x`. + +- [ ] **Step 3: Test the failure paths** + +Run: `node cli-helper/scripts/prepare-publish.mjs banana; echo "exit=$?"` +Expected: usage error, `exit=1`. + +- [ ] **Step 4: Restore manifests and remove copied binaries** + +```bash +git checkout cli-helper/npm/darwin-arm64/package.json cli-helper/npm/darwin-x64/package.json +rm -f cli-helper/npm/darwin-arm64/keychain-export cli-helper/npm/darwin-x64/keychain-export +``` + +Also append to `.gitignore` so a local run never commits binaries: + +``` +cli-helper/npm/*/keychain-export +``` + +- [ ] **Step 5: Commit** + +```bash +git add cli-helper/scripts/prepare-publish.mjs .gitignore +git commit -m "feat(cli-helper): version-stamp and package-prep script" +``` + +--- + +### Task 4: `macos-signing.ts` — package name mapping + requirement string (TDD) + +**Files:** +- Modify: `cli/src/build/onboarding/macos-signing.ts` +- Test: `cli/test/test-macos-signing.mjs` + +- [ ] **Step 1: Write failing tests** + +Append to `cli/test/test-macos-signing.mjs` (add `helperPackageName, helperSignatureRequirement` to the existing import block from `'../src/build/onboarding/macos-signing.ts'`): + +```js +// ─── helperPackageName ──────────────────────────────────────────────── + +t('helperPackageName maps arm64 and x64 to scoped packages', () => { + assert.equal(helperPackageName('arm64'), '@capgo/cli-keychain-darwin-arm64') + assert.equal(helperPackageName('x64'), '@capgo/cli-keychain-darwin-x64') +}) + +t('helperPackageName returns null for unsupported architectures', () => { + assert.equal(helperPackageName('ia32'), null) + assert.equal(helperPackageName('ppc64'), null) + assert.equal(helperPackageName(''), null) +}) + +// ─── helperSignatureRequirement ─────────────────────────────────────── + +t('helperSignatureRequirement pins Developer ID + team', () => { + const req = helperSignatureRequirement('ABCDE12345') + assert.ok(req.startsWith('=anchor apple generic')) + assert.ok(req.includes('certificate leaf[field.1.2.840.113635.100.6.1.13]')) + assert.ok(req.includes('certificate leaf[subject.OU] = "ABCDE12345"')) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd cli && bun test/test-macos-signing.mjs` +Expected: FAIL — `helperPackageName` is not exported. + +- [ ] **Step 3: Implement** + +In `cli/src/build/onboarding/macos-signing.ts`, after the `generateP12Passphrase` function (around line 262), add: + +```ts +// ─── Precompiled helper resolution ──────────────────────────────────── + +/** + * Apple Team ID the precompiled helper binaries are signed with. Used in the + * codesign designated-requirement check before executing a package-resolved + * binary. Must match the Developer ID Application cert used by + * .github/workflows/publish_cli_helper.yml. + */ +const CAPGO_APPLE_TEAM_ID = 'UVTJ336J2D' + +const HELPER_PACKAGE_PREFIX = '@capgo/cli-keychain-darwin-' + +/** + * Map a Node `process.arch` value to the matching helper package name, or + * null when no precompiled helper exists for that architecture. + */ +export function helperPackageName(arch: string): string | null { + if (arch === 'arm64' || arch === 'x64') + return `${HELPER_PACKAGE_PREFIX}${arch}` + return null +} + +/** + * codesign designated requirement asserting: Apple-rooted chain, a + * Developer ID Application leaf cert (OID 1.2.840.113635.100.6.1.13), and + * the given Apple Team ID as the signing team. + */ +export function helperSignatureRequirement(teamId: string = CAPGO_APPLE_TEAM_ID): string { + return `=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "${teamId}"` +} +``` + +**Before committing:** confirm the Team ID with the user (`UVTJ336J2D` per existing fixtures — but verify; it must equal the `subject.OU` of the Developer ID cert created in Task 12). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd cli && bun test/test-macos-signing.mjs` +Expected: PASS, including all pre-existing tests. + +- [ ] **Step 5: Commit** + +```bash +git add cli/src/build/onboarding/macos-signing.ts cli/test/test-macos-signing.mjs +git commit -m "feat(cli): helper package name mapping and codesign requirement" +``` + +--- + +### Task 5: `macos-signing.ts` — `resolveHelperBinary` with signature verification (TDD) + +**Files:** +- Modify: `cli/src/build/onboarding/macos-signing.ts` +- Test: `cli/test/test-macos-signing.mjs` + +- [ ] **Step 1: Write failing tests** + +Append to `cli/test/test-macos-signing.mjs` (add `resolveHelperBinary` to the import; `chmodSync` to the `node:fs` import line): + +```js +// ─── resolveHelperBinary ────────────────────────────────────────────── + +function makeFakeBinary() { + const dir = mkdtempSync(join(tmpdir(), 'capgo-helper-test-')) + const bin = join(dir, 'keychain-export') + writeFileSync(bin, '#!/bin/sh\nexit 0\n') + chmodSync(bin, 0o755) + return { dir, bin } +} + +const okCodesign = async () => ({ stdout: '', stderr: '', code: 0 }) +const failCodesign = async () => ({ stdout: '', stderr: 'test requirement failed', code: 3 }) + +await tAsync('resolveHelperBinary rejects unsupported architectures', async () => { + await assert.rejects( + resolveHelperBinary({ arch: 'ia32', resolve: () => { throw new Error('unreachable') } }), + /No precompiled Capgo keychain helper exists for .*ia32/, + ) +}) + +await tAsync('resolveHelperBinary names the missing package in its error', async () => { + await assert.rejects( + resolveHelperBinary({ arch: 'arm64', resolve: () => { throw new Error('not found') } }), + /@capgo\/cli-keychain-darwin-arm64.*not installed/s, + ) +}) + +await tAsync('resolveHelperBinary returns the binary when signature verifies', async () => { + const { dir, bin } = makeFakeBinary() + try { + const resolved = await resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: okCodesign, + }) + assert.equal(resolved, bin) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('resolveHelperBinary hard-errors when signature verification fails', async () => { + const { dir } = makeFakeBinary() + try { + await assert.rejects( + resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: failCodesign, + }), + /Refusing to run the keychain helper.*did not verify/s, + ) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('resolveHelperBinary errors when resolved binary file is missing', async () => { + const dir = mkdtempSync(join(tmpdir(), 'capgo-helper-test-')) + try { + // package.json resolvable, but no keychain-export next to it + await assert.rejects( + resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: okCodesign, + }), + /not installed|missing its binary/s, + ) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('env override wins when explicitly allowed (dev builds)', async () => { + const { dir, bin } = makeFakeBinary() + process.env.CAPGO_KEYCHAIN_HELPER_PATH = bin + try { + const resolved = await resolveHelperBinary({ + allowEnvOverride: true, + arch: 'arm64', + resolve: () => { throw new Error('should not be consulted') }, + codesignRunner: failCodesign, // override path skips signature check too + }) + assert.equal(resolved, bin) + } + finally { + delete process.env.CAPGO_KEYCHAIN_HELPER_PATH + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('env override is ignored by default (release semantics)', async () => { + const { dir, bin } = makeFakeBinary() + process.env.CAPGO_KEYCHAIN_HELPER_PATH = '/nonexistent/evil-binary' + try { + const resolved = await resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: okCodesign, + }) + assert.equal(resolved, bin) + } + finally { + delete process.env.CAPGO_KEYCHAIN_HELPER_PATH + rmSync(dir, { recursive: true, force: true }) + } +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd cli && bun test/test-macos-signing.mjs` +Expected: FAIL — `resolveHelperBinary` is not exported. + +- [ ] **Step 3: Implement** + +In `cli/src/build/onboarding/macos-signing.ts`: + +Add to the imports at the top (`accessSync`, `constants` join the existing `node:fs` import; `createRequire` is new): + +```ts +import { accessSync, constants, existsSync } from 'node:fs' +import { createRequire } from 'node:module' +``` + +After `helperSignatureRequirement`, add: + +```ts +/** + * Build-time flag controlling whether CAPGO_KEYCHAIN_HELPER_PATH is honored. + * cli/build.mjs `define`s this to `false` for npm release builds — the whole + * env-override branch (including the string literal) is dead-code-eliminated + * from dist/index.js, and CI asserts the string is absent. Dev builds + * (NODE_ENV=development) define it `true`. Running unbundled source (tests, + * `bun src/index.ts`) leaves it undefined → override disabled (fail closed). + */ +declare const __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__: boolean | undefined + +interface CodesignRunner { + (args: readonly string[]): Promise +} + +const defaultCodesignRunner: CodesignRunner = args => spawnCapture('/usr/bin/codesign', args) + +export interface ResolveHelperBinaryOptions { + /** Override `process.arch` (tests). */ + arch?: string + /** + * Override module resolution (tests). Receives the package's + * `package.json` specifier; must return its absolute path or throw. + */ + resolve?: (specifier: string) => string + /** Override the codesign spawn (tests). */ + codesignRunner?: CodesignRunner + /** Force the dev env-override gate (tests). Defaults to the build-time flag. */ + allowEnvOverride?: boolean +} + +/** + * Locate the precompiled keychain-export binary for this machine and verify + * its code signature chains to Capgo's Developer ID before returning it. + * + * Resolution order: + * 1. CAPGO_KEYCHAIN_HELPER_PATH (dev builds only — see the build-time flag) + * 2. The arch-matching @capgo/cli-keychain-darwin-* optional dependency + * 3. Hard error with install guidance. There is no compile fallback. + */ +export async function resolveHelperBinary(options: ResolveHelperBinaryOptions = {}): Promise { + const allowEnvOverride = options.allowEnvOverride + ?? (typeof __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__ !== 'undefined' && __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__) + + if (allowEnvOverride) { + const overridePath = process.env.CAPGO_KEYCHAIN_HELPER_PATH + if (overridePath) { + if (!existsSync(overridePath)) + throw new MacOSSigningError(`CAPGO_KEYCHAIN_HELPER_PATH points to a missing file: ${overridePath}`) + return overridePath + } + } + + const arch = options.arch ?? process.arch + const packageName = helperPackageName(arch) + if (!packageName) { + throw new MacOSSigningError( + `No precompiled Capgo keychain helper exists for ${process.platform}/${arch}. ` + + `Supported macOS architectures: arm64, x64.`, + ) + } + + const resolveSpecifier = options.resolve ?? createRequire(import.meta.url).resolve + let packageJsonPath: string + try { + packageJsonPath = resolveSpecifier(`${packageName}/package.json`) + } + catch { + throw new MacOSSigningError( + `The Capgo keychain helper package (${packageName}) is not installed. ` + + `It ships as an optional dependency of @capgo/cli — reinstall without ` + + `--no-optional / --omit=optional, or install it directly: npm i ${packageName}`, + ) + } + + const binaryPath = join(dirname(packageJsonPath), 'keychain-export') + try { + accessSync(binaryPath, constants.X_OK) + } + catch { + throw new MacOSSigningError( + `The keychain helper package (${packageName}) is installed but missing its binary ` + + `(or it is not executable) at ${binaryPath}. Reinstall ${packageName}.`, + ) + } + + await verifyHelperSignature(binaryPath, packageName, options.codesignRunner ?? defaultCodesignRunner) + return binaryPath +} + +/** + * Verify the binary's code signature against Capgo's designated requirement + * (Apple-rooted chain + Developer ID Application leaf + Capgo Team ID). + * macOS validates the certificate chain and the binary's seal, so this also + * detects post-install tampering. Throws — never executes the binary — on + * any failure. + */ +async function verifyHelperSignature( + binaryPath: string, + packageName: string, + runner: CodesignRunner, +): Promise { + const result = await runner(['--verify', '--strict', '-R', helperSignatureRequirement(), binaryPath]) + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() + throw new MacOSSigningError( + `Refusing to run the keychain helper at ${binaryPath}: its code signature ` + + `did not verify as Capgo's (codesign exit ${result.code}${detail ? `: ${detail}` : ''}). ` + + `Reinstall ${packageName} and try again.`, + ) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd cli && bun test/test-macos-signing.mjs` +Expected: PASS (all new + all pre-existing). + +- [ ] **Step 5: Typecheck and commit** + +Run: `cd cli && bun run typecheck` +Expected: clean. + +```bash +git add cli/src/build/onboarding/macos-signing.ts cli/test/test-macos-signing.mjs +git commit -m "feat(cli): resolve precompiled keychain helper with signature verification" +``` + +--- + +### Task 6: `macos-signing.ts` — wire `exportP12FromKeychain`, delete the compile path + +**Files:** +- Modify: `cli/src/build/onboarding/macos-signing.ts` + +- [ ] **Step 1: Rewire `exportP12FromKeychain`** + +At current line 515 (`const helperPath = options.helperPathOverride ?? await ensureSwiftHelper()`), change to: + +```ts + const helperPath = options.helperPathOverride ?? await resolveHelperBinary(options.resolveOptions) +``` + +Extend `ExportP12Options` (current lines 479-485): + +```ts +export interface ExportP12Options { + /** + * Pre-resolved Swift helper binary path. Used in tests to inject a fake + * binary; in production this is computed automatically. Bypasses the + * signature check — not reachable from user input. + */ + helperPathOverride?: string + /** Injection points for {@link resolveHelperBinary} (tests). */ + resolveOptions?: ResolveHelperBinaryOptions +} +``` + +Also update the function's doc comment: replace the paragraph "Internally calls the bundled Swift helper (compiled on first use to the OS temp folder via `swiftc`)." with "Internally runs the precompiled, signature-verified helper from the arch-matching `@capgo/cli-keychain-darwin-*` package." + +- [ ] **Step 2: Delete the compile path** + +Remove these (current line numbers, all in `macos-signing.ts`): +- `resolveSwiftSourcePath()` (lines ~283-310) and its doc comment +- `compiledHelperPath()` (lines ~312-326) and doc comment +- `compileSwiftHelper()` (lines ~328-354) and doc comment +- `isHelperCached()` (lines ~356-366) and doc comment +- `ensureSwiftHelper()` (lines ~368-383) and doc comment +- `precompileSwiftHelper()` (lines ~437-447) and doc comment +- The `// ─── Native helper (Swift) ...` section header comment stays (it still heads the helper-result interfaces). + +Then remove now-unused imports: `chmod`, `rename` (from `node:fs/promises`), `fileURLToPath` (from `node:url`) — verify each is truly unused before deleting (`rm`, `mkdtemp`, `readFile`, `readdir` are still used; `dirname` and `join` are still used). + +- [ ] **Step 3: Run tests + typecheck + lint** + +Run: `cd cli && bun test/test-macos-signing.mjs && bun run typecheck && bun run lint` +Expected: tests PASS; typecheck reports errors **only** in `ui/app.tsx` (imports of the deleted `isHelperCached`/`precompileSwiftHelper` — fixed in Task 7). If typecheck fails on anything else in `macos-signing.ts`, fix it now. + +- [ ] **Step 4: Commit** + +```bash +git add cli/src/build/onboarding/macos-signing.ts +git commit -m "feat(cli): remove runtime swiftc compilation of keychain helper" +``` + +(Committing with the known app.tsx typecheck break is acceptable here only because Task 7 immediately fixes it; if you prefer atomically green commits, squash Tasks 6+7 into one commit at the end of Task 7.) + +--- + +### Task 7: Remove the "compiling helper" UI step + +**Files:** +- Modify: `cli/src/build/onboarding/types.ts:51,257,341` +- Modify: `cli/src/build/onboarding/ui/app.tsx:50,107,1728-1744,4268,4282` +- Modify: `cli/src/build/onboarding/ui/steps/ios-import.tsx:412-~460` + +- [ ] **Step 1: types.ts — remove the step** + +- Line 51: delete `| 'import-compiling-helper'` from the `OnboardingStep` union. +- Line 257: delete `'import-compiling-helper': 72,` from `STEP_PROGRESS`. (Leave `'import-exporting': 75` as is.) +- Line 341: delete `case 'import-compiling-helper':` (the fall-through label inside `getPhaseLabel`; `case 'import-export-warning':` and `case 'import-exporting':` keep returning `'Step 4 of 4 · Export from Keychain'`). + +- [ ] **Step 2: app.tsx — remove usage** + +- Line 50: remove `isHelperCached` and `precompileSwiftHelper` from the `'../macos-signing.js'` import (keep `exportP12FromKeychain`, `bundleIdMatches`, etc.). +- Line 107: remove `ImportCompilingHelperStep,` from the steps import. +- Lines 1728-1744: delete the entire `if (step === 'import-compiling-helper') { ... }` effect block. +- Line 4268: change + +```tsx +setStep(isHelperCached() ? 'import-exporting' : 'import-compiling-helper') +``` + +to + +```tsx +setStep('import-exporting') +``` + +- Line 4282: delete `{step === 'import-compiling-helper' && }`. + +- [ ] **Step 3: ios-import.tsx — delete the component** + +Delete the comment block at lines 412-419, `ImportCompilingHelperStepProps` (420-422), and the whole `ImportCompilingHelperStep` component (424 through its closing `}` — runs to roughly line 460; read to the component's end before deleting). Then check whether `SpinnerLine` / `Newline` are still used elsewhere in the file before touching its imports. + +- [ ] **Step 4: Typecheck, lint, full signing tests** + +Run: `cd cli && bun run typecheck && bun run lint && bun test/test-macos-signing.mjs` +Expected: all clean. Typecheck exhaustiveness over `OnboardingStep` will surface any `'import-compiling-helper'` reference we missed — fix any stragglers. + +- [ ] **Step 5: Commit** + +```bash +git add cli/src/build/onboarding/types.ts cli/src/build/onboarding/ui/app.tsx cli/src/build/onboarding/ui/steps/ios-import.tsx +git commit -m "feat(cli): drop compiling-helper onboarding step" +``` + +--- + +### Task 8: `cli/build.mjs` — externals, define, drop the .swift copy + +**Files:** +- Modify: `cli/build.mjs:302-326` (buildCLI), `:329-347` (buildSDK), `:408-415` (swift copy) + +- [ ] **Step 1: Edit buildCLI and buildSDK** + +Near the top of `cli/build.mjs` (after the imports), add: + +```js +// Precompiled keychain helper packages resolve from node_modules at runtime +// (binary-only optional deps) — never bundle them. +const HELPER_PACKAGES = [ + '@capgo/cli-keychain-darwin-arm64', + '@capgo/cli-keychain-darwin-x64', +] +``` + +In the `buildCLI` options (current lines 302-326), add two entries: + +```js + external: HELPER_PACKAGES, + define: { + 'process.env.SUPA_DB': '"production"', + // Gates the CAPGO_KEYCHAIN_HELPER_PATH dev override. `false` here makes + // the minifier delete the whole branch from release bundles — + // publish_cli.yml asserts the string is absent from dist/index.js. + '__CAPGO_ALLOW_HELPER_ENV_OVERRIDE__': env.NODE_ENV === 'development' ? 'true' : 'false', + }, +``` + +(That is: extend the existing `define` block and add the new `external` key.) Apply the same `external: HELPER_PACKAGES` to `buildSDK` (lines 329-347) for safety; its `define` gets the same new entry. + +- [ ] **Step 2: Delete the .swift copy** + +Delete current lines 408-415 (the comment + `copyFileSync('src/build/onboarding/keychain-export.swift', 'dist/keychain-export.swift')` call — the source path no longer exists after Task 1). + +- [ ] **Step 3: Build and assert dead-code elimination** + +Run: `cd cli && bun run build && { ~/bin/zigrep -c "CAPGO_KEYCHAIN_HELPER_PATH" dist/index.js && echo "FAIL: leaked" && exit 1 || echo "OK: stripped"; }` +Expected: build succeeds; `OK: stripped`. + +Then the dev build keeps it: +Run: `cd cli && NODE_ENV=development bun run build && ~/bin/zigrep -c "CAPGO_KEYCHAIN_HELPER_PATH" dist/index.js` +Expected: count ≥ 1. + +Finally rebuild for release mode so no dev artifact lingers: `cd cli && bun run build` + +- [ ] **Step 4: Run the CLI test suite's bundle test** + +Run: `cd cli && bun run test:bundle` +Expected: PASS (catches bundling regressions from the external/define changes). + +- [ ] **Step 5: Commit** + +```bash +git add cli/build.mjs +git commit -m "feat(cli): externalize helper packages, strip dev env override from release builds" +``` + +--- + +### Task 9: ⚠️ GATED — add `optionalDependencies` to `cli/package.json` + +**Do NOT execute until helper 1.0.0 is published (after Task 12).** Adding unpublished packages breaks `bun install --frozen-lockfile` everywhere. + +**Files:** +- Modify: `cli/package.json` +- Modify: `bun.lock` (via `bun install`) + +- [ ] **Step 1: Add the block** (after `"dependencies"`, current line 133): + +```json + "optionalDependencies": { + "@capgo/cli-keychain-darwin-arm64": "^1.0.0", + "@capgo/cli-keychain-darwin-x64": "^1.0.0" + }, +``` + +- [ ] **Step 2: Refresh the lockfile** + +Run: `bun install` (repo root) +Expected: resolves both packages; on this arm64 Mac, `cli/node_modules/@capgo/cli-keychain-darwin-arm64/keychain-export` exists and is executable. + +- [ ] **Step 3: End-to-end resolution check on this machine** + +Run: `cd cli && node -e "import('./src/build/onboarding/macos-signing.ts').catch(()=>null)" 2>/dev/null; bun -e "const m = await import('./src/build/onboarding/macos-signing.ts'); const p = await m.resolveHelperBinary(); console.log(p); const r = Bun.spawnSync([p]); console.log(r.stdout.toString())"` +Expected: prints the node_modules binary path, then the helper's `INVALID_ARGS` JSON envelope (proving real codesign verification + execution of the published binary). + +- [ ] **Step 4: Commit** + +```bash +git add cli/package.json bun.lock +git commit -m "feat(cli): depend on precompiled keychain helper packages" +``` + +--- + +### Task 10: `cli-helper/scripts/sign-and-notarize.sh` + +**Files:** +- Create: `cli-helper/scripts/sign-and-notarize.sh` + +- [ ] **Step 1: Write the script** + +```bash +#!/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: ()" +# 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"'"' + +for arch in arm64 x64; do + bin="dist/keychain-export-$arch" + echo "── Signing $bin" + codesign --force --sign "$DEVELOPER_ID_IDENTITY" --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 + echo "Notarization failed for $bin (status: ${status:-unknown})" >&2 + if [ -n "$id" ]; then + 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." +``` + +```bash +chmod +x cli-helper/scripts/sign-and-notarize.sh +``` + +(`CAPGO_APPLE_TEAM_ID` here and the constant in `macos-signing.ts` must match — both set from the value confirmed in Task 4/12.) + +- [ ] **Step 2: Shellcheck it (if installed; skip otherwise)** + +Run: `command -v shellcheck >/dev/null && shellcheck cli-helper/scripts/sign-and-notarize.sh || echo "shellcheck not installed — skipped"` +Expected: no errors (or skipped). + +- [ ] **Step 3: Commit** + +```bash +git add cli-helper/scripts/sign-and-notarize.sh +git commit -m "feat(cli-helper): codesign + notarize script with runtime-requirement verification" +``` + +--- + +### Task 11: CI workflows + +**Files:** +- Create: `.github/workflows/publish_cli_helper.yml` +- Modify: `.github/workflows/publish_cli.yml` (insert one step after "Build CLI", line 37) + +- [ ] **Step 1: Write `publish_cli_helper.yml`** + +```yaml +name: Build and publish CLI keychain helper + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + tags: + - "cli-helper-[0-9]*" + +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 + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#cli-helper-}" >> "$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 + # Derive the codesign identity from the imported cert + 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/keychain-export-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: 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 GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + cli-helper/dist/keychain-export-arm64 + cli-helper/dist/keychain-export-x64 + make_latest: false + token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" +``` + +Note the new secret referenced: `APPLE_TEAM_ID` (set in Task 12 alongside the cert secrets). + +- [ ] **Step 2: Add the strip assertion to `publish_cli.yml`** + +Insert after the "Build CLI" step (after line 37): + +```yaml + - name: Assert dev-only env override is stripped from release bundle + run: | + 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 +``` + +- [ ] **Step 3: Validate workflow syntax** + +Run: `bunx --yes yaml-lint .github/workflows/publish_cli_helper.yml 2>/dev/null || node -e "const fs=require('fs');const yaml=require('js-yaml');yaml.load(fs.readFileSync('.github/workflows/publish_cli_helper.yml','utf8'));console.log('YAML OK')"` +Expected: `YAML OK` (or yaml-lint pass). If `js-yaml` is unavailable, `actionlint` via `brew install actionlint && actionlint .github/workflows/publish_cli_helper.yml` is the better check. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/publish_cli_helper.yml .github/workflows/publish_cli.yml +git commit -m "ci: keychain helper sign/notarize/publish workflow + release strip assertion" +``` + +--- + +### Task 12: Apple setup + local dry run (user-guided) + +No repo files change in this task (except possibly the Team ID constant if it differs from what Task 4 used). Everything here is performed by the user with agent guidance. + +- [ ] **Step 1: Create the Developer ID Application certificate** + +User actions (requires **Account Holder** role on the Apple Developer team): +1. https://developer.apple.com/account/resources/certificates → `+` → **Developer ID Application** → follow CSR instructions (Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority, saved to disk). +2. Download the `.cer`, double-click to install into the login keychain. +3. Keychain Access → My Certificates → right-click the "Developer ID Application: …" entry → Export as `.p12` with a strong password. + +- [ ] **Step 2: Record the Team ID and reconcile the constant** + +Run: `security find-identity -v -p codesigning | head -5` — the Developer ID line ends in `(TEAMID)`. +If it differs from the `CAPGO_APPLE_TEAM_ID` value committed in Task 4, update the constant in `cli/src/build/onboarding/macos-signing.ts` and commit (`fix(cli): correct Apple Team ID for helper verification`). + +- [ ] **Step 3: Set GitHub secrets** + +```bash +base64 -i DeveloperID.p12 | gh secret set DEVELOPER_ID_CERT_BASE64 --repo Cap-go/capgo +gh secret set DEVELOPER_ID_CERT_PASSWORD --repo Cap-go/capgo # paste the export password +gh secret set APPLE_TEAM_ID --repo Cap-go/capgo # the 10-char Team ID +``` + +(`APPLE_KEY_ID`, `APPLE_ISSUER_ID`, `APPLE_KEY_CONTENT`, `NPM_TOKEN`, `PERSONAL_ACCESS_TOKEN` already exist.) + +- [ ] **Step 4: Verify the App Store Connect API key can notarize** + +The key needs **Developer role or higher**. Local dry run (binaries from Task 2 exist): + +```bash +export DEVELOPER_ID_IDENTITY="$(security find-identity -v -p codesigning | awk -F'"' '/Developer ID Application/ {print $2; exit}')" +export CAPGO_APPLE_TEAM_ID= +export APPLE_KEY_ID= APPLE_ISSUER_ID= APPLE_KEY_PATH= +bash cli-helper/scripts/sign-and-notarize.sh +``` + +Expected: both binaries report `Notarization accepted` and both `codesign --verify` checks pass. This validates the cert, the key's notarization permission, and the exact command set before CI ever runs. + +--- + +### Task 13: Release sequencing + +- [ ] **Step 1: Merge everything except Task 9** (the `optionalDependencies` change stays unmerged/uncommitted until Step 3). + +- [ ] **Step 2: Tag and publish helper 1.0.0** + +```bash +git tag cli-helper-1.0.0 && git push origin cli-helper-1.0.0 +``` + +Watch the workflow: `gh run watch --repo Cap-go/capgo`. Then verify: + +```bash +npm view @capgo/cli-keychain-darwin-arm64@1.0.0 dist.tarball +npm view @capgo/cli-keychain-darwin-x64@1.0.0 dist.tarball +``` + +Expected: both resolve. Also confirm provenance badges on npmjs.com. + +- [ ] **Step 3: Execute Task 9** (optionalDependencies + lockfile), merge it. + +- [ ] **Step 4: Manual acceptance on this Mac** + +```bash +cd "$(mktemp -d)" && npm init -y >/dev/null && npm i @capgo/cli +node -e "const {execFileSync}=require('node:child_process');const p='node_modules/@capgo/cli-keychain-darwin-arm64/keychain-export';execFileSync('/usr/bin/codesign',['--verify','--strict','-R','=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = \"'+process.env.TEAM_ID+'\"',p]);console.log('signature OK')" TEAM_ID= +``` + +Then run the real onboarding export flow once (`npx @capgo/cli build init` → iOS → import existing) and confirm: no "compiling helper" step, successful P12 export, two Keychain prompts max. If an Intel Mac or Rosetta terminal is available, repeat there (x64 package). + +- [ ] **Step 5: Regression — `--no-optional` produces the guidance error** + +```bash +cd "$(mktemp -d)" && npm init -y >/dev/null && npm i @capgo/cli --omit=optional +``` + +Run the import flow; expected: hard error naming `@capgo/cli-keychain-darwin-arm64` with reinstall guidance (no crash, no swiftc mention). + +- [ ] **Step 6: Cut the CLI release** per the normal `cli-X.Y.Z` tag process. The release workflow's new strip assertion must pass. + +--- + +## Self-review notes + +- Spec coverage: package layout (T1), build targets (T2), version stamping (T3), name mapping + requirement (T4), resolution + verification + env override (T5), compile-path removal (T6), UI removal (T7), build defines/externals/copy removal (T8), optionalDependencies (T9), sign/notarize (T10), CI pipeline + strip assertion (T11), Apple runbook (T12), release ordering + manual acceptance + `--no-optional` regression (T13). All spec sections map to tasks. +- Type consistency: `SpawnResult` (existing, kept), `ResolveHelperBinaryOptions.resolve` receives `/package.json` and the binary is `join(dirname(...), 'keychain-export')` — consistent between Task 5 implementation and tests (tests return a `package.json` path inside the fixture dir). +- Known accepted wart: Task 6's commit leaves `app.tsx` typecheck-broken until Task 7 (called out inline with a squash alternative). From e36c1ebf2cc2bd546bc69c38744e573a3a762280 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 8 Jun 2026 07:53:38 +0200 Subject: [PATCH 05/26] docs: generic helper binary + subcommand, anti-footgun gate, SECURITY.md threat model --- .../2026-06-06-keychain-helper-precompile.md | 443 ++++++++++++------ ...06-06-keychain-helper-precompile-design.md | 95 +++- 2 files changed, 388 insertions(+), 150 deletions(-) diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md index 4012f78a1c..90abb01c82 100644 --- a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -2,18 +2,18 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Ship the keychain-export Swift helper as precompiled, Developer-ID-signed, notarized per-arch npm packages (`@capgo/cli-keychain-darwin-arm64` / `-x64`), verified at runtime by the CLI, with the runtime swiftc compilation path deleted. +**Goal:** Ship the keychain-export logic as a precompiled, Developer-ID-signed, notarized generic `helper` binary in per-arch npm packages (`@capgo/cli-keychain-darwin-arm64` / `-x64`), invoked as `helper keychain-export …`, verified at runtime by the CLI, with the runtime swiftc compilation path deleted. -**Architecture:** A new `cli-helper/` monorepo dir owns the Swift source and two binary-only npm packages. A tag-triggered (`cli-helper-X.Y.Z`) GitHub Actions workflow on `macos-latest` builds both arch slices, codesigns with hardened runtime, notarizes via `notarytool`, and publishes with npm provenance. The CLI resolves the arch-matching package at runtime, verifies its code signature against Capgo's Apple Team ID via a `codesign` designated-requirement check, and hard-errors with install guidance when anything is missing — no compile fallback. A dev-only `CAPGO_KEYCHAIN_HELPER_PATH` env override is dead-code-eliminated from release builds via a `Bun.build` define. +**Architecture:** A new `cli-helper/` monorepo dir owns the Swift source (`helper.swift`, a single binary with subcommand dispatch) and two binary-only npm packages. A tag-triggered (`cli-helper-X.Y.Z`) GitHub Actions workflow on `macos-latest` builds both arch slices, codesigns with hardened runtime, notarizes via `notarytool`, and publishes with npm provenance. The CLI resolves the arch-matching package at runtime, verifies its code signature against Capgo's Apple Team ID via a `codesign` designated-requirement check, and hard-errors with install guidance when anything is missing — no compile fallback. A dev-only `CAPGO_KEYCHAIN_HELPER_PATH` env override is dead-code-eliminated from release builds via a `Bun.build` define. The sensitive `keychain-export` subcommand carries an anti-footgun gate (internal handshake flag + non-TTY stdout) documented as a non-security-boundary in `cli-helper/SECURITY.md`. **Tech Stack:** Swift (Security framework), Bun build pipeline, Node `createRequire` resolution, GitHub Actions (macos-latest), `codesign`/`notarytool`, npm provenance. **Spec:** `docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md` -**⚠️ Sequencing constraint:** Task 9 (adding `optionalDependencies` to `cli/package.json`) MUST NOT merge to main until helper 1.0.0 is live on npm (Task 12) — otherwise `bun install --frozen-lockfile` in every CI job fails resolving the not-yet-published packages. Tasks 1–8 and 10–11 are safe to merge any time (the workflow only fires on `cli-helper-*` tags). The CLI release tag (Task 13) comes last. +**⚠️ Sequencing constraint:** Task 9 (adding `optionalDependencies` to `cli/package.json`) MUST NOT merge to main until helper 1.0.0 is live on npm (Task 13). Otherwise `bun install --frozen-lockfile` in every CI job fails resolving the not-yet-published packages. Tasks 1–8 and 10–11 are safe to merge any time (the workflow only fires on `cli-helper-*` tags). The CLI release tag (Task 13) comes last. **⚠️ User input needed during execution:** -- Task 5 / Task 10: Capgo's Apple **Team ID** (10-char, the `subject.OU` of the Developer ID cert). Likely `UVTJ336J2D` (appears in existing test fixtures as "digital shift oü (UVTJ336J2D)") — **confirm with the user before hardcoding**. +- Task 4 / Task 12: Capgo's Apple **Team ID** (10-char, the `subject.OU` of the Developer ID cert). Likely `UVTJ336J2D` (appears in existing test fixtures as "digital shift oü (UVTJ336J2D)") — **confirm with the user before hardcoding**. - Task 12: Apple Developer portal actions (cert creation) and GitHub secrets — user-performed, agent-guided. --- @@ -24,12 +24,13 @@ | Path | Responsibility | | --- | --- | -| `cli-helper/src/keychain-export.swift` | Swift source (git-moved from `cli/src/build/onboarding/`) | +| `cli-helper/src/helper.swift` | Swift source (git-moved+renamed from `cli/src/build/onboarding/keychain-export.swift`); one binary, subcommand dispatch, anti-footgun gate | | `cli-helper/npm/darwin-arm64/package.json` | arm64 npm package manifest | | `cli-helper/npm/darwin-x64/package.json` | x64 npm package manifest | | `cli-helper/scripts/build.sh` | swiftc per-arch builds → `cli-helper/dist/` | | `cli-helper/scripts/sign-and-notarize.sh` | codesign + notarytool + verify, both binaries | | `cli-helper/scripts/prepare-publish.mjs` | stamp tag version into manifests, copy binaries into packages | +| `cli-helper/SECURITY.md` | threat model — macOS Keychain ACL is the boundary; reporting expectation | | `cli-helper/README.md` | purpose, dev bootstrap, release runbook | | `.github/workflows/publish_cli_helper.yml` | tag-triggered build/sign/notarize/publish | @@ -37,7 +38,7 @@ | Path | Change | | --- | --- | -| `cli/src/build/onboarding/macos-signing.ts` | delete compile path; add `helperPackageName`, `helperSignatureRequirement`, `resolveHelperBinary`, `verifyHelperSignature` | +| `cli/src/build/onboarding/macos-signing.ts` | delete compile path; add `helperPackageName`, `helperSignatureRequirement`, `resolveHelperBinary`, `verifyHelperSignature`; add `FORBIDDEN_CALLER` to the result-code union; invoke with `keychain-export` subcommand + handshake | | `cli/src/build/onboarding/types.ts` | remove `'import-compiling-helper'` step (union L51, `STEP_PROGRESS` L257, `getPhaseLabel` case L341) | | `cli/src/build/onboarding/ui/app.tsx` | remove compiling-helper effect (L1728-1744), `isHelperCached` branch (L4268), render line (L4282), imports (L50, L107) | | `cli/src/build/onboarding/ui/steps/ios-import.tsx` | delete `ImportCompilingHelperStep` component + props (L412-~460) | @@ -46,36 +47,147 @@ | `cli/test/test-macos-signing.mjs` | add resolution + signature-verification tests | | `.github/workflows/publish_cli.yml` | assert env-override string absent from release bundle | -All commands below run from the **repo root** unless stated otherwise. The CLI test runner is `bun` (`cd cli && bun test/test-macos-signing.mjs`); typecheck is `cd cli && bun run typecheck`; lint is `cd cli && bun run lint`. +All commands below run from the **repo root** unless stated otherwise. The CLI test runner is `bun` (`cd cli && bun test/test-macos-signing.mjs`); typecheck is `cd cli && bun run typecheck`; lint is `cd cli && bun run lint`. (If `~/bin/zigrep` is on the executor's PATH, prefer it over `grep` per repo hooks; plain `grep` shown below is illustrative of intent.) --- -### Task 1: Create `cli-helper/` skeleton and move the Swift source +### Task 1: `cli-helper/` skeleton, move+rename Swift source, add subcommand+gate, SECURITY.md **Files:** -- Move: `cli/src/build/onboarding/keychain-export.swift` → `cli-helper/src/keychain-export.swift` -- Create: `cli-helper/npm/darwin-arm64/package.json` -- Create: `cli-helper/npm/darwin-x64/package.json` -- Create: `cli-helper/README.md` +- Move: `cli/src/build/onboarding/keychain-export.swift` → `cli-helper/src/helper.swift` +- Modify: `cli-helper/src/helper.swift` (subcommand dispatch + anti-footgun gate + `FORBIDDEN_CALLER`) +- Create: `cli-helper/npm/darwin-arm64/package.json`, `cli-helper/npm/darwin-x64/package.json` +- Create: `cli-helper/SECURITY.md`, `cli-helper/README.md` -- [ ] **Step 1: git-move the Swift source** +- [ ] **Step 1: git-move + rename the Swift source** ```bash mkdir -p cli-helper/src cli-helper/npm/darwin-arm64 cli-helper/npm/darwin-x64 cli-helper/scripts -git mv cli/src/build/onboarding/keychain-export.swift cli-helper/src/keychain-export.swift +git mv cli/src/build/onboarding/keychain-export.swift cli-helper/src/helper.swift ``` -Note: `cli/build.mjs:412-415` still references the old path — the CLI build is broken until Task 8. That's fine; tasks 2-8 don't run the CLI build, and Task 8 fixes it before anything needs it. +Note: `cli/build.mjs:412-415` still references the old path — the CLI build is broken until Task 8. Tasks 2-8 don't run the CLI build; Task 8 fixes it. -- [ ] **Step 2: Write the arm64 package manifest** +- [ ] **Step 2: Add `FORBIDDEN_CALLER` to the Swift error enum** -Create `cli-helper/npm/darwin-arm64/package.json`: +In `cli-helper/src/helper.swift`, in `enum KeychainExportError` (the `case` list), add: + +```swift + case forbiddenCaller(String) +``` + +In the `errorCode` switch add `case .forbiddenCaller: return "FORBIDDEN_CALLER"`; in `exitCode` add `case .forbiddenCaller: return 5`; in `message` add `.forbiddenCaller(m)` to the existing `let .invalidArgs(m), let .noIdentity(m), let .writeFailed(m)` group (so it returns `m`). `osStatus` already defaults to `nil` for it. + +- [ ] **Step 3: Add `--invoked-by` to `Args` and `parseArgs`** + +Change `struct Args` to add a field: + +```swift +struct Args { + var sha1Hex: String = "" + var outputPath: String = "" + var passphrase: String = "" + var invokedBy: String = "" +} +``` + +Change `parseArgs()` to take an explicit argument list (so `main` can pass the post-subcommand slice) and accept the handshake flag. Replace the signature line `func parseArgs() throws -> Args {` and the `let cli = CommandLine.arguments` / `var i = 1` lines with: + +```swift +func parseArgs(_ cli: [String]) throws -> Args { + var args = Args() + var i = 0 +``` + +In the `switch flag` block add a case (before `default`): + +```swift + case "--invoked-by": args.invokedBy = value +``` + +(The `--invoked-by` value is NOT required by `parseArgs` — the gate validates it, so a missing handshake yields `FORBIDDEN_CALLER`, not `INVALID_ARGS`.) + +- [ ] **Step 4: Add the anti-footgun gate function** + +Add before `// MARK: - Main` (uses `isatty`/`STDOUT_FILENO` from Foundation/Darwin, already imported via Foundation): + +```swift +// MARK: - Caller gate (anti-footgun; NOT a security boundary — see SECURITY.md) +// +// Stops casual / accidental / naive-script invocation of the sensitive +// export path. It does NOT stop a determined local attacker, who can read the +// handshake straight out of the open-source CLI (or call Apple's keychain APIs +// directly). The macOS Keychain ACL is the real boundary. +func enforceCallerGate(_ args: Args) throws { + guard args.invokedBy == "capgo-cli" else { + throw KeychainExportError.forbiddenCaller( + "Refusing to run: missing or invalid --invoked-by handshake." + ) + } + guard isatty(STDOUT_FILENO) == 0 else { + throw KeychainExportError.forbiddenCaller( + "Refusing to run with an interactive (TTY) stdout." + ) + } +} +``` + +- [ ] **Step 5: Rewrite `main` to dispatch on a subcommand** + +Replace the `// MARK: - Main` `do { … }` block (currently calls `parseArgs()` directly) with: + +```swift +// MARK: - Main + +do { + let argv = CommandLine.arguments + guard argv.count >= 2 else { + throw KeychainExportError.invalidArgs("Missing subcommand. Usage: helper …") + } + switch argv[1] { + case "keychain-export": + let args = try parseArgs(Array(argv.dropFirst(2))) + try enforceCallerGate(args) + let (identity, identityName) = try findIdentityBySha1(args.sha1Hex) + let p12 = try exportIdentityAsPkcs12(identity, passphrase: args.passphrase) + try writeP12(p12, to: args.outputPath) + emitSuccessAndExit(p12Path: args.outputPath, p12SizeBytes: p12.count, identityName: identityName) + default: + throw KeychainExportError.invalidArgs("Unknown subcommand: \(argv[1])") + } +} catch let error as KeychainExportError { + emitFailureAndExit(error) +} catch { + emitFailureAndExit( + code: 1, + errorCode: "INTERNAL", + message: "Unhandled error: \(error.localizedDescription)" + ) +} +``` + +Also update the file's top usage comment (lines ~8-11) to show `helper keychain-export --sha1 … --output … --passphrase … --invoked-by capgo-cli` and the build line (~42) to `swiftc helper.swift -framework Security -o helper`. + +- [ ] **Step 6: Compile-check the Swift edits locally (this machine has Xcode CLT)** + +Run: `swiftc cli-helper/src/helper.swift -framework Security -O -o /tmp/helper-check && echo BUILD_OK` +Expected: `BUILD_OK`. + +Run (gate rejects missing handshake): `/tmp/helper-check keychain-export --sha1 $(printf 'a%.0s' {1..40}) --output /tmp/x.p12 --passphrase p | cat; echo "exit=${PIPESTATUS[0]}"` +Expected: one-line JSON with `"ok":false` and `"errorCode":"FORBIDDEN_CALLER"`, `exit=5`. (Piped through `cat` so stdout is not a TTY — proving the handshake, not the TTY check, is what fires.) + +Run (no subcommand → INVALID_ARGS): `/tmp/helper-check | cat; echo "exit=${PIPESTATUS[0]}"` +Expected: `"ok":false`, `"errorCode":"INVALID_ARGS"`, `exit=2`. + +- [ ] **Step 7: Write the two package manifests** + +`cli-helper/npm/darwin-arm64/package.json`: ```json { "name": "@capgo/cli-keychain-darwin-arm64", "version": "0.0.0", - "description": "Precompiled macOS (Apple Silicon) keychain-export helper for @capgo/cli", + "description": "Precompiled macOS (Apple Silicon) keychain helper for @capgo/cli", "repository": { "type": "git", "url": "git+https://github.com/Cap-go/capgo.git", @@ -84,63 +196,97 @@ Create `cli-helper/npm/darwin-arm64/package.json`: "license": "Apache 2.0", "os": ["darwin"], "cpu": ["arm64"], - "files": ["keychain-export"] + "files": ["helper"] } ``` +`cli-helper/npm/darwin-x64/package.json` — identical except `"name": "@capgo/cli-keychain-darwin-x64"`, `"description": "Precompiled macOS (Intel) keychain helper for @capgo/cli"`, `"cpu": ["x64"]`. + (`version` is `0.0.0` in-repo; `prepare-publish.mjs` stamps the real version from the release tag.) -- [ ] **Step 3: Write the x64 package manifest** +- [ ] **Step 8: Write `cli-helper/SECURITY.md`** -Create `cli-helper/npm/darwin-x64/package.json` — identical except: +```markdown +# Security model — Capgo CLI keychain helper -```json -{ - "name": "@capgo/cli-keychain-darwin-x64", - "version": "0.0.0", - "description": "Precompiled macOS (Intel) keychain-export 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": ["keychain-export"] -} +## 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. ``` -- [ ] **Step 4: Write `cli-helper/README.md`** +- [ ] **Step 9: Write `cli-helper/README.md`** ```markdown -# Capgo CLI keychain-export helper +# Capgo CLI keychain helper + +Small Swift program (Security framework only) shipped as one generic binary +named `helper`. Today it has a single subcommand: -Small Swift program (Security framework only) that exports a code-signing -identity from the macOS Keychain as a passphrase-wrapped PKCS#12. The Capgo -CLI's iOS onboarding invokes it; it always emits exactly one line of JSON on -stdout (`{"ok":true,...}` or `{"ok":false,"errorCode":...}`). + helper keychain-export --sha1 <40-hex> --output \ + --passphrase --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. +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/keychain-export.swift -framework Security -O \ - -o /tmp/keychain-export-dev + 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/keychain-export-dev node dist/index.js ... + 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). +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 @@ -148,19 +294,19 @@ eliminated from npm release builds (asserted in CI). 2. `git tag cli-helper-X.Y.Z && git push origin cli-helper-X.Y.Z` 3. `.github/workflows/publish_cli_helper.yml` builds, signs, notarizes, smoke-tests, and publishes both packages with npm provenance. -4. Release only when `src/keychain-export.swift` actually changed. +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), plus existing `APPLE_KEY_ID`, -`APPLE_ISSUER_ID`, `APPLE_KEY_CONTENT` (App Store Connect API key, used by -notarytool) and `NPM_TOKEN`. +(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`. ``` -- [ ] **Step 5: Commit** +- [ ] **Step 10: Commit** ```bash git add cli-helper cli/src/build/onboarding -git commit -m "feat(cli-helper): scaffold precompiled keychain helper packages, move Swift source" +git commit -m "feat(cli-helper): scaffold helper packages, move+rename Swift source, add subcommand+gate" ``` --- @@ -172,47 +318,45 @@ git commit -m "feat(cli-helper): scaffold precompiled keychain helper packages, - [ ] **Step 1: Write the build script** -Create `cli-helper/scripts/build.sh`: - ```bash #!/usr/bin/env bash -# Compile keychain-export for both macOS architectures into cli-helper/dist/. +# 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/keychain-export.swift -framework Security -O \ - -target arm64-apple-macos11 -o dist/keychain-export-arm64 -swiftc src/keychain-export.swift -framework Security -O \ - -target x86_64-apple-macos10.15 -o dist/keychain-export-x64 +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/keychain-export-arm64 dist/keychain-export-x64 +file dist/helper-arm64 dist/helper-x64 ``` ```bash chmod +x cli-helper/scripts/build.sh ``` -- [ ] **Step 2: Run it locally (this machine is a Mac with Xcode CLT)** +- [ ] **Step 2: Run it locally** Run: `bash cli-helper/scripts/build.sh` Expected output ends with: ``` -dist/keychain-export-arm64: Mach-O 64-bit executable arm64 -dist/keychain-export-x64: Mach-O 64-bit executable x86_64 +dist/helper-arm64: Mach-O 64-bit executable arm64 +dist/helper-x64: Mach-O 64-bit executable x86_64 ``` -- [ ] **Step 3: Smoke-run the arm64 binary (invalid args → JSON error envelope)** +- [ ] **Step 3: Smoke-run the arm64 binary (no subcommand → JSON INVALID_ARGS)** -Run: `./cli-helper/dist/keychain-export-arm64; echo "exit=$?"` -Expected: one line of JSON containing `"ok":false` and `"errorCode":"INVALID_ARGS"`, then a non-zero `exit=`. +Run: `./cli-helper/dist/helper-arm64 | cat; echo "exit=${PIPESTATUS[0]}"` +Expected: one line of JSON containing `"ok":false` and `"errorCode":"INVALID_ARGS"`, then `exit=2`. - [ ] **Step 4: Verify the deployment targets** -Run: `otool -l cli-helper/dist/keychain-export-x64 | grep -A2 LC_BUILD_VERSION | grep minos` +Run: `otool -l cli-helper/dist/helper-x64 | grep -A2 LC_BUILD_VERSION | grep minos` Expected: `minos 10.15` -Run: `otool -l cli-helper/dist/keychain-export-arm64 | grep -A2 LC_BUILD_VERSION | grep minos` +Run: `otool -l cli-helper/dist/helper-arm64 | grep -A2 LC_BUILD_VERSION | grep minos` Expected: `minos 11.0` - [ ] **Step 5: Add `dist/` to gitignore and commit** @@ -256,7 +400,7 @@ if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { } for (const arch of ['arm64', 'x64']) { - const src = join(root, 'dist', `keychain-export-${arch}`) + 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) @@ -266,7 +410,7 @@ for (const arch of ['arm64', 'x64']) { const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) const updated = { ...manifest, version } writeFileSync(manifestPath, `${JSON.stringify(updated, null, 2)}\n`) - const dest = join(pkgDir, 'keychain-export') + const dest = join(pkgDir, 'helper') copyFileSync(src, dest) chmodSync(dest, 0o755) console.log(`Prepared ${manifest.name}@${version}`) @@ -275,10 +419,10 @@ for (const arch of ['arm64', 'x64']) { - [ ] **Step 2: Test it locally (binaries exist from Task 2)** -Run: `node cli-helper/scripts/prepare-publish.mjs 1.0.0-test && node -e "console.log(JSON.parse(require('node:fs').readFileSync('cli-helper/npm/darwin-arm64/package.json','utf8')).version)" && ls -l cli-helper/npm/darwin-arm64/keychain-export` +Run: `node cli-helper/scripts/prepare-publish.mjs 1.0.0-test && node -e "console.log(JSON.parse(require('node:fs').readFileSync('cli-helper/npm/darwin-arm64/package.json','utf8')).version)" && ls -l cli-helper/npm/darwin-arm64/helper` Expected: `Prepared @capgo/cli-keychain-darwin-arm64@1.0.0-test`, `Prepared ...x64@1.0.0-test`, prints `1.0.0-test`, and the binary listed with `-rwxr-xr-x`. -- [ ] **Step 3: Test the failure paths** +- [ ] **Step 3: Test the failure path** Run: `node cli-helper/scripts/prepare-publish.mjs banana; echo "exit=$?"` Expected: usage error, `exit=1`. @@ -287,13 +431,13 @@ Expected: usage error, `exit=1`. ```bash git checkout cli-helper/npm/darwin-arm64/package.json cli-helper/npm/darwin-x64/package.json -rm -f cli-helper/npm/darwin-arm64/keychain-export cli-helper/npm/darwin-x64/keychain-export +rm -f cli-helper/npm/darwin-arm64/helper cli-helper/npm/darwin-x64/helper ``` -Also append to `.gitignore` so a local run never commits binaries: +Append to `.gitignore` so a local run never commits binaries: ``` -cli-helper/npm/*/keychain-export +cli-helper/npm/*/helper ``` - [ ] **Step 5: Commit** @@ -305,7 +449,7 @@ git commit -m "feat(cli-helper): version-stamp and package-prep script" --- -### Task 4: `macos-signing.ts` — package name mapping + requirement string (TDD) +### Task 4: `macos-signing.ts` — package name mapping + requirement string + result code (TDD) **Files:** - Modify: `cli/src/build/onboarding/macos-signing.ts` @@ -346,7 +490,7 @@ Expected: FAIL — `helperPackageName` is not exported. - [ ] **Step 3: Implement** -In `cli/src/build/onboarding/macos-signing.ts`, after the `generateP12Passphrase` function (around line 262), add: +In `cli/src/build/onboarding/macos-signing.ts`, after `generateP12Passphrase` (around line 262), add: ```ts // ─── Precompiled helper resolution ──────────────────────────────────── @@ -381,6 +525,9 @@ export function helperSignatureRequirement(teamId: string = CAPGO_APPLE_TEAM_ID) } ``` +Also extend the `SwiftHelperResult.errorCode` union (the existing interface near the helper-result types) to include the new code: change it to +`'INVALID_ARGS' | 'NO_IDENTITY' | 'USER_DENIED' | 'EXPORT_FAILED' | 'WRITE_FAILED' | 'FORBIDDEN_CALLER' | 'INTERNAL'`. + **Before committing:** confirm the Team ID with the user (`UVTJ336J2D` per existing fixtures — but verify; it must equal the `subject.OU` of the Developer ID cert created in Task 12). - [ ] **Step 4: Run tests to verify they pass** @@ -392,7 +539,7 @@ Expected: PASS, including all pre-existing tests. ```bash git add cli/src/build/onboarding/macos-signing.ts cli/test/test-macos-signing.mjs -git commit -m "feat(cli): helper package name mapping and codesign requirement" +git commit -m "feat(cli): helper package name mapping, codesign requirement, FORBIDDEN_CALLER code" ``` --- @@ -410,9 +557,9 @@ Append to `cli/test/test-macos-signing.mjs` (add `resolveHelperBinary` to the im ```js // ─── resolveHelperBinary ────────────────────────────────────────────── -function makeFakeBinary() { +function makeFakeHelper() { const dir = mkdtempSync(join(tmpdir(), 'capgo-helper-test-')) - const bin = join(dir, 'keychain-export') + const bin = join(dir, 'helper') writeFileSync(bin, '#!/bin/sh\nexit 0\n') chmodSync(bin, 0o755) return { dir, bin } @@ -436,7 +583,7 @@ await tAsync('resolveHelperBinary names the missing package in its error', async }) await tAsync('resolveHelperBinary returns the binary when signature verifies', async () => { - const { dir, bin } = makeFakeBinary() + const { dir, bin } = makeFakeHelper() try { const resolved = await resolveHelperBinary({ arch: 'arm64', @@ -451,7 +598,7 @@ await tAsync('resolveHelperBinary returns the binary when signature verifies', a }) await tAsync('resolveHelperBinary hard-errors when signature verification fails', async () => { - const { dir } = makeFakeBinary() + const { dir } = makeFakeHelper() try { await assert.rejects( resolveHelperBinary({ @@ -470,7 +617,6 @@ await tAsync('resolveHelperBinary hard-errors when signature verification fails' await tAsync('resolveHelperBinary errors when resolved binary file is missing', async () => { const dir = mkdtempSync(join(tmpdir(), 'capgo-helper-test-')) try { - // package.json resolvable, but no keychain-export next to it await assert.rejects( resolveHelperBinary({ arch: 'arm64', @@ -486,7 +632,7 @@ await tAsync('resolveHelperBinary errors when resolved binary file is missing', }) await tAsync('env override wins when explicitly allowed (dev builds)', async () => { - const { dir, bin } = makeFakeBinary() + const { dir, bin } = makeFakeHelper() process.env.CAPGO_KEYCHAIN_HELPER_PATH = bin try { const resolved = await resolveHelperBinary({ @@ -504,7 +650,7 @@ await tAsync('env override wins when explicitly allowed (dev builds)', async () }) await tAsync('env override is ignored by default (release semantics)', async () => { - const { dir, bin } = makeFakeBinary() + const { dir, bin } = makeFakeHelper() process.env.CAPGO_KEYCHAIN_HELPER_PATH = '/nonexistent/evil-binary' try { const resolved = await resolveHelperBinary({ @@ -571,8 +717,8 @@ export interface ResolveHelperBinaryOptions { } /** - * Locate the precompiled keychain-export binary for this machine and verify - * its code signature chains to Capgo's Developer ID before returning it. + * Locate the precompiled `helper` binary for this machine and verify its code + * signature chains to Capgo's Developer ID before returning it. * * Resolution order: * 1. CAPGO_KEYCHAIN_HELPER_PATH (dev builds only — see the build-time flag) @@ -614,7 +760,7 @@ export async function resolveHelperBinary(options: ResolveHelperBinaryOptions = ) } - const binaryPath = join(dirname(packageJsonPath), 'keychain-export') + const binaryPath = join(dirname(packageJsonPath), 'helper') try { accessSync(binaryPath, constants.X_OK) } @@ -677,20 +823,36 @@ git commit -m "feat(cli): resolve precompiled keychain helper with signature ver - [ ] **Step 1: Rewire `exportP12FromKeychain`** -At current line 515 (`const helperPath = options.helperPathOverride ?? await ensureSwiftHelper()`), change to: +At the line `const helperPath = options.helperPathOverride ?? await ensureSwiftHelper()`, change to: ```ts const helperPath = options.helperPathOverride ?? await resolveHelperBinary(options.resolveOptions) ``` -Extend `ExportP12Options` (current lines 479-485): +Change the spawn call to use the subcommand + handshake. Replace the `spawnCapture(helperPath, [ '--sha1', sha1, '--output', p12Path, '--passphrase', passphrase ])` argument array with: + +```ts + const result = await spawnCapture(helperPath, [ + 'keychain-export', + '--sha1', + sha1, + '--output', + p12Path, + '--passphrase', + passphrase, + '--invoked-by', + 'capgo-cli', + ]) +``` + +Extend `ExportP12Options`: ```ts export interface ExportP12Options { /** - * Pre-resolved Swift helper binary path. Used in tests to inject a fake - * binary; in production this is computed automatically. Bypasses the - * signature check — not reachable from user input. + * Pre-resolved helper binary path. Used in tests to inject a fake binary; + * in production this is computed automatically. Bypasses the signature + * check — not reachable from user input. */ helperPathOverride?: string /** Injection points for {@link resolveHelperBinary} (tests). */ @@ -698,20 +860,19 @@ export interface ExportP12Options { } ``` -Also update the function's doc comment: replace the paragraph "Internally calls the bundled Swift helper (compiled on first use to the OS temp folder via `swiftc`)." with "Internally runs the precompiled, signature-verified helper from the arch-matching `@capgo/cli-keychain-darwin-*` package." +Update the function's doc comment: replace the paragraph beginning "Internally calls the bundled Swift helper (compiled on first use…)" with "Internally runs the precompiled, signature-verified `helper keychain-export` subcommand from the arch-matching `@capgo/cli-keychain-darwin-*` package." - [ ] **Step 2: Delete the compile path** -Remove these (current line numbers, all in `macos-signing.ts`): -- `resolveSwiftSourcePath()` (lines ~283-310) and its doc comment -- `compiledHelperPath()` (lines ~312-326) and doc comment -- `compileSwiftHelper()` (lines ~328-354) and doc comment -- `isHelperCached()` (lines ~356-366) and doc comment -- `ensureSwiftHelper()` (lines ~368-383) and doc comment -- `precompileSwiftHelper()` (lines ~437-447) and doc comment -- The `// ─── Native helper (Swift) ...` section header comment stays (it still heads the helper-result interfaces). +Remove these from `macos-signing.ts`: +- `resolveSwiftSourcePath()` + doc comment +- `compiledHelperPath()` + doc comment +- `compileSwiftHelper()` + doc comment +- `isHelperCached()` + doc comment +- `ensureSwiftHelper()` + doc comment +- `precompileSwiftHelper()` + doc comment -Then remove now-unused imports: `chmod`, `rename` (from `node:fs/promises`), `fileURLToPath` (from `node:url`) — verify each is truly unused before deleting (`rm`, `mkdtemp`, `readFile`, `readdir` are still used; `dirname` and `join` are still used). +Then remove now-unused imports: `chmod`, `rename` (from `node:fs/promises`), `fileURLToPath` (from `node:url`) — verify each is truly unused before deleting (`rm`, `mkdtemp`, `readFile`, `readdir` are still used; `dirname`, `join` are still used; `existsSync` is now used by `resolveHelperBinary`). - [ ] **Step 3: Run tests + typecheck + lint** @@ -725,7 +886,7 @@ git add cli/src/build/onboarding/macos-signing.ts git commit -m "feat(cli): remove runtime swiftc compilation of keychain helper" ``` -(Committing with the known app.tsx typecheck break is acceptable here only because Task 7 immediately fixes it; if you prefer atomically green commits, squash Tasks 6+7 into one commit at the end of Task 7.) +(Committing with the known app.tsx typecheck break is acceptable only because Task 7 immediately fixes it; if you prefer atomically green commits, squash Tasks 6+7.) --- @@ -763,12 +924,12 @@ setStep('import-exporting') - [ ] **Step 3: ios-import.tsx — delete the component** -Delete the comment block at lines 412-419, `ImportCompilingHelperStepProps` (420-422), and the whole `ImportCompilingHelperStep` component (424 through its closing `}` — runs to roughly line 460; read to the component's end before deleting). Then check whether `SpinnerLine` / `Newline` are still used elsewhere in the file before touching its imports. +Delete the comment block (lines ~412-419), `ImportCompilingHelperStepProps` (~420-422), and the whole `ImportCompilingHelperStep` component (~424 through its closing `}`, ~line 460 — read to the component's end before deleting). Then check whether `SpinnerLine` / `Newline` are still used elsewhere in the file before touching its imports. - [ ] **Step 4: Typecheck, lint, full signing tests** Run: `cd cli && bun run typecheck && bun run lint && bun test/test-macos-signing.mjs` -Expected: all clean. Typecheck exhaustiveness over `OnboardingStep` will surface any `'import-compiling-helper'` reference we missed — fix any stragglers. +Expected: all clean. Typecheck exhaustiveness over `OnboardingStep` surfaces any `'import-compiling-helper'` reference we missed — fix stragglers. - [ ] **Step 5: Commit** @@ -797,7 +958,7 @@ const HELPER_PACKAGES = [ ] ``` -In the `buildCLI` options (current lines 302-326), add two entries: +In the `buildCLI` options (lines 302-326), add `external` and extend `define`: ```js external: HELPER_PACKAGES, @@ -810,24 +971,24 @@ In the `buildCLI` options (current lines 302-326), add two entries: }, ``` -(That is: extend the existing `define` block and add the new `external` key.) Apply the same `external: HELPER_PACKAGES` to `buildSDK` (lines 329-347) for safety; its `define` gets the same new entry. +Apply the same `external: HELPER_PACKAGES` to `buildSDK` (lines 329-347) for safety; its `define` gets the same new entry. - [ ] **Step 2: Delete the .swift copy** -Delete current lines 408-415 (the comment + `copyFileSync('src/build/onboarding/keychain-export.swift', 'dist/keychain-export.swift')` call — the source path no longer exists after Task 1). +Delete lines 408-415 (the comment + `copyFileSync('src/build/onboarding/keychain-export.swift', 'dist/keychain-export.swift')` call — the source path no longer exists after Task 1). - [ ] **Step 3: Build and assert dead-code elimination** -Run: `cd cli && bun run build && { ~/bin/zigrep -c "CAPGO_KEYCHAIN_HELPER_PATH" dist/index.js && echo "FAIL: leaked" && exit 1 || echo "OK: stripped"; }` -Expected: build succeeds; `OK: stripped`. +Run: `cd cli && bun run build && { grep -c "CAPGO_KEYCHAIN_HELPER_PATH" dist/index.js && echo "FAIL: leaked" && exit 1 || echo "OK: stripped"; }` +Expected: build succeeds; `OK: stripped`. (If `~/bin/zigrep` is available, substitute it for `grep`.) Then the dev build keeps it: -Run: `cd cli && NODE_ENV=development bun run build && ~/bin/zigrep -c "CAPGO_KEYCHAIN_HELPER_PATH" dist/index.js` +Run: `cd cli && NODE_ENV=development bun run build && grep -c "CAPGO_KEYCHAIN_HELPER_PATH" dist/index.js` Expected: count ≥ 1. Finally rebuild for release mode so no dev artifact lingers: `cd cli && bun run build` -- [ ] **Step 4: Run the CLI test suite's bundle test** +- [ ] **Step 4: Run the CLI bundle test** Run: `cd cli && bun run test:bundle` Expected: PASS (catches bundling regressions from the external/define changes). @@ -843,13 +1004,13 @@ git commit -m "feat(cli): externalize helper packages, strip dev env override fr ### Task 9: ⚠️ GATED — add `optionalDependencies` to `cli/package.json` -**Do NOT execute until helper 1.0.0 is published (after Task 12).** Adding unpublished packages breaks `bun install --frozen-lockfile` everywhere. +**Do NOT execute until helper 1.0.0 is published (after Task 12/13).** Adding unpublished packages breaks `bun install --frozen-lockfile` everywhere. **Files:** - Modify: `cli/package.json` - Modify: `bun.lock` (via `bun install`) -- [ ] **Step 1: Add the block** (after `"dependencies"`, current line 133): +- [ ] **Step 1: Add the block** (after `"dependencies"`, around line 133): ```json "optionalDependencies": { @@ -861,12 +1022,12 @@ git commit -m "feat(cli): externalize helper packages, strip dev env override fr - [ ] **Step 2: Refresh the lockfile** Run: `bun install` (repo root) -Expected: resolves both packages; on this arm64 Mac, `cli/node_modules/@capgo/cli-keychain-darwin-arm64/keychain-export` exists and is executable. +Expected: resolves both packages; on this arm64 Mac, `cli/node_modules/@capgo/cli-keychain-darwin-arm64/helper` exists and is executable. - [ ] **Step 3: End-to-end resolution check on this machine** -Run: `cd cli && node -e "import('./src/build/onboarding/macos-signing.ts').catch(()=>null)" 2>/dev/null; bun -e "const m = await import('./src/build/onboarding/macos-signing.ts'); const p = await m.resolveHelperBinary(); console.log(p); const r = Bun.spawnSync([p]); console.log(r.stdout.toString())"` -Expected: prints the node_modules binary path, then the helper's `INVALID_ARGS` JSON envelope (proving real codesign verification + execution of the published binary). +Run: `cd cli && bun -e "const m = await import('./src/build/onboarding/macos-signing.ts'); const p = await m.resolveHelperBinary(); console.log(p); const r = Bun.spawnSync([p]); console.log(r.stdout.toString())"` +Expected: prints the node_modules `helper` path, then the helper's `INVALID_ARGS` JSON envelope (proving real codesign verification + execution of the published binary). - [ ] **Step 4: Commit** @@ -904,7 +1065,7 @@ cd "$(dirname "$0")/.." 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"'"' for arch in arm64 x64; do - bin="dist/keychain-export-$arch" + bin="dist/helper-$arch" echo "── Signing $bin" codesign --force --sign "$DEVELOPER_ID_IDENTITY" --options runtime --timestamp "$bin" @@ -1012,7 +1173,6 @@ jobs: 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 - # Derive the codesign identity from the imported cert IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \ | awk -F'"' '/Developer ID Application/ {print $2; exit}') if [ -z "$IDENTITY" ]; then @@ -1035,12 +1195,19 @@ jobs: - name: Smoke test signed binary run: | set +e - out=$(./cli-helper/dist/keychain-export-arm64) + 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; } - name: Prepare packages run: node cli-helper/scripts/prepare-publish.mjs "${{ steps.version.outputs.version }}" - name: Publish darwin-arm64 @@ -1057,13 +1224,13 @@ jobs: uses: softprops/action-gh-release@v2 with: files: | - cli-helper/dist/keychain-export-arm64 - cli-helper/dist/keychain-export-x64 + cli-helper/dist/helper-arm64 + cli-helper/dist/helper-x64 make_latest: false token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" ``` -Note the new secret referenced: `APPLE_TEAM_ID` (set in Task 12 alongside the cert secrets). +New secret referenced: `APPLE_TEAM_ID` (set in Task 12 alongside the cert secrets). - [ ] **Step 2: Add the strip assertion to `publish_cli.yml`** @@ -1080,8 +1247,8 @@ Insert after the "Build CLI" step (after line 37): - [ ] **Step 3: Validate workflow syntax** -Run: `bunx --yes yaml-lint .github/workflows/publish_cli_helper.yml 2>/dev/null || node -e "const fs=require('fs');const yaml=require('js-yaml');yaml.load(fs.readFileSync('.github/workflows/publish_cli_helper.yml','utf8'));console.log('YAML OK')"` -Expected: `YAML OK` (or yaml-lint pass). If `js-yaml` is unavailable, `actionlint` via `brew install actionlint && actionlint .github/workflows/publish_cli_helper.yml` is the better check. +Run: `node -e "const fs=require('fs');const yaml=require('js-yaml');yaml.load(fs.readFileSync('.github/workflows/publish_cli_helper.yml','utf8'));console.log('YAML OK')"` +Expected: `YAML OK`. (If `js-yaml` is unavailable, `brew install actionlint && actionlint .github/workflows/publish_cli_helper.yml` is the better check.) - [ ] **Step 4: Commit** @@ -1094,19 +1261,19 @@ git commit -m "ci: keychain helper sign/notarize/publish workflow + release stri ### Task 12: Apple setup + local dry run (user-guided) -No repo files change in this task (except possibly the Team ID constant if it differs from what Task 4 used). Everything here is performed by the user with agent guidance. +No repo files change here (except possibly the Team ID constant if it differs from Task 4). Performed by the user with agent guidance. - [ ] **Step 1: Create the Developer ID Application certificate** User actions (requires **Account Holder** role on the Apple Developer team): 1. https://developer.apple.com/account/resources/certificates → `+` → **Developer ID Application** → follow CSR instructions (Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority, saved to disk). 2. Download the `.cer`, double-click to install into the login keychain. -3. Keychain Access → My Certificates → right-click the "Developer ID Application: …" entry → Export as `.p12` with a strong password. +3. Keychain Access → My Certificates → right-click the "Developer ID Application: …" entry → Export as `.p12` with a password. - [ ] **Step 2: Record the Team ID and reconcile the constant** Run: `security find-identity -v -p codesigning | head -5` — the Developer ID line ends in `(TEAMID)`. -If it differs from the `CAPGO_APPLE_TEAM_ID` value committed in Task 4, update the constant in `cli/src/build/onboarding/macos-signing.ts` and commit (`fix(cli): correct Apple Team ID for helper verification`). +If it differs from the `CAPGO_APPLE_TEAM_ID` committed in Task 4, update the constant in `cli/src/build/onboarding/macos-signing.ts` and commit (`fix(cli): correct Apple Team ID for helper verification`). - [ ] **Step 3: Set GitHub secrets** @@ -1129,7 +1296,7 @@ export APPLE_KEY_ID= APPLE_ISSUER_ID= APPLE_KEY_PATH=/dev/null && npm i @capgo/cli -node -e "const {execFileSync}=require('node:child_process');const p='node_modules/@capgo/cli-keychain-darwin-arm64/keychain-export';execFileSync('/usr/bin/codesign',['--verify','--strict','-R','=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = \"'+process.env.TEAM_ID+'\"',p]);console.log('signature OK')" TEAM_ID= +node -e "const {execFileSync}=require('node:child_process');const p='node_modules/@capgo/cli-keychain-darwin-arm64/helper';execFileSync('/usr/bin/codesign',['--verify','--strict','-R','=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = \"'+process.env.TEAM_ID+'\"',p]);console.log('signature OK')" TEAM_ID= ``` Then run the real onboarding export flow once (`npx @capgo/cli build init` → iOS → import existing) and confirm: no "compiling helper" step, successful P12 export, two Keychain prompts max. If an Intel Mac or Rosetta terminal is available, repeat there (x64 package). @@ -1177,6 +1344,6 @@ Run the import flow; expected: hard error naming `@capgo/cli-keychain-darwin-arm ## Self-review notes -- Spec coverage: package layout (T1), build targets (T2), version stamping (T3), name mapping + requirement (T4), resolution + verification + env override (T5), compile-path removal (T6), UI removal (T7), build defines/externals/copy removal (T8), optionalDependencies (T9), sign/notarize (T10), CI pipeline + strip assertion (T11), Apple runbook (T12), release ordering + manual acceptance + `--no-optional` regression (T13). All spec sections map to tasks. -- Type consistency: `SpawnResult` (existing, kept), `ResolveHelperBinaryOptions.resolve` receives `/package.json` and the binary is `join(dirname(...), 'keychain-export')` — consistent between Task 5 implementation and tests (tests return a `package.json` path inside the fixture dir). +- Spec coverage: package layout + SECURITY.md + README (T1), Swift subcommand/gate/FORBIDDEN_CALLER (T1), build targets (T2), version stamping (T3), name mapping + requirement + result-code union (T4), resolution + verification + env override (T5), compile-path removal + subcommand/handshake invocation (T6), UI removal (T7), build defines/externals/copy removal (T8), optionalDependencies (T9), sign/notarize (T10), CI pipeline + strip assertion + gate test (T11), Apple runbook (T12), release ordering + manual acceptance + `--no-optional` regression (T13). All spec sections map to tasks. +- Type consistency: binary name is `helper` everywhere (package `files`, `prepare-publish.mjs` dest, `resolveHelperBinary`'s `join(dirname(...), 'helper')`, fake-helper test fixture, CI paths). Subcommand token `keychain-export` + `--invoked-by capgo-cli` handshake match between Swift `main` dispatch (T1), the `exportP12FromKeychain` spawn args (T6), and the CI gate test (T11). `FORBIDDEN_CALLER` appears in the Swift enum (T1), the TS `SwiftHelperResult.errorCode` union (T4), and the CI gate test (T11). `SpawnResult` (existing) is reused by `CodesignRunner`. - Known accepted wart: Task 6's commit leaves `app.tsx` typecheck-broken until Task 7 (called out inline with a squash alternative). diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index f78f5d090a..27a6eaaa1c 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -29,7 +29,7 @@ either runs a verified Capgo-signed binary or fails with clear guidance. | Decision | Choice | | --- | --- | -| Source location | New top-level `cli-helper/` dir in the capgo monorepo; **Swift source moves there** (`cli-helper/src/keychain-export.swift`) as single source of truth | +| Source location | New top-level `cli-helper/` dir in the capgo monorepo; **Swift source moves there** (`cli-helper/src/helper.swift`) as single source of truth | | Package shape | Per-arch packages (esbuild style): `@capgo/cli-keychain-darwin-arm64`, `@capgo/cli-keychain-darwin-x64` | | Install mechanism | Both listed in `@capgo/cli` `optionalDependencies` with `^` range; `os`/`cpu` fields make npm/bun/pnpm install at most one | | Fallback | **None.** The runtime swiftc compile path and tmp-binary cache are deleted. Missing/unverifiable binary → hard error with install guidance | @@ -39,6 +39,8 @@ either runs a verified Capgo-signed binary or fails with clear guidance. | Signing | Developer ID Application certificate; hardened runtime + secure timestamp; notarized via `notarytool` with existing App Store Connect API key secrets | | Binary trust | CLI verifies the package-resolved binary's code signature (Developer ID + Capgo Team ID designated requirement) before executing it; failure is a hard error | | Env override | `CAPGO_KEYCHAIN_HELPER_PATH` exists in dev builds only — stripped from npm release builds via build-time define + dead-code elimination | +| Binary name | One generic binary named `helper`, invoked with a subcommand (`helper keychain-export …`). Future helpers are new subcommands of the same signed binary, not new files | +| Caller hardening | Anti-footgun gate on the sensitive subcommand (requires an internal handshake flag + non-TTY stdout) — explicitly a non-security-boundary; plus a `SECURITY.md` documenting why the macOS Keychain ACL is the actual boundary. "Always Allow" caching is kept | ## Architecture @@ -47,7 +49,7 @@ either runs a verified Capgo-signed binary or fails with clear guidance. ``` cli-helper/ ├── src/ -│ └── keychain-export.swift # moved from cli/src/build/onboarding/ +│ └── helper.swift # moved+renamed from cli/src/build/onboarding/keychain-export.swift ├── npm/ │ ├── darwin-arm64/package.json # @capgo/cli-keychain-darwin-arm64 │ └── darwin-x64/package.json # @capgo/cli-keychain-darwin-x64 @@ -55,6 +57,7 @@ cli-helper/ │ ├── build.sh # swiftc per-arch builds │ ├── sign-and-notarize.sh # codesign + notarytool submit --wait │ └── prepare-publish.mjs # stamps tag version, copies binaries into npm/*/ +├── SECURITY.md # threat model — why the macOS Keychain ACL is the boundary └── README.md # includes dev bootstrap instructions ``` @@ -67,14 +70,14 @@ cli-helper/ "description": "Precompiled macOS (Apple Silicon) keychain-export helper for @capgo/cli", "os": ["darwin"], "cpu": ["arm64"], - "files": ["keychain-export"], + "files": ["helper"], "license": "Apache 2.0" } ``` (`-x64` variant identical with `"cpu": ["x64"]`.) -- The binary ships as a plain executable `keychain-export` at the package root. +- The binary ships as a plain executable `helper` at the package root. No `bin` entry — it is never on PATH; the CLI resolves it by path. npm preserves the executable bit from the tarball. - Both packages always publish at the same version in the same workflow run. @@ -103,7 +106,7 @@ Runtime resolution order in `cli/src/build/onboarding/macos-signing.ts`: the Swift source), developers compile locally with one documented `swiftc` command and point the override at the result. 2. Precompiled package: - `createRequire(import.meta.url).resolve('@capgo/cli-keychain-darwin-' + archSuffix + '/keychain-export')` + `createRequire(import.meta.url).resolve('@capgo/cli-keychain-darwin-' + archSuffix + '/helper')` where `archSuffix` maps `process.arch` `arm64`→`arm64`, `x64`→`x64`. Wrapped in try/catch; verify the file exists and is executable, **then verify its code signature** (below) before use. @@ -111,6 +114,12 @@ Runtime resolution order in `cli/src/build/onboarding/macos-signing.ts`: signature verification failure — is a **hard error** (see Error handling). There is no compilation fallback. +The CLI invokes the resolved binary as +`helper keychain-export --sha1 … --output … --passphrase … --invoked-by capgo-cli`, +capturing stdout (piped, not a TTY). The leading `keychain-export` subcommand +and the `--invoked-by` handshake flag feed the anti-footgun gate (see Security +model). The JSON stdout contract is unchanged. + ### Signature verification of the precompiled binary Before executing a package-resolved binary (step 2), the CLI verifies it was @@ -144,6 +153,56 @@ know the exact binary hash). `exportP12FromKeychain`) bypasses the signature check; it is not reachable from user input. +## Security model + +This section is the source for `cli-helper/SECURITY.md`. It exists primarily to +give a documented, defensible "won't fix, by design" answer to the predictable +low-effort report: *"I can invoke your helper directly and export the user's +keychain!"* + +**The macOS Keychain ACL prompt is the security boundary, and macOS — not our +code — enforces it against our binary's code signature.** Exporting a private +key triggers an OS-level "Allow / Always Allow" prompt bound to the calling +binary's signature. + +**The helper grants a local attacker nothing they don't already have.** An +attacker who can execute our signed `helper` on the victim's machine already +has local code execution as that user, and can call Apple's own +`SecItemExport` / `/usr/bin/security export` directly. The helper is not a +privilege escalation — it is a worse-for-them version of tools already on the +box. + +**Why we don't authenticate the caller (it's neither feasible nor valuable):** +- The parent process is `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 JS in the npm tarball. +- Parent-PID checks are TOCTOU-racy and subject to PID reuse. + +**The one narrow residual exposure:** after the user clicks "Always Allow", the +cached ACL grant is bound to our binary, so a *separate malicious local +process* could invoke our helper and ride that grant to export without a fresh +prompt. We accept this, documented, because closing it fully requires dropping +"Always Allow" (re-prompting on every export) and the attacker already has +equivalent access by the reasoning above. + +**Anti-footgun gate (explicitly NOT a security boundary).** The sensitive +`keychain-export` subcommand refuses to run unless: +1. the internal handshake flag `--invoked-by capgo-cli` is present, and +2. stdout is not a TTY (the CLI always pipes it). + +A failed gate emits `{"ok":false,"errorCode":"FORBIDDEN_CALLER",...}` and exits +non-zero **without** touching the Keychain. This stops casual, accidental, and +naive-script invocation. It does **not** stop a determined local attacker (who +reads the flag straight out of the open-source CLI) — and SECURITY.md says so +in plain language. It is defense-in-depth and a clear "you bypassed a speed +bump, not a boundary" marker, nothing more. + +`SECURITY.md` also states the reporting expectation: invoking the helper is +equivalent to the caller invoking Apple's keychain APIs, which any local +process with the user's privileges can already do; reports demonstrating only +that are out of scope by design. + ### Code removed from the CLI - `compileSwiftHelper`, `ensureSwiftHelper`, `resolveSwiftSourcePath`, and the @@ -161,9 +220,11 @@ Build changes in `cli/build.mjs`: release build (and `'true'` under `NODE_ENV=development`) so the env override is dead-code-eliminated from the npm artifact. -The helper's stdout contract (one line of JSON: `ok`, `p12Path`, -`errorCode`, `osStatus`, …) is unchanged; `exportP12FromKeychain` parsing is -untouched. +The helper's success/failure stdout contract (one line of JSON: `ok`, +`p12Path`, `errorCode`, `osStatus`, …) is unchanged except for the new +`FORBIDDEN_CALLER` error code (anti-footgun gate); the CLI invokes it with the +`keychain-export` subcommand + `--invoked-by capgo-cli` handshake (see +Security model). `exportP12FromKeychain`'s JSON parsing is otherwise untouched. ## CI pipeline — `.github/workflows/publish_cli_helper.yml` @@ -171,8 +232,8 @@ Trigger: push of tags matching `cli-helper-[0-9]*`. Single job on `macos-latest` (both arches cross-compile on one runner; no artifact passing). 1. **Build** (per arch): - - `swiftc src/keychain-export.swift -framework Security -O -target arm64-apple-macos11 -o keychain-export-arm64` - - `swiftc src/keychain-export.swift -framework Security -O -target x86_64-apple-macos10.15 -o keychain-export-x64` + - `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` 2. **Sign**: create a throwaway keychain for the job; import the Developer ID Application cert from secrets `DEVELOPER_ID_CERT_BASE64` (.p12) + `DEVELOPER_ID_CERT_PASSWORD`; then per binary: @@ -187,8 +248,11 @@ Trigger: push of tags matching `cli-helper-[0-9]*`. Single job on files carry no quarantine xattr, and the notarization ticket is available online when Gatekeeper does evaluate. 4. **Verify**: `codesign --verify --strict` per binary; assert signing - authority is the Developer ID cert; smoke-run the arm64 binary with invalid - args and assert non-zero exit + `INVALID_ARGS` JSON envelope on stdout. + authority is the Developer ID cert; run the same designated-requirement + `codesign --verify -R` check the CLI performs at runtime; smoke-run the + arm64 binary with no subcommand and assert non-zero exit + `INVALID_ARGS` + JSON envelope on stdout (the anti-footgun gate guards only the + `keychain-export` subcommand, so a bare invocation reaches `INVALID_ARGS`). 5. **Publish**: `prepare-publish.mjs` reads the version from the tag, stamps both manifests (failing fast on mismatch), copies each binary into its package dir, then `npm publish --provenance --access public` for both @@ -262,6 +326,13 @@ To do: helper" step and successful P12 export; cover x64 via Intel Mac or Rosetta. - **Missing-package regression**: install with `--no-optional`, confirm the export flow fails with the actionable install-guidance error (not a crash). +- **Anti-footgun gate** (Swift, run in helper CI): `helper keychain-export …` + without `--invoked-by capgo-cli` → `FORBIDDEN_CALLER` + non-zero exit + no + Keychain access; the same with a forced-TTY stdout → `FORBIDDEN_CALLER`. The + happy path (handshake present, piped stdout) is exercised by the manual + acceptance run since it needs a real Keychain identity. +- **SECURITY.md presence**: `cli-helper/SECURITY.md` exists and states the + boundary (macOS Keychain ACL) and the out-of-scope reporting expectation. ## Benefits recap From 67f6829afb442e1176fe938f5bc362bc699622f2 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 8 Jun 2026 14:42:52 +0200 Subject: [PATCH 06/26] docs: record future native-UI/.app path + pin stable signing identifier for Keychain ACL persistence --- .../2026-06-06-keychain-helper-precompile.md | 10 +++- ...06-06-keychain-helper-precompile-design.md | 60 +++++++++++++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md index 90abb01c82..5c741b6e1e 100644 --- a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -1064,10 +1064,18 @@ cd "$(dirname "$0")/.." 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"'"' +# 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" --options runtime --timestamp "$bin" + codesign --force --sign "$DEVELOPER_ID_IDENTITY" --identifier "$HELPER_IDENTIFIER" --options runtime --timestamp "$bin" echo "── Notarizing $bin" ditto -c -k "$bin" "$bin.zip" diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index 27a6eaaa1c..b0ad6f94b4 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -36,7 +36,7 @@ either runs a verified Capgo-signed binary or fails with clear guidance. | Min macOS | x64 slice: macOS 10.15 (oldest macOS that runs Node 20, the CLI's floor); arm64 slice: macOS 11.0 | | Versioning | Independent semver, starting 1.0.0; release tag `cli-helper-X.Y.Z`; released **only when helper source changes**, not per CLI release | | Pipeline | Tag-triggered GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance | -| Signing | Developer ID Application certificate; hardened runtime + secure timestamp; notarized via `notarytool` with existing App Store Connect API key secrets | +| Signing | Developer ID Application certificate; hardened runtime + secure timestamp; stable code-signing identifier `app.capgo.cli.helper` (preserves Keychain "Always Allow" across re-signs and a future `.app` migration); notarized via `notarytool` with existing App Store Connect API key secrets | | Binary trust | CLI verifies the package-resolved binary's code signature (Developer ID + Capgo Team ID designated requirement) before executing it; failure is a hard error | | Env override | `CAPGO_KEYCHAIN_HELPER_PATH` exists in dev builds only — stripped from npm release builds via build-time define + dead-code elimination | | Binary name | One generic binary named `helper`, invoked with a subcommand (`helper keychain-export …`). Future helpers are new subcommands of the same signed binary, not new files | @@ -345,7 +345,59 @@ To do: to Capgo's team — npm provenance + notarization + runtime requirement check give a verifiable supply chain for a binary that reads users' keychains. -## Out of scope - -- Stapling (impossible for bare executables; not needed for npm distribution). +## Future: native notifications & UI (.app bundle) + +Not built now (YAGNI — the helper is headless and the CLI owns the terminal +UX). Recorded so the path is understood and the one cheap-now decision is +captured. Today's helper ships as a bare signed executable; a later subcommand +that needs a macOS notification or a small SwiftUI panel would graduate it to a +`Capgo.app` bundle. + +**Why a bundle is required for notifications.** `UNUserNotificationCenter` +requires a bundle identifier; a bare executable has none and the call fails. A +branded notification (Capgo name + icon) therefore needs a `Capgo.app` with an +`Info.plist` (`CFBundleIdentifier`, `CFBundleName`, `CFBundleIconFile`) and a +`Capgo.icns`. Renaming the bare binary to `capgo` does **not** help — the +displayed name/icon come from the bundle, not the filename. + +**Staying invisible.** Set `LSUIElement = true` (accessory activation policy): +no Dock icon, no Cmd-Tab entry, no menu bar — yet it can post notifications and +*show a window when needed*. An accessory app keeps **no Dock icon even while a +window is open** (Dock presence tracks the activation policy, not window +visibility). Caveats: windows don't auto-focus (call +`NSApp.activate(ignoringOtherApps: true)` + `makeKeyAndOrderFront`), there's no +app menu bar, and you can optionally flip to `.regular` while a window is up for +focus-grabbing at the cost of a brief Dock-icon flash. Pure headless work +(today's keychain export) never touches AppKit, so it's invisible regardless. + +**No Gatekeeper "downloaded from the internet" prompt.** Two independent +reasons, both holding for a bundle exactly as for the bare binary: (1) npm / +bun / pnpm do not set the `com.apple.quarantine` xattr, and (2) the CLI +`execve`s the inner binary directly (`Capgo.app/Contents/MacOS/capgo`), never +`open Capgo.app` — the first-launch Gatekeeper dialog is a LaunchServices/`open` +behavior, not an exec one. The only path that would prompt is a user manually +downloading the GitHub release asset in a browser and double-clicking it — and +a notarized, **stapled** bundle passes even then. (Stapling is a bonus bundles +get that bare executables can't.) + +**Decision baked in now (cheap now, expensive later):** the sign step pins a +stable code-signing identifier `app.capgo.cli.helper` (`codesign --identifier`). +macOS keys the Keychain "Always Allow" grant to the code's designated +requirement, which includes the identifier — so a future `Capgo.app` reusing +the same `CFBundleIdentifier` preserves every user's grant across the +bare-binary → bundle migration. Without this, the migration would silently +reset everyone's "Always Allow" once. + +**When built, this would add:** a bundle-assembly step in `build.sh` +(`Capgo.app/Contents/{MacOS/capgo, Info.plist, Resources/Capgo.icns}`), bundle +signing + notarization + stapling, npm `files: ["Capgo.app"]`, a CLI resolver +change to `…/Capgo.app/Contents/MacOS/capgo`, and (for notifications) a one-time +`UNUserNotificationCenter` authorization prompt branded "Capgo". The runtime +`codesign -R` requirement and the stable identifier carry over unchanged. + +## Out of scope (now) + +- Building the `.app` bundle / native UI (see Future section above). +- Stapling the bare executable (impossible for bare executables; not needed for + npm distribution since npm doesn't quarantine). - Windows/Linux variants (helper is macOS-only by nature). From 758cb9ab2c6c82753dff369d6be6378a721cfacf Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 8 Jun 2026 15:15:01 +0200 Subject: [PATCH 07/26] docs: switch helper release to deliberate workflow_dispatch (button), not auto-tag --- .../2026-06-06-keychain-helper-precompile.md | 41 +++++++++++++------ ...06-06-keychain-helper-precompile-design.md | 20 +++++---- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md index 5c741b6e1e..55b66bfcba 100644 --- a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -10,7 +10,7 @@ **Spec:** `docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md` -**⚠️ Sequencing constraint:** Task 9 (adding `optionalDependencies` to `cli/package.json`) MUST NOT merge to main until helper 1.0.0 is live on npm (Task 13). Otherwise `bun install --frozen-lockfile` in every CI job fails resolving the not-yet-published packages. Tasks 1–8 and 10–11 are safe to merge any time (the workflow only fires on `cli-helper-*` tags). The CLI release tag (Task 13) comes last. +**⚠️ Sequencing constraint:** Task 9 (adding `optionalDependencies` to `cli/package.json`) MUST NOT merge to main until helper 1.0.0 is live on npm (Task 13). Otherwise `bun install --frozen-lockfile` in every CI job fails resolving the not-yet-published packages. Tasks 1–8 and 10–11 are safe to merge any time (the helper workflow only runs on manual `workflow_dispatch`, never automatically). The CLI release (Task 13) comes last. **⚠️ User input needed during execution:** - Task 4 / Task 12: Capgo's Apple **Team ID** (10-char, the `subject.OU` of the Developer ID cert). Likely `UVTJ336J2D` (appears in existing test fixtures as "digital shift oü (UVTJ336J2D)") — **confirm with the user before hardcoding**. @@ -290,10 +290,12 @@ binary you built and trust. ## Release -1. Bump nothing in-repo — versions are stamped from the tag. -2. `git tag cli-helper-X.Y.Z && git push origin cli-helper-X.Y.Z` +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, and publishes both packages with npm provenance. + 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` @@ -1137,9 +1139,11 @@ concurrency: cancel-in-progress: true on: - push: - tags: - - "cli-helper-[0-9]*" + workflow_dispatch: + inputs: + version: + description: "Helper version to publish, e.g. 1.0.0 (no 'cli-helper-' prefix)" + required: true permissions: {} @@ -1159,9 +1163,14 @@ jobs: with: node-version: 24.x registry-url: https://registry.npmjs.org - - name: Extract version from tag + - name: Validate + capture version id: version - run: echo "version=${GITHUB_REF_NAME#cli-helper-}" >> "$GITHUB_OUTPUT" + 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 @@ -1228,9 +1237,11 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --provenance --access public - - name: Create GitHub release + - 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 @@ -1312,13 +1323,17 @@ Expected: both binaries report `Notarization accepted` and both `codesign --veri - [ ] **Step 1: Merge everything except Task 9** (the `optionalDependencies` change stays unmerged/uncommitted until Step 3). -- [ ] **Step 2: Tag and publish helper 1.0.0** +- [ ] **Step 2: Dispatch the helper release for 1.0.0** + +From the GitHub Actions UI ("Build and publish CLI keychain helper" → Run +workflow → version `1.0.0`), or: ```bash -git tag cli-helper-1.0.0 && git push origin cli-helper-1.0.0 +gh workflow run publish_cli_helper.yml --repo Cap-go/capgo -f version=1.0.0 ``` -Watch: `gh run watch --repo Cap-go/capgo`. Then verify: +Watch: `gh run watch --repo Cap-go/capgo`. The run signs, notarizes, publishes, +and creates the `cli-helper-1.0.0` tag + release. Then verify: ```bash npm view @capgo/cli-keychain-darwin-arm64@1.0.0 dist.tarball diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index b0ad6f94b4..2b2ce9d03b 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -35,7 +35,7 @@ either runs a verified Capgo-signed binary or fails with clear guidance. | Fallback | **None.** The runtime swiftc compile path and tmp-binary cache are deleted. Missing/unverifiable binary → hard error with install guidance | | Min macOS | x64 slice: macOS 10.15 (oldest macOS that runs Node 20, the CLI's floor); arm64 slice: macOS 11.0 | | Versioning | Independent semver, starting 1.0.0; release tag `cli-helper-X.Y.Z`; released **only when helper source changes**, not per CLI release | -| Pipeline | Tag-triggered GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance | +| Pipeline | **Manually dispatched** (`workflow_dispatch` with a `version` input) GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance; the run creates the `cli-helper-X.Y.Z` git tag + GitHub release itself. Deliberate (human-in-the-loop) because releases are rare and notarization is a flaky external dependency — intentionally diverges from the repo's auto-tag `bump_version.yml` path used by `capgo`/`cli` | | Signing | Developer ID Application certificate; hardened runtime + secure timestamp; stable code-signing identifier `app.capgo.cli.helper` (preserves Keychain "Always Allow" across re-signs and a future `.app` migration); notarized via `notarytool` with existing App Store Connect API key secrets | | Binary trust | CLI verifies the package-resolved binary's code signature (Developer ID + Capgo Team ID designated requirement) before executing it; failure is a hard error | | Env override | `CAPGO_KEYCHAIN_HELPER_PATH` exists in dev builds only — stripped from npm release builds via build-time define + dead-code elimination | @@ -228,8 +228,11 @@ Security model). `exportP12FromKeychain`'s JSON parsing is otherwise untouched. ## CI pipeline — `.github/workflows/publish_cli_helper.yml` -Trigger: push of tags matching `cli-helper-[0-9]*`. Single job on +Trigger: `workflow_dispatch` with a required `version` input (e.g. `1.0.0`), +run from the GitHub Actions UI or `gh workflow run`. Single job on `macos-latest` (both arches cross-compile on one runner; no artifact passing). +The run validates the version is semver, then drives the steps below, and at +the end creates the `cli-helper-` git tag + GitHub release. 1. **Build** (per arch): - `swiftc src/helper.swift -framework Security -O -target arm64-apple-macos11 -o dist/helper-arm64` @@ -253,12 +256,13 @@ Trigger: push of tags matching `cli-helper-[0-9]*`. Single job on arm64 binary with no subcommand and assert non-zero exit + `INVALID_ARGS` JSON envelope on stdout (the anti-footgun gate guards only the `keychain-export` subcommand, so a bare invocation reaches `INVALID_ARGS`). -5. **Publish**: `prepare-publish.mjs` reads the version from the tag, stamps - both manifests (failing fast on mismatch), copies each binary into its - package dir, then `npm publish --provenance --access public` for both - packages back-to-back after all gates pass. -6. **GitHub release**: same `softprops/action-gh-release` pattern as - `publish_cli.yml`, with both binaries attached as release assets. +5. **Publish**: `prepare-publish.mjs` takes the dispatched `version` input, + stamps both manifests, copies each binary into its package dir, then + `npm publish --provenance --access public` for both packages back-to-back + after all gates pass. +6. **Tag + GitHub release**: `softprops/action-gh-release` with + `tag_name: cli-helper-` (the action creates the tag on the + dispatched commit), both binaries attached as release assets. Required workflow permissions: `contents: write`, `id-token: write` (provenance). From f894a8ccb142105eaf045bc32f57d5caf973ac02 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 8 Jun 2026 15:37:56 +0200 Subject: [PATCH 08/26] feat(cli-helper): precompiled macOS keychain helper packages New cli-helper/ dir: generic 'helper' binary (Swift, subcommand dispatch) with the keychain-export subcommand moved+renamed from cli/. Per-arch npm packages @capgo/cli-keychain-darwin-{arm64,x64} (files:[helper]), build/sign-notarize/ prepare-publish scripts (stable --identifier app.capgo.cli.helper), and SECURITY.md threat model. Anti-footgun gate (--invoked-by handshake + non-TTY) emits FORBIDDEN_CALLER. --- .gitignore | 4 ++ cli-helper/README.md | 50 +++++++++++++++ cli-helper/SECURITY.md | 40 ++++++++++++ cli-helper/npm/darwin-arm64/package.json | 14 +++++ cli-helper/npm/darwin-x64/package.json | 14 +++++ cli-helper/scripts/build.sh | 13 ++++ cli-helper/scripts/prepare-publish.mjs | 33 ++++++++++ cli-helper/scripts/sign-and-notarize.sh | 53 ++++++++++++++++ .../src/helper.swift | 63 ++++++++++++++----- 9 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 cli-helper/README.md create mode 100644 cli-helper/SECURITY.md create mode 100644 cli-helper/npm/darwin-arm64/package.json create mode 100644 cli-helper/npm/darwin-x64/package.json create mode 100755 cli-helper/scripts/build.sh create mode 100644 cli-helper/scripts/prepare-publish.mjs create mode 100755 cli-helper/scripts/sign-and-notarize.sh rename cli/src/build/onboarding/keychain-export.swift => cli-helper/src/helper.swift (84%) diff --git a/.gitignore b/.gitignore index 1288a1aae5..4b3211736c 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,7 @@ graphify-out/*.html graphify-out/*.svg graphify-out/*.graphml graphify-out/cypher.txt + +# Capgo CLI keychain helper — locally built binaries +cli-helper/dist/ +cli-helper/npm/*/helper diff --git a/cli-helper/README.md b/cli-helper/README.md new file mode 100644 index 0000000000..9440156374 --- /dev/null +++ b/cli-helper/README.md @@ -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 \ + --passphrase --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`. diff --git a/cli-helper/SECURITY.md b/cli-helper/SECURITY.md new file mode 100644 index 0000000000..de7bf3fa18 --- /dev/null +++ b/cli-helper/SECURITY.md @@ -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. diff --git a/cli-helper/npm/darwin-arm64/package.json b/cli-helper/npm/darwin-arm64/package.json new file mode 100644 index 0000000000..5e0b65816e --- /dev/null +++ b/cli-helper/npm/darwin-arm64/package.json @@ -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": ["helper"] +} diff --git a/cli-helper/npm/darwin-x64/package.json b/cli-helper/npm/darwin-x64/package.json new file mode 100644 index 0000000000..6a2d79bbbc --- /dev/null +++ b/cli-helper/npm/darwin-x64/package.json @@ -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": ["helper"] +} diff --git a/cli-helper/scripts/build.sh b/cli-helper/scripts/build.sh new file mode 100755 index 0000000000..b8f854845a --- /dev/null +++ b/cli-helper/scripts/build.sh @@ -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 diff --git a/cli-helper/scripts/prepare-publish.mjs b/cli-helper/scripts/prepare-publish.mjs new file mode 100644 index 0000000000..a4789b08f7 --- /dev/null +++ b/cli-helper/scripts/prepare-publish.mjs @@ -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 +// 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 — 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}`) +} diff --git a/cli-helper/scripts/sign-and-notarize.sh b/cli-helper/scripts/sign-and-notarize.sh new file mode 100755 index 0000000000..579d31edc0 --- /dev/null +++ b/cli-helper/scripts/sign-and-notarize.sh @@ -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: ()" +# 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"'"' + +# 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 + echo "Notarization failed for $bin (status: ${status:-unknown})" >&2 + if [ -n "$id" ]; then + 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." diff --git a/cli/src/build/onboarding/keychain-export.swift b/cli-helper/src/helper.swift similarity index 84% rename from cli/src/build/onboarding/keychain-export.swift rename to cli-helper/src/helper.swift index 15d1411e46..0e54c1a396 100644 --- a/cli/src/build/onboarding/keychain-export.swift +++ b/cli-helper/src/helper.swift @@ -1,4 +1,4 @@ -// keychain-export.swift +// helper.swift // // Capgo helper: export ONE iOS signing identity from the user's Keychain as a // PKCS#12 blob. Always emits a single line of JSON on stdout describing the @@ -6,9 +6,10 @@ // stderr or guess from exit codes. // // Usage: -// keychain-export --sha1 <40-hex-char-cert-sha1> -// --output -// --passphrase +// helper keychain-export --sha1 <40-hex-char-cert-sha1> +// --output +// --passphrase +// --invoked-by capgo-cli // // JSON output (single line on stdout, ALWAYS emitted before exit): // @@ -39,7 +40,7 @@ // "Always Allow" decisions, so subsequent runs are silent. // // Build: -// swiftc keychain-export.swift -framework Security -o keychain-export +// swiftc helper.swift -framework Security -o helper // // Tested on macOS 11+ (Swift 5.5+, CryptoKit available). @@ -119,6 +120,7 @@ enum KeychainExportError: Error { case exportFailed(OSStatus, String) case writeFailed(String) case copyFailed(OSStatus, String) + case forbiddenCaller(String) } extension KeychainExportError { @@ -130,6 +132,7 @@ extension KeychainExportError { case .exportFailed: return "EXPORT_FAILED" case .writeFailed: return "WRITE_FAILED" case .copyFailed: return "EXPORT_FAILED" + case .forbiddenCaller: return "FORBIDDEN_CALLER" } } var exitCode: Int32 { @@ -137,12 +140,13 @@ extension KeychainExportError { case .invalidArgs: return 2 case .noIdentity: return 3 case .userDenied: return 4 + case .forbiddenCaller: return 5 default: return 1 } } var message: String { switch self { - case let .invalidArgs(m), let .noIdentity(m), let .writeFailed(m): return m + case let .invalidArgs(m), let .noIdentity(m), let .writeFailed(m), let .forbiddenCaller(m): return m case let .userDenied(_, m), let .exportFailed(_, m), let .copyFailed(_, m): return m } } @@ -174,12 +178,12 @@ struct Args { var sha1Hex: String = "" var outputPath: String = "" var passphrase: String = "" + var invokedBy: String = "" } -func parseArgs() throws -> Args { +func parseArgs(_ cli: [String]) throws -> Args { var args = Args() - let cli = CommandLine.arguments - var i = 1 + var i = 0 while i < cli.count { let flag = cli[i] i += 1 @@ -192,6 +196,7 @@ func parseArgs() throws -> Args { case "--sha1": args.sha1Hex = value.lowercased() case "--output": args.outputPath = value case "--passphrase": args.passphrase = value + case "--invoked-by": args.invokedBy = value default: throw KeychainExportError.invalidArgs("Unknown argument: \(flag)") } } @@ -331,18 +336,46 @@ func writeP12(_ data: Data, to path: String) throws { } } +// MARK: - Caller gate (anti-footgun; NOT a security boundary — see SECURITY.md) +// +// Stops casual / accidental / naive-script invocation of the sensitive +// export path. It does NOT stop a determined local attacker, who can read the +// handshake straight out of the open-source CLI (or call Apple's keychain APIs +// directly). The macOS Keychain ACL is the real boundary. +func enforceCallerGate(_ args: Args) throws { + guard args.invokedBy == "capgo-cli" else { + throw KeychainExportError.forbiddenCaller( + "Refusing to run: missing or invalid --invoked-by handshake." + ) + } + guard isatty(STDOUT_FILENO) == 0 else { + throw KeychainExportError.forbiddenCaller( + "Refusing to run with an interactive (TTY) stdout." + ) + } +} + // MARK: - Main do { - let args = try parseArgs() - let (identity, identityName) = try findIdentityBySha1(args.sha1Hex) - let p12 = try exportIdentityAsPkcs12(identity, passphrase: args.passphrase) - try writeP12(p12, to: args.outputPath) - emitSuccessAndExit(p12Path: args.outputPath, p12SizeBytes: p12.count, identityName: identityName) + let argv = CommandLine.arguments + guard argv.count >= 2 else { + throw KeychainExportError.invalidArgs("Missing subcommand. Usage: helper …") + } + switch argv[1] { + case "keychain-export": + let args = try parseArgs(Array(argv.dropFirst(2))) + try enforceCallerGate(args) + let (identity, identityName) = try findIdentityBySha1(args.sha1Hex) + let p12 = try exportIdentityAsPkcs12(identity, passphrase: args.passphrase) + try writeP12(p12, to: args.outputPath) + emitSuccessAndExit(p12Path: args.outputPath, p12SizeBytes: p12.count, identityName: identityName) + default: + throw KeychainExportError.invalidArgs("Unknown subcommand: \(argv[1])") + } } catch let error as KeychainExportError { emitFailureAndExit(error) } catch { - // Any other Swift error (Foundation throw, etc.) lands here. emitFailureAndExit( code: 1, errorCode: "INTERNAL", From ee6f104a44a6bc6d5be5e041da8c54294c81645b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 8 Jun 2026 15:37:56 +0200 Subject: [PATCH 09/26] feat(cli): run precompiled helper with signature verification, drop swiftc path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveHelperBinary resolves the arch-matching @capgo/cli-keychain-darwin-* optional dep and verifies its Developer ID + Capgo Team ID codesign requirement before exec; hard-errors (no compile fallback). exportP12FromKeychain invokes 'helper keychain-export … --invoked-by capgo-cli'. Removes the runtime swiftc compile path, tmp cache, and the compiling-helper onboarding step. Dev-only CAPGO_KEYCHAIN_HELPER_PATH override is dead-code-eliminated from release builds. --- cli/build.mjs | 26 +- cli/src/build/onboarding/error-categories.ts | 2 - cli/src/build/onboarding/macos-signing.ts | 264 ++++++++++-------- cli/src/build/onboarding/types.ts | 3 - cli/src/build/onboarding/ui/app.tsx | 33 +-- .../build/onboarding/ui/steps/ios-import.tsx | 47 ---- cli/test/test-macos-signing.mjs | 138 ++++++++- 7 files changed, 310 insertions(+), 203 deletions(-) diff --git a/cli/build.mjs b/cli/build.mjs index 8920da497e..1dc9a08415 100644 --- a/cli/build.mjs +++ b/cli/build.mjs @@ -1,6 +1,13 @@ import { copyFileSync, readFileSync, writeFileSync } from 'node:fs' import { env, exit } from 'node:process' +// Precompiled keychain helper packages resolve from node_modules at runtime +// (binary-only optional deps) — never bundle them. +const HELPER_PACKAGES = [ + '@capgo/cli-keychain-darwin-arm64', + '@capgo/cli-keychain-darwin-x64', +] + // Shared plugin definitions - Bun's plugin API is compatible with esbuild's const stubSemver = { name: 'stub-semver', @@ -308,8 +315,13 @@ const buildCLI = Bun.build({ minify: true, // Keep env access runtime-only unless explicitly defined below. env: 'disable', + external: HELPER_PACKAGES, define: { 'process.env.SUPA_DB': '"production"', + // Gates the CAPGO_KEYCHAIN_HELPER_PATH dev override. `false` here makes + // the minifier delete the whole branch from release bundles — + // publish_cli.yml asserts the string is absent from dist/index.js. + '__CAPGO_ALLOW_HELPER_ENV_OVERRIDE__': env.NODE_ENV === 'development' ? 'true' : 'false', }, plugins: [ fixCapacitorCliDirname, @@ -336,8 +348,13 @@ const buildSDK = Bun.build({ format: 'esm', // Keep env access runtime-only unless explicitly defined below. env: 'disable', + external: HELPER_PACKAGES, define: { 'process.env.SUPA_DB': '"production"', + // Gates the CAPGO_KEYCHAIN_HELPER_PATH dev override. `false` here makes + // the minifier delete the whole branch from release bundles — + // publish_cli.yml asserts the string is absent from dist/index.js. + '__CAPGO_ALLOW_HELPER_ENV_OVERRIDE__': env.NODE_ENV === 'development' ? 'true' : 'false', }, plugins: [ fixCapacitorCliDirname, @@ -405,15 +422,6 @@ Promise.all([buildCLI, buildSDK]).then(async (results) => { copyFileSync('package.json', 'dist/package.json') - // Ship the macOS keychain-export Swift helper alongside the bundle. The - // CLI compiles it on first use into an OS temp folder via `swiftc`. Source - // is shipped (not a precompiled binary) to keep the npm tarball Linux/Win- - // safe and to skip code-signing infrastructure for now. - copyFileSync( - 'src/build/onboarding/keychain-export.swift', - 'dist/keychain-export.swift', - ) - console.warn('✅ Built CLI and SDK successfully') }).catch((err) => { console.error('Build failed:', err) diff --git a/cli/src/build/onboarding/error-categories.ts b/cli/src/build/onboarding/error-categories.ts index 1bb57a4106..2e0f40ab12 100644 --- a/cli/src/build/onboarding/error-categories.ts +++ b/cli/src/build/onboarding/error-categories.ts @@ -54,8 +54,6 @@ export function mapIosOnboardingError( // failure occurred. if (failedStep === 'import-scanning') return 'keychain_no_identities' - if (failedStep === 'import-compiling-helper') - return 'keychain_helper_compile_failed' if (failedStep === 'import-exporting') return 'keychain_export_failed' if (failedStep === 'import-provide-profile-path') diff --git a/cli/src/build/onboarding/macos-signing.ts b/cli/src/build/onboarding/macos-signing.ts index ba8fc53601..70f4736d6d 100644 --- a/cli/src/build/onboarding/macos-signing.ts +++ b/cli/src/build/onboarding/macos-signing.ts @@ -12,12 +12,12 @@ import type { Buffer } from 'node:buffer' import type { MobileprovisionDetail } from '../mobileprovision-parser.js' import { spawn } from 'node:child_process' import { randomBytes } from 'node:crypto' -import { existsSync } from 'node:fs' -import { chmod, mkdtemp, readdir, readFile, rename, rm } from 'node:fs/promises' +import { accessSync, constants, existsSync } from 'node:fs' +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import process from 'node:process' -import { fileURLToPath } from 'node:url' import { parseMobileprovisionDetailed } from '../mobileprovision-parser.js' /** Absolute path to the system `security` binary. */ @@ -313,142 +313,178 @@ export function generateP12Passphrase(): string { return randomBytes(32).toString('hex') } -// ─── Native helper (Swift) for single-prompt P12 export ────────────── +// ─── Precompiled helper resolution ──────────────────────────────────── /** - * Output shape from the Swift helper's stdout — always emitted as one line of - * JSON regardless of success or failure. See keychain-export.swift for the - * source of truth. + * Apple Team ID the precompiled helper binaries are signed with. Used in the + * codesign designated-requirement check before executing a package-resolved + * binary. Must match the Developer ID Application cert used by + * .github/workflows/publish_cli_helper.yml. */ -interface SwiftHelperResult { - ok: boolean - // Success fields: - p12Path?: string - p12SizeBytes?: number - identityName?: string - // Failure fields: - errorCode?: 'INVALID_ARGS' | 'NO_IDENTITY' | 'USER_DENIED' | 'EXPORT_FAILED' | 'WRITE_FAILED' | 'INTERNAL' - message?: string - osStatus?: number -} +const CAPGO_APPLE_TEAM_ID = 'UVTJ336J2D' + +const HELPER_PACKAGE_PREFIX = '@capgo/cli-keychain-darwin-' /** - * Resolve the bundled keychain-export.swift source file. - * - * In production (installed npm package) the .swift sits next to dist/index.js - * — copied there by build.mjs. In dev the bundle and source share a parent. - * In tests the file resolves relative to this module's source path. We try - * each in order so the helper Just Works in every environment. + * Map a Node `process.arch` value to the matching helper package name, or + * null when no precompiled helper exists for that architecture. */ -function resolveSwiftSourcePath(): string | null { - const candidates: string[] = [] - - // 1. Production: dist/keychain-export.swift next to the bundled CLI - try { - const here = dirname(fileURLToPath(import.meta.url)) - candidates.push(join(here, 'keychain-export.swift')) - // 2. Dev: src/build/onboarding/keychain-export.swift relative to this module - candidates.push(join(here, '..', '..', '..', 'src', 'build', 'onboarding', 'keychain-export.swift')) - } - catch { - // import.meta.url can throw under certain bundlers — fall through - } - - for (const candidate of candidates) { - if (existsSync(candidate)) - return candidate - } +export function helperPackageName(arch: string): string | null { + if (arch === 'arm64' || arch === 'x64') + return `${HELPER_PACKAGE_PREFIX}${arch}` return null } /** - * Return the path to the cached compiled Swift helper. The cache lives in - * the OS temp dir keyed by CLI version, so: - * - Same CLI version → reuses the cached binary - * - CLI upgrade → triggers a fresh compile - * - macOS `periodic` cleans tmp eventually → triggers a fresh compile - * - * The version is read at runtime from CLI_VERSION env (set by callers/tests) - * or falls back to the package.json version embedded at build time. + * codesign designated requirement asserting: Apple-rooted chain, a + * Developer ID Application leaf cert (OID 1.2.840.113635.100.6.1.13), and + * the given Apple Team ID as the signing team. */ -function compiledHelperPath(): string { - // CLI_VERSION env lets us pin in tests; otherwise use the npm version. - const version = process.env.CAPGO_CLI_VERSION || process.env.npm_package_version || 'dev' - return join(tmpdir(), `capgo-keychain-export-v${version}`) +export function helperSignatureRequirement(teamId: string = CAPGO_APPLE_TEAM_ID): string { + return `=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "${teamId}"` } /** - * Compile keychain-export.swift to the cached path. Returns the path on - * success. Atomic: writes to `.tmp` then renames so a partial compile - * never lands at the cache key. + * Build-time flag controlling whether CAPGO_KEYCHAIN_HELPER_PATH is honored. + * cli/build.mjs `define`s this to `false` for npm release builds — the whole + * env-override branch (including the string literal) is dead-code-eliminated + * from dist/index.js, and CI asserts the string is absent. Dev builds + * (NODE_ENV=development) define it `true`. Running unbundled source (tests, + * `bun src/index.ts`) leaves it undefined → override disabled (fail closed). */ -async function compileSwiftHelper(swiftSrc: string, outPath: string): Promise { - const tmpOut = `${outPath}.${randomBytes(6).toString('hex')}.tmp` - const result = await spawnCapture('swiftc', [ - swiftSrc, - '-framework', - 'Security', - '-O', - '-o', - tmpOut, - ]) - if (result.code !== 0) { - await rm(tmpOut, { force: true }).catch(() => { /* best-effort */ }) - throw new MacOSSigningError( - `Failed to compile keychain-export.swift with swiftc (exit ${result.code}). ` - + `Make sure Xcode Command Line Tools are installed (xcode-select --install). ` - + `Stderr: ${result.stderr.trim() || '(empty)'}`, - ) - } - await chmod(tmpOut, 0o755) - await rename(tmpOut, outPath) - return outPath +declare const __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__: boolean | undefined + +interface CodesignRunner { + (args: readonly string[]): Promise +} + +const defaultCodesignRunner: CodesignRunner = args => spawnCapture('/usr/bin/codesign', args) + +export interface ResolveHelperBinaryOptions { + /** Override `process.arch` (tests). */ + arch?: string + /** + * Override module resolution (tests). Receives the package's + * `package.json` specifier; must return its absolute path or throw. + */ + resolve?: (specifier: string) => string + /** Override the codesign spawn (tests). */ + codesignRunner?: CodesignRunner + /** Force the dev env-override gate (tests). Defaults to the build-time flag. */ + allowEnvOverride?: boolean } /** - * Returns true if the Swift helper is already cached at the version-keyed - * tmp path. Lets the UI decide whether to show a "compiling…" step or skip - * straight to the export step (the cached case is effectively instant). + * Locate the precompiled `helper` binary for this machine and verify its code + * signature chains to Capgo's Developer ID before returning it. * - * Sync + cheap (single existsSync). Safe to call from a React onChange - * handler. + * Resolution order: + * 1. CAPGO_KEYCHAIN_HELPER_PATH (dev builds only — see the build-time flag) + * 2. The arch-matching @capgo/cli-keychain-darwin-* optional dependency + * 3. Hard error with install guidance. There is no compile fallback. */ -export function isHelperCached(): boolean { - return existsSync(compiledHelperPath()) +export async function resolveHelperBinary(options: ResolveHelperBinaryOptions = {}): Promise { + // Env-override gate. The OUTER condition folds to a literal `false` in npm + // release bundles (build.mjs defines __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__ = + // false), so the minifier deletes this whole block — including the + // CAPGO_KEYCHAIN_HELPER_PATH string literal. CI asserts that string is absent + // from dist/index.js. The gate is open when the flag is undefined (unbundled + // source: tests, `bun src/index.ts`) or defined true (dev builds). + if (typeof __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__ === 'undefined' || __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__) { + const allowEnvOverride = options.allowEnvOverride + ?? (typeof __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__ !== 'undefined' && __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__) + if (allowEnvOverride) { + const overridePath = process.env.CAPGO_KEYCHAIN_HELPER_PATH + if (overridePath) { + if (!existsSync(overridePath)) + throw new MacOSSigningError(`CAPGO_KEYCHAIN_HELPER_PATH points to a missing file: ${overridePath}`) + return overridePath + } + } + } + + const arch = options.arch ?? process.arch + const packageName = helperPackageName(arch) + if (!packageName) { + throw new MacOSSigningError( + `No precompiled Capgo keychain helper exists for ${process.platform}/${arch}. ` + + `Supported macOS architectures: arm64, x64.`, + ) + } + + const resolveSpecifier = options.resolve ?? createRequire(import.meta.url).resolve + let packageJsonPath: string + try { + packageJsonPath = resolveSpecifier(`${packageName}/package.json`) + } + catch { + throw new MacOSSigningError( + `The Capgo keychain helper package (${packageName}) is not installed. ` + + `It ships as an optional dependency of @capgo/cli — reinstall without ` + + `--no-optional / --omit=optional, or install it directly: npm i ${packageName}`, + ) + } + + const binaryPath = join(dirname(packageJsonPath), 'helper') + try { + accessSync(binaryPath, constants.X_OK) + } + catch { + throw new MacOSSigningError( + `The keychain helper package (${packageName}) is installed but missing its binary ` + + `(or it is not executable) at ${binaryPath}. Reinstall ${packageName}.`, + ) + } + + await verifyHelperSignature(binaryPath, packageName, options.codesignRunner ?? defaultCodesignRunner) + return binaryPath } /** - * Get or build the Swift helper binary. Caches at `compiledHelperPath()`. + * Verify the binary's code signature against Capgo's designated requirement + * (Apple-rooted chain + Developer ID Application leaf + Capgo Team ID). + * macOS validates the certificate chain and the binary's seal, so this also + * detects post-install tampering. Throws — never executes the binary — on + * any failure. */ -async function ensureSwiftHelper(): Promise { - const cached = compiledHelperPath() - if (existsSync(cached)) - return cached - const src = resolveSwiftSourcePath() - if (!src) { +async function verifyHelperSignature( + binaryPath: string, + packageName: string, + runner: CodesignRunner, +): Promise { + const result = await runner(['--verify', '--strict', '-R', helperSignatureRequirement(), binaryPath]) + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() throw new MacOSSigningError( - 'Could not locate bundled keychain-export.swift source file. ' - + 'This is a packaging bug — please report it.', + `Refusing to run the keychain helper at ${binaryPath}: its code signature ` + + `did not verify as Capgo's (codesign exit ${result.code}${detail ? `: ${detail}` : ''}). ` + + `Reinstall ${packageName} and try again.`, ) } - return compileSwiftHelper(src, cached) } +// ─── Native helper (Swift) for single-prompt P12 export ────────────── + /** - * Pre-compile the Swift helper without doing anything else. Used by the UI - * to show an explicit "compiling helper" step before the export, so the user - * isn't left staring at a spinner that says "look for the macOS dialog" - * while we silently build a binary. - * - * Returns the path to the compiled binary (same as `ensureSwiftHelper`). + * Output shape from the Swift helper's stdout — always emitted as one line of + * JSON regardless of success or failure. See cli-helper/src/helper.swift for + * the source of truth. */ -export async function precompileSwiftHelper(): Promise { - return ensureSwiftHelper() +interface SwiftHelperResult { + ok: boolean + // Success fields: + p12Path?: string + p12SizeBytes?: number + identityName?: string + // Failure fields: + errorCode?: 'INVALID_ARGS' | 'NO_IDENTITY' | 'USER_DENIED' | 'EXPORT_FAILED' | 'WRITE_FAILED' | 'FORBIDDEN_CALLER' | 'INTERNAL' + message?: string + osStatus?: number } /** * Spawn an arbitrary command, capturing stdout/stderr/exit-code. Used for - * `swiftc` and the Swift helper itself. + * `/usr/bin/codesign` and the precompiled Swift helper itself. */ interface SpawnResult { stdout: string @@ -478,10 +514,13 @@ function spawnCapture(command: string, args: readonly string[]): Promise = { 'import-provide-profile-path': 58, 'import-create-profile-only': 60, 'import-export-warning': 70, - 'import-compiling-helper': 72, 'import-exporting': 75, // Create-new sub-flow 'api-key-instructions': 5, @@ -338,7 +336,6 @@ export function getPhaseLabel(step: OnboardingStep): string { case 'import-create-profile-only': return 'Step 3 of 4 · Creating profile via Apple' case 'import-export-warning': - case 'import-compiling-helper': case 'import-exporting': return 'Step 4 of 4 · Export from Keychain' case 'api-key-instructions': diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index b5460fe861..5dcb5b4123 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -47,7 +47,7 @@ import { createP12, DEFAULT_P12_PASSWORD, generateCsr } from '../csr.js' import { mapIosOnboardingError } from '../error-categories.js' import { canUseFilePicker, openFilePicker, openMobileprovisionPicker } from '../file-picker.js' import { parseMobileprovisionDetailed } from '../../mobileprovision-parser.js' -import { bundleIdMatches, exportP12FromKeychain, filterProfilesForApp, isHelperCached, isMacOS, listSigningIdentities, matchIdentitiesToProfiles, precompileSwiftHelper, scanProvisioningProfiles } from '../macos-signing.js' +import { bundleIdMatches, exportP12FromKeychain, filterProfilesForApp, isMacOS, listSigningIdentities, matchIdentitiesToProfiles, scanProvisioningProfiles } from '../macos-signing.js' import { deleteProgress, extractKeyIdFromP8Path, getImportEntryStep, getResumeStep, loadProgress, saveProgress } from '../progress.js' import { getBuildOnboardingRecoveryAdvice } from '../recovery.js' import { createCiSecretEntries, detectCiSecretTargets, getCiSecretRepoLabelAsync, getCiSecretTargetLabel, listExistingCiSecretKeysAsync, uploadCiSecretsAsync } from '../ci-secrets.js' @@ -104,7 +104,6 @@ import { DetectingCiSecretsStep, } from './steps/ios-ci.js' import { - ImportCompilingHelperStep, ImportCreateProfileOnlyStep, ImportDistributionModeStep, ImportExportingStep, @@ -1725,24 +1724,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres })() } - if (step === 'import-compiling-helper') { - ;(async () => { - try { - const startedAt = Date.now() - await precompileSwiftHelper() - if (cancelled) - return - const elapsedMs = Date.now() - startedAt - addLog(`✔ Compiled keychain-export helper in ${elapsedMs}ms`) - setStep('import-exporting') - } - catch (err) { - if (!cancelled) - handleError(err, 'import-compiling-helper') - } - })() - } - if (step === 'import-exporting') { ;(async () => { try { @@ -4260,12 +4241,9 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres dense={dense} onChange={(value) => { if (value === 'go') { - // First run on this CLI version: compile the Swift helper - // explicitly so the user sees what's happening, instead of - // staring at the "look for the macOS dialog" spinner while - // we silently do a 2-3s swiftc invocation. Cache hit skips - // straight to export. - setStep(isHelperCached() ? 'import-exporting' : 'import-compiling-helper') + // Go straight to the export step; the precompiled helper is + // resolved and signature-verified there. + setStep('import-exporting') } else if (value === 'back') { // Back goes to profile selection (distribution mode is now upstream of this step) @@ -4278,9 +4256,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres /> )} - {/* Import: compiling helper (one-time per CLI version) */} - {step === 'import-compiling-helper' && } - {/* Import: exporting (the one Keychain prompt happens here) */} {step === 'import-exporting' && } diff --git a/cli/src/build/onboarding/ui/steps/ios-import.tsx b/cli/src/build/onboarding/ui/steps/ios-import.tsx index 2704b96f82..9e6fa6ed74 100644 --- a/cli/src/build/onboarding/ui/steps/ios-import.tsx +++ b/cli/src/build/onboarding/ui/steps/ios-import.tsx @@ -408,53 +408,6 @@ export const ImportExportWarningStep: FC = ({ iden ) } -// ── import-compiling-helper ─────────────────────────────────────────────────── -// One-time-per-CLI-version compile of the Swift keychain-export helper. -// -// Comfortable: the original spinner, a , then the two full wrapping -// paragraphs (the "~350 lines / wraps Apple's Security framework / compiles via -// swiftc into your OS temp folder" explanation + the "cached for this CLI -// version" note). Dense: the blank line drops and both paragraphs collapse to -// terse single-line notes (the original wrapping paragraphs blew the budget at -// 60 cols). -export interface ImportCompilingHelperStepProps { - dense?: boolean -} - -export const ImportCompilingHelperStep: FC = ({ dense = false }) => { - return ( - - - {!dense && } - {dense - ? ( - - Compiling a ~350-line Swift helper via swiftc; cached per CLI version. - - ) - : ( - - - We ship a small Swift program (~350 lines) that wraps Apple's - Security framework. It compiles via - {' '} - swiftc - {' '} - into your OS temp folder. - - - The result is cached for this CLI version — future runs of - {' '} - build init - {' '} - skip this step. - - - )} - - ) -} - // ── import-exporting ────────────────────────────────────────────────────────── // Spinner + a single short note. Identical in both forms (the note fits the // budget at 60 cols on its own), so no `dense` branch is needed. Restores the diff --git a/cli/test/test-macos-signing.mjs b/cli/test/test-macos-signing.mjs index c18cd1fac1..8ea0718701 100644 --- a/cli/test/test-macos-signing.mjs +++ b/cli/test/test-macos-signing.mjs @@ -1,17 +1,20 @@ import assert from 'node:assert/strict' import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { bundleIdMatches, filterProfilesForApp, generateP12Passphrase, + helperPackageName, + helperSignatureRequirement, isMacOS, matchIdentitiesToProfiles, parseFindIdentityOutput, parseHelperJson, + resolveHelperBinary, scanProvisioningProfiles, } from '../src/build/onboarding/macos-signing.ts' @@ -422,4 +425,137 @@ t('filterProfilesForApp accepts the bare "*" wildcard against any concrete appId assert.equal(filtered.length, 1) }) +// ─── helperPackageName ──────────────────────────────────────────────── + +t('helperPackageName maps arm64 and x64 to scoped packages', () => { + assert.equal(helperPackageName('arm64'), '@capgo/cli-keychain-darwin-arm64') + assert.equal(helperPackageName('x64'), '@capgo/cli-keychain-darwin-x64') +}) + +t('helperPackageName returns null for unsupported architectures', () => { + assert.equal(helperPackageName('ia32'), null) + assert.equal(helperPackageName('ppc64'), null) + assert.equal(helperPackageName(''), null) +}) + +// ─── helperSignatureRequirement ─────────────────────────────────────── + +t('helperSignatureRequirement pins Developer ID + team', () => { + const req = helperSignatureRequirement('ABCDE12345') + assert.ok(req.startsWith('=anchor apple generic')) + assert.ok(req.includes('certificate leaf[field.1.2.840.113635.100.6.1.13]')) + assert.ok(req.includes('certificate leaf[subject.OU] = "ABCDE12345"')) +}) + +// ─── resolveHelperBinary ────────────────────────────────────────────── + +function makeFakeHelper() { + const dir = mkdtempSync(join(tmpdir(), 'capgo-helper-test-')) + const bin = join(dir, 'helper') + writeFileSync(bin, '#!/bin/sh\nexit 0\n') + chmodSync(bin, 0o755) + return { dir, bin } +} + +const okCodesign = async () => ({ stdout: '', stderr: '', code: 0 }) +const failCodesign = async () => ({ stdout: '', stderr: 'test requirement failed', code: 3 }) + +await tAsync('resolveHelperBinary rejects unsupported architectures', async () => { + await assert.rejects( + resolveHelperBinary({ arch: 'ia32', resolve: () => { throw new Error('unreachable') } }), + /No precompiled Capgo keychain helper exists for .*ia32/, + ) +}) + +await tAsync('resolveHelperBinary names the missing package in its error', async () => { + await assert.rejects( + resolveHelperBinary({ arch: 'arm64', resolve: () => { throw new Error('not found') } }), + /@capgo\/cli-keychain-darwin-arm64.*not installed/s, + ) +}) + +await tAsync('resolveHelperBinary returns the binary when signature verifies', async () => { + const { dir, bin } = makeFakeHelper() + try { + const resolved = await resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: okCodesign, + }) + assert.equal(resolved, bin) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('resolveHelperBinary hard-errors when signature verification fails', async () => { + const { dir } = makeFakeHelper() + try { + await assert.rejects( + resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: failCodesign, + }), + /Refusing to run the keychain helper.*did not verify/s, + ) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('resolveHelperBinary errors when resolved binary file is missing', async () => { + const dir = mkdtempSync(join(tmpdir(), 'capgo-helper-test-')) + try { + await assert.rejects( + resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: okCodesign, + }), + /not installed|missing its binary/s, + ) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('env override wins when explicitly allowed (dev builds)', async () => { + const { dir, bin } = makeFakeHelper() + process.env.CAPGO_KEYCHAIN_HELPER_PATH = bin + try { + const resolved = await resolveHelperBinary({ + allowEnvOverride: true, + arch: 'arm64', + resolve: () => { throw new Error('should not be consulted') }, + codesignRunner: failCodesign, // override path skips signature check too + }) + assert.equal(resolved, bin) + } + finally { + delete process.env.CAPGO_KEYCHAIN_HELPER_PATH + rmSync(dir, { recursive: true, force: true }) + } +}) + +await tAsync('env override is ignored by default (release semantics)', async () => { + const { dir, bin } = makeFakeHelper() + process.env.CAPGO_KEYCHAIN_HELPER_PATH = '/nonexistent/evil-binary' + try { + const resolved = await resolveHelperBinary({ + arch: 'arm64', + resolve: () => join(dir, 'package.json'), + codesignRunner: okCodesign, + }) + assert.equal(resolved, bin) + } + finally { + delete process.env.CAPGO_KEYCHAIN_HELPER_PATH + rmSync(dir, { recursive: true, force: true }) + } +}) + process.stdout.write('OK\n') From cbfa23c17f79527ee3e9c48309c4650bed1a043d Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 8 Jun 2026 15:37:56 +0200 Subject: [PATCH 10/26] ci: workflow_dispatch helper publish + release strip assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish_cli_helper.yml: manual workflow_dispatch(version) — build, codesign (hardened runtime), notarize, verify, smoke+gate test, npm publish with provenance, then create the cli-helper- tag + release. publish_cli.yml fails the CLI release if CAPGO_KEYCHAIN_HELPER_PATH leaks into the bundle. --- .github/workflows/publish_cli.yml | 6 ++ .github/workflows/publish_cli_helper.yml | 115 +++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 .github/workflows/publish_cli_helper.yml diff --git a/.github/workflows/publish_cli.yml b/.github/workflows/publish_cli.yml index 311c5db726..5746cf4c67 100644 --- a/.github/workflows/publish_cli.yml +++ b/.github/workflows/publish_cli.yml @@ -35,6 +35,12 @@ 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: | + 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 - name: Generate AI changelog id: changelog uses: mistricky/ccc@v0.2.6 diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml new file mode 100644 index 0000000000..dd1e4e3d9e --- /dev/null +++ b/.github/workflows/publish_cli_helper.yml @@ -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 + - 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; } + - 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 }}" From 36bb529bf9fdb70cee90cb5ddbc103b6f65d24b3 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 8 Jun 2026 15:38:58 +0200 Subject: [PATCH 11/26] docs: fix plan architecture line to workflow_dispatch (was stale 'tag-triggered') --- docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md index 55b66bfcba..96f2c2f954 100644 --- a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -4,7 +4,7 @@ **Goal:** Ship the keychain-export logic as a precompiled, Developer-ID-signed, notarized generic `helper` binary in per-arch npm packages (`@capgo/cli-keychain-darwin-arm64` / `-x64`), invoked as `helper keychain-export …`, verified at runtime by the CLI, with the runtime swiftc compilation path deleted. -**Architecture:** A new `cli-helper/` monorepo dir owns the Swift source (`helper.swift`, a single binary with subcommand dispatch) and two binary-only npm packages. A tag-triggered (`cli-helper-X.Y.Z`) GitHub Actions workflow on `macos-latest` builds both arch slices, codesigns with hardened runtime, notarizes via `notarytool`, and publishes with npm provenance. The CLI resolves the arch-matching package at runtime, verifies its code signature against Capgo's Apple Team ID via a `codesign` designated-requirement check, and hard-errors with install guidance when anything is missing — no compile fallback. A dev-only `CAPGO_KEYCHAIN_HELPER_PATH` env override is dead-code-eliminated from release builds via a `Bun.build` define. The sensitive `keychain-export` subcommand carries an anti-footgun gate (internal handshake flag + non-TTY stdout) documented as a non-security-boundary in `cli-helper/SECURITY.md`. +**Architecture:** A new `cli-helper/` monorepo dir owns the Swift source (`helper.swift`, a single binary with subcommand dispatch) and two binary-only npm packages. A manually dispatched (`workflow_dispatch` with a `version` input) GitHub Actions workflow on `macos-latest` builds both arch slices, codesigns with hardened runtime, notarizes via `notarytool`, publishes with npm provenance, and creates the `cli-helper-X.Y.Z` tag + release. The CLI resolves the arch-matching package at runtime, verifies its code signature against Capgo's Apple Team ID via a `codesign` designated-requirement check, and hard-errors with install guidance when anything is missing — no compile fallback. A dev-only `CAPGO_KEYCHAIN_HELPER_PATH` env override is dead-code-eliminated from release builds via a `Bun.build` define. The sensitive `keychain-export` subcommand carries an anti-footgun gate (internal handshake flag + non-TTY stdout) documented as a non-security-boundary in `cli-helper/SECURITY.md`. **Tech Stack:** Swift (Security framework), Bun build pipeline, Node `createRequire` resolution, GitHub Actions (macos-latest), `codesign`/`notarytool`, npm provenance. From 8ec2181b30df31bfa6f86d2c0cf9f38461be51ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 14:10:45 +0000 Subject: [PATCH 12/26] fix: drop stale compile-helper test + address review nits The runtime swiftc compile path was deleted in this PR, but tests/onboarding-error-categories.unit.test.ts still asserted the now-gone `import-compiling-helper` step maps to `keychain_helper_compile_failed`, failing the "Run tests" suite. Remove the obsolete case and the orphaned `keychain_helper_compile_failed` category (no longer produced anywhere). Also address valid CodeRabbit review comments: - publish_cli_helper.yml: select helper binary by `uname -m` in the smoke + gate tests instead of hardcoding helper-arm64 (portable to x64 runners). - publish_cli.yml: fail explicitly if cli/dist/index.js is missing before the env-override grep gate, so the release check can't be silently bypassed. - cli-helper npm manifests: use valid SPDX identifier `Apache-2.0`. - sign-and-notarize.sh: fail fast if CAPGO_APPLE_TEAM_ID drifts from the team ID the CLI runtime verifier enforces (single source of truth). --- .github/workflows/publish_cli.yml | 4 ++++ .github/workflows/publish_cli_helper.yml | 16 ++++++++++++++-- cli-helper/npm/darwin-arm64/package.json | 2 +- cli-helper/npm/darwin-x64/package.json | 2 +- cli-helper/scripts/sign-and-notarize.sh | 10 ++++++++++ cli/src/build/onboarding/types.ts | 1 - tests/onboarding-error-categories.unit.test.ts | 4 ---- 7 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish_cli.yml b/.github/workflows/publish_cli.yml index 5746cf4c67..56f3111cff 100644 --- a/.github/workflows/publish_cli.yml +++ b/.github/workflows/publish_cli.yml @@ -37,6 +37,10 @@ jobs: 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 diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index dd1e4e3d9e..9628527dae 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -77,8 +77,14 @@ jobs: 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/helper-arm64" ;; + x86_64) helper="./cli-helper/dist/helper-x64" ;; + *) echo "::error::unsupported runner arch: $arch"; exit 1 ;; + esac set +e - out=$(./cli-helper/dist/helper-arm64) + out=$("$helper") code=$? set -e [ "$code" -ne 0 ] || { echo "::error::expected non-zero exit"; exit 1; } @@ -86,8 +92,14 @@ jobs: || { 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/helper-arm64" ;; + x86_64) helper="./cli-helper/dist/helper-x64" ;; + *) echo "::error::unsupported runner arch: $arch"; exit 1 ;; + esac set +e - out=$(./cli-helper/dist/helper-arm64 keychain-export --sha1 "$(printf 'a%.0s' {1..40})" --output /tmp/x.p12 --passphrase p | cat) + 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; } diff --git a/cli-helper/npm/darwin-arm64/package.json b/cli-helper/npm/darwin-arm64/package.json index 5e0b65816e..3eb1c5ab42 100644 --- a/cli-helper/npm/darwin-arm64/package.json +++ b/cli-helper/npm/darwin-arm64/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/Cap-go/capgo.git", "directory": "cli-helper" }, - "license": "Apache 2.0", + "license": "Apache-2.0", "os": ["darwin"], "cpu": ["arm64"], "files": ["helper"] diff --git a/cli-helper/npm/darwin-x64/package.json b/cli-helper/npm/darwin-x64/package.json index 6a2d79bbbc..c7e27f3804 100644 --- a/cli-helper/npm/darwin-x64/package.json +++ b/cli-helper/npm/darwin-x64/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/Cap-go/capgo.git", "directory": "cli-helper" }, - "license": "Apache 2.0", + "license": "Apache-2.0", "os": ["darwin"], "cpu": ["x64"], "files": ["helper"] diff --git a/cli-helper/scripts/sign-and-notarize.sh b/cli-helper/scripts/sign-and-notarize.sh index 579d31edc0..351091ab87 100755 --- a/cli-helper/scripts/sign-and-notarize.sh +++ b/cli-helper/scripts/sign-and-notarize.sh @@ -14,6 +14,16 @@ cd "$(dirname "$0")/.." : "${DEVELOPER_ID_IDENTITY:?}" "${CAPGO_APPLE_TEAM_ID:?}" "${APPLE_KEY_ID:?}" "${APPLE_ISSUER_ID:?}" "${APPLE_KEY_PATH:?}" +# Single source of truth: the Team ID the CLI's runtime verifier enforces +# (CAPGO_APPLE_TEAM_ID in cli/src/build/onboarding/macos-signing.ts). If the +# APPLE_TEAM_ID secret drifts from this value the helpers would sign and notarize +# fine here but be rejected at runtime — so fail fast before signing. +EXPECTED_TEAM_ID="UVTJ336J2D" +if [ "$CAPGO_APPLE_TEAM_ID" != "$EXPECTED_TEAM_ID" ]; then + echo "::error::CAPGO_APPLE_TEAM_ID ('$CAPGO_APPLE_TEAM_ID') != expected '$EXPECTED_TEAM_ID' enforced by the CLI runtime verifier (macos-signing.ts). Update both in lockstep." >&2 + exit 1 +fi + 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"'"' # Stable code-signing identifier. macOS keys the Keychain "Always Allow" grant diff --git a/cli/src/build/onboarding/types.ts b/cli/src/build/onboarding/types.ts index 8a53edb282..a1a77285b3 100644 --- a/cli/src/build/onboarding/types.ts +++ b/cli/src/build/onboarding/types.ts @@ -105,7 +105,6 @@ export type OnboardingErrorCategory // Import-existing flow (keychain / provisioning profile imports) | 'keychain_no_identities' | 'keychain_export_failed' - | 'keychain_helper_compile_failed' | 'profile_no_match' | 'profile_read_failed' | 'unknown' diff --git a/tests/onboarding-error-categories.unit.test.ts b/tests/onboarding-error-categories.unit.test.ts index 227a5e5207..138fe2d7be 100644 --- a/tests/onboarding-error-categories.unit.test.ts +++ b/tests/onboarding-error-categories.unit.test.ts @@ -38,10 +38,6 @@ describe('mapIosOnboardingError', () => { expect(mapIosOnboardingError(new Error('no identities'), 'import-scanning')).toBe('keychain_no_identities') }) - it.concurrent('maps import-compiling-helper failures to keychain_helper_compile_failed', () => { - expect(mapIosOnboardingError(new Error('compile failed'), 'import-compiling-helper')).toBe('keychain_helper_compile_failed') - }) - it.concurrent('maps import-exporting failures to keychain_export_failed', () => { expect(mapIosOnboardingError(new Error('wrong password'), 'import-exporting')).toBe('keychain_export_failed') }) From 41ecf9c86544bb4c6ee9e93a2b6757f8af24da38 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 9 Jun 2026 12:09:20 +0200 Subject: [PATCH 13/26] feat(cli-helper): ship helper as hidden Capgo.app bundle Wrap the keychain helper in a signed, notarized, stapled Capgo.app (LSUIElement = no Dock icon) so the macOS Keychain export prompts show the Capgo name + icon, and CFBundleIdentifier app.capgo.cli.helper keys the 'Always Allow' grant across releases. Per-arch packages ship Capgo.app (files:[Capgo.app]); the CLI verifies the bundle signature and execs Contents/MacOS/capgo. build.sh assembles the bundle (Info.plist + Capgo.icns from the iOS app icon) and bakes the version before signing; sign-and-notarize staples the ticket. Resolver + tests + CI updated for the bundle layout. --- .github/workflows/publish_cli_helper.yml | 20 ++++--- .gitignore | 4 +- cli-helper/README.md | 48 +++++++++++----- cli-helper/assets/Capgo.icns | Bin 0 -> 110348 bytes cli-helper/assets/Info.plist.template | 30 ++++++++++ cli-helper/npm/darwin-arm64/package.json | 2 +- cli-helper/npm/darwin-x64/package.json | 2 +- cli-helper/scripts/build.sh | 40 ++++++++++--- cli-helper/scripts/prepare-publish.mjs | 38 +++++++----- cli-helper/scripts/sign-and-notarize.sh | 54 +++++++----------- cli/src/build/onboarding/macos-signing.ts | 17 ++++-- cli/test/test-macos-signing.mjs | 10 +++- .../2026-06-06-keychain-helper-precompile.md | 8 +++ ...06-06-keychain-helper-precompile-design.md | 12 ++++ 14 files changed, 199 insertions(+), 86 deletions(-) create mode 100644 cli-helper/assets/Capgo.icns create mode 100644 cli-helper/assets/Info.plist.template diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index 9628527dae..69aba92eca 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -37,8 +37,8 @@ jobs: 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: 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 }} @@ -79,8 +79,8 @@ jobs: run: | arch="$(uname -m)" case "$arch" in - arm64) helper="./cli-helper/dist/helper-arm64" ;; - x86_64) helper="./cli-helper/dist/helper-x64" ;; + 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 @@ -94,8 +94,8 @@ jobs: run: | arch="$(uname -m)" case "$arch" in - arm64) helper="./cli-helper/dist/helper-arm64" ;; - x86_64) helper="./cli-helper/dist/helper-x64" ;; + 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 @@ -115,13 +115,17 @@ jobs: 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/helper-arm64 - cli-helper/dist/helper-x64 + cli-helper/dist/Capgo-arm64.zip + cli-helper/dist/Capgo-x64.zip make_latest: false token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" diff --git a/.gitignore b/.gitignore index 4b3211736c..6785444169 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,6 @@ graphify-out/*.svg graphify-out/*.graphml graphify-out/cypher.txt -# Capgo CLI keychain helper — locally built binaries +# Capgo CLI keychain helper — locally built bundles cli-helper/dist/ -cli-helper/npm/*/helper +cli-helper/npm/*/Capgo.app diff --git a/cli-helper/README.md b/cli-helper/README.md index 9440156374..3cb3557eff 100644 --- a/cli-helper/README.md +++ b/cli-helper/README.md @@ -1,38 +1,60 @@ # Capgo CLI keychain helper -Small Swift program (Security framework only) shipped as one generic binary -named `helper`. Today it has a single subcommand: +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`: - helper keychain-export --sha1 <40-hex> --output \ - --passphrase --invoked-by capgo-cli + Capgo.app/Contents/MacOS/capgo keychain-export \ + --sha1 <40-hex> --output --passphrase --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: +## 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. 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. +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: +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 subcommand wrapper, so point it at a -binary you built and trust. +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 diff --git a/cli-helper/assets/Capgo.icns b/cli-helper/assets/Capgo.icns new file mode 100644 index 0000000000000000000000000000000000000000..363ac3b39baa6fc0106e54e2ee5d875c5434c5f8 GIT binary patch literal 110348 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

tHiYsR^YgVN(GC7hWQ)j9 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%0Ap4N}mhq@`k+9Kp;e!f2*kL|rLe($)v-HG( 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 literal 0 HcmV?d00001 diff --git a/cli-helper/assets/Info.plist.template b/cli-helper/assets/Info.plist.template new file mode 100644 index 0000000000..c5a0e76932 --- /dev/null +++ b/cli-helper/assets/Info.plist.template @@ -0,0 +1,30 @@ + + + + + CFBundleName + Capgo + CFBundleDisplayName + Capgo + CFBundleIdentifier + app.capgo.cli.helper + CFBundleExecutable + capgo + CFBundleIconFile + Capgo + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + __MINOS__ + LSUIElement + + NSHumanReadableCopyright + © Capgo + + diff --git a/cli-helper/npm/darwin-arm64/package.json b/cli-helper/npm/darwin-arm64/package.json index 3eb1c5ab42..e487e1a9e6 100644 --- a/cli-helper/npm/darwin-arm64/package.json +++ b/cli-helper/npm/darwin-arm64/package.json @@ -10,5 +10,5 @@ "license": "Apache-2.0", "os": ["darwin"], "cpu": ["arm64"], - "files": ["helper"] + "files": ["Capgo.app"] } diff --git a/cli-helper/npm/darwin-x64/package.json b/cli-helper/npm/darwin-x64/package.json index c7e27f3804..3212508233 100644 --- a/cli-helper/npm/darwin-x64/package.json +++ b/cli-helper/npm/darwin-x64/package.json @@ -10,5 +10,5 @@ "license": "Apache-2.0", "os": ["darwin"], "cpu": ["x64"], - "files": ["helper"] + "files": ["Capgo.app"] } diff --git a/cli-helper/scripts/build.sh b/cli-helper/scripts/build.sh index b8f854845a..47c687fed0 100755 --- a/cli-helper/scripts/build.sh +++ b/cli-helper/scripts/build.sh @@ -1,13 +1,39 @@ #!/usr/bin/env bash -# Compile helper for both macOS architectures into cli-helper/dist/. +# 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 -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 + +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 diff --git a/cli-helper/scripts/prepare-publish.mjs b/cli-helper/scripts/prepare-publish.mjs index a4789b08f7..e970e041b7 100644 --- a/cli-helper/scripts/prepare-publish.mjs +++ b/cli-helper/scripts/prepare-publish.mjs @@ -1,9 +1,11 @@ -// Stamp the release version into both npm manifests and copy the signed -// binaries into their package dirs. +// Stamp the release version into both npm manifests and copy the signed, +// stapled Capgo.app bundles into their package dirs. // Usage: node cli-helper/scripts/prepare-publish.mjs -// 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' +// The MUST match the version baked into each bundle's Info.plist by +// build.sh (we assert it) so the published package version, the npm tarball, +// and the bundle's CFBundleShortVersionString all agree. Fails fast on a +// malformed version or a missing bundle so a bad tag can never publish. +import { chmodSync, cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' @@ -16,18 +18,28 @@ if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { } 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`) + const src = join(root, 'dist', arch, 'Capgo.app') + const innerExec = join(src, 'Contents', 'MacOS', 'capgo') + if (!existsSync(innerExec)) { + console.error(`Missing bundle ${src} — run build.sh + sign-and-notarize.sh first`) process.exit(1) } + // Assert the bundle was built at this version (Info.plist is sealed, so this + // also confirms we're shipping a bundle built for this exact release). + const plist = readFileSync(join(src, 'Contents', 'Info.plist'), 'utf-8') + if (!plist.includes(`${version}`)) { + console.error(`Bundle ${src} Info.plist version != ${version} — rebuild with build.sh ${version}`) + 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) + writeFileSync(manifestPath, `${JSON.stringify({ ...manifest, version }, null, 2)}\n`) + + const dest = join(pkgDir, 'Capgo.app') + rmSync(dest, { recursive: true, force: true }) + cpSync(src, dest, { recursive: true }) + chmodSync(join(dest, 'Contents', 'MacOS', 'capgo'), 0o755) console.log(`Prepared ${manifest.name}@${version}`) } diff --git a/cli-helper/scripts/sign-and-notarize.sh b/cli-helper/scripts/sign-and-notarize.sh index 351091ab87..160a4c5502 100755 --- a/cli-helper/scripts/sign-and-notarize.sh +++ b/cli-helper/scripts/sign-and-notarize.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash -# Codesign (hardened runtime + timestamp) and notarize both helper binaries, -# then verify each against the same designated requirement the CLI enforces +# Sign (Developer ID + hardened runtime), notarize, and STAPLE each Capgo.app +# bundle, then verify against the same designated requirement the CLI enforces # at runtime — a cert/team mismatch fails the release, not the user. # +# The bundle identifier (app.capgo.cli.helper) comes from Info.plist; it is part +# of the designated requirement and keys the Keychain "Always Allow" grant, so +# it must never change. Unlike a bare executable, an app bundle can be stapled, +# so the notarization ticket travels with the bundle. +# # Required env: # DEVELOPER_ID_IDENTITY codesign identity, e.g. "Developer ID Application: ()" # CAPGO_APPLE_TEAM_ID 10-char Apple Team ID (must match macos-signing.ts) @@ -14,50 +19,35 @@ cd "$(dirname "$0")/.." : "${DEVELOPER_ID_IDENTITY:?}" "${CAPGO_APPLE_TEAM_ID:?}" "${APPLE_KEY_ID:?}" "${APPLE_ISSUER_ID:?}" "${APPLE_KEY_PATH:?}" -# Single source of truth: the Team ID the CLI's runtime verifier enforces -# (CAPGO_APPLE_TEAM_ID in cli/src/build/onboarding/macos-signing.ts). If the -# APPLE_TEAM_ID secret drifts from this value the helpers would sign and notarize -# fine here but be rejected at runtime — so fail fast before signing. -EXPECTED_TEAM_ID="UVTJ336J2D" -if [ "$CAPGO_APPLE_TEAM_ID" != "$EXPECTED_TEAM_ID" ]; then - echo "::error::CAPGO_APPLE_TEAM_ID ('$CAPGO_APPLE_TEAM_ID') != expected '$EXPECTED_TEAM_ID' enforced by the CLI runtime verifier (macos-signing.ts). Update both in lockstep." >&2 - exit 1 -fi - 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"'"' -# 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" + app="dist/$arch/Capgo.app" + echo "── Signing $app" + codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_IDENTITY" "$app" - echo "── Notarizing $bin" - ditto -c -k "$bin" "$bin.zip" - out=$(xcrun notarytool submit "$bin.zip" \ + echo "── Notarizing $app" + ditto -c -k --keepParent "$app" "dist/$arch/Capgo.zip" + out=$(xcrun notarytool submit "dist/$arch/Capgo.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 - echo "Notarization failed for $bin (status: ${status:-unknown})" >&2 + echo "Notarization failed for $app (status: ${status:-unknown})" >&2 if [ -n "$id" ]; then 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 "── Notarization accepted ($id); stapling" + xcrun stapler staple "$app" + rm -f "dist/$arch/Capgo.zip" - echo "── Verifying $bin" - codesign --verify --strict "$bin" - codesign --verify --strict -R "$REQUIREMENT" "$bin" + echo "── Verifying $app" + codesign --verify --strict --deep "$app" + codesign --verify --strict -R "$REQUIREMENT" "$app" + spctl -a -t exec -vv "$app" 2>&1 | head -3 || true done -echo "All binaries signed, notarized, and verified." +echo "All bundles signed, notarized, stapled, and verified." diff --git a/cli/src/build/onboarding/macos-signing.ts b/cli/src/build/onboarding/macos-signing.ts index 70f4736d6d..8df2d7ad7c 100644 --- a/cli/src/build/onboarding/macos-signing.ts +++ b/cli/src/build/onboarding/macos-signing.ts @@ -425,19 +425,24 @@ export async function resolveHelperBinary(options: ResolveHelperBinaryOptions = ) } - const binaryPath = join(dirname(packageJsonPath), 'helper') + // The package ships a signed `Capgo.app` bundle (a directory). We verify the + // bundle's code signature, then run the executable inside it. The bundle — + // not a bare binary — is what gives the macOS Keychain prompts the "Capgo" + // name + icon and keys the "Always Allow" grant to CFBundleIdentifier. + const bundlePath = join(dirname(packageJsonPath), 'Capgo.app') + const execPath = join(bundlePath, 'Contents', 'MacOS', 'capgo') try { - accessSync(binaryPath, constants.X_OK) + accessSync(execPath, constants.X_OK) } catch { throw new MacOSSigningError( - `The keychain helper package (${packageName}) is installed but missing its binary ` - + `(or it is not executable) at ${binaryPath}. Reinstall ${packageName}.`, + `The keychain helper package (${packageName}) is installed but its Capgo.app ` + + `bundle is missing or not executable at ${execPath}. Reinstall ${packageName}.`, ) } - await verifyHelperSignature(binaryPath, packageName, options.codesignRunner ?? defaultCodesignRunner) - return binaryPath + await verifyHelperSignature(bundlePath, packageName, options.codesignRunner ?? defaultCodesignRunner) + return execPath } /** diff --git a/cli/test/test-macos-signing.mjs b/cli/test/test-macos-signing.mjs index 8ea0718701..8c89535310 100644 --- a/cli/test/test-macos-signing.mjs +++ b/cli/test/test-macos-signing.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -449,9 +449,13 @@ t('helperSignatureRequirement pins Developer ID + team', () => { // ─── resolveHelperBinary ────────────────────────────────────────────── +// Builds a fake package dir containing a Capgo.app/Contents/MacOS/capgo bundle. +// `bin` is the inner executable path resolveHelperBinary returns. function makeFakeHelper() { const dir = mkdtempSync(join(tmpdir(), 'capgo-helper-test-')) - const bin = join(dir, 'helper') + const macosDir = join(dir, 'Capgo.app', 'Contents', 'MacOS') + mkdirSync(macosDir, { recursive: true }) + const bin = join(macosDir, 'capgo') writeFileSync(bin, '#!/bin/sh\nexit 0\n') chmodSync(bin, 0o755) return { dir, bin } @@ -515,7 +519,7 @@ await tAsync('resolveHelperBinary errors when resolved binary file is missing', resolve: () => join(dir, 'package.json'), codesignRunner: okCodesign, }), - /not installed|missing its binary/s, + /not installed|bundle is missing or not executable/s, ) } finally { diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md index 96f2c2f954..25480bd8af 100644 --- a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -10,6 +10,14 @@ **Spec:** `docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md` +> **AMENDMENT (implemented):** the helper now ships inside a hidden `Capgo.app` +> bundle (`LSUIElement`), not a bare `helper` binary. Tasks below that say +> `files: ["helper"]`, copy/resolve a bare `helper`, or sign a bare binary now +> operate on `Capgo.app` (inner exec `Contents/MacOS/capgo`); `build.sh` takes a +> version arg and bakes Info.plist; signing also staples. The branded "Capgo" +> Keychain prompt + icon is the payoff. See `cli-helper/README.md` and the spec +> amendment for the current shape. + **⚠️ Sequencing constraint:** Task 9 (adding `optionalDependencies` to `cli/package.json`) MUST NOT merge to main until helper 1.0.0 is live on npm (Task 13). Otherwise `bun install --frozen-lockfile` in every CI job fails resolving the not-yet-published packages. Tasks 1–8 and 10–11 are safe to merge any time (the helper workflow only runs on manual `workflow_dispatch`, never automatically). The CLI release (Task 13) comes last. **⚠️ User input needed during execution:** diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index 2b2ce9d03b..b802adfa5c 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -25,6 +25,18 @@ macOS-only npm packages, resolved and signature-verified at runtime by the CLI. The runtime swiftc compilation path is **removed entirely** — the CLI either runs a verified Capgo-signed binary or fails with clear guidance. +> **AMENDMENT (implemented after design):** the helper now ships **inside a +> hidden `Capgo.app` bundle** (`LSUIElement` agent — no Dock icon), not as a +> bare `helper` binary. Everywhere this spec says "the binary", read "the +> bundle's inner executable at `Capgo.app/Contents/MacOS/capgo`"; the package +> `files` is `["Capgo.app"]`; runtime resolution returns that inner exec after +> verifying the **bundle's** code signature; CI signs + notarizes + **staples** +> the bundle. The win: macOS Keychain prompts during export show the **Capgo +> name + icon** (signed bundles only), and `CFBundleIdentifier = +> app.capgo.cli.helper` keys the "Always Allow" grant. This realizes the +> bundle-packaging half of "Future: native notifications & UI" below; only the +> notification/window *code* remains future. See `cli-helper/README.md`. + ## Decisions (settled during brainstorming) | Decision | Choice | From 5630bf20f74f22424acfc11bdddf78bbf75dab9e Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 9 Jun 2026 13:13:58 +0200 Subject: [PATCH 14/26] chore: address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - publish_cli_helper.yml: persist-credentials: false on checkout (privileged signing/publish workflow) - sign-and-notarize.sh: guard CAPGO_APPLE_TEAM_ID == the CLI verifier's UVTJ336J2D (catches secret drift at release, not user runtime); stop masking Gatekeeper (spctl) failures with || true - docs: @latest in customer-facing CLI example; language tags on fenced blocks; mark the .app bundle as implemented in the Future section (Contents/MacOS/ path kept — it's the real bundle dir, not a macOS typo) --- .github/workflows/publish_cli_helper.yml | 2 ++ cli-helper/scripts/sign-and-notarize.sh | 19 ++++++++++++++++++- .../2026-06-06-keychain-helper-precompile.md | 4 ++-- ...06-06-keychain-helper-precompile-design.md | 19 ++++++++++++------- 4 files changed, 34 insertions(+), 10 deletions(-) mode change 100755 => 100644 cli-helper/scripts/sign-and-notarize.sh diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index 69aba92eca..66d79907f9 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v6 with: diff --git a/cli-helper/scripts/sign-and-notarize.sh b/cli-helper/scripts/sign-and-notarize.sh old mode 100755 new mode 100644 index 160a4c5502..16fcb1bba9 --- a/cli-helper/scripts/sign-and-notarize.sh +++ b/cli-helper/scripts/sign-and-notarize.sh @@ -19,6 +19,18 @@ cd "$(dirname "$0")/.." : "${DEVELOPER_ID_IDENTITY:?}" "${CAPGO_APPLE_TEAM_ID:?}" "${APPLE_KEY_ID:?}" "${APPLE_ISSUER_ID:?}" "${APPLE_KEY_PATH:?}" +# Single source of truth: the runtime verifier (CAPGO_APPLE_TEAM_ID in +# cli/src/build/onboarding/macos-signing.ts) accepts ONLY this team. If the CI +# secret drifts from it, the release would succeed but every shipped helper +# would be rejected at runtime. Fail fast here instead. Keep in sync with +# macos-signing.ts. +EXPECTED_TEAM_ID="UVTJ336J2D" +if [ "$CAPGO_APPLE_TEAM_ID" != "$EXPECTED_TEAM_ID" ]; then + echo "CAPGO_APPLE_TEAM_ID ($CAPGO_APPLE_TEAM_ID) != $EXPECTED_TEAM_ID expected by the CLI verifier." >&2 + echo "Fix the APPLE_TEAM_ID secret or update macos-signing.ts; refusing to sign a helper users can't run." >&2 + exit 1 +fi + 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"'"' for arch in arm64 x64; do @@ -48,6 +60,11 @@ for arch in arm64 x64; do echo "── Verifying $app" codesign --verify --strict --deep "$app" codesign --verify --strict -R "$REQUIREMENT" "$app" - spctl -a -t exec -vv "$app" 2>&1 | head -3 || true + if ! spctl_out=$(spctl -a -t exec -vv "$app" 2>&1); then + echo "$spctl_out" | head -5 >&2 + echo "Gatekeeper assessment (spctl) failed for $app" >&2 + exit 1 + fi + echo "$spctl_out" | head -3 done echo "All bundles signed, notarized, stapled, and verified." diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md index 25480bd8af..09f5baca02 100644 --- a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -352,7 +352,7 @@ chmod +x cli-helper/scripts/build.sh Run: `bash cli-helper/scripts/build.sh` Expected output ends with: -``` +```text dist/helper-arm64: Mach-O 64-bit executable arm64 dist/helper-x64: Mach-O 64-bit executable x86_64 ``` @@ -1359,7 +1359,7 @@ cd "$(mktemp -d)" && npm init -y >/dev/null && npm i @capgo/cli node -e "const {execFileSync}=require('node:child_process');const p='node_modules/@capgo/cli-keychain-darwin-arm64/helper';execFileSync('/usr/bin/codesign',['--verify','--strict','-R','=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = \"'+process.env.TEAM_ID+'\"',p]);console.log('signature OK')" TEAM_ID= ``` -Then run the real onboarding export flow once (`npx @capgo/cli build init` → iOS → import existing) and confirm: no "compiling helper" step, successful P12 export, two Keychain prompts max. If an Intel Mac or Rosetta terminal is available, repeat there (x64 package). +Then run the real onboarding export flow once (`npx @capgo/cli@latest build init` → iOS → import existing) and confirm: no "compiling helper" step, successful P12 export, two Keychain prompts max. If an Intel Mac or Rosetta terminal is available, repeat there (x64 package). - [ ] **Step 5: Regression — `--no-optional` produces the guidance error** diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index b802adfa5c..d6279812c7 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -58,7 +58,7 @@ either runs a verified Capgo-signed binary or fails with clear guidance. ### Repository layout -``` +```text cli-helper/ ├── src/ │ └── helper.swift # moved+renamed from cli/src/build/onboarding/keychain-export.swift @@ -137,7 +137,7 @@ model). The JSON stdout contract is unchanged. Before executing a package-resolved binary (step 2), the CLI verifies it was signed by the Capgo team using a `codesign` designated-requirement check: -``` +```text codesign --verify --strict -R '=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] @@ -363,11 +363,16 @@ To do: ## Future: native notifications & UI (.app bundle) -Not built now (YAGNI — the helper is headless and the CLI owns the terminal -UX). Recorded so the path is understood and the one cheap-now decision is -captured. Today's helper ships as a bare signed executable; a later subcommand -that needs a macOS notification or a small SwiftUI panel would graduate it to a -`Capgo.app` bundle. +> **STATUS:** The `Capgo.app` bundle described here is **now implemented** (see +> the AMENDMENT under "Goal"). The helper ships *inside* the signed bundle +> today. What remains future is only the **notification / SwiftUI window code** — +> the bundle that would host it already exists. Read the paragraphs below as the +> rationale for the bundle (delivered) plus the not-yet-built UI on top of it. + +Recorded so the path is understood and the cheap-now decisions are captured. +The helper ships inside a signed, hidden (`LSUIElement`) `Capgo.app`; a later +subcommand that needs a macOS notification or a small SwiftUI panel builds on +that existing bundle. **Why a bundle is required for notifications.** `UNUserNotificationCenter` requires a bundle identifier; a bare executable has none and the call fails. A From b3c8485e5ec0e6d424b724cccbde0d8bc994f5e0 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 9 Jun 2026 13:21:08 +0200 Subject: [PATCH 15/26] ci(helper): drop GITHUB_TOKEN to contents: read (release uses PAT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag + GitHub release authenticate via PERSONAL_ACCESS_TOKEN, npm publish via NPM_TOKEN, and provenance via id-token. The automatic GITHUB_TOKEN is only used by checkout, which needs read. Tighten contents: write → read (least privilege). --- .github/workflows/publish_cli_helper.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index 66d79907f9..5c4af6b10d 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -19,7 +19,10 @@ jobs: name: Build, sign, notarize, publish keychain helper timeout-minutes: 45 permissions: - contents: write + # 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 From 5f96c4cf1b9ad135fa4cddd348dcf7f8d275209a Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 9 Jun 2026 13:42:45 +0200 Subject: [PATCH 16/26] ci(helper): also trigger on cli-helper-* tag (testable from a branch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_dispatch can't run from a non-default branch, so add a push:tags trigger — pushing a cli-helper-X.Y.Z tag runs the pipeline from that commit, letting the whole thing be tested on a PR branch without merging. Version comes from the dispatch input or the tag name. The tag+release are created with the built-in GITHUB_TOKEN (loop-safe: GITHUB_TOKEN tags don't re-trigger push:tags), so contents: write returns and the PAT is dropped. --- .github/workflows/publish_cli_helper.yml | 27 ++++++++++++++----- cli-helper/README.md | 26 +++++++++++++----- ...06-06-keychain-helper-precompile-design.md | 2 +- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index 5c4af6b10d..0d8aa8880c 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -5,11 +5,17 @@ concurrency: cancel-in-progress: true on: + # Normal release: the deliberate button (choose the version). workflow_dispatch: inputs: version: description: "Helper version to publish, e.g. 1.0.0 (no 'cli-helper-' prefix)" required: true + # Also fires on a pushed cli-helper-X.Y.Z tag — lets you test the whole + # pipeline from a PR branch (tag a branch commit) without merging first. + push: + tags: + - "cli-helper-[0-9]*" permissions: {} @@ -19,9 +25,10 @@ jobs: 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 + # The workflow creates the cli-helper- tag + GitHub release with + # the built-in GITHUB_TOKEN. That is loop-safe: a GITHUB_TOKEN-created tag + # does NOT re-trigger the push:tags trigger above (a PAT-created tag would). + contents: write # required for `npm publish --provenance` (mints the OIDC token). id-token: write steps: @@ -34,10 +41,16 @@ jobs: with: node-version: 24.x registry-url: https://registry.npmjs.org - - name: Validate + capture version + - name: Resolve + validate version id: version run: | - v="${{ github.event.inputs.version }}" + # Two ways in: the workflow_dispatch button (version input), or + # pushing a cli-helper-X.Y.Z tag (e.g. to test from a PR branch). + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + v="${{ github.event.inputs.version }}" + else + v="${GITHUB_REF_NAME#cli-helper-}" + fi 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 @@ -133,4 +146,6 @@ jobs: cli-helper/dist/Capgo-arm64.zip cli-helper/dist/Capgo-x64.zip make_latest: false - token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" + # Built-in token (NOT a PAT): a GITHUB_TOKEN-created tag does not + # re-trigger this workflow's push:tags trigger, avoiding a publish loop. + token: "${{ github.token }}" diff --git a/cli-helper/README.md b/cli-helper/README.md index 3cb3557eff..f5c1c53a44 100644 --- a/cli-helper/README.md +++ b/cli-helper/README.md @@ -58,13 +58,25 @@ 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. +The workflow has two triggers: + +- **Normal release** — the deliberate button: GitHub Actions UI → "Run + workflow" → enter the version, or `gh workflow run publish_cli_helper.yml -f + version=X.Y.Z`. (`workflow_dispatch` requires the workflow to be on the + default branch.) +- **Test from a branch / manual release** — push a `cli-helper-X.Y.Z` tag. + The workflow runs the version of itself *at that tagged commit*, so you can + validate the whole pipeline from a PR branch without merging: + + git tag cli-helper-1.0.0-rc.1 # tags your current HEAD + git push origin cli-helper-1.0.0-rc.1 + + (Use a `-rc.N` prerelease for tests — npm versions are immutable. The Apple + secrets below must exist for the signing steps to pass.) + +Either way it builds, signs, notarizes, staples, smoke-tests, publishes both +packages with npm provenance, and creates the `cli-helper-X.Y.Z` tag + GitHub +release. 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 diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index d6279812c7..3455e341e8 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -47,7 +47,7 @@ either runs a verified Capgo-signed binary or fails with clear guidance. | Fallback | **None.** The runtime swiftc compile path and tmp-binary cache are deleted. Missing/unverifiable binary → hard error with install guidance | | Min macOS | x64 slice: macOS 10.15 (oldest macOS that runs Node 20, the CLI's floor); arm64 slice: macOS 11.0 | | Versioning | Independent semver, starting 1.0.0; release tag `cli-helper-X.Y.Z`; released **only when helper source changes**, not per CLI release | -| Pipeline | **Manually dispatched** (`workflow_dispatch` with a `version` input) GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance; the run creates the `cli-helper-X.Y.Z` git tag + GitHub release itself. Deliberate (human-in-the-loop) because releases are rare and notarization is a flaky external dependency — intentionally diverges from the repo's auto-tag `bump_version.yml` path used by `capgo`/`cli` | +| Pipeline | Dual-trigger GitHub Actions workflow on `macos-latest`: build → codesign → notarize → verify → npm publish with provenance; the run creates the `cli-helper-X.Y.Z` git tag + GitHub release (via the built-in `GITHUB_TOKEN`, which is loop-safe). Triggers: the deliberate **`workflow_dispatch`** button (version input) for normal releases, **and** a pushed `cli-helper-X.Y.Z` tag — the tag path lets the whole pipeline be tested from a PR branch without merging. Intentionally separate from the repo's auto-tag `bump_version.yml` path used by `capgo`/`cli` | | Signing | Developer ID Application certificate; hardened runtime + secure timestamp; stable code-signing identifier `app.capgo.cli.helper` (preserves Keychain "Always Allow" across re-signs and a future `.app` migration); notarized via `notarytool` with existing App Store Connect API key secrets | | Binary trust | CLI verifies the package-resolved binary's code signature (Developer ID + Capgo Team ID designated requirement) before executing it; failure is a hard error | | Env override | `CAPGO_KEYCHAIN_HELPER_PATH` exists in dev builds only — stripped from npm release builds via build-time define + dead-code elimination | From 57aa9ff597d0b3d7f0aeb44785ae35cb07e3dd16 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 9 Jun 2026 14:35:14 +0200 Subject: [PATCH 17/26] ci(helper): publish under the 'rc' dist-tag (keep new packages off 'latest') While the packages are new/unproven, publish both with --tag rc so they never become the default 'latest' install. Does not affect @capgo/cli: its optionalDependencies (^1.0.0) resolve against published versions, not dist-tags. --- .github/workflows/publish_cli_helper.yml | 9 +++++++-- cli-helper/README.md | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index 0d8aa8880c..c7091ac1d9 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -123,16 +123,21 @@ jobs: || { echo "::error::gate did not reject missing handshake: $out"; exit 1; } - name: Prepare packages run: node cli-helper/scripts/prepare-publish.mjs "${{ steps.version.outputs.version }}" + # Publish under the `rc` dist-tag while the package is new/unproven, so it + # never becomes npm `latest`. This does NOT affect @capgo/cli installs: + # its optionalDependencies `^1.0.0` resolve against published versions, not + # dist-tags. To promote to stable later, either change `--tag rc` to drop + # the flag, or run: npm dist-tag add @ latest - name: Publish darwin-arm64 working-directory: cli-helper/npm/darwin-arm64 env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --provenance --access public + run: npm publish --provenance --access public --tag rc - name: Publish darwin-x64 working-directory: cli-helper/npm/darwin-x64 env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --provenance --access public + run: npm publish --provenance --access public --tag rc - name: Zip signed bundles for release assets run: | ditto -c -k --keepParent cli-helper/dist/arm64/Capgo.app cli-helper/dist/Capgo-arm64.zip diff --git a/cli-helper/README.md b/cli-helper/README.md index f5c1c53a44..731ca84577 100644 --- a/cli-helper/README.md +++ b/cli-helper/README.md @@ -78,6 +78,12 @@ Either way it builds, signs, notarizes, staples, smoke-tests, publishes both packages with npm provenance, and creates the `cli-helper-X.Y.Z` tag + GitHub release. Release only when `src/helper.swift` actually changed. +**npm dist-tag:** while the packages are new/unproven they publish under the +**`rc`** dist-tag, not `latest`, so they never become the default bare install. +This doesn't affect `@capgo/cli`, whose `optionalDependencies` (`^1.0.0`) +resolve against published *versions*, not dist-tags. Promote to stable later by +dropping `--tag rc` in the workflow or `npm dist-tag add @ latest`. + 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 From c0c0c4140d80432def3a8857be5d00be93eba636 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 11 Jun 2026 07:19:01 +0200 Subject: [PATCH 18/26] ci(helper): hardcode public team id UVTJ336J2D (not a secret) --- .github/workflows/publish_cli_helper.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index c7091ac1d9..c7ba3c947a 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -89,7 +89,9 @@ jobs: echo "APPLE_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV" - name: Sign and notarize env: - CAPGO_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Public (not a secret): the team ID appears in the cert subject and + # every signed binary. Must match macos-signing.ts's CAPGO_APPLE_TEAM_ID. + CAPGO_APPLE_TEAM_ID: UVTJ336J2D APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }} run: bash cli-helper/scripts/sign-and-notarize.sh From 0d15569ce4e0ac4448094ec3d95912e0402bd7ac Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 11 Jun 2026 07:56:42 +0200 Subject: [PATCH 19/26] ci(helper): decode base64 APPLE_KEY_CONTENT before notarytool (fixes invalidAsn1) The secret stores the .p8 base64-encoded (Capgo CLI convention); writing it raw gave notarytool 'Error: invalidAsn1'. Decode (accepting raw PEM too) and validate with openssl before signing starts. --- .github/workflows/publish_cli_helper.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index c7ba3c947a..9fb05d2882 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -85,7 +85,16 @@ jobs: env: APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }} run: | - printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8" + # APPLE_KEY_CONTENT is stored base64-encoded (the Capgo CLI's own + # convention — see cli/src/sdk.ts). Accept raw PEM too, then validate: + # notarytool fails with the opaque 'Error: invalidAsn1' on a bad key. + if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8" + else + printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/AuthKey.p8" + fi + openssl pkey -in "$RUNNER_TEMP/AuthKey.p8" -noout 2>/dev/null \ + || { echo "::error::APPLE_KEY_CONTENT does not decode to a valid .p8 private key (tried raw PEM and base64)"; exit 1; } echo "APPLE_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV" - name: Sign and notarize env: From 4a1bd15528cec34329f0e99b647d79d59b03196e Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 11 Jun 2026 16:37:10 +0200 Subject: [PATCH 20/26] ci(helper): run precompiled-helper signature tests in the macOS TUI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the private suite submodule to the commit that adds the keychain-helper signature-gate test (Cap-go/cli-mcp-tests#2) and runs it as a dedicated step in builder_onboarding_tui_preview.yml. The test installs the REAL published @capgo/cli-keychain-darwin- package and drives resolveHelperBinary() through the REAL macOS codesign — genuine Developer ID bundle accepted; ad-hoc/tampered/missing/uninstalled/unsupported-arch all refused. Kept as its own `test:signing` step (network npm install) rather than folded into the hermetic test:e2e-tui:harness chain. --- .github/workflows/builder_onboarding_tui_preview.yml | 6 ++++++ private/cli-mcp-tests | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/builder_onboarding_tui_preview.yml b/.github/workflows/builder_onboarding_tui_preview.yml index 40a412abf3..7e466323c7 100644 --- a/.github/workflows/builder_onboarding_tui_preview.yml +++ b/.github/workflows/builder_onboarding_tui_preview.yml @@ -137,6 +137,12 @@ jobs: CAPGO_CLI_ROOT: ${{ github.workspace }}/cli run: bun run test:e2e-tui:harness + - name: Run keychain helper signature tests + working-directory: ${{ env.PRIVATE_SUITE_DIR }} + env: + CAPGO_CLI_ROOT: ${{ github.workspace }}/cli + run: bun run test:signing + - name: Run private TUI E2E suite working-directory: ${{ env.PRIVATE_SUITE_DIR }} env: diff --git a/private/cli-mcp-tests b/private/cli-mcp-tests index 630a8d0e5d..b9404f97de 160000 --- a/private/cli-mcp-tests +++ b/private/cli-mcp-tests @@ -1 +1 @@ -Subproject commit 630a8d0e5de157fbe612a90c3f10f0ddd8974d55 +Subproject commit b9404f97de2b551b54ede280883253e1036a2992 From 56842c3a2df46cd5c2fd1eae6e1d322d3e7fad25 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Thu, 11 Jun 2026 17:55:40 +0200 Subject: [PATCH 21/26] test(helper): bump private suite to drift-fixed main (drops compiling-helper) Removing the on-demand swiftc compile path in this PR made `import-compiling-helper` a non-existent step id, which tripped the private suite's drift guard (UNCOVERED key must be a real STEP_PROGRESS id). Bumps the submodule to cli-mcp-tests main with that exclusion removed (Cap-go/cli-mcp-tests#3) so the macOS TUI workflow's harness self-tests pass again. --- private/cli-mcp-tests | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/private/cli-mcp-tests b/private/cli-mcp-tests index b9404f97de..d5c5fb4a6f 160000 --- a/private/cli-mcp-tests +++ b/private/cli-mcp-tests @@ -1 +1 @@ -Subproject commit b9404f97de2b551b54ede280883253e1036a2992 +Subproject commit d5c5fb4a6f7cbd4d87e9564e31eea904094bedd2 From d19ec573c0ff53e6401d791e94f55e7bbe11848a Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 12 Jun 2026 07:17:55 +0200 Subject: [PATCH 22/26] test(helper): pin private suite to the capgo-main-calibrated branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is built on capgo `main`, but the private suite's `main` branch was re-calibrated to the onboarding mega-PR (#2394) — its cert golden expects "Apple Distribution" and its resume journey asserts #2394's credentials-gate, neither of which exist on capgo main. That made 2 wizard journeys fail here even though the signing work is green. Point the submodule at cli-mcp-tests `compat/capgo-main-helper` (= tests main at the signing-test/drift-fix point, with only those 2 files restored to capgo-main wording; keeps the update-prompt hermeticity fix). The mega-PR keeps pinning tests `main`. Full suite verified green vs this PR's CLI: 79 journeys (78 pass, 1 flaky), 0 missing drift steps, harness + signing green. --- private/cli-mcp-tests | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/private/cli-mcp-tests b/private/cli-mcp-tests index d5c5fb4a6f..71db228b2c 160000 --- a/private/cli-mcp-tests +++ b/private/cli-mcp-tests @@ -1 +1 @@ -Subproject commit d5c5fb4a6f7cbd4d87e9564e31eea904094bedd2 +Subproject commit 71db228b2c3a2c4b70e9dc8ba53d5ac0d455f19a From 5f2d2777e8631797457fa99891278e7d5e220200 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 12 Jun 2026 08:26:40 +0200 Subject: [PATCH 23/26] fix(helper): harden signing path per hostile review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the confirmed findings from the multi-engine hostile review of the precompiled keychain helper. Refuted findings were not touched. Security / correctness: - Pin the bundle identifier (`identifier "app.capgo.cli.helper"`) in the codesign designated requirement (macos-signing.ts + sign-and-notarize.sh). Without it the check accepted ANY binary signed with Capgo's Developer ID cert, not just the helper — and the script comment already (falsely) claimed the identifier was pinned. The currently-published bundle satisfies the tighter requirement. - helper.swift: hold `cfPass` with withExtendedLifetime across SecItemExport (the `_ = cfPass` no-op did not prevent a use-after-free); make a failed 0600 chmod on the exported .p12 delete the file and fail loudly instead of a stderr warning the Node side never reads. - Pass the p12 wrap passphrase over stdin, never argv, so it cannot be read via `ps` during export (helper.swift reads one line of stdin; spawnCapture pipes it). - Validate the exported artifact: strict `ok === true`, non-empty buffer, and a size match against the helper-reported p12SizeBytes. - CAPGO_KEYCHAIN_HELPER_PATH dev override now checks X_OK, not just existsSync. CI / release safety: - publish_cli_helper.yml: cancel-in-progress false (a cancel mid-publish leaves the release half-published; the retry then 409s on "version already exists"). - Add scripts/check-helper-dce.sh (wired into the cli `test` chain as test:helper-dce) so a production build that fails to dead-code-eliminate the CAPGO_KEYCHAIN_HELPER_PATH override is caught on PRs, not only at publish. The check lives in a script file (not an npm-script string) and forces a production build, so it neither self-trips via the bundle's inlined package.json nor depends on the ambient NODE_ENV. - Rename the misleading "release semantics" unit test to describe what it actually asserts (unbundled fail-closed default), and add exportP12FromKeychain tests covering the stdin passphrase + artifact validation. - helper.swift: document exit code 5 / FORBIDDEN_CALLER; reconcile the design/plan docs with the as-built Capgo.app bundle, hardcoded team id, and stdin passphrase. Not changed (deliberate): declaring the helper packages as optionalDependencies is the plan's gated Task 9 ("do not add until helper 1.0.0 is live"); a `^1.0.0` optional dep would churn the frozen lockfile against an unpublished version, and pinning to rc would ship a prerelease to all users. Left as the documented merge gate. Swift-side fixes ship with the next helper rebuild/republish. --- .github/workflows/publish_cli_helper.yml | 5 +- cli-helper/scripts/sign-and-notarize.sh | 7 +- cli-helper/src/helper.swift | 63 ++++++++------ cli/package.json | 3 +- cli/scripts/check-helper-dce.sh | 31 +++++++ cli/src/build/onboarding/macos-signing.ts | 68 ++++++++++++--- cli/test/test-macos-signing.mjs | 85 ++++++++++++++++++- .../2026-06-06-keychain-helper-precompile.md | 2 + ...06-06-keychain-helper-precompile-design.md | 18 +++- 9 files changed, 238 insertions(+), 44 deletions(-) create mode 100644 cli/scripts/check-helper-dce.sh diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index 9fb05d2882..9bc43bff9c 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -2,7 +2,10 @@ name: Build and publish CLI keychain helper concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Never cancel a publish mid-flight: cancelling between the arm64 and x64 + # `npm publish` steps leaves the release half-published, and the retry then + # fails with "version already exists". Queue instead. + cancel-in-progress: false on: # Normal release: the deliberate button (choose the version). diff --git a/cli-helper/scripts/sign-and-notarize.sh b/cli-helper/scripts/sign-and-notarize.sh index 16fcb1bba9..12fffbbda8 100644 --- a/cli-helper/scripts/sign-and-notarize.sh +++ b/cli-helper/scripts/sign-and-notarize.sh @@ -25,18 +25,21 @@ cd "$(dirname "$0")/.." # would be rejected at runtime. Fail fast here instead. Keep in sync with # macos-signing.ts. EXPECTED_TEAM_ID="UVTJ336J2D" +# Bundle identifier baked into Info.plist; pinned in the designated requirement +# (must match HELPER_BUNDLE_IDENTIFIER in macos-signing.ts). +HELPER_IDENTIFIER="app.capgo.cli.helper" if [ "$CAPGO_APPLE_TEAM_ID" != "$EXPECTED_TEAM_ID" ]; then echo "CAPGO_APPLE_TEAM_ID ($CAPGO_APPLE_TEAM_ID) != $EXPECTED_TEAM_ID expected by the CLI verifier." >&2 echo "Fix the APPLE_TEAM_ID secret or update macos-signing.ts; refusing to sign a helper users can't run." >&2 exit 1 fi -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"'"' +REQUIREMENT='=identifier "'"$HELPER_IDENTIFIER"'" and anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "'"$CAPGO_APPLE_TEAM_ID"'"' for arch in arm64 x64; do app="dist/$arch/Capgo.app" echo "── Signing $app" - codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_IDENTITY" "$app" + codesign --force --options runtime --timestamp --identifier "$HELPER_IDENTIFIER" --sign "$DEVELOPER_ID_IDENTITY" "$app" echo "── Notarizing $app" ditto -c -k --keepParent "$app" "dist/$arch/Capgo.zip" diff --git a/cli-helper/src/helper.swift b/cli-helper/src/helper.swift index 0e54c1a396..73f1073d7c 100644 --- a/cli-helper/src/helper.swift +++ b/cli-helper/src/helper.swift @@ -8,8 +8,9 @@ // Usage: // helper keychain-export --sha1 <40-hex-char-cert-sha1> // --output -// --passphrase // --invoked-by capgo-cli +// The PKCS#12 wrap passphrase is read as ONE line from stdin (never argv, so +// it does not appear in `ps`). The caller writes "\n" then closes. // // JSON output (single line on stdout, ALWAYS emitted before exit): // @@ -20,6 +21,7 @@ // {"ok":false,"errorCode":"USER_DENIED","message":"…","osStatus":-128} // {"ok":false,"errorCode":"NO_IDENTITY","message":"…"} // {"ok":false,"errorCode":"INVALID_ARGS","message":"…"} +// {"ok":false,"errorCode":"FORBIDDEN_CALLER","message":"…"} // {"ok":false,"errorCode":"EXPORT_FAILED","message":"…","osStatus":-12345} // {"ok":false,"errorCode":"WRITE_FAILED","message":"…"} // {"ok":false,"errorCode":"INTERNAL","message":"…"} @@ -30,7 +32,7 @@ // 2 — argument parsing error (INVALID_ARGS) // 3 — no identity matching the given SHA1 (NO_IDENTITY) // 4 — user denied macOS Keychain access (USER_DENIED) -// +// 5 — caller not permitted (FORBIDDEN_CALLER) // Why we use SecItemExport(.formatPKCS12) and accept the 2 prompts: // Xcode-imported signing keys are non-extractable (kSecKeyExtractable=false). // `SecKeyCopyExternalRepresentation` rejects them with @@ -177,8 +179,9 @@ func describeStatus(_ status: OSStatus) -> String { struct Args { var sha1Hex: String = "" var outputPath: String = "" - var passphrase: String = "" var invokedBy: String = "" + // The PKCS#12 wrap passphrase is NOT an argv flag — it is read from stdin + // (see readPassphraseFromStdin) so it never appears in `ps`/argv. } func parseArgs(_ cli: [String]) throws -> Args { @@ -195,7 +198,6 @@ func parseArgs(_ cli: [String]) throws -> Args { switch flag { case "--sha1": args.sha1Hex = value.lowercased() case "--output": args.outputPath = value - case "--passphrase": args.passphrase = value case "--invoked-by": args.invokedBy = value default: throw KeychainExportError.invalidArgs("Unknown argument: \(flag)") } @@ -206,15 +208,22 @@ func parseArgs(_ cli: [String]) throws -> Args { if args.outputPath.isEmpty { throw KeychainExportError.invalidArgs("Required: --output ") } - if args.passphrase.isEmpty { - throw KeychainExportError.invalidArgs("Required: --passphrase ") - } if args.sha1Hex.count != 40 || args.sha1Hex.range(of: "^[0-9a-f]{40}$", options: .regularExpression) == nil { throw KeychainExportError.invalidArgs("--sha1 must be 40 lowercase hex chars (got \"\(args.sha1Hex)\")") } return args } +/// Read the PKCS#12 wrap passphrase as a single line from stdin. Kept off argv +/// so it never shows up in `ps`/argv. The Node caller writes "\n" +/// then closes stdin. +func readPassphraseFromStdin() throws -> String { + guard let line = readLine(strippingNewline: true), !line.isEmpty else { + throw KeychainExportError.invalidArgs("Required: PKCS#12 wrap passphrase on stdin (one line).") + } + return line +} + // MARK: - SHA1 of cert DER (matches `security find-identity` output) func sha1OfCertDer(_ cert: SecCertificate) -> String { @@ -269,22 +278,26 @@ func findIdentityBySha1(_ targetSha1: String) throws -> (SecIdentity, String) { // MARK: - Export to PKCS#12 func exportIdentityAsPkcs12(_ identity: SecIdentity, passphrase: String) throws -> Data { - // CFString must outlive the SecItemExport call. Holding `cfPass` in a - // local keeps it alive for the duration of this function. let cfPass: CFString = passphrase as CFString var keyParams = SecItemImportExportKeyParameters() keyParams.version = UInt32(SEC_KEY_IMPORT_EXPORT_PARAMS_VERSION) keyParams.passphrase = Unmanaged.passUnretained(cfPass) var exportedData: CFData? - let status = withUnsafePointer(to: &keyParams) { paramsPtr in - SecItemExport( - identity, - .formatPKCS12, - SecItemImportExportFlags(rawValue: 0), - paramsPtr, - &exportedData - ) + // Unmanaged.passUnretained does NOT bump cfPass's retain count — the Security + // framework borrows the string for the duration of the call. Hold it alive + // explicitly with withExtendedLifetime so the optimizer can't release it + // before SecItemExport returns (a use-after-free otherwise). + let status = withExtendedLifetime(cfPass) { + withUnsafePointer(to: &keyParams) { paramsPtr in + SecItemExport( + identity, + .formatPKCS12, + SecItemImportExportFlags(rawValue: 0), + paramsPtr, + &exportedData + ) + } } // Treat user-denied / canceled distinctly so the caller can offer retry @@ -307,9 +320,6 @@ func exportIdentityAsPkcs12(_ identity: SecIdentity, passphrase: String) throws throw KeychainExportError.exportFailed(0, "SecItemExport returned nil data with success status") } - // Keep cfPass alive past the call — Unmanaged.passUnretained doesn't - // bump the retain count; the Security framework relies on us holding it. - _ = cfPass return data as Data } @@ -323,15 +333,19 @@ func writeP12(_ data: Data, to path: String) throws { "Failed to write P12 to \(path): \(error.localizedDescription)" ) } - // Best-effort 0600 chmod. Non-fatal if it fails. + // The file holds a signing private key. Restrict it to 0600; if that fails, + // delete it and fail loudly — never leave a world/group-readable key behind + // while reporting success (the Node caller only reads our JSON, not stderr). do { try FileManager.default.setAttributes( [.posixPermissions: NSNumber(value: Int16(0o600))], ofItemAtPath: path ) } catch { - FileHandle.standardError.write( - Data("warning: could not chmod 0600 on \(path): \(error.localizedDescription)\n".utf8) + try? FileManager.default.removeItem(atPath: path) + throw KeychainExportError.writeFailed( + "Wrote P12 to \(path) but could not restrict it to 0600 " + + "(\(error.localizedDescription)); removed it to avoid leaving a readable private key." ) } } @@ -366,8 +380,9 @@ do { case "keychain-export": let args = try parseArgs(Array(argv.dropFirst(2))) try enforceCallerGate(args) + let passphrase = try readPassphraseFromStdin() let (identity, identityName) = try findIdentityBySha1(args.sha1Hex) - let p12 = try exportIdentityAsPkcs12(identity, passphrase: args.passphrase) + let p12 = try exportIdentityAsPkcs12(identity, passphrase: passphrase) try writeP12(p12, to: args.outputPath) emitSuccessAndExit(p12Path: args.outputPath, p12SizeBytes: p12.count, identityName: identityName) default: diff --git a/cli/package.json b/cli/package.json index e6ef0979d3..38578e5c12 100644 --- a/cli/package.json +++ b/cli/package.json @@ -105,6 +105,7 @@ "test:platform-paths": "bun test/test-platform-paths.mjs", "test:payload-split": "bun test/test-payload-split.mjs", "test:macos-signing": "bun test/test-macos-signing.mjs", + "test:helper-dce": "bash scripts/check-helper-dce.sh", "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", "test:bundle-id-detector": "bun test/test-bundle-id-detector.mjs", "test:apple-api-app-list": "bun test/test-apple-api-app-list.mjs", @@ -113,7 +114,7 @@ "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", "test:self-update": "bun test/test-self-update.mjs", "test:update-prompt": "bun test/test-update-prompt.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", diff --git a/cli/scripts/check-helper-dce.sh b/cli/scripts/check-helper-dce.sh new file mode 100644 index 0000000000..305da783b0 --- /dev/null +++ b/cli/scripts/check-helper-dce.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Assert the dev-only CAPGO_KEYCHAIN_HELPER_PATH override is dead-code-eliminated +# from the PRODUCTION CLI bundle. +# +# resolveHelperBinary() gates the override behind __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__, +# which cli/build.mjs defines as `false` for production builds; the minifier then +# deletes the whole branch, including the env-var access. A regression (an +# accidental NODE_ENV=development build, or refactoring the define away) would ship +# the override to users — this fails the build instead. +# +# Notes: +# - We force a production build so the result does not depend on the ambient +# NODE_ENV of whoever runs `bun run test`. +# - We force a production build so the result does not depend on the ambient +# NODE_ENV of whoever runs `bun run test`. +# - We grep the bare variable name. That is safe because this check's npm script +# is `bash scripts/check-helper-dce.sh` (it does NOT mention the variable), so +# the bundle's inlined package.json no longer contains it — the only possible +# match is the override code itself (minified, e.g. `MN.env.CAPGO_KEYCHAIN_HELPER_PATH`). +set -euo pipefail +cd "$(dirname "$0")/.." + +NODE_ENV=production bun run build >/dev/null + +if grep -q 'CAPGO_KEYCHAIN_HELPER_PATH' dist/index.js; then + echo "FAIL: the dev-only keychain-helper override survived dead-code elimination" >&2 + echo " in the production bundle (cli/dist/index.js). It must be stripped." >&2 + exit 1 +fi +echo "helper-dce OK: dev keychain-helper override absent from the production bundle" +echo "helper-dce OK: dev keychain-helper override absent from the production bundle" diff --git a/cli/src/build/onboarding/macos-signing.ts b/cli/src/build/onboarding/macos-signing.ts index 8df2d7ad7c..22537e9feb 100644 --- a/cli/src/build/onboarding/macos-signing.ts +++ b/cli/src/build/onboarding/macos-signing.ts @@ -323,6 +323,14 @@ export function generateP12Passphrase(): string { */ const CAPGO_APPLE_TEAM_ID = 'UVTJ336J2D' +/** + * Bundle identifier (CFBundleIdentifier) the helper's Capgo.app is built with. + * Pinned in the designated requirement so the check accepts ONLY this binary, + * not merely any binary signed with Capgo's Developer ID cert. Must match + * cli-helper/assets/Info.plist.template and sign-and-notarize.sh. + */ +const HELPER_BUNDLE_IDENTIFIER = 'app.capgo.cli.helper' + const HELPER_PACKAGE_PREFIX = '@capgo/cli-keychain-darwin-' /** @@ -336,12 +344,15 @@ export function helperPackageName(arch: string): string | null { } /** - * codesign designated requirement asserting: Apple-rooted chain, a - * Developer ID Application leaf cert (OID 1.2.840.113635.100.6.1.13), and - * the given Apple Team ID as the signing team. + * codesign designated requirement asserting: the exact helper bundle identifier + * (app.capgo.cli.helper), an Apple-rooted chain, a Developer ID Application leaf + * cert (OID 1.2.840.113635.100.6.1.13), and the given Apple Team ID as the + * signing team. The identifier clause is what scopes the requirement to THIS + * binary — without it, any other binary signed with Capgo's Developer ID cert + * (a future tool, a leaked artifact) would also satisfy the check. */ export function helperSignatureRequirement(teamId: string = CAPGO_APPLE_TEAM_ID): string { - return `=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "${teamId}"` + return `=identifier "${HELPER_BUNDLE_IDENTIFIER}" and anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "${teamId}"` } /** @@ -398,6 +409,12 @@ export async function resolveHelperBinary(options: ResolveHelperBinaryOptions = if (overridePath) { if (!existsSync(overridePath)) throw new MacOSSigningError(`CAPGO_KEYCHAIN_HELPER_PATH points to a missing file: ${overridePath}`) + try { + accessSync(overridePath, constants.X_OK) + } + catch { + throw new MacOSSigningError(`CAPGO_KEYCHAIN_HELPER_PATH points to a non-executable file: ${overridePath}`) + } return overridePath } } @@ -497,15 +514,21 @@ interface SpawnResult { code: number | null } -function spawnCapture(command: string, args: readonly string[]): Promise { +function spawnCapture(command: string, args: readonly string[], stdinInput?: string): Promise { return new Promise((resolveRun) => { - const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] }) + // Pipe stdin only when we have something to feed it (the keychain helper's + // wrap passphrase) — that keeps it off the process argv where any same-user + // process could read it via `ps`. codesign and other callers pass no input. + const stdin: 'pipe' | 'ignore' = stdinInput !== undefined ? 'pipe' : 'ignore' + const child = spawn(command, [...args], { stdio: [stdin, 'pipe', 'pipe'] }) let stdout = '' let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { + // stdout/stderr are always 'pipe' above, so they are non-null at runtime; + // optional chaining only satisfies the widened type from the stdin variable. + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8') }) - child.stderr.on('data', (chunk: Buffer) => { + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf-8') }) child.once('error', (err) => { @@ -514,6 +537,12 @@ function spawnCapture(command: string, args: readonly string[]): Promise { resolveRun({ stdout, stderr, code }) }) + if (stdinInput !== undefined && child.stdin) { + // Swallow EPIPE if the child exits before reading stdin — the exit + // code/JSON is the real signal, not the write. + child.stdin.on('error', () => {}) + child.stdin.end(stdinInput) + } }) } @@ -562,23 +591,25 @@ export async function exportP12FromKeychain( const p12Path = join(workDir, 'identity.p12') try { + // Passphrase goes over stdin (one line), NOT argv, so it never appears in + // `ps`/argv for the brief export window. The helper reads one line of stdin. const result = await spawnCapture(helperPath, [ 'keychain-export', '--sha1', sha1, '--output', p12Path, - '--passphrase', - passphrase, '--invoked-by', 'capgo-cli', - ]) + ], `${passphrase}\n`) // The helper ALWAYS emits one line of JSON on stdout — success or fail. // Parse it before checking exit code so we get the structured errorCode. const parsed = parseHelperJson(result.stdout, result.stderr, result.code) - if (!parsed.ok) { + // Strict `=== true`: the JSON comes from an external process, so a truthy + // non-boolean `ok` must NOT be read as success. + if (parsed.ok !== true) { const code = parsed.errorCode ?? 'INTERNAL' const msg = parsed.message ?? 'Unknown error from keychain-export helper' const osStatus = parsed.osStatus !== undefined ? ` [OSStatus ${parsed.osStatus}]` : '' @@ -587,7 +618,20 @@ export async function exportP12FromKeychain( ) } + // Independently validate the artifact before we treat it as a signing key: + // a truncated/empty export must fail loudly here, not be stored as a + // "successful" empty credential. const p12Buffer = await readFile(p12Path) + if (p12Buffer.length === 0) { + throw new MacOSSigningError( + 'keychain-export reported success but the exported .p12 is empty.', + ) + } + if (typeof parsed.p12SizeBytes === 'number' && parsed.p12SizeBytes !== p12Buffer.length) { + throw new MacOSSigningError( + `keychain-export size mismatch: helper reported ${parsed.p12SizeBytes} bytes, read ${p12Buffer.length}.`, + ) + } return { base64: p12Buffer.toString('base64'), passphrase } } finally { diff --git a/cli/test/test-macos-signing.mjs b/cli/test/test-macos-signing.mjs index 8c89535310..1885531820 100644 --- a/cli/test/test-macos-signing.mjs +++ b/cli/test/test-macos-signing.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { bundleIdMatches, + exportP12FromKeychain, filterProfilesForApp, generateP12Passphrase, helperPackageName, @@ -440,9 +441,9 @@ t('helperPackageName returns null for unsupported architectures', () => { // ─── helperSignatureRequirement ─────────────────────────────────────── -t('helperSignatureRequirement pins Developer ID + team', () => { +t('helperSignatureRequirement pins identifier + Developer ID + team', () => { const req = helperSignatureRequirement('ABCDE12345') - assert.ok(req.startsWith('=anchor apple generic')) + assert.ok(req.startsWith('=identifier "app.capgo.cli.helper" and anchor apple generic'), `got: ${req}`) assert.ok(req.includes('certificate leaf[field.1.2.840.113635.100.6.1.13]')) assert.ok(req.includes('certificate leaf[subject.OU] = "ABCDE12345"')) }) @@ -545,7 +546,12 @@ await tAsync('env override wins when explicitly allowed (dev builds)', async () } }) -await tAsync('env override is ignored by default (release semantics)', async () => { +// NOTE: this runs against UNBUNDLED source, where __CAPGO_ALLOW_HELPER_ENV_OVERRIDE__ +// is `undefined`, so `allowEnvOverride` defaults to false and the override is +// fail-closed. It does NOT prove the release BUNDLE drops the override branch via +// dead-code elimination — that is a separate property asserted on the built +// dist/index.js by the `test:helper-dce` script (run in CI), not here. +await tAsync('env override is fail-closed by default in unbundled source', async () => { const { dir, bin } = makeFakeHelper() process.env.CAPGO_KEYCHAIN_HELPER_PATH = '/nonexistent/evil-binary' try { @@ -562,4 +568,77 @@ await tAsync('env override is ignored by default (release semantics)', async () } }) +// ─── exportP12FromKeychain: stdin passphrase + artifact validation ──── +// exportP12FromKeychain gates on isMacOS(), so these only run on darwin (the +// cli unit suite also runs on Linux CI, where the export path is unreachable). +// They drive a fake helper that reads the passphrase from STDIN — proving it is +// not passed on argv — and exercise the post-export artifact validation. +if (isMacOS()) { + // Fake helper: read one line of stdin (the passphrase) -> write it to passFile, + // write `bodyBytes` bytes to --output, emit success JSON reporting `reportSize`. + function makeExportHelper({ bodyBytes, reportSize, passFile }) { + const dir = mkdtempSync(join(tmpdir(), 'capgo-export-helper-')) + const bin = join(dir, 'fake-export.sh') + const body = 'x'.repeat(bodyBytes) + const script = [ + '#!/bin/sh', + 'read PASS', + 'OUT=""', + 'while [ $# -gt 0 ]; do', + ' case "$1" in --output) OUT="$2"; shift 2 ;; --sha1|--invoked-by) shift 2 ;; *) shift ;; esac', + 'done', + `printf '%s' "$PASS" > "${passFile}"`, + `printf '%s' '${body}' > "$OUT"`, + `echo '{"ok":true,"p12Path":"'"$OUT"'","p12SizeBytes":${reportSize},"identityName":"Fake Dist"}'`, + '', + ].join('\n') + writeFileSync(bin, script) + chmodSync(bin, 0o755) + return { dir, bin } + } + + const exportSha1 = 'a'.repeat(40) + const readFileSyncFn = require('node:fs').readFileSync + + await tAsync('exportP12FromKeychain feeds the passphrase via stdin (not argv) and returns base64', async () => { + const passDir = mkdtempSync(join(tmpdir(), 'capgo-pass-')) + const passFile = join(passDir, 'pass.txt') + const { dir, bin } = makeExportHelper({ bodyBytes: 16, reportSize: 16, passFile }) + try { + const result = await exportP12FromKeychain(exportSha1, { helperPathOverride: bin }) + assert.equal(result.base64, Buffer.from('x'.repeat(16)).toString('base64')) + assert.match(result.passphrase, /^[0-9a-f]{64}$/) + assert.equal(readFileSyncFn(passFile, 'utf8'), result.passphrase, 'helper received the passphrase over stdin') + } + finally { + rmSync(dir, { recursive: true, force: true }) + rmSync(passDir, { recursive: true, force: true }) + } + }) + + await tAsync('exportP12FromKeychain rejects an empty exported p12', async () => { + const passDir = mkdtempSync(join(tmpdir(), 'capgo-pass-')) + const { dir, bin } = makeExportHelper({ bodyBytes: 0, reportSize: 0, passFile: join(passDir, 'pass.txt') }) + try { + await assert.rejects(exportP12FromKeychain(exportSha1, { helperPathOverride: bin }), /exported \.p12 is empty/) + } + finally { + rmSync(dir, { recursive: true, force: true }) + rmSync(passDir, { recursive: true, force: true }) + } + }) + + await tAsync('exportP12FromKeychain rejects a reported/actual size mismatch', async () => { + const passDir = mkdtempSync(join(tmpdir(), 'capgo-pass-')) + const { dir, bin } = makeExportHelper({ bodyBytes: 8, reportSize: 9999, passFile: join(passDir, 'pass.txt') }) + try { + await assert.rejects(exportP12FromKeychain(exportSha1, { helperPathOverride: bin }), /size mismatch/) + } + finally { + rmSync(dir, { recursive: true, force: true }) + rmSync(passDir, { recursive: true, force: true }) + } + }) +} + process.stdout.write('OK\n') diff --git a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md index 09f5baca02..9e18a4b94f 100644 --- a/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md +++ b/docs/superpowers/plans/2026-06-06-keychain-helper-precompile.md @@ -4,6 +4,8 @@ **Goal:** Ship the keychain-export logic as a precompiled, Developer-ID-signed, notarized generic `helper` binary in per-arch npm packages (`@capgo/cli-keychain-darwin-arm64` / `-x64`), invoked as `helper keychain-export …`, verified at runtime by the CLI, with the runtime swiftc compilation path deleted. +> **As-built note (PR #2458):** the helper ships inside a signed/notarized **`Capgo.app` bundle** (`Capgo.app/Contents/MacOS/capgo`), not a bare `helper` binary — so `build.sh` assembles the bundle, `prepare-publish.mjs` copies `Capgo.app`, the package `files` list is `["Capgo.app"]`, and `resolveHelperBinary` returns the inner exec. The designated requirement also pins `identifier "app.capgo.cli.helper"`. The Apple Team ID is **hardcoded** (`UVTJ336J2D`, public — no `APPLE_TEAM_ID`/`CAPGO_APPLE_TEAM_ID` secret). The wrap passphrase is sent over **stdin**, not argv. Task 9 (`optionalDependencies`) is still **gated** — do not add until helper `1.0.0` is published. Where this plan and the shipped code disagree, the code wins. + **Architecture:** A new `cli-helper/` monorepo dir owns the Swift source (`helper.swift`, a single binary with subcommand dispatch) and two binary-only npm packages. A manually dispatched (`workflow_dispatch` with a `version` input) GitHub Actions workflow on `macos-latest` builds both arch slices, codesigns with hardened runtime, notarizes via `notarytool`, publishes with npm provenance, and creates the `cli-helper-X.Y.Z` tag + release. The CLI resolves the arch-matching package at runtime, verifies its code signature against Capgo's Apple Team ID via a `codesign` designated-requirement check, and hard-errors with install guidance when anything is missing — no compile fallback. A dev-only `CAPGO_KEYCHAIN_HELPER_PATH` env override is dead-code-eliminated from release builds via a `Bun.build` define. The sensitive `keychain-export` subcommand carries an anti-footgun gate (internal handshake flag + non-TTY stdout) documented as a non-security-boundary in `cli-helper/SECURITY.md`. **Tech Stack:** Swift (Security framework), Bun build pipeline, Node `createRequire` resolution, GitHub Actions (macos-latest), `codesign`/`notarytool`, npm provenance. diff --git a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md index 3455e341e8..e221917829 100644 --- a/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md +++ b/docs/superpowers/specs/2026-06-06-keychain-helper-precompile-design.md @@ -1,7 +1,23 @@ # Precompiled macOS Keychain-Export Helper — Design **Date:** 2026-06-06 -**Status:** Approved design, pending implementation plan +**Status:** Implemented (PR #2458). This is the original design doc; some +examples below predate the final implementation. **The code is the source of +truth** — where this doc and the code disagree, the code wins. As-built deltas: + +- The helper ships inside a signed/notarized **`Capgo.app` bundle**, not a bare + binary. Runtime resolution is + `…/@capgo/cli-keychain-darwin-/Capgo.app/Contents/MacOS/capgo`, and the + npm package `files` list ships `Capgo.app` (not `helper`). +- The designated requirement pins the **bundle identifier** + (`identifier "app.capgo.cli.helper"`) in addition to the Apple chain + + Developer ID OID + Team OU. +- The Apple Team ID (`UVTJ336J2D`) is **hardcoded** in `macos-signing.ts` and + `sign-and-notarize.sh` (it is public, not a secret), so there is no + `APPLE_TEAM_ID` secret to wire. +- The wrap passphrase is passed to the helper over **stdin**, never argv. +- `optionalDependencies` for the helper packages are **deliberately not yet + declared** — gated until helper `1.0.0` is published (see the plan's Task 9). ## Problem From 97795f739317a5512778e0d7b85a96c1abee8f07 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 12 Jun 2026 08:48:50 +0200 Subject: [PATCH 24/26] ci(helper): fix gate smoke tests for stdin passphrase interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-build gate test still passed --passphrase (the old argv interface), which the stdin-passphrase helper now rejects as INVALID_ARGS instead of FORBIDDEN_CALLER — failing the publish workflow (caught on rc.3). Drop the flag, feed stdin from /dev/null for the negative gate, and add a positive test that pipes a passphrase + valid handshake and expects NO_IDENTITY, guarding the new stdin interface in CI. --- .github/workflows/publish_cli_helper.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index 9bc43bff9c..e0d29824b6 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -130,11 +130,29 @@ jobs: x86_64) helper="./cli-helper/dist/x64/Capgo.app/Contents/MacOS/capgo" ;; *) echo "::error::unsupported runner arch: $arch"; exit 1 ;; esac + # No --invoked-by: the caller gate must reject before anything else + # (the wrap passphrase is read from stdin, not argv, so none is passed). set +e - out=$("$helper" keychain-export --sha1 "$(printf 'a%.0s' {1..40})" --output /tmp/x.p12 --passphrase p | cat) + out=$("$helper" keychain-export --sha1 "$(printf 'a%.0s' {1..40})" --output /tmp/x.p12 < /dev/null | 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; } + - name: Gate test — valid handshake + stdin passphrase reaches keychain lookup + 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 + # WITH the handshake and a passphrase on stdin, the helper passes the gate, + # reads the passphrase, and fails only at the keychain lookup (the random + # SHA1 matches nothing). This guards the stdin passphrase interface. + set +e + out=$(printf 'wrap-pass\n' | "$helper" keychain-export --sha1 "$(printf 'a%.0s' {1..40})" --output /tmp/x.p12 --invoked-by capgo-cli | cat) + set -e + echo "$out" | jq -e '.ok == false and .errorCode == "NO_IDENTITY"' > /dev/null \ + || { echo "::error::expected NO_IDENTITY with handshake + stdin passphrase: $out"; exit 1; } - name: Prepare packages run: node cli-helper/scripts/prepare-publish.mjs "${{ steps.version.outputs.version }}" # Publish under the `rc` dist-tag while the package is new/unproven, so it From a692637488b98e95302346f3aac4291556ef486c Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 12 Jun 2026 09:06:01 +0200 Subject: [PATCH 25/26] ci(helper): publish stable versions to npm 'latest', prereleases to 'rc' The publish steps hardcoded --tag rc, so a stable 1.0.0 would land under rc and leave 'latest' pointing at an old prerelease (breaking the runtime reinstall hint and plain npm i). Compute the dist-tag from the version: anything with a '-' (e.g. 1.0.0-rc.4) -> rc, otherwise -> latest. --- .github/workflows/publish_cli_helper.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish_cli_helper.yml b/.github/workflows/publish_cli_helper.yml index e0d29824b6..0526154beb 100644 --- a/.github/workflows/publish_cli_helper.yml +++ b/.github/workflows/publish_cli_helper.yml @@ -58,6 +58,13 @@ jobs: echo "::error::version '$v' is not semver (e.g. 1.0.0)"; exit 1 fi echo "version=$v" >> "$GITHUB_OUTPUT" + # Prereleases (e.g. 1.0.0-rc.4) publish under the `rc` dist-tag so they + # stay off the default install; stable versions go to `latest`. + if printf '%s' "$v" | grep -q '-'; then + echo "npm_tag=rc" >> "$GITHUB_OUTPUT" + else + echo "npm_tag=latest" >> "$GITHUB_OUTPUT" + fi - name: Build helper bundles run: bash cli-helper/scripts/build.sh "${{ steps.version.outputs.version }}" - name: Import Developer ID certificate into throwaway keychain @@ -164,12 +171,12 @@ jobs: working-directory: cli-helper/npm/darwin-arm64 env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --provenance --access public --tag rc + run: npm publish --provenance --access public --tag ${{ steps.version.outputs.npm_tag }} - name: Publish darwin-x64 working-directory: cli-helper/npm/darwin-x64 env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --provenance --access public --tag rc + run: npm publish --provenance --access public --tag ${{ steps.version.outputs.npm_tag }} - name: Zip signed bundles for release assets run: | ditto -c -k --keepParent cli-helper/dist/arm64/Capgo.app cli-helper/dist/Capgo-arm64.zip From cd6c6e46c23fea8155250aafbd83813b0a1d61ae Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 12 Jun 2026 09:09:58 +0200 Subject: [PATCH 26/26] feat(cli): declare keychain helper packages as optionalDependencies Now that @capgo/cli-keychain-darwin-{arm64,x64}@1.0.0 are published, declare them as optionalDependencies (^1.0.0) so a normal `npm i @capgo/cli` pulls the arch-matching, signed/notarized helper into node_modules. Each package is os/cpu-restricted, so only the matching arch installs. This un-gates the import-existing keychain export path (resolveHelperBinary previously threw "not installed" on every real install). Verified: bun install --frozen-lockfile is clean, and resolveHelperBinary() with no override resolves the installed 1.0.0 helper and verifies its signature. Closes the plan's gated Task 9. --- bun.lock | 10 +++++++++- cli/package.json | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index e032084ab4..82c450f8f0 100644 --- a/bun.lock +++ b/bun.lock @@ -207,7 +207,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "8.0.4", + "version": "8.1.9", "bin": { "capgo": "dist/index.js", }, @@ -262,6 +262,10 @@ "ws": "^8.21.0", "zod": "^4.4.3", }, + "optionalDependencies": { + "@capgo/cli-keychain-darwin-arm64": "^1.0.0", + "@capgo/cli-keychain-darwin-x64": "^1.0.0", + }, }, }, "trustedDependencies": [ @@ -612,6 +616,10 @@ "@capgo/cli": ["@capgo/cli@workspace:cli"], + "@capgo/cli-keychain-darwin-arm64": ["@capgo/cli-keychain-darwin-arm64@1.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Qh506oVrMNwErFLHXUvdQMadoxptA+XjNozWz9+2Z9iEi7omgzSWzAN0nDUI+uLNzhAHqxsVkpc/oD9q6NALA=="], + + "@capgo/cli-keychain-darwin-x64": ["@capgo/cli-keychain-darwin-x64@1.0.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BxMx8QI1IsRVVpSqmCYxe4EShUpdBHgmxYF0AdWVn56lroRVkC7QZ7CXOEM2+YrnYFA4SUVdkBtRuHRhMbmauA=="], + "@capgo/find-package-manager": ["@capgo/find-package-manager@0.0.18", "", {}, "sha512-YTajLnUJYYOqHWH59l6Umlqq1PmdUReWY5HLgfHfVHJk/xyWzfQ8Kzo5dLd1vxqNWZV8zvGHLk9mCxMAxt/q1A=="], "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], diff --git a/cli/package.json b/cli/package.json index 38578e5c12..b5239a8dc2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -150,6 +150,10 @@ "react": "^19.2.6", "string-width": "^8.2.1" }, + "optionalDependencies": { + "@capgo/cli-keychain-darwin-arm64": "^1.0.0", + "@capgo/cli-keychain-darwin-x64": "^1.0.0" + }, "devDependencies": { "@antfu/eslint-config": "^9.0.0", "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@0.9.6",