diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 5b595170296..4362b6ed781 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -141,6 +141,8 @@ jobs: node --check config/scripts/ssh-relay-runtime-macos-signature-verification.test.mjs node --check config/scripts/ssh-relay-runtime-windows-signature-verification.mjs node --check config/scripts/ssh-relay-runtime-windows-signature-verification.test.mjs + node --check config/scripts/ssh-relay-runtime-windows-source-signature-verification.mjs + node --check config/scripts/ssh-relay-runtime-windows-source-signature-verification.test.mjs pnpm exec vitest run --config config/vitest.config.ts \ config/scripts/ssh-relay-node-release-verification.test.mjs \ config/scripts/ssh-relay-node-tar-inspection.test.mjs \ @@ -161,6 +163,7 @@ jobs: config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs \ config/scripts/ssh-relay-runtime-macos-signature-verification.test.mjs \ config/scripts/ssh-relay-runtime-windows-signature-verification.test.mjs \ + config/scripts/ssh-relay-runtime-windows-source-signature-verification.test.mjs \ config/scripts/ssh-relay-runtime-closure.test.mjs \ config/scripts/ssh-relay-runtime-pty-smoke.test.mjs \ config/scripts/ssh-relay-runtime-reproducibility.test.mjs \ @@ -472,6 +475,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'runtime Windows signature verification script syntax check failed' } node --check config/scripts/ssh-relay-runtime-windows-signature-verification.test.mjs if ($LASTEXITCODE -ne 0) { throw 'runtime Windows signature verification test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-windows-source-signature-verification.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime Windows source signature verification script syntax check failed' } + node --check config/scripts/ssh-relay-runtime-windows-source-signature-verification.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime Windows source signature verification test syntax check failed' } pnpm exec vitest run --config config/vitest.config.ts ` config/scripts/ssh-relay-node-release-verification.test.mjs ` config/scripts/ssh-relay-node-tar-inspection.test.mjs ` @@ -492,6 +499,7 @@ jobs: config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs ` config/scripts/ssh-relay-runtime-macos-signature-verification.test.mjs ` config/scripts/ssh-relay-runtime-windows-signature-verification.test.mjs ` + config/scripts/ssh-relay-runtime-windows-source-signature-verification.test.mjs ` config/scripts/ssh-relay-runtime-closure.test.mjs ` config/scripts/ssh-relay-runtime-pty-smoke.test.mjs ` config/scripts/ssh-relay-runtime-reproducibility.test.mjs ` @@ -639,6 +647,28 @@ jobs: [System.Text.UTF8Encoding]::new($false) ) $reportJson + $sourceSignatureReport = Join-Path $outputRoot 'orca-ssh-relay-runtime-${{ matrix.tuple }}.source-signatures.json' + $sourceSignatureLines = & node config/scripts/ssh-relay-runtime-windows-source-signature-verification.mjs ` + --identity (Join-Path $firstOutput 'orca-ssh-relay-runtime-${{ matrix.tuple }}.identity.json') ` + --runtime-directory (Join-Path $firstOutput 'runtime') ` + --signing-report $signingReport + if ($LASTEXITCODE -ne 0) { throw 'runtime Windows source signature verification failed' } + $sourceSignatureJson = $sourceSignatureLines -join [Environment]::NewLine + $sourceSignature = $sourceSignatureJson | ConvertFrom-Json + if ( + $sourceSignature.tupleId -ne '${{ matrix.tuple }}' -or + @($sourceSignature.verifiedFiles).Count -ne 3 -or + @($sourceSignature.verifiedFiles | Where-Object { $_.signerKind -eq 'official-node' }).Count -ne 1 -or + @($sourceSignature.verifiedFiles | Where-Object { $_.signerKind -eq 'preserved-upstream' }).Count -ne 2 + ) { + throw 'Windows source signature report violates the immutable/preserved trust contract' + } + [System.IO.File]::WriteAllText( + $sourceSignatureReport, + "$sourceSignatureJson`n", + [System.Text.UTF8Encoding]::new($false) + ) + $sourceSignatureJson Remove-Item -LiteralPath $signingStage -Recurse -Force $evidence = Join-Path $env:GITHUB_WORKSPACE 'runtime-evidence/${{ matrix.tuple }}' New-Item -ItemType Directory -Path $evidence -Force | Out-Null @@ -648,6 +678,7 @@ jobs: Where-Object { $_.Name -match '\.(?:spdx|provenance|identity)\.json$' } | Copy-Item -Destination $evidence Copy-Item -LiteralPath $signingReport -Destination $evidence + Copy-Item -LiteralPath $sourceSignatureReport -Destination $evidence - name: Upload unpublished artifact evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/config/scripts/ssh-relay-runtime-windows-signature-verification.mjs b/config/scripts/ssh-relay-runtime-windows-signature-verification.mjs index ae7f5f37d1b..0531ffa27b0 100644 --- a/config/scripts/ssh-relay-runtime-windows-signature-verification.mjs +++ b/config/scripts/ssh-relay-runtime-windows-signature-verification.mjs @@ -114,7 +114,13 @@ function assertSignerPolicy(signature, signerKind, expectedSigner) { return classified } -async function verifySignedFile({ path, entry, signerKind, expectedSigner, spawnSyncImpl }) { +export async function verifySshRelayRuntimeWindowsSignatureTarget({ + path, + entry, + signerKind, + expectedSigner, + spawnSyncImpl = spawnSync +}) { const metadata = await lstat(path) if (metadata.isSymbolicLink() || !metadata.isFile()) { throw new Error(`Runtime Windows signature target is not a regular file: ${entry.path}`) @@ -191,7 +197,7 @@ export async function verifySshRelayRuntimeWindowsSignatures({ const verifiedFiles = [] for (const target of verificationTargets(finalIdentity, selection)) { verifiedFiles.push( - await verifySignedFile({ + await verifySshRelayRuntimeWindowsSignatureTarget({ path: localPath(physicalRoot, target.entry.path), ...target, spawnSyncImpl diff --git a/config/scripts/ssh-relay-runtime-windows-source-signature-verification.mjs b/config/scripts/ssh-relay-runtime-windows-source-signature-verification.mjs new file mode 100644 index 00000000000..39eedb6cfdd --- /dev/null +++ b/config/scripts/ssh-relay-runtime-windows-source-signature-verification.mjs @@ -0,0 +1,192 @@ +import { spawnSync } from 'node:child_process' +import { lstat, readFile, realpath } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { isDeepStrictEqual } from 'node:util' + +import { readSshRelayRuntimeNativeSigningIdentity } from './ssh-relay-runtime-native-signing-plan.mjs' +import { + assertSshRelayRuntimeNativeSigningSelection, + buildSshRelayRuntimeNativeSigningSelection +} from './ssh-relay-runtime-native-signing-selection.mjs' +import { verifySshRelayRuntimeWindowsSignatureTarget } from './ssh-relay-runtime-windows-signature-verification.mjs' +import { verifyRuntimeTree } from './verify-ssh-relay-runtime.mjs' + +const MAX_SIGNING_REPORT_BYTES = 4 * 1024 * 1024 +const REPORT_FIELDS = [ + 'assessments', + 'immutableVendorFiles', + 'payload', + 'platform', + 'policy', + 'preservedUpstreamFiles', + 'signingFiles', + 'tupleId' +] +const ARGUMENT_FIELDS = new Map([ + ['--identity', 'identityPath'], + ['--runtime-directory', 'runtimeRoot'], + ['--signing-report', 'signingReportPath'] +]) + +function assertExactFields(value, expected, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Runtime Windows source signature ${label} must be an object`) + } + const actual = Object.keys(value).sort() + if ( + actual.length !== expected.length || + actual.some((field, index) => field !== expected[index]) + ) { + throw new Error(`Runtime Windows source signature ${label} has unexpected fields`) + } +} + +export function parseSshRelayRuntimeWindowsSourceSignatureArguments(argv) { + const result = {} + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index] + const field = ARGUMENT_FIELDS.get(flag) + const value = argv[index + 1] + if (!field) { + throw new Error(`Unknown runtime Windows source signature argument: ${flag}`) + } + if (result[field]) { + throw new Error(`Duplicate runtime Windows source signature argument: ${flag}`) + } + if (!value || value.startsWith('--')) { + throw new Error(`Runtime Windows source signature ${flag} requires a value`) + } + result[field] = resolve(value) + } + for (const field of ARGUMENT_FIELDS.values()) { + if (!result[field]) { + throw new Error(`Missing required runtime Windows source signature argument: ${field}`) + } + } + return result +} + +export async function readSshRelayRuntimeWindowsSigningStageReport(path) { + const metadata = await lstat(path) + if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_SIGNING_REPORT_BYTES) { + throw new Error('Runtime Windows signing-stage report must be one bounded regular file') + } + const source = await readFile(path, 'utf8') + try { + return JSON.parse(source) + } catch (error) { + throw new Error(`Runtime Windows signing-stage report is not valid JSON: ${error.message}`) + } +} + +export function selectionFromSshRelayRuntimeWindowsSigningStageReport(identity, report) { + assertExactFields(report, REPORT_FIELDS, 'signing-stage report') + if (!report.payload || typeof report.payload !== 'object' || Array.isArray(report.payload)) { + throw new Error('Runtime Windows signing-stage report has malformed payload evidence') + } + const selection = buildSshRelayRuntimeNativeSigningSelection(identity, report.assessments) + const reportSelection = { + tupleId: report.tupleId, + platform: report.platform, + policy: report.policy, + immutableVendorFiles: report.immutableVendorFiles, + signingFiles: report.signingFiles, + preservedUpstreamFiles: report.preservedUpstreamFiles + } + const expected = { + tupleId: selection.tupleId, + platform: selection.platform, + policy: selection.policy, + immutableVendorFiles: selection.immutableVendorFiles, + signingFiles: selection.signingFiles, + preservedUpstreamFiles: selection.preservedUpstreamFiles + } + if (!isDeepStrictEqual(reportSelection, expected)) { + // Why: retained signature evidence must describe the same hash-bound selection used for staging. + throw new Error('Runtime Windows signing-stage report and authenticated selection disagree') + } + return selection +} + +function localPath(root, portablePath) { + return resolve(root, ...portablePath.split('/')) +} + +export async function verifySshRelayRuntimeWindowsSourceSignatures({ + runtimeRoot, + identity, + selection, + platform = process.platform, + spawnSyncImpl = spawnSync +}) { + if (platform !== 'win32') { + throw new Error('Runtime Windows source signature verification requires Windows') + } + assertSshRelayRuntimeNativeSigningSelection(identity, selection) + const rootMetadata = await lstat(resolve(runtimeRoot)) + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new Error('Runtime Windows source signature verification requires a real runtime root') + } + const physicalRoot = await realpath(resolve(runtimeRoot)) + // Why: native trust probes run only after the entire unsigned source tree matches its identity. + await verifyRuntimeTree(physicalRoot, identity) + + const sourceFiles = new Map( + identity.entries.filter((entry) => entry.type === 'file').map((entry) => [entry.path, entry]) + ) + const targets = [ + ...selection.immutableVendorFiles.map((expectedSigner) => ({ + expectedSigner, + signerKind: 'official-node' + })), + ...selection.preservedUpstreamFiles.map((expectedSigner) => ({ + expectedSigner, + signerKind: 'preserved-upstream' + })) + ] + const verifiedFiles = [] + for (const target of targets) { + const entry = sourceFiles.get(target.expectedSigner.path) + if (!entry) { + throw new Error('Runtime Windows source signature target is missing from its identity') + } + verifiedFiles.push( + await verifySshRelayRuntimeWindowsSignatureTarget({ + path: localPath(physicalRoot, entry.path), + entry, + ...target, + spawnSyncImpl + }) + ) + } + return { + tupleId: identity.tupleId, + sourceContentId: identity.contentId, + verifiedFiles + } +} + +async function main() { + const options = parseSshRelayRuntimeWindowsSourceSignatureArguments(process.argv.slice(2)) + const [identity, report] = await Promise.all([ + readSshRelayRuntimeNativeSigningIdentity(options.identityPath), + readSshRelayRuntimeWindowsSigningStageReport(options.signingReportPath) + ]) + const selection = selectionFromSshRelayRuntimeWindowsSigningStageReport(identity, report) + const result = await verifySshRelayRuntimeWindowsSourceSignatures({ + runtimeRoot: options.runtimeRoot, + identity, + selection + }) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + main().catch((error) => { + process.stderr.write( + `SSH relay runtime Windows source signature verification failed: ${error.stack ?? error}\n` + ) + process.exitCode = 1 + }) +} diff --git a/config/scripts/ssh-relay-runtime-windows-source-signature-verification.test.mjs b/config/scripts/ssh-relay-runtime-windows-source-signature-verification.test.mjs new file mode 100644 index 00000000000..b31269cfb90 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-windows-source-signature-verification.test.mjs @@ -0,0 +1,326 @@ +import { createHash } from 'node:crypto' +import { appendFileSync } from 'node:fs' +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { expectedSshRelayRuntimeClosureEntries } from './ssh-relay-runtime-closure.mjs' +import { sshRelayRuntimeCompatibility } from './ssh-relay-runtime-compatibility.mjs' +import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity.mjs' +import { buildSshRelayRuntimeNativeSigningPlan } from './ssh-relay-runtime-native-signing-plan.mjs' +import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs' +import { + parseSshRelayRuntimeWindowsSourceSignatureArguments, + readSshRelayRuntimeWindowsSigningStageReport, + selectionFromSshRelayRuntimeWindowsSigningStageReport, + verifySshRelayRuntimeWindowsSourceSignatures +} from './ssh-relay-runtime-windows-source-signature-verification.mjs' +import { OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT } from './ssh-relay-runtime-windows-signature-verification.mjs' + +const MICROSOFT_SUBJECT = + 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US' + +function digest(bytes) { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}` +} + +async function sourceFixture() { + const root = await mkdtemp(join(tmpdir(), 'ssh-relay-windows-source-signature-')) + const runtimeRoot = join(root, 'runtime') + await mkdir(runtimeRoot) + const entries = [] + for (const entry of expectedSshRelayRuntimeClosureEntries('win32-x64')) { + if (entry.type === 'directory') { + await mkdir(join(runtimeRoot, ...entry.path.split('/')), { + recursive: true, + mode: entry.mode + }) + entries.push(entry) + continue + } + const bytes = Buffer.from(`fixture:${entry.path}`) + const path = join(runtimeRoot, ...entry.path.split('/')) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, bytes, { mode: entry.mode }) + entries.push({ ...entry, size: bytes.length, sha256: digest(bytes) }) + } + const base = { + identitySchemaVersion: 1, + tupleId: 'win32-x64', + os: 'win32', + architecture: 'x64', + compatibility: sshRelayRuntimeCompatibility['win32-x64'], + nodeVersion: '24.18.0', + dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' }, + entries + } + const files = entries.filter((entry) => entry.type === 'file') + const identity = { + ...base, + contentId: computeSshRelayRuntimeContentId(base), + fileCount: files.length, + expandedSize: files.reduce((total, entry) => total + entry.size, 0) + } + const assessments = buildSshRelayRuntimeNativeSigningPlan(identity).signingCandidates.map( + (entry, index) => { + const preserved = + entry.path.endsWith('/OpenConsole.exe') || entry.path.endsWith('/conpty.dll') + return preserved + ? { + path: entry.path, + sourceSha256: entry.sourceSha256, + status: 'valid-upstream', + signerSubject: MICROSOFT_SUBJECT, + signerThumbprint: `${index + 1}`.repeat(40) + } + : { path: entry.path, sourceSha256: entry.sourceSha256, status: 'unsigned' } + } + ) + const selection = buildSshRelayRuntimeNativeSigningSelection(identity, assessments) + const report = { + tupleId: selection.tupleId, + platform: selection.platform, + policy: selection.policy, + assessments, + immutableVendorFiles: selection.immutableVendorFiles, + signingFiles: selection.signingFiles, + preservedUpstreamFiles: selection.preservedUpstreamFiles, + payload: { stagingRequired: true } + } + const reportPath = join(root, 'signing-stage.json') + await writeFile(reportPath, JSON.stringify(report)) + return { root, runtimeRoot, identity, assessments, selection, report, reportPath } +} + +function entryForPath(selection, physicalPath) { + return [...selection.immutableVendorFiles, ...selection.preservedUpstreamFiles].find((entry) => + physicalPath.endsWith(join(...entry.path.split('/'))) + ) +} + +function signatureJson(subject, thumbprint) { + return JSON.stringify({ status: 'Valid', signerSubject: subject, signerThumbprint: thumbprint }) +} + +function successfulAuthenticode(selection, calls, override = () => undefined) { + return (command, args, options) => { + calls.push({ command, args, options }) + const entry = entryForPath(selection, options.env.ORCA_SSH_RELAY_AUTHENTICODE_FILE) + const overridden = override(entry) + if (overridden) { + return { status: 0, stdout: JSON.stringify(overridden), stderr: '' } + } + return { + status: 0, + stdout: + entry.action === 'preserve-exact-bytes' + ? signatureJson(OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT, 'A'.repeat(40)) + : signatureJson(entry.signerSubject, entry.signerThumbprint), + stderr: '' + } + } +} + +describe('SSH relay runtime Windows source signature verification', () => { + it('parses exact CLI arguments and authenticates the signing-stage report', async () => { + expect( + parseSshRelayRuntimeWindowsSourceSignatureArguments([ + '--identity', + 'identity.json', + '--runtime-directory', + 'runtime', + '--signing-report', + 'report.json' + ]) + ).toEqual({ + identityPath: expect.stringMatching(/identity\.json$/u), + runtimeRoot: expect.stringMatching(/runtime$/u), + signingReportPath: expect.stringMatching(/report\.json$/u) + }) + expect(() => + parseSshRelayRuntimeWindowsSourceSignatureArguments(['--identity', 'identity.json']) + ).toThrow(/missing required/i) + + const fixture = await sourceFixture() + try { + const report = await readSshRelayRuntimeWindowsSigningStageReport(fixture.reportPath) + expect( + selectionFromSshRelayRuntimeWindowsSigningStageReport(fixture.identity, report) + ).toEqual(fixture.selection) + + report.preservedUpstreamFiles[0].signerThumbprint = '0'.repeat(40) + expect(() => + selectionFromSshRelayRuntimeWindowsSigningStageReport(fixture.identity, report) + ).toThrow(/report and authenticated selection disagree/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('verifies the complete source tree before exact Node and preserved signer policy', async () => { + const fixture = await sourceFixture() + const calls = [] + try { + const result = await verifySshRelayRuntimeWindowsSourceSignatures({ + runtimeRoot: fixture.runtimeRoot, + identity: fixture.identity, + selection: fixture.selection, + platform: 'win32', + spawnSyncImpl: successfulAuthenticode(fixture.selection, calls) + }) + + expect(result.tupleId).toBe('win32-x64') + expect(result.verifiedFiles).toHaveLength(3) + expect(result.verifiedFiles.map((entry) => entry.signerKind).sort()).toEqual([ + 'official-node', + 'preserved-upstream', + 'preserved-upstream' + ]) + expect(calls).toHaveLength(3) + expect(calls.every((call) => call.command === 'pwsh')).toBe(true) + expect(calls.every((call) => call.options.timeout === 30_000)).toBe(true) + expect(calls.every((call) => call.options.maxBuffer === 64 * 1024)).toBe(true) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('rejects wrong Node or preserved signer policy and invalid status', async () => { + const fixture = await sourceFixture() + const common = { + runtimeRoot: fixture.runtimeRoot, + identity: fixture.identity, + selection: fixture.selection, + platform: 'win32' + } + try { + await expect( + verifySshRelayRuntimeWindowsSourceSignatures({ + ...common, + spawnSyncImpl: successfulAuthenticode(fixture.selection, [], (entry) => + entry.action === 'preserve-exact-bytes' + ? { + status: 'Valid', + signerSubject: 'CN=Unexpected Node', + signerThumbprint: 'A'.repeat(40) + } + : undefined + ) + }) + ).rejects.toThrow(/official Node signer policy/i) + await expect( + verifySshRelayRuntimeWindowsSourceSignatures({ + ...common, + spawnSyncImpl: successfulAuthenticode(fixture.selection, [], (entry) => + entry.action === 'preserve-valid-upstream' + ? { + status: 'Valid', + signerSubject: entry.signerSubject, + signerThumbprint: '0'.repeat(40) + } + : undefined + ) + }) + ).rejects.toThrow(/preserved upstream signer policy/i) + await expect( + verifySshRelayRuntimeWindowsSourceSignatures({ + ...common, + spawnSyncImpl: () => ({ + status: 0, + stdout: JSON.stringify({ + status: 'NotSigned', + signerSubject: null, + signerThumbprint: null + }), + stderr: '' + }) + }) + ).rejects.toThrow(/requires a valid Authenticode signature/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('rejects tree mutation before probing and source mutation during a probe', async () => { + const fixture = await sourceFixture() + let calls = 0 + try { + await appendFile(join(fixture.runtimeRoot, 'relay.js'), ':mutated') + await expect( + verifySshRelayRuntimeWindowsSourceSignatures({ + runtimeRoot: fixture.runtimeRoot, + identity: fixture.identity, + selection: fixture.selection, + platform: 'win32', + spawnSyncImpl: () => { + calls += 1 + throw new Error('must not spawn') + } + }) + ).rejects.toThrow(/integrity mismatch/i) + expect(calls).toBe(0) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + + const raced = await sourceFixture() + try { + const spawn = successfulAuthenticode(raced.selection, []) + let mutated = false + await expect( + verifySshRelayRuntimeWindowsSourceSignatures({ + runtimeRoot: raced.runtimeRoot, + identity: raced.identity, + selection: raced.selection, + platform: 'win32', + spawnSyncImpl: (command, args, options) => { + if (!mutated) { + mutated = true + appendFileSync(options.env.ORCA_SSH_RELAY_AUTHENTICODE_FILE, ':raced') + } + return spawn(command, args, options) + } + }) + ).rejects.toThrow(/changed during signature verification/i) + } finally { + await rm(raced.root, { recursive: true, force: true }) + } + }) + + it('rejects cross-platform execution and stale preserved-signer evidence', async () => { + const fixture = await sourceFixture() + let calls = 0 + try { + await expect( + verifySshRelayRuntimeWindowsSourceSignatures({ + runtimeRoot: fixture.runtimeRoot, + identity: fixture.identity, + selection: fixture.selection, + platform: 'linux', + spawnSyncImpl: () => { + calls += 1 + throw new Error('must not spawn') + } + }) + ).rejects.toThrow(/requires Windows/i) + expect(calls).toBe(0) + + const stale = structuredClone(fixture.selection) + stale.preservedUpstreamFiles[0].signerThumbprint = '0'.repeat(40) + await expect( + verifySshRelayRuntimeWindowsSourceSignatures({ + runtimeRoot: fixture.runtimeRoot, + identity: fixture.identity, + selection: stale, + platform: 'win32', + spawnSyncImpl: successfulAuthenticode(fixture.selection, []) + }) + ).rejects.toThrow(/preserved upstream signer policy/i) + expect(calls).toBe(0) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 04a6aae1c9a..cd6938bf512 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -95,6 +95,9 @@ describe('SSH relay runtime artifact workflow', () => { expect( source.match(/ssh-relay-runtime-windows-signature-verification\.test\.mjs/g) ).toHaveLength(4) + expect( + source.match(/ssh-relay-runtime-windows-source-signature-verification\.test\.mjs/g) + ).toHaveLength(4) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-plan\.mjs/g) ).toHaveLength(2) @@ -125,6 +128,11 @@ describe('SSH relay runtime artifact workflow', () => { /node --check config\/scripts\/ssh-relay-runtime-windows-signature-verification\.mjs/g ) ).toHaveLength(2) + expect( + source.match( + /node --check config\/scripts\/ssh-relay-runtime-windows-source-signature-verification\.mjs/g + ) + ).toHaveLength(2) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-closure\.mjs/g) ).toHaveLength(2) @@ -219,7 +227,20 @@ describe('SSH relay runtime artifact workflow', () => { expect(windowsRun).toContain('@($report.preservedUpstreamFiles).Count -ne 2') expect(windowsRun).toContain('Required upstream signature was not preserved') expect(windowsRun).toContain("Join-Path $signingStage 'bin/node.exe'") + expect(windowsRun).toContain('ssh-relay-runtime-windows-source-signature-verification.mjs') + expect( + windowsRun.indexOf('ssh-relay-runtime-windows-source-signature-verification.mjs') + ).toBeGreaterThan(windowsRun.indexOf('ssh-relay-runtime-native-signing-stage.mjs')) + expect( + windowsRun.indexOf('ssh-relay-runtime-windows-source-signature-verification.mjs') + ).toBeLessThan(windowsRun.indexOf('Remove-Item -LiteralPath $signingStage')) + expect(windowsRun).toContain( + 'Windows source signature report violates the immutable/preserved trust contract' + ) + expect(windowsRun).toContain("$_.signerKind -eq 'official-node'") + expect(windowsRun).toContain("$_.signerKind -eq 'preserved-upstream'") expect(source.match(/\.signing-stage\.json/g)).toHaveLength(3) + expect(source.match(/\.source-signatures\.json/g)).toHaveLength(1) expect(source).not.toMatch(/SIGNPATH_|APPLE_(?:ID|KEY)|Developer ID Application/) })