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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/publish_cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Generate AI changelog
id: changelog
uses: mistricky/ccc@v0.2.6
Expand Down
115 changes: 115 additions & 0 deletions .github/workflows/publish_cli_helper.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Build and publish CLI keychain helper

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

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

permissions: {}

jobs:
publish_cli_helper:
runs-on: macos-latest
name: Build, sign, notarize, publish keychain helper
timeout-minutes: 45
permissions:
contents: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.x
registry-url: https://registry.npmjs.org
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Validate + capture version
id: version
run: |
v="${{ github.event.inputs.version }}"
if ! echo "$v" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then
echo "::error::version '$v' is not semver (e.g. 1.0.0)"; exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
- name: Build helper 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; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Prepare packages
run: node cli-helper/scripts/prepare-publish.mjs "${{ steps.version.outputs.version }}"
- name: Publish darwin-arm64
working-directory: cli-helper/npm/darwin-arm64
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --provenance --access public
- name: Publish darwin-x64
working-directory: cli-helper/npm/darwin-x64
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --provenance --access public
- name: 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 }}"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@ graphify-out/*.html
graphify-out/*.svg
graphify-out/*.graphml
graphify-out/cypher.txt

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

Small Swift program (Security framework only) shipped as one generic binary
named `helper`. Today it has a single subcommand:

helper keychain-export --sha1 <40-hex> --output <path.p12> \
--passphrase <wrap-pass> --invoked-by capgo-cli

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

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

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

Both are `optionalDependencies` of `@capgo/cli`; npm installs at most one. The
CLI verifies the binary's code signature (Developer ID + Capgo Team ID) before
every execution and refuses to run anything else. See SECURITY.md for the
threat model.

## Dev bootstrap (working on the Swift source)

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

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

`CAPGO_KEYCHAIN_HELPER_PATH` only exists in dev builds — it is dead-code-
eliminated from npm release builds (asserted in CI). The env-override path
skips both the signature check and the subcommand wrapper, so point it at a
binary you built and trust.

## Release

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

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

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

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

## Invoking the helper grants no privilege

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

## Why we don't authenticate the caller

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

## What we do instead

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

## Reporting expectation

Demonstrating that you can invoke this helper yourself, or that doing so exports
a key after the user grants the macOS prompt, is **out of scope by design** — it
is equivalent to calling Apple's keychain APIs, which any local process with the
user's privileges can already do. Reports must show a privilege boundary being
crossed that the OS would otherwise enforce.
14 changes: 14 additions & 0 deletions cli-helper/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@capgo/cli-keychain-darwin-arm64",
"version": "0.0.0",
"description": "Precompiled macOS (Apple Silicon) keychain helper for @capgo/cli",
"repository": {
"type": "git",
"url": "git+https://github.com/Cap-go/capgo.git",
"directory": "cli-helper"
},
"license": "Apache 2.0",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"os": ["darwin"],
"cpu": ["arm64"],
"files": ["helper"]
}
14 changes: 14 additions & 0 deletions cli-helper/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@capgo/cli-keychain-darwin-x64",
"version": "0.0.0",
"description": "Precompiled macOS (Intel) keychain helper for @capgo/cli",
"repository": {
"type": "git",
"url": "git+https://github.com/Cap-go/capgo.git",
"directory": "cli-helper"
},
"license": "Apache 2.0",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"os": ["darwin"],
"cpu": ["x64"],
"files": ["helper"]
}
13 changes: 13 additions & 0 deletions cli-helper/scripts/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Compile helper for both macOS architectures into cli-helper/dist/.
# arm64 targets macOS 11 (first Apple Silicon release); x64 targets 10.15
# (oldest macOS that can run Node 20, the CLI's floor).
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p dist
swiftc src/helper.swift -framework Security -O \
-target arm64-apple-macos11 -o dist/helper-arm64
swiftc src/helper.swift -framework Security -O \
-target x86_64-apple-macos10.15 -o dist/helper-x64
echo "Built:"
file dist/helper-arm64 dist/helper-x64
33 changes: 33 additions & 0 deletions cli-helper/scripts/prepare-publish.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Stamp the release version into both npm manifests and copy the signed
// binaries into their package dirs.
// Usage: node cli-helper/scripts/prepare-publish.mjs <semver>
// Fails fast on a malformed version or missing binary so a bad tag can
// never publish.
import { chmodSync, copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const version = process.argv[2]
if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
console.error(`Usage: node prepare-publish.mjs <semver> — got "${version ?? ''}"`)
process.exit(1)
}

for (const arch of ['arm64', 'x64']) {
const src = join(root, 'dist', `helper-${arch}`)
if (!existsSync(src)) {
console.error(`Missing binary ${src} — run build.sh + sign-and-notarize.sh first`)
process.exit(1)
}
const pkgDir = join(root, 'npm', `darwin-${arch}`)
const manifestPath = join(pkgDir, 'package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
const updated = { ...manifest, version }
writeFileSync(manifestPath, `${JSON.stringify(updated, null, 2)}\n`)
const dest = join(pkgDir, 'helper')
copyFileSync(src, dest)
chmodSync(dest, 0o755)
console.log(`Prepared ${manifest.name}@${version}`)
}
53 changes: 53 additions & 0 deletions cli-helper/scripts/sign-and-notarize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Codesign (hardened runtime + timestamp) and notarize both helper binaries,
# then verify each against the same designated requirement the CLI enforces
# at runtime — a cert/team mismatch fails the release, not the user.
#
# Required env:
# DEVELOPER_ID_IDENTITY codesign identity, e.g. "Developer ID Application: <name> (<TEAMID>)"
# CAPGO_APPLE_TEAM_ID 10-char Apple Team ID (must match macos-signing.ts)
# APPLE_KEY_ID App Store Connect API key id
# APPLE_ISSUER_ID App Store Connect API key issuer
# APPLE_KEY_PATH path to the API key .p8 file
set -euo pipefail
cd "$(dirname "$0")/.."

: "${DEVELOPER_ID_IDENTITY:?}" "${CAPGO_APPLE_TEAM_ID:?}" "${APPLE_KEY_ID:?}" "${APPLE_ISSUER_ID:?}" "${APPLE_KEY_PATH:?}"

REQUIREMENT='=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "'"$CAPGO_APPLE_TEAM_ID"'"'

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Stable code-signing identifier. macOS keys the Keychain "Always Allow" grant
# to the code's designated requirement, which includes this identifier — so
# pinning it now keeps users' grants intact across future re-signs, including a
# possible migration to a `Capgo.app` bundle that reuses the SAME
# CFBundleIdentifier. Never change this value. See "Future: native
# notifications & UI" in the design spec.
HELPER_IDENTIFIER="app.capgo.cli.helper"

for arch in arm64 x64; do
bin="dist/helper-$arch"
echo "── Signing $bin"
codesign --force --sign "$DEVELOPER_ID_IDENTITY" --identifier "$HELPER_IDENTIFIER" --options runtime --timestamp "$bin"

echo "── Notarizing $bin"
ditto -c -k "$bin" "$bin.zip"
out=$(xcrun notarytool submit "$bin.zip" \
--key "$APPLE_KEY_PATH" --key-id "$APPLE_KEY_ID" --issuer "$APPLE_ISSUER_ID" \
--wait --timeout 30m --output-format json) || true
id=$(echo "$out" | jq -r '.id // empty')
status=$(echo "$out" | jq -r '.status // empty')
if [ "$status" != "Accepted" ]; then

Check failure on line 39 in cli-helper/scripts/sign-and-notarize.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ6neWsVe1EwRgTwlwdU&open=AZ6neWsVe1EwRgTwlwdU&pullRequest=2458
echo "Notarization failed for $bin (status: ${status:-unknown})" >&2
if [ -n "$id" ]; then

Check failure on line 41 in cli-helper/scripts/sign-and-notarize.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ6neWsVe1EwRgTwlwdV&open=AZ6neWsVe1EwRgTwlwdV&pullRequest=2458
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."
Loading
Loading