From c847c4a11d60ee37d161564ca3685955ce3c2d6d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 14 Jul 2026 21:51:45 -0400 Subject: [PATCH] build(ssh): validate native signing payloads Co-authored-by: Orca --- .../workflows/ssh-relay-runtime-artifacts.yml | 16 + ...h-relay-runtime-native-signing-payload.mjs | 240 ++++++++++++ ...ay-runtime-native-signing-payload.test.mjs | 360 ++++++++++++++++++ ...relay-runtime-native-signing-selection.mjs | 160 ++++++++ ...-runtime-native-signing-selection.test.mjs | 167 ++++++++ .../ssh-relay-runtime-workflow.test.mjs | 8 + 6 files changed, 951 insertions(+) create mode 100644 config/scripts/ssh-relay-runtime-native-signing-payload.mjs create mode 100644 config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs create mode 100644 config/scripts/ssh-relay-runtime-native-signing-selection.mjs create mode 100644 config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 3ae99261ad2..ccb0c6cd780 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -127,6 +127,10 @@ jobs: node --check config/scripts/ssh-relay-runtime-toolchain.test.mjs node --check config/scripts/ssh-relay-runtime-native-signing-plan.mjs node --check config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs + node --check config/scripts/ssh-relay-runtime-native-signing-selection.mjs + node --check config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs + node --check config/scripts/ssh-relay-runtime-native-signing-payload.mjs + node --check config/scripts/ssh-relay-runtime-native-signing-payload.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 \ @@ -140,6 +144,8 @@ jobs: config/scripts/ssh-relay-runtime-build.test.mjs \ config/scripts/ssh-relay-runtime-linux-build-evidence.test.mjs \ config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs \ + config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs \ + config/scripts/ssh-relay-runtime-native-signing-payload.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 \ @@ -386,6 +392,14 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'runtime native signing plan script syntax check failed' } node --check config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs if ($LASTEXITCODE -ne 0) { throw 'runtime native signing plan test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-native-signing-selection.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime native signing selection script syntax check failed' } + node --check config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime native signing selection test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-native-signing-payload.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime native signing payload script syntax check failed' } + node --check config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime native signing payload 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 ` @@ -399,6 +413,8 @@ jobs: config/scripts/ssh-relay-runtime-build.test.mjs ` config/scripts/ssh-relay-runtime-linux-build-evidence.test.mjs ` config/scripts/ssh-relay-runtime-native-signing-plan.test.mjs ` + config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs ` + config/scripts/ssh-relay-runtime-native-signing-payload.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-native-signing-payload.mjs b/config/scripts/ssh-relay-runtime-native-signing-payload.mjs new file mode 100644 index 00000000000..25b49e9a3be --- /dev/null +++ b/config/scripts/ssh-relay-runtime-native-signing-payload.mjs @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto' +import { constants, createReadStream } from 'node:fs' +import { copyFile, lstat, mkdir, readdir, realpath, rm } from 'node:fs/promises' +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path' + +const MAX_SIGNED_FILE_GROWTH_BYTES = 4 * 1024 * 1024 +const MAX_RETURNED_PAYLOAD_BYTES = 64 * 1024 * 1024 + +function localPath(root, portablePath) { + if ( + typeof portablePath !== 'string' || + portablePath.length === 0 || + portablePath.includes('\\') || + portablePath.startsWith('/') || + portablePath.split('/').some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error(`Runtime native signing rejects non-portable path: ${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')}` +} + +async function assertRegularFile(path, label) { + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) { + throw new Error(`Runtime native signing ${label} is a symbolic link: ${path}`) + } + if (!metadata.isFile()) { + throw new Error(`Runtime native signing ${label} is not a regular file: ${path}`) + } + return metadata +} + +async function verifySourceFile(runtimeRoot, entry) { + const path = localPath(runtimeRoot, entry.path) + const metadata = await assertRegularFile(path, 'source') + if (metadata.size !== entry.sourceSize) { + throw new Error(`Runtime native signing source has wrong authenticated size: ${entry.path}`) + } + if ((await sha256File(path)) !== entry.sourceSha256) { + throw new Error(`Runtime native signing source has wrong authenticated hash: ${entry.path}`) + } +} + +function assertExclusiveStageLocation(runtimeRoot, stagingRoot) { + const relativeStage = relative(runtimeRoot, stagingRoot) + if (relativeStage === '' || (!relativeStage.startsWith('..') && !isAbsolute(relativeStage))) { + throw new Error('Runtime native signing staging root must be outside the runtime') + } +} + +async function resolveSigningRoots(runtimeRoot, stagingRoot) { + const runtimeMetadata = await lstat(runtimeRoot) + if (runtimeMetadata.isSymbolicLink() || !runtimeMetadata.isDirectory()) { + throw new Error('Runtime native signing source root must be a real directory') + } + const physicalRuntimeRoot = await realpath(runtimeRoot) + const physicalStagingParent = await realpath(dirname(stagingRoot)) + const physicalStagingRoot = resolve(physicalStagingParent, basename(stagingRoot)) + assertExclusiveStageLocation(physicalRuntimeRoot, physicalStagingRoot) + return { physicalRuntimeRoot, physicalStagingRoot } +} + +async function assertPathAbsent(path) { + try { + await lstat(path) + } catch (error) { + if (error.code === 'ENOENT') { + return + } + throw error + } + throw new Error('Runtime native signing requires an exclusive staging root') +} + +async function copySigningFile(runtimeRoot, stagingRoot, entry) { + const source = localPath(runtimeRoot, entry.path) + const destination = localPath(stagingRoot, entry.path) + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source, destination, constants.COPYFILE_EXCL) + const metadata = await assertRegularFile(destination, 'staged file') + if ( + metadata.size !== entry.sourceSize || + (await sha256File(destination)) !== entry.sourceSha256 + ) { + throw new Error(`Runtime native signing staged bytes changed during copy: ${entry.path}`) + } + return { path: entry.path, sourceSha256: entry.sourceSha256, sourceSize: entry.sourceSize } +} + +export async function stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot, + stagingRoot, + selection +}) { + const absoluteRuntimeRoot = resolve(runtimeRoot) + const absoluteStagingRoot = resolve(stagingRoot) + const { physicalRuntimeRoot, physicalStagingRoot } = await resolveSigningRoots( + absoluteRuntimeRoot, + absoluteStagingRoot + ) + await assertPathAbsent(physicalStagingRoot) + await Promise.all( + selection.verificationFiles.map((entry) => verifySourceFile(physicalRuntimeRoot, entry)) + ) + if (selection.signingFiles.length === 0) { + return { tupleId: selection.tupleId, stagingRequired: false, stagedFiles: [], stagedSize: 0 } + } + + await mkdir(physicalStagingRoot) + try { + const stagedFiles = [] + for (const entry of selection.signingFiles) { + stagedFiles.push(await copySigningFile(physicalRuntimeRoot, physicalStagingRoot, entry)) + } + return { + tupleId: selection.tupleId, + stagingRequired: true, + stagedFiles, + stagedSize: stagedFiles.reduce((total, entry) => total + entry.sourceSize, 0) + } + } catch (error) { + // Why: a partial signing payload must never be mistaken for a complete retryable input. + await rm(physicalStagingRoot, { recursive: true, force: true }) + throw error + } +} + +function expectedDirectories(files) { + const directories = new Set() + for (const file of files) { + const segments = file.path.split('/') + for (let depth = 1; depth < segments.length; depth += 1) { + directories.add(segments.slice(0, depth).join('/')) + } + } + return directories +} + +async function walkReturnedTree( + root, + segments = [], + result = { directories: new Set(), files: new Map() } +) { + for (const directoryEntry of await readdir(root, { withFileTypes: true })) { + const childSegments = [...segments, directoryEntry.name] + const portablePath = childSegments.join('/') + const path = resolve(root, directoryEntry.name) + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) { + throw new Error( + `Runtime native signing returned tree contains a symbolic link: ${portablePath}` + ) + } + if (metadata.isDirectory()) { + result.directories.add(portablePath) + await walkReturnedTree(path, childSegments, result) + } else if (metadata.isFile()) { + result.files.set(portablePath, { path, metadata }) + } else { + throw new Error( + `Runtime native signing returned tree contains a special entry: ${portablePath}` + ) + } + } + return result +} + +function assertExactReturnedClosure(actual, expectedFiles) { + const expectedFilePaths = new Set(expectedFiles.map((entry) => entry.path)) + for (const entry of expectedFiles) { + if (!actual.files.has(entry.path)) { + throw new Error(`Runtime native signing is missing returned file: ${entry.path}`) + } + } + for (const path of actual.files.keys()) { + if (!expectedFilePaths.has(path)) { + throw new Error(`Runtime native signing has unexpected returned file: ${path}`) + } + } + const directories = expectedDirectories(expectedFiles) + for (const path of actual.directories) { + if (!directories.has(path)) { + throw new Error(`Runtime native signing has unexpected returned directory: ${path}`) + } + } + for (const path of directories) { + if (!actual.directories.has(path)) { + throw new Error(`Runtime native signing is missing returned directory: ${path}`) + } + } +} + +export async function verifySshRelayRuntimeNativeSigningReturn({ returnedRoot, selection }) { + if (selection.signingFiles.length === 0) { + throw new Error('Runtime native signing selection has no returned payload') + } + const absoluteReturnedRoot = resolve(returnedRoot) + const rootMetadata = await lstat(absoluteReturnedRoot) + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { + throw new Error('Runtime native signing returned root must be a real directory') + } + const actual = await walkReturnedTree(absoluteReturnedRoot) + assertExactReturnedClosure(actual, selection.signingFiles) + + let returnedSize = 0 + const returnedFiles = [] + for (const entry of selection.signingFiles) { + const returned = actual.files.get(entry.path) + if ( + returned.metadata.size <= 0 || + returned.metadata.size > entry.sourceSize + MAX_SIGNED_FILE_GROWTH_BYTES + ) { + throw new Error(`Runtime native signing returned file exceeds size bound: ${entry.path}`) + } + returnedSize += returned.metadata.size + if (returnedSize > MAX_RETURNED_PAYLOAD_BYTES) { + throw new Error('Runtime native signing returned payload exceeds total size bound') + } + const signedSha256 = await sha256File(returned.path) + if (signedSha256 === entry.sourceSha256) { + throw new Error(`Runtime native signing returned file did not change: ${entry.path}`) + } + returnedFiles.push({ + path: entry.path, + role: entry.role, + sourceSha256: entry.sourceSha256, + signedSha256, + signedSize: returned.metadata.size + }) + } + return { tupleId: selection.tupleId, returnedFiles, returnedSize } +} diff --git a/config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs b/config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs new file mode 100644 index 00000000000..2b36836edaa --- /dev/null +++ b/config/scripts/ssh-relay-runtime-native-signing-payload.test.mjs @@ -0,0 +1,360 @@ +import { createHash } from 'node:crypto' +import { + appendFile, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + truncate, + 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 { + stageSshRelayRuntimeNativeSigningPayload, + verifySshRelayRuntimeNativeSigningReturn +} from './ssh-relay-runtime-native-signing-payload.mjs' +import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs' + +function digest(bytes) { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}` +} + +function platformForTuple(tupleId) { + return tupleId.startsWith('linux-') ? 'linux' : tupleId.startsWith('darwin-') ? 'darwin' : 'win32' +} + +async function runtimeFixture(tupleId) { + const root = await mkdtemp(join(tmpdir(), 'ssh-relay-native-signing-payload-')) + const runtimeRoot = join(root, 'runtime') + await mkdir(runtimeRoot) + const entries = [] + for (const entry of expectedSshRelayRuntimeClosureEntries(tupleId)) { + if (entry.type === 'directory') { + await mkdir(join(runtimeRoot, ...entry.path.split('/')), { recursive: true }) + entries.push(entry) + continue + } + const bytes = Buffer.from(`fixture:${tupleId}:${entry.path}`) + const filePath = join(runtimeRoot, ...entry.path.split('/')) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, bytes, { mode: entry.mode }) + entries.push({ ...entry, size: bytes.length, sha256: digest(bytes) }) + } + return { + root, + runtimeRoot, + identity: { + tupleId, + os: platformForTuple(tupleId), + nodeVersion: '24.18.0', + dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' }, + entries + } + } +} + +function windowsAssessments(identity, preservedPath) { + const nativeEntries = identity.entries.filter( + (entry) => + entry.type === 'file' && + ['node-pty-native', 'parcel-watcher-native', 'native-runtime'].includes(entry.role) + ) + return nativeEntries.map((entry) => + entry.path === preservedPath + ? { + path: entry.path, + sourceSha256: entry.sha256, + status: 'valid-upstream', + signerSubject: 'CN=Microsoft Corporation', + signerThumbprint: 'B'.repeat(40) + } + : { path: entry.path, sourceSha256: entry.sha256, status: 'unsigned' } + ) +} + +async function createReturnedTree(stagingRoot, returnedRoot, selection) { + await cp(stagingRoot, returnedRoot, { recursive: true, errorOnExist: true, force: false }) + for (const entry of selection.signingFiles) { + await appendFile(join(returnedRoot, ...entry.path.split('/')), ':signed') + } +} + +describe('SSH relay runtime native signing payload', () => { + it('authenticates Linux native bytes without creating a signing payload', async () => { + const fixture = await runtimeFixture('linux-x64-glibc') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const stagingRoot = join(fixture.root, 'stage') + await expect( + stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot, + selection + }) + ).resolves.toEqual({ + tupleId: 'linux-x64-glibc', + stagingRequired: false, + stagedFiles: [], + stagedSize: 0 + }) + await expect(lstat(stagingRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('verifies all native bytes before exclusively staging only macOS signing files', async () => { + const fixture = await runtimeFixture('darwin-arm64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const stagingRoot = join(fixture.root, 'stage') + const report = await stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot, + selection + }) + + expect(report.stagedFiles.map((entry) => entry.path)).toEqual( + selection.signingFiles.map((entry) => entry.path) + ) + await expect(lstat(join(stagingRoot, 'bin', 'node'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + for (const entry of selection.signingFiles) { + await expect(readFile(join(stagingRoot, ...entry.path.split('/')))).resolves.toEqual( + await readFile(join(fixture.runtimeRoot, ...entry.path.split('/'))) + ) + } + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('does not stage a Windows file whose valid upstream signature is preserved', async () => { + const fixture = await runtimeFixture('win32-x64') + try { + const preservedPath = 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe' + const selection = buildSshRelayRuntimeNativeSigningSelection( + fixture.identity, + windowsAssessments(fixture.identity, preservedPath) + ) + const stagingRoot = join(fixture.root, 'stage') + const report = await stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot, + selection + }) + + expect(report.stagedFiles).toHaveLength(4) + await expect(lstat(join(stagingRoot, ...preservedPath.split('/')))).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('rejects source mutation before creating a stage', async () => { + const fixture = await runtimeFixture('darwin-x64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const stagingRoot = join(fixture.root, 'stage') + await appendFile(join(fixture.runtimeRoot, 'bin', 'node'), ':mutated') + await expect( + stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot, + selection + }) + ).rejects.toThrow(/authenticated size/i) + await expect(lstat(stagingRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('rejects an existing or source-nested staging root', async () => { + const fixture = await runtimeFixture('darwin-arm64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const existing = join(fixture.root, 'existing') + await mkdir(existing) + await expect( + stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot: existing, + selection + }) + ).rejects.toThrow(/exclusive/i) + await expect( + stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot: join(fixture.runtimeRoot, 'nested-stage'), + selection + }) + ).rejects.toThrow(/outside the runtime/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('removes a partial stage when authenticated signing-file copy fails', async () => { + const fixture = await runtimeFixture('darwin-arm64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + selection.signingFiles[1].sourceSha256 = `sha256:${'f'.repeat(64)}` + const stagingRoot = join(fixture.root, 'stage') + await expect( + stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot, + selection + }) + ).rejects.toThrow(/changed during copy/i) + await expect(lstat(stagingRoot)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('accepts an exact returned tree only after every staged hash changes', async () => { + const fixture = await runtimeFixture('darwin-arm64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const stagingRoot = join(fixture.root, 'stage') + const returnedRoot = join(fixture.root, 'returned') + await stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot, + selection + }) + await createReturnedTree(stagingRoot, returnedRoot, selection) + + const report = await verifySshRelayRuntimeNativeSigningReturn({ returnedRoot, selection }) + expect(report.returnedFiles).toHaveLength(3) + expect(report.returnedFiles.every((entry) => entry.signedSha256 !== entry.sourceSha256)).toBe( + true + ) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it('rejects unchanged, missing, extra, and oversized returned bytes', async () => { + const fixture = await runtimeFixture('darwin-x64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const stagingRoot = join(fixture.root, 'stage') + await stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot, + selection + }) + + const unchangedRoot = join(fixture.root, 'unchanged') + await cp(stagingRoot, unchangedRoot, { recursive: true }) + await expect( + verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: unchangedRoot, selection }) + ).rejects.toThrow(/did not change/i) + + const missingRoot = join(fixture.root, 'missing') + await createReturnedTree(stagingRoot, missingRoot, selection) + await rm(join(missingRoot, ...selection.signingFiles[0].path.split('/'))) + await expect( + verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: missingRoot, selection }) + ).rejects.toThrow(/missing returned file/i) + + const extraRoot = join(fixture.root, 'extra') + await createReturnedTree(stagingRoot, extraRoot, selection) + await writeFile(join(extraRoot, 'extra.node'), 'extra') + await expect( + verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: extraRoot, selection }) + ).rejects.toThrow(/unexpected returned file/i) + + const oversizedRoot = join(fixture.root, 'oversized') + await createReturnedTree(stagingRoot, oversizedRoot, selection) + const first = selection.signingFiles[0] + await truncate( + join(oversizedRoot, ...first.path.split('/')), + first.sourceSize + 4 * 1024 * 1024 + 1 + ) + await expect( + verifySshRelayRuntimeNativeSigningReturn({ returnedRoot: oversizedRoot, selection }) + ).rejects.toThrow(/size bound/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'rejects a symlink in returned signing bytes', + async () => { + const fixture = await runtimeFixture('darwin-arm64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const returnedRoot = join(fixture.root, 'returned') + const target = join(fixture.root, 'target') + const first = selection.signingFiles[0] + await writeFile(target, 'signed') + await mkdir(dirname(join(returnedRoot, ...first.path.split('/'))), { recursive: true }) + await symlink(target, join(returnedRoot, ...first.path.split('/'))) + await expect( + verifySshRelayRuntimeNativeSigningReturn({ returnedRoot, selection }) + ).rejects.toThrow(/symbolic link/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')('rejects a symlinked native source file', async () => { + const fixture = await runtimeFixture('darwin-x64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const first = selection.signingFiles[0] + const sourcePath = join(fixture.runtimeRoot, ...first.path.split('/')) + const target = join(fixture.root, 'native-target') + await cp(sourcePath, target) + await rm(sourcePath) + await symlink(target, sourcePath) + await expect( + stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot: join(fixture.root, 'stage'), + selection + }) + ).rejects.toThrow(/source is a symbolic link/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'rejects a symlinked staging parent that resolves inside the runtime', + async () => { + const fixture = await runtimeFixture('darwin-arm64') + try { + const selection = buildSshRelayRuntimeNativeSigningSelection(fixture.identity, []) + const redirectedParent = join(fixture.root, 'redirected-parent') + await symlink(fixture.runtimeRoot, redirectedParent) + await expect( + stageSshRelayRuntimeNativeSigningPayload({ + runtimeRoot: fixture.runtimeRoot, + stagingRoot: join(redirectedParent, 'stage'), + selection + }) + ).rejects.toThrow(/outside the runtime/i) + } finally { + await rm(fixture.root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/config/scripts/ssh-relay-runtime-native-signing-selection.mjs b/config/scripts/ssh-relay-runtime-native-signing-selection.mjs new file mode 100644 index 00000000000..fdc5debc3d3 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-native-signing-selection.mjs @@ -0,0 +1,160 @@ +import { buildSshRelayRuntimeNativeSigningPlan } from './ssh-relay-runtime-native-signing-plan.mjs' + +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/ +const WINDOWS_UNSIGNED_FIELDS = ['path', 'sourceSha256', 'status'] +const WINDOWS_VALID_FIELDS = ['path', 'signerSubject', 'signerThumbprint', 'sourceSha256', 'status'] + +function assertExactFields(value, expected, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Runtime native signing ${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 native signing ${label} has unexpected fields`) + } +} + +function authenticatedFile(identityEntries, planEntry) { + const entry = identityEntries.get(planEntry.path) + if (!entry || entry.type !== 'file') { + throw new Error(`Runtime native signing identity is missing file: ${planEntry.path}`) + } + if (!Number.isSafeInteger(entry.size) || entry.size <= 0) { + throw new Error(`Runtime native signing identity has invalid size: ${planEntry.path}`) + } + if (!SHA256_PATTERN.test(entry.sha256)) { + throw new Error(`Runtime native signing identity has invalid digest: ${planEntry.path}`) + } + if (entry.sha256 !== planEntry.sourceSha256 || entry.role !== planEntry.role) { + throw new Error(`Runtime native signing identity disagrees with plan: ${planEntry.path}`) + } + return { + path: entry.path, + role: entry.role, + sourceSha256: entry.sha256, + sourceSize: entry.size + } +} + +function indexIdentityFiles(identity) { + const entries = new Map() + for (const entry of identity.entries) { + if (entry.type === 'file') { + entries.set(entry.path, entry) + } + } + return entries +} + +function containsAsciiControl(value) { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) + return codePoint <= 0x1f || codePoint === 0x7f + }) +} + +function assertSignerIdentity(assessment) { + if ( + typeof assessment.signerSubject !== 'string' || + assessment.signerSubject.length === 0 || + assessment.signerSubject.length > 1024 || + containsAsciiControl(assessment.signerSubject) + ) { + throw new Error( + `Runtime native signing assessment has invalid signer subject: ${assessment.path}` + ) + } + if (!/^[0-9a-f]{40}$/i.test(assessment.signerThumbprint)) { + throw new Error( + `Runtime native signing assessment has invalid signer thumbprint: ${assessment.path}` + ) + } +} + +function windowsSelection(plan, identityEntries, assessments) { + if (!Array.isArray(assessments)) { + throw new Error('Runtime native signing Windows assessments must be an array') + } + const candidates = new Map(plan.signingCandidates.map((entry) => [entry.path, entry])) + const indexed = new Map() + for (const assessment of assessments) { + if (!assessment || typeof assessment.path !== 'string' || !candidates.has(assessment.path)) { + throw new Error(`Runtime native signing has unexpected assessment: ${assessment?.path}`) + } + if (indexed.has(assessment.path)) { + throw new Error(`Runtime native signing has duplicate assessment: ${assessment.path}`) + } + if (!['unsigned', 'valid-upstream'].includes(assessment.status)) { + throw new Error(`Runtime native signing rejects signature status: ${assessment.status}`) + } + assertExactFields( + assessment, + assessment.status === 'unsigned' ? WINDOWS_UNSIGNED_FIELDS : WINDOWS_VALID_FIELDS, + 'assessment' + ) + const candidate = candidates.get(assessment.path) + if (assessment.sourceSha256 !== candidate.sourceSha256) { + throw new Error(`Runtime native signing assessment has wrong source hash: ${assessment.path}`) + } + if (assessment.status === 'valid-upstream') { + assertSignerIdentity(assessment) + } + indexed.set(assessment.path, assessment) + } + + return plan.signingCandidates.map((candidate) => { + const assessment = indexed.get(candidate.path) + if (!assessment) { + throw new Error(`Runtime native signing is missing assessment: ${candidate.path}`) + } + const file = authenticatedFile(identityEntries, candidate) + return assessment.status === 'unsigned' + ? { ...file, action: 'signpath-required' } + : { + ...file, + action: 'preserve-valid-upstream', + signerSubject: assessment.signerSubject, + signerThumbprint: assessment.signerThumbprint.toUpperCase() + } + }) +} + +export function buildSshRelayRuntimeNativeSigningSelection(identity, assessments) { + const plan = buildSshRelayRuntimeNativeSigningPlan(identity) + const identityEntries = indexIdentityFiles(identity) + const immutableVendorFiles = plan.immutableVendorFiles.map((entry) => ({ + ...authenticatedFile(identityEntries, entry), + action: entry.action + })) + const verificationFiles = plan.verificationFiles.map((entry) => + authenticatedFile(identityEntries, entry) + ) + + let candidateFiles + if (plan.platform === 'win32') { + candidateFiles = windowsSelection(plan, identityEntries, assessments) + } else { + if (!Array.isArray(assessments) || assessments.length !== 0) { + throw new Error(`Runtime native signing ${plan.platform} does not accept assessments`) + } + candidateFiles = plan.signingCandidates.map((entry) => ({ + ...authenticatedFile(identityEntries, entry), + action: entry.action + })) + } + + return { + tupleId: plan.tupleId, + platform: plan.platform, + policy: plan.policy, + immutableVendorFiles, + signingFiles: candidateFiles.filter((entry) => entry.action !== 'preserve-valid-upstream'), + preservedUpstreamFiles: candidateFiles.filter( + (entry) => entry.action === 'preserve-valid-upstream' + ), + verificationFiles + } +} diff --git a/config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs b/config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs new file mode 100644 index 00000000000..c23c107fdd6 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-native-signing-selection.test.mjs @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' + +import { expectedSshRelayRuntimeClosureEntries } from './ssh-relay-runtime-closure.mjs' +import { buildSshRelayRuntimeNativeSigningPlan } from './ssh-relay-runtime-native-signing-plan.mjs' +import { buildSshRelayRuntimeNativeSigningSelection } from './ssh-relay-runtime-native-signing-selection.mjs' + +const DIGEST = `sha256:${'a'.repeat(64)}` + +function identityFor(tupleId) { + const os = tupleId.startsWith('linux-') + ? 'linux' + : tupleId.startsWith('darwin-') + ? 'darwin' + : 'win32' + return { + tupleId, + os, + nodeVersion: '24.18.0', + dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' }, + entries: expectedSshRelayRuntimeClosureEntries(tupleId).map((entry) => + entry.type === 'file' ? { ...entry, size: 100, sha256: DIGEST } : entry + ) + } +} + +function windowsAssessments(identity, preservedPath) { + return buildSshRelayRuntimeNativeSigningPlan(identity).signingCandidates.map((entry) => + entry.path === preservedPath + ? { + path: entry.path, + sourceSha256: entry.sourceSha256, + status: 'valid-upstream', + signerSubject: 'CN=Microsoft Corporation', + signerThumbprint: 'A'.repeat(40) + } + : { path: entry.path, sourceSha256: entry.sourceSha256, status: 'unsigned' } + ) +} + +describe('SSH relay runtime native signing selection', () => { + it('keeps Linux hash-only and official Node verification exact', () => { + const selection = buildSshRelayRuntimeNativeSigningSelection(identityFor('linux-x64-glibc'), []) + + expect(selection.signingFiles).toEqual([]) + expect(selection.immutableVendorFiles).toEqual([ + expect.objectContaining({ path: 'bin/node', action: 'preserve-exact-bytes', sourceSize: 100 }) + ]) + expect(selection.verificationFiles).toHaveLength(3) + }) + + it('requires every macOS candidate and ignores no assessment input', () => { + const identity = identityFor('darwin-arm64') + const selection = buildSshRelayRuntimeNativeSigningSelection(identity, []) + + expect(selection.signingFiles.map((entry) => [entry.path, entry.action])).toEqual([ + ['node_modules/@parcel/watcher-darwin-arm64/watcher.node', 'developer-id-required'], + ['node_modules/node-pty/build/Release/pty.node', 'developer-id-required'], + ['node_modules/node-pty/build/Release/spawn-helper', 'developer-id-required'] + ]) + expect(() => + buildSshRelayRuntimeNativeSigningSelection(identity, [ + { path: selection.signingFiles[0].path, status: 'unsigned', sourceSha256: DIGEST } + ]) + ).toThrow(/does not accept assessments/i) + }) + + it('stages only unsigned Windows candidates and preserves valid upstream identity', () => { + const identity = identityFor('win32-x64') + const preservedPath = 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe' + const selection = buildSshRelayRuntimeNativeSigningSelection( + identity, + windowsAssessments(identity, preservedPath) + ) + + expect(selection.signingFiles).toHaveLength(4) + expect(selection.preservedUpstreamFiles).toEqual([ + expect.objectContaining({ + path: preservedPath, + action: 'preserve-valid-upstream', + signerSubject: 'CN=Microsoft Corporation', + signerThumbprint: 'A'.repeat(40) + }) + ]) + expect(selection.verificationFiles).toHaveLength(6) + }) + + it('rejects invalid Windows signature states and malformed signer identity', () => { + const identity = identityFor('win32-arm64') + const assessments = windowsAssessments(identity) + assessments[0].status = 'hash-mismatch' + expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, assessments)).toThrow( + /signature status/i + ) + + const malformedSigner = windowsAssessments( + identity, + 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe' + ) + malformedSigner.find((entry) => entry.status === 'valid-upstream').signerThumbprint = 'bad' + expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, malformedSigner)).toThrow( + /thumbprint/i + ) + + const controlSubject = windowsAssessments( + identity, + 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe' + ) + controlSubject.find((entry) => entry.status === 'valid-upstream').signerSubject = + 'CN=Microsoft\nCorporation' + expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, controlSubject)).toThrow( + /signer subject/i + ) + }) + + it('rejects assessment fields outside the exact status-qualified schema', () => { + const identity = identityFor('win32-x64') + const unexpected = windowsAssessments(identity) + unexpected[0].signerSubject = 'CN=Unexpected' + expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, unexpected)).toThrow( + /unexpected fields/i + ) + + const preserved = windowsAssessments( + identity, + 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe' + ) + preserved.find((entry) => entry.status === 'valid-upstream').unexpected = true + expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, preserved)).toThrow( + /unexpected fields/i + ) + }) + + it('rejects missing, duplicate, extra, and hash-mismatched assessments', () => { + const identity = identityFor('win32-x64') + const assessments = windowsAssessments(identity) + expect(() => + buildSshRelayRuntimeNativeSigningSelection(identity, assessments.slice(1)) + ).toThrow(/missing assessment/i) + + expect(() => + buildSshRelayRuntimeNativeSigningSelection(identity, [assessments[0], ...assessments]) + ).toThrow(/duplicate assessment/i) + + expect(() => + buildSshRelayRuntimeNativeSigningSelection(identity, [ + ...assessments, + { path: 'extra.exe', sourceSha256: DIGEST, status: 'unsigned' } + ]) + ).toThrow(/unexpected assessment/i) + + const mismatched = structuredClone(assessments) + mismatched[0].sourceSha256 = `sha256:${'b'.repeat(64)}` + expect(() => buildSshRelayRuntimeNativeSigningSelection(identity, mismatched)).toThrow( + /source hash/i + ) + }) + + it('rejects malformed authenticated size and digest metadata', () => { + const invalidSize = identityFor('darwin-x64') + invalidSize.entries.find((entry) => entry.role === 'node').size = -1 + expect(() => buildSshRelayRuntimeNativeSigningSelection(invalidSize, [])).toThrow(/size/i) + + const invalidDigest = identityFor('darwin-x64') + invalidDigest.entries.find((entry) => entry.role === 'node').sha256 = 'sha256:nope' + expect(() => buildSshRelayRuntimeNativeSigningSelection(invalidDigest, [])).toThrow(/digest/i) + }) +}) diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 8578c1b7368..fd24d8b48ef 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -82,9 +82,17 @@ describe('SSH relay runtime artifact workflow', () => { expect(source.match(/ssh-relay-runtime-provenance\.test\.mjs/g)).toHaveLength(4) expect(source.match(/ssh-relay-runtime-toolchain\.test\.mjs/g)).toHaveLength(4) expect(source.match(/ssh-relay-runtime-native-signing-plan\.test\.mjs/g)).toHaveLength(4) + expect(source.match(/ssh-relay-runtime-native-signing-selection\.test\.mjs/g)).toHaveLength(4) + expect(source.match(/ssh-relay-runtime-native-signing-payload\.test\.mjs/g)).toHaveLength(4) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-plan\.mjs/g) ).toHaveLength(2) + expect( + source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-selection\.mjs/g) + ).toHaveLength(2) + expect( + source.match(/node --check config\/scripts\/ssh-relay-runtime-native-signing-payload\.mjs/g) + ).toHaveLength(2) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-closure\.mjs/g) ).toHaveLength(2)