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) + }) +})