From cd8567902f6167e0b101eecfc717afddb03ca6e8 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:42:02 +0000 Subject: [PATCH 01/16] fix(ai): Resolve issue #2106 - Darwin ARM64 packaged reprobe: preserve OS Safe St Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- .../desktop-connect-discovery-guard.yml | 45 +----- apps/desktop/README.md | 7 + .../packaged-connect-platform.test.mjs | 76 +++++++-- .../run-packaged-darwin-connect-smoke.sh | 146 ++++++++++++++++++ .../scripts/sign-darwin-packaged-connect.mjs | 49 ++++++ ...rify-darwin-packaged-connect-signature.mjs | 112 ++++++++++++++ ...darwin-packaged-connect-signature.test.mjs | 53 +++++++ 7 files changed, 432 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh create mode 100644 apps/desktop/scripts/sign-darwin-packaged-connect.mjs create mode 100644 apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs create mode 100644 apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index cd8fb385a..e3fdc0464 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,7 @@ 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: 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..57adb1961 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 root and +code-signing certificate 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, removes the temporary trust, deletes the keychain, and removes all generated certificate 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-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 0e8950ebe..83d054bf5 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -6,6 +6,15 @@ 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', +); describe('packaged Connect target-native credential setup', () => { test('Linux retains one isolated unlocked libsecret session and rejects plaintext fallback', async () => { @@ -23,24 +32,61 @@ 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, /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="\$\(mktemp -d\)"/u); + assert.match(darwinRunner, /keychain_password="\$\(\/usr\/bin\/openssl rand -hex 32\)"/u); + assert.match(darwinRunner, /identity_password="\$\(\/usr\/bin\/openssl rand -hex 32\)"/u); + assert.match(darwinRunner, /extendedKeyUsage = critical,codeSigning/u); + assert.match(darwinRunner, /\/usr\/bin\/security add-trusted-cert -r trustRoot -p codeSign/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 apple-tool:,apple:,codesign:/u); + assert.match(darwinRunner, /node "\$application_signer" "\$application" "\$keychain_path" "\$identity_sha1"/u); + assert.match(darwinSigner, /identityValidation: true/u); + assert.match(darwinSigner, /timestamp: 'none'/u); + assert.match(darwinSigner, /certificate leaf = H"\$\{certificateSha1\}"/u); + assert.match(darwinSigner, /ignore: \[PACKAGED_CONNECT_NATIVE_ARTIFACTS\]/u); + assert.match(darwinSigner, /strictVerify: true/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); + }); + + test('Darwin restores keychain state and deletes identity, trust, credentials, and files on every exit', () => { + assert.match(darwinRunner, /trap cleanup_keychain EXIT/u); + assert.match(darwinRunner, /trap 'exit 129' HUP/u); + assert.match(darwinRunner, /trap 'exit 130' INT/u); + assert.match(darwinRunner, /trap 'exit 143' TERM/u); + assert.match(darwinRunner, /\/usr\/bin\/security remove-trusted-cert "\$root_certificate"/u); + assert.match(darwinRunner, /\/usr\/bin\/security list-keychains -d user -s "\$\{original_keychains\[@\]\}"/u); + assert.match(darwinRunner, /\/usr\/bin\/security default-keychain -d user -s "\$original_default"/u); + assert.match(darwinRunner, /\/usr\/bin\/security delete-keychain "\$keychain_path"/u); + assert.match(darwinRunner, /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-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh new file mode 100644 index 000000000..a8dcec40a --- /dev/null +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -0,0 +1,146 @@ +#!/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" +if [[ ! -d "$application" || ! -f "$signature_verifier" || ! -f "$application_signer" ]]; then + echo 'Packaged Darwin Connect acceptance artifact is missing.' >&2 + exit 1 +fi +cd "$repository_root" + +original_keychain_output="$(/usr/bin/security list-keychains -d user)" +original_default="$(/usr/bin/security default-keychain -d user)" +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 <<< "$original_keychain_output" +original_default="${original_default#"${original_default%%[![:space:]]*}"}" +original_default="${original_default#\"}" +original_default="${original_default%\"}" + +umask 077 +keychain_root="$(mktemp -d)" +keychain_path="$keychain_root/propr-packaged-connect-smoke.keychain-db" +root_private_key="$keychain_root/root-private.pem" +root_certificate="$keychain_root/root-certificate.pem" +leaf_private_key="$keychain_root/leaf-private.pem" +leaf_request="$keychain_root/leaf-request.pem" +leaf_certificate="$keychain_root/leaf-certificate.pem" +identity_archive="$keychain_root/identity.p12" +root_config="$keychain_root/root.cnf" +leaf_config="$keychain_root/leaf.cnf" +requirement_proof="$keychain_root/designated-requirement.txt" + +cleanup_keychain() { + local primary_status=$? + local cleanup_status=0 + trap - EXIT HUP INT TERM + set +e + /usr/bin/security remove-trusted-cert "$root_certificate" >/dev/null 2>&1 || cleanup_status=1 + if (( ${#original_keychains[@]} > 0 )); then + /usr/bin/security list-keychains -d user -s "${original_keychains[@]}" >/dev/null 2>&1 || cleanup_status=1 + else + /usr/bin/security list-keychains -d user -s >/dev/null 2>&1 || cleanup_status=1 + fi + if [[ -n "$original_default" ]]; then + /usr/bin/security default-keychain -d user -s "$original_default" >/dev/null 2>&1 || cleanup_status=1 + fi + /usr/bin/security delete-keychain "$keychain_path" >/dev/null 2>&1 || cleanup_status=1 + rm -rf -- "$keychain_root" || cleanup_status=1 + unset keychain_password identity_password certificate_serial + 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" +} +trap cleanup_keychain EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +cat > "$root_config" <<'EOF' +[req] +distinguished_name = root_name +x509_extensions = root_extensions +prompt = no + +[root_name] +CN = ProPR Packaged Connect CI Root + +[root_extensions] +basicConstraints = critical,CA:TRUE,pathlen:0 +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +EOF + +cat > "$leaf_config" <<'EOF' +[leaf_extensions] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature +extendedKeyUsage = critical,codeSigning +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer +EOF + +keychain_password="$(/usr/bin/openssl rand -hex 32)" +identity_password="$(/usr/bin/openssl rand -hex 32)" +certificate_serial="$(/usr/bin/openssl rand -hex 16)" +/usr/bin/openssl req -new -x509 -newkey rsa:2048 -sha256 -nodes -days 1 \ + -config "$root_config" -keyout "$root_private_key" -out "$root_certificate" >/dev/null 2>&1 +/usr/bin/openssl req -new -newkey rsa:2048 -sha256 -nodes \ + -subj '/CN=ProPR Packaged Connect CI' \ + -keyout "$leaf_private_key" -out "$leaf_request" >/dev/null 2>&1 +/usr/bin/openssl x509 -req -sha256 -days 1 -in "$leaf_request" \ + -CA "$root_certificate" -CAkey "$root_private_key" -set_serial "0x$certificate_serial" \ + -extfile "$leaf_config" -extensions leaf_extensions -out "$leaf_certificate" >/dev/null 2>&1 +/usr/bin/openssl pkcs12 -export -inkey "$leaf_private_key" -in "$leaf_certificate" \ + -out "$identity_archive" -passout "pass:$identity_password" >/dev/null 2>&1 +identity_sha1="$(/usr/bin/openssl x509 -in "$leaf_certificate" -noout -fingerprint -sha1 \ + | sed -E 's/^.*=//; s/://g')" +if [[ ! "$identity_sha1" =~ ^[A-F0-9]{40}$ ]]; then + echo 'Disposable Darwin signing certificate fingerprint is invalid.' >&2 + exit 1 +fi + +/usr/bin/security create-keychain -p "$keychain_password" "$keychain_path" +/usr/bin/security set-keychain-settings -lut 21600 "$keychain_path" +/usr/bin/security unlock-keychain -p "$keychain_password" "$keychain_path" +/usr/bin/security list-keychains -d user -s "$keychain_path" +/usr/bin/security default-keychain -d user -s "$keychain_path" +/usr/bin/security add-trusted-cert -r trustRoot -p codeSign -k "$keychain_path" \ + "$root_certificate" >/dev/null +/usr/bin/security import "$identity_archive" -k "$keychain_path" -P "$identity_password" \ + -T /usr/bin/codesign >/dev/null +/usr/bin/security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ + -k "$keychain_password" "$keychain_path" >/dev/null +unset keychain_password identity_password certificate_serial + +node "$application_signer" "$application" "$keychain_path" "$identity_sha1" +node "$signature_verifier" establish "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" + +npm run smoke:connect-package -w @propr/desktop + +node "$signature_verifier" stable "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" 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..fcba75da0 --- /dev/null +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import { sign } from '@electron/osx-sign'; +import { fileURLToPath } from 'node:url'; + +const CERTIFICATE_SHA1 = /^[A-F0-9]{40}$/u; +const PACKAGED_CONNECT_NATIVE_ARTIFACTS = /\/Resources\/app\.asar\.unpacked\/\.vite\/native\/prebuilds\//u; + +export const signDarwinPackagedConnectApplication = async ({ + application, + keychain, + certificateSha1, +}) => { + if (!application.endsWith('.app') || !keychain.endsWith('.keychain-db') + || !CERTIFICATE_SHA1.test(certificateSha1)) { + throw new Error('invalid-acceptance-signing-input'); + } + const designatedRequirement = `designated => identifier "dev.propr.desktop" and certificate leaf = H"${certificateSha1}"`; + await sign({ + app: application, + platform: 'darwin', + identity: certificateSha1, + keychain, + identityValidation: true, + preAutoEntitlements: false, + strictVerify: true, + // These native artifacts have committed byte hashes checked by the smoke. + // They are sealed as bundle resources but must not be rewritten by codesign. + ignore: [PACKAGED_CONNECT_NATIVE_ARTIFACTS], + optionsForFile: filePath => ({ + timestamp: 'none', + ...(filePath === application ? { requirements: `=${designatedRequirement}` } : {}), + }), + }); +}; + +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 { + process.stderr.write('Darwin packaged Connect application signing failed.\n'); + process.exitCode = 1; + } +} 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..d0b3e175f --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { execFile as nodeExecFile } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFile = promisify(nodeExecFile); +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const SHA1_PATTERN = /^[A-F0-9]{40}$/u; + +const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); + +export const assertDarwinSigningEvidence = ({ + expectedCertificateSha1, + identities, + signatureDetails, + designatedRequirement, + previousDesignatedRequirement, +}) => { + const expectedSha1 = expectedCertificateSha1.toUpperCase(); + if (!SHA1_PATTERN.test(expectedSha1)) throw new Error('invalid-certificate-fingerprint'); + + const validIdentities = normalizeLines(identities) + .map(line => /^\s*\d+\)\s+([A-Fa-f0-9]{40})\s+"[^"]+"\s*$/u.exec(line)?.[1]?.toUpperCase()) + .filter(Boolean); + if (validIdentities.length !== 1 || validIdentities[0] !== expectedSha1 + || !normalizeLines(identities).some(line => /^\s*1 valid identities found\s*$/u.test(line))) { + throw new Error('identity-not-unique'); + } + + const details = normalizeLines(signatureDetails); + if (details.some(line => line === 'Signature=adhoc') + || !details.some(line => line.startsWith('Authority=') && line.length > 'Authority='.length) + || !details.includes(`Identifier=${REQUIRED_IDENTIFIER}`)) { + throw new Error('signature-not-certificate-backed'); + } + + const requirements = normalizeLines(designatedRequirement) + .filter(line => line.startsWith('designated =>')); + if (requirements.length !== 1 + || !requirements[0].includes(`identifier "${REQUIRED_IDENTIFIER}"`) + || !requirements[0].toUpperCase().includes(`CERTIFICATE LEAF = H"${expectedSha1}"`)) { + throw new Error('designated-requirement-not-bound'); + } + if (previousDesignatedRequirement !== undefined + && requirements[0] !== previousDesignatedRequirement.trim()) { + throw new Error('designated-requirement-changed'); + } + return requirements[0]; +}; + +const inspectDarwinSigningEvidence = async ({ application, keychain }) => { + const [identityResult, signatureResult, requirementResult] = await Promise.all([ + execFile('/usr/bin/security', ['find-identity', '-v', '-p', 'codesigning', keychain], { + encoding: 'utf8', maxBuffer: 256 * 1024, + }), + execFile('/usr/bin/codesign', ['-d', '--verbose=4', application], { + encoding: 'utf8', maxBuffer: 256 * 1024, + }), + execFile('/usr/bin/codesign', ['-d', '-r-', application], { + encoding: 'utf8', maxBuffer: 256 * 1024, + }), + ]); + await execFile('/usr/bin/codesign', ['--verify', '--deep', '--strict', application], { + encoding: 'utf8', maxBuffer: 256 * 1024, + }); + return { + identities: `${identityResult.stdout}\n${identityResult.stderr}`, + signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, + designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, + }; +}; + +export const verifyDarwinPackagedConnectSignature = async ({ + mode, + application, + keychain, + expectedCertificateSha1, + proofPath, +}) => { + if (mode !== 'establish' && mode !== 'stable') throw new Error('invalid-verification-mode'); + const previousDesignatedRequirement = mode === 'stable' + ? await readFile(proofPath, 'utf8') + : undefined; + const evidence = await inspectDarwinSigningEvidence({ application, keychain }); + const requirement = assertDarwinSigningEvidence({ + expectedCertificateSha1, + previousDesignatedRequirement, + ...evidence, + }); + if (mode === 'establish') { + await writeFile(proofPath, `${requirement}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + } +}; + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const [mode, application, keychain, expectedCertificateSha1, proofPath] = process.argv.slice(2); + try { + if (process.platform !== 'darwin' + || !mode || !application || !keychain || !expectedCertificateSha1 || !proofPath) { + throw new Error('invalid-invocation'); + } + await verifyDarwinPackagedConnectSignature({ + mode, application, keychain, expectedCertificateSha1, proofPath, + }); + } catch { + process.stderr.write(`Darwin packaged Connect signing identity ${mode === 'stable' ? 'changed' : 'could not be established'}.\n`); + 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..ea89e3790 --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { assertDarwinSigningEvidence } from './verify-darwin-packaged-connect-signature.mjs'; + +const fingerprint = 'A'.repeat(40); +const requirement = `designated => identifier "dev.propr.desktop" and certificate leaf = H"${fingerprint}"`; +const validEvidence = () => ({ + expectedCertificateSha1: fingerprint, + identities: ` 1) ${fingerprint} "ProPR Packaged Connect CI"\n 1 valid identities found`, + signatureDetails: [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + 'Identifier=dev.propr.desktop', + 'Authority=ProPR Packaged Connect CI', + 'Authority=ProPR Packaged Connect CI Root', + ].join('\n'), + designatedRequirement: requirement, +}); + +describe('Darwin packaged Connect acceptance signature proof', () => { + test('accepts one certificate identity bound into the app designated requirement', () => { + assert.equal(assertDarwinSigningEvidence(validEvidence()), requirement); + assert.equal(assertDarwinSigningEvidence({ + ...validEvidence(), previousDesignatedRequirement: `${requirement}\n`, + }), requirement); + }); + + test('fails closed when the disposable identity is missing, ambiguous, or different', () => { + for (const identities of [ + '0 valid identities found', + `${validEvidence().identities}\n 2) ${'B'.repeat(40)} "Unexpected"`, + ` 1) ${'B'.repeat(40)} "Unexpected"\n 1 valid identities found`, + ]) { + assert.throws(() => assertDarwinSigningEvidence({ ...validEvidence(), identities })); + } + }); + + test('rejects ad-hoc signatures and requirements not pinned to the generated certificate', () => { + assert.throws(() => assertDarwinSigningEvidence({ + ...validEvidence(), signatureDetails: 'Identifier=dev.propr.desktop\nSignature=adhoc', + })); + assert.throws(() => assertDarwinSigningEvidence({ + ...validEvidence(), + designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor trusted', + })); + }); + + test('rejects any signing identity change after the pair and reprobe launches', () => { + assert.throws(() => assertDarwinSigningEvidence({ + ...validEvidence(), + previousDesignatedRequirement: `${requirement.replace(fingerprint, 'B'.repeat(40))}\n`, + })); + }); +}); From a4988eb7cbfc88e4196133633c8d2bbe149ba358 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:23:06 +0000 Subject: [PATCH 02/16] feat(ai): Implemented the Darwin-only PR correction without touching Linux, Windows, or production signing. Implemented the Darwin-only PR correction without touching Linux, Windows, or production signing. Key changes: - Added allowlisted `STARTED/PASSED/FAILED` markers for all 12 requested phases in [run-packaged-darwin-connect-smoke.sh](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T14-05-12/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh:29). - Added a Node process-group supervisor with bounded output, TERM/KILL escalation, signal propagation, redacted diagnostics, and cleanup grace in [run-bounded-darwin-command.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T14-05-12/apps/desktop/scripts/run-bounded-darwin-command.mjs:41). - Bounded every setup, signing, verification, journey, and cleanup operation, plus the complete Darwin wrapper. - Removed the signing hot path by enabling inside-out batched `codesign` calls and disabling irrelevant provisioning-profile work in [sign-darwin-packaged-connect.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T14-05-12/apps/desktop/scripts/sign-darwin-packaged-connect.mjs:19). - Added per-command verification timeouts in [verify-darwin-packaged-connect-signature.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T14-05-12/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs:11). - Preserved the certificate-bound designated requirement and real two-process `os-protected` Safe Storage journey. Verification: - Focused marker/timeout/cleanup/signature tests: 16 passed. - Full desktop suite: 443 tests, 418 passed, 25 platform skips. - Desktop typecheck passed. - Shell syntax and `git diff --check` passed. - No visual preview created because this is CI-only. PR: #2108 Comment by: @integry (ID: 5541584292) Model: gpt-5.6-sol --- .../desktop-connect-discovery-guard.yml | 8 +- .../packaged-connect-platform.test.mjs | 101 ++++- .../scripts/run-bounded-darwin-command.mjs | 205 +++++++++++ .../run-bounded-darwin-command.test.mjs | 132 +++++++ .../run-packaged-darwin-connect-smoke.sh | 345 +++++++++++++----- .../scripts/sign-darwin-packaged-connect.mjs | 3 + ...rify-darwin-packaged-connect-signature.mjs | 29 +- 7 files changed, 695 insertions(+), 128 deletions(-) create mode 100644 apps/desktop/scripts/run-bounded-darwin-command.mjs create mode 100644 apps/desktop/scripts/run-bounded-darwin-command.test.mjs diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index e3fdc0464..9148dfd1f 100644 --- a/.github/workflows/desktop-connect-discovery-guard.yml +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -115,7 +115,13 @@ jobs: - name: Run packaged Darwin main-to-renderer discovery if: matrix.platform == 'darwin' shell: bash - run: bash apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh '${{ matrix.arch }}' + 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/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 83d054bf5..f0775e1b6 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -1,6 +1,10 @@ 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), @@ -15,6 +19,14 @@ 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 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 () => { @@ -41,22 +53,24 @@ describe('packaged Connect target-native credential setup', () => { 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, /bash apps\/desktop\/scripts\/run-packaged-darwin-connect-smoke\.sh '\$\{\{ matrix\.arch \}\}'/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="\$\(mktemp -d\)"/u); - assert.match(darwinRunner, /keychain_password="\$\(\/usr\/bin\/openssl rand -hex 32\)"/u); - assert.match(darwinRunner, /identity_password="\$\(\/usr\/bin\/openssl rand -hex 32\)"/u); + 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, /extendedKeyUsage = critical,codeSigning/u); - assert.match(darwinRunner, /\/usr\/bin\/security add-trusted-cert -r trustRoot -p codeSign/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 apple-tool:,apple:,codesign:/u); - assert.match(darwinRunner, /node "\$application_signer" "\$application" "\$keychain_path" "\$identity_sha1"/u); + assert.match(darwinRunner, /\/usr\/bin\/security add-trusted-cert \\[\s\S]*?-r trustRoot -p codeSign/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 "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); assert.match(darwinSigner, /identityValidation: true/u); + assert.match(darwinSigner, /batchCodesignCalls: true/u); + assert.match(darwinSigner, /preEmbedProvisioningProfile: false/u); assert.match(darwinSigner, /timestamp: 'none'/u); assert.match(darwinSigner, /certificate leaf = H"\$\{certificateSha1\}"/u); assert.match(darwinSigner, /ignore: \[PACKAGED_CONNECT_NATIVE_ARTIFACTS\]/u); @@ -67,16 +81,73 @@ describe('packaged Connect target-native credential setup', () => { assert.ok(establish >= 0 && establish < smoke && smoke < stable); }); + test('Darwin emits only allowlisted fixed stage markers around every blocking phase', async () => { + const expectedStages = [ + 'KEY_CERTIFICATE_GENERATION', + 'KEYCHAIN_CREATION_SELECTION', + 'TRUST_INSTALLATION', + 'IDENTITY_IMPORT', + 'PARTITION_LIST_UPDATE', + 'APPLICATION_SIGNING', + 'INITIAL_SIGNATURE_VERIFICATION', + 'PAIR_REPROBE_JOURNEY', + 'STABLE_SIGNATURE_VERIFICATION', + 'TRUST_REMOVAL', + '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, /signalProcessGroup\(child, 'SIGTERM'/u); + assert.match(boundedDarwinRunner, /signalProcessGroup\(child, 'SIGKILL'/u); + assert.match(boundedDarwinRunner, /maximumBytes - state\.bytes/u); + assert.match(darwinVerifier, /timeout: VERIFICATION_TIMEOUT_MS/u); + assert.match(darwinVerifier, /maxBuffer: VERIFICATION_MAX_BUFFER/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 "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); + assert.match(darwinRunner, /run_bounded_forward "\$JOURNEY_TIMEOUT_MS" npm run smoke:connect-package/u); + }); + test('Darwin restores keychain state and deletes identity, trust, credentials, and files on every exit', () => { assert.match(darwinRunner, /trap cleanup_keychain EXIT/u); - assert.match(darwinRunner, /trap 'exit 129' HUP/u); - assert.match(darwinRunner, /trap 'exit 130' INT/u); - assert.match(darwinRunner, /trap 'exit 143' TERM/u); - assert.match(darwinRunner, /\/usr\/bin\/security remove-trusted-cert "\$root_certificate"/u); - assert.match(darwinRunner, /\/usr\/bin\/security list-keychains -d user -s "\$\{original_keychains\[@\]\}"/u); - assert.match(darwinRunner, /\/usr\/bin\/security default-keychain -d user -s "\$original_default"/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.match(darwinRunner, /run_bounded "\$CLEANUP_TIMEOUT_MS" \/usr\/bin\/security remove-trusted-cert "\$root_certificate"/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.match(darwinRunner, /rm -rf -- "\$keychain_root"/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); }); 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..4d3c7d3c5 --- /dev/null +++ b/apps/desktop/scripts/run-bounded-darwin-command.mjs @@ -0,0 +1,205 @@ +#!/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]]); + +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; + } +}; + +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 child; + 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 { + child = spawn(executable, arguments_, { + detached: platform !== 'win32', + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + onSpawn?.(child); + if (primaryReason) signalProcessGroup(child, 'SIGTERM', platform); + 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, + )); + + const timeout = setTimeout(() => requestTermination('timeout'), timeoutMs); + const closeResult = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (exitCode, signal) => resolve({ exitCode, signal })); + }); + const result = await Promise.race([closeResult, forcedSettlement]) + .finally(() => clearTimeout(timeout)); + if (forceTimer) clearTimeout(forceTimer); + if (drainTimer) clearTimeout(drainTimer); + if (result.drainTimedOut) { + 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 (error instanceof BoundedProcessError) throw error; + throw new BoundedProcessError('spawn-or-io', { cause: error }); + } finally { + 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) { + 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..954544ef6 --- /dev/null +++ b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs @@ -0,0 +1,132 @@ +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('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('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 index a8dcec40a..c0cb7fb21 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -18,56 +18,128 @@ 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" -if [[ ! -d "$application" || ! -f "$signature_verifier" || ! -f "$application_signer" ]]; then +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" -original_keychain_output="$(/usr/bin/security list-keychains -d user)" -original_default="$(/usr/bin/security default-keychain -d user)" -original_keychains=() -while IFS= read -r keychain; do - keychain="${keychain#"${keychain%%[![:space:]]*}"}" - keychain="${keychain#\"}" - keychain="${keychain%\"}" - if [[ -n "$keychain" ]]; then - original_keychains+=("$keychain") +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|TRUST_INSTALLATION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|STABLE_SIGNATURE_VERIFICATION|TRUST_REMOVAL|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 -done <<< "$original_keychain_output" -original_default="${original_default#"${original_default%%[![:space:]]*}"}" -original_default="${original_default#\"}" -original_default="${original_default%\"}" +} umask 077 -keychain_root="$(mktemp -d)" -keychain_path="$keychain_root/propr-packaged-connect-smoke.keychain-db" -root_private_key="$keychain_root/root-private.pem" -root_certificate="$keychain_root/root-certificate.pem" -leaf_private_key="$keychain_root/leaf-private.pem" -leaf_request="$keychain_root/leaf-request.pem" -leaf_certificate="$keychain_root/leaf-certificate.pem" -identity_archive="$keychain_root/identity.p12" -root_config="$keychain_root/root.cnf" -leaf_config="$keychain_root/leaf.cnf" -requirement_proof="$keychain_root/designated-requirement.txt" +keychain_root='' +keychain_path='' +root_private_key='' +root_certificate='' +leaf_private_key='' +leaf_request='' +leaf_certificate='' +identity_archive='' +root_config='' +leaf_config='' +requirement_proof='' +identity_sha1='' +original_default='' +original_keychains=() +trust_installed=0 +keychain_created=0 +keychain_state_captured=0 +active_stage='' + +remove_trust() { + if (( trust_installed == 0 )); then return 0; fi + run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security remove-trusted-cert "$root_certificate" +} + +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 - /usr/bin/security remove-trusted-cert "$root_certificate" >/dev/null 2>&1 || cleanup_status=1 - if (( ${#original_keychains[@]} > 0 )); then - /usr/bin/security list-keychains -d user -s "${original_keychains[@]}" >/dev/null 2>&1 || cleanup_status=1 - else - /usr/bin/security list-keychains -d user -s >/dev/null 2>&1 || cleanup_status=1 - fi - if [[ -n "$original_default" ]]; then - /usr/bin/security default-keychain -d user -s "$original_default" >/dev/null 2>&1 || cleanup_status=1 - fi - /usr/bin/security delete-keychain "$keychain_path" >/dev/null 2>&1 || cleanup_status=1 - rm -rf -- "$keychain_root" || cleanup_status=1 + run_stage TRUST_REMOVAL remove_trust || cleanup_status=1 + 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 certificate_serial if (( cleanup_status != 0 )); then echo 'Packaged Darwin Connect acceptance cleanup failed.' >&2 @@ -75,72 +147,145 @@ cleanup_keychain() { 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 129' HUP -trap 'exit 130' INT -trap 'exit 143' TERM - -cat > "$root_config" <<'EOF' -[req] -distinguished_name = root_name -x509_extensions = root_extensions -prompt = no - -[root_name] -CN = ProPR Packaged Connect CI Root - -[root_extensions] -basicConstraints = critical,CA:TRUE,pathlen:0 -keyUsage = critical,keyCertSign,cRLSign -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid:always -EOF - -cat > "$leaf_config" <<'EOF' -[leaf_extensions] -basicConstraints = critical,CA:FALSE -keyUsage = critical,digitalSignature -extendedKeyUsage = critical,codeSigning -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid,issuer -EOF - -keychain_password="$(/usr/bin/openssl rand -hex 32)" -identity_password="$(/usr/bin/openssl rand -hex 32)" -certificate_serial="$(/usr/bin/openssl rand -hex 16)" -/usr/bin/openssl req -new -x509 -newkey rsa:2048 -sha256 -nodes -days 1 \ - -config "$root_config" -keyout "$root_private_key" -out "$root_certificate" >/dev/null 2>&1 -/usr/bin/openssl req -new -newkey rsa:2048 -sha256 -nodes \ - -subj '/CN=ProPR Packaged Connect CI' \ - -keyout "$leaf_private_key" -out "$leaf_request" >/dev/null 2>&1 -/usr/bin/openssl x509 -req -sha256 -days 1 -in "$leaf_request" \ - -CA "$root_certificate" -CAkey "$root_private_key" -set_serial "0x$certificate_serial" \ - -extfile "$leaf_config" -extensions leaf_extensions -out "$leaf_certificate" >/dev/null 2>&1 -/usr/bin/openssl pkcs12 -export -inkey "$leaf_private_key" -in "$leaf_certificate" \ - -out "$identity_archive" -passout "pass:$identity_password" >/dev/null 2>&1 -identity_sha1="$(/usr/bin/openssl x509 -in "$leaf_certificate" -noout -fingerprint -sha1 \ - | sed -E 's/^.*=//; s/://g')" -if [[ ! "$identity_sha1" =~ ^[A-F0-9]{40}$ ]]; then - echo 'Disposable Darwin signing certificate fingerprint is invalid.' >&2 - exit 1 -fi +trap 'exit_for_signal 129' HUP +trap 'exit_for_signal 130' INT +trap 'exit_for_signal 143' TERM -/usr/bin/security create-keychain -p "$keychain_password" "$keychain_path" -/usr/bin/security set-keychain-settings -lut 21600 "$keychain_path" -/usr/bin/security unlock-keychain -p "$keychain_password" "$keychain_path" -/usr/bin/security list-keychains -d user -s "$keychain_path" -/usr/bin/security default-keychain -d user -s "$keychain_path" -/usr/bin/security add-trusted-cert -r trustRoot -p codeSign -k "$keychain_path" \ - "$root_certificate" >/dev/null -/usr/bin/security import "$identity_archive" -k "$keychain_path" -P "$identity_password" \ - -T /usr/bin/codesign >/dev/null -/usr/bin/security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ - -k "$keychain_password" "$keychain_path" >/dev/null -unset keychain_password identity_password certificate_serial +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" +} -node "$application_signer" "$application" "$keychain_path" "$identity_sha1" -node "$signature_verifier" establish "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" +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" + root_private_key="$keychain_root/root-private.pem" + root_certificate="$keychain_root/root-certificate.pem" + leaf_private_key="$keychain_root/leaf-private.pem" + leaf_request="$keychain_root/leaf-request.pem" + leaf_certificate="$keychain_root/leaf-certificate.pem" + identity_archive="$keychain_root/identity.p12" + root_config="$keychain_root/root.cnf" + 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 = root_name' \ + 'x509_extensions = root_extensions' 'prompt = no' '' '[root_name]' \ + 'CN = ProPR Packaged Connect CI Root' '' '[root_extensions]' \ + 'basicConstraints = critical,CA:TRUE,pathlen:0' \ + 'keyUsage = critical,keyCertSign,cRLSign' 'subjectKeyIdentifier = hash' \ + 'authorityKeyIdentifier = keyid:always' > "$root_config" || return $? + builtin printf '%s\n' '[leaf_extensions]' \ + 'basicConstraints = critical,CA:FALSE' 'keyUsage = critical,digitalSignature' \ + 'extendedKeyUsage = critical,codeSigning' 'subjectKeyIdentifier = hash' \ + 'authorityKeyIdentifier = keyid,issuer' > "$leaf_config" || return $? -npm run smoke:connect-package -w @propr/desktop + certificate_serial="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ + /usr/bin/openssl rand -hex 16)" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl req -new -x509 -newkey rsa:2048 \ + -sha256 -nodes -days 1 -config "$root_config" -keyout "$root_private_key" \ + -out "$root_certificate" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl req -new -newkey rsa:2048 \ + -sha256 -nodes -subj '/CN=ProPR Packaged Connect CI' -keyout "$leaf_private_key" \ + -out "$leaf_request" || return $? + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl x509 -req -sha256 -days 1 \ + -in "$leaf_request" -CA "$root_certificate" -CAkey "$root_private_key" \ + -set_serial "0x$certificate_serial" -extfile "$leaf_config" \ + -extensions leaf_extensions -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 +} + +install_trust() { + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security add-trusted-cert \ + -r trustRoot -p codeSign -k "$keychain_path" "$root_certificate" || return $? + trust_installed=1 +} + +import_identity() { + run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security import "$identity_archive" \ + -k "$keychain_path" -P "$identity_password" -T /usr/bin/codesign +} -node "$signature_verifier" stable "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" +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 "$SIGNING_TIMEOUT_MS" node "$application_signer" \ + "$application" "$keychain_path" "$identity_sha1" +} + +verify_initial_signature() { + run_bounded "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ + "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" +} + +run_pair_and_reprobe() { + run_bounded_forward "$JOURNEY_TIMEOUT_MS" npm run smoke:connect-package -w @propr/desktop +} + +verify_stable_signature() { + run_bounded "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ + "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" +} + +run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates +run_stage KEYCHAIN_CREATION_SELECTION create_and_select_keychain +run_stage TRUST_INSTALLATION install_trust +run_stage IDENTITY_IMPORT import_identity +run_stage PARTITION_LIST_UPDATE update_partition_list +unset keychain_password identity_password certificate_serial +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 index fcba75da0..eb44362dd 100644 --- a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs @@ -22,6 +22,9 @@ export const signDarwinPackagedConnectApplication = async ({ identity: certificateSha1, keychain, identityValidation: true, + // Preserve inside-out signing while avoiding one noninteractive codesign process per binary. + batchCodesignCalls: true, + preEmbedProvisioningProfile: false, preAutoEntitlements: false, strictVerify: true, // These native artifacts have committed byte hashes checked by the smoke. diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index d0b3e175f..9d54221e3 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -8,6 +8,15 @@ import { fileURLToPath } from 'node:url'; const execFile = promisify(nodeExecFile); const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; const SHA1_PATTERN = /^[A-F0-9]{40}$/u; +const VERIFICATION_TIMEOUT_MS = 20_000; +const VERIFICATION_MAX_BUFFER = 256 * 1024; + +const runVerificationCommand = (executable, arguments_) => execFile(executable, arguments_, { + encoding: 'utf8', + maxBuffer: VERIFICATION_MAX_BUFFER, + timeout: VERIFICATION_TIMEOUT_MS, + killSignal: 'SIGKILL', +}); const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); @@ -52,19 +61,15 @@ export const assertDarwinSigningEvidence = ({ const inspectDarwinSigningEvidence = async ({ application, keychain }) => { const [identityResult, signatureResult, requirementResult] = await Promise.all([ - execFile('/usr/bin/security', ['find-identity', '-v', '-p', 'codesigning', keychain], { - encoding: 'utf8', maxBuffer: 256 * 1024, - }), - execFile('/usr/bin/codesign', ['-d', '--verbose=4', application], { - encoding: 'utf8', maxBuffer: 256 * 1024, - }), - execFile('/usr/bin/codesign', ['-d', '-r-', application], { - encoding: 'utf8', maxBuffer: 256 * 1024, - }), + runVerificationCommand('/usr/bin/security', [ + 'find-identity', '-v', '-p', 'codesigning', keychain, + ]), + runVerificationCommand('/usr/bin/codesign', ['-d', '--verbose=4', application]), + runVerificationCommand('/usr/bin/codesign', ['-d', '-r-', application]), + ]); + await runVerificationCommand('/usr/bin/codesign', [ + '--verify', '--deep', '--strict', application, ]); - await execFile('/usr/bin/codesign', ['--verify', '--deep', '--strict', application], { - encoding: 'utf8', maxBuffer: 256 * 1024, - }); return { identities: `${identityResult.stdout}\n${identityResult.stderr}`, signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, From d585e76984b29a52980322e664e5efac5173ea85 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:46:17 +0000 Subject: [PATCH 03/16] feat(ai): Implemented both merge-blocker corrections without committing. Implemented both merge-blocker corrections without committing. - Process supervision now retains a dedicated group leader through timeout escalation, preventing PGID reuse and ensuring SIGKILL reaches TERM-ignoring descendants. Added a regression test proving descendant termination. - Removed all trust installation/removal mutations. Signing directly selects the imported disposable certificate fingerprint, while verification still enforces the unique certificate, strict deep signature, and stable designated requirement. - Updated fixed stage and cleanup contracts accordingly. Verification: - Focused tests: 17/17 passed - Desktop suite: 420 passed, 25 platform skips - Desktop/UI typechecks passed - ESLint, shell syntax, and `git diff --check` passed The native Darwin packaged journey remains CI-only on this Linux worker. No visual preview was created because the changes are non-visual. PR: #2108 Comment by: @integry (ID: 5541881164) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 14 ++-- .../scripts/run-bounded-darwin-command.mjs | 79 +++++++++++++++++-- .../run-bounded-darwin-command.test.mjs | 31 ++++++++ .../run-packaged-darwin-connect-smoke.sh | 16 +--- .../scripts/sign-darwin-packaged-connect.mjs | 4 +- ...rify-darwin-packaged-connect-signature.mjs | 17 ++-- ...darwin-packaged-connect-signature.test.mjs | 16 ++-- 7 files changed, 132 insertions(+), 45 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index f0775e1b6..b7e9ccd61 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -64,11 +64,13 @@ describe('packaged Connect target-native credential setup', () => { 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, /extendedKeyUsage = critical,codeSigning/u); - assert.match(darwinRunner, /\/usr\/bin\/security add-trusted-cert \\[\s\S]*?-r trustRoot -p codeSign/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 "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); - assert.match(darwinSigner, /identityValidation: true/u); + assert.match(darwinSigner, /identityValidation: false/u); + assert.match(darwinVerifier, /'find-certificate', '-a', '-Z', keychain/u); + assert.doesNotMatch(darwinVerifier, /find-identity/u); assert.match(darwinSigner, /batchCodesignCalls: true/u); assert.match(darwinSigner, /preEmbedProvisioningProfile: false/u); assert.match(darwinSigner, /timestamp: 'none'/u); @@ -85,14 +87,12 @@ describe('packaged Connect target-native credential setup', () => { const expectedStages = [ 'KEY_CERTIFICATE_GENERATION', 'KEYCHAIN_CREATION_SELECTION', - 'TRUST_INSTALLATION', 'IDENTITY_IMPORT', 'PARTITION_LIST_UPDATE', 'APPLICATION_SIGNING', 'INITIAL_SIGNATURE_VERIFICATION', 'PAIR_REPROBE_JOURNEY', 'STABLE_SIGNATURE_VERIFICATION', - 'TRUST_REMOVAL', 'KEYCHAIN_RESTORATION_DELETION', 'TEMPORARY_FILE_CLEANUP', ]; @@ -126,6 +126,8 @@ describe('packaged Connect target-native credential setup', () => { 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); @@ -137,13 +139,13 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinRunner, /run_bounded_forward "\$JOURNEY_TIMEOUT_MS" npm run smoke:connect-package/u); }); - test('Darwin restores keychain state and deletes identity, trust, credentials, and files on every exit', () => { + 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.match(darwinRunner, /run_bounded "\$CLEANUP_TIMEOUT_MS" \/usr\/bin\/security remove-trusted-cert "\$root_certificate"/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); diff --git a/apps/desktop/scripts/run-bounded-darwin-command.mjs b/apps/desktop/scripts/run-bounded-darwin-command.mjs index 4d3c7d3c5..1fba33bd6 100644 --- a/apps/desktop/scripts/run-bounded-darwin-command.mjs +++ b/apps/desktop/scripts/run-bounded-darwin-command.mjs @@ -6,6 +6,9 @@ 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) { @@ -29,7 +32,7 @@ const appendBounded = (chunks, chunk, state, maximumBytes, forward) => { }; const signalProcessGroup = (child, signal, platform) => { - if (!child.pid) return; + if (!child?.pid) return; try { if (platform === 'win32') child.kill(signal); else process.kill(-child.pid, signal); @@ -38,6 +41,46 @@ const signalProcessGroup = (child, signal, platform) => { } }; +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 })); + 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_ = [], @@ -66,7 +109,9 @@ export const runBoundedProcess = async ({ let requestedSignal; let forceTimer; let drainTimer; + let timeout; let child; + let commandResult; let resolveForcedSettlement; const forcedSettlement = new Promise(resolve => { resolveForcedSettlement = resolve; }); @@ -102,11 +147,18 @@ export const runBoundedProcess = async ({ } try { - child = spawn(executable, arguments_, { + 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: ['ignore', 'pipe', 'pipe'], + stdio: guardProcessGroup + ? ['ignore', 'pipe', 'pipe', 'ipc'] + : ['ignore', 'pipe', 'pipe'], }); onSpawn?.(child); if (primaryReason) signalProcessGroup(child, 'SIGTERM', platform); @@ -119,16 +171,28 @@ export const runBoundedProcess = async ({ forwardOutput ? process.stderr : undefined, )); - const timeout = setTimeout(() => requestTermination('timeout'), timeoutMs); + timeout = setTimeout(() => requestTermination('timeout'), timeoutMs); const closeResult = new Promise((resolve, reject) => { child.once('error', reject); - child.once('close', (exitCode, signal) => resolve({ exitCode, signal })); + if (guardProcessGroup) { + child.on('message', message => { + if (message?.type !== GROUP_GUARD_RESULT || commandResult) return; + commandResult = { exitCode: message.exitCode, signal: message.signal }; + if (!primaryReason) { + // Keep the outer bound active until the guard itself closes. + child.send({ type: GROUP_GUARD_RELEASE }, () => {}); + } + }); + } + child.once('close', (exitCode, signal) => resolve(commandResult ?? { exitCode, signal })); }); const result = await Promise.race([closeResult, forcedSettlement]) .finally(() => clearTimeout(timeout)); 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(); @@ -150,6 +214,7 @@ export const runBoundedProcess = async ({ 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); @@ -184,7 +249,9 @@ const parseCli = argv => { const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { - try { + 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) { diff --git a/apps/desktop/scripts/run-bounded-darwin-command.test.mjs b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs index 954544ef6..f0fc33eff 100644 --- a/apps/desktop/scripts/run-bounded-darwin-command.test.mjs +++ b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs @@ -66,6 +66,37 @@ test('timeout terminates the owned process group including a descendant', async } }); +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'); diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh index c0cb7fb21..7dcbde437 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -37,7 +37,7 @@ stage_marker() { local stage="$1" local code="$2" case "$stage" in - KEY_CERTIFICATE_GENERATION|KEYCHAIN_CREATION_SELECTION|TRUST_INSTALLATION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|STABLE_SIGNATURE_VERIFICATION|TRUST_REMOVAL|KEYCHAIN_RESTORATION_DELETION|TEMPORARY_FILE_CLEANUP) ;; + 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 @@ -95,16 +95,10 @@ requirement_proof='' identity_sha1='' original_default='' original_keychains=() -trust_installed=0 keychain_created=0 keychain_state_captured=0 active_stage='' -remove_trust() { - if (( trust_installed == 0 )); then return 0; fi - run_bounded "$CLEANUP_TIMEOUT_MS" /usr/bin/security remove-trusted-cert "$root_certificate" -} - restore_and_delete_keychain() { local restore_status=0 if (( keychain_state_captured != 0 )); then @@ -137,7 +131,6 @@ cleanup_keychain() { local cleanup_status=0 trap - EXIT HUP INT TERM set +e - run_stage TRUST_REMOVAL remove_trust || cleanup_status=1 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 certificate_serial @@ -244,12 +237,6 @@ generate_key_and_certificates() { fi } -install_trust() { - run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security add-trusted-cert \ - -r trustRoot -p codeSign -k "$keychain_path" "$root_certificate" || return $? - trust_installed=1 -} - import_identity() { run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/security import "$identity_archive" \ -k "$keychain_path" -P "$identity_password" -T /usr/bin/codesign @@ -281,7 +268,6 @@ verify_stable_signature() { run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates run_stage KEYCHAIN_CREATION_SELECTION create_and_select_keychain -run_stage TRUST_INSTALLATION install_trust run_stage IDENTITY_IMPORT import_identity run_stage PARTITION_LIST_UPDATE update_partition_list unset keychain_password identity_password certificate_serial diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs index eb44362dd..1976a764b 100644 --- a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs @@ -21,7 +21,9 @@ export const signDarwinPackagedConnectApplication = async ({ platform: 'darwin', identity: certificateSha1, keychain, - identityValidation: true, + // The identity is selected by its generated fingerprint. Avoid osx-sign's trust-dependent + // `security find-identity` lookup: this disposable keychain is intentionally never trusted. + identityValidation: false, // Preserve inside-out signing while avoiding one noninteractive codesign process per binary. batchCodesignCalls: true, preEmbedProvisioningProfile: false, diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index 9d54221e3..ecb1852ea 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -22,7 +22,7 @@ const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); export const assertDarwinSigningEvidence = ({ expectedCertificateSha1, - identities, + certificates, signatureDetails, designatedRequirement, previousDesignatedRequirement, @@ -30,12 +30,11 @@ export const assertDarwinSigningEvidence = ({ const expectedSha1 = expectedCertificateSha1.toUpperCase(); if (!SHA1_PATTERN.test(expectedSha1)) throw new Error('invalid-certificate-fingerprint'); - const validIdentities = normalizeLines(identities) - .map(line => /^\s*\d+\)\s+([A-Fa-f0-9]{40})\s+"[^"]+"\s*$/u.exec(line)?.[1]?.toUpperCase()) + const certificateFingerprints = normalizeLines(certificates) + .map(line => /^\s*SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/u.exec(line)?.[1]?.toUpperCase()) .filter(Boolean); - if (validIdentities.length !== 1 || validIdentities[0] !== expectedSha1 - || !normalizeLines(identities).some(line => /^\s*1 valid identities found\s*$/u.test(line))) { - throw new Error('identity-not-unique'); + if (certificateFingerprints.length !== 1 || certificateFingerprints[0] !== expectedSha1) { + throw new Error('certificate-not-unique'); } const details = normalizeLines(signatureDetails); @@ -60,9 +59,9 @@ export const assertDarwinSigningEvidence = ({ }; const inspectDarwinSigningEvidence = async ({ application, keychain }) => { - const [identityResult, signatureResult, requirementResult] = await Promise.all([ + const [certificateResult, signatureResult, requirementResult] = await Promise.all([ runVerificationCommand('/usr/bin/security', [ - 'find-identity', '-v', '-p', 'codesigning', keychain, + 'find-certificate', '-a', '-Z', keychain, ]), runVerificationCommand('/usr/bin/codesign', ['-d', '--verbose=4', application]), runVerificationCommand('/usr/bin/codesign', ['-d', '-r-', application]), @@ -71,7 +70,7 @@ const inspectDarwinSigningEvidence = async ({ application, keychain }) => { '--verify', '--deep', '--strict', application, ]); return { - identities: `${identityResult.stdout}\n${identityResult.stderr}`, + certificates: `${certificateResult.stdout}\n${certificateResult.stderr}`, signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, }; diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index ea89e3790..e83056cf3 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -6,7 +6,7 @@ const fingerprint = 'A'.repeat(40); const requirement = `designated => identifier "dev.propr.desktop" and certificate leaf = H"${fingerprint}"`; const validEvidence = () => ({ expectedCertificateSha1: fingerprint, - identities: ` 1) ${fingerprint} "ProPR Packaged Connect CI"\n 1 valid identities found`, + certificates: `SHA-256 hash: ${'C'.repeat(64)}\nSHA-1 hash: ${fingerprint}`, signatureDetails: [ 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', 'Identifier=dev.propr.desktop', @@ -17,20 +17,20 @@ const validEvidence = () => ({ }); describe('Darwin packaged Connect acceptance signature proof', () => { - test('accepts one certificate identity bound into the app designated requirement', () => { + test('accepts one imported certificate bound into the app designated requirement', () => { assert.equal(assertDarwinSigningEvidence(validEvidence()), requirement); assert.equal(assertDarwinSigningEvidence({ ...validEvidence(), previousDesignatedRequirement: `${requirement}\n`, }), requirement); }); - test('fails closed when the disposable identity is missing, ambiguous, or different', () => { - for (const identities of [ - '0 valid identities found', - `${validEvidence().identities}\n 2) ${'B'.repeat(40)} "Unexpected"`, - ` 1) ${'B'.repeat(40)} "Unexpected"\n 1 valid identities found`, + test('fails closed when the disposable certificate is missing, ambiguous, or different', () => { + for (const certificates of [ + '', + `${validEvidence().certificates}\nSHA-1 hash: ${'B'.repeat(40)}`, + `SHA-1 hash: ${'B'.repeat(40)}`, ]) { - assert.throws(() => assertDarwinSigningEvidence({ ...validEvidence(), identities })); + assert.throws(() => assertDarwinSigningEvidence({ ...validEvidence(), certificates })); } }); From 589e10ec28d742d2a574f31bff32f34a8c889519 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:05:21 +0000 Subject: [PATCH 04/16] feat(ai): Implemented the CI-only Darwin correction without trust mutation. Implemented the CI-only Darwin correction without trust mutation. - Generates a one-day self-signed code-signing leaf and imports its private key/certificate as PKCS#12 in [run-packaged-darwin-connect-smoke.sh](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T14-50-46/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh:182). - Replaced `osx-sign` execution with bounded, inside-out direct `codesign` using the explicit keychain, exact SHA1, no timestamp, preserved metadata, certificate-bound requirement, and strict deep verification in [sign-darwin-packaged-connect.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T14-50-46/apps/desktop/scripts/sign-darwin-packaged-connect.mjs:120). - Added fixed secret-safe diagnostics for missing identity/chain, trust rejection, requirements failure, and codesign failure. - Preserved strict before/after verification, native-artifact hashes, cleanup, production signing isolation, and existing Linux/Windows behavior. - Retained and re-proved SIGKILL escalation against TERM-ignoring descendants. Verification: - Focused tests: 22 passed - Desktop suite: 444 passed, 25 platform skips - ESLint, shell syntax, and `git diff --check`: passed - No visual preview created; this is CI-only Native Darwin execution could not run on this Linux x64 worker. The existing Darwin x64 and ARM64 packaged jobs must provide the authoritative native signing and Safe Storage proof. No commit was created. PR: #2108 Comment by: @integry (ID: 5542208980) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 40 +++- .../run-packaged-darwin-connect-smoke.sh | 44 ++--- .../scripts/sign-darwin-packaged-connect.mjs | 179 +++++++++++++++--- .../sign-darwin-packaged-connect.test.mjs | 122 ++++++++++++ ...rify-darwin-packaged-connect-signature.mjs | 29 ++- ...darwin-packaged-connect-signature.test.mjs | 1 - 6 files changed, 341 insertions(+), 74 deletions(-) create mode 100644 apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index b7e9ccd61..b3243d322 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -63,20 +63,26 @@ describe('packaged Connect target-native credential setup', () => { 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 "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); - assert.match(darwinSigner, /identityValidation: false/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(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/u); assert.match(darwinVerifier, /'find-certificate', '-a', '-Z', keychain/u); assert.doesNotMatch(darwinVerifier, /find-identity/u); - assert.match(darwinSigner, /batchCodesignCalls: true/u); - assert.match(darwinSigner, /preEmbedProvisioningProfile: false/u); - assert.match(darwinSigner, /timestamp: 'none'/u); assert.match(darwinSigner, /certificate leaf = H"\$\{certificateSha1\}"/u); - assert.match(darwinSigner, /ignore: \[PACKAGED_CONNECT_NATIVE_ARTIFACTS\]/u); - assert.match(darwinSigner, /strictVerify: true/u); + assert.match(darwinSigner, /filter\(filePath => !PACKAGED_CONNECT_NATIVE_ARTIFACTS\.test\(filePath\)\)/u); + assert.match(darwinSigner, /'--verify', '--deep', '--strict', application/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'); @@ -133,12 +139,30 @@ describe('packaged Connect target-native credential setup', () => { assert.match(boundedDarwinRunner, /maximumBytes - state\.bytes/u); assert.match(darwinVerifier, /timeout: VERIFICATION_TIMEOUT_MS/u); assert.match(darwinVerifier, /maxBuffer: VERIFICATION_MAX_BUFFER/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 "\$SIGNING_TIMEOUT_MS" node "\$application_signer"/u); + 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')); + } + 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); diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh index 7dcbde437..fa0f12b77 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -83,13 +83,9 @@ run_stage() { umask 077 keychain_root='' keychain_path='' -root_private_key='' -root_certificate='' leaf_private_key='' -leaf_request='' leaf_certificate='' identity_archive='' -root_config='' leaf_config='' requirement_proof='' identity_sha1='' @@ -133,7 +129,7 @@ cleanup_keychain() { 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 certificate_serial + 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 @@ -188,42 +184,26 @@ generate_key_and_certificates() { 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" - root_private_key="$keychain_root/root-private.pem" - root_certificate="$keychain_root/root-certificate.pem" leaf_private_key="$keychain_root/leaf-private.pem" - leaf_request="$keychain_root/leaf-request.pem" leaf_certificate="$keychain_root/leaf-certificate.pem" identity_archive="$keychain_root/identity.p12" - root_config="$keychain_root/root.cnf" 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 = root_name' \ - 'x509_extensions = root_extensions' 'prompt = no' '' '[root_name]' \ - 'CN = ProPR Packaged Connect CI Root' '' '[root_extensions]' \ - 'basicConstraints = critical,CA:TRUE,pathlen:0' \ - 'keyUsage = critical,keyCertSign,cRLSign' 'subjectKeyIdentifier = hash' \ - 'authorityKeyIdentifier = keyid:always' > "$root_config" || return $? - builtin printf '%s\n' '[leaf_extensions]' \ + 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,issuer' > "$leaf_config" || return $? + 'authorityKeyIdentifier = keyid:always,issuer' > "$leaf_config" || return $? - certificate_serial="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ - /usr/bin/openssl rand -hex 16)" || 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 "$root_config" -keyout "$root_private_key" \ - -out "$root_certificate" || return $? - run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl req -new -newkey rsa:2048 \ - -sha256 -nodes -subj '/CN=ProPR Packaged Connect CI' -keyout "$leaf_private_key" \ - -out "$leaf_request" || return $? - run_bounded "$COMMAND_TIMEOUT_MS" /usr/bin/openssl x509 -req -sha256 -days 1 \ - -in "$leaf_request" -CA "$root_certificate" -CAkey "$root_private_key" \ - -set_serial "0x$certificate_serial" -extfile "$leaf_config" \ - -extensions leaf_extensions -out "$leaf_certificate" || return $? + -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 $? @@ -248,12 +228,12 @@ update_partition_list() { } sign_application() { - run_bounded "$SIGNING_TIMEOUT_MS" node "$application_signer" \ + run_bounded_forward "$SIGNING_TIMEOUT_MS" node "$application_signer" \ "$application" "$keychain_path" "$identity_sha1" } verify_initial_signature() { - run_bounded "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ + run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" } @@ -262,7 +242,7 @@ run_pair_and_reprobe() { } verify_stable_signature() { - run_bounded "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ + run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" } @@ -270,7 +250,7 @@ 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 certificate_serial +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 diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs index 1976a764b..32ff330ea 100644 --- a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs @@ -1,42 +1,173 @@ #!/usr/bin/env node -import { sign } from '@electron/osx-sign'; +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 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 designatedRequirement = `designated => identifier "dev.propr.desktop" and certificate leaf = H"${certificateSha1}"`; - await sign({ - app: application, - platform: 'darwin', - identity: certificateSha1, - keychain, - // The identity is selected by its generated fingerprint. Avoid osx-sign's trust-dependent - // `security find-identity` lookup: this disposable keychain is intentionally never trusted. - identityValidation: false, - // Preserve inside-out signing while avoiding one noninteractive codesign process per binary. - batchCodesignCalls: true, - preEmbedProvisioningProfile: false, - preAutoEntitlements: false, - strictVerify: true, - // These native artifacts have committed byte hashes checked by the smoke. - // They are sealed as bundle resources but must not be rewritten by codesign. - ignore: [PACKAGED_CONNECT_NATIVE_ARTIFACTS], - optionsForFile: filePath => ({ - timestamp: 'none', - ...(filePath === application ? { requirements: `=${designatedRequirement}` } : {}), - }), + 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 "dev.propr.desktop" 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', + '--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]; @@ -47,8 +178,8 @@ if (isMain) { throw new Error('invalid-invocation'); } await signDarwinPackagedConnectApplication({ application, keychain, certificateSha1 }); - } catch { - process.stderr.write('Darwin packaged Connect application signing failed.\n'); + } 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..09e3a87ca --- /dev/null +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs @@ -0,0 +1,122 @@ +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.deepEqual(call.arguments.slice(0, 7), [ + '--sign', fingerprint, + '--force', + '--keychain', keychain, + '--timestamp=none', + '--preserve-metadata=identifier,entitlements,flags', + ]); + assert.equal(call.forwardOutput, false); + assert.equal(call.timeoutMs, 30_000); + assert.equal(call.terminationGraceMs, 1_000); + } + assert.ok(signingCalls.at(-1).arguments.includes( + `-r=designated => identifier "dev.propr.desktop" and certificate leaf = H"${fingerprint}"`, + )); + assert.deepEqual(calls.at(-1).arguments, [ + '--verify', '--deep', '--strict', application, + ]); + }); + + test('fails before codesign when the imported certificate is absent or ambiguous', async () => { + for (const stdout of [ + '', + `SHA-1 hash: ${fingerprint}\nSHA-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/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index ecb1852ea..605e20388 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -4,6 +4,11 @@ import { execFile as nodeExecFile } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; +import { + DARWIN_SIGNING_DIAGNOSTICS, + DarwinSigningDiagnosticError, + darwinSigningDiagnosticLine, +} from './sign-darwin-packaged-connect.mjs'; const execFile = promisify(nodeExecFile); const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; @@ -34,14 +39,14 @@ export const assertDarwinSigningEvidence = ({ .map(line => /^\s*SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/u.exec(line)?.[1]?.toUpperCase()) .filter(Boolean); if (certificateFingerprints.length !== 1 || certificateFingerprints[0] !== expectedSha1) { - throw new Error('certificate-not-unique'); + throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain); } const details = normalizeLines(signatureDetails); if (details.some(line => line === 'Signature=adhoc') || !details.some(line => line.startsWith('Authority=') && line.length > 'Authority='.length) || !details.includes(`Identifier=${REQUIRED_IDENTIFIER}`)) { - throw new Error('signature-not-certificate-backed'); + throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure); } const requirements = normalizeLines(designatedRequirement) @@ -49,20 +54,26 @@ export const assertDarwinSigningEvidence = ({ if (requirements.length !== 1 || !requirements[0].includes(`identifier "${REQUIRED_IDENTIFIER}"`) || !requirements[0].toUpperCase().includes(`CERTIFICATE LEAF = H"${expectedSha1}"`)) { - throw new Error('designated-requirement-not-bound'); + throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure); } if (previousDesignatedRequirement !== undefined && requirements[0] !== previousDesignatedRequirement.trim()) { - throw new Error('designated-requirement-changed'); + throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure); } return requirements[0]; }; const inspectDarwinSigningEvidence = async ({ application, keychain }) => { + const certificatePromise = runVerificationCommand('/usr/bin/security', [ + 'find-certificate', '-a', '-Z', keychain, + ]).catch(error => { + throw new DarwinSigningDiagnosticError( + DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, + error, + ); + }); const [certificateResult, signatureResult, requirementResult] = await Promise.all([ - runVerificationCommand('/usr/bin/security', [ - 'find-certificate', '-a', '-Z', keychain, - ]), + certificatePromise, runVerificationCommand('/usr/bin/codesign', ['-d', '--verbose=4', application]), runVerificationCommand('/usr/bin/codesign', ['-d', '-r-', application]), ]); @@ -109,8 +120,8 @@ if (isMain) { await verifyDarwinPackagedConnectSignature({ mode, application, keychain, expectedCertificateSha1, proofPath, }); - } catch { - process.stderr.write(`Darwin packaged Connect signing identity ${mode === 'stable' ? 'changed' : 'could not be established'}.\n`); + } 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 index e83056cf3..c77279666 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -11,7 +11,6 @@ const validEvidence = () => ({ 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', 'Identifier=dev.propr.desktop', 'Authority=ProPR Packaged Connect CI', - 'Authority=ProPR Packaged Connect CI Root', ].join('\n'), designatedRequirement: requirement, }); From bd8e61e3e918874a70a2552e3aa426dcba47b109 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:14:47 +0000 Subject: [PATCH 05/16] feat(ai): Implemented the process-supervisor cleanup fix. Implemented the process-supervisor cleanup fix. - Nonzero exits now retain the process-group guard through TERM grace and SIGKILL escalation. - Inner command spawn errors use the same guarded cleanup path. - Unexpected supervisor errors await bounded cleanup before timers are cleared. - Added regression coverage proving a TERM-ignoring descendant exits after its parent returns 23. - Preserved bounded output, secret-safe diagnostics, and the active signing correction. Changed: - [run-bounded-darwin-command.mjs](/home/node/workspace/apps/desktop/scripts/run-bounded-darwin-command.mjs:74) - [run-bounded-darwin-command.test.mjs](/home/node/workspace/apps/desktop/scripts/run-bounded-darwin-command.test.mjs:142) Verification: - Focused supervisor/platform tests: 16 passed - Full desktop suite: 446 passed, 25 skipped - ESLint passed - `git diff --check` passed No preview created because the change is non-visual. No commit was made. PR: #2108 Comment by: @integry (ID: 5542264275) Model: gpt-5.6-sol --- .../scripts/run-bounded-darwin-command.mjs | 54 ++++++++++++------- .../run-bounded-darwin-command.test.mjs | 47 ++++++++++++++++ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/apps/desktop/scripts/run-bounded-darwin-command.mjs b/apps/desktop/scripts/run-bounded-darwin-command.mjs index 1fba33bd6..f6e388235 100644 --- a/apps/desktop/scripts/run-bounded-darwin-command.mjs +++ b/apps/desktop/scripts/run-bounded-darwin-command.mjs @@ -71,7 +71,9 @@ const runProcessGroupGuard = async argv => { windowsHide: true, stdio: ['ignore', 'inherit', 'inherit'], }); - command.once('error', () => publishResult({ exitCode: 1, signal: null })); + command.once('error', () => publishResult({ + exitCode: 1, signal: null, spawnError: true, + })); command.once('close', (exitCode, signal) => publishResult({ exitCode, signal })); process.on('message', message => { @@ -111,7 +113,9 @@ export const runBoundedProcess = async ({ let drainTimer; let timeout; let child; + let childClosed; let commandResult; + let commandSpawnFailed = false; let resolveForcedSettlement; const forcedSettlement = new Promise(resolve => { resolveForcedSettlement = resolve; }); @@ -160,8 +164,26 @@ export const runBoundedProcess = async ({ ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'], }); - onSpawn?.(child); - if (primaryReason) signalProcessGroup(child, 'SIGTERM', platform); + 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, @@ -171,23 +193,15 @@ export const runBoundedProcess = async ({ forwardOutput ? process.stderr : undefined, )); + onSpawn?.(child); + if (primaryReason) signalProcessGroup(child, 'SIGTERM', platform); timeout = setTimeout(() => requestTermination('timeout'), timeoutMs); - const closeResult = new Promise((resolve, reject) => { - child.once('error', reject); - if (guardProcessGroup) { - child.on('message', message => { - if (message?.type !== GROUP_GUARD_RESULT || commandResult) return; - commandResult = { exitCode: message.exitCode, signal: message.signal }; - if (!primaryReason) { - // Keep the outer bound active until the guard itself closes. - child.send({ type: GROUP_GUARD_RELEASE }, () => {}); - } - }); - } - child.once('close', (exitCode, signal) => resolve(commandResult ?? { exitCode, signal })); - }); - const result = await Promise.race([closeResult, forcedSettlement]) + 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) { @@ -211,6 +225,10 @@ export const runBoundedProcess = async ({ 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 { diff --git a/apps/desktop/scripts/run-bounded-darwin-command.test.mjs b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs index f0fc33eff..6315feaf4 100644 --- a/apps/desktop/scripts/run-bounded-darwin-command.test.mjs +++ b/apps/desktop/scripts/run-bounded-darwin-command.test.mjs @@ -139,6 +139,53 @@ test('a command failure is not replaced by timeout or cleanup status', async () } }); +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) => { From b9f85d5471b183aee2e75a7475c62403c0fa3f16 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:26:37 +0000 Subject: [PATCH 06/16] feat(ai): Implemented the Darwin-only verifier correction. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the Darwin-only verifier correction. - Replaced `Authority=` reliance with bounded `codesign` certificate extraction into the private temp directory. - Requires exactly one extracted X.509 leaf and an exact SHA-1 match. - Preserved ad-hoc rejection, identifier/designated-requirement binding, and strict deep verification before and after the journey. - Added focused valid self-signed, missing, ambiguous, mismatched, and ad-hoc tests. - Existing temp cleanup removes extracted certificates. Key changes: [verifier](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T15-15-03/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs:36), [tests](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T15-15-03/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs:55). Validation: - Desktop suite: 475 tests, 450 passed, 25 platform-specific skipped. - Shell syntax and `git diff --check`: passed. - Native Darwin x64/ARM64 execution requires the CI matrix and wasn’t available on this Linux worker. No preview was created because the change is non-visual CI infrastructure. PR: #2108 Comment by: @integry (ID: 5542459592) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 13 +- .../run-packaged-darwin-connect-smoke.sh | 4 +- ...rify-darwin-packaged-connect-signature.mjs | 130 ++++++++---- ...darwin-packaged-connect-signature.test.mjs | 187 +++++++++++++++--- 4 files changed, 264 insertions(+), 70 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index b3243d322..f8ae1f2bc 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -78,11 +78,17 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, /'--keychain', keychain/u); assert.match(darwinSigner, /'--timestamp=none'/u); assert.match(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/u); - assert.match(darwinVerifier, /'find-certificate', '-a', '-Z', keychain/u); + assert.match(darwinVerifier, /`--extract-certificates=\$\{certificatePrefix\}`/u); + assert.match(darwinVerifier, /new X509Certificate\(certificateBytes\)/u); + assert.match(darwinVerifier, /leafCertificate\.fingerprint\.replaceAll\(':', ''\)/u); + assert.doesNotMatch(darwinVerifier, /Authority=/u); + assert.doesNotMatch(darwinVerifier, /find-certificate/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, /certificateDirectory: dirname\(proofPath\)/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'); @@ -137,8 +143,9 @@ describe('packaged Connect target-native credential setup', () => { assert.match(boundedDarwinRunner, /signalProcessGroup\(child, 'SIGTERM'/u); assert.match(boundedDarwinRunner, /signalProcessGroup\(child, 'SIGKILL'/u); assert.match(boundedDarwinRunner, /maximumBytes - state\.bytes/u); - assert.match(darwinVerifier, /timeout: VERIFICATION_TIMEOUT_MS/u); - assert.match(darwinVerifier, /maxBuffer: VERIFICATION_MAX_BUFFER/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); diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh index fa0f12b77..7cf834be6 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -234,7 +234,7 @@ sign_application() { verify_initial_signature() { run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ - "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" + "$application" "$identity_sha1" "$requirement_proof" } run_pair_and_reprobe() { @@ -243,7 +243,7 @@ run_pair_and_reprobe() { verify_stable_signature() { run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ - "$application" "$keychain_path" "$identity_sha1" "$requirement_proof" + "$application" "$identity_sha1" "$requirement_proof" } run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index 605e20388..b59965065 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -1,33 +1,64 @@ #!/usr/bin/env node -import { execFile as nodeExecFile } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; -import { promisify } from 'node:util'; +import { X509Certificate } from 'node:crypto'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; import { DARWIN_SIGNING_DIAGNOSTICS, DarwinSigningDiagnosticError, darwinSigningDiagnosticLine, } from './sign-darwin-packaged-connect.mjs'; -const execFile = promisify(nodeExecFile); const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; const SHA1_PATTERN = /^[A-F0-9]{40}$/u; const VERIFICATION_TIMEOUT_MS = 20_000; -const VERIFICATION_MAX_BUFFER = 256 * 1024; +const VERIFICATION_TERMINATION_GRACE_MS = 1_000; +const VERIFICATION_MAX_OUTPUT_BYTES = 256 * 1024; -const runVerificationCommand = (executable, arguments_) => execFile(executable, arguments_, { - encoding: 'utf8', - maxBuffer: VERIFICATION_MAX_BUFFER, - timeout: VERIFICATION_TIMEOUT_MS, - killSignal: 'SIGKILL', +const runVerificationCommand = (runCommand, executable, arguments_) => runCommand({ + executable, + arguments: arguments_, + timeoutMs: VERIFICATION_TIMEOUT_MS, + terminationGraceMs: VERIFICATION_TERMINATION_GRACE_MS, + maxOutputBytes: VERIFICATION_MAX_OUTPUT_BYTES, + forwardOutput: false, }); const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); +const missingCertificateEvidence = cause => new DarwinSigningDiagnosticError( + DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, + cause, +); + +export const readExtractedCertificateFingerprints = async ({ + certificateDirectory, + certificatePrefix, +}) => { + try { + const prefixName = basename(certificatePrefix); + const entries = (await readdir(certificateDirectory, { withFileTypes: true })) + .filter(entry => entry.name.startsWith(prefixName)); + if (entries.length !== 1 + || entries[0].name !== `${prefixName}0` + || !entries[0].isFile()) { + throw missingCertificateEvidence(); + } + + const certificateBytes = await readFile(join(certificateDirectory, entries[0].name)); + const leafCertificate = new X509Certificate(certificateBytes); + return [leafCertificate.fingerprint.replaceAll(':', '').toUpperCase()]; + } catch (error) { + if (error instanceof DarwinSigningDiagnosticError) throw error; + throw missingCertificateEvidence(error); + } +}; + export const assertDarwinSigningEvidence = ({ expectedCertificateSha1, - certificates, + certificateFingerprints, signatureDetails, designatedRequirement, previousDesignatedRequirement, @@ -35,17 +66,16 @@ export const assertDarwinSigningEvidence = ({ const expectedSha1 = expectedCertificateSha1.toUpperCase(); if (!SHA1_PATTERN.test(expectedSha1)) throw new Error('invalid-certificate-fingerprint'); - const certificateFingerprints = normalizeLines(certificates) - .map(line => /^\s*SHA-1 hash:\s*([A-Fa-f0-9]{40})\s*$/u.exec(line)?.[1]?.toUpperCase()) - .filter(Boolean); - if (certificateFingerprints.length !== 1 || certificateFingerprints[0] !== expectedSha1) { - throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain); + const details = normalizeLines(signatureDetails).map(line => line.trim()); + if (details.includes('Signature=adhoc')) { + throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure); } - - const details = normalizeLines(signatureDetails); - if (details.some(line => line === 'Signature=adhoc') - || !details.some(line => line.startsWith('Authority=') && line.length > 'Authority='.length) - || !details.includes(`Identifier=${REQUIRED_IDENTIFIER}`)) { + if (!Array.isArray(certificateFingerprints) + || certificateFingerprints.length !== 1 + || certificateFingerprints[0] !== expectedSha1) { + throw missingCertificateEvidence(); + } + if (!details.includes(`Identifier=${REQUIRED_IDENTIFIER}`)) { throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure); } @@ -63,26 +93,39 @@ export const assertDarwinSigningEvidence = ({ return requirements[0]; }; -const inspectDarwinSigningEvidence = async ({ application, keychain }) => { - const certificatePromise = runVerificationCommand('/usr/bin/security', [ - 'find-certificate', '-a', '-Z', keychain, - ]).catch(error => { - throw new DarwinSigningDiagnosticError( - DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, - error, - ); - }); - const [certificateResult, signatureResult, requirementResult] = await Promise.all([ - certificatePromise, - runVerificationCommand('/usr/bin/codesign', ['-d', '--verbose=4', application]), - runVerificationCommand('/usr/bin/codesign', ['-d', '-r-', application]), +export const inspectDarwinSigningEvidence = async ({ + mode, + application, + certificateDirectory, + runCommand = runBoundedProcess, +}) => { + const certificatePrefix = join( + certificateDirectory, + `codesign-${mode}-certificate-`, + ); + const signatureResult = await runVerificationCommand(runCommand, '/usr/bin/codesign', [ + '--display', '--verbose=4', `--extract-certificates=${certificatePrefix}`, application, ]); - await runVerificationCommand('/usr/bin/codesign', [ + const signatureDetails = `${signatureResult.stdout}\n${signatureResult.stderr}`; + if (normalizeLines(signatureDetails).some(line => line.trim() === 'Signature=adhoc')) { + throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure); + } + + const certificateFingerprints = await readExtractedCertificateFingerprints({ + certificateDirectory, + certificatePrefix, + }); + const requirementResult = await runVerificationCommand( + runCommand, + '/usr/bin/codesign', + ['-d', '-r-', application], + ); + await runVerificationCommand(runCommand, '/usr/bin/codesign', [ '--verify', '--deep', '--strict', application, ]); return { - certificates: `${certificateResult.stdout}\n${certificateResult.stderr}`, - signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, + certificateFingerprints, + signatureDetails, designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, }; }; @@ -90,7 +133,6 @@ const inspectDarwinSigningEvidence = async ({ application, keychain }) => { export const verifyDarwinPackagedConnectSignature = async ({ mode, application, - keychain, expectedCertificateSha1, proofPath, }) => { @@ -98,7 +140,11 @@ export const verifyDarwinPackagedConnectSignature = async ({ const previousDesignatedRequirement = mode === 'stable' ? await readFile(proofPath, 'utf8') : undefined; - const evidence = await inspectDarwinSigningEvidence({ application, keychain }); + const evidence = await inspectDarwinSigningEvidence({ + mode, + application, + certificateDirectory: dirname(proofPath), + }); const requirement = assertDarwinSigningEvidence({ expectedCertificateSha1, previousDesignatedRequirement, @@ -111,14 +157,14 @@ export const verifyDarwinPackagedConnectSignature = async ({ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { - const [mode, application, keychain, expectedCertificateSha1, proofPath] = process.argv.slice(2); + const [mode, application, expectedCertificateSha1, proofPath] = process.argv.slice(2); try { if (process.platform !== 'darwin' - || !mode || !application || !keychain || !expectedCertificateSha1 || !proofPath) { + || !mode || !application || !expectedCertificateSha1 || !proofPath) { throw new Error('invalid-invocation'); } await verifyDarwinPackagedConnectSignature({ - mode, application, keychain, expectedCertificateSha1, proofPath, + mode, application, expectedCertificateSha1, proofPath, }); } catch (error) { process.stderr.write(darwinSigningDiagnosticLine(error)); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index c77279666..f1b26b298 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -1,41 +1,182 @@ import assert from 'node:assert/strict'; +import { createHash, X509Certificate } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { assertDarwinSigningEvidence } from './verify-darwin-packaged-connect-signature.mjs'; +import { rootCertificates } from 'node:tls'; +import { + DARWIN_SIGNING_DIAGNOSTICS, + classifyDarwinSigningFailure, +} from './sign-darwin-packaged-connect.mjs'; +import { + assertDarwinSigningEvidence, + inspectDarwinSigningEvidence, + readExtractedCertificateFingerprints, +} from './verify-darwin-packaged-connect-signature.mjs'; const fingerprint = 'A'.repeat(40); -const requirement = `designated => identifier "dev.propr.desktop" and certificate leaf = H"${fingerprint}"`; -const validEvidence = () => ({ - expectedCertificateSha1: fingerprint, - certificates: `SHA-256 hash: ${'C'.repeat(64)}\nSHA-1 hash: ${fingerprint}`, +const requirementFor = certificateSha1 => ( + `designated => identifier "dev.propr.desktop" and certificate leaf = H"${certificateSha1}"` +); +const validEvidence = (certificateSha1 = fingerprint) => ({ + expectedCertificateSha1: certificateSha1, + certificateFingerprints: [certificateSha1], signatureDetails: [ 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', 'Identifier=dev.propr.desktop', - 'Authority=ProPR Packaged Connect CI', ].join('\n'), - designatedRequirement: requirement, + designatedRequirement: requirementFor(certificateSha1), }); +const selfSignedCertificate = rootCertificates + .map(certificate => new X509Certificate(certificate)) + .find(certificate => certificate.checkIssued(certificate)); +assert.ok(selfSignedCertificate, 'Node must provide a self-signed certificate test fixture'); +const selfSignedFingerprint = createHash('sha1') + .update(selfSignedCertificate.raw) + .digest('hex') + .toUpperCase(); + +const withCertificateDirectory = async callback => { + const certificateDirectory = await mkdtemp(join(tmpdir(), 'propr-codesign-evidence-')); + const certificatePrefix = join(certificateDirectory, 'codesign-establish-certificate-'); + try { + await callback({ certificateDirectory, certificatePrefix }); + } finally { + await rm(certificateDirectory, { recursive: true, force: true }); + } +}; + +const isDiagnostic = diagnostic => error => ( + classifyDarwinSigningFailure(error) === diagnostic +); + describe('Darwin packaged Connect acceptance signature proof', () => { - test('accepts one imported certificate bound into the app designated requirement', () => { - assert.equal(assertDarwinSigningEvidence(validEvidence()), requirement); - assert.equal(assertDarwinSigningEvidence({ - ...validEvidence(), previousDesignatedRequirement: `${requirement}\n`, - }), requirement); + test('uses bounded codesign extraction, requirement, and strict deep verification commands', async () => { + await withCertificateDirectory(async ({ certificateDirectory }) => { + const calls = []; + const application = '/private/tmp/propr-desktop.app'; + const evidence = await inspectDarwinSigningEvidence({ + mode: 'establish', + application, + certificateDirectory, + runCommand: async options => { + calls.push(options); + const extractionArgument = options.arguments + .find(argument => argument.startsWith('--extract-certificates=')); + if (extractionArgument) { + const certificatePrefix = extractionArgument.slice('--extract-certificates='.length); + await writeFile(`${certificatePrefix}0`, selfSignedCertificate.raw); + return { stdout: '', stderr: 'Identifier=dev.propr.desktop\n' }; + } + if (options.arguments.includes('-r-')) { + return { stdout: '', stderr: `${requirementFor(selfSignedFingerprint)}\n` }; + } + return { stdout: '', stderr: '' }; + }, + }); + + assert.equal(assertDarwinSigningEvidence({ + expectedCertificateSha1: selfSignedFingerprint, + ...evidence, + }), requirementFor(selfSignedFingerprint)); + assert.deepEqual(calls.map(call => call.arguments), [ + [ + '--display', '--verbose=4', + `--extract-certificates=${join(certificateDirectory, 'codesign-establish-certificate-')}`, + application, + ], + ['-d', '-r-', application], + ['--verify', '--deep', '--strict', application], + ]); + for (const call of calls) { + assert.equal(call.executable, '/usr/bin/codesign'); + 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 one matching self-signed certificate without Authority output', async () => { + await withCertificateDirectory(async ({ certificateDirectory, certificatePrefix }) => { + await writeFile(`${certificatePrefix}0`, selfSignedCertificate.raw); + const certificateFingerprints = await readExtractedCertificateFingerprints({ + certificateDirectory, + certificatePrefix, + }); + assert.deepEqual(certificateFingerprints, [selfSignedFingerprint]); + + const evidence = { + ...validEvidence(selfSignedFingerprint), + certificateFingerprints, + }; + const requirement = requirementFor(selfSignedFingerprint); + assert.equal(assertDarwinSigningEvidence(evidence), requirement); + assert.equal(assertDarwinSigningEvidence({ + ...evidence, + previousDesignatedRequirement: `${requirement}\n`, + }), requirement); + }); + }); + + test('fails closed when extracted certificate evidence is missing', async () => { + await withCertificateDirectory(async ({ certificateDirectory, certificatePrefix }) => { + await assert.rejects(readExtractedCertificateFingerprints({ + certificateDirectory, + certificatePrefix, + }), isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain)); + }); + assert.throws( + () => assertDarwinSigningEvidence({ + ...validEvidence(), certificateFingerprints: [], + }), + isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain), + ); + }); + + test('fails closed when extracted certificate evidence is ambiguous', async () => { + await withCertificateDirectory(async ({ certificateDirectory, certificatePrefix }) => { + await Promise.all([ + writeFile(`${certificatePrefix}0`, selfSignedCertificate.raw), + writeFile(`${certificatePrefix}1`, selfSignedCertificate.raw), + ]); + await assert.rejects(readExtractedCertificateFingerprints({ + certificateDirectory, + certificatePrefix, + }), isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain)); + }); + assert.throws( + () => assertDarwinSigningEvidence({ + ...validEvidence(), certificateFingerprints: [fingerprint, 'B'.repeat(40)], + }), + isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain), + ); + }); + + test('fails closed when the extracted leaf fingerprint is mismatched', () => { + assert.throws( + () => assertDarwinSigningEvidence({ + ...validEvidence(), certificateFingerprints: ['B'.repeat(40)], + }), + isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain), + ); }); - test('fails closed when the disposable certificate is missing, ambiguous, or different', () => { - for (const certificates of [ - '', - `${validEvidence().certificates}\nSHA-1 hash: ${'B'.repeat(40)}`, - `SHA-1 hash: ${'B'.repeat(40)}`, - ]) { - assert.throws(() => assertDarwinSigningEvidence({ ...validEvidence(), certificates })); - } + test('rejects an ad-hoc signature even when the other evidence matches', () => { + assert.throws( + () => assertDarwinSigningEvidence({ + ...validEvidence(), signatureDetails: 'Identifier=dev.propr.desktop\nSignature=adhoc', + }), + isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure), + ); }); - test('rejects ad-hoc signatures and requirements not pinned to the generated certificate', () => { + test('rejects identifiers and requirements not pinned to the generated certificate', () => { assert.throws(() => assertDarwinSigningEvidence({ - ...validEvidence(), signatureDetails: 'Identifier=dev.propr.desktop\nSignature=adhoc', + ...validEvidence(), signatureDetails: 'Identifier=dev.other.desktop', })); assert.throws(() => assertDarwinSigningEvidence({ ...validEvidence(), @@ -46,7 +187,7 @@ describe('Darwin packaged Connect acceptance signature proof', () => { test('rejects any signing identity change after the pair and reprobe launches', () => { assert.throws(() => assertDarwinSigningEvidence({ ...validEvidence(), - previousDesignatedRequirement: `${requirement.replace(fingerprint, 'B'.repeat(40))}\n`, + previousDesignatedRequirement: `${requirementFor('B'.repeat(40))}\n`, })); }); }); From ad8e5850890eca8bc758017e925fac90b4c1ede2 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:28:39 +0000 Subject: [PATCH 07/16] feat(ai): Updated [apps/desktop/README.md](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T15-26-51/apps/desktop/README.md:51) to describe: Updated [apps/desktop/README.md](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T15-26-51/apps/desktop/README.md:51) to describe: - Original keychain configuration restoration - Disposable keychain deletion - Temporary signing-material removal Removed the inaccurate temporary-trust removal claim. No behavior or tests changed. `git diff --check` passes; no visual preview was needed. PR: #2108 Comment by: @integry (ID: 5542577122) Model: gpt-5.6-sol --- apps/desktop/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 57adb1961..2698bd5ec 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -49,7 +49,7 @@ code-signing certificate in an isolated default keychain and signs only that smo 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, removes the temporary trust, deletes the keychain, and removes all generated certificate material. +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 From a20bcd01db48d685efa83ecef1c5ae6cdacf0c84 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:36:06 +0000 Subject: [PATCH 08/16] feat(ai): Implemented the Darwin verifier follow-up: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the Darwin verifier follow-up: - `codesign` now receives `--extract-certificates` and the prefix as separate argv entries. - Explicitly validates the sole generated `prefix0` regular file before X.509 parsing. - Added exact argv regression assertions and rejected the old `--extract-certificates=...` form. - Preserved SHA1 matching, ad-hoc rejection, deep/strict verification, stable requirements, cleanup, and diagnostics. Validation: - Full desktop suite: 475 tests, 450 passed, 25 platform skips. - `git diff --check` and Node syntax checks passed. - Only three Darwin verifier/test files changed; no trust, production signing, Linux, or Windows behavior changed. - No visual preview created because this is CI-only. Per instruction, I did not commit. The system-generated commit will match the workflow’s `apps/desktop/**` path filter; native Darwin ARM64/x64 results must be confirmed by that workflow run. PR: #2108 Comment by: @integry (ID: 5542782622) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 3 ++- ...rify-darwin-packaged-connect-signature.mjs | 12 +++++++---- ...darwin-packaged-connect-signature.test.mjs | 21 ++++++++++--------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index f8ae1f2bc..beca7c2b0 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -78,7 +78,8 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, /'--keychain', keychain/u); assert.match(darwinSigner, /'--timestamp=none'/u); assert.match(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/u); - assert.match(darwinVerifier, /`--extract-certificates=\$\{certificatePrefix\}`/u); + assert.match(darwinVerifier, /'--extract-certificates', certificatePrefix/u); + assert.doesNotMatch(darwinVerifier, /--extract-certificates=/u); assert.match(darwinVerifier, /new X509Certificate\(certificateBytes\)/u); assert.match(darwinVerifier, /leafCertificate\.fingerprint\.replaceAll\(':', ''\)/u); assert.doesNotMatch(darwinVerifier, /Authority=/u); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index b59965065..18719f576 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { X509Certificate } from 'node:crypto'; -import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { lstat, readFile, readdir, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; @@ -39,15 +39,19 @@ export const readExtractedCertificateFingerprints = async ({ }) => { try { const prefixName = basename(certificatePrefix); + const expectedCertificateName = `${prefixName}0`; + const expectedCertificatePath = join(certificateDirectory, expectedCertificateName); const entries = (await readdir(certificateDirectory, { withFileTypes: true })) .filter(entry => entry.name.startsWith(prefixName)); if (entries.length !== 1 - || entries[0].name !== `${prefixName}0` + || entries[0].name !== expectedCertificateName || !entries[0].isFile()) { throw missingCertificateEvidence(); } - const certificateBytes = await readFile(join(certificateDirectory, entries[0].name)); + const certificateFile = await lstat(expectedCertificatePath); + if (!certificateFile.isFile()) throw missingCertificateEvidence(); + const certificateBytes = await readFile(expectedCertificatePath); const leafCertificate = new X509Certificate(certificateBytes); return [leafCertificate.fingerprint.replaceAll(':', '').toUpperCase()]; } catch (error) { @@ -104,7 +108,7 @@ export const inspectDarwinSigningEvidence = async ({ `codesign-${mode}-certificate-`, ); const signatureResult = await runVerificationCommand(runCommand, '/usr/bin/codesign', [ - '--display', '--verbose=4', `--extract-certificates=${certificatePrefix}`, application, + '--display', '--verbose=4', '--extract-certificates', certificatePrefix, application, ]); const signatureDetails = `${signatureResult.stdout}\n${signatureResult.stderr}`; if (normalizeLines(signatureDetails).some(line => line.trim() === 'Signature=adhoc')) { diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index f1b26b298..ec3bd05e9 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -57,16 +57,19 @@ describe('Darwin packaged Connect acceptance signature proof', () => { await withCertificateDirectory(async ({ certificateDirectory }) => { const calls = []; const application = '/private/tmp/propr-desktop.app'; + const certificatePrefix = join( + certificateDirectory, + 'codesign-establish-certificate-', + ); const evidence = await inspectDarwinSigningEvidence({ mode: 'establish', application, certificateDirectory, runCommand: async options => { calls.push(options); - const extractionArgument = options.arguments - .find(argument => argument.startsWith('--extract-certificates=')); - if (extractionArgument) { - const certificatePrefix = extractionArgument.slice('--extract-certificates='.length); + const extractionArgumentIndex = options.arguments.indexOf('--extract-certificates'); + if (extractionArgumentIndex !== -1) { + assert.equal(options.arguments[extractionArgumentIndex + 1], certificatePrefix); await writeFile(`${certificatePrefix}0`, selfSignedCertificate.raw); return { stdout: '', stderr: 'Identifier=dev.propr.desktop\n' }; } @@ -81,12 +84,10 @@ describe('Darwin packaged Connect acceptance signature proof', () => { expectedCertificateSha1: selfSignedFingerprint, ...evidence, }), requirementFor(selfSignedFingerprint)); - assert.deepEqual(calls.map(call => call.arguments), [ - [ - '--display', '--verbose=4', - `--extract-certificates=${join(certificateDirectory, 'codesign-establish-certificate-')}`, - application, - ], + assert.deepEqual(calls[0].arguments, [ + '--display', '--verbose=4', '--extract-certificates', certificatePrefix, application, + ]); + assert.deepEqual(calls.slice(1).map(call => call.arguments), [ ['-d', '-r-', application], ['--verify', '--deep', '--strict', application], ]); From e9fd5d17f3ad0dae6d1c62dccd97d4c2ceb6ccc5 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:51:15 +0000 Subject: [PATCH 09/16] feat(ai): Implemented the Darwin verifier follow-up. Implemented the Darwin verifier follow-up. - Replaced certificate extraction with bounded native `codesign --verify --test-requirement` evaluation pinned to `dev.propr.desktop` and the expected leaf SHA-1. - Retained separate embedded-requirement display, byte-exact before/after stability, and ordinary `--verify --deep --strict`. - Added all five fixed secret-safe verifier diagnostics. - Added coverage for exact argv, self-signed signatures, ad-hoc/unsigned code, wrong leaf/identifier, missing/ambiguous evidence, and stability. - Preserved cleanup, no-trust, and production isolation constraints. Validation: - Focused tests: 17 passed. - Full desktop suite: 451 passed, 25 platform skips. - Syntax and `git diff --check`: passed. - Only three Darwin verifier/test files changed. - No visual preview created because this is CI-only. The system-generated commit will trigger the path-filtered native ARM64/x64 workflow; those real journey results cannot run on this Linux worker. PR: #2108 Comment by: @integry (ID: 5542901295) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 18 +- ...rify-darwin-packaged-connect-signature.mjs | 225 ++++++----- ...darwin-packaged-connect-signature.test.mjs | 353 +++++++++++------- 3 files changed, 356 insertions(+), 240 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index beca7c2b0..2d98e1c4c 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -78,10 +78,9 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, /'--keychain', keychain/u); assert.match(darwinSigner, /'--timestamp=none'/u); assert.match(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/u); - assert.match(darwinVerifier, /'--extract-certificates', certificatePrefix/u); - assert.doesNotMatch(darwinVerifier, /--extract-certificates=/u); - assert.match(darwinVerifier, /new X509Certificate\(certificateBytes\)/u); - assert.match(darwinVerifier, /leafCertificate\.fingerprint\.replaceAll\(':', ''\)/u); + assert.match(darwinVerifier, /'--verify', '--test-requirement', `=\$\{expected\.expression\}`, application/u); + assert.match(darwinVerifier, /identifier "\$\{REQUIRED_IDENTIFIER\}" and certificate leaf = H"\$\{expectedSha1\}"/u); + assert.doesNotMatch(darwinVerifier, /extract-certificates|X509Certificate/u); assert.doesNotMatch(darwinVerifier, /Authority=/u); assert.doesNotMatch(darwinVerifier, /find-certificate/u); assert.doesNotMatch(darwinVerifier, /find-identity/u); @@ -89,7 +88,7 @@ describe('packaged Connect target-native credential setup', () => { 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, /certificateDirectory: dirname\(proofPath\)/u); + assert.match(darwinVerifier, /previousDesignatedRequirement !== `\$\{requirements\[0\]\}\\n`/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'); @@ -165,6 +164,15 @@ describe('packaged Connect target-native credential setup', () => { ]) { assert.match(darwinSigner, new RegExp(`['"]${diagnostic}['"]`, 'u')); } + for (const diagnostic of [ + 'SIGNATURE_DISPLAY_FAILURE', + 'EXPECTED_REQUIREMENT_FAILURE', + 'EMBEDDED_REQUIREMENT_FAILURE', + 'STRICT_VERIFY_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); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index 18719f576..a0c1370c9 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -1,12 +1,9 @@ #!/usr/bin/env node -import { X509Certificate } from 'node:crypto'; -import { lstat, readFile, readdir, writeFile } from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; import { - DARWIN_SIGNING_DIAGNOSTICS, DarwinSigningDiagnosticError, darwinSigningDiagnosticLine, } from './sign-darwin-packaged-connect.mjs'; @@ -17,121 +14,133 @@ const VERIFICATION_TIMEOUT_MS = 20_000; const VERIFICATION_TERMINATION_GRACE_MS = 1_000; const VERIFICATION_MAX_OUTPUT_BYTES = 256 * 1024; -const runVerificationCommand = (runCommand, executable, arguments_) => runCommand({ - executable, - arguments: arguments_, - timeoutMs: VERIFICATION_TIMEOUT_MS, - terminationGraceMs: VERIFICATION_TERMINATION_GRACE_MS, - maxOutputBytes: VERIFICATION_MAX_OUTPUT_BYTES, - forwardOutput: false, +export const DARWIN_VERIFICATION_DIAGNOSTICS = Object.freeze({ + signatureDisplayFailure: 'SIGNATURE_DISPLAY_FAILURE', + expectedRequirementFailure: 'EXPECTED_REQUIREMENT_FAILURE', + embeddedRequirementFailure: 'EMBEDDED_REQUIREMENT_FAILURE', + strictVerifyFailure: 'STRICT_VERIFY_FAILURE', + evidenceAssertionFailure: 'EVIDENCE_ASSERTION_FAILURE', }); -const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); - -const missingCertificateEvidence = cause => new DarwinSigningDiagnosticError( - DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain, +const verificationFailure = (diagnostic, cause) => new DarwinSigningDiagnosticError( + diagnostic, cause, ); -export const readExtractedCertificateFingerprints = async ({ - certificateDirectory, - certificatePrefix, -}) => { +const runVerificationCommand = async (runCommand, arguments_, diagnostic) => { try { - const prefixName = basename(certificatePrefix); - const expectedCertificateName = `${prefixName}0`; - const expectedCertificatePath = join(certificateDirectory, expectedCertificateName); - const entries = (await readdir(certificateDirectory, { withFileTypes: true })) - .filter(entry => entry.name.startsWith(prefixName)); - if (entries.length !== 1 - || entries[0].name !== expectedCertificateName - || !entries[0].isFile()) { - throw missingCertificateEvidence(); - } + return await runCommand({ + executable: '/usr/bin/codesign', + 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 certificateFile = await lstat(expectedCertificatePath); - if (!certificateFile.isFile()) throw missingCertificateEvidence(); - const certificateBytes = await readFile(expectedCertificatePath); - const leafCertificate = new X509Certificate(certificateBytes); - return [leafCertificate.fingerprint.replaceAll(':', '').toUpperCase()]; - } catch (error) { - if (error instanceof DarwinSigningDiagnosticError) throw error; - throw missingCertificateEvidence(error); +const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); + +const expectedRequirementsFor = expectedCertificateSha1 => { + try { + const expectedSha1 = expectedCertificateSha1.toUpperCase(); + 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, + ); } }; export const assertDarwinSigningEvidence = ({ expectedCertificateSha1, - certificateFingerprints, signatureDetails, designatedRequirement, previousDesignatedRequirement, }) => { - const expectedSha1 = expectedCertificateSha1.toUpperCase(); - if (!SHA1_PATTERN.test(expectedSha1)) throw new Error('invalid-certificate-fingerprint'); - - const details = normalizeLines(signatureDetails).map(line => line.trim()); - if (details.includes('Signature=adhoc')) { - throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure); - } - if (!Array.isArray(certificateFingerprints) - || certificateFingerprints.length !== 1 - || certificateFingerprints[0] !== expectedSha1) { - throw missingCertificateEvidence(); - } - if (!details.includes(`Identifier=${REQUIRED_IDENTIFIER}`)) { - throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure); - } + try { + const expected = expectedRequirementsFor(expectedCertificateSha1); + const details = normalizeLines(signatureDetails).map(line => line.trim()); + const identifiers = details.filter(line => line.startsWith('Identifier=')); + if (details.includes('Signature=adhoc') + || identifiers.length !== 1 + || identifiers[0] !== `Identifier=${REQUIRED_IDENTIFIER}`) { + throw new Error('invalid-signature-display-evidence'); + } - const requirements = normalizeLines(designatedRequirement) - .filter(line => line.startsWith('designated =>')); - if (requirements.length !== 1 - || !requirements[0].includes(`identifier "${REQUIRED_IDENTIFIER}"`) - || !requirements[0].toUpperCase().includes(`CERTIFICATE LEAF = H"${expectedSha1}"`)) { - throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure); - } - if (previousDesignatedRequirement !== undefined - && requirements[0] !== previousDesignatedRequirement.trim()) { - throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.requirementsFailure); + const requirements = normalizeLines(designatedRequirement) + .filter(line => line.startsWith('designated =>')); + const requirementMatch = requirements.length === 1 + ? requirements[0].match( + /^designated => identifier "dev\.propr\.desktop" and certificate leaf = H"([A-Fa-f0-9]{40})"$/u, + ) + : null; + if (!requirementMatch || requirementMatch[1].toUpperCase() !== expected.expectedSha1) { + throw new Error('invalid-embedded-requirement-evidence'); + } + if (previousDesignatedRequirement !== undefined + && previousDesignatedRequirement !== `${requirements[0]}\n`) { + throw new Error('unstable-embedded-requirement-evidence'); + } + return requirements[0]; + } catch (cause) { + if (cause instanceof DarwinSigningDiagnosticError + && cause.diagnostic === DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure) { + throw cause; + } + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); } - return requirements[0]; }; export const inspectDarwinSigningEvidence = async ({ - mode, application, - certificateDirectory, + expectedCertificateSha1, runCommand = runBoundedProcess, }) => { - const certificatePrefix = join( - certificateDirectory, - `codesign-${mode}-certificate-`, + const expected = expectedRequirementsFor(expectedCertificateSha1); + const signatureResult = await runVerificationCommand( + runCommand, + ['--display', '--verbose=4', application], + DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, + ); + await runVerificationCommand( + runCommand, + ['--verify', '--test-requirement', `=${expected.expression}`, application], + DARWIN_VERIFICATION_DIAGNOSTICS.expectedRequirementFailure, ); - const signatureResult = await runVerificationCommand(runCommand, '/usr/bin/codesign', [ - '--display', '--verbose=4', '--extract-certificates', certificatePrefix, application, - ]); - const signatureDetails = `${signatureResult.stdout}\n${signatureResult.stderr}`; - if (normalizeLines(signatureDetails).some(line => line.trim() === 'Signature=adhoc')) { - throw new DarwinSigningDiagnosticError(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure); - } - - const certificateFingerprints = await readExtractedCertificateFingerprints({ - certificateDirectory, - certificatePrefix, - }); const requirementResult = await runVerificationCommand( runCommand, - '/usr/bin/codesign', ['-d', '-r-', application], + DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, + ); + await runVerificationCommand( + runCommand, + ['--verify', '--deep', '--strict', application], + DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, ); - await runVerificationCommand(runCommand, '/usr/bin/codesign', [ - '--verify', '--deep', '--strict', application, - ]); - return { - certificateFingerprints, - signatureDetails, - designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, - }; + 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 ({ @@ -139,15 +148,26 @@ export const verifyDarwinPackagedConnectSignature = async ({ application, expectedCertificateSha1, proofPath, + runCommand = runBoundedProcess, }) => { - if (mode !== 'establish' && mode !== 'stable') throw new Error('invalid-verification-mode'); - const previousDesignatedRequirement = mode === 'stable' - ? await readFile(proofPath, 'utf8') - : undefined; + if (mode !== 'establish' && mode !== 'stable') { + 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({ - mode, application, - certificateDirectory: dirname(proofPath), + expectedCertificateSha1, + runCommand, }); const requirement = assertDarwinSigningEvidence({ expectedCertificateSha1, @@ -155,7 +175,16 @@ export const verifyDarwinPackagedConnectSignature = async ({ ...evidence, }); if (mode === 'establish') { - await writeFile(proofPath, `${requirement}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + try { + await writeFile(proofPath, `${requirement}\n`, { + encoding: 'utf8', mode: 0o600, flag: 'wx', + }); + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } } }; @@ -165,7 +194,7 @@ if (isMain) { try { if (process.platform !== 'darwin' || !mode || !application || !expectedCertificateSha1 || !proofPath) { - throw new Error('invalid-invocation'); + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); } await verifyDarwinPackagedConnectSignature({ mode, application, expectedCertificateSha1, proofPath, diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index ec3bd05e9..029fbe277 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -1,33 +1,30 @@ import assert from 'node:assert/strict'; import { createHash, X509Certificate } from 'node:crypto'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import { rootCertificates } from 'node:tls'; import { - DARWIN_SIGNING_DIAGNOSTICS, classifyDarwinSigningFailure, + darwinSigningDiagnosticLine, } from './sign-darwin-packaged-connect.mjs'; import { + DARWIN_VERIFICATION_DIAGNOSTICS, assertDarwinSigningEvidence, inspectDarwinSigningEvidence, - readExtractedCertificateFingerprints, + verifyDarwinPackagedConnectSignature, } from './verify-darwin-packaged-connect-signature.mjs'; -const fingerprint = 'A'.repeat(40); +const REQUIRED_IDENTIFIER = 'dev.propr.desktop'; +const application = '/private/tmp/propr-desktop.app'; +const otherFingerprint = 'B'.repeat(40); +const requirementExpressionFor = certificateSha1 => ( + `identifier "${REQUIRED_IDENTIFIER}" and certificate leaf = H"${certificateSha1}"` +); const requirementFor = certificateSha1 => ( - `designated => identifier "dev.propr.desktop" and certificate leaf = H"${certificateSha1}"` + `designated => ${requirementExpressionFor(certificateSha1)}` ); -const validEvidence = (certificateSha1 = fingerprint) => ({ - expectedCertificateSha1: certificateSha1, - certificateFingerprints: [certificateSha1], - signatureDetails: [ - 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', - 'Identifier=dev.propr.desktop', - ].join('\n'), - designatedRequirement: requirementFor(certificateSha1), -}); const selfSignedCertificate = rootCertificates .map(certificate => new X509Certificate(certificate)) @@ -38,157 +35,239 @@ const selfSignedFingerprint = createHash('sha1') .digest('hex') .toUpperCase(); -const withCertificateDirectory = async callback => { - const certificateDirectory = await mkdtemp(join(tmpdir(), 'propr-codesign-evidence-')); - const certificatePrefix = join(certificateDirectory, 'codesign-establish-certificate-'); +const validEvidence = (overrides = {}) => ({ + expectedCertificateSha1: selfSignedFingerprint, + signatureDetails: [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + `Identifier=${REQUIRED_IDENTIFIER}`, + 'Signature size=1024', + ].join('\n'), + designatedRequirement: requirementFor(selfSignedFingerprint), + ...overrides, +}); + +const createCodesignSimulator = (overrides = {}) => { + const fixture = { + signed: true, + adHoc: false, + identifier: REQUIRED_IDENTIFIER, + leaf: selfSignedFingerprint, + designatedRequirement: requirementFor(selfSignedFingerprint), + strictValid: true, + ...overrides, + }; + const calls = []; + const runCommand = async options => { + calls.push(options); + const arguments_ = options.arguments; + if (arguments_.includes('--verbose=4')) { + if (!fixture.signed) throw new Error(`unsigned secret ${application}`); + return { + stdout: '', + stderr: [ + `Identifier=${fixture.identifier}`, + fixture.adHoc ? 'Signature=adhoc' : 'Signature size=1024', + ].join('\n'), + }; + } + if (arguments_.includes('--test-requirement')) { + if (!fixture.signed || fixture.adHoc + || fixture.identifier !== REQUIRED_IDENTIFIER + || fixture.leaf !== selfSignedFingerprint) { + throw new Error(`requirement mismatch ${fixture.leaf} ${application}`); + } + return { stdout: '', stderr: '' }; + } + if (arguments_.includes('-r-')) { + if (!fixture.signed) throw new Error(`missing requirement ${application}`); + return { stdout: '', stderr: `${fixture.designatedRequirement}\n` }; + } + if (arguments_.includes('--strict')) { + if (!fixture.strictValid) throw new Error(`strict failure ${application}`); + return { stdout: '', stderr: '' }; + } + throw new Error('unexpected simulated codesign invocation'); + }; + return { calls, runCommand }; +}; + +const withProofPath = async callback => { + const directory = await mkdtemp(join(tmpdir(), 'propr-requirement-proof-')); try { - await callback({ certificateDirectory, certificatePrefix }); + await callback(join(directory, 'designated-requirement.txt')); } finally { - await rm(certificateDirectory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true }); } }; -const isDiagnostic = diagnostic => error => ( - classifyDarwinSigningFailure(error) === diagnostic -); +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; +}; describe('Darwin packaged Connect acceptance signature proof', () => { - test('uses bounded codesign extraction, requirement, and strict deep verification commands', async () => { - await withCertificateDirectory(async ({ certificateDirectory }) => { - const calls = []; - const application = '/private/tmp/propr-desktop.app'; - const certificatePrefix = join( - certificateDirectory, - 'codesign-establish-certificate-', - ); - const evidence = await inspectDarwinSigningEvidence({ - mode: 'establish', - application, - certificateDirectory, - runCommand: async options => { - calls.push(options); - const extractionArgumentIndex = options.arguments.indexOf('--extract-certificates'); - if (extractionArgumentIndex !== -1) { - assert.equal(options.arguments[extractionArgumentIndex + 1], certificatePrefix); - await writeFile(`${certificatePrefix}0`, selfSignedCertificate.raw); - return { stdout: '', stderr: 'Identifier=dev.propr.desktop\n' }; - } - if (options.arguments.includes('-r-')) { - return { stdout: '', stderr: `${requirementFor(selfSignedFingerprint)}\n` }; - } - return { stdout: '', stderr: '' }; - }, - }); + test('uses exact bounded argv for native requirement evaluation and separate evidence checks', async () => { + const simulator = createCodesignSimulator(); + const evidence = await inspectDarwinSigningEvidence({ + application, + expectedCertificateSha1: selfSignedFingerprint, + runCommand: simulator.runCommand, + }); - assert.equal(assertDarwinSigningEvidence({ + assert.equal( + assertDarwinSigningEvidence({ expectedCertificateSha1: selfSignedFingerprint, ...evidence, - }), requirementFor(selfSignedFingerprint)); - assert.deepEqual(calls[0].arguments, [ - '--display', '--verbose=4', '--extract-certificates', certificatePrefix, application, - ]); - assert.deepEqual(calls.slice(1).map(call => call.arguments), [ - ['-d', '-r-', application], - ['--verify', '--deep', '--strict', application], - ]); - for (const call of calls) { - assert.equal(call.executable, '/usr/bin/codesign'); - assert.equal(call.timeoutMs, 20_000); - assert.equal(call.terminationGraceMs, 1_000); - assert.equal(call.maxOutputBytes, 256 * 1024); - assert.equal(call.forwardOutput, false); - } - }); + }), + requirementFor(selfSignedFingerprint), + ); + assert.deepEqual(simulator.calls.map(call => call.arguments), [ + ['--display', '--verbose=4', application], + [ + '--verify', + '--test-requirement', + `=${requirementExpressionFor(selfSignedFingerprint)}`, + application, + ], + ['-d', '-r-', application], + ['--verify', '--deep', '--strict', application], + ]); + for (const call of simulator.calls) { + assert.equal(call.executable, '/usr/bin/codesign'); + 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 one matching self-signed certificate without Authority output', async () => { - await withCertificateDirectory(async ({ certificateDirectory, certificatePrefix }) => { - await writeFile(`${certificatePrefix}0`, selfSignedCertificate.raw); - const certificateFingerprints = await readExtractedCertificateFingerprints({ - certificateDirectory, - certificatePrefix, + test('accepts a valid self-signed leaf and the exact stable embedded requirement', async () => { + await withProofPath(async proofPath => { + const displayedRequirement = requirementFor(selfSignedFingerprint.toLowerCase()); + await verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application, + expectedCertificateSha1: selfSignedFingerprint, + proofPath, + runCommand: createCodesignSimulator({ + designatedRequirement: displayedRequirement, + }).runCommand, + }); + assert.equal( + await readFile(proofPath, 'utf8'), + `${displayedRequirement}\n`, + ); + await verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: selfSignedFingerprint, + proofPath, + runCommand: createCodesignSimulator({ + designatedRequirement: displayedRequirement, + }).runCommand, }); - assert.deepEqual(certificateFingerprints, [selfSignedFingerprint]); - - const evidence = { - ...validEvidence(selfSignedFingerprint), - certificateFingerprints, - }; - const requirement = requirementFor(selfSignedFingerprint); - assert.equal(assertDarwinSigningEvidence(evidence), requirement); - assert.equal(assertDarwinSigningEvidence({ - ...evidence, - previousDesignatedRequirement: `${requirement}\n`, - }), requirement); }); }); - test('fails closed when extracted certificate evidence is missing', async () => { - await withCertificateDirectory(async ({ certificateDirectory, certificatePrefix }) => { - await assert.rejects(readExtractedCertificateFingerprints({ - certificateDirectory, - certificatePrefix, - }), isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain)); - }); - assert.throws( - () => assertDarwinSigningEvidence({ - ...validEvidence(), certificateFingerprints: [], - }), - isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain), - ); + test('native expected-requirement evaluation rejects ad-hoc, wrong-leaf, and wrong-identifier code', async () => { + for (const fixture of [ + { adHoc: true }, + { leaf: otherFingerprint }, + { identifier: 'dev.other.desktop' }, + ]) { + await assert.rejects(inspectDarwinSigningEvidence({ + application, + expectedCertificateSha1: selfSignedFingerprint, + runCommand: createCodesignSimulator(fixture).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.expectedRequirementFailure)); + } }); - test('fails closed when extracted certificate evidence is ambiguous', async () => { - await withCertificateDirectory(async ({ certificateDirectory, certificatePrefix }) => { - await Promise.all([ - writeFile(`${certificatePrefix}0`, selfSignedCertificate.raw), - writeFile(`${certificatePrefix}1`, selfSignedCertificate.raw), - ]); - await assert.rejects(readExtractedCertificateFingerprints({ - certificateDirectory, - certificatePrefix, - }), isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain)); - }); - assert.throws( - () => assertDarwinSigningEvidence({ - ...validEvidence(), certificateFingerprints: [fingerprint, 'B'.repeat(40)], - }), - isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain), - ); + test('unsigned code fails at signature display with a fixed secret-safe subcode', async () => { + await assert.rejects(inspectDarwinSigningEvidence({ + application, + expectedCertificateSha1: selfSignedFingerprint, + runCommand: createCodesignSimulator({ signed: false }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure)); }); - test('fails closed when the extracted leaf fingerprint is mismatched', () => { - assert.throws( - () => assertDarwinSigningEvidence({ - ...validEvidence(), certificateFingerprints: ['B'.repeat(40)], + test('wraps every native verifier operation in its distinct fixed subcode', async () => { + const diagnostics = [ + DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.expectedRequirementFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, + ]; + for (const [failureIndex, diagnostic] of diagnostics.entries()) { + let invocation = 0; + const simulator = createCodesignSimulator(); + await assert.rejects(inspectDarwinSigningEvidence({ + application, + expectedCertificateSha1: selfSignedFingerprint, + runCommand: async options => { + if (invocation++ === failureIndex) { + throw new Error(`private failure ${application} ${selfSignedFingerprint}`); + } + return simulator.runCommand(options); + }, + }), isDiagnostic(diagnostic)); + } + }); + + test('rejects missing or ambiguous display and embedded-requirement evidence', () => { + for (const evidence of [ + validEvidence({ signatureDetails: 'Signature size=1024' }), + validEvidence({ + signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}`, }), - isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.missingIdentityOrChain), - ); + validEvidence({ designatedRequirement: '' }), + validEvidence({ + designatedRequirement: [ + requirementFor(selfSignedFingerprint), + requirementFor(selfSignedFingerprint), + ].join('\n'), + }), + ]) { + assert.throws( + () => assertDarwinSigningEvidence(evidence), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + ); + } }); - test('rejects an ad-hoc signature even when the other evidence matches', () => { + test('rejects ad-hoc evidence even if a mocked native evaluator reports success', () => { assert.throws( - () => assertDarwinSigningEvidence({ - ...validEvidence(), signatureDetails: 'Identifier=dev.propr.desktop\nSignature=adhoc', - }), - isDiagnostic(DARWIN_SIGNING_DIAGNOSTICS.codesignFailure), + () => assertDarwinSigningEvidence(validEvidence({ + signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nSignature=adhoc`, + })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), ); }); - test('rejects identifiers and requirements not pinned to the generated certificate', () => { - assert.throws(() => assertDarwinSigningEvidence({ - ...validEvidence(), signatureDetails: 'Identifier=dev.other.desktop', - })); - assert.throws(() => assertDarwinSigningEvidence({ - ...validEvidence(), - designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor trusted', - })); + test('requires byte-exact embedded designated-requirement stability after reprobe', async () => { + await withProofPath(async proofPath => { + await writeFile(proofPath, `${requirementFor(otherFingerprint)}\n`, { mode: 0o600 }); + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application, + expectedCertificateSha1: selfSignedFingerprint, + proofPath, + runCommand: createCodesignSimulator().runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + }); }); - test('rejects any signing identity change after the pair and reprobe launches', () => { - assert.throws(() => assertDarwinSigningEvidence({ - ...validEvidence(), - previousDesignatedRequirement: `${requirementFor('B'.repeat(40))}\n`, - })); + test('rejects a mismatched embedded requirement even after native expected evaluation passes', () => { + assert.throws( + () => assertDarwinSigningEvidence(validEvidence({ + designatedRequirement: requirementFor(otherFingerprint), + })), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + ); }); }); From 8f1554d281d90a4fd574376448f18ee4231500ac Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:03:06 +0000 Subject: [PATCH 10/16] feat(ai): Implemented the PR #2108 follow-up. Implemented the PR #2108 follow-up. - Corrected `codesign` argv to `['--verify', '-R=', application]` in [verify-darwin-packaged-connect-signature.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T15-56-36/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs:120). - Updated the exact argv regression to reject the invalid split form in [verify-darwin-packaged-connect-signature.test.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T15-56-36/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs:133). - Strengthened platform contracts for native x64/ARM64, separate pair/reprobe launches, and stable verification afterward in [packaged-connect-platform.test.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T15-56-36/apps/desktop/scripts/packaged-connect-platform.test.mjs:85). Verification: - Desktop suite: 476 tests, 451 passed, 25 platform-specific skipped, 0 failed. - `git diff --check`: passed. - Native Darwin jobs will trigger when the system commits/publishes these path-filtered changes. No visual preview was created because this is CI-only and not user-visible. No commit was made. PR: #2108 Comment by: @integry (ID: 5543086525) Model: gpt-5.6-sol --- .../scripts/packaged-connect-platform.test.mjs | 11 ++++++++++- .../verify-darwin-packaged-connect-signature.mjs | 2 +- ...erify-darwin-packaged-connect-signature.test.mjs | 13 ++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 2d98e1c4c..256cb7c37 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -23,6 +23,10 @@ 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 boundedDarwinRunner = await readFile( new URL('./run-bounded-darwin-command.mjs', import.meta.url), 'utf8', @@ -78,7 +82,8 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, /'--keychain', keychain/u); assert.match(darwinSigner, /'--timestamp=none'/u); assert.match(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/u); - assert.match(darwinVerifier, /'--verify', '--test-requirement', `=\$\{expected\.expression\}`, application/u); + assert.match(darwinVerifier, /'--verify', `-R=\$\{expected\.expression\}`, application/u); + assert.doesNotMatch(darwinVerifier, /'--test-requirement'\s*,\s*`?=/u); assert.match(darwinVerifier, /identifier "\$\{REQUIRED_IDENTIFIER\}" and certificate leaf = H"\$\{expectedSha1\}"/u); assert.doesNotMatch(darwinVerifier, /extract-certificates|X509Certificate/u); assert.doesNotMatch(darwinVerifier, /Authority=/u); @@ -93,6 +98,10 @@ describe('packaged Connect target-native credential setup', () => { 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 emits only allowlisted fixed stage markers around every blocking phase', async () => { diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index a0c1370c9..c6e65bb37 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -117,7 +117,7 @@ export const inspectDarwinSigningEvidence = async ({ ); await runVerificationCommand( runCommand, - ['--verify', '--test-requirement', `=${expected.expression}`, application], + ['--verify', `-R=${expected.expression}`, application], DARWIN_VERIFICATION_DIAGNOSTICS.expectedRequirementFailure, ); const requirementResult = await runVerificationCommand( diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index 029fbe277..9d7e5943a 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -70,7 +70,11 @@ const createCodesignSimulator = (overrides = {}) => { ].join('\n'), }; } - if (arguments_.includes('--test-requirement')) { + const requirementArgument = arguments_.find(argument => argument.startsWith('-R=')); + if (requirementArgument !== undefined) { + if (requirementArgument !== `-R=${requirementExpressionFor(selfSignedFingerprint)}`) { + throw new Error('unexpected simulated requirement expression'); + } if (!fixture.signed || fixture.adHoc || fixture.identifier !== REQUIRED_IDENTIFIER || fixture.leaf !== selfSignedFingerprint) { @@ -130,13 +134,16 @@ describe('Darwin packaged Connect acceptance signature proof', () => { ['--display', '--verbose=4', application], [ '--verify', - '--test-requirement', - `=${requirementExpressionFor(selfSignedFingerprint)}`, + `-R=${requirementExpressionFor(selfSignedFingerprint)}`, application, ], ['-d', '-r-', application], ['--verify', '--deep', '--strict', application], ]); + const requirementArguments = simulator.calls[1].arguments; + assert.equal(requirementArguments.length, 3); + assert.ok(!requirementArguments.includes('--test-requirement')); + assert.ok(!requirementArguments.some(argument => argument.startsWith('='))); for (const call of simulator.calls) { assert.equal(call.executable, '/usr/bin/codesign'); assert.equal(call.timeoutMs, 20_000); From 2da9a0a6f8c4a2762a4d5a02b4cfb683148719cf Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:16:35 +0000 Subject: [PATCH 11/16] feat(ai): Implemented the Darwin-only verifier follow-up. Implemented the Darwin-only verifier follow-up. - Removed `-R` requirement evaluation. - Added separate bounded metadata and certificate-extraction commands with exact argv. - Requires exactly `prefix0` as a regular, parseable certificate and matches its SHA1. - Added `CERTIFICATE_EXTRACTION_FAILURE`. - Enforces exact certificate-bound designated requirements and byte-stable reprobe evidence. - Uses separate private initial/stable prefixes cleaned by existing temp-root cleanup. - Preserved strict deep verification and existing diagnostics. Validation: - Desktop script suite: 222 tests, 198 passed, 24 platform skips, 0 failures. - ESLint passed. - Shell syntax and `git diff --check` passed. - Native Darwin execution remains for x64/ARM64 CI. - No visual preview created because the change is CI-only. PR: #2108 Comment by: @integry (ID: 5543237359) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 17 +- .../run-packaged-darwin-connect-smoke.sh | 9 +- ...rify-darwin-packaged-connect-signature.mjs | 88 ++++-- ...darwin-packaged-connect-signature.test.mjs | 280 +++++++++++------- 4 files changed, 258 insertions(+), 136 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 256cb7c37..155f20284 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -82,10 +82,14 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, /'--keychain', keychain/u); assert.match(darwinSigner, /'--timestamp=none'/u); assert.match(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/u); - assert.match(darwinVerifier, /'--verify', `-R=\$\{expected\.expression\}`, application/u); - assert.doesNotMatch(darwinVerifier, /'--test-requirement'\s*,\s*`?=/u); + assert.match(darwinVerifier, /\['-d', '--verbose=4', application\]/u); + assert.match(darwinVerifier, /\['-d', '--extract-certificates', certificatePrefix, application\]/u); + assert.doesNotMatch(darwinVerifier, /'--test-requirement'|['"`]?-R(?:=|['"`])/u); assert.match(darwinVerifier, /identifier "\$\{REQUIRED_IDENTIFIER\}" and certificate leaf = H"\$\{expectedSha1\}"/u); - assert.doesNotMatch(darwinVerifier, /extract-certificates|X509Certificate/u); + assert.match(darwinVerifier, /new X509Certificate\(await readFile\(leafPath\)\)/u); + assert.match(darwinVerifier, /prefixFiles\.length !== 1 \|\| prefixFiles\[0\] !== `\$\{prefixName\}0`/u); + assert.match(darwinVerifier, /if \(!stats\.isFile\(\)\)/u); + assert.match(darwinVerifier, /extractedSha1 !== expectedSha1/u); assert.doesNotMatch(darwinVerifier, /Authority=/u); assert.doesNotMatch(darwinVerifier, /find-certificate/u); assert.doesNotMatch(darwinVerifier, /find-identity/u); @@ -93,7 +97,7 @@ describe('packaged Connect target-native credential setup', () => { 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 !== `\$\{requirements\[0\]\}\\n`/u); + assert.match(darwinVerifier, /previousDesignatedRequirement !== designatedRequirement/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'); @@ -175,6 +179,7 @@ describe('packaged Connect target-native credential setup', () => { } for (const diagnostic of [ 'SIGNATURE_DISPLAY_FAILURE', + 'CERTIFICATE_EXTRACTION_FAILURE', 'EXPECTED_REQUIREMENT_FAILURE', 'EMBEDDED_REQUIREMENT_FAILURE', 'STRICT_VERIFY_FAILURE', @@ -198,6 +203,10 @@ describe('packaged Connect target-native credential setup', () => { 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.match(darwinRunner, /initial_certificate_prefix="\$keychain_root\/initial-certificate-"/u); + assert.match(darwinRunner, /stable_certificate_prefix="\$keychain_root\/stable-certificate-"/u); + assert.match(darwinRunner, /"\$requirement_proof" "\$initial_certificate_prefix"/u); + assert.match(darwinRunner, /"\$requirement_proof" "\$stable_certificate_prefix"/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); }); diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh index 7cf834be6..a0eae233b 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -88,6 +88,8 @@ leaf_certificate='' identity_archive='' leaf_config='' requirement_proof='' +initial_certificate_prefix='' +stable_certificate_prefix='' identity_sha1='' original_default='' original_keychains=() @@ -189,6 +191,9 @@ generate_key_and_certificates() { identity_archive="$keychain_root/identity.p12" leaf_config="$keychain_root/leaf.cnf" requirement_proof="$keychain_root/designated-requirement.txt" + # The private-root cleanup removes every file emitted for both extraction prefixes. + initial_certificate_prefix="$keychain_root/initial-certificate-" + stable_certificate_prefix="$keychain_root/stable-certificate-" keychain_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ /usr/bin/openssl rand -hex 32)" || return $? identity_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ @@ -234,7 +239,7 @@ sign_application() { verify_initial_signature() { run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ - "$application" "$identity_sha1" "$requirement_proof" + "$application" "$identity_sha1" "$requirement_proof" "$initial_certificate_prefix" } run_pair_and_reprobe() { @@ -243,7 +248,7 @@ run_pair_and_reprobe() { verify_stable_signature() { run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ - "$application" "$identity_sha1" "$requirement_proof" + "$application" "$identity_sha1" "$requirement_proof" "$stable_certificate_prefix" } run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index c6e65bb37..fdee6f74a 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node -import { readFile, writeFile } from 'node:fs/promises'; +import { createHash, X509Certificate } from 'node:crypto'; +import { lstat, readFile, readdir, writeFile } from 'node:fs/promises'; +import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; import { @@ -16,6 +18,7 @@ const VERIFICATION_MAX_OUTPUT_BYTES = 256 * 1024; export const DARWIN_VERIFICATION_DIAGNOSTICS = Object.freeze({ signatureDisplayFailure: 'SIGNATURE_DISPLAY_FAILURE', + certificateExtractionFailure: 'CERTIFICATE_EXTRACTION_FAILURE', expectedRequirementFailure: 'EXPECTED_REQUIREMENT_FAILURE', embeddedRequirementFailure: 'EMBEDDED_REQUIREMENT_FAILURE', strictVerifyFailure: 'STRICT_VERIFY_FAILURE', @@ -46,7 +49,7 @@ const normalizeLines = value => value.replace(/\r\n?/gu, '\n').split('\n'); const expectedRequirementsFor = expectedCertificateSha1 => { try { - const expectedSha1 = expectedCertificateSha1.toUpperCase(); + 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 { @@ -61,6 +64,38 @@ const expectedRequirementsFor = expectedCertificateSha1 => { } }; +const assertExtractedCertificate = async ({ + certificatePrefix, + expectedCertificateSha1, +}) => { + try { + const { expectedSha1 } = expectedRequirementsFor(expectedCertificateSha1); + const prefixName = basename(certificatePrefix); + const prefixFiles = (await readdir(dirname(certificatePrefix))) + .filter(entry => entry.startsWith(prefixName)); + if (prefixFiles.length !== 1 || prefixFiles[0] !== `${prefixName}0`) { + throw new Error('invalid-extracted-certificate-count'); + } + const leafPath = `${certificatePrefix}0`; + const stats = await lstat(leafPath); + if (!stats.isFile()) throw new Error('invalid-extracted-certificate-file'); + const leaf = new X509Certificate(await readFile(leafPath)); + const extractedSha1 = createHash('sha1').update(leaf.raw).digest('hex').toUpperCase(); + if (extractedSha1 !== expectedSha1) { + throw new Error('unexpected-extracted-certificate'); + } + } catch (cause) { + if (cause instanceof DarwinSigningDiagnosticError + && cause.diagnostic === DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure) { + throw cause; + } + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + cause, + ); + } +}; + export const assertDarwinSigningEvidence = ({ expectedCertificateSha1, signatureDetails, @@ -71,27 +106,25 @@ export const assertDarwinSigningEvidence = ({ const expected = expectedRequirementsFor(expectedCertificateSha1); const details = normalizeLines(signatureDetails).map(line => line.trim()); const identifiers = details.filter(line => line.startsWith('Identifier=')); - if (details.includes('Signature=adhoc') + const signatures = details.filter(line => line.startsWith('Signature')); + if (signatures.length !== 1 + || !/^Signature size=\d+$/u.test(signatures[0]) || identifiers.length !== 1 || identifiers[0] !== `Identifier=${REQUIRED_IDENTIFIER}`) { throw new Error('invalid-signature-display-evidence'); } - const requirements = normalizeLines(designatedRequirement) - .filter(line => line.startsWith('designated =>')); - const requirementMatch = requirements.length === 1 - ? requirements[0].match( - /^designated => identifier "dev\.propr\.desktop" and certificate leaf = H"([A-Fa-f0-9]{40})"$/u, - ) - : null; + const requirementMatch = designatedRequirement.match( + /^designated => identifier "dev\.propr\.desktop" and certificate leaf = H"([A-Fa-f0-9]{40})"\n$/u, + ); if (!requirementMatch || requirementMatch[1].toUpperCase() !== expected.expectedSha1) { throw new Error('invalid-embedded-requirement-evidence'); } if (previousDesignatedRequirement !== undefined - && previousDesignatedRequirement !== `${requirements[0]}\n`) { + && previousDesignatedRequirement !== designatedRequirement) { throw new Error('unstable-embedded-requirement-evidence'); } - return requirements[0]; + return designatedRequirement; } catch (cause) { if (cause instanceof DarwinSigningDiagnosticError && cause.diagnostic === DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure) { @@ -107,19 +140,21 @@ export const assertDarwinSigningEvidence = ({ export const inspectDarwinSigningEvidence = async ({ application, expectedCertificateSha1, + certificatePrefix, runCommand = runBoundedProcess, }) => { - const expected = expectedRequirementsFor(expectedCertificateSha1); + expectedRequirementsFor(expectedCertificateSha1); const signatureResult = await runVerificationCommand( runCommand, - ['--display', '--verbose=4', application], + ['-d', '--verbose=4', application], DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, ); await runVerificationCommand( runCommand, - ['--verify', `-R=${expected.expression}`, application], - DARWIN_VERIFICATION_DIAGNOSTICS.expectedRequirementFailure, + ['-d', '--extract-certificates', certificatePrefix, application], + DARWIN_VERIFICATION_DIAGNOSTICS.certificateExtractionFailure, ); + await assertExtractedCertificate({ certificatePrefix, expectedCertificateSha1 }); const requirementResult = await runVerificationCommand( runCommand, ['-d', '-r-', application], @@ -132,8 +167,8 @@ export const inspectDarwinSigningEvidence = async ({ ); try { return { - signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, - designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, + signatureDetails: `${signatureResult.stdout}${signatureResult.stderr}`, + designatedRequirement: `${requirementResult.stdout}${requirementResult.stderr}`, }; } catch (cause) { throw verificationFailure( @@ -148,11 +183,16 @@ export const verifyDarwinPackagedConnectSignature = async ({ application, expectedCertificateSha1, proofPath, + certificatePrefix, runCommand = runBoundedProcess, }) => { if (mode !== 'establish' && mode !== 'stable') { throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); } + if (!certificatePrefix + || resolve(dirname(certificatePrefix)) !== resolve(dirname(proofPath))) { + throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); + } let previousDesignatedRequirement; if (mode === 'stable') { try { @@ -167,6 +207,7 @@ export const verifyDarwinPackagedConnectSignature = async ({ const evidence = await inspectDarwinSigningEvidence({ application, expectedCertificateSha1, + certificatePrefix, runCommand, }); const requirement = assertDarwinSigningEvidence({ @@ -176,7 +217,7 @@ export const verifyDarwinPackagedConnectSignature = async ({ }); if (mode === 'establish') { try { - await writeFile(proofPath, `${requirement}\n`, { + await writeFile(proofPath, requirement, { encoding: 'utf8', mode: 0o600, flag: 'wx', }); } catch (cause) { @@ -190,14 +231,17 @@ export const verifyDarwinPackagedConnectSignature = async ({ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { - const [mode, application, expectedCertificateSha1, proofPath] = process.argv.slice(2); + const [ + mode, application, expectedCertificateSha1, proofPath, certificatePrefix, + ] = process.argv.slice(2); try { if (process.platform !== 'darwin' - || !mode || !application || !expectedCertificateSha1 || !proofPath) { + || !mode || !application || !expectedCertificateSha1 || !proofPath + || !certificatePrefix) { throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); } await verifyDarwinPackagedConnectSignature({ - mode, application, expectedCertificateSha1, proofPath, + mode, application, expectedCertificateSha1, proofPath, certificatePrefix, }); } catch (error) { process.stderr.write(darwinSigningDiagnosticLine(error)); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index 9d7e5943a..7a26c7792 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { createHash, X509Certificate } from 'node:crypto'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { + mkdir, mkdtemp, readFile, rm, symlink, writeFile, +} from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { describe, test } from 'node:test'; import { rootCertificates } from 'node:tls'; import { @@ -25,15 +27,19 @@ const requirementExpressionFor = certificateSha1 => ( const requirementFor = certificateSha1 => ( `designated => ${requirementExpressionFor(certificateSha1)}` ); +const fingerprintFor = certificate => createHash('sha1') + .update(certificate.raw) + .digest('hex') + .toUpperCase(); -const selfSignedCertificate = rootCertificates - .map(certificate => new X509Certificate(certificate)) +const parsedRootCertificates = rootCertificates.map(certificate => new X509Certificate(certificate)); +const selfSignedCertificate = parsedRootCertificates .find(certificate => certificate.checkIssued(certificate)); assert.ok(selfSignedCertificate, 'Node must provide a self-signed certificate test fixture'); -const selfSignedFingerprint = createHash('sha1') - .update(selfSignedCertificate.raw) - .digest('hex') - .toUpperCase(); +const selfSignedFingerprint = fingerprintFor(selfSignedCertificate); +const mismatchedCertificate = parsedRootCertificates + .find(certificate => fingerprintFor(certificate) !== selfSignedFingerprint); +assert.ok(mismatchedCertificate, 'Node must provide a mismatched certificate test fixture'); const validEvidence = (overrides = {}) => ({ expectedCertificateSha1: selfSignedFingerprint, @@ -42,7 +48,7 @@ const validEvidence = (overrides = {}) => ({ `Identifier=${REQUIRED_IDENTIFIER}`, 'Signature size=1024', ].join('\n'), - designatedRequirement: requirementFor(selfSignedFingerprint), + designatedRequirement: `${requirementFor(selfSignedFingerprint)}\n`, ...overrides, }); @@ -51,8 +57,10 @@ const createCodesignSimulator = (overrides = {}) => { signed: true, adHoc: false, identifier: REQUIRED_IDENTIFIER, - leaf: selfSignedFingerprint, - designatedRequirement: requirementFor(selfSignedFingerprint), + certificate: selfSignedCertificate, + leafFile: 'regular', + additionalCertificate: false, + designatedRequirement: `${requirementFor(selfSignedFingerprint)}\n`, strictValid: true, ...overrides, }; @@ -60,7 +68,7 @@ const createCodesignSimulator = (overrides = {}) => { const runCommand = async options => { calls.push(options); const arguments_ = options.arguments; - if (arguments_.includes('--verbose=4')) { + if (arguments_[0] === '-d' && arguments_[1] === '--verbose=4') { if (!fixture.signed) throw new Error(`unsigned secret ${application}`); return { stdout: '', @@ -70,21 +78,27 @@ const createCodesignSimulator = (overrides = {}) => { ].join('\n'), }; } - const requirementArgument = arguments_.find(argument => argument.startsWith('-R=')); - if (requirementArgument !== undefined) { - if (requirementArgument !== `-R=${requirementExpressionFor(selfSignedFingerprint)}`) { - throw new Error('unexpected simulated requirement expression'); + if (arguments_[0] === '-d' && arguments_[1] === '--extract-certificates') { + const prefix = arguments_[2]; + if (fixture.leafFile === 'regular') { + await writeFile(`${prefix}0`, fixture.certificate.raw); + } else if (fixture.leafFile === 'directory') { + await mkdir(`${prefix}0`); + } else if (fixture.leafFile === 'symlink') { + const target = join(dirname(prefix), 'certificate-target.der'); + await writeFile(target, fixture.certificate.raw); + await symlink(target, `${prefix}0`); + } else if (fixture.leafFile === 'malformed') { + await writeFile(`${prefix}0`, 'not a certificate'); } - if (!fixture.signed || fixture.adHoc - || fixture.identifier !== REQUIRED_IDENTIFIER - || fixture.leaf !== selfSignedFingerprint) { - throw new Error(`requirement mismatch ${fixture.leaf} ${application}`); + if (fixture.additionalCertificate) { + await writeFile(`${prefix}1`, fixture.certificate.raw); } return { stdout: '', stderr: '' }; } - if (arguments_.includes('-r-')) { + if (arguments_[0] === '-d' && arguments_[1] === '-r-') { if (!fixture.signed) throw new Error(`missing requirement ${application}`); - return { stdout: '', stderr: `${fixture.designatedRequirement}\n` }; + return { stdout: '', stderr: fixture.designatedRequirement }; } if (arguments_.includes('--strict')) { if (!fixture.strictValid) throw new Error(`strict failure ${application}`); @@ -95,10 +109,14 @@ const createCodesignSimulator = (overrides = {}) => { return { calls, runCommand }; }; -const withProofPath = async callback => { - const directory = await mkdtemp(join(tmpdir(), 'propr-requirement-proof-')); +const withPrivateEvidencePaths = async callback => { + const directory = await mkdtemp(join(tmpdir(), 'propr-signature-evidence-')); try { - await callback(join(directory, 'designated-requirement.txt')); + await callback({ + proofPath: join(directory, 'designated-requirement.txt'), + initialCertificatePrefix: join(directory, 'initial-certificate-'), + stableCertificatePrefix: join(directory, 'stable-certificate-'), + }); } finally { await rm(directory, { recursive: true, force: true }); } @@ -115,65 +133,67 @@ const isDiagnostic = diagnostic => error => { }; describe('Darwin packaged Connect acceptance signature proof', () => { - test('uses exact bounded argv for native requirement evaluation and separate evidence checks', async () => { - const simulator = createCodesignSimulator(); - const evidence = await inspectDarwinSigningEvidence({ - application, - expectedCertificateSha1: selfSignedFingerprint, - runCommand: simulator.runCommand, - }); - - assert.equal( - assertDarwinSigningEvidence({ - expectedCertificateSha1: selfSignedFingerprint, - ...evidence, - }), - requirementFor(selfSignedFingerprint), - ); - assert.deepEqual(simulator.calls.map(call => call.arguments), [ - ['--display', '--verbose=4', application], - [ - '--verify', - `-R=${requirementExpressionFor(selfSignedFingerprint)}`, + test('uses separate exact bounded argv for display, extraction, requirement, and strict checks', async () => { + await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { + const simulator = createCodesignSimulator(); + const evidence = await inspectDarwinSigningEvidence({ application, - ], - ['-d', '-r-', application], - ['--verify', '--deep', '--strict', application], - ]); - const requirementArguments = simulator.calls[1].arguments; - assert.equal(requirementArguments.length, 3); - assert.ok(!requirementArguments.includes('--test-requirement')); - assert.ok(!requirementArguments.some(argument => argument.startsWith('='))); - for (const call of simulator.calls) { - assert.equal(call.executable, '/usr/bin/codesign'); - assert.equal(call.timeoutMs, 20_000); - assert.equal(call.terminationGraceMs, 1_000); - assert.equal(call.maxOutputBytes, 256 * 1024); - assert.equal(call.forwardOutput, false); - } + expectedCertificateSha1: selfSignedFingerprint, + certificatePrefix: initialCertificatePrefix, + runCommand: simulator.runCommand, + }); + + assert.equal( + assertDarwinSigningEvidence({ + expectedCertificateSha1: selfSignedFingerprint, + ...evidence, + }), + `${requirementFor(selfSignedFingerprint)}\n`, + ); + assert.deepEqual(simulator.calls.map(call => call.arguments), [ + ['-d', '--verbose=4', application], + ['-d', '--extract-certificates', initialCertificatePrefix, application], + ['-d', '-r-', application], + ['--verify', '--deep', '--strict', application], + ]); + assert.ok(!simulator.calls.some(call => call.arguments.some( + argument => argument === '-R' || argument.startsWith('-R='), + ))); + for (const call of simulator.calls) { + assert.equal(call.executable, '/usr/bin/codesign'); + 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 a valid self-signed leaf and the exact stable embedded requirement', async () => { - await withProofPath(async proofPath => { - const displayedRequirement = requirementFor(selfSignedFingerprint.toLowerCase()); + test('accepts exactly one parsed self-signed leaf and byte-identical requirements', async () => { + await withPrivateEvidencePaths(async ({ + proofPath, initialCertificatePrefix, stableCertificatePrefix, + }) => { + const displayedRequirement = `${requirementFor(selfSignedFingerprint.toLowerCase())}\n`; await verifyDarwinPackagedConnectSignature({ mode: 'establish', application, expectedCertificateSha1: selfSignedFingerprint, proofPath, + certificatePrefix: initialCertificatePrefix, runCommand: createCodesignSimulator({ designatedRequirement: displayedRequirement, }).runCommand, }); assert.equal( await readFile(proofPath, 'utf8'), - `${displayedRequirement}\n`, + displayedRequirement, ); await verifyDarwinPackagedConnectSignature({ mode: 'stable', application, expectedCertificateSha1: selfSignedFingerprint, proofPath, + certificatePrefix: stableCertificatePrefix, runCommand: createCodesignSimulator({ designatedRequirement: displayedRequirement, }).runCommand, @@ -181,64 +201,92 @@ describe('Darwin packaged Connect acceptance signature proof', () => { }); }); - test('native expected-requirement evaluation rejects ad-hoc, wrong-leaf, and wrong-identifier code', async () => { + test('rejects a mismatched extracted leaf, ad-hoc code, and the wrong identifier', async () => { for (const fixture of [ + { certificate: mismatchedCertificate }, { adHoc: true }, - { leaf: otherFingerprint }, { identifier: 'dev.other.desktop' }, ]) { - await assert.rejects(inspectDarwinSigningEvidence({ - application, - expectedCertificateSha1: selfSignedFingerprint, - runCommand: createCodesignSimulator(fixture).runCommand, - }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.expectedRequirementFailure)); + await withPrivateEvidencePaths(async ({ proofPath, initialCertificatePrefix }) => { + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application, + expectedCertificateSha1: selfSignedFingerprint, + proofPath, + certificatePrefix: initialCertificatePrefix, + runCommand: createCodesignSimulator(fixture).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + }); + } + }); + + test('requires exactly prefix0 as a parsed regular file', async () => { + for (const fixture of [ + { leafFile: 'missing' }, + { additionalCertificate: true }, + { leafFile: 'directory' }, + { leafFile: 'symlink' }, + { leafFile: 'malformed' }, + ]) { + await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { + await assert.rejects(inspectDarwinSigningEvidence({ + application, + expectedCertificateSha1: selfSignedFingerprint, + certificatePrefix: initialCertificatePrefix, + runCommand: createCodesignSimulator(fixture).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + }); } }); test('unsigned code fails at signature display with a fixed secret-safe subcode', async () => { - await assert.rejects(inspectDarwinSigningEvidence({ - application, - expectedCertificateSha1: selfSignedFingerprint, - runCommand: createCodesignSimulator({ signed: false }).runCommand, - }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure)); + await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { + await assert.rejects(inspectDarwinSigningEvidence({ + application, + expectedCertificateSha1: selfSignedFingerprint, + certificatePrefix: initialCertificatePrefix, + runCommand: createCodesignSimulator({ signed: false }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure)); + }); }); test('wraps every native verifier operation in its distinct fixed subcode', async () => { const diagnostics = [ DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure, - DARWIN_VERIFICATION_DIAGNOSTICS.expectedRequirementFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.certificateExtractionFailure, DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, ]; for (const [failureIndex, diagnostic] of diagnostics.entries()) { - let invocation = 0; - const simulator = createCodesignSimulator(); - await assert.rejects(inspectDarwinSigningEvidence({ - application, - expectedCertificateSha1: selfSignedFingerprint, - runCommand: async options => { - if (invocation++ === failureIndex) { - throw new Error(`private failure ${application} ${selfSignedFingerprint}`); - } - return simulator.runCommand(options); - }, - }), isDiagnostic(diagnostic)); + await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { + let invocation = 0; + const simulator = createCodesignSimulator(); + await assert.rejects(inspectDarwinSigningEvidence({ + application, + expectedCertificateSha1: selfSignedFingerprint, + certificatePrefix: initialCertificatePrefix, + runCommand: async options => { + if (invocation++ === failureIndex) { + throw new Error(`private failure ${application} ${selfSignedFingerprint}`); + } + return simulator.runCommand(options); + }, + }), isDiagnostic(diagnostic)); + }); } }); - test('rejects missing or ambiguous display and embedded-requirement evidence', () => { + test('rejects missing or ambiguous display and non-exact requirement evidence', () => { for (const evidence of [ + validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}` }), validEvidence({ signatureDetails: 'Signature size=1024' }), validEvidence({ - signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}`, + signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, }), validEvidence({ designatedRequirement: '' }), - validEvidence({ - designatedRequirement: [ - requirementFor(selfSignedFingerprint), - requirementFor(selfSignedFingerprint), - ].join('\n'), - }), + validEvidence({ designatedRequirement: `${requirementFor(selfSignedFingerprint)}\r\n` }), + validEvidence({ designatedRequirement: `${requirementFor(selfSignedFingerprint)}\nextra\n` }), + validEvidence({ designatedRequirement: `${requirementFor(otherFingerprint)}\n` }), ]) { assert.throws( () => assertDarwinSigningEvidence(evidence), @@ -247,7 +295,7 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); - test('rejects ad-hoc evidence even if a mocked native evaluator reports success', () => { + test('rejects ad-hoc evidence even if the other mocked operations report success', () => { assert.throws( () => assertDarwinSigningEvidence(validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nSignature=adhoc`, @@ -257,24 +305,40 @@ describe('Darwin packaged Connect acceptance signature proof', () => { }); test('requires byte-exact embedded designated-requirement stability after reprobe', async () => { - await withProofPath(async proofPath => { - await writeFile(proofPath, `${requirementFor(otherFingerprint)}\n`, { mode: 0o600 }); + await withPrivateEvidencePaths(async ({ + proofPath, initialCertificatePrefix, stableCertificatePrefix, + }) => { + await verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application, + expectedCertificateSha1: selfSignedFingerprint, + proofPath, + certificatePrefix: initialCertificatePrefix, + runCommand: createCodesignSimulator().runCommand, + }); await assert.rejects(verifyDarwinPackagedConnectSignature({ mode: 'stable', application, expectedCertificateSha1: selfSignedFingerprint, proofPath, - runCommand: createCodesignSimulator().runCommand, + certificatePrefix: stableCertificatePrefix, + runCommand: createCodesignSimulator({ + designatedRequirement: `${requirementFor(selfSignedFingerprint.toLowerCase())}\n`, + }).runCommand, }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); }); }); - test('rejects a mismatched embedded requirement even after native expected evaluation passes', () => { - assert.throws( - () => assertDarwinSigningEvidence(validEvidence({ - designatedRequirement: requirementFor(otherFingerprint), - })), - isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), - ); + test('requires extraction prefixes to share the private proof directory', async () => { + await withPrivateEvidencePaths(async ({ proofPath }) => { + await assert.rejects(verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application, + expectedCertificateSha1: selfSignedFingerprint, + proofPath, + certificatePrefix: join(tmpdir(), 'outside-certificate-'), + runCommand: createCodesignSimulator().runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + }); }); }); From e4fea05fbbf24f49efd9513113c2d464d612267c Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:34:36 +0000 Subject: [PATCH 12/16] feat(ai): Implemented the PR #2108 follow-up without committing. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the PR #2108 follow-up without committing. - Replaced certificate extraction and `-R` evaluation with bounded keychain SHA-1 lookup, signature metadata validation, exact designated-requirement evidence, and strict verification in [verify-darwin-packaged-connect-signature.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T16-21-41/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs:18). - Removed extraction prefixes and obsolete diagnostics from the Darwin wrapper. - Enforced an explicit `os-protected` journey report while retaining fresh-process credential continuity checks. - Added all requested negative tests. Validation passed: - Desktop/UI typechecks - Full desktop suite: 453 passed, 25 platform-specific skipped - Focused suite: 54 passed - Shell syntax and `git diff --check` Native macOS x64/ARM64 execution requires the PR’s CI runners and was not available on this Linux host. No preview was created because the changes are CI-only and not visually perceptible. PR: #2108 Comment by: @integry (ID: 5543406076) Model: gpt-5.6-sol --- .../scripts/packaged-connect-lifecycle.mjs | 19 +- .../packaged-connect-lifecycle.test.mjs | 43 +++ .../packaged-connect-platform.test.mjs | 26 +- .../run-packaged-darwin-connect-smoke.sh | 9 +- .../sign-darwin-packaged-connect.test.mjs | 4 +- .../scripts/smoke-packaged-connect.mjs | 1 + ...rify-darwin-packaged-connect-signature.mjs | 73 ++-- ...darwin-packaged-connect-signature.test.mjs | 362 ++++++++---------- apps/desktop/src/main.ts | 11 +- 9 files changed, 273 insertions(+), 275 deletions(-) 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 155f20284..987f5e7f1 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -27,6 +27,7 @@ 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', @@ -82,22 +83,26 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, /'--keychain', keychain/u); assert.match(darwinSigner, /'--timestamp=none'/u); assert.match(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/u); + assert.match(darwinVerifier, /'find-certificate', '-a', '-Z', keychain/u); assert.match(darwinVerifier, /\['-d', '--verbose=4', application\]/u); - assert.match(darwinVerifier, /\['-d', '--extract-certificates', certificatePrefix, application\]/u); assert.doesNotMatch(darwinVerifier, /'--test-requirement'|['"`]?-R(?:=|['"`])/u); assert.match(darwinVerifier, /identifier "\$\{REQUIRED_IDENTIFIER\}" and certificate leaf = H"\$\{expectedSha1\}"/u); - assert.match(darwinVerifier, /new X509Certificate\(await readFile\(leafPath\)\)/u); - assert.match(darwinVerifier, /prefixFiles\.length !== 1 \|\| prefixFiles\[0\] !== `\$\{prefixName\}0`/u); - assert.match(darwinVerifier, /if \(!stats\.isFile\(\)\)/u); - assert.match(darwinVerifier, /extractedSha1 !== expectedSha1/u); + assert.match(darwinVerifier, /fingerprints\.length !== 1 \|\| fingerprints\[0\] !== expectedSha1/u); + assert.match(darwinVerifier, /\^Signature size=\(\[1-9\]\\d\*\)\$/u); + assert.match(darwinVerifier, /requirementMatch\[1\]\.toUpperCase\(\) !== expected\.expectedSha1/u); assert.doesNotMatch(darwinVerifier, /Authority=/u); - assert.doesNotMatch(darwinVerifier, /find-certificate/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 !== designatedRequirement/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'); @@ -178,9 +183,8 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, new RegExp(`['"]${diagnostic}['"]`, 'u')); } for (const diagnostic of [ + 'CERTIFICATE_LOOKUP_FAILURE', 'SIGNATURE_DISPLAY_FAILURE', - 'CERTIFICATE_EXTRACTION_FAILURE', - 'EXPECTED_REQUIREMENT_FAILURE', 'EMBEDDED_REQUIREMENT_FAILURE', 'STRICT_VERIFY_FAILURE', 'EVIDENCE_ASSERTION_FAILURE', @@ -203,10 +207,8 @@ describe('packaged Connect target-native credential setup', () => { 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.match(darwinRunner, /initial_certificate_prefix="\$keychain_root\/initial-certificate-"/u); - assert.match(darwinRunner, /stable_certificate_prefix="\$keychain_root\/stable-certificate-"/u); - assert.match(darwinRunner, /"\$requirement_proof" "\$initial_certificate_prefix"/u); - assert.match(darwinRunner, /"\$requirement_proof" "\$stable_certificate_prefix"/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); }); diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh index a0eae233b..79e7586fd 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -88,8 +88,6 @@ leaf_certificate='' identity_archive='' leaf_config='' requirement_proof='' -initial_certificate_prefix='' -stable_certificate_prefix='' identity_sha1='' original_default='' original_keychains=() @@ -191,9 +189,6 @@ generate_key_and_certificates() { identity_archive="$keychain_root/identity.p12" leaf_config="$keychain_root/leaf.cnf" requirement_proof="$keychain_root/designated-requirement.txt" - # The private-root cleanup removes every file emitted for both extraction prefixes. - initial_certificate_prefix="$keychain_root/initial-certificate-" - stable_certificate_prefix="$keychain_root/stable-certificate-" keychain_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ /usr/bin/openssl rand -hex 32)" || return $? identity_password="$(run_bounded_forward "$COMMAND_TIMEOUT_MS" \ @@ -239,7 +234,7 @@ sign_application() { verify_initial_signature() { run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" establish \ - "$application" "$identity_sha1" "$requirement_proof" "$initial_certificate_prefix" + "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" } run_pair_and_reprobe() { @@ -248,7 +243,7 @@ run_pair_and_reprobe() { verify_stable_signature() { run_bounded_forward "$COMMAND_TIMEOUT_MS" node "$signature_verifier" stable \ - "$application" "$identity_sha1" "$requirement_proof" "$stable_certificate_prefix" + "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" } run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs index 09e3a87ca..8f58edba2 100644 --- a/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs @@ -87,10 +87,12 @@ describe('Darwin packaged Connect direct signing', () => { ]); }); - test('fails before codesign when the imported certificate is absent or ambiguous', async () => { + 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, 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 index fdee6f74a..63e88b59f 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -1,8 +1,6 @@ #!/usr/bin/env node -import { createHash, X509Certificate } from 'node:crypto'; -import { lstat, readFile, readdir, writeFile } from 'node:fs/promises'; -import { basename, dirname, resolve } from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { runBoundedProcess } from './run-bounded-darwin-command.mjs'; import { @@ -12,14 +10,14 @@ import { 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 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', - certificateExtractionFailure: 'CERTIFICATE_EXTRACTION_FAILURE', - expectedRequirementFailure: 'EXPECTED_REQUIREMENT_FAILURE', embeddedRequirementFailure: 'EMBEDDED_REQUIREMENT_FAILURE', strictVerifyFailure: 'STRICT_VERIFY_FAILURE', evidenceAssertionFailure: 'EVIDENCE_ASSERTION_FAILURE', @@ -30,10 +28,10 @@ const verificationFailure = (diagnostic, cause) => new DarwinSigningDiagnosticEr cause, ); -const runVerificationCommand = async (runCommand, arguments_, diagnostic) => { +const runVerificationCommand = async (runCommand, executable, arguments_, diagnostic) => { try { return await runCommand({ - executable: '/usr/bin/codesign', + executable, arguments: arguments_, timeoutMs: VERIFICATION_TIMEOUT_MS, terminationGraceMs: VERIFICATION_TERMINATION_GRACE_MS, @@ -64,25 +62,13 @@ const expectedRequirementsFor = expectedCertificateSha1 => { } }; -const assertExtractedCertificate = async ({ - certificatePrefix, - expectedCertificateSha1, -}) => { +const assertExactKeychainCertificate = (certificateDetails, expectedCertificateSha1) => { try { const { expectedSha1 } = expectedRequirementsFor(expectedCertificateSha1); - const prefixName = basename(certificatePrefix); - const prefixFiles = (await readdir(dirname(certificatePrefix))) - .filter(entry => entry.startsWith(prefixName)); - if (prefixFiles.length !== 1 || prefixFiles[0] !== `${prefixName}0`) { - throw new Error('invalid-extracted-certificate-count'); - } - const leafPath = `${certificatePrefix}0`; - const stats = await lstat(leafPath); - if (!stats.isFile()) throw new Error('invalid-extracted-certificate-file'); - const leaf = new X509Certificate(await readFile(leafPath)); - const extractedSha1 = createHash('sha1').update(leaf.raw).digest('hex').toUpperCase(); - if (extractedSha1 !== expectedSha1) { - throw new Error('unexpected-extracted-certificate'); + 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) { if (cause instanceof DarwinSigningDiagnosticError @@ -107,8 +93,11 @@ export const assertDarwinSigningEvidence = ({ const details = normalizeLines(signatureDetails).map(line => line.trim()); const identifiers = details.filter(line => line.startsWith('Identifier=')); const signatures = details.filter(line => line.startsWith('Signature')); + const signatureSize = signatures.length === 1 + ? signatures[0].match(/^Signature size=([1-9]\d*)$/u) + : null; if (signatures.length !== 1 - || !/^Signature size=\d+$/u.test(signatures[0]) + || !signatureSize || identifiers.length !== 1 || identifiers[0] !== `Identifier=${REQUIRED_IDENTIFIER}`) { throw new Error('invalid-signature-display-evidence'); @@ -139,29 +128,36 @@ export const assertDarwinSigningEvidence = ({ export const inspectDarwinSigningEvidence = async ({ application, + keychain, expectedCertificateSha1, - certificatePrefix, 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, ); - await runVerificationCommand( - runCommand, - ['-d', '--extract-certificates', certificatePrefix, application], - DARWIN_VERIFICATION_DIAGNOSTICS.certificateExtractionFailure, - ); - await assertExtractedCertificate({ certificatePrefix, expectedCertificateSha1 }); 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, ); @@ -183,14 +179,13 @@ export const verifyDarwinPackagedConnectSignature = async ({ application, expectedCertificateSha1, proofPath, - certificatePrefix, + keychain, runCommand = runBoundedProcess, }) => { if (mode !== 'establish' && mode !== 'stable') { throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); } - if (!certificatePrefix - || resolve(dirname(certificatePrefix)) !== resolve(dirname(proofPath))) { + if (!keychain || !keychain.endsWith('.keychain-db')) { throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); } let previousDesignatedRequirement; @@ -206,8 +201,8 @@ export const verifyDarwinPackagedConnectSignature = async ({ } const evidence = await inspectDarwinSigningEvidence({ application, + keychain, expectedCertificateSha1, - certificatePrefix, runCommand, }); const requirement = assertDarwinSigningEvidence({ @@ -232,16 +227,16 @@ export const verifyDarwinPackagedConnectSignature = async ({ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { const [ - mode, application, expectedCertificateSha1, proofPath, certificatePrefix, + mode, application, expectedCertificateSha1, proofPath, keychain, ] = process.argv.slice(2); try { if (process.platform !== 'darwin' || !mode || !application || !expectedCertificateSha1 || !proofPath - || !certificatePrefix) { + || !keychain) { throw verificationFailure(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure); } await verifyDarwinPackagedConnectSignature({ - mode, application, expectedCertificateSha1, proofPath, certificatePrefix, + mode, application, expectedCertificateSha1, proofPath, keychain, }); } catch (error) { process.stderr.write(darwinSigningDiagnosticLine(error)); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index 7a26c7792..233baabb1 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -1,12 +1,8 @@ import assert from 'node:assert/strict'; -import { createHash, X509Certificate } from 'node:crypto'; -import { - mkdir, mkdtemp, readFile, rm, symlink, writeFile, -} from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +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 { rootCertificates } from 'node:tls'; import { classifyDarwinSigningFailure, darwinSigningDiagnosticLine, @@ -20,6 +16,8 @@ import { 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}"` @@ -27,40 +25,25 @@ const requirementExpressionFor = certificateSha1 => ( const requirementFor = certificateSha1 => ( `designated => ${requirementExpressionFor(certificateSha1)}` ); -const fingerprintFor = certificate => createHash('sha1') - .update(certificate.raw) - .digest('hex') - .toUpperCase(); - -const parsedRootCertificates = rootCertificates.map(certificate => new X509Certificate(certificate)); -const selfSignedCertificate = parsedRootCertificates - .find(certificate => certificate.checkIssued(certificate)); -assert.ok(selfSignedCertificate, 'Node must provide a self-signed certificate test fixture'); -const selfSignedFingerprint = fingerprintFor(selfSignedCertificate); -const mismatchedCertificate = parsedRootCertificates - .find(certificate => fingerprintFor(certificate) !== selfSignedFingerprint); -assert.ok(mismatchedCertificate, 'Node must provide a mismatched certificate test fixture'); const validEvidence = (overrides = {}) => ({ - expectedCertificateSha1: selfSignedFingerprint, + expectedCertificateSha1: fingerprint, signatureDetails: [ 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', `Identifier=${REQUIRED_IDENTIFIER}`, 'Signature size=1024', ].join('\n'), - designatedRequirement: `${requirementFor(selfSignedFingerprint)}\n`, + designatedRequirement: `${requirementFor(fingerprint)}\n`, ...overrides, }); -const createCodesignSimulator = (overrides = {}) => { +const createVerifierSimulator = (overrides = {}) => { const fixture = { signed: true, - adHoc: false, + certificateFingerprints: [fingerprint], identifier: REQUIRED_IDENTIFIER, - certificate: selfSignedCertificate, - leafFile: 'regular', - additionalCertificate: false, - designatedRequirement: `${requirementFor(selfSignedFingerprint)}\n`, + signatureLine: 'Signature size=1024', + designatedRequirement: `${requirementFor(fingerprint)}\n`, strictValid: true, ...overrides, }; @@ -68,55 +51,41 @@ const createCodesignSimulator = (overrides = {}) => { 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: [ `Identifier=${fixture.identifier}`, - fixture.adHoc ? 'Signature=adhoc' : 'Signature size=1024', - ].join('\n'), + fixture.signatureLine, + ].filter(value => value !== null).join('\n'), }; } - if (arguments_[0] === '-d' && arguments_[1] === '--extract-certificates') { - const prefix = arguments_[2]; - if (fixture.leafFile === 'regular') { - await writeFile(`${prefix}0`, fixture.certificate.raw); - } else if (fixture.leafFile === 'directory') { - await mkdir(`${prefix}0`); - } else if (fixture.leafFile === 'symlink') { - const target = join(dirname(prefix), 'certificate-target.der'); - await writeFile(target, fixture.certificate.raw); - await symlink(target, `${prefix}0`); - } else if (fixture.leafFile === 'malformed') { - await writeFile(`${prefix}0`, 'not a certificate'); - } - if (fixture.additionalCertificate) { - await writeFile(`${prefix}1`, fixture.certificate.raw); - } - return { stdout: '', stderr: '' }; - } if (arguments_[0] === '-d' && arguments_[1] === '-r-') { - if (!fixture.signed) throw new Error(`missing requirement ${application}`); 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 codesign invocation'); + throw new Error('unexpected simulated verifier invocation'); }; return { calls, runCommand }; }; -const withPrivateEvidencePaths = async callback => { +const withPrivateProofPath = async callback => { const directory = await mkdtemp(join(tmpdir(), 'propr-signature-evidence-')); try { - await callback({ - proofPath: join(directory, 'designated-requirement.txt'), - initialCertificatePrefix: join(directory, 'initial-certificate-'), - stableCertificatePrefix: join(directory, 'stable-certificate-'), - }); + await callback(join(directory, 'designated-requirement.txt')); } finally { await rm(directory, { recursive: true, force: true }); } @@ -132,161 +101,179 @@ const isDiagnostic = diagnostic => error => { return true; }; -describe('Darwin packaged Connect acceptance signature proof', () => { - test('uses separate exact bounded argv for display, extraction, requirement, and strict checks', async () => { - await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { - const simulator = createCodesignSimulator(); - const evidence = await inspectDarwinSigningEvidence({ - application, - expectedCertificateSha1: selfSignedFingerprint, - certificatePrefix: initialCertificatePrefix, - runCommand: simulator.runCommand, - }); +const verifyEstablish = async (proofPath, fixture = {}) => { + const simulator = createVerifierSimulator(fixture); + await verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application, + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: simulator.runCommand, + }); + return simulator; +}; - assert.equal( - assertDarwinSigningEvidence({ - expectedCertificateSha1: selfSignedFingerprint, - ...evidence, - }), - `${requirementFor(selfSignedFingerprint)}\n`, - ); - assert.deepEqual(simulator.calls.map(call => call.arguments), [ - ['-d', '--verbose=4', application], - ['-d', '--extract-certificates', initialCertificatePrefix, application], - ['-d', '-r-', application], - ['--verify', '--deep', '--strict', application], - ]); - assert.ok(!simulator.calls.some(call => call.arguments.some( - argument => argument === '-R' || argument.startsWith('-R='), - ))); - for (const call of simulator.calls) { - assert.equal(call.executable, '/usr/bin/codesign'); - assert.equal(call.timeoutMs, 20_000); - assert.equal(call.terminationGraceMs, 1_000); - assert.equal(call.maxOutputBytes, 256 * 1024); - assert.equal(call.forwardOutput, false); - } +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 one parsed self-signed leaf and byte-identical requirements', async () => { - await withPrivateEvidencePaths(async ({ - proofPath, initialCertificatePrefix, stableCertificatePrefix, - }) => { - const displayedRequirement = `${requirementFor(selfSignedFingerprint.toLowerCase())}\n`; - await verifyDarwinPackagedConnectSignature({ - mode: 'establish', - application, - expectedCertificateSha1: selfSignedFingerprint, - proofPath, - certificatePrefix: initialCertificatePrefix, - runCommand: createCodesignSimulator({ - designatedRequirement: displayedRequirement, - }).runCommand, - }); - assert.equal( - await readFile(proofPath, 'utf8'), - displayedRequirement, - ); + test('accepts exactly the generated keychain fingerprint and byte-identical 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: selfSignedFingerprint, + expectedCertificateSha1: fingerprint, proofPath, - certificatePrefix: stableCertificatePrefix, - runCommand: createCodesignSimulator({ + keychain, + runCommand: createVerifierSimulator({ designatedRequirement: displayedRequirement, }).runCommand, }); }); }); - test('rejects a mismatched extracted leaf, ad-hoc code, and the wrong identifier', async () => { - for (const fixture of [ - { certificate: mismatchedCertificate }, - { adHoc: true }, - { identifier: 'dev.other.desktop' }, + test('rejects duplicate and wrong keychain fingerprints', async () => { + for (const certificateFingerprints of [ + [fingerprint, fingerprint], + [fingerprint, otherFingerprint], + [otherFingerprint], ]) { - await withPrivateEvidencePaths(async ({ proofPath, initialCertificatePrefix }) => { - await assert.rejects(verifyDarwinPackagedConnectSignature({ - mode: 'establish', - application, - expectedCertificateSha1: selfSignedFingerprint, - proofPath, - certificatePrefix: initialCertificatePrefix, - runCommand: createCodesignSimulator(fixture).runCommand, - }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { certificateFingerprints }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + ); + }); + } + }); + + test('rejects ad-hoc, zero-size, and missing signature evidence', async () => { + for (const signatureLine of ['Signature=adhoc', 'Signature size=0', null]) { + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, { signatureLine }), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + ); }); } }); - test('requires exactly prefix0 as a parsed regular file', async () => { + test('rejects the wrong identifier and wrong embedded leaf', async () => { for (const fixture of [ - { leafFile: 'missing' }, - { additionalCertificate: true }, - { leafFile: 'directory' }, - { leafFile: 'symlink' }, - { leafFile: 'malformed' }, + { identifier: 'dev.other.desktop' }, + { designatedRequirement: `${requirementFor(otherFingerprint)}\n` }, ]) { - await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { - await assert.rejects(inspectDarwinSigningEvidence({ - application, - expectedCertificateSha1: selfSignedFingerprint, - certificatePrefix: initialCertificatePrefix, - runCommand: createCodesignSimulator(fixture).runCommand, - }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + await withPrivateProofPath(async proofPath => { + await assert.rejects( + verifyEstablish(proofPath, fixture), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + ); }); } }); - test('unsigned code fails at signature display with a fixed secret-safe subcode', async () => { - await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { - await assert.rejects(inspectDarwinSigningEvidence({ + 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: selfSignedFingerprint, - certificatePrefix: initialCertificatePrefix, - runCommand: createCodesignSimulator({ signed: false }).runCommand, - }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure)); + expectedCertificateSha1: fingerprint, + proofPath, + keychain, + runCommand: createVerifierSimulator({ + designatedRequirement: `${requirementFor(fingerprint.toLowerCase())}\n`, + }).runCommand, + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + }); + }); + + 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.certificateExtractionFailure, DARWIN_VERIFICATION_DIAGNOSTICS.embeddedRequirementFailure, DARWIN_VERIFICATION_DIAGNOSTICS.strictVerifyFailure, ]; for (const [failureIndex, diagnostic] of diagnostics.entries()) { - await withPrivateEvidencePaths(async ({ initialCertificatePrefix }) => { - let invocation = 0; - const simulator = createCodesignSimulator(); - await assert.rejects(inspectDarwinSigningEvidence({ - application, - expectedCertificateSha1: selfSignedFingerprint, - certificatePrefix: initialCertificatePrefix, - runCommand: async options => { - if (invocation++ === failureIndex) { - throw new Error(`private failure ${application} ${selfSignedFingerprint}`); - } - return simulator.runCommand(options); - }, - }), isDiagnostic(diagnostic)); - }); + 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('rejects missing or ambiguous display and non-exact requirement evidence', () => { + 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('rejects missing, ambiguous, or non-exact display and requirement evidence', () => { for (const evidence of [ validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}` }), validEvidence({ signatureDetails: 'Signature size=1024' }), validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, }), + validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=01` }), validEvidence({ designatedRequirement: '' }), - validEvidence({ designatedRequirement: `${requirementFor(selfSignedFingerprint)}\r\n` }), - validEvidence({ designatedRequirement: `${requirementFor(selfSignedFingerprint)}\nextra\n` }), - validEvidence({ designatedRequirement: `${requirementFor(otherFingerprint)}\n` }), + validEvidence({ designatedRequirement: `${requirementFor(fingerprint)}\r\n` }), + validEvidence({ designatedRequirement: `${requirementFor(fingerprint)}\nextra\n` }), ]) { assert.throws( () => assertDarwinSigningEvidence(evidence), @@ -294,51 +281,4 @@ describe('Darwin packaged Connect acceptance signature proof', () => { ); } }); - - test('rejects ad-hoc evidence even if the other mocked operations report success', () => { - assert.throws( - () => assertDarwinSigningEvidence(validEvidence({ - signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nSignature=adhoc`, - })), - isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), - ); - }); - - test('requires byte-exact embedded designated-requirement stability after reprobe', async () => { - await withPrivateEvidencePaths(async ({ - proofPath, initialCertificatePrefix, stableCertificatePrefix, - }) => { - await verifyDarwinPackagedConnectSignature({ - mode: 'establish', - application, - expectedCertificateSha1: selfSignedFingerprint, - proofPath, - certificatePrefix: initialCertificatePrefix, - runCommand: createCodesignSimulator().runCommand, - }); - await assert.rejects(verifyDarwinPackagedConnectSignature({ - mode: 'stable', - application, - expectedCertificateSha1: selfSignedFingerprint, - proofPath, - certificatePrefix: stableCertificatePrefix, - runCommand: createCodesignSimulator({ - designatedRequirement: `${requirementFor(selfSignedFingerprint.toLowerCase())}\n`, - }).runCommand, - }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); - }); - }); - - test('requires extraction prefixes to share the private proof directory', async () => { - await withPrivateEvidencePaths(async ({ proofPath }) => { - await assert.rejects(verifyDarwinPackagedConnectSignature({ - mode: 'establish', - application, - expectedCertificateSha1: selfSignedFingerprint, - proofPath, - certificatePrefix: join(tmpdir(), 'outside-certificate-'), - runCommand: createCodesignSimulator().runCommand, - }), 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}`, { From 07c97ade5b6d9eaeda8a8acd6883364b9c1ab995 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:48:03 +0000 Subject: [PATCH 13/16] feat(ai): Implemented the follow-up verifier fix. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the follow-up verifier fix. - Normalizes CRLF and trims lines, selects exactly one designated-requirement line, and validates an anchored whitespace/case-tolerant grammar with the exact identifier and certificate leaf—rejecting extra clauses. - Stores the normalized selected line plus one newline and compares it after reprobe. - Adds secret-safe `KEYCHAIN_EVIDENCE_FAILURE`, `SIGNATURE_METADATA_FAILURE`, and `REQUIREMENT_EVIDENCE_FAILURE` diagnostics. - Preserves all existing verifier commands, strict checks, cleanup, no-trust behavior, and platform isolation. - Added comprehensive regression coverage in [verify-darwin-packaged-connect-signature.test.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T16-40-32/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs:153). Validation: - Focused tests: 23/23 passed - Full desktop suite: 458 passed, 25 platform-specific skipped - ESLint: passed - `git diff --check`: passed The changed desktop paths trigger the existing native x64 and ARM64 full-journey workflow matrix when published. No visual preview was created because this is CI-only behavior. PR: #2108 Comment by: @integry (ID: 5543631504) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 12 +- ...rify-darwin-packaged-connect-signature.mjs | 78 ++++++---- ...darwin-packaged-connect-signature.test.mjs | 134 ++++++++++++++++-- 3 files changed, 181 insertions(+), 43 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 987f5e7f1..fc85af460 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -86,10 +86,13 @@ describe('packaged Connect target-native credential setup', () => { 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, /identifier "\$\{REQUIRED_IDENTIFIER\}" and certificate leaf = H"\$\{expectedSha1\}"/u); assert.match(darwinVerifier, /fingerprints\.length !== 1 \|\| fingerprints\[0\] !== expectedSha1/u); assert.match(darwinVerifier, /\^Signature size=\(\[1-9\]\\d\*\)\$/u); - assert.match(darwinVerifier, /requirementMatch\[1\]\.toUpperCase\(\) !== expected\.expectedSha1/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); @@ -97,7 +100,7 @@ describe('packaged Connect target-native credential setup', () => { 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 !== designatedRequirement/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); @@ -187,6 +190,9 @@ describe('packaged Connect target-native credential setup', () => { 'SIGNATURE_DISPLAY_FAILURE', 'EMBEDDED_REQUIREMENT_FAILURE', 'STRICT_VERIFY_FAILURE', + 'KEYCHAIN_EVIDENCE_FAILURE', + 'SIGNATURE_METADATA_FAILURE', + 'REQUIREMENT_EVIDENCE_FAILURE', 'EVIDENCE_ASSERTION_FAILURE', ]) { assert.match(darwinVerifier, new RegExp(`['"]${diagnostic}['"]`, 'u')); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index 63e88b59f..6b5b52d41 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -11,6 +11,8 @@ import { 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 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; @@ -20,6 +22,9 @@ export const DARWIN_VERIFICATION_DIAGNOSTICS = Object.freeze({ signatureDisplayFailure: 'SIGNATURE_DISPLAY_FAILURE', embeddedRequirementFailure: 'EMBEDDED_REQUIREMENT_FAILURE', strictVerifyFailure: 'STRICT_VERIFY_FAILURE', + keychainEvidenceFailure: 'KEYCHAIN_EVIDENCE_FAILURE', + signatureMetadataFailure: 'SIGNATURE_METADATA_FAILURE', + requirementEvidenceFailure: 'REQUIREMENT_EVIDENCE_FAILURE', evidenceAssertionFailure: 'EVIDENCE_ASSERTION_FAILURE', }); @@ -63,33 +68,23 @@ const expectedRequirementsFor = expectedCertificateSha1 => { }; const assertExactKeychainCertificate = (certificateDetails, expectedCertificateSha1) => { + const { expectedSha1 } = expectedRequirementsFor(expectedCertificateSha1); try { - const { expectedSha1 } = expectedRequirementsFor(expectedCertificateSha1); 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) { - if (cause instanceof DarwinSigningDiagnosticError - && cause.diagnostic === DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure) { - throw cause; - } throw verificationFailure( - DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.keychainEvidenceFailure, cause, ); } }; -export const assertDarwinSigningEvidence = ({ - expectedCertificateSha1, - signatureDetails, - designatedRequirement, - previousDesignatedRequirement, -}) => { +const assertSignatureMetadata = signatureDetails => { try { - const expected = expectedRequirementsFor(expectedCertificateSha1); const details = normalizeLines(signatureDetails).map(line => line.trim()); const identifiers = details.filter(line => line.startsWith('Identifier=')); const signatures = details.filter(line => line.startsWith('Signature')); @@ -102,30 +97,61 @@ export const assertDarwinSigningEvidence = ({ || identifiers[0] !== `Identifier=${REQUIRED_IDENTIFIER}`) { throw new Error('invalid-signature-display-evidence'); } - - const requirementMatch = designatedRequirement.match( - /^designated => identifier "dev\.propr\.desktop" and certificate leaf = H"([A-Fa-f0-9]{40})"\n$/u, + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure, + cause, ); - if (!requirementMatch || requirementMatch[1].toUpperCase() !== expected.expectedSha1) { + } +}; + +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 !== designatedRequirement) { + && previousDesignatedRequirement !== normalizedRequirement) { throw new Error('unstable-embedded-requirement-evidence'); } - return designatedRequirement; + return normalizedRequirement; } catch (cause) { - if (cause instanceof DarwinSigningDiagnosticError - && cause.diagnostic === DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure) { - throw cause; - } throw verificationFailure( - DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure, + DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure, cause, ); } }; +export const assertDarwinSigningEvidence = ({ + expectedCertificateSha1, + signatureDetails, + designatedRequirement, + previousDesignatedRequirement, +}) => { + const expected = expectedRequirementsFor(expectedCertificateSha1); + assertSignatureMetadata(signatureDetails); + return assertDesignatedRequirement( + designatedRequirement, + expected.expectedSha1, + previousDesignatedRequirement, + ); +}; + export const inspectDarwinSigningEvidence = async ({ application, keychain, @@ -163,8 +189,8 @@ export const inspectDarwinSigningEvidence = async ({ ); try { return { - signatureDetails: `${signatureResult.stdout}${signatureResult.stderr}`, - designatedRequirement: `${requirementResult.stdout}${requirementResult.stderr}`, + signatureDetails: `${signatureResult.stdout}\n${signatureResult.stderr}`, + designatedRequirement: `${requirementResult.stdout}\n${requirementResult.stderr}`, }; } catch (cause) { throw verificationFailure( diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index 233baabb1..8d24e1a64 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -65,8 +65,11 @@ const createVerifierSimulator = (overrides = {}) => { return { stdout: '', stderr: [ + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', `Identifier=${fixture.identifier}`, + 'Format=app bundle with Mach-O universal (x86_64 arm64)', fixture.signatureLine, + 'Info.plist entries=25', ].filter(value => value !== null).join('\n'), }; } @@ -147,7 +150,7 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); - test('accepts exactly the generated keychain fingerprint and byte-identical requirements', async () => { + 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 }); @@ -174,7 +177,7 @@ describe('Darwin packaged Connect acceptance signature proof', () => { await withPrivateProofPath(async proofPath => { await assert.rejects( verifyEstablish(proofPath, { certificateFingerprints }), - isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.keychainEvidenceFailure), ); }); } @@ -185,21 +188,27 @@ describe('Darwin packaged Connect acceptance signature proof', () => { await withPrivateProofPath(async proofPath => { await assert.rejects( verifyEstablish(proofPath, { signatureLine }), - isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure), ); }); } }); - test('rejects the wrong identifier and wrong embedded leaf', async () => { - for (const fixture of [ - { identifier: 'dev.other.desktop' }, - { designatedRequirement: `${requirementFor(otherFingerprint)}\n` }, + test('rejects the wrong signature identifier and embedded requirement leaf distinctly', async () => { + for (const [fixture, diagnostic] of [ + [ + { identifier: 'dev.other.desktop' }, + DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure, + ], + [ + { designatedRequirement: `${requirementFor(otherFingerprint)}\n` }, + DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure, + ], ]) { await withPrivateProofPath(async proofPath => { await assert.rejects( verifyEstablish(proofPath, fixture), - isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + isDiagnostic(diagnostic), ); }); } @@ -217,7 +226,7 @@ describe('Darwin packaged Connect acceptance signature proof', () => { runCommand: createVerifierSimulator({ designatedRequirement: `${requirementFor(fingerprint.toLowerCase())}\n`, }).runCommand, - }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure)); + }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure)); }); }); @@ -263,7 +272,34 @@ describe('Darwin packaged Connect acceptance signature proof', () => { }), isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureDisplayFailure)); }); - test('rejects missing, ambiguous, or non-exact display and requirement evidence', () => { + 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('rejects missing, ambiguous, or non-exact signature metadata distinctly', () => { for (const evidence of [ validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}` }), validEvidence({ signatureDetails: 'Signature size=1024' }), @@ -271,14 +307,84 @@ describe('Darwin packaged Connect acceptance signature proof', () => { signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, }), validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=01` }), - validEvidence({ designatedRequirement: '' }), - validEvidence({ designatedRequirement: `${requirementFor(fingerprint)}\r\n` }), - validEvidence({ designatedRequirement: `${requirementFor(fingerprint)}\nextra\n` }), ]) { assert.throws( () => assertDarwinSigningEvidence(evidence), - isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.evidenceAssertionFailure), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure), + ); + } + }); + + 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), + ); + }); }); From f79321e61e5ac23e119e491aeba4fe101ba106a6 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:58:39 +0000 Subject: [PATCH 14/16] feat(ai): Implemented the requested Darwin metadata-parser follow-up. Implemented the requested Darwin metadata-parser follow-up. - Removed all `Signature size` requirements. - Added whitespace/case-tolerant explicit `Signature=adhoc` rejection. - Made `Identifier=` optional, but exact when present; duplicate/conflicting lines fail. - Added fixed `ADHOC_SIGNATURE_FAILURE` and `IDENTIFIER_METADATA_FAILURE` diagnostics. - Preserved keychain SHA, embedded requirement, strict verification, pair/reprobe, cleanup, and platform isolation. - Added realistic missing-field, spacing/case, ad-hoc, duplicate/conflicting, and valid identifier tests. Changed files: - [Verifier](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T16-54-01/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs:14) - [Verifier tests](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T16-54-01/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs:186) - [Platform contracts](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T16-54-01/apps/desktop/scripts/packaged-connect-platform.test.mjs:87) Validation: - Focused tests: 25 passed - All desktop script tests: 206 passed, 24 platform-specific skipped - Syntax and `git diff --check`: passed Native Darwin x64/ARM64 execution requires the macOS CI runners. No visual preview was created because this is CI-only behavior. PR: #2108 Comment by: @integry (ID: 5543787926) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 9 +- ...rify-darwin-packaged-connect-signature.mjs | 43 ++++++---- ...darwin-packaged-connect-signature.test.mjs | 82 ++++++++++++------- 3 files changed, 87 insertions(+), 47 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index fc85af460..8cc9899a4 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -87,7 +87,10 @@ describe('packaged Connect target-native credential setup', () => { 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, /\^Signature size=\(\[1-9\]\\d\*\)\$/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.doesNotMatch(darwinVerifier, /Signature size=/u); assert.match(darwinVerifier, /DESIGNATED_REQUIREMENT_PREFIX/u); assert.match(darwinVerifier, /DESIGNATED_REQUIREMENT_GRAMMAR/u); assert.match(darwinVerifier, /designatedLines\.length !== 1/u); @@ -191,12 +194,14 @@ describe('packaged Connect target-native credential setup', () => { 'EMBEDDED_REQUIREMENT_FAILURE', 'STRICT_VERIFY_FAILURE', 'KEYCHAIN_EVIDENCE_FAILURE', - 'SIGNATURE_METADATA_FAILURE', + 'ADHOC_SIGNATURE_FAILURE', + 'IDENTIFIER_METADATA_FAILURE', 'REQUIREMENT_EVIDENCE_FAILURE', 'EVIDENCE_ASSERTION_FAILURE', ]) { assert.match(darwinVerifier, new RegExp(`['"]${diagnostic}['"]`, 'u')); } + assert.doesNotMatch(darwinVerifier, /SIGNATURE_METADATA_FAILURE/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); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index 6b5b52d41..6b05e091c 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -11,6 +11,8 @@ import { 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 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; @@ -23,7 +25,8 @@ export const DARWIN_VERIFICATION_DIAGNOSTICS = Object.freeze({ embeddedRequirementFailure: 'EMBEDDED_REQUIREMENT_FAILURE', strictVerifyFailure: 'STRICT_VERIFY_FAILURE', keychainEvidenceFailure: 'KEYCHAIN_EVIDENCE_FAILURE', - signatureMetadataFailure: 'SIGNATURE_METADATA_FAILURE', + adhocSignatureFailure: 'ADHOC_SIGNATURE_FAILURE', + identifierMetadataFailure: 'IDENTIFIER_METADATA_FAILURE', requirementEvidenceFailure: 'REQUIREMENT_EVIDENCE_FAILURE', evidenceAssertionFailure: 'EVIDENCE_ASSERTION_FAILURE', }); @@ -83,23 +86,32 @@ const assertExactKeychainCertificate = (certificateDetails, expectedCertificateS } }; -const assertSignatureMetadata = signatureDetails => { +const assertNotAdhocSignature = signatureDetails => { try { - const details = normalizeLines(signatureDetails).map(line => line.trim()); - const identifiers = details.filter(line => line.startsWith('Identifier=')); - const signatures = details.filter(line => line.startsWith('Signature')); - const signatureSize = signatures.length === 1 - ? signatures[0].match(/^Signature size=([1-9]\d*)$/u) - : null; - if (signatures.length !== 1 - || !signatureSize - || identifiers.length !== 1 - || identifiers[0] !== `Identifier=${REQUIRED_IDENTIFIER}`) { - throw new Error('invalid-signature-display-evidence'); + if (normalizeLines(signatureDetails).some(line => ADHOC_SIGNATURE_LINE.test(line))) { + throw new Error('ad-hoc-signature-evidence'); } } catch (cause) { throw verificationFailure( - DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure, + 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.length === 1 && identifiers[0] !== REQUIRED_IDENTIFIER)) { + throw new Error('invalid-identifier-display-evidence'); + } + } catch (cause) { + throw verificationFailure( + DARWIN_VERIFICATION_DIAGNOSTICS.identifierMetadataFailure, cause, ); } @@ -144,7 +156,8 @@ export const assertDarwinSigningEvidence = ({ previousDesignatedRequirement, }) => { const expected = expectedRequirementsFor(expectedCertificateSha1); - assertSignatureMetadata(signatureDetails); + assertNotAdhocSignature(signatureDetails); + assertIdentifierMetadata(signatureDetails); return assertDesignatedRequirement( designatedRequirement, expected.expectedSha1, diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index 8d24e1a64..026ccba1b 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -41,7 +41,7 @@ const createVerifierSimulator = (overrides = {}) => { const fixture = { signed: true, certificateFingerprints: [fingerprint], - identifier: REQUIRED_IDENTIFIER, + identifierLine: `Identifier=${REQUIRED_IDENTIFIER}`, signatureLine: 'Signature size=1024', designatedRequirement: `${requirementFor(fingerprint)}\n`, strictValid: true, @@ -66,7 +66,7 @@ const createVerifierSimulator = (overrides = {}) => { stdout: '', stderr: [ 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', - `Identifier=${fixture.identifier}`, + fixture.identifierLine, 'Format=app bundle with Mach-O universal (x86_64 arm64)', fixture.signatureLine, 'Info.plist entries=25', @@ -183,37 +183,60 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); - test('rejects ad-hoc, zero-size, and missing signature evidence', async () => { - for (const signatureLine of ['Signature=adhoc', 'Signature size=0', null]) { + 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.signatureMetadataFailure), + isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.adhocSignatureFailure), ); }); } }); - test('rejects the wrong signature identifier and embedded requirement leaf distinctly', async () => { - for (const [fixture, diagnostic] of [ - [ - { identifier: 'dev.other.desktop' }, - DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure, - ], - [ - { designatedRequirement: `${requirementFor(otherFingerprint)}\n` }, - DARWIN_VERIFICATION_DIAGNOSTICS.requirementEvidenceFailure, - ], + test('accepts missing signature size and optional valid identifier metadata variants', async () => { + for (const fixture of [ + { signatureLine: null }, + { signatureLine: 'Signature size=0' }, + { signatureLine: 'Signature size=01' }, + { identifierLine: `Identifier=${REQUIRED_IDENTIFIER}` }, + { identifierLine: ` iDeNtIfIeR = ${REQUIRED_IDENTIFIER} ` }, + { identifierLine: null, signatureLine: null }, ]) { await withPrivateProofPath(async proofPath => { - await assert.rejects( - verifyEstablish(proofPath, fixture), - isDiagnostic(diagnostic), - ); + await verifyEstablish(proofPath, fixture); }); } }); + test('rejects wrong, duplicate, and conflicting identifier metadata distinctly', async () => { + for (const signatureDetails of [ + '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 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); @@ -299,18 +322,17 @@ describe('Darwin packaged Connect acceptance signature proof', () => { }); }); - test('rejects missing, ambiguous, or non-exact signature metadata distinctly', () => { - for (const evidence of [ - validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}` }), - validEvidence({ signatureDetails: 'Signature size=1024' }), - validEvidence({ - signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nIdentifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, - }), - validEvidence({ signatureDetails: `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=01` }), + test('accepts realistic verbose metadata with omitted redundant fields', () => { + for (const signatureDetails of [ + '', + 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', + `Identifier=${REQUIRED_IDENTIFIER}`, + 'Signature size=1024', + `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\r\n IDENTIFIER = ${REQUIRED_IDENTIFIER} \r\nFormat=app bundle with Mach-O thin (arm64)`, ]) { - assert.throws( - () => assertDarwinSigningEvidence(evidence), - isDiagnostic(DARWIN_VERIFICATION_DIAGNOSTICS.signatureMetadataFailure), + assert.equal( + assertDarwinSigningEvidence(validEvidence({ signatureDetails })), + `${requirementFor(fingerprint)}\n`, ); } }); From bb67c1239479ddd822ec1363c37429dfbe08c989 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:08:28 +0000 Subject: [PATCH 15/16] feat(ai): Implemented the Darwin root-signing correction. Implemented the Darwin root-signing correction. - Root app now uses `--identifier dev.propr.desktop` and preserves only `entitlements,flags`; nested targets retain identifier preservation and never receive the root identifier. - Exact SHA, keychain, no-timestamp signing, root-only certificate requirement, inside-out order, and strict verification remain intact. - Verifier now requires exactly one root `Identifier=dev.propr.desktop` during initial and stable verification. - Added exact argv, identifier-evidence, and platform-contract regression tests. Files: [signer](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T17-01-56/apps/desktop/scripts/sign-darwin-packaged-connect.mjs:145), [verifier](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T17-01-56/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs:102) Validation: - Full desktop suite: 462 passed, 25 platform-specific skipped - `git diff --check`: passed - Native macOS x64/ARM64 journeys require the post-commit CI matrix and could not run on this Linux worker. No visual preview was generated because this is CI-only behavior. PR: #2108 Comment by: @integry (ID: 5543879385) Model: gpt-5.6-sol --- .../packaged-connect-platform.test.mjs | 12 +++++-- .../scripts/sign-darwin-packaged-connect.mjs | 10 ++++-- .../sign-darwin-packaged-connect.test.mjs | 33 +++++++++++++------ ...rify-darwin-packaged-connect-signature.mjs | 3 +- ...darwin-packaged-connect-signature.test.mjs | 33 +++++++++++++++---- 5 files changed, 68 insertions(+), 23 deletions(-) diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 8cc9899a4..e7457d7d9 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -82,13 +82,12 @@ describe('packaged Connect target-native credential setup', () => { assert.match(darwinSigner, /'--sign', certificateSha1/u); assert.match(darwinSigner, /'--keychain', keychain/u); assert.match(darwinSigner, /'--timestamp=none'/u); - assert.match(darwinSigner, /'--preserve-metadata=identifier,entitlements,flags'/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\.length !== 1/u); assert.match(darwinVerifier, /identifiers\[0\] !== REQUIRED_IDENTIFIER/u); assert.doesNotMatch(darwinVerifier, /Signature size=/u); assert.match(darwinVerifier, /DESIGNATED_REQUIREMENT_PREFIX/u); @@ -119,6 +118,15 @@ describe('packaged Connect target-native credential setup', () => { 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', diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs index 32ff330ea..b494d6076 100644 --- a/apps/desktop/scripts/sign-darwin-packaged-connect.mjs +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.mjs @@ -10,6 +10,7 @@ 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, @@ -141,7 +142,7 @@ export const signDarwinPackagedConnectApplication = async ({ certificateSha1, ); - const designatedRequirement = `designated => identifier "dev.propr.desktop" and certificate leaf = H"${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] @@ -159,7 +160,12 @@ export const signDarwinPackagedConnectApplication = async ({ '--force', '--keychain', keychain, '--timestamp=none', - '--preserve-metadata=identifier,entitlements,flags', + ...(isApplication ? [ + '--identifier', REQUIRED_IDENTIFIER, + '--preserve-metadata=entitlements,flags', + ] : [ + '--preserve-metadata=identifier,entitlements,flags', + ]), ...(isApplication ? [`-r=${designatedRequirement}`] : []), ...targetGroup, ]; diff --git a/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs index 8f58edba2..aea733459 100644 --- a/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs +++ b/apps/desktop/scripts/sign-darwin-packaged-connect.test.mjs @@ -68,20 +68,33 @@ describe('Darwin packaged Connect direct signing', () => { assert.ok(signedTargets.indexOf(helper) < signedTargets.indexOf(mainExecutable)); for (const call of signingCalls) { assert.equal(call.executable, '/usr/bin/codesign'); - assert.deepEqual(call.arguments.slice(0, 7), [ - '--sign', fingerprint, - '--force', - '--keychain', keychain, - '--timestamp=none', - '--preserve-metadata=identifier,entitlements,flags', - ]); assert.equal(call.forwardOutput, false); assert.equal(call.timeoutMs, 30_000); assert.equal(call.terminationGraceMs, 1_000); } - assert.ok(signingCalls.at(-1).arguments.includes( - `-r=designated => identifier "dev.propr.desktop" and certificate leaf = H"${fingerprint}"`, - )); + 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, ]); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index 6b05e091c..e8d7bc3af 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -105,8 +105,7 @@ const assertIdentifierMetadata = signatureDetails => { .map(line => line.match(IDENTIFIER_LINE)) .filter(match => match !== null) .map(match => match[1]); - if (identifiers.length > 1 - || (identifiers.length === 1 && identifiers[0] !== REQUIRED_IDENTIFIER)) { + if (identifiers.length !== 1 || identifiers[0] !== REQUIRED_IDENTIFIER) { throw new Error('invalid-identifier-display-evidence'); } } catch (cause) { diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index 026ccba1b..9c06b2378 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -197,14 +197,13 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); - test('accepts missing signature size and optional valid identifier metadata variants', async () => { + test('accepts missing signature size with exact identifier metadata variants', async () => { for (const fixture of [ { signatureLine: null }, { signatureLine: 'Signature size=0' }, { signatureLine: 'Signature size=01' }, { identifierLine: `Identifier=${REQUIRED_IDENTIFIER}` }, { identifierLine: ` iDeNtIfIeR = ${REQUIRED_IDENTIFIER} ` }, - { identifierLine: null, signatureLine: null }, ]) { await withPrivateProofPath(async proofPath => { await verifyEstablish(proofPath, fixture); @@ -212,8 +211,11 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); - test('rejects wrong, duplicate, and conflicting identifier metadata distinctly', async () => { + test('rejects missing, wrong, duplicate, and conflicting identifier metadata distinctly', async () => { 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}`, @@ -226,6 +228,24 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); + 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('rejects the wrong embedded requirement leaf distinctly', async () => { await withPrivateProofPath(async proofPath => { await assert.rejects( @@ -322,12 +342,11 @@ describe('Darwin packaged Connect acceptance signature proof', () => { }); }); - test('accepts realistic verbose metadata with omitted redundant fields', () => { + test('accepts realistic verbose metadata with an exact identifier and omitted signature size', () => { for (const signatureDetails of [ - '', - 'Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop', `Identifier=${REQUIRED_IDENTIFIER}`, - 'Signature size=1024', + `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\nIdentifier=${REQUIRED_IDENTIFIER}`, + `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\r\n IDENTIFIER = ${REQUIRED_IDENTIFIER} \r\nFormat=app bundle with Mach-O thin (arm64)`, ]) { assert.equal( From 7704927180e3867e8b2a5b453a6b150f7b2d8e3e Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:15:42 +0000 Subject: [PATCH 16/16] feat(ai): Implemented the PR #2108 follow-up without committing. Implemented the PR #2108 follow-up without committing. - Enforced exactly one `Identifier=dev.propr.desktop` and one positive numeric `Signature size`, with fixed `SIGNATURE_METADATA_FAILURE` diagnostics while retaining ad-hoc rejection in [verify-darwin-packaged-connect-signature.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T17-08-42/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs:14). - Added all requested negative regressions in [verify-darwin-packaged-connect-signature.test.mjs](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T17-08-42/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs:186). - Corrected the self-signed `CA:false` leaf wording in [README.md](/tmp/git-processor/worktrees/integry/propr/pr-2108-followup-2026-09-04T17-08-42/apps/desktop/README.md:47). Validation: - Full desktop suite: 463 passed, 25 platform-specific skipped. - Focused signing/verifier suite: 33/33 passed. - Syntax, ESLint, and `git diff --check` passed. - Native macOS execution was unavailable on this Linux worker. - No visual preview created because the change is CI/documentation-only. PR: #2108 Comment by: @integry (ID: 5543908141) Model: gpt-5.6-sol --- apps/desktop/README.md | 4 +- .../packaged-connect-platform.test.mjs | 5 +- ...rify-darwin-packaged-connect-signature.mjs | 21 +++++++ ...darwin-packaged-connect-signature.test.mjs | 60 +++++++++++++------ 4 files changed, 68 insertions(+), 22 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2698bd5ec..c68462b50 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,8 +44,8 @@ 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 root and -code-signing certificate in an isolated default keychain and signs only that smoke artifact. The signature uses an +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 diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index e7457d7d9..a37981688 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -89,7 +89,8 @@ describe('packaged Connect target-native credential setup', () => { 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.doesNotMatch(darwinVerifier, /Signature size=/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); @@ -204,12 +205,12 @@ describe('packaged Connect target-native credential setup', () => { '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.doesNotMatch(darwinVerifier, /SIGNATURE_METADATA_FAILURE/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); diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs index e8d7bc3af..9de49e00e 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.mjs @@ -13,6 +13,8 @@ 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; @@ -27,6 +29,7 @@ export const DARWIN_VERIFICATION_DIAGNOSTICS = Object.freeze({ keychainEvidenceFailure: 'KEYCHAIN_EVIDENCE_FAILURE', adhocSignatureFailure: 'ADHOC_SIGNATURE_FAILURE', identifierMetadataFailure: 'IDENTIFIER_METADATA_FAILURE', + signatureMetadataFailure: 'SIGNATURE_METADATA_FAILURE', requirementEvidenceFailure: 'REQUIREMENT_EVIDENCE_FAILURE', evidenceAssertionFailure: 'EVIDENCE_ASSERTION_FAILURE', }); @@ -116,6 +119,23 @@ const assertIdentifierMetadata = signatureDetails => { } }; +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, @@ -157,6 +177,7 @@ export const assertDarwinSigningEvidence = ({ const expected = expectedRequirementsFor(expectedCertificateSha1); assertNotAdhocSignature(signatureDetails); assertIdentifierMetadata(signatureDetails); + assertSignatureMetadata(signatureDetails); return assertDesignatedRequirement( designatedRequirement, expected.expectedSha1, diff --git a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs index 9c06b2378..490f4a4ae 100644 --- a/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs +++ b/apps/desktop/scripts/verify-darwin-packaged-connect-signature.test.mjs @@ -197,23 +197,15 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); - test('accepts missing signature size with exact identifier metadata variants', async () => { - for (const fixture of [ - { signatureLine: null }, - { signatureLine: 'Signature size=0' }, - { signatureLine: 'Signature size=01' }, - { identifierLine: `Identifier=${REQUIRED_IDENTIFIER}` }, - { identifierLine: ` iDeNtIfIeR = ${REQUIRED_IDENTIFIER} ` }, - ]) { - await withPrivateProofPath(async proofPath => { - await verifyEstablish(proofPath, fixture); - }); - } + 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', async () => { + 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', @@ -228,6 +220,21 @@ describe('Darwin packaged Connect acceptance signature proof', () => { } }); + 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( @@ -246,6 +253,24 @@ describe('Darwin packaged Connect acceptance signature proof', () => { }); }); + 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( @@ -342,12 +367,11 @@ describe('Darwin packaged Connect acceptance signature proof', () => { }); }); - test('accepts realistic verbose metadata with an exact identifier and omitted signature size', () => { + test('accepts realistic verbose metadata with exactly one identifier and positive signature size', () => { for (const signatureDetails of [ - `Identifier=${REQUIRED_IDENTIFIER}`, - `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\nIdentifier=${REQUIRED_IDENTIFIER}`, `Identifier=${REQUIRED_IDENTIFIER}\nSignature size=1024`, - `Executable=/private/tmp/propr-desktop.app/Contents/MacOS/propr-desktop\r\n IDENTIFIER = ${REQUIRED_IDENTIFIER} \r\nFormat=app bundle with Mach-O thin (arm64)`, + `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 })),