From 9e52ac60c46df497ba39bec9543e2a6cb2799394 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 2 Oct 2026 06:22:26 +0000 Subject: [PATCH] fix(mcp): preserve macOS release identity across standalone upgrades Sign and verify macOS MCP artifacts with a stable Developer ID requirement. Reject unsigned npm reruns, verify archive integrity, and clean up the isolated CI keychain on success or failure. Closes #10715 --- .github/scripts/mcp-signing.md | 32 ++ .github/scripts/sign-mcp-macos.mjs | 124 ++++++ .github/scripts/test-mcp-signing-macos.sh | 60 +++ .github/workflows/mcp-release.yml | 63 ++- packages/app-tests/mcpReleaseSigning.test.ts | 409 +++++++++++++++++++ 5 files changed, 681 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/mcp-signing.md create mode 100644 .github/scripts/sign-mcp-macos.mjs create mode 100644 .github/scripts/test-mcp-signing-macos.sh create mode 100644 packages/app-tests/mcpReleaseSigning.test.ts diff --git a/.github/scripts/mcp-signing.md b/.github/scripts/mcp-signing.md new file mode 100644 index 0000000000..aeb77f8b6f --- /dev/null +++ b/.github/scripts/mcp-signing.md @@ -0,0 +1,32 @@ +# macOS MCP release identity + +`mcp-release.yml` signs both native macOS MCP matrix builds after compilation and before npm staging. The release uses the desktop workflow's existing `APPLE_CERTIFICATE` (base64 PKCS12), `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY` and `APPLE_TEAM_ID` secrets. The identity must be a valid Developer ID Application certificate; its exact name or SHA-1 fingerprint selects the imported identity. Signing credentials are used only in the signing step. + +`sign-mcp-macos.mjs` imports that identity into a unique temporary keychain with a random password. Only the imported key receives the CI signing access settings. The helper restores the entire original user keychain search list, deletes only its temporary keychain, and removes the PKCS12 material on success, error or handled interruption. Cleanup errors fail the release. Like other process cleanup, this cannot run after SIGKILL or loss of the runner; use disposable CI runners for release signing. + +The dedicated identifier is `com.dbx.app.mcp`. Its designated requirement binds the identifier to Apple's anchor, the Developer ID intermediate and Application certificate markers, and the configured Team ID. It follows Apple's Developer ID requirement generation and intentionally contains neither a build hash nor an individual certificate fingerprint. Renewal or rotation to another valid Developer ID Application certificate for the same team preserves that identity; update the configured signing identity when its name or fingerprint changes. A different team requires an explicit identity migration and renewed user authorization. + +Signing requires a secure timestamp (`--timestamp`). An unavailable timestamp service, expired signing identity, missing credentials, incorrect team/identifier, weaker designated requirement, or invalid signature blocks publication. There is no ad-hoc fallback. Verification checks all architectures with `--strict` and the expected certificate requirement, as well as the embedded identifier, team, timestamp and designated requirement. + +Staging and npm packing must preserve the signed bytes. macOS publishes the verified tarball, with package lifecycle scripts disabled. A rerun downloads and verifies an already-published npm package before accepting it; an older unsigned version must be replaced by a **new package version**, since npm versions are immutable. The MCP standalone repack job runs on macOS so it can verify both architectures before archiving and after extraction. Homebrew uses these archives and their SHA-256 checksums. The other Homebrew update workflow also renders the same formula from the published MCP archive checksums. There are no separate legacy MCP compiler jobs at this revision; any future build leg must use the same signing and verification gates. CLI signing and publication are separate. + +The first upgrade from an ad-hoc build can still prompt once. Later builds with this identity can satisfy the earlier build's designated requirement. Custom Keychain ACLs and simultaneous prompts are separate behaviors; this release change does not edit users' keys or access controls. MCP startup already opens storage with secret-key creation disabled, and the standalone installer only replaces the binary/version marker. + +## Validation + +Run the focused `packages/app-tests/mcpReleaseSigning.test.ts` Vitest file. Its executable security/codesign mocks exercise the helper and the workflow shell blocks, including failures and cleanup; they do not establish that Apple accepted a real signature. + +For a native check, use a disposable macOS runner with Xcode command-line tools, Node 22.13+ and **controlled** Developer ID Application credentials supplied through the four environment variables above. Set `TMPDIR` or `RUNNER_TEMP` to a private scratch directory, then run from the checkout: + +```sh +bash .github/scripts/test-mcp-signing-macos.sh +``` + +The bounded probe compiles two distinct inert executables for each architecture, signs them with the production helper, checks the second against the first's requirement, verifies archive preservation, rejects tampering/ad-hoc signatures, and compares the keychain search list before/after. It never executes those binaries or reads any DBX secret-store item. A real Developer ID release and a consenting user's Keychain upgrade check remain separate release validation; no production credentials are required for the Linux regression tests. + +## Apple references + +- [Requirement Language](https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/RequirementLang/RequirementLang.html): build-specific `cdhash`, ad-hoc identity, certificate fields and Team ID. +- [TN2206](https://developer.apple.com/library/archive/technotes/tn2206/_index.html): Keychain tracking of designated requirements, certificate reissuance and secure timestamp validity. +- [Apple Security DRMaker](https://github.com/apple-oss-distributions/Security/blob/main/OSX/libsecurity_codesigning/lib/drmaker.cpp): Developer ID requirement generation using Apple's anchor, certificate markers and leaf `subject.OU`. +- [Apple Security requirement dumper](https://github.com/apple-oss-distributions/Security/blob/main/OSX/libsecurity_codesigning/lib/reqdumper.cpp): canonical display of certificate existence constraints. diff --git a/.github/scripts/sign-mcp-macos.mjs b/.github/scripts/sign-mcp-macos.mjs new file mode 100644 index 0000000000..5e595e349c --- /dev/null +++ b/.github/scripts/sign-mcp-macos.mjs @@ -0,0 +1,124 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const mcpIdentifier = "com.dbx.app.mcp"; + +export function mcpRequirement(team) { + if (!/^[A-Z0-9]{10}$/.test(team ?? "")) throw new Error("A valid APPLE_TEAM_ID is required."); + return `identifier "${mcpIdentifier}" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "${team}"`; +} + +function context(options) { + const { platform = process.platform, env = process.env, security = "/usr/bin/security", codesign = "/usr/bin/codesign", signal } = options; + if (platform !== "darwin") throw new Error("MCP release signing requires macOS."); + return { env, security, codesign, signal, requirement: mcpRequirement(env.APPLE_TEAM_ID) }; +} + +async function command(executable, args, signal) { + return new Promise((resolveCommand, reject) => { + const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], signal }); + let output = ""; + child.stdout.on("data", (data) => { output += data; }); + child.stderr.on("data", (data) => { output += data; }); + const timer = setTimeout(() => child.kill("SIGKILL"), 120000); + const fail = () => reject(new Error(`${executable.split("/").at(-1)} ${args[0]} failed.`)); + let failed = false; + child.on("error", () => { failed = true; }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0 && !failed) resolveCommand(output); + else fail(); + }); + }); +} + +const normalizeRequirement = (value) => value.replace(/\/\*\s*exists\s*\*\//g, "exists").replace(/[\s"]/g, ""); + +export async function verifyMcp(binary, options = {}) { + const ctx = context(options); + if (!statSync(binary).isFile()) throw new Error("MCP binary must be a regular file."); + await command(ctx.codesign, ["--verify", "--strict", "--all-architectures", "-R", `=${ctx.requirement}`, binary], ctx.signal); + const info = await command(ctx.codesign, ["--display", "--verbose=4", "--requirements", "-", binary], ctx.signal); + if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`) + || !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`) + || !/^Timestamp=(?!none$).+/m.test(info) + || /Signature=adhoc/.test(info)) { + throw new Error("MCP signature must have the expected identifier, team and secure timestamp."); + } + const designated = info.match(/^designated => (.+)$/m)?.[1]; + if (!designated || normalizeRequirement(designated) !== normalizeRequirement(ctx.requirement)) { + throw new Error("MCP designated requirement must retain its stable Developer ID identity."); + } +} + +export async function signMcp(binary, options = {}) { + const ctx = context(options); + for (const name of ["APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY"]) { + if (!ctx.env[name]?.trim()) throw new Error(`${name} is required for MCP release signing.`); + } + if (!statSync(binary).isFile()) throw new Error("MCP binary must be a regular file."); + const encoded = ctx.env.APPLE_CERTIFICATE.replace(/\s/g, ""); + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) { + throw new Error("APPLE_CERTIFICATE must be a base64 PKCS12 bundle."); + } + const originalList = await command(ctx.security, ["list-keychains", "-d", "user"], ctx.signal); + const originalKeychains = originalList.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line.trim())); + if (!originalKeychains.every((keychain) => typeof keychain === "string")) throw new Error("Invalid keychain search list."); + const directory = mkdtempSync(join(ctx.env.RUNNER_TEMP || ctx.env.TMPDIR || tmpdir(), "dbx-mcp-signing-")); + chmodSync(directory, 0o700); + const keychain = join(directory, "signing.keychain-db"); + const certificate = join(directory, "certificate.p12"); + const password = randomBytes(32).toString("hex"); + let error; + try { + writeFileSync(certificate, Buffer.from(encoded, "base64"), { mode: 0o600, flag: "wx" }); + await command(ctx.security, ["create-keychain", "-p", password, keychain], ctx.signal); + await command(ctx.security, ["set-keychain-settings", "-lut", "3600", keychain], ctx.signal); + await command(ctx.security, ["unlock-keychain", "-p", password, keychain], ctx.signal); + await command(ctx.security, ["import", certificate, "-k", keychain, "-P", ctx.env.APPLE_CERTIFICATE_PASSWORD, "-T", "/usr/bin/codesign"], ctx.signal); + rmSync(certificate); + await command(ctx.security, ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain], ctx.signal); + await command(ctx.security, ["list-keychains", "-d", "user", "-s", keychain, ...originalKeychains], ctx.signal); + const identities = await command(ctx.security, ["find-identity", "-v", "-p", "codesigning", keychain], ctx.signal); + const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)] + .filter(([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`) + && (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase())); + if (candidates.length !== 1) throw new Error("Expected exactly one valid configured Developer ID Application identity in the temporary keychain."); + await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier, + "--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal); + await verifyMcp(binary, options); + } catch (failure) { + error = failure; + } finally { + const failures = []; + try { await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]); } catch (failure) { failures.push(failure); } + if (existsSync(keychain)) { + try { await command(ctx.security, ["delete-keychain", keychain]); } catch (failure) { failures.push(failure); } + } + try { rmSync(directory, { recursive: true, force: true }); } catch (failure) { failures.push(failure); } + if (failures.length) error = new Error("MCP signing keychain cleanup failed; refusing publication.", { cause: error }); + } + if (error) throw error; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const [mode, binary, ...extra] = process.argv.slice(2); + const controller = new AbortController(); + const abort = () => controller.abort(); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + try { + if (!binary || extra.length || !["sign", "verify"].includes(mode)) throw new Error("Usage: sign-mcp-macos.mjs "); + await (mode === "sign" ? signMcp : verifyMcp)(resolve(binary), { signal: controller.signal }); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } finally { + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); + } +} diff --git a/.github/scripts/test-mcp-signing-macos.sh b/.github/scripts/test-mcp-signing-macos.sh new file mode 100644 index 0000000000..466cd59589 --- /dev/null +++ b/.github/scripts/test-mcp-signing-macos.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != Darwin ]]; then + echo "This probe requires macOS and controlled Developer ID Application credentials." >&2 + exit 1 +fi + +: "${APPLE_CERTIFICATE:?Required base64 PKCS12 bundle}" +: "${APPLE_CERTIFICATE_PASSWORD:?Required PKCS12 password}" +: "${APPLE_SIGNING_IDENTITY:?Required Developer ID Application identity}" +: "${APPLE_TEAM_ID:?Required signing team}" +probe_root="${RUNNER_TEMP:-${TMPDIR:?Set TMPDIR to a private temporary directory}}" +work_dir="$(mktemp -d "$probe_root/dbx-mcp-identity.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT +script_dir="$(cd "$(dirname "$0")" && pwd)" +security list-keychains -d user > "$work_dir/keychains-before" + +for arch in arm64 x86_64; do + for build in 1 2; do + printf 'int main(void) { return %s; }\n' "$build" > "$work_dir/probe.c" + xcrun clang -arch "$arch" "$work_dir/probe.c" -o "$work_dir/$arch-$build" + node "$script_dir/sign-mcp-macos.mjs" sign "$work_dir/$arch-$build" + done + + codesign -d -r- "$work_dir/$arch-1" > "$work_dir/requirement" 2> "$work_dir/codesign-display" + requirement="$(sed -n 's/^designated => /=/p' "$work_dir/requirement")" + test -n "$requirement" + codesign --verify --strict --all-architectures -R "$requirement" "$work_dir/$arch-2" + + mkdir "$work_dir/$arch-package" "$work_dir/$arch-unpacked" + cp "$work_dir/$arch-2" "$work_dir/$arch-package/dbx-mcp" + tar -C "$work_dir/$arch-package" -czf "$work_dir/$arch.tar.gz" dbx-mcp + tar -C "$work_dir/$arch-unpacked" -xzf "$work_dir/$arch.tar.gz" + cmp "$work_dir/$arch-2" "$work_dir/$arch-unpacked/dbx-mcp" + node "$script_dir/sign-mcp-macos.mjs" verify "$work_dir/$arch-unpacked/dbx-mcp" + + cp "$work_dir/$arch-2" "$work_dir/tampered" + node - "$work_dir/tampered" <<'NODE' +const fs = require("node:fs"); +const binary = fs.readFileSync(process.argv[2]); +binary[4096] ^= 1; +fs.writeFileSync(process.argv[2], binary); +NODE + if node "$script_dir/sign-mcp-macos.mjs" verify "$work_dir/tampered"; then + echo "Tampered binary unexpectedly passed verification." >&2 + exit 1 + fi + + cp "$work_dir/$arch-2" "$work_dir/adhoc" + codesign --force --sign - --identifier com.dbx.app.mcp "$work_dir/adhoc" + if node "$script_dir/sign-mcp-macos.mjs" verify "$work_dir/adhoc"; then + echo "Ad-hoc binary unexpectedly passed verification." >&2 + exit 1 + fi +done + +security list-keychains -d user > "$work_dir/keychains-after" +cmp "$work_dir/keychains-before" "$work_dir/keychains-after" +echo "Both architectures passed distinct-build identity, archive, rejection and keychain cleanup checks." diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index eed8639cef..c1396c9fbd 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -672,26 +672,64 @@ jobs: cargo build --release -p dbx-mcp --features os-keyring --target "${{ matrix.target }}" fi + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + + - name: Sign macOS MCP binary + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: node .github/scripts/sign-mcp-macos.mjs sign "target/${{ matrix.target }}/release/${{ matrix.binary }}" + - name: Stage platform package shell: bash + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | mkdir -p "packages/${{ matrix.package-dir }}/bin" cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" if [[ "${{ runner.os }}" != "Windows" ]]; then chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" fi - - - uses: actions/setup-node@v6 - with: - node-version: 22.13.0 - registry-url: https://registry.npmjs.org + if [[ "${{ runner.os }}" == "macOS" ]]; then + cmp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" + node .github/scripts/sign-mcp-macos.mjs verify "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" + fi - name: Publish platform package shell: bash env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} VERSION: ${{ needs.prepare.outputs.version }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | + set -euo pipefail + if [[ "${{ runner.os }}" == "macOS" ]]; then + work_dir="$(mktemp -d)" + trap 'rm -rf "$work_dir"' EXIT + package_ref="./packages/${{ matrix.package-dir }}" + exists=false + if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then + package_ref="${{ matrix.package-name }}@${VERSION}" + exists=true + fi + npm pack "$package_ref" --ignore-scripts --pack-destination "$work_dir" --json > "$work_dir/pack.json" + tarball="$work_dir/$(node -p 'require(process.argv[1])[0].filename' "$work_dir/pack.json")" + tar -xzf "$tarball" -C "$work_dir" + node .github/scripts/sign-mcp-macos.mjs verify "$work_dir/package/bin/${{ matrix.binary }}" + if [ "$exists" = true ]; then + echo "${{ matrix.package-name }}@${VERSION} already exists with a verified MCP identity; skipping." + exit 0 + fi + cmp "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" "$work_dir/package/bin/${{ matrix.binary }}" + npm publish "$tarball" --access public --provenance --ignore-scripts + exit 0 + fi if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping." exit 0 @@ -764,7 +802,7 @@ jobs: publish-mcp-github-release: name: Publish MCP GitHub Release assets - runs-on: ubuntu-latest + runs-on: macos-15 needs: [prepare, publish-mcp-server] steps: - uses: actions/checkout@v5 @@ -780,6 +818,7 @@ jobs: shell: bash env: VERSION: ${{ needs.prepare.outputs.version }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail mkdir -p release-assets @@ -794,7 +833,17 @@ jobs: mkdir -p "${work_dir}/archive" cp "${work_dir}/package/bin/dbx-mcp" "${work_dir}/archive/dbx-mcp" chmod +x "${work_dir}/archive/dbx-mcp" + if [[ "$package_name" == @dbx-app/mcp-darwin-* ]]; then + node .github/scripts/sign-mcp-macos.mjs verify "${work_dir}/archive/dbx-mcp" + fi tar -C "${work_dir}/archive" -czf "release-assets/${asset_name}.tar.gz" dbx-mcp + mkdir "${work_dir}/unpacked" + tar -xzf "release-assets/${asset_name}.tar.gz" -C "${work_dir}/unpacked" + cmp "${work_dir}/package/bin/dbx-mcp" "${work_dir}/unpacked/dbx-mcp" + if [[ "$package_name" == @dbx-app/mcp-darwin-* ]]; then + node .github/scripts/sign-mcp-macos.mjs verify "${work_dir}/unpacked/dbx-mcp" + fi + rm -rf "$work_dir" } pack_windows() { @@ -816,7 +865,7 @@ jobs: ( cd release-assets - sha256sum *.tar.gz *.zip > SHA256SUMS + shasum -a 256 *.tar.gz *.zip > SHA256SUMS ) - name: Attest native release archives diff --git a/packages/app-tests/mcpReleaseSigning.test.ts b/packages/app-tests/mcpReleaseSigning.test.ts new file mode 100644 index 0000000000..aac99dedec --- /dev/null +++ b/packages/app-tests/mcpReleaseSigning.test.ts @@ -0,0 +1,409 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse } from "yaml"; +import { afterEach, test } from "vitest"; +import { mcpIdentifier, mcpRequirement, signMcp, verifyMcp } from "../../.github/scripts/sign-mcp-macos.mjs"; + +const workflow = parse(readFileSync(".github/workflows/mcp-release.yml", "utf8")); + +test("every macOS MCP build signs before staging and validates npm reruns", () => { + const job = workflow.jobs["publish-mcp-platforms"]; + assert.deepEqual(job.strategy.matrix.include.filter((leg) => leg.target.endsWith("apple-darwin")).map((leg) => leg.target).sort(), [ + "aarch64-apple-darwin", "x86_64-apple-darwin", + ]); + const steps = job.steps; + const build = steps.findIndex((step) => step.name === "Build Rust MCP binary"); + const sign = steps.findIndex((step) => step.run?.includes("sign-mcp-macos.mjs sign")); + const stage = steps.findIndex((step) => step.name === "Stage platform package"); + assert.ok(sign > build && sign < stage, "MCP macOS binaries must be signed after building and before staging"); + assert.equal(steps[sign].if, "runner.os == 'macOS'"); + assert.ok(!steps[sign]["continue-on-error"]); + assert.match(steps[stage].run, /sign-mcp-macos.mjs verify/); + const publish = steps.find((step) => step.name === "Publish platform package").run; + assert.ok(publish.indexOf("sign-mcp-macos.mjs verify") < publish.indexOf("exit 0")); + assert.match(publish, /npm pack/); +}); + +test("standalone MCP and Homebrew use verified signed npm bytes", () => { + const job = workflow.jobs["publish-mcp-github-release"]; + assert.match(job["runs-on"], /^macos-/); + const pack = job.steps.find((step) => step.name === "Build native release archives from npm packages").run; + assert.match(pack, /sign-mcp-macos.mjs verify/); + assert.match(pack, /cmp /); + assert.match(workflow.jobs["publish-homebrew-formula"].steps.at(-1).run, /render-mcp-formula.mjs/); +}); + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function fixture(config = {}) { + const root = mkdtempSync(join(tmpdir(), "mcp-signing-test-")); + roots.push(root); + const binary = join(root, "dbx-mcp"); + writeFileSync(binary, "first build"); + const state = join(root, "state.json"); + const log = join(root, "commands.jsonl"); + const settings = join(root, "config.json"); + const original = ["/Users/runner/Library/Keychains/login.keychain-db", "/a keychain/custom.keychain-db"]; + writeFileSync(state, JSON.stringify(original)); + writeFileSync(settings, JSON.stringify(config)); + const source = `#!${process.execPath} +import { appendFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +const args = process.argv.slice(2); +const security = process.argv[1].endsWith('/security'); +const config = JSON.parse(readFileSync(${JSON.stringify(settings)}, 'utf8')); +const state = ${JSON.stringify(state)}; +const op = security ? args[0] : args.includes('--sign') ? 'sign' : args[0]; +if (config.diagnostic && config.fail === op) console.error(config.diagnostic); +appendFileSync(${JSON.stringify(log)}, JSON.stringify({ tool: security ? 'security' : 'codesign', args }) + '\\n'); +if (config.pause === op) await new Promise(resolve => setTimeout(resolve, 10000)); +const keychain = args.at(-1); +if (security && op === 'create-keychain') { + writeFileSync(keychain, 'temporary keychain'); + writeFileSync(state, JSON.stringify([...JSON.parse(readFileSync(state)), keychain])); +} +if (config.fail === op || (config.fail === 'restore' && op === 'list-keychains' && args.includes('-s') && !args[4]?.includes('dbx-mcp-signing-'))) process.exit(19); +if (security) { + if (op === 'list-keychains') { + if (args.includes('-s')) writeFileSync(state, JSON.stringify(args.slice(4))); + else console.log(JSON.parse(readFileSync(state)).map(keychain => JSON.stringify(keychain)).join('\\n')); + } + if (op === 'find-identity') console.log(config.identities ?? ' 1) AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA "Developer ID Application: DBX (ABCDE12345)"'); + if (op === 'delete-keychain') rmSync(keychain); +} else { + const binary = args.at(-1); + const marker = '\\nFAKE_SIGNATURE='; + const text = readFileSync(binary, 'utf8'); + const payload = text.split(marker)[0]; + const hash = createHash('sha256').update(payload).digest('hex'); + if (op === 'sign') { + const metadata = { hash, requirement: args[args.indexOf('--requirements') + 1].replace('=designated => ', ''), identifier: args[args.indexOf('--identifier') + 1], team: 'ABCDE12345' }; + writeFileSync(binary, payload + marker + JSON.stringify(metadata)); + } else { + if (!text.includes(marker)) process.exit(20); + const metadata = JSON.parse(text.split(marker)[1]); + if (metadata.hash !== hash) process.exit(21); + if (op === '--verify' && args[args.indexOf('-R') + 1] !== '=' + metadata.requirement) process.exit(22); + if (op === '--display') { + console.log('Identifier=' + (config.identifier ?? metadata.identifier)); + console.log('TeamIdentifier=' + (config.team ?? metadata.team)); + console.log(config.adhoc ? 'Signature=adhoc' : 'Signature size=1234'); + if (!config.noTimestamp) console.log('Timestamp=Oct 2, 2026 at 1:00:00 PM'); + console.log('designated => ' + (config.requirement ?? metadata.requirement.replaceAll(' exists', ' /* exists */'))); + } + } +} +`; + for (const tool of ["security", "codesign"]) writeFileSync(join(root, tool), source, { mode: 0o755 }); + const options = { + platform: "darwin", security: join(root, "security"), codesign: join(root, "codesign"), + env: { RUNNER_TEMP: root, APPLE_TEAM_ID: "ABCDE12345", APPLE_CERTIFICATE: Buffer.from("fixture certificate").toString("base64"), + APPLE_CERTIFICATE_PASSWORD: "fixture password", APPLE_SIGNING_IDENTITY: "Developer ID Application: DBX (ABCDE12345)" }, + }; + return { + root, binary, options, original, + configure: (value) => writeFileSync(settings, JSON.stringify(value)), + commands: () => existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [], + cleaned: () => { + assert.deepEqual(JSON.parse(readFileSync(state, "utf8")), original); + assert.ok(!readdirSync(root).some((name) => name.startsWith("dbx-mcp-signing-"))); + }, + }; +} + +test("imports only into an isolated keychain, signs and verifies in order, then restores all keychains", async () => { + const scenario = fixture(); + await signMcp(scenario.binary, scenario.options); + scenario.cleaned(); + const commands = scenario.commands(); + assert.deepEqual(commands.map(({ tool, args }) => `${tool}:${args[0]}`), [ + "security:list-keychains", "security:create-keychain", "security:set-keychain-settings", "security:unlock-keychain", + "security:import", "security:set-key-partition-list", "security:list-keychains", "security:find-identity", + "codesign:--force", "codesign:--verify", "codesign:--display", "security:list-keychains", "security:delete-keychain", + ]); + const imported = commands.find(({ args }) => args[0] === "import").args; + assert.ok(!imported.includes("-A")); + assert.equal(imported[imported.indexOf("-T") + 1], "/usr/bin/codesign"); + const signing = commands.find(({ args }) => args.includes("--sign")).args; + assert.equal(signing[signing.indexOf("--sign") + 1], "A".repeat(40)); + assert.equal(signing[signing.indexOf("--identifier") + 1], mcpIdentifier); + assert.ok(signing.includes("--timestamp")); + assert.ok(!signing.includes("--timestamp=none")); + const verify = commands.find(({ args }) => args[0] === "--verify").args; + assert.ok(verify.includes("--strict") && verify.includes("--all-architectures")); + assert.equal(verify[verify.indexOf("-R") + 1], `=${mcpRequirement("ABCDE12345")}`); + assert.deepEqual(commands.at(-2).args.slice(4), scenario.original); + assert.match(commands.at(-1).args[1], /dbx-mcp-signing-.*\/signing.keychain-db$/); +}); + +test("distinct builds and renewed certificates keep the same signer-constrained requirement", async () => { + const scenario = fixture(); + await signMcp(scenario.binary, scenario.options); + const first = readFileSync(scenario.binary, "utf8"); + writeFileSync(scenario.binary, "second build"); + scenario.configure({ identities: ` 1) ${"B".repeat(40)} "Developer ID Application: DBX (ABCDE12345)"` }); + await signMcp(scenario.binary, scenario.options); + assert.notEqual(readFileSync(scenario.binary, "utf8"), first); + const signatures = scenario.commands().filter(({ args }) => args.includes("--sign")).map(({ args }) => args); + assert.notEqual(signatures[0][2], signatures[1][2]); + assert.equal(signatures[0][signatures[0].indexOf("--requirements") + 1], signatures[1][signatures[1].indexOf("--requirements") + 1]); + assert.match(mcpRequirement("ABCDE12345"), /anchor apple generic/); + assert.match(mcpRequirement("ABCDE12345"), /certificate leaf\[subject.OU\] = "ABCDE12345"/); + assert.match(mcpRequirement("ABCDE12345"), /field.1.2.840.113635.100.6.1.13/); + assert.doesNotMatch(mcpRequirement("ABCDE12345"), /cdhash|certificate leaf = H/); + scenario.cleaned(); +}); + +test("rejects unsupported hosts, missing credentials and malformed teams before keychain access", async () => { + const scenario = fixture(); + await assert.rejects(signMcp(scenario.binary, { ...scenario.options, platform: "linux" }), /requires macOS/); + await assert.rejects(verifyMcp(scenario.binary, { ...scenario.options, platform: "win32" }), /requires macOS/); + for (const name of ["APPLE_TEAM_ID", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY"]) { + await assert.rejects(signMcp(scenario.binary, { ...scenario.options, env: { ...scenario.options.env, [name]: "" } }), /required/); + } + await assert.rejects(signMcp(scenario.binary, { ...scenario.options, env: { ...scenario.options.env, APPLE_TEAM_ID: 'x" or true' } }), /APPLE_TEAM_ID/); + await assert.rejects(signMcp(scenario.binary, { ...scenario.options, env: { ...scenario.options.env, APPLE_CERTIFICATE: "invalid!" } }), /base64/); + assert.deepEqual(scenario.commands(), []); +}); + +test.each(["create-keychain", "set-keychain-settings", "unlock-keychain", "import", "set-key-partition-list", "find-identity", "sign", "--verify", "--display"])("propagates %s failure and cleans up", async (fail) => { + const scenario = fixture({ fail }); + await assert.rejects(signMcp(scenario.binary, scenario.options), /failed/); + scenario.cleaned(); + assert.equal(scenario.commands().at(-1).args[0], "delete-keychain"); +}); + +test.each([ + { identities: ' 1) AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA "Developer ID Application: DBX (OTHER12345)"' }, + { identities: ' 1) AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA "Apple Development: DBX (ABCDE12345)"' }, + { identities: "0 valid identities found" }, + { team: "OTHER12345" }, { identifier: "com.dbx.app" }, { adhoc: true }, { noTimestamp: true }, + { requirement: 'identifier "com.dbx.app.mcp"' }, { requirement: 'cdhash H"abcd"' }, +])("rejects invalid signer or signature metadata: %j", async (config) => { + const scenario = fixture(config); + await assert.rejects(signMcp(scenario.binary, scenario.options), /identity|identifier|requirement/); + scenario.cleaned(); +}); + +test.each(["restore", "delete-keychain"])("cleanup failure at %s fails the release and still removes temporary material", async (fail) => { + const scenario = fixture({ fail }); + await assert.rejects(signMcp(scenario.binary, scenario.options), /cleanup failed/); + assert.ok(!readdirSync(scenario.root).some((name) => name.startsWith("dbx-mcp-signing-"))); + assert.equal(scenario.commands().at(-1).args[0], "delete-keychain"); +}); + +test("signature survives package staging and tar repacking, while tampering and unsigned reruns fail", async () => { + const scenario = fixture(); + await assert.rejects(verifyMcp(scenario.binary, scenario.options), /failed/); + await signMcp(scenario.binary, scenario.options); + const staged = join(scenario.root, "package", "bin"); + const unpacked = join(scenario.root, "unpacked"); + mkdirSync(staged, { recursive: true }); + mkdirSync(unpacked); + copyFileSync(scenario.binary, join(staged, "dbx-mcp")); + assert.equal(spawnSync("tar", ["-czf", join(scenario.root, "package.tgz"), "-C", scenario.root, "package"]).status, 0); + assert.equal(spawnSync("tar", ["-xzf", join(scenario.root, "package.tgz"), "-C", unpacked]).status, 0); + const binary = join(unpacked, "package", "bin", "dbx-mcp"); + assert.deepEqual(readFileSync(binary), readFileSync(scenario.binary)); + await verifyMcp(binary, scenario.options); + writeFileSync(binary, readFileSync(binary, "utf8").replace("first build", "tampered build")); + await assert.rejects(verifyMcp(binary, scenario.options), /failed/); + scenario.cleaned(); +}); + +function workflowFixture(scenario, config: { existing?: boolean; corrupt?: boolean; replacement?: string } = {}) { + const mocks = join(scenario.root, "tools"); + mkdirSync(mocks); + const npmLog = join(scenario.root, "npm.jsonl"); + writeFileSync(join(mocks, "node"), `#!${process.execPath} +import { spawnSync } from 'node:child_process'; +import { signMcp, verifyMcp } from ${JSON.stringify(pathToFileURL(resolve(".github/scripts/sign-mcp-macos.mjs")).href)}; +const args = process.argv.slice(2); +if (args[0] === '.github/scripts/sign-mcp-macos.mjs') { + try { await (args[1] === 'sign' ? signMcp : verifyMcp)(args[2], ${JSON.stringify(scenario.options)}); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} else { + const child = spawnSync(${JSON.stringify(process.execPath)}, args, { stdio: 'inherit' }); + process.exitCode = child.status ?? 1; +} +`, { mode: 0o755 }); + writeFileSync(join(mocks, "npm"), `#!${process.execPath} +import { appendFileSync, copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(npmLog)}, JSON.stringify(args) + '\\n'); +if (args[0] === 'view') process.exit(${config.existing ? 0 : 1}); +if (args[0] === 'pack') { + const destination = args[args.indexOf('--pack-destination') + 1]; + const staging = join(destination, 'input'); + const bin = join(staging, 'package/bin'); + mkdirSync(bin, { recursive: true }); + const name = args[1].includes('win32') ? 'dbx-mcp.exe' : 'dbx-mcp'; + const source = args[1].startsWith('./') ? join(args[1], 'bin', name) : ${JSON.stringify(scenario.binary)}; + copyFileSync(source, join(bin, name)); + if (${Boolean(config.replacement)}) copyFileSync(${JSON.stringify(config.replacement ?? "")}, join(bin, name)); + if (${Boolean(config.corrupt)}) writeFileSync(join(bin, name), 'unsigned package'); + writeFileSync(join(staging, 'package/package.json'), '{}'); + const filename = 'mcp-fixture.tgz'; + const result = spawnSync('tar', ['-czf', join(destination, filename), '-C', staging, 'package']); + if (result.status !== 0) process.exit(result.status ?? 1); + rmSync(staging, { recursive: true }); + console.log(JSON.stringify([{ filename }])); +} +`, { mode: 0o755 }); + writeFileSync(join(mocks, "jq"), `#!${process.execPath} +import { readFileSync } from 'node:fs'; +console.log(JSON.parse(readFileSync(process.argv.at(-1), 'utf8'))[0].filename); +`, { mode: 0o755 }); + writeFileSync(join(mocks, "zip"), `#!/usr/bin/env python3 +import os, sys, zipfile +with zipfile.ZipFile(sys.argv[2], 'w') as archive: + archive.write(sys.argv[3], os.path.basename(sys.argv[3])) +`, { mode: 0o755 }); + return { + run: (script, leg = { target: "aarch64-apple-darwin", "package-dir": "mcp-darwin-arm64", "package-name": "@dbx-app/mcp-darwin-arm64", binary: "dbx-mcp", os: "macOS" }) => { + const expanded = script.replace(/\$\{\{ matrix\.([^ }]+) \}\}/g, (_expression, key) => leg[key]).replaceAll("${{ runner.os }}", leg.os); + const mockPath = `${mocks}:/usr/bin:/bin`; + const isolatedScript = `export PATH=${JSON.stringify(mockPath)}\n[[ "$(command -v npm)" == ${JSON.stringify(join(mocks, "npm"))} ]]\n[[ "$(command -v node)" == ${JSON.stringify(join(mocks, "node"))} ]]\n${expanded}`; + return spawnSync("/bin/bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", isolatedScript], { + cwd: scenario.root, encoding: "utf8", env: { PATH: mockPath, HOME: scenario.root, TMPDIR: scenario.root, VERSION: "0.4.104" }, + }); + }, + npmCommands: () => existsSync(npmLog) ? readFileSync(npmLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [], + }; +} + +test.each(["arm64", "x64"])("macOS %s workflow stages and publishes the exact signed npm tarball", async (arch) => { + const scenario = fixture(); + const workflowScenario = workflowFixture(scenario); + const leg = { target: arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin", "package-dir": `mcp-darwin-${arch}`, + "package-name": `@dbx-app/mcp-darwin-${arch}`, binary: "dbx-mcp", os: "macOS" }; + const release = join(scenario.root, "target", leg.target, "release"); + mkdirSync(release, { recursive: true }); + copyFileSync(scenario.binary, join(release, leg.binary)); + const steps = workflow.jobs["publish-mcp-platforms"].steps; + for (const name of ["Sign macOS MCP binary", "Stage platform package", "Publish platform package"]) { + const result = workflowScenario.run(steps.find((step) => step.name === name).run, leg); + assert.equal(result.status, 0, result.stderr); + } + assert.deepEqual(readFileSync(join(release, leg.binary)), readFileSync(join(scenario.root, "packages", leg["package-dir"], "bin", leg.binary))); + const publish = workflowScenario.npmCommands().find((args) => args[0] === "publish"); + assert.match(publish[1], /mcp-fixture.tgz$/); + assert.ok(publish.includes("--ignore-scripts")); + assert.ok(!existsSync(publish[1])); + scenario.cleaned(); +}); + +test.each([true, false])("npm reruns verify existing artifacts and reject unsigned ones (corrupt=%s)", async (corrupt) => { + const scenario = fixture(); + await signMcp(scenario.binary, scenario.options); + const workflowScenario = workflowFixture(scenario, { existing: true, corrupt }); + const script = workflow.jobs["publish-mcp-platforms"].steps.find((step) => step.name === "Publish platform package").run; + const result = workflowScenario.run(script); + assert.equal(result.status, corrupt ? 1 : 0, result.stderr); + assert.ok(!workflowScenario.npmCommands().some((args) => args[0] === "publish")); + assert.ok(workflowScenario.npmCommands().find((args) => args[0] === "pack")[1].endsWith("@0.4.104")); + scenario.cleaned(); +}); + +test("an altered new npm tarball blocks publication", async () => { + const scenario = fixture(); + await signMcp(scenario.binary, scenario.options); + const workflowScenario = workflowFixture(scenario, { corrupt: true }); + mkdirSync(join(scenario.root, "packages/mcp-darwin-arm64/bin"), { recursive: true }); + copyFileSync(scenario.binary, join(scenario.root, "packages/mcp-darwin-arm64/bin/dbx-mcp")); + const result = workflowScenario.run(workflow.jobs["publish-mcp-platforms"].steps.find((step) => step.name === "Publish platform package").run); + assert.equal(result.status, 1); + assert.ok(!workflowScenario.npmCommands().some((args) => args[0] === "publish")); +}); + +test("packaging cannot substitute a different valid signed build", async () => { + const scenario = fixture(); + await signMcp(scenario.binary, scenario.options); + const replacement = join(scenario.root, "different-build"); + writeFileSync(replacement, "a different valid build"); + await signMcp(replacement, scenario.options); + const workflowScenario = workflowFixture(scenario, { replacement }); + mkdirSync(join(scenario.root, "packages/mcp-darwin-arm64/bin"), { recursive: true }); + copyFileSync(scenario.binary, join(scenario.root, "packages/mcp-darwin-arm64/bin/dbx-mcp")); + const result = workflowScenario.run(workflow.jobs["publish-mcp-platforms"].steps.find((step) => step.name === "Publish platform package").run); + assert.equal(result.status, 1); + assert.match(result.stdout, /differ/); + assert.ok(!workflowScenario.npmCommands().some((args) => args[0] === "publish")); +}); + +test.each(["Linux", "Windows"])("%s npm publication keeps its existing path without invoking macOS tools", (os) => { + const scenario = fixture(); + const workflowScenario = workflowFixture(scenario); + const result = workflowScenario.run(workflow.jobs["publish-mcp-platforms"].steps.find((step) => step.name === "Publish platform package").run, + { target: "unused", "package-dir": "mcp-fixture", "package-name": "@dbx-app/mcp-fixture", binary: "dbx-mcp", os }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(workflowScenario.npmCommands().at(-1), ["publish", "./packages/mcp-fixture", "--access", "public", "--provenance"]); + assert.deepEqual(scenario.commands(), []); +}); + +test("standalone archive workflow preserves npm signatures and produces Homebrew checksums", async () => { + const scenario = fixture(); + await signMcp(scenario.binary, scenario.options); + const workflowScenario = workflowFixture(scenario); + const script = workflow.jobs["publish-mcp-github-release"].steps.find((step) => step.name === "Build native release archives from npm packages").run; + const result = workflowScenario.run(script); + assert.equal(result.status, 0, result.stderr); + for (const arch of ["arm64", "x64"]) { + const binary = spawnSync("tar", ["-xOzf", join(scenario.root, `release-assets/dbx-mcp-darwin-${arch}.tar.gz`), "dbx-mcp"]); + assert.equal(binary.status, 0); + assert.deepEqual(binary.stdout, readFileSync(scenario.binary)); + } + assert.equal(readFileSync(join(scenario.root, "release-assets/SHA256SUMS"), "utf8").trim().split("\n").length, 6); + const sums = spawnSync("shasum", ["-a", "256", "-c", "SHA256SUMS"], { cwd: join(scenario.root, "release-assets"), encoding: "utf8" }); + assert.equal(sums.status, 0, sums.stderr); +}); + +test("interruption waits for the signing command to stop and restores the keychain search list", async () => { + const scenario = fixture({ pause: "import" }); + const controller = new AbortController(); + const result = assert.rejects(signMcp(scenario.binary, { ...scenario.options, signal: controller.signal }), /failed/); + const deadline = Date.now() + 5000; + while (!scenario.commands().some(({ args }) => args[0] === "import") && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(scenario.commands().some(({ args }) => args[0] === "import")); + controller.abort(); + await result; + scenario.cleaned(); + assert.ok(!scenario.commands().some(({ args }) => args.includes("--sign"))); +}); + +test("all MCP build paths are covered and CLI jobs keep their original release behavior", () => { + for (const [name, job] of Object.entries(workflow.jobs) as [string, any][]) { + const buildsMcp = job.steps?.some((step) => /cargo (?:build|zigbuild).* -p dbx-mcp\b/.test(step.run ?? "")); + if (buildsMcp) assert.equal(name, "publish-mcp-platforms", "A new MCP build path needs signing coverage"); + if (name.startsWith("publish-cli")) { + assert.ok(!JSON.stringify(job).includes("sign-mcp-macos")); + } + } +}); + +test("tool diagnostics and credential arguments are not exposed in signing errors", async () => { + const scenario = fixture({ fail: "import", diagnostic: "sensitive mocked certificate error" }); + await assert.rejects(signMcp(scenario.binary, scenario.options), (error: Error) => { + assert.equal(error.message, "security import failed."); + assert.ok(!error.message.includes(scenario.options.env.APPLE_CERTIFICATE_PASSWORD)); + return true; + }); + scenario.cleaned(); +}); + +test("standalone repacking rejects unsigned npm artifacts before producing checksums", () => { + const scenario = fixture(); + const workflowScenario = workflowFixture(scenario); + const result = workflowScenario.run(workflow.jobs["publish-mcp-github-release"].steps.find((step) => step.name === "Build native release archives from npm packages").run); + assert.equal(result.status, 1); + assert.ok(!existsSync(join(scenario.root, "release-assets/SHA256SUMS"))); +});