diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index cbb23eac16b..5b595170296 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -139,6 +139,8 @@ jobs: node --check config/scripts/ssh-relay-runtime-native-signing-apply.test.mjs node --check config/scripts/ssh-relay-runtime-macos-signature-verification.mjs 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 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 \ @@ -158,6 +160,7 @@ jobs: config/scripts/ssh-relay-runtime-native-signing-stage.test.mjs \ 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-closure.test.mjs \ config/scripts/ssh-relay-runtime-pty-smoke.test.mjs \ config/scripts/ssh-relay-runtime-reproducibility.test.mjs \ @@ -465,6 +468,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'runtime macOS signature verification script syntax check failed' } node --check config/scripts/ssh-relay-runtime-macos-signature-verification.test.mjs if ($LASTEXITCODE -ne 0) { throw 'runtime macOS signature verification test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-windows-signature-verification.mjs + 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' } 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 ` @@ -484,6 +491,7 @@ jobs: config/scripts/ssh-relay-runtime-native-signing-stage.test.mjs ` 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-closure.test.mjs ` config/scripts/ssh-relay-runtime-pty-smoke.test.mjs ` config/scripts/ssh-relay-runtime-reproducibility.test.mjs ` diff --git a/config/scripts/ssh-relay-runtime-windows-signature-verification.mjs b/config/scripts/ssh-relay-runtime-windows-signature-verification.mjs new file mode 100644 index 00000000000..ae7f5f37d1b --- /dev/null +++ b/config/scripts/ssh-relay-runtime-windows-signature-verification.mjs @@ -0,0 +1,207 @@ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { lstat, realpath } from 'node:fs/promises' +import { resolve } from 'node:path' +import { isDeepStrictEqual } from 'node:util' + +import { assertSshRelayRuntimeClosureEntries } from './ssh-relay-runtime-closure.mjs' +import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity.mjs' +import { + MAX_RETURNED_PAYLOAD_BYTES, + MAX_SIGNED_FILE_GROWTH_BYTES +} from './ssh-relay-runtime-native-signing-payload.mjs' +import { assertSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs' +import { + classifySshRelayRuntimeWindowsAuthenticode, + getSshRelayRuntimeWindowsAuthenticodeJson, + parseSshRelayRuntimeWindowsAuthenticodeJson +} from './ssh-relay-runtime-windows-authenticode-assessment.mjs' +import { verifyRuntimeTree } from './verify-ssh-relay-runtime.mjs' + +export const OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT = + 'CN=OpenJS Foundation, O=OpenJS Foundation, L=San Francisco, S=California, C=US' +export const ORCA_WINDOWS_SIGNER_SUBJECT = + 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + +function localPath(root, portablePath) { + return resolve(root, ...portablePath.split('/')) +} + +async function sha256File(path) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) { + hash.update(chunk) + } + return `sha256:${hash.digest('hex')}` +} + +function assertIdentityTransition(sourceIdentity, finalIdentity, selection) { + assertSshRelayRuntimeClosureEntries(finalIdentity) + if ( + computeSshRelayRuntimeContentId(sourceIdentity) !== sourceIdentity.contentId || + computeSshRelayRuntimeContentId(finalIdentity) !== finalIdentity.contentId || + Object.hasOwn(finalIdentity, 'archive') || + finalIdentity.tupleId !== sourceIdentity.tupleId || + finalIdentity.os !== 'win32' || + finalIdentity.contentId === sourceIdentity.contentId + ) { + throw new Error('Runtime Windows final identity does not match its unsigned source') + } + const signingPaths = new Set(selection.signingFiles.map((entry) => entry.path)) + const finalEntries = new Map(finalIdentity.entries.map((entry) => [entry.path, entry])) + let returnedSize = 0 + for (const sourceEntry of sourceIdentity.entries) { + const finalEntry = finalEntries.get(sourceEntry.path) + if (!finalEntry) { + throw new Error(`Runtime Windows final identity is missing entry: ${sourceEntry.path}`) + } + if (signingPaths.has(sourceEntry.path)) { + if ( + sourceEntry.type !== 'file' || + finalEntry.type !== 'file' || + finalEntry.sha256 === sourceEntry.sha256 || + finalEntry.size <= 0 || + finalEntry.size > sourceEntry.size + MAX_SIGNED_FILE_GROWTH_BYTES || + finalEntry.path !== sourceEntry.path || + finalEntry.role !== sourceEntry.role || + finalEntry.mode !== sourceEntry.mode + ) { + throw new Error( + `Runtime Windows signed identity transition is invalid: ${sourceEntry.path}` + ) + } + returnedSize += finalEntry.size + } else if (!isDeepStrictEqual(finalEntry, sourceEntry)) { + throw new Error(`Runtime Windows unsigned identity entry changed: ${sourceEntry.path}`) + } + finalEntries.delete(sourceEntry.path) + } + if (finalEntries.size !== 0) { + throw new Error( + `Runtime Windows final identity has an extra entry: ${finalEntries.keys().next().value}` + ) + } + if (returnedSize > MAX_RETURNED_PAYLOAD_BYTES) { + throw new Error('Runtime Windows signed identity exceeds the returned payload size bound') + } + const files = finalIdentity.entries.filter((entry) => entry.type === 'file') + const expandedSize = files.reduce((total, entry) => total + entry.size, 0) + if (finalIdentity.fileCount !== files.length || finalIdentity.expandedSize !== expandedSize) { + throw new Error('Runtime Windows final identity totals are inconsistent') + } +} + +function assertSignerPolicy(signature, signerKind, expectedSigner) { + if (signature.status !== 'Valid') { + throw new Error('Runtime Windows requires a valid Authenticode signature on every native file') + } + const classified = classifySshRelayRuntimeWindowsAuthenticode(signature) + if (signerKind === 'official-node') { + if (classified.signerSubject !== OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT) { + throw new Error('Runtime Windows signature violates official Node signer policy') + } + } else if (signerKind === 'orca-built') { + if (classified.signerSubject !== ORCA_WINDOWS_SIGNER_SUBJECT) { + throw new Error('Runtime Windows signature violates Orca signer policy') + } + } else if ( + classified.signerSubject !== expectedSigner.signerSubject || + classified.signerThumbprint !== expectedSigner.signerThumbprint + ) { + throw new Error('Runtime Windows signature violates preserved upstream signer policy') + } + return classified +} + +async function verifySignedFile({ path, entry, signerKind, expectedSigner, spawnSyncImpl }) { + const metadata = await lstat(path) + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error(`Runtime Windows signature target is not a regular file: ${entry.path}`) + } + if ((await sha256File(path)) !== entry.sha256) { + throw new Error(`Runtime Windows signature target has wrong authenticated hash: ${entry.path}`) + } + const signature = parseSshRelayRuntimeWindowsAuthenticodeJson( + getSshRelayRuntimeWindowsAuthenticodeJson(path, spawnSyncImpl) + ) + const classified = assertSignerPolicy(signature, signerKind, expectedSigner) + if ((await sha256File(path)) !== entry.sha256) { + throw new Error(`Runtime Windows file changed during signature verification: ${entry.path}`) + } + return { + path: entry.path, + role: entry.role, + sha256: entry.sha256, + signerKind, + signerSubject: classified.signerSubject, + signerThumbprint: classified.signerThumbprint + } +} + +function verificationTargets(finalIdentity, selection) { + const finalFiles = new Map( + finalIdentity.entries + .filter((entry) => entry.type === 'file') + .map((entry) => [entry.path, entry]) + ) + const node = new Map(selection.immutableVendorFiles.map((entry) => [entry.path, entry])) + const signing = new Map(selection.signingFiles.map((entry) => [entry.path, entry])) + const preserved = new Map(selection.preservedUpstreamFiles.map((entry) => [entry.path, entry])) + return selection.verificationFiles.map((verification) => { + const entry = finalFiles.get(verification.path) + if (!entry) { + throw new Error('Runtime Windows signature target is missing from the final identity') + } + if (node.has(entry.path)) { + return { entry, signerKind: 'official-node' } + } + if (signing.has(entry.path)) { + return { entry, signerKind: 'orca-built' } + } + const expectedSigner = preserved.get(entry.path) + if (!expectedSigner) { + throw new Error(`Runtime Windows signature target has no signer policy: ${entry.path}`) + } + return { entry, signerKind: 'preserved-upstream', expectedSigner } + }) +} + +export async function verifySshRelayRuntimeWindowsSignatures({ + runtimeRoot, + sourceIdentity, + finalIdentity, + selection, + platform = process.platform, + spawnSyncImpl = spawnSync +}) { + if (platform !== 'win32') { + throw new Error('Runtime Windows signature verification requires Windows') + } + assertSshRelayRuntimeNativeSigningSelection(sourceIdentity, selection) + assertIdentityTransition(sourceIdentity, finalIdentity, selection) + const rootMetadata = await lstat(resolve(runtimeRoot)) + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new Error('Runtime Windows signature verification requires a real runtime root') + } + const physicalRoot = await realpath(resolve(runtimeRoot)) + // Why: native code is probed only after every byte in the final runtime matches its new identity. + await verifyRuntimeTree(physicalRoot, finalIdentity) + + const verifiedFiles = [] + for (const target of verificationTargets(finalIdentity, selection)) { + verifiedFiles.push( + await verifySignedFile({ + path: localPath(physicalRoot, target.entry.path), + ...target, + spawnSyncImpl + }) + ) + } + return { + tupleId: finalIdentity.tupleId, + sourceContentId: sourceIdentity.contentId, + finalContentId: finalIdentity.contentId, + verifiedFiles + } +} diff --git a/config/scripts/ssh-relay-runtime-windows-signature-verification.test.mjs b/config/scripts/ssh-relay-runtime-windows-signature-verification.test.mjs new file mode 100644 index 00000000000..44ceed0f942 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-windows-signature-verification.test.mjs @@ -0,0 +1,376 @@ +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 { applySshRelayRuntimeNativeSigningReturn } from './ssh-relay-runtime-native-signing-apply.mjs' +import { buildSshRelayRuntimeNativeSigningPlan } from './ssh-relay-runtime-native-signing-plan.mjs' +import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs' +import { + OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT, + ORCA_WINDOWS_SIGNER_SUBJECT, + verifySshRelayRuntimeWindowsSignatures +} 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 runtimeFixture() { + const root = await mkdtemp(join(tmpdir(), 'ssh-relay-windows-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') + return { + root, + runtimeRoot, + identity: { + ...base, + contentId: computeSshRelayRuntimeContentId(base), + fileCount: files.length, + expandedSize: files.reduce((total, entry) => total + entry.size, 0) + } + } +} + +function assessmentsFor(identity) { + return 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' } + }) +} + +async function signedFixture() { + const fixture = await runtimeFixture() + const selection = buildSshRelayRuntimeNativeSigningSelection( + fixture.identity, + assessmentsFor(fixture.identity) + ) + const returnedRoot = join(fixture.root, 'returned') + for (const entry of selection.signingFiles) { + const path = join(returnedRoot, ...entry.path.split('/')) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `signed:${entry.path}`) + } + const finalRuntimeRoot = join(fixture.root, 'final-runtime') + const applied = await applySshRelayRuntimeNativeSigningReturn({ + sourceRuntimeRoot: fixture.runtimeRoot, + returnedRoot, + outputRuntimeRoot: finalRuntimeRoot, + identity: fixture.identity, + selection + }) + return { ...fixture, finalRuntimeRoot, selection, finalIdentity: applied.identity } +} + +function signatureJson(signerSubject, signerThumbprint) { + return JSON.stringify({ status: 'Valid', signerSubject, signerThumbprint }) +} + +function entryForPhysicalPath(selection, physicalPath) { + return [ + ...selection.immutableVendorFiles, + ...selection.signingFiles, + ...selection.preservedUpstreamFiles + ].find((entry) => physicalPath.endsWith(join(...entry.path.split('/')))) +} + +function successfulAuthenticode(selection, calls, override = () => undefined) { + return (command, args, options) => { + calls.push({ command, args, options }) + const physicalPath = options.env.ORCA_SSH_RELAY_AUTHENTICODE_FILE + const entry = entryForPhysicalPath(selection, physicalPath) + const overridden = override(entry) + if (overridden) { + return { status: 0, stdout: JSON.stringify(overridden), stderr: '' } + } + if (entry.action === 'preserve-exact-bytes') { + return { + status: 0, + stdout: signatureJson(OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT, 'A'.repeat(40)), + stderr: '' + } + } + if (entry.action === 'preserve-valid-upstream') { + return { + status: 0, + stdout: signatureJson(entry.signerSubject, entry.signerThumbprint), + stderr: '' + } + } + return { + status: 0, + stdout: signatureJson(ORCA_WINDOWS_SIGNER_SUBJECT, 'F'.repeat(40)), + stderr: '' + } + } +} + +describe('SSH relay runtime Windows signature verification', () => { + it('pins exact official Node and Orca SignPath signer subjects', () => { + expect(OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT).toBe( + 'CN=OpenJS Foundation, O=OpenJS Foundation, L=San Francisco, S=California, C=US' + ) + expect(ORCA_WINDOWS_SIGNER_SUBJECT).toBe( + 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + ) + }) + + it('verifies the complete final tree before exact Node, SignPath, and preserved signer policy', async () => { + const fixture = await signedFixture() + const calls = [] + try { + const report = await verifySshRelayRuntimeWindowsSignatures({ + runtimeRoot: fixture.finalRuntimeRoot, + sourceIdentity: fixture.identity, + finalIdentity: fixture.finalIdentity, + selection: fixture.selection, + platform: 'win32', + spawnSyncImpl: successfulAuthenticode(fixture.selection, calls) + }) + + expect(report.tupleId).toBe('win32-x64') + expect(report.verifiedFiles).toHaveLength(6) + expect(report.verifiedFiles.find((entry) => entry.role === 'node')).toEqual( + expect.objectContaining({ + signerKind: 'official-node', + signerSubject: OFFICIAL_NODE_WINDOWS_SIGNER_SUBJECT + }) + ) + expect(calls).toHaveLength(6) + 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 unexpected official Node, SignPath, and preserved signer identities', async () => { + const fixture = await signedFixture() + const common = { + runtimeRoot: fixture.finalRuntimeRoot, + sourceIdentity: fixture.identity, + finalIdentity: fixture.finalIdentity, + selection: fixture.selection, + platform: 'win32' + } + try { + await expect( + verifySshRelayRuntimeWindowsSignatures({ + ...common, + spawnSyncImpl: successfulAuthenticode(fixture.selection, [], (entry) => + entry.action === 'preserve-exact-bytes' + ? { + status: 'Valid', + signerSubject: 'CN=Unexpected Node Signer', + signerThumbprint: 'A'.repeat(40) + } + : undefined + ) + }) + ).rejects.toThrow(/official Node signer policy/i) + + await expect( + verifySshRelayRuntimeWindowsSignatures({ + ...common, + spawnSyncImpl: successfulAuthenticode(fixture.selection, [], (entry) => + entry.action === 'signpath-required' + ? { + status: 'Valid', + signerSubject: 'CN=Unexpected Orca Signer', + signerThumbprint: 'F'.repeat(40) + } + : undefined + ) + }) + ).rejects.toThrow(/Orca signer policy/i) + + await expect( + verifySshRelayRuntimeWindowsSignatures({ + ...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) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('fails closed on invalid signature status and bounded probe failure', async () => { + const fixture = await signedFixture() + const common = { + runtimeRoot: fixture.finalRuntimeRoot, + sourceIdentity: fixture.identity, + finalIdentity: fixture.finalIdentity, + selection: fixture.selection, + platform: 'win32' + } + try { + await expect( + verifySshRelayRuntimeWindowsSignatures({ + ...common, + spawnSyncImpl: () => ({ + status: 0, + stdout: JSON.stringify({ + status: 'NotSigned', + signerSubject: null, + signerThumbprint: null + }), + stderr: '' + }) + }) + ).rejects.toThrow(/requires a valid Authenticode signature/i) + await expect( + verifySshRelayRuntimeWindowsSignatures({ + ...common, + spawnSyncImpl: () => ({ error: new Error('timed out') }) + }) + ).rejects.toThrow(/timed out/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('authenticates the tree before spawning and rejects mutation during native probes', async () => { + const fixture = await signedFixture() + let calls = 0 + try { + await appendFile(join(fixture.finalRuntimeRoot, 'relay.js'), ':mutated') + await expect( + verifySshRelayRuntimeWindowsSignatures({ + runtimeRoot: fixture.finalRuntimeRoot, + sourceIdentity: fixture.identity, + finalIdentity: fixture.finalIdentity, + 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 signedFixture() + try { + const spawn = successfulAuthenticode(raced.selection, []) + let mutated = false + await expect( + verifySshRelayRuntimeWindowsSignatures({ + runtimeRoot: raced.finalRuntimeRoot, + sourceIdentity: raced.identity, + finalIdentity: raced.finalIdentity, + 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 selection or final identity before probes', async () => { + const fixture = await signedFixture() + let calls = 0 + const common = { + runtimeRoot: fixture.finalRuntimeRoot, + sourceIdentity: fixture.identity, + finalIdentity: fixture.finalIdentity, + selection: fixture.selection, + spawnSyncImpl: () => { + calls += 1 + throw new Error('must not spawn') + } + } + try { + await expect( + verifySshRelayRuntimeWindowsSignatures({ ...common, platform: 'darwin' }) + ).rejects.toThrow(/requires Windows/i) + + const staleSelection = structuredClone(fixture.selection) + staleSelection.signingFiles[0].sourceSha256 = `sha256:${'f'.repeat(64)}` + await expect( + verifySshRelayRuntimeWindowsSignatures({ + ...common, + selection: staleSelection, + platform: 'win32' + }) + ).rejects.toThrow(/wrong source hash|selection and identity disagree/i) + + const staleArchive = structuredClone(fixture.finalIdentity) + staleArchive.archive = { fileName: 'unsigned.zip' } + await expect( + verifySshRelayRuntimeWindowsSignatures({ + ...common, + finalIdentity: staleArchive, + platform: 'win32' + }) + ).rejects.toThrow(/does not match its unsigned source/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 aa6cacb5888..04a6aae1c9a 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -92,6 +92,9 @@ describe('SSH relay runtime artifact workflow', () => { expect(source.match(/ssh-relay-runtime-macos-signature-verification\.test\.mjs/g)).toHaveLength( 4 ) + expect( + source.match(/ssh-relay-runtime-windows-signature-verification\.test\.mjs/g) + ).toHaveLength(4) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-plan\.mjs/g) ).toHaveLength(2) @@ -117,6 +120,11 @@ describe('SSH relay runtime artifact workflow', () => { /node --check config\/scripts\/ssh-relay-runtime-macos-signature-verification\.mjs/g ) ).toHaveLength(2) + expect( + source.match( + /node --check config\/scripts\/ssh-relay-runtime-windows-signature-verification\.mjs/g + ) + ).toHaveLength(2) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-closure\.mjs/g) ).toHaveLength(2)