diff --git a/.bumpy/sign-standalone-macos-binaries.md b/.bumpy/sign-standalone-macos-binaries.md new file mode 100644 index 000000000..8c1ac15a1 --- /dev/null +++ b/.bumpy/sign-standalone-macos-binaries.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +The standalone macOS `varlock` binaries are now Developer ID signed and notarized, with the hardened runtime enabled and no entitlement exceptions granted. Without the hardened runtime, any process running as your user could attach to varlock and read resolved secrets out of its memory. diff --git a/.github/workflows/binary-release.yaml b/.github/workflows/binary-release.yaml index 65e3c8b99..930b6c3ef 100644 --- a/.github/workflows/binary-release.yaml +++ b/.github/workflows/binary-release.yaml @@ -6,9 +6,13 @@ name: Re-cut varlock CLI binaries # The normal release flow (release.yaml) builds and uploads these in the same run # as the npm publish, reusing the in-run signed/notarized native binaries. This # workflow is only for re-cutting binaries of an existing version — it pulls the -# signed native binaries from the published npm package (varlock's `files` -# includes /native-bins) rather than rebuilding + re-signing them, so it needs no -# macOS/Windows runner, no Azure, and no Apple credentials. +# signed native *helper* binaries from the published npm package (varlock's +# `files` includes /native-bins) rather than rebuilding + re-signing them, so it +# needs no Windows runner and no Azure. +# +# It does need a macOS runner and Apple credentials: the standalone `varlock` CLI +# binary is itself Developer ID signed and notarized, and that is rebuilt here +# rather than being recoverable from npm. on: workflow_dispatch: inputs: @@ -23,7 +27,20 @@ permissions: concurrency: ${{ github.workflow }}-${{ inputs.version }} jobs: + # macOS CLI archives need a macOS runner for codesign + notarytool + build-cli-binaries-macos: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/build-cli-binaries-macos.yaml + with: + build-type: release + native-bins-npm-version: ${{ inputs.version }} + artifact-name: varlock-cli-binaries-macos + notarize: true + secrets: + OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }} + release-binaries: + needs: build-cli-binaries-macos if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: @@ -85,8 +102,29 @@ jobs: run: bun run build:libs env: BUILD_TYPE: release - - name: Build varlock SEA binaries - run: bun run packages/varlock/scripts/build-binaries.ts + # macOS is excluded here — those archives come from build-cli-binaries-macos + - name: Build varlock SEA binaries (non-macOS) + run: | + bun run packages/varlock/scripts/build-binaries.ts \ + --targets=linux-x64,linux-arm64,linux-musl-x64,linux-musl-arm64,win-x64 + + # After the build, since build-binaries.ts clears dist-sea on start + - name: Download signed macOS CLI archives + uses: actions/download-artifact@v8 + with: + name: varlock-cli-binaries-macos + path: packages/varlock/dist-sea + - name: Add macOS archives to checksums + working-directory: packages/varlock/dist-sea + run: | + set -euo pipefail + for f in varlock-macos-x64.tar.gz varlock-macos-arm64.tar.gz; do + [ -f "$f" ] || { echo "::error::missing $f from the macOS build"; exit 1; } + done + sha256sum varlock-macos-*.tar.gz >> checksums.txt + sort -k2 checksums.txt -o checksums.txt + cat checksums.txt + # See the matching step in release.yaml for why only checksums.txt is signed. - name: Sign checksums with cosign working-directory: packages/varlock/dist-sea diff --git a/.github/workflows/build-cli-binaries-macos.yaml b/.github/workflows/build-cli-binaries-macos.yaml new file mode 100644 index 000000000..f946c0f74 --- /dev/null +++ b/.github/workflows/build-cli-binaries-macos.yaml @@ -0,0 +1,256 @@ +name: Build macOS varlock CLI binaries + +# Reusable workflow that builds the two macOS standalone (SEA) CLI archives on a +# macOS runner, Developer ID signs the `varlock` Mach-O with hardened runtime, +# and notarizes it. +# +# Why a separate job: codesign and notarytool only exist on macOS, but the other +# five targets cross-compile fine on linux and there's no reason to move them. +# The caller builds those with `--targets=` and downloads this job's archives. +# +# Hardened runtime is the substance here. Without it (or with get-task-allow +# granted) any process running as the same user can attach to varlock and read +# resolved secrets out of its memory. Entitlements live in +# packages/varlock/varlock-cli.entitlements and grant nothing. +# +# Note there is no stapling step: `xcrun stapler` only handles bundles, disk +# images and installer packages, not bare Mach-O executables. The notarization +# ticket is published by Apple and Gatekeeper resolves it online, which is the +# normal arrangement for a signed CLI shipped in a tarball. + +permissions: + contents: read + +on: + workflow_call: + inputs: + build-type: + description: 'BUILD_TYPE for the libs build: release or preview' + type: string + default: 'release' + native-bins-artifact: + description: 'Artifact holding packages/varlock/native-bins (signed + notarized helpers). Mutually exclusive with native-bins-npm-version.' + type: string + default: '' + native-bins-npm-version: + description: 'Published varlock version to pull native-bins from, for re-cuts of an existing release.' + type: string + default: '' + artifact-name: + description: 'Name for the uploaded archive artifact' + type: string + default: 'varlock-cli-binaries-macos' + notarize: + description: 'Submit the signed binaries to Apple for notarization' + type: boolean + default: true + secrets: + OP_CI_TOKEN: + required: true + +jobs: + build-macos-cli-binaries: + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Use Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: "24.x" + - name: Install node deps + run: bun install + - name: Enable turborepo build cache + uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14 + + - name: Validate native-bins source + run: | + set -euo pipefail + if [ -n "${{ inputs.native-bins-artifact }}" ] && [ -n "${{ inputs.native-bins-npm-version }}" ]; then + echo "::error::Pass either native-bins-artifact or native-bins-npm-version, not both" + exit 1 + fi + if [ -z "${{ inputs.native-bins-artifact }}" ] && [ -z "${{ inputs.native-bins-npm-version }}" ]; then + echo "::error::One of native-bins-artifact or native-bins-npm-version is required" + exit 1 + fi + + - name: Download native binaries (artifact) + if: inputs.native-bins-artifact != '' + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.native-bins-artifact }} + path: packages/varlock/native-bins + + # Re-cut path: the published npm package ships the signed/notarized helpers + # in /native-bins, so we reuse them rather than rebuilding and re-signing. + - name: Fetch native binaries from published npm package + if: inputs.native-bins-npm-version != '' + env: + RELEASE_VERSION: ${{ inputs.native-bins-npm-version }} + run: | + set -euo pipefail + TMP="$RUNNER_TEMP/varlock-npm" + mkdir -p "$TMP" && cd "$TMP" + for i in $(seq 1 30); do + if npm view "varlock@${RELEASE_VERSION}" version >/dev/null 2>&1; then break; fi + echo "waiting for varlock@${RELEASE_VERSION} on npm ($i)..."; sleep 10 + done + npm pack "varlock@${RELEASE_VERSION}" + tar -xzf varlock-*.tgz + rm -rf "$GITHUB_WORKSPACE/packages/varlock/native-bins" + cp -R package/native-bins "$GITHUB_WORKSPACE/packages/varlock/native-bins" + + - name: Restore native binary execute permissions + run: chmod +x packages/varlock/native-bins/darwin/VarlockEnclave.app/Contents/MacOS/varlock-local-encrypt + + - name: Build libs + run: bun run build:libs + env: + BUILD_TYPE: ${{ inputs.build-type }} + + # Apple credentials come from the same 1Password item the native-binary + # workflows use, so there is one place to rotate them + - name: Load signing secrets + uses: dmno-dev/varlock-action@v1.0.5 + with: + working-directory: packages/encryption-binary-swift + env: + OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }} + + - name: Import signing certificate + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 24) + + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/certificate.p12 + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + security import $RUNNER_TEMP/certificate.p12 \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + echo "APPLE_SIGNING_IDENTITY=$APPLE_SIGNING_IDENTITY" >> $GITHUB_ENV + + # The script signs each macOS `varlock` binary right after compiling it and + # before archiving. It deliberately does not touch the bundled + # VarlockEnclave.app, whose stapled ticket a re-sign would invalidate. + - name: Build and sign macOS CLI binaries + run: | + bun run packages/varlock/scripts/build-binaries.ts \ + --targets=macos-x64,macos-arm64 \ + --sign "$APPLE_SIGNING_IDENTITY" + + - name: Notarize signed binaries + if: inputs.notarize + working-directory: packages/varlock/dist-sea + env: + OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }} + run: | + set -euo pipefail + # notarytool takes a zip/pkg/dmg container, so submit both binaries in one + ditto -c -k macos-x64/varlock $RUNNER_TEMP/varlock-macos-x64.zip + ditto -c -k macos-arm64/varlock $RUNNER_TEMP/varlock-macos-arm64.zip + + for arch in x64 arm64; do + echo "=== notarizing macos-$arch ===" + xcrun notarytool submit "$RUNNER_TEMP/varlock-macos-$arch.zip" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + done + + - name: Verify signatures + run: | + set -euo pipefail + cd packages/varlock/dist-sea + FAILED=0 + for arch in x64 arm64; do + BIN="macos-$arch/varlock" + echo "=== $BIN ===" + lipo -info "$BIN" + codesign --verify --strict --verbose=2 "$BIN" + codesign -dvvv "$BIN" 2>&1 | grep -E "Authority|TeamIdentifier|flags=" || true + + # Hardened runtime must be on, or the whole point is lost + if ! codesign -dvvv "$BIN" 2>&1 | grep -q "flags=.*runtime"; then + echo "::error::$BIN is missing the hardened runtime flag" + FAILED=1 + fi + if ! codesign -dvvv "$BIN" 2>&1 | grep -q "Developer ID Application"; then + echo "::error::$BIN is not Developer ID signed" + FAILED=1 + fi + # Any granted exception weakens the runtime; the entitlements file + # grants none, so a here means it drifted + if codesign -d --entitlements - --xml "$BIN" 2>/dev/null | grep -q ""; then + echo "::error::$BIN was signed with a granted hardened-runtime exception" + codesign -d --entitlements - --xml "$BIN" 2>/dev/null + FAILED=1 + fi + done + exit $FAILED + + # Test what users actually get: extract the archive and exercise it there. + # The signature has to survive tar, and the bundled .app has to keep its own. + - name: Verify archive round-trip + working-directory: packages/varlock/dist-sea + run: | + set -euo pipefail + for arch in x64 arm64; do + DEST="$RUNNER_TEMP/extract-$arch" + rm -rf "$DEST" && mkdir -p "$DEST" + tar -xzf "varlock-macos-$arch.tar.gz" -C "$DEST" + + if find "$DEST" -name '._*' | grep -q .; then + echo "::error::AppleDouble sidecar files leaked into varlock-macos-$arch.tar.gz" + exit 1 + fi + codesign --verify --strict "$DEST/varlock" + codesign --verify --deep --strict "$DEST/VarlockEnclave.app" + # The .app's own notarization is asserted by verify-native-macos; a + # missing ticket here is worth surfacing but is not this job's gate + xcrun stapler validate "$DEST/VarlockEnclave.app" \ + || echo "::warning::bundled VarlockEnclave.app has no stapled notarization ticket" + echo "varlock-macos-$arch.tar.gz round-trip OK" + done + + # The runner is arm64, so only that slice can actually execute + - name: Smoke test the extracted arm64 binary + run: | + set -euo pipefail + cd "$RUNNER_TEMP/extract-arm64" + ./varlock --version + ./varlock --help > /dev/null + printf 'PUBLIC_VAR=hello\n' > .env.schema + ./varlock load --format json + ./varlock run -- node -e 'if (process.env.PUBLIC_VAR !== "hello") { console.error("env not injected"); process.exit(1); }' + + - name: Upload macOS CLI archives + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-name }} + path: | + packages/varlock/dist-sea/varlock-macos-x64.tar.gz + packages/varlock/dist-sea/varlock-macos-arm64.tar.gz + retention-days: 7 + + - name: Cleanup signing keychain + if: always() + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db + if [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + rm -f $RUNNER_TEMP/certificate.p12 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fc308d33d..d70dc808b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -425,12 +425,29 @@ jobs: # TODO: send notifications? # --- varlock CLI binary distribution --------------------------------------- + # The macOS CLI archives are built on a macOS runner so the `varlock` binary can + # be Developer ID signed with hardened runtime and notarized. The other five + # targets keep cross-compiling on linux in release-binaries below. + build-cli-binaries-macos: + name: Build + sign macOS CLI binaries + needs: [plan, release] + # Same guard as release-binaries — see the comment there + if: always() && !failure() && !cancelled() && needs.plan.outputs.mode == 'publish' && needs.plan.outputs.includes-varlock == 'true' + uses: ./.github/workflows/build-cli-binaries-macos.yaml + with: + build-type: release + native-bins-artifact: native-bins-staged + artifact-name: varlock-cli-binaries-macos + notarize: true + secrets: + OP_CI_TOKEN: ${{ secrets.OP_CI_TOKEN }} + # Reuse the signed/notarized native binaries built earlier in THIS run (no # rebuild, no re-sign, no Azure/Apple on this path). Gated on a varlock publish; # the varlock@ GitHub release that bumpy just created is the target. release-binaries: name: Release varlock CLI binaries - needs: [plan, release] + needs: [plan, release, build-cli-binaries-macos] # `release` always has a by-design skipped dependency (the native-binary cache # logic skips either verify-native-macos on a miss, or build/notarize on a hit), # and GitHub propagates that skip through `release`'s always() into the implicit @@ -494,8 +511,29 @@ jobs: run: bun run build:libs env: BUILD_TYPE: release - - name: Build varlock SEA binaries - run: bun run packages/varlock/scripts/build-binaries.ts + # macOS is excluded here — those archives are built and signed on a macOS + # runner by build-cli-binaries-macos and downloaded below + - name: Build varlock SEA binaries (non-macOS) + run: | + bun run packages/varlock/scripts/build-binaries.ts \ + --targets=linux-x64,linux-arm64,linux-musl-x64,linux-musl-arm64,win-x64 + + # After the build, since build-binaries.ts clears dist-sea on start + - name: Download signed macOS CLI archives + uses: actions/download-artifact@v8 + with: + name: varlock-cli-binaries-macos + path: packages/varlock/dist-sea + - name: Add macOS archives to checksums + working-directory: packages/varlock/dist-sea + run: | + set -euo pipefail + for f in varlock-macos-x64.tar.gz varlock-macos-arm64.tar.gz; do + [ -f "$f" ] || { echo "::error::missing $f from the macOS build"; exit 1; } + done + sha256sum varlock-macos-*.tar.gz >> checksums.txt + sort -k2 checksums.txt -o checksums.txt + cat checksums.txt # Sign checksums.txt rather than each archive: it already covers every # archive by hash, so one signature transitively covers them all, and diff --git a/AGENTS.md b/AGENTS.md index c86b3b2de..aed7db5d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,11 @@ This is a monorepo managed with bun workspaces and Turborepo: - The varlock CLI binary is built using `bun build --compile` (not Node SEA or pkg) - `bun run --filter varlock build:binary` builds a local dev binary for the current platform at `packages/varlock/dist-sea/varlock` -- `packages/varlock/scripts/build-binaries.ts` builds cross-platform release binaries (or use `--current-platform` for a single local binary) +- `packages/varlock/scripts/build-binaries.ts` builds cross-platform release binaries (`--dev` for a single local binary, `--targets=macos-x64,linux-x64,...` for a subset) - `bun run --filter varlock test:binary:local` builds the local binary and runs a smoke `load` check (WSL-aware helper copy) - `bun run --filter varlock pack:local` builds + packs a local tarball and prints a ready-to-paste `file:` dependency +- On macOS the CLI binary is codesigned with hardened runtime, entitlements in `packages/varlock/varlock-cli.entitlements` (every exception ``). Local builds get an ad-hoc signature; set `APPLE_SIGNING_IDENTITY` or pass `--sign ""` for a real one, or `--no-sign` to skip (hardened runtime blocks debugger attach, so stepping through the compiled binary needs `--no-sign`). Note the entitlements plist cannot contain XML comments, codesign rejects them +- Release CI splits the build: `build-cli-binaries-macos.yaml` builds + signs + notarizes the two macOS archives on a macOS runner, and `release-binaries` cross-compiles the rest on linux and merges the checksums ## Testing diff --git a/packages/varlock-website/src/content/docs/getting-started/installation.mdx b/packages/varlock-website/src/content/docs/getting-started/installation.mdx index c43815f70..cded7053a 100644 --- a/packages/varlock-website/src/content/docs/getting-started/installation.mdx +++ b/packages/varlock-website/src/content/docs/getting-started/installation.mdx @@ -112,6 +112,30 @@ Both workflow identities are accepted because binaries are normally cut by `rele Signing was added after varlock 1.14.0, so older releases have `checksums.txt` but no bundle. If a release has no `checksums.txt.cosign.bundle` asset, it predates signing and only the checksum step applies. ::: +### macOS code signing + +The `varlock` binary in the macOS archives is Developer ID signed and notarized, built with the hardened runtime enabled and no entitlement exceptions granted. Check a binary you have on disk with: + +```bash +codesign -dvvv /path/to/varlock +``` + +Two things to look for: a `flags=` line that includes `runtime`, and an `Authority=Developer ID Application: ...` line naming varlock's signing team. To confirm no exceptions were granted: + +```bash +codesign -d --entitlements - --xml /path/to/varlock +``` + +Every key should be ``. + +The hardened runtime is doing real work here, not just satisfying Gatekeeper. Without it, any process running as your user can attach a debugger to varlock and read resolved secret values out of its memory. Several entitlements reopen that same hole, notably `get-task-allow`, `com.apple.security.cs.debugger`, and `disable-executable-page-protection`, which is why varlock grants none of them. This matters for anything that decides what a process is allowed to do based on its runtime posture, and it is worth knowing that a `node`-based install cannot offer the same property: the official Node.js binaries ship with `get-task-allow` enabled, so they are attachable regardless of the hardened runtime flag. + +The binary has no stapled notarization ticket, because `xcrun stapler` only handles bundles, disk images, and installer packages, not bare executables. Apple publishes the ticket and Gatekeeper resolves it online, so notarization still applies to an archive you downloaded through a browser. The `VarlockEnclave.app` bundled alongside the CLI is signed, notarized, and stapled separately, and you can check it with `xcrun stapler validate VarlockEnclave.app`. + +:::note +macOS signing of the standalone CLI binary was added after varlock 1.16.1. Earlier standalone archives contain an unsigned `varlock` binary, though the bundled `VarlockEnclave.app` helper has been signed and notarized for longer. +::: + ## Varlock skill Then install the Varlock agent skill: diff --git a/packages/varlock-website/src/content/docs/guides/local-encryption.mdx b/packages/varlock-website/src/content/docs/guides/local-encryption.mdx index a1185348a..3f743645e 100644 --- a/packages/varlock-website/src/content/docs/guides/local-encryption.mdx +++ b/packages/varlock-website/src/content/docs/guides/local-encryption.mdx @@ -214,4 +214,4 @@ shasum -a 256 varlock-local-encrypt **Reporting.** If you hit a detection on a current release, [file an issue](https://github.com/dmno-dev/varlock/issues) with the file path and the detection name. You can also [submit the file to Microsoft](https://www.microsoft.com/en-us/wdsi/filesubmission) as a false positive, which is what gets the definition corrected for everyone. -**What we do to prevent this.** Windows binaries are Authenticode-signed via Azure Artifact Signing, macOS binaries are Developer ID signed and notarized, and no binary is compressed with an executable packer (packers such as UPX are a well-known trigger for these detections). \ No newline at end of file +**What we do to prevent this.** Windows binaries are Authenticode-signed via Azure Artifact Signing, macOS binaries are Developer ID signed and notarized (both the `VarlockEnclave.app` helper and the standalone `varlock` CLI, see [macOS code signing](/getting-started/installation/#macos-code-signing)), and no binary is compressed with an executable packer (packers such as UPX are a well-known trigger for these detections). \ No newline at end of file diff --git a/packages/varlock/scripts/build-binaries.ts b/packages/varlock/scripts/build-binaries.ts index ed1cb6a35..cd48720cb 100644 --- a/packages/varlock/scripts/build-binaries.ts +++ b/packages/varlock/scripts/build-binaries.ts @@ -8,6 +8,7 @@ const PKG_DIR = path.resolve(__dirname, '..'); const DIST_DIR = 'dist-sea'; const NATIVE_BINS_DIR = path.join(PKG_DIR, 'native-bins'); const ENTRY = 'src/cli/cli-executable.ts'; +const ENTITLEMENTS = path.join(PKG_DIR, 'varlock-cli.entitlements'); const ALL_TARGETS = [ { bunTarget: 'bun-darwin-x64', archiveName: 'macos-x64' }, @@ -22,6 +23,40 @@ const ALL_TARGETS = [ const devMode = process.argv.includes('--dev'); const skipNative = process.argv.includes('--skip-native'); +function getArg(flag: string): string | undefined { + const idx = process.argv.indexOf(flag); + if (idx === -1) return undefined; + return process.argv[idx + 1]; +} + +// --targets=macos-x64,macos-arm64 restricts the build to a subset of archives. +// Release CI uses it to split the macOS archives onto a macOS runner (where +// codesign exists) while the rest keep building on linux. +const targetsArg = process.argv.find((a) => a.startsWith('--targets='))?.slice('--targets='.length); +const selectedTargets = targetsArg + ? targetsArg.split(',').map((t) => t.trim()).filter(Boolean) + : null; +if (selectedTargets) { + const known = new Set(ALL_TARGETS.map((t) => t.archiveName)); + const unknown = selectedTargets.filter((t) => !known.has(t)); + if (unknown.length) { + throw new Error(`Unknown --targets value(s): ${unknown.join(', ')}. Known: ${[...known].join(', ')}`); + } +} +const TARGETS = selectedTargets + ? ALL_TARGETS.filter((t) => selectedTargets.includes(t.archiveName)) + : ALL_TARGETS; + +// Signing identity: explicit flag > env var > ad-hoc. Mirrors the resolution +// order in packages/encryption-binary-swift/scripts/build-swift.ts. +const signingIdentity = getArg('--sign') ?? process.env.APPLE_SIGNING_IDENTITY; +// Escape hatch: hardened runtime blocks debugger attach, so a local build you +// want to step through needs --no-sign +const skipSigning = process.argv.includes('--no-sign'); + +// sha256sum is GNU coreutils; macOS ships shasum instead +const SHA256_CMD = process.platform === 'darwin' ? 'shasum -a 256' : 'sha256sum'; + function isWSL(): boolean { if (process.platform !== 'linux') return false; if (process.env.WSL_DISTRO_NAME) return true; @@ -37,6 +72,51 @@ function exec(cmd: string) { execSync(cmd, { cwd: PKG_DIR, stdio: 'inherit' }); } +/** + * Developer ID sign the CLI binary with hardened runtime enabled. + * + * Only the `varlock` Mach-O is signed. The VarlockEnclave.app sitting next to it + * is already signed and notarized by the native-binary workflow, and re-signing + * it here would invalidate its stapled ticket, hence no `--deep`. + * + * Hardened runtime is the point of this, not a formality: without it (or with + * `get-task-allow` granted) any process running as the same user can attach to + * varlock and read resolved secrets straight out of its memory. + * + * varlock-cli.entitlements lists every hardened-runtime exception as `` + * rather than omitting them, so a future edit that flips one shows up in the + * diff. Bun's codesigning guide suggests granting allow-jit, + * allow-unsigned-executable-memory, disable-executable-page-protection, + * allow-dyld-environment-variables, and disable-library-validation; none are + * actually needed. The compiled binary was verified to run under + * `--options runtime` with an empty entitlement set, including the path that + * spawns VarlockEnclave.app and talks to it over its unix socket. Note that the + * entitlements plist cannot carry XML comments: codesign feeds it to + * AMFIUnserializeXML, which rejects them. + */ +function signMacBinary(binPath: string) { + if (skipSigning) { + console.log(' Skipping codesign (--no-sign)'); + return; + } + if (process.platform !== 'darwin') { + console.log(' Skipping codesign (not running on macOS)'); + return; + } + if (!fs.existsSync(ENTITLEMENTS)) { + throw new Error(`Entitlements file not found at ${ENTITLEMENTS}`); + } + + if (signingIdentity) { + console.log(` Signing ${path.basename(binPath)} with "${signingIdentity}"`); + exec(`codesign --force --options runtime --timestamp --entitlements "${ENTITLEMENTS}" --sign "${signingIdentity}" "${binPath}"`); + } else { + console.log(` Ad-hoc signing ${path.basename(binPath)} (set APPLE_SIGNING_IDENTITY for a real signature)`); + exec(`codesign --force --options runtime --entitlements "${ENTITLEMENTS}" --sign - "${binPath}"`); + } + exec(`codesign --verify --strict --verbose=2 "${binPath}"`); +} + exec(`rm -rf ${DIST_DIR}`); exec(`mkdir -p ${DIST_DIR}`); @@ -58,6 +138,10 @@ if (devMode) { // Bundle platform-specific native binary alongside the dev binary if (process.platform === 'darwin') { + // Ad-hoc sign with hardened runtime so the local binary has the same runtime + // posture as a release build + signMacBinary(path.join(PKG_DIR, DIST_DIR, binName)); + const appBundleSrc = path.join(NATIVE_BINS_DIR, 'darwin', 'VarlockEnclave.app'); if (fs.existsSync(appBundleSrc)) { console.log('Bundling macOS native binary (VarlockEnclave.app)'); @@ -79,8 +163,8 @@ if (devMode) { } } } else { - // Build for all platforms and create archives - for (const { bunTarget, archiveName } of ALL_TARGETS) { + // Build for all selected platforms and create archives + for (const { bunTarget, archiveName } of TARGETS) { console.log(`Building: ${bunTarget}`); const isWin = archiveName.startsWith('win-'); const targetDir = `${DIST_DIR}/${archiveName}`; @@ -104,6 +188,12 @@ if (devMode) { ENTRY, ].join(' ')); + // Sign before archiving. codesign happily signs a cross-compiled x64 Mach-O + // from an arm64 host, so both macOS archives can be signed in one job. + if (archiveName.startsWith('macos-')) { + signMacBinary(path.join(PKG_DIR, targetDir, binName)); + } + // Bundle platform-specific native binaries alongside the CLI binary if (!skipNative) { const isMac = archiveName.startsWith('macos-'); @@ -155,10 +245,12 @@ if (devMode) { archiveCmd = `zip -j ${DIST_DIR}/${archive} ${targetDir}/*`; } else { archive = `varlock-${archiveName}.tar.gz`; - archiveCmd = `tar --gzip -cf ${DIST_DIR}/${archive} -C ${targetDir}/ .`; + // COPYFILE_DISABLE stops bsdtar (macOS) from emitting ._* AppleDouble + // sidecars for the signed .app; a no-op for GNU tar on linux + archiveCmd = `COPYFILE_DISABLE=1 tar --gzip -cf ${DIST_DIR}/${archive} -C ${targetDir}/ .`; } exec(archiveCmd); - execSync(`sha256sum ${archive} >> checksums.txt`, { + execSync(`${SHA256_CMD} ${archive} >> checksums.txt`, { cwd: path.join(PKG_DIR, DIST_DIR), }); } @@ -166,7 +258,7 @@ if (devMode) { // Print size summary for all archives console.log('\n=== Release archive size summary ==='); let totalBytes = 0; - for (const { archiveName } of ALL_TARGETS) { + for (const { archiveName } of TARGETS) { const isWin = archiveName.startsWith('win-'); const ext = isWin ? 'zip' : 'tar.gz'; const archivePath = path.join(PKG_DIR, DIST_DIR, `varlock-${archiveName}.${ext}`); diff --git a/packages/varlock/varlock-cli.entitlements b/packages/varlock/varlock-cli.entitlements new file mode 100644 index 000000000..a9724b663 --- /dev/null +++ b/packages/varlock/varlock-cli.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-executable-page-protection + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.debugger + + com.apple.security.get-task-allow + + +