diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index cd8fb385a..9148dfd1f 100644 --- a/.github/workflows/desktop-connect-discovery-guard.yml +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -92,6 +92,9 @@ jobs: - name: Package the target-native desktop app run: npm run desktop:package + - name: Inspect the unsigned target-native desktop app + run: npm run desktop:smoke:inspect + - name: Run packaged Linux main-to-renderer discovery if: matrix.platform == 'linux' shell: bash @@ -112,47 +115,13 @@ jobs: - name: Run packaged Darwin main-to-renderer discovery if: matrix.platform == 'darwin' shell: bash - run: | - set -euo pipefail - keychain_root="$(mktemp -d)" - keychain_path="$keychain_root/propr-packaged-connect-smoke.keychain-db" - keychain_password="$(openssl rand -hex 32)" - original_keychains=() - while IFS= read -r keychain; do - keychain="${keychain#"${keychain%%[![:space:]]*}"}" - keychain="${keychain#\"}" - keychain="${keychain%\"}" - if [[ -n "$keychain" ]]; then - original_keychains+=("$keychain") - fi - done < <(security list-keychains -d user) - IFS= read -r original_default < <(security default-keychain -d user) - original_default="${original_default#"${original_default%%[![:space:]]*}"}" - original_default="${original_default#\"}" - original_default="${original_default%\"}" - cleanup_keychain() { - if (( ${#original_keychains[@]} > 0 )); then - security list-keychains -d user -s "${original_keychains[@]}" || true - else - security list-keychains -d user -s || true - fi - if [[ -n "$original_default" ]]; then - security default-keychain -d user -s "$original_default" || true - fi - security delete-keychain "$keychain_path" || true - rm -rf -- "$keychain_root" - } - trap cleanup_keychain EXIT - 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 list-keychains -d user -s "$keychain_path" - security default-keychain -d user -s "$keychain_path" - unset keychain_password - safe_storage_secret="$(openssl rand -hex 32)" - security add-generic-password -a "ProPR Desktop" -s "ProPR Desktop Safe Storage" -w "$safe_storage_secret" -A "$keychain_path" - unset safe_storage_secret - npm run smoke:connect-package -w @propr/desktop + run: >- + node apps/desktop/scripts/run-bounded-darwin-command.mjs + --timeout-ms 480000 + --termination-grace-ms 90000 + --max-output-bytes 1048576 + --forward-output true + -- bash apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh '${{ matrix.arch }}' - name: Run packaged Windows main-to-renderer discovery as an ordinary user if: matrix.platform == 'win32' diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 97df88995..c68462b50 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,6 +44,13 @@ executable and fuse inspection without launching a window. Release CI launches b inspects macOS and Windows packages on their native runners, validates DMG/ZIP/DEB/RPM/MSI packages, and validates configured OS signatures. +Darwin packaged Connect acceptance first inspects the normal unsigned package, then generates a one-run self-signed +CA:false code-signing leaf in an isolated default keychain and signs only that smoke artifact. The signature uses an +explicit certificate-bound designated requirement that is verified before the pair process and again after the +reprobe process. Chromium creates and reopens its real Safe Storage key in the same disposable keychain; the harness +does not pre-seed or widen access to that item. A signal-aware exit trap restores the runner's original keychain list +and default, deletes the disposable keychain, and removes all temporary signing material. + The first-release Windows MVP packages only the normal desktop application. Native self-update installation authority is deferred to issue #2000: no broker, bootstrap, launcher, service, or authority custom action is built, copied into `resources`, or installed by the MSI. Both Windows architectures remain mandatory release targets, and package/MSI diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.mjs index 59b4849c9..77ca73c10 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.mjs @@ -209,7 +209,14 @@ export const boundedChildDiagnostics = records => { ...(journeyStageCodes.has(candidateCode) && (record.event === CONNECT_DISCOVERY_MILESTONE_EVENT || record.event === CONNECT_JOURNEY_STAGE_EVENT) - ? { code: candidateCode } + ? { + code: candidateCode, + ...(candidateCode === 'JOURNEY_STORAGE_BACKEND' + && (record.storageBackend === 'gnome_libsecret' + || record.storageBackend === 'os-protected') + ? { storageBackend: record.storageBackend } + : {}), + } : diagnosticPhases.has(phase) && diagnosticPhaseCodes.has(candidateCode) ? { phase, @@ -515,6 +522,7 @@ export const runPackagedConnectLifecycle = async ({ platform, arch, authorityMechanism, + expectedStorageBackend, sensitiveNeedles = [], treeKillerPath, spawn = nodeSpawn, @@ -528,6 +536,7 @@ export const runPackagedConnectLifecycle = async ({ const first = deferred(); let firstSettled = false; let invalidReadyObserved = false; + let reportedStorageBackend; let child; const settleFirst = value => { if (firstSettled) return; @@ -539,8 +548,14 @@ export const runPackagedConnectLifecycle = async ({ onSensitiveOutput: () => settleFirst({ category: 'output-rejected' }), onRecord: record => { if (records.length < RECORD_MAX_COUNT) records.push(record); + if (record.event === CONNECT_JOURNEY_STAGE_EVENT + && record.code === 'JOURNEY_STORAGE_BACKEND') { + reportedStorageBackend = record.storageBackend; + } if (record.event !== CONNECT_READY_EVENT) return; - const valid = isExactReadyRecord(record, { platform, arch, authorityMechanism }); + const valid = isExactReadyRecord(record, { platform, arch, authorityMechanism }) + && (expectedStorageBackend === undefined + || reportedStorageBackend === expectedStorageBackend); if (!valid) invalidReadyObserved = true; settleFirst(valid ? { category: 'ready' } : { category: 'ready-validation' }); }, diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs index c045e8365..eb2e5b830 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -132,6 +132,49 @@ describe('packaged Connect bounded child lifecycle', () => { assert.equal(invocations.length, 1); }); + test('requires and preserves the expected fixed storage-backend report before readiness', async () => { + const accepted = await run({ + expectedStorageBackend: 'os-protected', + onApp: app => { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_STORAGE_BACKEND', + storageBackend: 'os-protected', + }); + app.write(readyRecord()); + queueMicrotask(() => app.close(0, null)); + }, + }); + assert.equal(accepted.result.ok, true); + assert.deepEqual(accepted.result.records, [ + { + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_STORAGE_BACKEND', + storageBackend: 'os-protected', + }, + { event: CONNECT_READY_EVENT }, + ]); + + for (const storageBackend of [undefined, 'gnome_libsecret']) { + const rejected = await run({ + expectedStorageBackend: 'os-protected', + onApp: app => { + if (storageBackend) { + app.write({ + event: CONNECT_JOURNEY_STAGE_EVENT, + code: 'JOURNEY_STORAGE_BACKEND', + storageBackend, + }); + } + app.write(readyRecord()); + queueMicrotask(() => app.close(0, null)); + }, + }); + assert.equal(rejected.result.ok, false); + assert.equal(rejected.result.category, 'ready-validation'); + } + }); + test('does not accept an intermediate discovery milestone as terminal readiness', async () => { const { result } = await run({ onApp: app => { diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 0e8950ebe..a37981688 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -1,11 +1,37 @@ import assert from 'node:assert/strict'; +import { execFile as nodeExecFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { describe, test } from 'node:test'; +import { promisify } from 'node:util'; + +const execFile = promisify(nodeExecFile); const workflow = await readFile( new URL('../../../.github/workflows/desktop-connect-discovery-guard.yml', import.meta.url), 'utf8', ); +const darwinRunner = await readFile( + new URL('./run-packaged-darwin-connect-smoke.sh', import.meta.url), + 'utf8', +); +const forgeConfig = await readFile(new URL('../forge.config.ts', import.meta.url), 'utf8'); +const darwinSigner = await readFile( + new URL('./sign-darwin-packaged-connect.mjs', import.meta.url), + 'utf8', +); +const darwinVerifier = await readFile( + new URL('./verify-darwin-packaged-connect-signature.mjs', import.meta.url), + 'utf8', +); +const packagedConnectSmoke = await readFile( + new URL('./smoke-packaged-connect.mjs', import.meta.url), + 'utf8', +); +const desktopMain = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); +const boundedDarwinRunner = await readFile( + new URL('./run-bounded-darwin-command.mjs', import.meta.url), + 'utf8', +); describe('packaged Connect target-native credential setup', () => { test('Linux retains one isolated unlocked libsecret session and rejects plaintext fallback', async () => { @@ -23,24 +49,197 @@ describe('packaged Connect target-native credential setup', () => { assert.match(main, /security\.backend !== requiredStorageBackend/u); }); - test('Darwin uses only a generated ephemeral default keychain and restores it on exit', () => { + test('inspects the ordinary unsigned package before adding the Darwin-only acceptance identity', () => { const darwin = workflow.slice( workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'), workflow.indexOf('- name: Run packaged Windows main-to-renderer discovery'), ); - assert.match(darwin, /keychain_root="\$\(mktemp -d\)"/u); - assert.match(darwin, /keychain_password="\$\(openssl rand -hex 32\)"/u); - assert.match(darwin, /trap cleanup_keychain EXIT/u); - assert.match(darwin, /security create-keychain -p "\$keychain_password" "\$keychain_path"/u); - assert.match(darwin, /security unlock-keychain -p "\$keychain_password" "\$keychain_path"/u); - assert.match(darwin, /security list-keychains -d user -s "\$keychain_path"/u); - assert.match(darwin, /security default-keychain -d user -s "\$keychain_path"/u); - assert.match(darwin, /safe_storage_secret="\$\(openssl rand -hex 32\)"/u); - assert.match(darwin, /security add-generic-password -a "ProPR Desktop" -s "ProPR Desktop Safe Storage" -w "\$safe_storage_secret" -A "\$keychain_path"/u); - assert.match(darwin, /unset safe_storage_secret\n\s+npm run smoke:connect-package/u); - assert.match(darwin, /security list-keychains -d user -s "\$\{original_keychains\[@\]\}"/u); - assert.match(darwin, /security delete-keychain "\$keychain_path"/u); - assert.doesNotMatch(darwin, /echo[^\n]*safe_storage_secret|printf[^\n]*safe_storage_secret/u); - assert.doesNotMatch(darwin, /CERTIFICATE|security import|codesign|notari/iu); + const inspect = workflow.indexOf('- name: Inspect the unsigned target-native desktop app'); + const darwinLaunch = workflow.indexOf('- name: Run packaged Darwin main-to-renderer discovery'); + assert.ok(inspect >= 0 && inspect < darwinLaunch); + assert.match(workflow, /- name: Inspect the unsigned target-native desktop app\n\s+run: npm run desktop:smoke:inspect/u); + assert.match(darwin, /node apps\/desktop\/scripts\/run-bounded-darwin-command\.mjs[\s\S]*?--timeout-ms 480000[\s\S]*?-- bash apps\/desktop\/scripts\/run-packaged-darwin-connect-smoke\.sh '\$\{\{ matrix\.arch \}\}'/u); + assert.doesNotMatch(forgeConfig, /PACKAGED_CONNECT.*SIGN|SMOKE.*SIGN/iu); + assert.match(forgeConfig, /\.\.\.\(macSigning \? \{[\s\S]*?osxSign: \{[\s\S]*?identity: macSigning\.PROPR_DESKTOP_MAC_SIGNING_IDENTITY/u); + assert.doesNotMatch(`${forgeConfig}\n${darwinRunner}\n${darwinSigner}`, /Developer ID Application/u); + }); + + test('Darwin creates one ephemeral certificate-backed identity and proves it across both launches', () => { + assert.match(darwinRunner, /keychain_root="\$\(run_bounded_forward[^\n]*\/usr\/bin\/mktemp -d\)"/u); + assert.match(darwinRunner, /keychain_password="\$\(run_bounded_forward[\s\S]*?\/usr\/bin\/openssl rand -hex 32\)"/u); + assert.match(darwinRunner, /identity_password="\$\(run_bounded_forward[\s\S]*?\/usr\/bin\/openssl rand -hex 32\)"/u); + assert.match(darwinRunner, /x509_extensions = leaf_extensions/u); + assert.match(darwinRunner, /basicConstraints = critical,CA:FALSE/u); + assert.match(darwinRunner, /extendedKeyUsage = critical,codeSigning/u); + assert.match(darwinRunner, /openssl req -new -x509 -newkey rsa:2048[\s\S]*?-days 1[\s\S]*?-config "\$leaf_config"/u); + assert.doesNotMatch(darwinRunner, /root_(?:private_key|certificate|config)|leaf_request|-CA(?:key)?\b/u); + assert.doesNotMatch(darwinRunner, /add-trusted-cert|remove-trusted-cert|trustRoot/u); + assert.match(darwinRunner, /\/usr\/bin\/security import "\$identity_archive" \\[\s\S]*?-T \/usr\/bin\/codesign/u); + assert.match(darwinRunner, /\/usr\/bin\/security set-key-partition-list \\[\s\S]*?-S apple-tool:,apple:,codesign:/u); + assert.match(darwinRunner, /run_bounded_forward "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); + assert.doesNotMatch(darwinSigner, /from '@electron\/osx-sign'/u); + assert.match(darwinSigner, /discoverDarwinSignablePaths/u); + assert.match(darwinSigner, /'--sign', certificateSha1/u); + assert.match(darwinSigner, /'--keychain', keychain/u); + assert.match(darwinSigner, /'--timestamp=none'/u); + assert.match(darwinVerifier, /'find-certificate', '-a', '-Z', keychain/u); + assert.match(darwinVerifier, /\['-d', '--verbose=4', application\]/u); + assert.doesNotMatch(darwinVerifier, /'--test-requirement'|['"`]?-R(?:=|['"`])/u); + assert.match(darwinVerifier, /fingerprints\.length !== 1 \|\| fingerprints\[0\] !== expectedSha1/u); + assert.match(darwinVerifier, /ADHOC_SIGNATURE_LINE = \/\^\\s\*signature\\s\*=\\s\*adhoc\\s\*\$\/iu/u); + assert.match(darwinVerifier, /identifiers\.length !== 1/u); + assert.match(darwinVerifier, /identifiers\[0\] !== REQUIRED_IDENTIFIER/u); + assert.match(darwinVerifier, /signatureSizes\.length !== 1/u); + assert.match(darwinVerifier, /POSITIVE_SIGNATURE_SIZE\.test\(signatureSizes\[0\]\)/u); + assert.match(darwinVerifier, /DESIGNATED_REQUIREMENT_PREFIX/u); + assert.match(darwinVerifier, /DESIGNATED_REQUIREMENT_GRAMMAR/u); + assert.match(darwinVerifier, /designatedLines\.length !== 1/u); + assert.match(darwinVerifier, /requirementMatch\[1\] !== REQUIRED_IDENTIFIER/u); + assert.match(darwinVerifier, /requirementMatch\[2\]\.toUpperCase\(\) !== expectedSha1/u); + assert.doesNotMatch(darwinVerifier, /Authority=/u); + assert.doesNotMatch(darwinVerifier, /extract-certificates/u); + assert.doesNotMatch(darwinVerifier, /find-identity/u); + assert.match(darwinSigner, /certificate leaf = H"\$\{certificateSha1\}"/u); + assert.match(darwinSigner, /filter\(filePath => !PACKAGED_CONNECT_NATIVE_ARTIFACTS\.test\(filePath\)\)/u); + assert.match(darwinSigner, /'--verify', '--deep', '--strict', application/u); + assert.match(darwinVerifier, /'--verify', '--deep', '--strict', application/u); + assert.match(darwinVerifier, /previousDesignatedRequirement !== normalizedRequirement/u); + assert.match(desktopMain, /storageBackend: requiredStorageBackend/u); + assert.match(packagedConnectSmoke, /expectedStorageBackend: 'os-protected'/u); + assert.match(packagedConnectSmoke, /outcome = await runPhase\('pair'\);[\s\S]*?outcome = await runPhase\('reprobe'\)/u); + assert.match(packagedConnectSmoke, /authenticatedRestCount: authenticatedRest\.length/u); + assert.match(packagedConnectSmoke, /authenticatedSocketCount: socketEvidence\.authenticatedSocketCount/u); + const establish = darwinRunner.indexOf('node "$signature_verifier" establish'); + const smoke = darwinRunner.indexOf('npm run smoke:connect-package'); + const stable = darwinRunner.indexOf('node "$signature_verifier" stable'); + assert.ok(establish >= 0 && establish < smoke && smoke < stable); + assert.match(packagedConnectSmoke, /const runPhase = async phase => await runPackagedConnectLifecycle\([\s\S]*?spawn: spawnLifecycleProcess/u); + assert.match(packagedConnectSmoke, /outcome = await runPhase\('pair'\);[\s\S]*?if \(outcome\.ok && journeyFixture\) \{\s*outcome = await runPhase\('reprobe'\);/u); + assert.match(workflow, /target: darwin-x64\s+runner: macos-15-intel\s+platform: darwin\s+arch: x64/u); + assert.match(workflow, /target: darwin-arm64\s+runner: macos-15\s+platform: darwin\s+arch: arm64/u); + }); + + test('Darwin root signing sets, but never preserves, the required identifier', () => { + assert.match(darwinSigner, /isApplication \? \[\s*'--identifier', REQUIRED_IDENTIFIER,\s*'--preserve-metadata=entitlements,flags',\s*\] : \[\s*'--preserve-metadata=identifier,entitlements,flags',\s*\]/u); + const rootMetadataBranch = /isApplication \? \[([\s\S]*?)\] : \[/u.exec(darwinSigner)?.[1]; + assert.ok(rootMetadataBranch); + assert.match(rootMetadataBranch, /'--identifier', REQUIRED_IDENTIFIER/u); + assert.match(rootMetadataBranch, /'--preserve-metadata=entitlements,flags'/u); + assert.doesNotMatch(rootMetadataBranch, /--preserve-metadata=identifier,/u); + }); + + test('Darwin emits only allowlisted fixed stage markers around every blocking phase', async () => { + const expectedStages = [ + 'KEY_CERTIFICATE_GENERATION', + 'KEYCHAIN_CREATION_SELECTION', + 'IDENTITY_IMPORT', + 'PARTITION_LIST_UPDATE', + 'APPLICATION_SIGNING', + 'INITIAL_SIGNATURE_VERIFICATION', + 'PAIR_REPROBE_JOURNEY', + 'STABLE_SIGNATURE_VERIFICATION', + 'KEYCHAIN_RESTORATION_DELETION', + 'TEMPORARY_FILE_CLEANUP', + ]; + const invokedStages = [...darwinRunner.matchAll(/^\s*run_stage ([A-Z_]+)\b/gmu)] + .map(match => match[1]); + assert.deepEqual(new Set(invokedStages), new Set(expectedStages)); + assert.equal(invokedStages.length, expectedStages.length); + assert.match(darwinRunner, /case "\$code" in\n\s+STARTED\|PASSED\|FAILED\)/u); + assert.match(darwinRunner, /printf 'DARWIN_PACKAGED_CONNECT_SETUP:%s:%s\\n' "\$stage" "\$code"/u); + assert.doesNotMatch(darwinRunner, /stage_marker[^\n]*(?:password|certificate_serial|identity_sha1)/u); + + const markerFunction = darwinRunner.slice( + darwinRunner.indexOf('stage_marker() {'), + darwinRunner.indexOf('\n\nrun_bounded()'), + ); + const markerCalls = expectedStages + .flatMap(stage => ['STARTED', 'PASSED', 'FAILED'] + .map(code => `stage_marker ${stage} ${code}`)) + .join('\n'); + const { stdout } = await execFile('/bin/bash', ['-c', `${markerFunction}\n${markerCalls}`], { + encoding: 'utf8', timeout: 2_000, maxBuffer: 16 * 1024, + }); + assert.deepEqual(stdout.trim().split('\n'), expectedStages.flatMap(stage => [ + 'STARTED', 'PASSED', 'FAILED', + ].map(code => `DARWIN_PACKAGED_CONNECT_SETUP:${stage}:${code}`))); + await assert.rejects(execFile('/bin/bash', ['-c', `${markerFunction}\nstage_marker BAD SECRET`], { + encoding: 'utf8', timeout: 2_000, maxBuffer: 16 * 1024, + })); + }); + + test('Darwin bounds setup, nested signing, verification, journey, cleanup, and the wrapper', () => { + assert.match(boundedDarwinRunner, /detached: platform !== 'win32'/u); + assert.match(boundedDarwinRunner, /process\.kill\(-child\.pid, signal\)/u); + assert.match(boundedDarwinRunner, /GROUP_GUARD_RELEASE/u); + assert.match(boundedDarwinRunner, /prevents the PGID from being reused/u); + assert.match(boundedDarwinRunner, /signalProcessGroup\(child, 'SIGTERM'/u); + assert.match(boundedDarwinRunner, /signalProcessGroup\(child, 'SIGKILL'/u); + assert.match(boundedDarwinRunner, /maximumBytes - state\.bytes/u); + assert.match(darwinVerifier, /runBoundedProcess/u); + assert.match(darwinVerifier, /timeoutMs: VERIFICATION_TIMEOUT_MS/u); + assert.match(darwinVerifier, /maxOutputBytes: VERIFICATION_MAX_OUTPUT_BYTES/u); + assert.match(darwinSigner, /runBoundedProcess/u); + assert.match(darwinSigner, /timeoutMs: CODESIGN_TIMEOUT_MS/u); + assert.match(darwinSigner, /forwardOutput: false/u); + assert.match(darwinRunner, /run_bounded "\$COMMAND_TIMEOUT_MS" \/usr\/bin\/security/gmu); + assert.match(darwinRunner, /run_bounded "\$COMMAND_TIMEOUT_MS" \/usr\/bin\/openssl/gmu); + assert.match(darwinRunner, /run_bounded_forward "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); + assert.match(darwinRunner, /run_bounded_forward "\$JOURNEY_TIMEOUT_MS" npm run smoke:connect-package/u); + }); + + test('Darwin failure diagnostics are fixed, classified, and secret-safe', () => { + for (const diagnostic of [ + 'MISSING_IDENTITY_OR_CHAIN', + 'TRUST_REJECTION', + 'REQUIREMENTS_FAILURE', + 'CODESIGN_FAILURE', + ]) { + assert.match(darwinSigner, new RegExp(`['"]${diagnostic}['"]`, 'u')); + } + for (const diagnostic of [ + 'CERTIFICATE_LOOKUP_FAILURE', + 'SIGNATURE_DISPLAY_FAILURE', + 'EMBEDDED_REQUIREMENT_FAILURE', + 'STRICT_VERIFY_FAILURE', + 'KEYCHAIN_EVIDENCE_FAILURE', + 'ADHOC_SIGNATURE_FAILURE', + 'IDENTIFIER_METADATA_FAILURE', + 'SIGNATURE_METADATA_FAILURE', + 'REQUIREMENT_EVIDENCE_FAILURE', + 'EVIDENCE_ASSERTION_FAILURE', + ]) { + assert.match(darwinVerifier, new RegExp(`['"]${diagnostic}['"]`, 'u')); + } + assert.match(darwinSigner, /DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:\$\{classifyDarwinSigningFailure\(error\)\}/u); + assert.doesNotMatch(darwinSigner, /process\.stderr\.write\([^\n]*(?:application|keychain|certificateSha1|stderr|stdout)/u); + assert.match(darwinRunner, /run_bounded_forward "\$COMMAND_TIMEOUT_MS" node "\$signature_verifier" establish/u); + assert.match(darwinRunner, /run_bounded_forward "\$COMMAND_TIMEOUT_MS" node "\$signature_verifier" stable/u); + }); + + test('Darwin restores keychain state and deletes identity, credentials, and files on every exit', () => { + assert.match(darwinRunner, /trap cleanup_keychain EXIT/u); + assert.match(darwinRunner, /trap 'exit_for_signal 129' HUP/u); + assert.match(darwinRunner, /trap 'exit_for_signal 130' INT/u); + assert.match(darwinRunner, /trap 'exit_for_signal 143' TERM/u); + assert.match(darwinRunner, /if \[\[ -n "\$active_stage" \]\]; then\n\s+stage_marker "\$active_stage" FAILED/u); + assert.doesNotMatch(darwinRunner, /add-trusted-cert|remove-trusted-cert|trustRoot/u); + assert.match(darwinRunner, /\/usr\/bin\/security list-keychains -d user -s \\[\s\S]*?"\$\{original_keychains\[@\]\}"/u); + assert.match(darwinRunner, /\/usr\/bin\/security default-keychain -d user -s \\[\s\S]*?"\$original_default"/u); + assert.match(darwinRunner, /\/usr\/bin\/security delete-keychain "\$keychain_path"/u); + assert.doesNotMatch(darwinRunner, /certificate_prefix/u); + assert.match(darwinRunner, /"\$requirement_proof" "\$keychain_path"/u); + assert.match(darwinRunner, /run_bounded "\$CLEANUP_TIMEOUT_MS" \/bin\/rm -rf -- "\$keychain_root"/u); + assert.match(darwinRunner, /if \(\( cleanup_status != 0 \)\)[\s\S]*?primary_status=1/u); + }); + + test('Darwin smoke has no static or production identity and does not widen or pre-seed Safe Storage', async () => { + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.doesNotMatch(darwinRunner, /add-generic-password|Safe Storage|-A(?:\s|$)/u); + assert.doesNotMatch(darwinRunner, /Developer ID|notari|APPLE_|PROPR_DESKTOP_MAC_/iu); + assert.doesNotMatch(darwinRunner, /(?:keychain|identity)_password=['"][^$]/u); + assert.doesNotMatch(workflow, /secrets\.[^\n]*Packaged Connect|Packaged Connect[^\n]*secrets\./u); + assert.match(main, /const requiredStorageBackend = process\.platform === 'linux' \? 'gnome_libsecret' : 'os-protected'/u); + assert.match(main, /security\.backend !== requiredStorageBackend/u); }); }); diff --git a/apps/desktop/scripts/run-bounded-darwin-command.mjs b/apps/desktop/scripts/run-bounded-darwin-command.mjs new file mode 100644 index 000000000..f6e388235 --- /dev/null +++ b/apps/desktop/scripts/run-bounded-darwin-command.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node + +import { spawn as nodeSpawn } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024; +const EXIT_FOR_SIGNAL = new Map([['SIGHUP', 129], ['SIGINT', 130], ['SIGTERM', 143]]); +const GROUP_GUARD_ARGUMENT = '--internal-process-group-guard'; +const GROUP_GUARD_RELEASE = 'release-process-group-guard'; +const GROUP_GUARD_RESULT = 'process-group-command-result'; + +export class BoundedProcessError extends Error { + constructor(reason, result) { + super(`bounded-process-${reason}`); + this.name = 'BoundedProcessError'; + this.reason = reason; + this.result = result; + } +} + +const appendBounded = (chunks, chunk, state, maximumBytes, forward) => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const available = Math.max(0, maximumBytes - state.bytes); + const accepted = value.subarray(0, available); + if (accepted.length > 0) { + chunks.push(accepted); + state.bytes += accepted.length; + forward?.write(accepted); + } + if (accepted.length !== value.length) state.truncated = true; +}; + +const signalProcessGroup = (child, signal, platform) => { + if (!child?.pid) return; + try { + if (platform === 'win32') child.kill(signal); + else process.kill(-child.pid, signal); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } +}; + +const runProcessGroupGuard = async argv => { + if (argv[0] !== '--' || typeof argv[1] !== 'string' || argv[1].length === 0) { + process.exitCode = 1; + return; + } + + // The guard is the process-group leader and deliberately survives TERM. Keeping its PID + // occupied until its supervisor releases or kills it prevents the PGID from being reused + // while a TERM-ignoring descendant may still belong to the group. + const ignoredSignals = [...EXIT_FOR_SIGNAL.keys()]; + const ignoreSignal = () => {}; + for (const signal of ignoredSignals) process.on(signal, ignoreSignal); + + let commandResult; + let resultPublished = false; + const publishResult = result => { + if (resultPublished) return; + resultPublished = true; + commandResult = result; + if (process.send) { + process.send({ type: GROUP_GUARD_RESULT, ...result }, () => {}); + } + }; + + const command = nodeSpawn(argv[1], argv.slice(2), { + detached: false, + shell: false, + windowsHide: true, + stdio: ['ignore', 'inherit', 'inherit'], + }); + command.once('error', () => publishResult({ + exitCode: 1, signal: null, spawnError: true, + })); + command.once('close', (exitCode, signal) => publishResult({ exitCode, signal })); + + process.on('message', message => { + if (message?.type !== GROUP_GUARD_RELEASE || !commandResult) return; + process.exitCode = commandResult.exitCode ?? 1; + process.disconnect?.(); + }); +}; + +export const runBoundedProcess = async ({ + executable, + arguments: arguments_ = [], + timeoutMs, + terminationGraceMs = 5_000, + maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, + forwardOutput = false, + spawn = nodeSpawn, + platform = process.platform, + onSpawn, + signalSource = process, +}) => { + if (typeof executable !== 'string' || executable.length === 0 + || !Array.isArray(arguments_) + || !Number.isInteger(timeoutMs) || timeoutMs <= 0 + || !Number.isInteger(terminationGraceMs) || terminationGraceMs <= 0 + || !Number.isInteger(maxOutputBytes) || maxOutputBytes <= 0) { + throw new BoundedProcessError('invalid-input'); + } + + const stdoutChunks = []; + const stderrChunks = []; + const stdoutState = { bytes: 0, truncated: false }; + const stderrState = { bytes: 0, truncated: false }; + let primaryReason; + let requestedSignal; + let forceTimer; + let drainTimer; + let timeout; + let child; + let childClosed; + let commandResult; + let commandSpawnFailed = false; + let resolveForcedSettlement; + const forcedSettlement = new Promise(resolve => { resolveForcedSettlement = resolve; }); + + const requestTermination = reason => { + if (!primaryReason) primaryReason = reason; + try { + signalProcessGroup(child, 'SIGTERM', platform); + } catch { + // The primary failure remains the timeout/signal even if termination reports a race. + } + if (!forceTimer) { + forceTimer = setTimeout(() => { + try { + signalProcessGroup(child, 'SIGKILL', platform); + } catch { + // A failed final kill is reflected by the bounded supervisor exit, without arguments. + } + drainTimer = setTimeout(() => resolveForcedSettlement({ + exitCode: null, signal: 'SIGKILL', drainTimedOut: true, + }), 1_000); + }, terminationGraceMs); + } + }; + + const signalHandlers = new Map(); + for (const signal of EXIT_FOR_SIGNAL.keys()) { + const handler = () => { + requestedSignal ??= signal; + requestTermination('signal'); + }; + signalHandlers.set(signal, handler); + signalSource.on(signal, handler); + } + + try { + const guardProcessGroup = platform !== 'win32'; + child = spawn( + guardProcessGroup ? process.execPath : executable, + guardProcessGroup + ? [fileURLToPath(import.meta.url), GROUP_GUARD_ARGUMENT, '--', executable, ...arguments_] + : arguments_, { + detached: platform !== 'win32', + shell: false, + windowsHide: true, + stdio: guardProcessGroup + ? ['ignore', 'pipe', 'pipe', 'ipc'] + : ['ignore', 'pipe', 'pipe'], + }); + const processError = new Promise(resolve => { + child.once('error', error => resolve({ operationError: error })); + }); + childClosed = new Promise(resolve => { + child.once('close', (exitCode, signal) => resolve(commandResult ?? { exitCode, signal })); + }); + if (guardProcessGroup) { + child.on('message', message => { + if (message?.type !== GROUP_GUARD_RESULT || commandResult) return; + commandResult = { exitCode: message.exitCode, signal: message.signal }; + commandSpawnFailed = message.spawnError === true; + if (primaryReason) return; + if (commandSpawnFailed) requestTermination('spawn-or-io'); + else if (commandResult.exitCode !== 0 || commandResult.signal) requestTermination('exit'); + else { + // Only success releases the guard. Every failure retains the PGID through SIGKILL. + child.send({ type: GROUP_GUARD_RELEASE }, () => {}); + } + }); + } + child.stdout?.on('data', chunk => appendBounded( + stdoutChunks, chunk, stdoutState, maxOutputBytes, + forwardOutput ? process.stdout : undefined, + )); + child.stderr?.on('data', chunk => appendBounded( + stderrChunks, chunk, stderrState, maxOutputBytes, + forwardOutput ? process.stderr : undefined, + )); + + onSpawn?.(child); + if (primaryReason) signalProcessGroup(child, 'SIGTERM', platform); + timeout = setTimeout(() => requestTermination('timeout'), timeoutMs); + const settlement = await Promise.race([ + childClosed, processError, forcedSettlement, + ]) + .finally(() => clearTimeout(timeout)); + if ('operationError' in settlement) throw settlement.operationError; + const result = settlement; + if (forceTimer) clearTimeout(forceTimer); + if (drainTimer) clearTimeout(drainTimer); + if (result.drainTimedOut) { + try { child.disconnect?.(); } catch { /* The IPC channel may already be closed. */ } + child.channel?.unref?.(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + } + + const completed = { + ...result, + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + stdoutTruncated: stdoutState.truncated, + stderrTruncated: stderrState.truncated, + requestedSignal, + }; + if (primaryReason) throw new BoundedProcessError(primaryReason, completed); + if (result.exitCode !== 0) throw new BoundedProcessError('exit', completed); + return completed; + } catch (error) { + if (child?.pid && !primaryReason) requestTermination('spawn-or-io'); + if (child?.pid && childClosed && forceTimer) { + // The guard ignores TERM, so this settles only after SIGKILL or the final drain bound. + await Promise.race([childClosed, forcedSettlement]); + } + if (error instanceof BoundedProcessError) throw error; + throw new BoundedProcessError('spawn-or-io', { cause: error }); + } finally { + if (timeout) clearTimeout(timeout); + if (forceTimer) clearTimeout(forceTimer); + if (drainTimer) clearTimeout(drainTimer); + for (const [signal, handler] of signalHandlers) signalSource.off(signal, handler); + } +}; + +const parseCli = argv => { + const separator = argv.indexOf('--'); + if (separator < 0 || separator === argv.length - 1) throw new Error('invalid-cli'); + const options = argv.slice(0, separator); + const command = argv.slice(separator + 1); + const parsed = { + timeoutMs: undefined, + terminationGraceMs: 5_000, + maxOutputBytes: DEFAULT_MAX_OUTPUT_BYTES, + forwardOutput: false, + stdoutFile: undefined, + }; + for (let index = 0; index < options.length; index += 2) { + const option = options[index]; + const value = options[index + 1]; + if (value === undefined) throw new Error('invalid-cli'); + if (option === '--timeout-ms') parsed.timeoutMs = Number(value); + else if (option === '--termination-grace-ms') parsed.terminationGraceMs = Number(value); + else if (option === '--max-output-bytes') parsed.maxOutputBytes = Number(value); + else if (option === '--forward-output') parsed.forwardOutput = value === 'true'; + else if (option === '--stdout-file') parsed.stdoutFile = value; + else throw new Error('invalid-cli'); + } + return { ...parsed, executable: command[0], arguments: command.slice(1) }; +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + if (process.argv[2] === GROUP_GUARD_ARGUMENT) { + await runProcessGroupGuard(process.argv.slice(3)); + } else try { + const options = parseCli(process.argv.slice(2)); + const result = await runBoundedProcess(options); + if (options.stdoutFile) { + await writeFile(options.stdoutFile, result.stdout, { encoding: 'utf8', mode: 0o600 }); + } + } catch (error) { + if (error instanceof BoundedProcessError && error.reason === 'timeout') { + process.stderr.write('Bounded Darwin operation timed out.\n'); + process.exitCode = 124; + } else if (error instanceof BoundedProcessError && error.reason === 'signal') { + process.exitCode = EXIT_FOR_SIGNAL.get(error.result?.requestedSignal) ?? 1; + } else { + process.stderr.write('Bounded Darwin operation failed.\n'); + process.exitCode = error instanceof BoundedProcessError && error.reason === 'exit' + ? (error.result.exitCode ?? 1) : 1; + } + } +} diff --git a/apps/desktop/scripts/run-bounded-darwin-command.test.mjs b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs new file mode 100644 index 000000000..6315feaf4 --- /dev/null +++ b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { execFile as nodeExecFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { BoundedProcessError, runBoundedProcess } from './run-bounded-darwin-command.mjs'; + +const helperPath = join(dirname(fileURLToPath(import.meta.url)), 'run-bounded-darwin-command.mjs'); + +const waitForProcessExit = async processId => { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + process.kill(processId, 0); + if (process.platform === 'linux') { + const processState = (await readFile(`/proc/${processId}/stat`, 'utf8')).split(' ')[2]; + if (processState === 'Z') return; + } + await delay(20); + } catch (error) { + if (error?.code === 'ESRCH') return; + throw error; + } + } + assert.fail('timed-out descendant process remained alive'); +}; + +test('bounds output while continuously draining both child streams', async () => { + const result = await runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', 'process.stdout.write("A".repeat(8192)); process.stderr.write("B".repeat(8192));'], + timeoutMs: 2_000, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }); + assert.equal(Buffer.byteLength(result.stdout), 1_024); + assert.equal(Buffer.byteLength(result.stderr), 1_024); + assert.equal(result.stdoutTruncated, true); + assert.equal(result.stderrTruncated, true); +}); + +test('timeout terminates the owned process group including a descendant', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-bound-')); + const descendantPidPath = join(fixtureRoot, 'descendant.pid'); + try { + await assert.rejects(runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });', + 'writeFileSync(process.argv[1], String(child.pid));', + 'setInterval(() => {}, 1000);', + ].join(' '), descendantPidPath], + timeoutMs: 300, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError && error.reason === 'timeout'); + const descendantPid = Number(await readFile(descendantPidPath, 'utf8')); + assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); + await waitForProcessExit(descendantPid); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('SIGKILL escalation survives leader close and removes a TERM-ignoring descendant', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-escalation-')); + const descendantPidPath = join(fixtureRoot, 'descendant.pid'); + try { + await assert.rejects(runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', [ + 'const { spawn } = require("node:child_process");', + 'process.on("SIGTERM", () => process.exit(0));', + 'spawn(process.execPath, ["-e", [', + ' "const { writeFileSync } = require(\\"node:fs\\");",', + ' "process.on(\\"SIGTERM\\", () => {});",', + ' "writeFileSync(process.argv[1], String(process.pid));",', + ' "setInterval(() => {}, 1000);",', + '].join(" "), process.argv[1]], { stdio: "ignore" });', + 'setInterval(() => {}, 1000);', + ].join(' '), descendantPidPath], + timeoutMs: 500, + terminationGraceMs: 150, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'timeout' + && error.result.exitCode === 0); + const descendantPid = Number(await readFile(descendantPidPath, 'utf8')); + assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); + await waitForProcessExit(descendantPid); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('timeout remains primary while TERM runs the wrapper cleanup', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-cleanup-')); + const cleanupPath = join(fixtureRoot, 'cleanup.txt'); + try { + await assert.rejects(runBoundedProcess({ + executable: '/bin/bash', + arguments: ['-c', [ + 'trap \"printf CLEANED > \\\"$1\\\"; exit 143\" TERM', + 'sleep 30 &', + 'wait', + ].join('\n'), 'bash', cleanupPath], + timeoutMs: 300, + terminationGraceMs: 1_000, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'timeout' + && error.result.exitCode === 143); + assert.equal(await readFile(cleanupPath, 'utf8'), 'CLEANED'); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('a command failure is not replaced by timeout or cleanup status', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-primary-')); + const cleanupPath = join(fixtureRoot, 'cleanup.txt'); + try { + await assert.rejects(runBoundedProcess({ + executable: '/bin/bash', + arguments: ['-c', 'trap \"printf CLEANED > \\\"$1\\\"\" EXIT; exit 23', 'bash', cleanupPath], + timeoutMs: 2_000, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'exit' + && error.result.exitCode === 23); + assert.equal(await readFile(cleanupPath, 'utf8'), 'CLEANED'); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('nonzero exit escalates against a TERM-ignoring descendant before releasing the guard', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'propr-darwin-nonzero-')); + const descendantPidPath = join(fixtureRoot, 'descendant.pid'); + try { + await assert.rejects(runBoundedProcess({ + executable: process.execPath, + arguments: ['-e', [ + 'const { existsSync } = require("node:fs");', + 'const { spawn } = require("node:child_process");', + 'spawn(process.execPath, ["-e", [', + ' "const { writeFileSync } = require(\\"node:fs\\");",', + ' "process.on(\\"SIGTERM\\", () => {});",', + ' "writeFileSync(process.argv[1], String(process.pid));",', + ' "setInterval(() => {}, 1000);",', + '].join(" "), process.argv[1]], { stdio: "ignore" });', + 'const waitState = new Int32Array(new SharedArrayBuffer(4));', + 'const deadline = Date.now() + 1000;', + 'while (!existsSync(process.argv[1]) && Date.now() < deadline) Atomics.wait(waitState, 0, 0, 10);', + 'process.exit(existsSync(process.argv[1]) ? 23 : 24);', + ].join(' '), descendantPidPath], + timeoutMs: 2_000, + terminationGraceMs: 150, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'exit' + && error.result.exitCode === 23); + const descendantPid = Number(await readFile(descendantPidPath, 'utf8')); + assert.ok(Number.isInteger(descendantPid) && descendantPid > 0); + await waitForProcessExit(descendantPid); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +test('command spawn error retains the guard through SIGKILL escalation', async () => { + const startedAt = Date.now(); + await assert.rejects(runBoundedProcess({ + executable: join(tmpdir(), 'propr-command-that-does-not-exist'), + timeoutMs: 2_000, + terminationGraceMs: 100, + maxOutputBytes: 1_024, + }), error => error instanceof BoundedProcessError + && error.reason === 'spawn-or-io' + && error.result.exitCode === 1); + assert.ok(Date.now() - startedAt >= 75, 'spawn failure released the process-group guard early'); +}); + +test('CLI timeout diagnostics never echo command arguments or secret values', async () => { + const secretArgument = 'DO_NOT_PRINT_THIS_SECRET'; + await assert.rejects(new Promise((resolve, reject) => { + nodeExecFile(process.execPath, [ + helperPath, + '--timeout-ms', '200', + '--termination-grace-ms', '100', + '--max-output-bytes', '1024', + '--forward-output', 'false', + '--', process.execPath, '-e', 'setInterval(() => {}, 1000)', secretArgument, + ], { encoding: 'utf8', timeout: 2_000 }, (error, stdout, stderr) => { + if (error) reject(Object.assign(error, { stdout, stderr })); + else resolve(); + }); + }), error => { + assert.equal(error.code, 124); + assert.equal(error.stdout, ''); + assert.equal(error.stderr, 'Bounded Darwin operation timed out.\n'); + assert.doesNotMatch(`${error.stdout}${error.stderr}`, new RegExp(secretArgument, 'u')); + return true; + }); +}); diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh new file mode 100644 index 000000000..79e7586fd --- /dev/null +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -0,0 +1,257 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$(uname -s)" != 'Darwin' ]]; then + echo 'Packaged Darwin Connect acceptance requires macOS.' >&2 + exit 1 +fi + +architecture="${1:-}" +if [[ "$architecture" != 'arm64' && "$architecture" != 'x64' ]]; then + echo 'Packaged Darwin Connect acceptance requires an explicit supported architecture.' >&2 + exit 1 +fi + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repository_root="$(cd "$script_directory/../../.." && pwd -P)" +application="$repository_root/apps/desktop/out/propr-desktop-darwin-$architecture/propr-desktop.app" +signature_verifier="$script_directory/verify-darwin-packaged-connect-signature.mjs" +application_signer="$script_directory/sign-darwin-packaged-connect.mjs" +bounded_runner="$script_directory/run-bounded-darwin-command.mjs" +if [[ ! -d "$application" || ! -f "$signature_verifier" || ! -f "$application_signer" + || ! -f "$bounded_runner" ]]; then + echo 'Packaged Darwin Connect acceptance artifact is missing.' >&2 + exit 1 +fi +cd "$repository_root" + +readonly COMMAND_TIMEOUT_MS=30000 +readonly CLEANUP_TIMEOUT_MS=10000 +readonly SIGNING_TIMEOUT_MS=180000 +readonly JOURNEY_TIMEOUT_MS=240000 +readonly TERMINATION_GRACE_MS=5000 +readonly MAX_OUTPUT_BYTES=262144 + +stage_marker() { + local stage="$1" + local code="$2" + case "$stage" in + KEY_CERTIFICATE_GENERATION|KEYCHAIN_CREATION_SELECTION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|STABLE_SIGNATURE_VERIFICATION|KEYCHAIN_RESTORATION_DELETION|TEMPORARY_FILE_CLEANUP) ;; + *) return 1 ;; + esac + case "$code" in + STARTED|PASSED|FAILED) ;; + *) return 1 ;; + esac + printf 'DARWIN_PACKAGED_CONNECT_SETUP:%s:%s\n' "$stage" "$code" +} + +run_bounded() { + local timeout_ms="$1" + shift + node "$bounded_runner" --timeout-ms "$timeout_ms" \ + --termination-grace-ms "$TERMINATION_GRACE_MS" \ + --max-output-bytes "$MAX_OUTPUT_BYTES" --forward-output false -- "$@" +} + +run_bounded_forward() { + local timeout_ms="$1" + shift + node "$bounded_runner" --timeout-ms "$timeout_ms" \ + --termination-grace-ms "$TERMINATION_GRACE_MS" \ + --max-output-bytes "$MAX_OUTPUT_BYTES" --forward-output true -- "$@" +} + +run_stage() { + local stage="$1" + shift + active_stage="$stage" + stage_marker "$stage" STARTED + if "$@"; then + stage_marker "$stage" PASSED + active_stage='' + return 0 + else + local stage_status=$? + stage_marker "$stage" FAILED + active_stage='' + return "$stage_status" + fi +} + +umask 077 +keychain_root='' +keychain_path='' +leaf_private_key='' +leaf_certificate='' +identity_archive='' +leaf_config='' +requirement_proof='' +identity_sha1='' +original_default='' +original_keychains=() +keychain_created=0 +keychain_state_captured=0 +active_stage='' + +restore_and_delete_keychain() { + local restore_status=0 + if (( keychain_state_captured != 0 )); then + if (( ${#original_keychains[@]} > 0 )); then + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security list-keychains -d user -s \ + "${original_keychains[@]}" || restore_status=1 + else + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security list-keychains -d user -s \ + || restore_status=1 + fi + if [[ -n "$original_default" ]]; then + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security default-keychain -d user -s \ + "$original_default" || restore_status=1 + fi + fi + if (( keychain_created != 0 )); then + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security delete-keychain "$keychain_path" \ + || restore_status=1 + fi + return "$restore_status" +} + +remove_temporary_files() { + if [[ -z "$keychain_root" ]]; then return 0; fi + run_bounded "$CLEANUP_TIMEOUT_MS" /bin/rm -rf -- "$keychain_root" +} + +cleanup_keychain() { + local primary_status=$? + local cleanup_status=0 + trap - EXIT HUP INT TERM + set +e + run_stage KEYCHAIN_RESTORATION_DELETION restore_and_delete_keychain || cleanup_status=1 + run_stage TEMPORARY_FILE_CLEANUP remove_temporary_files || cleanup_status=1 + unset keychain_password identity_password + if (( cleanup_status != 0 )); then + echo 'Packaged Darwin Connect acceptance cleanup failed.' >&2 + if (( primary_status == 0 )); then primary_status=1; fi + fi + exit "$primary_status" +} + +exit_for_signal() { + local exit_code="$1" + if [[ -n "$active_stage" ]]; then + stage_marker "$active_stage" FAILED + active_stage='' + fi + exit "$exit_code" +} +trap cleanup_keychain EXIT +trap 'exit_for_signal 129' HUP +trap 'exit_for_signal 130' INT +trap 'exit_for_signal 143' TERM + +create_and_select_keychain() { + local original_keychain_output + original_keychain_output="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/security list-keychains -d user)" || return $? + while IFS= read -r keychain; do + keychain="${keychain#"${keychain%%[![:space:]]*}"}" + keychain="${keychain#\"}" + keychain="${keychain%\"}" + if [[ -n "$keychain" ]]; then original_keychains+=("$keychain"); fi + done <<< "$original_keychain_output" + original_default="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/security default-keychain -d user)" || return $? + original_default="${original_default#"${original_default%%[![:space:]]*}"}" + original_default="${original_default#\"}" + original_default="${original_default%\"}" + keychain_state_captured=1 + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security create-keychain \ + -p "$keychain_password" "$keychain_path" || return $? + keychain_created=1 + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security set-keychain-settings \ + -lut 21600 "$keychain_path" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security unlock-keychain \ + -p "$keychain_password" "$keychain_path" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security list-keychains \ + -d user -s "$keychain_path" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security default-keychain \ + -d user -s "$keychain_path" +} + +generate_key_and_certificates() { + local fingerprint_output + keychain_root="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" /usr/bin/mktemp -d)" || return $? + [[ -n "$keychain_root" ]] || return 1 + keychain_path="$keychain_root/propr-packaged-connect-smoke.keychain-db" + leaf_private_key="$keychain_root/leaf-private.pem" + leaf_certificate="$keychain_root/leaf-certificate.pem" + identity_archive="$keychain_root/identity.p12" + leaf_config="$keychain_root/leaf.cnf" + requirement_proof="$keychain_root/designated-requirement.txt" + keychain_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/openssl rand -hex 32)" || return $? + identity_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/openssl rand -hex 32)" || return $? + builtin printf '%s\n' '[req]' 'distinguished_name = leaf_name' \ + 'x509_extensions = leaf_extensions' 'prompt = no' '' '[leaf_name]' \ + 'CN = ProPR Packaged Connect CI' '' '[leaf_extensions]' \ + 'basicConstraints = critical,CA:FALSE' 'keyUsage = critical,digitalSignature' \ + 'extendedKeyUsage = critical,codeSigning' 'subjectKeyIdentifier = hash' \ + 'authorityKeyIdentifier = keyid:always,issuer' > "$leaf_config" || return $? + + # A self-signed leaf makes the disposable PKCS#12 chain complete without modifying trust. + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl req -new -x509 -newkey rsa:2048 \ + -sha256 -nodes -days 1 -config "$leaf_config" -keyout "$leaf_private_key" \ + -out "$leaf_certificate" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl pkcs12 -export \ + -inkey "$leaf_private_key" -in "$leaf_certificate" -out "$identity_archive" \ + -passout "pass:$identity_password" || return $? + fingerprint_output="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" /usr/bin/openssl x509 \ + -in "$leaf_certificate" -noout -fingerprint -sha1)" || return $? + identity_sha1="${fingerprint_output##*=}" + identity_sha1="${identity_sha1//:/}" + if [[ ! "$identity_sha1" =~ ^[A-F0-9]{40}$ ]]; then + echo 'Disposable Darwin signing certificate fingerprint is invalid.' >&2 + return 1 + fi +} + +import_identity() { + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security import "$identity_archive" \ + -k "$keychain_path" -P "$identity_password" -T /usr/bin/codesign +} + +update_partition_list() { + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security set-key-partition-list \ + -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" +} + +sign_application() { + run_bounded_forward "$SIGNING_TIMEOUT_MS" node "$application_signer" \ + "$application" "$keychain_path" "$identity_sha1" +} + +verify_initial_signature() { + run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ + "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" +} + +run_pair_and_reprobe() { + run_bounded_forward "$JOURNEY_TIMEOUT_MS" npm run smoke:connect-package -w @propr/desktop +} + +verify_stable_signature() { + run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ + "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" +} + +run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates +run_stage KEYCHAIN_CREATION_SELECTION create_and_select_keychain +run_stage IDENTITY_IMPORT import_identity +run_stage PARTITION_LIST_UPDATE update_partition_list +unset keychain_password identity_password +run_stage APPLICATION_SIGNING sign_application +run_stage INITIAL_SIGNATURE_VERIFICATION verify_initial_signature +run_stage PAIR_REPROBE_JOURNEY run_pair_and_reprobe +run_stage STABLE_SIGNATURE_VERIFICATION verify_stable_signature diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs new file mode 100644 index 000000000..b494d6076 --- /dev/null +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import { lstat, open, readdir } from 'node:fs/promises'; +import { extname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; + +const CERTIFICATE_SHA1 = /^[A-F0-9]{40}$/u; +const CERTIFICATE_LINE = /^\s*SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/gmu; +const PACKAGED_CONNECT_NATIVE_ARTIFACTS = /\/Resources\/app\.asar\.unpacked\/\.vite\/native\/prebuilds\//u; +const CODESIGN_TIMEOUT_MS = 30_000; +const CODESIGN_MAX_OUTPUT_BYTES = 256 * 1024; +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const MACH_O_MAGICS = new Set([ + 0xFEEDFACE, 0xFEEDFACF, 0xCEFAEDFE, 0xCFFAEDFE, + 0xCAFEBABE, 0xBEBAFECA, 0xCAFEBABF, 0xBFBAFECA, +]); + +export const DARWIN_SIGNING_DIAGNOSTICS = Object.freeze({ + missingIdentityOrChain: 'MISSING_IDENTITY_OR_CHAIN', + trustRejection: 'TRUST_REJECTION', + requirementsFailure: 'REQUIREMENTS_FAILURE', + codesignFailure: 'CODESIGN_FAILURE', +}); + +export class DarwinSigningDiagnosticError extends Error { + constructor(diagnostic, cause) { + super(`darwin-signing-${diagnostic.toLowerCase()}`, { cause }); + this.name = 'DarwinSigningDiagnosticError'; + this.diagnostic = diagnostic; + } +} + +const failureText = error => [ + error?.message, + error?.stdout, + error?.stderr, + error?.result?.stdout, + error?.result?.stderr, + error?.cause?.message, +].filter(value => typeof value === 'string').join('\n'); + +export const classifyDarwinSigningFailure = error => { + if (error instanceof DarwinSigningDiagnosticError) return error.diagnostic; + const details = failureText(error); + if (/CSSMERR_TP_NOT_TRUSTED|errSecNotTrusted|certificate (?:is )?not trusted|trust evaluation/iu.test(details)) { + return DARWIN_SIGNING_DIAGNOSTICS.trustRejection; + } + if (/unable to build chain|incomplete certificate chain|no identity found|identity[^\n]*not found|specified item could not be found in the keychain/iu.test(details)) { + return DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain; + } + if (/designated requirement|invalid requirement|code requirement|requirement compilation/iu.test(details)) { + return DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure; + } + return DARWIN_SIGNING_DIAGNOSTICS.codesignFailure; +}; + +export const darwinSigningDiagnosticLine = error => ( + `DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:${classifyDarwinSigningFailure(error)}\n` +); + +const signingRank = filePath => { + const depth = filePath.split(sep).length; + return depth * 2 + (/\.app\/Contents\/MacOS\/[^/]+$/u.test(filePath) ? 0 : 1); +}; + +const runSigningCommand = (runCommand, executable, arguments_) => runCommand({ + executable, + arguments: arguments_, + timeoutMs: CODESIGN_TIMEOUT_MS, + // Settle the nested command group before the outer signing wrapper's five-second escalation. + terminationGraceMs: 1_000, + maxOutputBytes: CODESIGN_MAX_OUTPUT_BYTES, + forwardOutput: false, +}); + +const assertExactImportedCertificate = (output, certificateSha1) => { + const fingerprints = [...output.matchAll(CERTIFICATE_LINE)] + .map(match => match[1].toUpperCase()); + if (fingerprints.length !== 1 || fingerprints[0] !== certificateSha1) { + throw new DarwinSigningDiagnosticError( + DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, + ); + } +}; + +const isMachO = async filePath => { + const handle = await open(filePath, 'r'); + try { + const header = Buffer.alloc(4); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + return bytesRead === header.length && MACH_O_MAGICS.has(header.readUInt32BE(0)); + } finally { + await handle.close(); + } +}; + +export const discoverDarwinSignablePaths = async root => { + const discovered = []; + const visit = async directory => { + const entries = await readdir(directory); + entries.sort(); + for (const entry of entries) { + const filePath = join(directory, entry); + const stats = await lstat(filePath); + if (stats.isSymbolicLink()) continue; + if (stats.isDirectory()) { + await visit(filePath); + if (extname(filePath) === '.app' || extname(filePath) === '.framework') { + discovered.push(filePath); + } + } else if (stats.isFile() && await isMachO(filePath)) { + discovered.push(filePath); + } + } + }; + await visit(root); + return discovered; +}; + +export const signDarwinPackagedConnectApplication = async ({ + application, + keychain, + certificateSha1, + discover = discoverDarwinSignablePaths, + runCommand = runBoundedProcess, +}) => { + if (!application.endsWith('.app') || !keychain.endsWith('.keychain-db') + || !CERTIFICATE_SHA1.test(certificateSha1)) { + throw new Error('invalid-acceptance-signing-input'); + } + const certificateResult = await runSigningCommand(runCommand, '/usr/bin/security', [ + 'find-certificate', '-a', '-Z', keychain, + ]).catch(error => { + throw new DarwinSigningDiagnosticError( + DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, + error, + ); + }); + assertExactImportedCertificate( + `${certificateResult.stdout}\n${certificateResult.stderr}`, + certificateSha1, + ); + + const designatedRequirement = `designated => identifier "${REQUIRED_IDENTIFIER}" and certificate leaf = H"${certificateSha1}"`; + const discovered = (await discover(join(application, 'Contents'))) + .filter(filePath => !PACKAGED_CONNECT_NATIVE_ARTIFACTS.test(filePath)); + const targets = [...discovered, application] + .sort((left, right) => signingRank(right) - signingRank(left)); + const targetsByRank = new Map(); + for (const target of targets) { + const rank = signingRank(target); + targetsByRank.set(rank, [...(targetsByRank.get(rank) ?? []), target]); + } + + for (const targetGroup of targetsByRank.values()) { + const isApplication = targetGroup.length === 1 && targetGroup[0] === application; + const arguments_ = [ + '--sign', certificateSha1, + '--force', + '--keychain', keychain, + '--timestamp=none', + ...(isApplication ? [ + '--identifier', REQUIRED_IDENTIFIER, + '--preserve-metadata=entitlements,flags', + ] : [ + '--preserve-metadata=identifier,entitlements,flags', + ]), + ...(isApplication ? [`-r=${designatedRequirement}`] : []), + ...targetGroup, + ]; + await runSigningCommand(runCommand, '/usr/bin/codesign', arguments_); + } + await runSigningCommand(runCommand, '/usr/bin/codesign', [ + '--verify', '--deep', '--strict', application, + ]); +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const [application, keychain, certificateSha1] = process.argv.slice(2); + try { + if (process.platform !== 'darwin' || !application || !keychain || !certificateSha1) { + throw new Error('invalid-invocation'); + } + await signDarwinPackagedConnectApplication({ application, keychain, certificateSha1 }); + } catch (error) { + process.stderr.write(darwinSigningDiagnosticLine(error)); + process.exitCode = 1; + } +} diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs new file mode 100644 index 000000000..aea733459 --- /dev/null +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + DARWIN_SIGNING_DIAGNOSTICS, + classifyDarwinSigningFailure, + darwinSigningDiagnosticLine, + discoverDarwinSignablePaths, + signDarwinPackagedConnectApplication, +} from './sign-darwin-packaged-connect.mjs'; + +const fingerprint = 'A'.repeat(40); +const application = '/tmp/propr-desktop.app'; +const keychain = '/tmp/propr-smoke.keychain-db'; +const nativeArtifact = `${application}/Contents/Resources/app.asar.unpacked/.vite/native/prebuilds/darwin-arm64/directory-operations.node`; + +describe('Darwin packaged Connect direct signing', () => { + test('discovers Mach-O files and nested code bundles', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-darwin-signables-')); + try { + const helper = join(root, 'Helper.app'); + const executable = join(helper, 'Contents', 'MacOS', 'Helper'); + const framework = join(root, 'Library.framework'); + await mkdir(join(helper, 'Contents', 'MacOS'), { recursive: true }); + await mkdir(framework); + await writeFile(executable, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x00])); + await writeFile(join(root, 'data.bin'), Buffer.from('not executable code')); + assert.deepEqual(await discoverDarwinSignablePaths(root), [ + executable, + helper, + framework, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('selects the exact certificate and signs inside-out with fixed noninteractive options', async () => { + const calls = []; + const framework = `${application}/Contents/Frameworks/Electron Framework.framework`; + const helper = `${application}/Contents/Frameworks/propr Helper.app`; + const helperExecutable = `${helper}/Contents/MacOS/propr Helper`; + const mainExecutable = `${application}/Contents/MacOS/propr-desktop`; + await signDarwinPackagedConnectApplication({ + application, + keychain, + certificateSha1: fingerprint, + discover: async () => [mainExecutable, framework, helper, helperExecutable, nativeArtifact], + runCommand: async options => { + calls.push(options); + if (options.executable === '/usr/bin/security') { + return { stdout: `SHA-1 hash: ${fingerprint}\n`, stderr: '' }; + } + return { stdout: '', stderr: '' }; + }, + }); + + assert.deepEqual(calls[0].arguments, ['find-certificate', '-a', '-Z', keychain]); + const signingCalls = calls.filter(call => call.arguments[0] === '--sign'); + const signedTargets = signingCalls.flatMap(call => call.arguments.filter(argument => ( + argument.startsWith(application) + ))); + assert.equal(signedTargets.includes(nativeArtifact), false); + assert.equal(signedTargets.at(-1), application); + assert.ok(signedTargets.indexOf(helperExecutable) < signedTargets.indexOf(helper)); + assert.ok(signedTargets.indexOf(helper) < signedTargets.indexOf(mainExecutable)); + for (const call of signingCalls) { + assert.equal(call.executable, '/usr/bin/codesign'); + assert.equal(call.forwardOutput, false); + assert.equal(call.timeoutMs, 30_000); + assert.equal(call.terminationGraceMs, 1_000); + } + const nestedArguments = targets => [ + '--sign', fingerprint, + '--force', + '--keychain', keychain, + '--timestamp=none', + '--preserve-metadata=identifier,entitlements,flags', + ...targets, + ]; + assert.deepEqual(signingCalls.map(call => call.arguments), [ + nestedArguments([helperExecutable]), + nestedArguments([framework, helper]), + nestedArguments([mainExecutable]), + [ + '--sign', fingerprint, + '--force', + '--keychain', keychain, + '--timestamp=none', + '--identifier', 'dev.propr.desktop', + '--preserve-metadata=entitlements,flags', + `-r=designated => identifier "dev.propr.desktop" and certificate leaf = H"${fingerprint}"`, + application, + ], + ]); + assert.deepEqual(calls.at(-1).arguments, [ + '--verify', '--deep', '--strict', application, + ]); + }); + + test('fails before codesign when the imported certificate is absent, duplicate, or wrong', async () => { + for (const stdout of [ + '', + `SHA-1 hash: ${fingerprint}\nSHA-1 hash: ${fingerprint}\n`, + `SHA-1 hash: ${fingerprint}\nSHA-1 hash: ${'B'.repeat(40)}\n`, + `SHA-1 hash: ${'B'.repeat(40)}\n`, + ]) { + await assert.rejects(signDarwinPackagedConnectApplication({ + application, + keychain, + certificateSha1: fingerprint, + discover: async () => [], + runCommand: async () => ({ stdout, stderr: '' }), + }), error => ( + classifyDarwinSigningFailure(error) + === DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain + )); + } + }); + + test('emits only fixed classified diagnostics for sensitive native failures', () => { + const secret = 'SECRET_PATH_PASSWORD_FINGERPRINT'; + const cases = [ + ['CSSMERR_TP_NOT_TRUSTED', DARWIN_SIGNING_DIAGNOSTICS.trustRejection], + ['unable to build chain to self-signed root', DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain], + ['invalid designated requirement', DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure], + ['codesign failed', DARWIN_SIGNING_DIAGNOSTICS.codesignFailure], + ]; + for (const [stderr, diagnostic] of cases) { + const line = darwinSigningDiagnosticLine({ stderr: `${stderr} ${secret}` }); + assert.equal(line, `DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:${diagnostic}\n`); + assert.doesNotMatch(line, new RegExp(secret, 'u')); + } + }); +}); diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index 243d63e24..d1bc37125 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -590,6 +590,7 @@ try { platform: process.platform, arch: process.arch, authorityMechanism: authorityMechanism(), + ...(process.platform === 'darwin' ? { expectedStorageBackend: 'os-protected' } : {}), sensitiveNeedles, treeKillerPath, env: { diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs new file mode 100644 index 000000000..9de49e00e --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; +import { + DarwinSigningDiagnosticError, + darwinSigningDiagnosticLine, +} from './sign-darwin-packaged-connect.mjs'; + +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const SHA1_PATTERN = /^[A-F0-9]{40}$/u; +const CERTIFICATE_LINE = /^\s*SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/gmu; +const ADHOC_SIGNATURE_LINE = /^\s*signature\s*=\s*adhoc\s*$/iu; +const IDENTIFIER_LINE = /^\s*identifier\s*=\s*(.*?)\s*$/iu; +const SIGNATURE_SIZE_LINE = /^\s*signature\s+size\s*=\s*(.*?)\s*$/iu; +const POSITIVE_SIGNATURE_SIZE = /^[1-9][0-9]*$/u; +const DESIGNATED_REQUIREMENT_PREFIX = /^designated\s*=>/iu; +const DESIGNATED_REQUIREMENT_GRAMMAR = /^designated\s*=>\s*identifier\s+"([^"]+)"\s+and\s+certificate\s+leaf\s*=\s*H\s*"([A-F0-9]{40})"$/iu; +const VERIFICATION_TIMEOUT_MS = 20_000; +const VERIFICATION_TERMINATION_GRACE_MS = 1_000; +const VERIFICATION_MAX_OUTPUT_BYTES = 256 * 1024; + +export const DARWIN_VERIFICATION_DIAGNOSTICS = Object.freeze({ + certificateLookupFailure: 'CERTIFICATE_LOOKUP_FAILURE', + signatureDisplayFailure: 'SIGNATURE_DISPLAY_FAILURE', + embeddedRequirementFailure: 'EMBEDDED_REQUIREMENT_FAILURE', + strictVerifyFailure: 'STRICT_VERIFY_FAILURE', + keychainEvidenceFailure: 'KEYCHAIN_EVIDENCE_FAILURE', + adhocSignatureFailure: 'ADHOC_SIGNATURE_FAILURE', + identifierMetadataFailure: 'IDENTIFIER_METADATA_FAILURE', + signatureMetadataFailure: 'SIGNATURE_METADATA_FAILURE', + requirementEvidenceFailure: 'REQUIREMENT_EVIDENCE_FAILURE', + evidenceAssertionFailure: 'EVIDENCE_ASSERTION_FAILURE', +}); + +const verificationFailure = (diagnostic, cause) => new DarwinSigningDiagnosticError( + diagnostic, + cause, +); + +const runVerificationCommand = async (runCommand, executable, arguments_, diagnostic) => { + try { + return await runCommand({ + executable, + arguments: arguments_, + timeoutMs: VERIFICATION_TIMEOUT_MS, + terminationGraceMs: VERIFICATION_TERMINATION_GRACE_MS, + maxOutputBytes: VERIFICATION_MAX_OUTPUT_BYTES, + forwardOutput: false, + }); + } catch (cause) { + throw verificationFailure(diagnostic, cause); + } +}; + +const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); + +const expectedRequirementsFor = expectedCertificateSha1 => { + try { + const expectedSha1 = expectedCertificateSha1; + if (!SHA1_PATTERN.test(expectedSha1)) throw new Error('invalid-certificate-fingerprint'); + const expression = `identifier "${REQUIRED_IDENTIFIER}" and certificate leaf = H"${expectedSha1}"`; + return { + expectedSha1, + expression, + }; + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } +}; + +const assertExactKeychainCertificate = (certificateDetails, expectedCertificateSha1) => { + const { expectedSha1 } = expectedRequirementsFor(expectedCertificateSha1); + try { + const fingerprints = [...certificateDetails.matchAll(CERTIFICATE_LINE)] + .map(match => match[1].toUpperCase()); + if (fingerprints.length !== 1 || fingerprints[0] !== expectedSha1) { + throw new Error('invalid-keychain-certificate-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.keychainEvidenceFailure, + cause, + ); + } +}; + +const assertNotAdhocSignature = signatureDetails => { + try { + if (normalizeLines(signatureDetails).some(line => ADHOC_SIGNATURE_LINE.test(line))) { + throw new Error('ad-hoc-signature-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.adhocSignatureFailure, + cause, + ); + } +}; + +const assertIdentifierMetadata = signatureDetails => { + try { + const identifiers = normalizeLines(signatureDetails) + .map(line => line.match(IDENTIFIER_LINE)) + .filter(match => match !== null) + .map(match => match[1]); + if (identifiers.length !== 1 || identifiers[0] !== REQUIRED_IDENTIFIER) { + throw new Error('invalid-identifier-display-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure, + cause, + ); + } +}; + +const assertSignatureMetadata = signatureDetails => { + try { + const signatureSizes = normalizeLines(signatureDetails) + .map(line => line.match(SIGNATURE_SIZE_LINE)) + .filter(match => match !== null) + .map(match => match[1]); + if (signatureSizes.length !== 1 || !POSITIVE_SIGNATURE_SIZE.test(signatureSizes[0])) { + throw new Error('invalid-signature-display-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure, + cause, + ); + } +}; + +const assertDesignatedRequirement = ( + designatedRequirement, + expectedSha1, + previousDesignatedRequirement, +) => { + try { + const designatedLines = normalizeLines(designatedRequirement) + .map(line => line.trim()) + .filter(line => DESIGNATED_REQUIREMENT_PREFIX.test(line)); + if (designatedLines.length !== 1) { + throw new Error('ambiguous-embedded-requirement-evidence'); + } + const requirementMatch = designatedLines[0].match(DESIGNATED_REQUIREMENT_GRAMMAR); + if (!requirementMatch + || requirementMatch[1] !== REQUIRED_IDENTIFIER + || requirementMatch[2].toUpperCase() !== expectedSha1) { + throw new Error('invalid-embedded-requirement-evidence'); + } + const normalizedRequirement = `${designatedLines[0]}\n`; + if (previousDesignatedRequirement !== undefined + && previousDesignatedRequirement !== normalizedRequirement) { + throw new Error('unstable-embedded-requirement-evidence'); + } + return normalizedRequirement; + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure, + cause, + ); + } +}; + +export const assertDarwinSigningEvidence = ({ + expectedCertificateSha1, + signatureDetails, + designatedRequirement, + previousDesignatedRequirement, +}) => { + const expected = expectedRequirementsFor(expectedCertificateSha1); + assertNotAdhocSignature(signatureDetails); + assertIdentifierMetadata(signatureDetails); + assertSignatureMetadata(signatureDetails); + return assertDesignatedRequirement( + designatedRequirement, + expected.expectedSha1, + previousDesignatedRequirement, + ); +}; + +export const inspectDarwinSigningEvidence = async ({ + application, + keychain, + expectedCertificateSha1, + runCommand = runBoundedProcess, +}) => { + expectedRequirementsFor(expectedCertificateSha1); + const certificateResult = await runVerificationCommand( + runCommand, + '/usr/bin/security', + ['find-certificate', '-a', '-Z', keychain], + DARWIN_VERIFICATION_DIAGNOSTICS.certificateLookupFailure, + ); + assertExactKeychainCertificate( + `${certificateResult.stdout}\n${certificateResult.stderr}`, + expectedCertificateSha1, + ); + const signatureResult = await runVerificationCommand( + runCommand, + '/usr/bin/codesign', + ['-d', '--verbose=4', application], + DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, + ); + const requirementResult = await runVerificationCommand( + runCommand, + '/usr/bin/codesign', + ['-d', '-r-', application], + DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, + ); + await runVerificationCommand( + runCommand, + '/usr/bin/codesign', + ['--verify', '--deep', '--strict', application], + DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, + ); + try { + return { + signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, + designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, + }; + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } +}; + +export const verifyDarwinPackagedConnectSignature = async ({ + mode, + application, + expectedCertificateSha1, + proofPath, + keychain, + runCommand = runBoundedProcess, +}) => { + if (mode !== 'establish' && mode !== 'stable') { + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); + } + if (!keychain || !keychain.endsWith('.keychain-db')) { + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); + } + let previousDesignatedRequirement; + if (mode === 'stable') { + try { + previousDesignatedRequirement = await readFile(proofPath, 'utf8'); + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } + } + const evidence = await inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1, + runCommand, + }); + const requirement = assertDarwinSigningEvidence({ + expectedCertificateSha1, + previousDesignatedRequirement, + ...evidence, + }); + if (mode === 'establish') { + try { + await writeFile(proofPath, requirement, { + encoding: 'utf8', mode: 0o600, flag: 'wx', + }); + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } + } +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const [ + mode, application, expectedCertificateSha1, proofPath, keychain, + ] = process.argv.slice(2); + try { + if (process.platform !== 'darwin' + || !mode || !application || !expectedCertificateSha1 || !proofPath + || !keychain) { + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); + } + await verifyDarwinPackagedConnectSignature({ + mode, application, expectedCertificateSha1, proofPath, keychain, + }); + } catch (error) { + process.stderr.write(darwinSigningDiagnosticLine(error)); + process.exitCode = 1; + } +} diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs new file mode 100644 index 000000000..490f4a4ae --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -0,0 +1,455 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + classifyDarwinSigningFailure, + darwinSigningDiagnosticLine, +} from './sign-darwin-packaged-connect.mjs'; +import { + DARWIN_VERIFICATION_DIAGNOSTICS, + assertDarwinSigningEvidence, + inspectDarwinSigningEvidence, + verifyDarwinPackagedConnectSignature, +} from './verify-darwin-packaged-connect-signature.mjs'; + +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const application = '/private/tmp/propr-desktop.app'; +const keychain = '/private/tmp/propr-smoke.keychain-db'; +const fingerprint = 'A'.repeat(40); +const otherFingerprint = 'B'.repeat(40); +const requirementExpressionFor = certificateSha1 => ( + `identifier "${REQUIRED_IDENTIFIER}" and certificate leaf = H"${certificateSha1}"` +); +const requirementFor = certificateSha1 => ( + `designated => ${requirementExpressionFor(certificateSha1)}` +); + +const validEvidence = (overrides = {}) => ({ + expectedCertificateSha1: fingerprint, + signatureDetails: [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + `Identifier=${REQUIRED_IDENTIFIER}`, + 'Signature size=1024', + ].join('\n'), + designatedRequirement: `${requirementFor(fingerprint)}\n`, + ...overrides, +}); + +const createVerifierSimulator = (overrides = {}) => { + const fixture = { + signed: true, + certificateFingerprints: [fingerprint], + identifierLine: `Identifier=${REQUIRED_IDENTIFIER}`, + signatureLine: 'Signature size=1024', + designatedRequirement: `${requirementFor(fingerprint)}\n`, + strictValid: true, + ...overrides, + }; + const calls = []; + const runCommand = async options => { + calls.push(options); + const arguments_ = options.arguments; + if (options.executable === '/usr/bin/security') { + assert.deepEqual(arguments_, ['find-certificate', '-a', '-Z', keychain]); + return { + stdout: fixture.certificateFingerprints + .map(value => `SHA-1 hash: ${value}`) + .join('\n'), + stderr: '', + }; + } + if (arguments_[0] === '-d' && arguments_[1] === '--verbose=4') { + if (!fixture.signed) throw new Error(`unsigned secret ${application}`); + return { + stdout: '', + stderr: [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + fixture.identifierLine, + 'Format=app bundle with Mach-O universal (x86_64 arm64)', + fixture.signatureLine, + 'Info.plist entries=25', + ].filter(value => value !== null).join('\n'), + }; + } + if (arguments_[0] === '-d' && arguments_[1] === '-r-') { + return { stdout: '', stderr: fixture.designatedRequirement }; + } + if (arguments_.includes('--strict')) { + if (!fixture.strictValid) throw new Error(`strict failure ${application}`); + return { stdout: '', stderr: '' }; + } + throw new Error('unexpected simulated verifier invocation'); + }; + return { calls, runCommand }; +}; + +const withPrivateProofPath = async callback => { + const directory = await mkdtemp(join(tmpdir(), 'propr-signature-evidence-')); + try { + await callback(join(directory, 'designated-requirement.txt')); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +const isDiagnostic = diagnostic => error => { + assert.equal(classifyDarwinSigningFailure(error), diagnostic); + assert.equal( + darwinSigningDiagnosticLine(error), + `DARWIN_PACKAGED_CONNECT_DIAGNOSTIC:${diagnostic}\n`, + ); + assert.doesNotMatch(darwinSigningDiagnosticLine(error), /private|[A-F0-9]{40}/u); + return true; +}; + +const verifyEstablish = async (proofPath, fixture = {}) => { + const simulator = createVerifierSimulator(fixture); + await verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: simulator.runCommand, + }); + return simulator; +}; + +describe('Darwin packaged Connect acceptance signature proof', () => { + test('uses the portable bounded certificate, display, requirement, and strict proof chain', async () => { + const simulator = createVerifierSimulator(); + const evidence = await inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1: fingerprint, + runCommand: simulator.runCommand, + }); + + assert.equal( + assertDarwinSigningEvidence({ expectedCertificateSha1: fingerprint, ...evidence }), + `${requirementFor(fingerprint)}\n`, + ); + assert.deepEqual(simulator.calls.map(call => [call.executable, call.arguments]), [ + ['/usr/bin/security', ['find-certificate', '-a', '-Z', keychain]], + ['/usr/bin/codesign', ['-d', '--verbose=4', application]], + ['/usr/bin/codesign', ['-d', '-r-', application]], + ['/usr/bin/codesign', ['--verify', '--deep', '--strict', application]], + ]); + assert.ok(!simulator.calls.some(call => call.arguments.some(argument => ( + argument === '-R' + || argument.startsWith('-R=') + || argument === '--extract-certificates' + )))); + for (const call of simulator.calls) { + assert.equal(call.timeoutMs, 20_000); + assert.equal(call.terminationGraceMs, 1_000); + assert.equal(call.maxOutputBytes, 256 * 1024); + assert.equal(call.forwardOutput, false); + } + }); + + test('accepts exactly the generated keychain fingerprint and stable normalized requirements', async () => { + await withPrivateProofPath(async proofPath => { + const displayedRequirement = `${requirementFor(fingerprint.toLowerCase())}\n`; + await verifyEstablish(proofPath, { designatedRequirement: displayedRequirement }); + assert.equal(await readFile(proofPath, 'utf8'), displayedRequirement); + await verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ + designatedRequirement: displayedRequirement, + }).runCommand, + }); + }); + }); + + test('rejects duplicate and wrong keychain fingerprints', async () => { + for (const certificateFingerprints of [ + [fingerprint, fingerprint], + [fingerprint, otherFingerprint], + [otherFingerprint], + ]) { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { certificateFingerprints }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.keychainEvidenceFailure), + ); + }); + } + }); + + test('rejects explicit ad-hoc signature metadata with spacing and case variants', async () => { + for (const signatureLine of [ + 'Signature=adhoc', + ' sIgNaTuRe = AdHoC ', + ]) { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { signatureLine }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.adhocSignatureFailure), + ); + }); + } + }); + + test('rejects empty signature details', () => { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ signatureDetails: '' })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure), + ); + }); + + test('rejects missing, wrong, duplicate, and conflicting identifier metadata distinctly', () => { + for (const signatureDetails of [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + 'Signature size=1024', + 'Identifier=dev.other.desktop', + 'Identifier=DEV.PROPR.DESKTOP', + `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}`, + `Identifier=${REQUIRED_IDENTIFIER}\nIDENTIFIER = dev.other.desktop`, + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ signatureDetails })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure), + ); + } + }); + + test('rejects missing, zero, duplicate, and conflicting signature-size metadata distinctly', () => { + for (const signatureDetails of [ + `Identifier=${REQUIRED_IDENTIFIER}`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=0`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=01`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024\nSignature size=1024`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024\nSIGNATURE SIZE = 2048`, + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ signatureDetails })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure), + ); + } + }); + + test('requires root identifier evidence during both initial and stable native inspections', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { identifierLine: null }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure), + ); + await verifyEstablish(proofPath); + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ identifierLine: null }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure)); + }); + }); + + test('requires positive signature-size evidence during both native inspections', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { signatureLine: null }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure), + ); + await verifyEstablish(proofPath); + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ signatureLine: 'Signature size=0' }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure)); + }); + }); + + test('rejects the wrong embedded requirement leaf distinctly', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { + designatedRequirement: `${requirementFor(otherFingerprint)}\n`, + }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + }); + }); + + test('requires byte-exact embedded designated-requirement stability after reprobe', async () => { + await withPrivateProofPath(async proofPath => { + await verifyEstablish(proofPath); + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ + designatedRequirement: `${requirementFor(fingerprint.toLowerCase())}\n`, + }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure)); + }); + }); + + test('strict verification failure has its fixed secret-safe subcode', async () => { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { strictValid: false }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure), + ); + }); + }); + + test('wraps every native verifier operation in its distinct fixed subcode', async () => { + const diagnostics = [ + DARWIN_VERIFICATION_DIAGNOSTICS.certificateLookupFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, + ]; + for (const [failureIndex, diagnostic] of diagnostics.entries()) { + let invocation = 0; + const simulator = createVerifierSimulator(); + await assert.rejects(inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1: fingerprint, + runCommand: async options => { + if (invocation++ === failureIndex) { + throw new Error(`private failure ${application} ${fingerprint}`); + } + return simulator.runCommand(options); + }, + }), isDiagnostic(diagnostic)); + } + }); + + test('unsigned code fails at signature display with a fixed secret-safe subcode', async () => { + await assert.rejects(inspectDarwinSigningEvidence({ + application, + keychain, + expectedCertificateSha1: fingerprint, + runCommand: createVerifierSimulator({ signed: false }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure)); + }); + + test('accepts surrounding requirement metadata and CRLF, spacing, and keyword case variants', async () => { + await withPrivateProofPath(async proofPath => { + const selectedLine = [ + 'DeSiGnAtEd => IDENTIFIER "dev.propr.desktop" AnD', + `CERTIFICATE LEAF = h "${fingerprint.toLowerCase()}"`, + ].join(' '); + const requirementOutput = [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + 'warning: using architecture arm64', + ` ${selectedLine} `, + 'Format=app bundle with Mach-O thin (arm64)', + ].join('\r\n'); + await verifyEstablish(proofPath, { designatedRequirement: requirementOutput }); + assert.equal(await readFile(proofPath, 'utf8'), `${selectedLine}\n`); + await verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ + designatedRequirement: requirementOutput, + }).runCommand, + }); + }); + }); + + test('accepts realistic verbose metadata with exactly one identifier and positive signature size', () => { + for (const signatureDetails of [ + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, + `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\nIdentifier=${REQUIRED_IDENTIFIER}\nSignature size=2048`, + `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\r\n IDENTIFIER = ${REQUIRED_IDENTIFIER} \r\nFormat=app bundle with Mach-O thin (arm64)\r\n SIGNATURE SIZE = 4096 `, + ]) { + assert.equal( + assertDarwinSigningEvidence(validEvidence({ signatureDetails })), + `${requirementFor(fingerprint)}\n`, + ); + } + }); + + test('rejects duplicate designated lines, wrong identifiers and leaves, and extra clauses', () => { + for (const designatedRequirement of [ + '', + [requirementFor(fingerprint), requirementFor(fingerprint)].join('\n'), + `${requirementFor(fingerprint)}\n${requirementFor(otherFingerprint)}\n`, + `${requirementFor(fingerprint).replace(REQUIRED_IDENTIFIER, 'dev.other.desktop')}\n`, + `${requirementFor(fingerprint).replace(REQUIRED_IDENTIFIER, 'DEV.PROPR.DESKTOP')}\n`, + `${requirementFor(otherFingerprint)}\n`, + `${requirementFor(fingerprint)} or anchor apple\n`, + `${requirementFor(fingerprint)} and certificate 1 trusted\n`, + `designated => (${requirementExpressionFor(fingerprint)})\n`, + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ designatedRequirement })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + } + }); + + test('ignores unrelated surrounding lines but requires exactly one designated line', () => { + for (const designatedRequirement of [ + [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + 'Format=app bundle with Mach-O universal (x86_64 arm64)', + ].join('\n'), + [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + requirementFor(fingerprint), + 'Format=app bundle with Mach-O thin (x86_64)', + requirementFor(fingerprint), + ].join('\n'), + ]) { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ designatedRequirement })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + } + }); + + test('normalizes only surrounding line whitespace when comparing stable requirements', () => { + const initial = validEvidence({ + designatedRequirement: `metadata\r\n ${requirementFor(fingerprint)} \r\nmore metadata\r\n`, + }); + assert.equal( + assertDarwinSigningEvidence(initial), + `${requirementFor(fingerprint)}\n`, + ); + assert.equal( + assertDarwinSigningEvidence({ + ...initial, + previousDesignatedRequirement: `${requirementFor(fingerprint)}\n`, + }), + `${requirementFor(fingerprint)}\n`, + ); + assert.throws( + () => assertDarwinSigningEvidence({ + ...initial, + designatedRequirement: `${requirementFor(fingerprint).replace(' and ', ' and ')}\n`, + previousDesignatedRequirement: `${requirementFor(fingerprint)}\n`, + }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure), + ); + }); + + test('retains EVIDENCE_ASSERTION_FAILURE for invalid verifier inputs', () => { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ + expectedCertificateSha1: 'not-a-sha1', + })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + ); + }); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8ce0d7a82..2ec7cba6f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -248,9 +248,12 @@ const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: } }; -const reportPackagedConnectJourneyStage = (code: PackagedConnectJourneyStage): void => { +const reportPackagedConnectJourneyStage = ( + code: PackagedConnectJourneyStage, + evidence: { storageBackend: 'gnome_libsecret' | 'os-protected' } | undefined = undefined, +): void => { if (packagedConnectJourneyDiagnosticState) packagedConnectJourneyDiagnosticState.stage = code; - log('info', PACKAGED_CONNECT_JOURNEY_STAGE_EVENT, { code }); + log('info', PACKAGED_CONNECT_JOURNEY_STAGE_EVENT, { code, ...evidence }); }; const packagedConnectJourneyFailureReason = (error: unknown): PackagedConnectJourneyFailureReason => { @@ -605,12 +608,14 @@ const runPackagedConnectJourneySmoke = async ( phase: 'pair' | 'reprobe', stages: PackagedJourneyStageTracker, ): Promise => { - reportPackagedConnectJourneyStage('JOURNEY_STORAGE_BACKEND'); const security = profiles.security(); const requiredStorageBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; if (!security.available || security.backend !== requiredStorageBackend) { throw new Error('Packaged Connect journey requires the production OS credential backend'); } + reportPackagedConnectJourneyStage('JOURNEY_STORAGE_BACKEND', { + storageBackend: requiredStorageBackend, + }); if (phase === 'pair') { const setMode = async (mode: 'success' | 'malformed' | 'oversized' | 'expiry' | 'cancel') => { const response = await session.defaultSession.fetch(`${endpoint}/__packaged/control/${mode}`, {