From 2455ebdfd81f06f786076e5eb4f81297a3fd31b8 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Sat, 27 Jun 2026 22:17:51 -0700 Subject: [PATCH] Verify the inner Orca.exe is Authenticode-signed in the Windows release pipeline CI hardening prerequisite for issue #6487 (Windows Smart App Control). The release pipeline only signed+verified the outer NSIS installer; nothing asserted the inner extracted Orca.exe carried a valid Authenticode signature. SAC evaluates that inner executable, so an unsigned Orca.exe could still ship inside a signed installer. Adds a cross-platform parsing module + unit tests and a release-cut.yml gate that extracts the inner Orca.exe from the signed installer and fails the release if it is not validly signed by the expected identity. The actual recursive PE signing must be enabled server-side in SignPath's artifact configuration; documented in the new gate and the script header. --- .github/workflows/release-cut.yml | 45 ++++ .../verify-windows-inner-signature.mjs | 202 ++++++++++++++++++ .../verify-windows-inner-signature.test.mjs | 149 +++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 config/scripts/verify-windows-inner-signature.mjs create mode 100644 config/scripts/verify-windows-inner-signature.test.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 11d09a2e576..1ea9277e032 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1094,6 +1094,51 @@ jobs: } $signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint + # Why: Smart App Control (issue #6487) evaluates the inner Orca.exe, not + # just the outer installer. SignPath must be configured to RECURSIVELY + # sign nested PE files — its "github-actions-windows-installer" artifact + # configuration has to declare the inner Orca.exe (inside app-64.7z) as a + # signable element. That config lives in the SignPath dashboard, not this + # repo. This step is the release gate that fails if recursive signing did + # not actually land on Orca.exe, so an unsigned inner binary cannot ship. + - name: Verify signed inner Orca.exe (Smart App Control prerequisite) + if: matrix.platform == 'win' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $extractRoot = Join-Path $env:RUNNER_TEMP 'inner-exe-verify' + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null + + # Why: electron-builder NSIS installers embed the packaged app in a + # nested app-64.7z; 7-Zip (preinstalled on windows runners) unpacks the + # NSIS shell first, then the inner 7z, exposing Orca.exe on disk. + # Why: native commands do not honor $ErrorActionPreference, so check + # $LASTEXITCODE explicitly to surface a failed extraction clearly. + & 7z x 'dist/orca-windows-setup.exe' "-o$extractRoot" -y | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "7z failed to extract the NSIS installer (exit $LASTEXITCODE)." + } + $innerArchive = Get-ChildItem -Path $extractRoot -Recurse -File -Filter 'app-64.7z' | Select-Object -First 1 + if ($null -ne $innerArchive) { + $appRoot = Join-Path $extractRoot 'app' + & 7z x $innerArchive.FullName "-o$appRoot" -y | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "7z failed to extract the inner app-64.7z payload (exit $LASTEXITCODE)." + } + } + + $innerExe = Get-ChildItem -Path $extractRoot -Recurse -File -Filter 'Orca.exe' | Select-Object -First 1 + if ($null -eq $innerExe) { + throw 'Could not locate inner Orca.exe inside the NSIS installer payload.' + } + + node config/scripts/verify-windows-inner-signature.mjs $innerExe.FullName + env: + ORCA_WINDOWS_EXPECTED_SIGNER: SignPath Foundation + - name: Publish signed Windows release artifacts if: matrix.platform == 'win' uses: nick-fields/retry@v4 diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs new file mode 100644 index 00000000000..0307845b34d --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +// Why: Windows Smart App Control (issue #6487) evaluates the inner application +// executable (Orca.exe), not just the outer NSIS installer. The release pipeline +// signs and verifies orca-windows-setup.exe, but nothing asserted that the +// Orca.exe extracted from that installer carries a valid Authenticode signature. +// SignPath must be configured server-side to recursively sign nested PE files +// (its artifact configuration must declare the inner exe / app-64.7z payload as +// signable; that lives in the SignPath dashboard, not this repo). This script is +// the CI gate that fails the release if recursive signing did not actually land +// on Orca.exe, so an unsigned inner binary can never reach users. + +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +// Why: signtool ships a localized "Successfully verified" line, but the +// structured, scriptable signal is the per-file "Number of ... errors: N" +// summary, which is emitted in the invariant (English) form regardless of UI +// language. We treat zero signing-cert errors as the success condition. +const SIGNTOOL_SUCCESS_PATTERN = /Successfully verified:/i +const SIGNTOOL_ERROR_COUNT_PATTERN = /Number of errors:\s*(\d+)/i + +/** + * Parse `signtool verify /pa /v ` output into a normalized result. + * `signtool` exits non-zero on failure, so callers should also honor the exit + * code; this parser exists so the verdict + signer can be asserted in tests and + * surfaced in logs without depending on a live signed binary. + * + * @param {string} output combined stdout/stderr from signtool + * @returns {{ valid: boolean, signerSubject: string | null }} + */ +export function parseSigntoolOutput(output) { + const text = String(output ?? '') + const errorCountMatch = text.match(SIGNTOOL_ERROR_COUNT_PATTERN) + const errorCount = errorCountMatch ? Number.parseInt(errorCountMatch[1], 10) : null + // Why: prefer the explicit error count when signtool prints it; otherwise fall + // back to the success line so non-verbose invocations still parse. + const valid = + errorCount === 0 || (errorCount === null && SIGNTOOL_SUCCESS_PATTERN.test(text)) + return { + valid, + signerSubject: extractSigntoolSignerSubject(text) + } +} + +function extractSigntoolSignerSubject(text) { + // signtool /v prints "Issued to: " for the leaf signing cert. + const issuedTo = text.match(/Issued to:\s*(.+?)\s*$/im) + if (issuedTo) { + return issuedTo[1].trim() + } + return null +} + +/** + * Parse a JSON object emitted by PowerShell's Get-AuthenticodeSignature + * (`... | Select-Object Status,@{...SignerCertificate.Subject...} | ConvertTo-Json`). + * PowerShell's Authenticode Status enum is `Valid` (0) when the signature chains + * to a trusted root and matches the file hash. + * + * @param {unknown} parsed already-JSON-parsed Get-AuthenticodeSignature object + * @returns {{ valid: boolean, signerSubject: string | null, status: string | null }} + */ +export function parseAuthenticodeSignature(parsed) { + if (parsed === null || typeof parsed !== 'object') { + return { valid: false, signerSubject: null, status: null } + } + const record = /** @type {Record} */ (parsed) + const status = typeof record.Status === 'string' ? record.Status : null + const signerSubject = + typeof record.SignerSubject === 'string' && record.SignerSubject.length > 0 + ? record.SignerSubject + : null + return { + valid: status === 'Valid', + signerSubject, + status + } +} + +/** + * Assert that a parsed inner-signature result is acceptable for release. + * `expectedSignerSubstring` guards against a valid-but-wrong-signer regression + * (e.g. an ad-hoc or developer cert), mirroring the outer-installer gate that + * pins `CN=SignPath Foundation`. + * + * @param {{ valid: boolean, signerSubject: string | null }} result + * @param {{ expectedSignerSubstring?: string | null }} [options] + * @returns {{ ok: boolean, reason: string | null }} + */ +export function evaluateInnerSignature(result, options = {}) { + const expected = options.expectedSignerSubstring ?? null + if (!result.valid) { + return { + ok: false, + reason: 'Inner Orca.exe does not carry a valid Authenticode signature.' + } + } + if (expected) { + const subject = result.signerSubject ?? '' + if (!subject.toLowerCase().includes(expected.toLowerCase())) { + return { + ok: false, + reason: `Inner Orca.exe signer "${result.signerSubject ?? ''}" does not contain expected "${expected}".` + } + } + } + return { ok: true, reason: null } +} + +function verifyWithSigntool(filePath) { + // Why: /pa uses the "Default Authentication Verification Policy" (the same + // chain SAC evaluates); /v gives the verbose "Issued to" signer line. + const output = execFileSync('signtool', ['verify', '/pa', '/v', filePath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + return parseSigntoolOutput(output) +} + +function verifyWithPowerShell(filePath) { + const script = [ + '$ErrorActionPreference = "Stop"', + `$sig = Get-AuthenticodeSignature -LiteralPath "${filePath.replace(/"/g, '`"')}"`, + '$obj = [PSCustomObject]@{', + ' Status = $sig.Status.ToString()', + ' SignerSubject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { $null }', + '}', + '$obj | ConvertTo-Json -Compress' + ].join('\n') + const output = execFileSync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + return parseAuthenticodeSignature(JSON.parse(output)) +} + +/** + * Run the available platform verifier against a PE file. Prefers signtool (the + * tool SignPath/electron-builder use), falling back to PowerShell's + * Get-AuthenticodeSignature. Only meaningful on Windows runners. + * + * @param {string} filePath + * @returns {{ valid: boolean, signerSubject: string | null }} + */ +export function verifyInnerExecutableSignature(filePath) { + if (!existsSync(filePath)) { + throw new Error(`Inner executable not found at ${filePath}`) + } + if (process.platform !== 'win32') { + throw new Error( + 'verifyInnerExecutableSignature must run on a Windows runner (signtool/PowerShell required).' + ) + } + try { + return verifyWithSigntool(filePath) + } catch (signtoolError) { + try { + return verifyWithPowerShell(filePath) + } catch (powershellError) { + throw new Error( + `Unable to verify Authenticode signature for ${filePath}. signtool: ${ + signtoolError instanceof Error ? signtoolError.message : signtoolError + }; powershell: ${ + powershellError instanceof Error ? powershellError.message : powershellError + }` + ) + } + } +} + +function main() { + const filePath = process.argv[2] + if (!filePath) { + throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs ') + } + const expectedSignerSubstring = + process.env.ORCA_WINDOWS_EXPECTED_SIGNER || 'SignPath Foundation' + const result = verifyInnerExecutableSignature(filePath) + const verdict = evaluateInnerSignature(result, { expectedSignerSubstring }) + if (!verdict.ok) { + throw new Error( + `${verdict.reason}\nSigner subject: ${result.signerSubject ?? ''}\n` + + 'SignPath must be configured to recursively sign nested PE files ' + + '(the inner Orca.exe / app-64.7z payload) in its artifact configuration.' + ) + } + console.log( + `Verified inner Orca.exe Authenticode signature (signer: ${result.signerSubject ?? ''}).` + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main() + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) + } +} diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs new file mode 100644 index 00000000000..281ff7e7541 --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.test.mjs @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { + evaluateInnerSignature, + parseAuthenticodeSignature, + parseSigntoolOutput +} from './verify-windows-inner-signature.mjs' + +describe('parseSigntoolOutput', () => { + it('treats zero errors with a signer line as valid', () => { + const output = [ + 'Verifying: Orca.exe', + '', + 'Signature Index: 0 (Primary Signature)', + 'Hash of file (sha256): ABC123', + '', + 'Signing Certificate Chain:', + ' Issued to: SignPath Foundation', + ' Issued by: SSL.com Code Signing Intermediate CA RSA R1', + '', + 'Successfully verified: Orca.exe', + '', + 'Number of files successfully Verified: 1', + 'Number of warnings: 0', + 'Number of errors: 0' + ].join('\r\n') + + expect(parseSigntoolOutput(output)).toEqual({ + valid: true, + signerSubject: 'SignPath Foundation' + }) + }) + + it('treats a nonzero error count as invalid', () => { + const output = [ + 'Verifying: Orca.exe', + 'SignTool Error: No signature found.', + '', + 'Number of errors: 1' + ].join('\r\n') + + expect(parseSigntoolOutput(output)).toEqual({ + valid: false, + signerSubject: null + }) + }) + + it('falls back to the success line when no error count is printed', () => { + const output = ['Successfully verified: Orca.exe'].join('\n') + expect(parseSigntoolOutput(output)).toEqual({ + valid: true, + signerSubject: null + }) + }) + + it('reports invalid for empty or missing output', () => { + expect(parseSigntoolOutput('')).toEqual({ valid: false, signerSubject: null }) + expect(parseSigntoolOutput(undefined)).toEqual({ valid: false, signerSubject: null }) + }) +}) + +describe('parseAuthenticodeSignature', () => { + it('maps a Valid status to a valid result and extracts the signer subject', () => { + expect( + parseAuthenticodeSignature({ + Status: 'Valid', + SignerSubject: 'CN=SignPath Foundation, O=SignPath Foundation, C=US' + }) + ).toEqual({ + valid: true, + signerSubject: 'CN=SignPath Foundation, O=SignPath Foundation, C=US', + status: 'Valid' + }) + }) + + it('maps a NotSigned status to invalid', () => { + expect(parseAuthenticodeSignature({ Status: 'NotSigned', SignerSubject: null })).toEqual({ + valid: false, + signerSubject: null, + status: 'NotSigned' + }) + }) + + it('handles HashMismatch (tampered binary) as invalid', () => { + expect( + parseAuthenticodeSignature({ Status: 'HashMismatch', SignerSubject: 'CN=Whoever' }) + ).toEqual({ + valid: false, + signerSubject: 'CN=Whoever', + status: 'HashMismatch' + }) + }) + + it('tolerates non-object input', () => { + expect(parseAuthenticodeSignature(null)).toEqual({ + valid: false, + signerSubject: null, + status: null + }) + expect(parseAuthenticodeSignature('not-an-object')).toEqual({ + valid: false, + signerSubject: null, + status: null + }) + }) +}) + +describe('evaluateInnerSignature', () => { + it('passes when valid and the signer matches the expected substring', () => { + expect( + evaluateInnerSignature( + { valid: true, signerSubject: 'CN=SignPath Foundation, O=SignPath Foundation' }, + { expectedSignerSubstring: 'SignPath Foundation' } + ) + ).toEqual({ ok: true, reason: null }) + }) + + it('matches the expected signer case-insensitively', () => { + expect( + evaluateInnerSignature( + { valid: true, signerSubject: 'CN=signpath foundation' }, + { expectedSignerSubstring: 'SignPath Foundation' } + ).ok + ).toBe(true) + }) + + it('fails when the signature is invalid even if a subject is present', () => { + const verdict = evaluateInnerSignature( + { valid: false, signerSubject: 'CN=SignPath Foundation' }, + { expectedSignerSubstring: 'SignPath Foundation' } + ) + expect(verdict.ok).toBe(false) + expect(verdict.reason).toMatch(/valid Authenticode signature/) + }) + + it('fails a valid signature signed by an unexpected identity', () => { + const verdict = evaluateInnerSignature( + { valid: true, signerSubject: 'CN=Some Dev Cert' }, + { expectedSignerSubstring: 'SignPath Foundation' } + ) + expect(verdict.ok).toBe(false) + expect(verdict.reason).toMatch(/does not contain expected/) + }) + + it('passes a valid signature when no expected signer is configured', () => { + expect( + evaluateInnerSignature({ valid: true, signerSubject: null }).ok + ).toBe(true) + }) +})