Files
orca/config/scripts/ssh-relay-node-release-contract.mjs
T
2026-07-14 09:58:21 -04:00

263 lines
9.3 KiB
JavaScript

import { createHash } from 'node:crypto'
const EXPECTED_TUPLES = Object.freeze([
'darwin-arm64',
'darwin-x64',
'linux-arm64-glibc',
'linux-x64-glibc',
'win32-arm64',
'win32-x64'
])
const ARCHIVE_SUFFIXES = Object.freeze({
'darwin-arm64': 'darwin-arm64.tar.xz',
'darwin-x64': 'darwin-x64.tar.xz',
'linux-arm64-glibc': 'linux-arm64.tar.xz',
'linux-x64-glibc': 'linux-x64.tar.xz',
'win32-arm64': 'win-arm64.zip',
'win32-x64': 'win-x64.zip'
})
const WINDOWS_LIBRARY_NAMES = Object.freeze({
'win32-arm64': 'win-arm64/node.lib',
'win32-x64': 'win-x64/node.lib'
})
const HEX_SHA256 = /^[0-9a-f]{64}$/
const OPENPGP_FINGERPRINT = /^[0-9A-F]{40}$/
const NODE_VERSION = /^\d+\.\d+\.\d+$/
const MAX_METADATA_BYTES = 16 * 1024 * 1024
const MAX_SIGNATURE_BYTES = 1024 * 1024
const MAX_ARCHIVE_BYTES = 8 * 1024 * 1024 * 1024
function assertRecord(value, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object`)
}
}
function assertExactKeys(value, keys, label) {
const actual = Object.keys(value).sort()
const expected = [...keys].sort()
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
throw new Error(`${label} contains unknown or missing fields`)
}
}
function assertSha256(value, label) {
if (typeof value !== 'string' || !HEX_SHA256.test(value)) {
throw new Error(`${label} must be a lowercase SHA-256 digest`)
}
}
function assertByteLimit(value, maximum, label) {
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
throw new Error(`${label} must be a positive safe integer no larger than ${maximum}`)
}
}
function expectedArchiveName(nodeVersion, tuple) {
return `node-v${nodeVersion}-${ARCHIVE_SUFFIXES[tuple]}`
}
function validateMetadataContract(release) {
assertRecord(release.checksumDocument, 'checksum document')
assertExactKeys(release.checksumDocument, ['name', 'sha256', 'maximumBytes'], 'checksum document')
if (release.checksumDocument.name !== 'SHASUMS256.txt') {
throw new Error('checksum document name must be SHASUMS256.txt')
}
assertSha256(release.checksumDocument.sha256, 'checksum document SHA-256')
assertByteLimit(
release.checksumDocument.maximumBytes,
MAX_METADATA_BYTES,
'checksum document size limit'
)
assertRecord(release.signature, 'signature')
assertExactKeys(
release.signature,
['name', 'sha256', 'maximumBytes', 'signerFingerprint', 'key'],
'signature'
)
if (release.signature.name !== 'SHASUMS256.txt.sig') {
throw new Error('signature name must be SHASUMS256.txt.sig')
}
assertSha256(release.signature.sha256, 'signature SHA-256')
assertByteLimit(release.signature.maximumBytes, MAX_SIGNATURE_BYTES, 'signature size limit')
if (!OPENPGP_FINGERPRINT.test(release.signature.signerFingerprint)) {
throw new Error('signature signer fingerprint must be 40 uppercase hexadecimal characters')
}
assertRecord(release.signature.key, 'signature key')
assertExactKeys(
release.signature.key,
['path', 'sha256', 'sourceCommit', 'sourceUrl'],
'signature key'
)
if (typeof release.signature.key.path !== 'string' || release.signature.key.path.length === 0) {
throw new Error('signature key path must be a non-empty string')
}
assertSha256(release.signature.key.sha256, 'signature key SHA-256')
if (!/^[0-9a-f]{40}$/.test(release.signature.key.sourceCommit)) {
throw new Error('signature key source commit must be a full lowercase SHA-1')
}
const expectedKeyUrl =
`https://raw.githubusercontent.com/nodejs/release-keys/${release.signature.key.sourceCommit}` +
`/keys/${release.signature.signerFingerprint}.asc`
if (release.signature.key.sourceUrl !== expectedKeyUrl) {
throw new Error('signature key source URL must use its exact immutable repository commit')
}
}
function validateArchives(release) {
assertByteLimit(release.maximumArchiveBytes, MAX_ARCHIVE_BYTES, 'archive size limit')
assertRecord(release.archives, 'archives')
const tuples = Object.keys(release.archives).sort()
if (
tuples.length !== EXPECTED_TUPLES.length ||
tuples.some((tuple, index) => tuple !== EXPECTED_TUPLES[index])
) {
throw new Error(`archive tuple set must be exactly: ${EXPECTED_TUPLES.join(', ')}`)
}
for (const tuple of EXPECTED_TUPLES) {
const archive = release.archives[tuple]
assertRecord(archive, `archive ${tuple}`)
assertExactKeys(archive, ['name', 'sha256'], `archive ${tuple}`)
if (archive.name !== expectedArchiveName(release.nodeVersion, tuple)) {
throw new Error(`archive ${tuple} name does not match the pinned Node version and tuple`)
}
assertSha256(archive.sha256, `archive ${tuple} SHA-256`)
}
}
function validateWindowsBuildInputs(release) {
assertRecord(release.windowsBuildInputs, 'Windows build inputs')
assertExactKeys(
release.windowsBuildInputs,
['headersArchive', 'importLibraries'],
'Windows build inputs'
)
const headers = release.windowsBuildInputs.headersArchive
assertRecord(headers, 'Windows headers archive')
assertExactKeys(headers, ['name', 'sha256'], 'Windows headers archive')
if (headers.name !== `node-v${release.nodeVersion}-headers.tar.gz`) {
throw new Error('Windows headers archive name does not match the pinned Node version')
}
assertSha256(headers.sha256, 'Windows headers archive SHA-256')
const libraries = release.windowsBuildInputs.importLibraries
assertRecord(libraries, 'Windows import libraries')
assertExactKeys(libraries, Object.keys(WINDOWS_LIBRARY_NAMES), 'Windows import libraries')
for (const [tuple, expectedName] of Object.entries(WINDOWS_LIBRARY_NAMES)) {
const library = libraries[tuple]
assertRecord(library, `Windows import library ${tuple}`)
assertExactKeys(library, ['name', 'sha256'], `Windows import library ${tuple}`)
if (library.name !== expectedName) {
throw new Error(`Windows import library name does not match ${tuple}`)
}
assertSha256(library.sha256, `Windows import library ${tuple} SHA-256`)
}
}
export function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
export function validateSshRelayNodeReleaseContract(release) {
assertRecord(release, 'Node release contract')
assertExactKeys(
release,
[
'schemaVersion',
'nodeVersion',
'baseUrl',
'checksumDocument',
'signature',
'maximumArchiveBytes',
'archives',
'windowsBuildInputs'
],
'Node release contract'
)
if (release.schemaVersion !== 1) {
throw new Error('Node release contract schemaVersion must be 1')
}
if (typeof release.nodeVersion !== 'string' || !NODE_VERSION.test(release.nodeVersion)) {
throw new Error('Node version must be an exact three-component release')
}
if (release.baseUrl !== `https://nodejs.org/dist/v${release.nodeVersion}`) {
throw new Error('Node release base URL must be the immutable exact-version nodejs.org URL')
}
validateMetadataContract(release)
validateArchives(release)
validateWindowsBuildInputs(release)
return release
}
function parseChecksumLines(bytes) {
let source
try {
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
throw new Error('Node checksum document must be valid UTF-8')
}
const lines = source.endsWith('\n') ? source.slice(0, -1).split('\n') : source.split('\n')
const entries = new Map()
for (const line of lines) {
const match = /^([0-9a-f]{64}) ([!-~]+)$/.exec(line)
if (!match) {
throw new Error('Node checksum document contains a malformed line')
}
const [, digest, name] = match
if (entries.has(name)) {
throw new Error(`Node checksum document contains duplicate entry: ${name}`)
}
entries.set(name, digest)
}
return entries
}
export function verifySshRelayNodeChecksumDocument(releaseInput, bytesInput) {
const release = validateSshRelayNodeReleaseContract(releaseInput)
if (!Buffer.isBuffer(bytesInput) && !(bytesInput instanceof Uint8Array)) {
throw new Error('Node checksum document must be bytes')
}
const bytes = Buffer.from(bytesInput)
if (bytes.length === 0 || bytes.length > release.checksumDocument.maximumBytes) {
throw new Error('Node checksum document exceeds its size limit')
}
// Parse first so duplicate or malformed names cannot be hidden behind a digest mismatch.
const entries = parseChecksumLines(bytes)
if (sha256(bytes) !== release.checksumDocument.sha256) {
throw new Error('Node checksum document SHA-256 does not match the pinned contract')
}
const expectedFiles = [
...EXPECTED_TUPLES.map((tuple) => ({
kind: 'runtime-archive',
tuple,
...release.archives[tuple]
})),
{ kind: 'windows-headers', ...release.windowsBuildInputs.headersArchive },
...Object.entries(release.windowsBuildInputs.importLibraries).map(([tuple, file]) => ({
kind: 'windows-import-library',
tuple,
...file
}))
]
return expectedFiles.map((file) => {
const documentDigest = entries.get(file.name)
if (documentDigest === undefined) {
throw new Error(`Node checksum document is missing pinned input: ${file.name}`)
}
if (documentDigest !== file.sha256) {
throw new Error(`Node checksum document digest mismatch for pinned input: ${file.name}`)
}
return file
})
}
export { EXPECTED_TUPLES }