#!/usr/bin/env node import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseBootstrapManifest, parseEnvironmentManifest, parseStable } from '../lib/contracts.mjs' import { validateSupportedTarget } from '../lib/supported-target.mjs' import { verifyRecoveryKeyring } from '../stage0/lib/keyring.mjs' import { verifyDetached } from '../stage0/lib/signature.mjs' async function verifyFile(path, descriptor, label) { const bytes = await readFile(path) const digest = createHash('sha256').update(bytes).digest('hex') if (bytes.byteLength !== descriptor.size || digest !== descriptor.sha256) { throw new Error(`${label} does not match its signed descriptor`) } return bytes } function descriptorById(artifacts, id, label) { const descriptor = artifacts.find(candidate => candidate.id === id) if (descriptor === undefined) throw new Error(`${label} ${id} is missing`) return descriptor } function assetPath(root, descriptor) { const name = basename(new URL(descriptor.url).pathname) if (name === '' || name === '.' || name === '..') throw new Error(`Artifact ${descriptor.id} has no safe filename`) return join(root, name) } function assertChannelTarget(artifacts, targetSequence, label) { const immutableTarget = `/release-channel/targets/${String(targetSequence)}/` if (artifacts.some(descriptor => !new URL(descriptor.url).pathname.includes(immutableTarget))) { throw new Error(`${label} Artifact URLs must use its immutable signed channel target`) } } async function verifiedManifest({ releaseRoot, stable, desired, keyring, parser, label }) { const manifestDescriptor = descriptorById(stable.artifacts, desired.manifestArtifactId, `${label} manifest`) const signatureDescriptor = descriptorById(stable.artifacts, desired.signatureArtifactId, `${label} signature`) const manifestBytes = await verifyFile(assetPath(releaseRoot, manifestDescriptor), manifestDescriptor, `${label} manifest`) const signatureBytes = await verifyFile(assetPath(releaseRoot, signatureDescriptor), signatureDescriptor, `${label} signature`) verifyDetached(manifestBytes, JSON.parse(signatureBytes.toString('utf8')), keyring.current.publicKey, `${label} manifest`) const manifest = parser(manifestBytes) if (manifest.version !== desired.version || manifest.keyringGeneration !== stable.keyringGeneration || manifest.targetSequence !== stable.targetSequence) { throw new Error(`${label} manifest does not match Stable metadata`) } assertChannelTarget(manifest.artifacts, stable.targetSequence, label) const paths = new Map() for (const descriptor of manifest.artifacts) { const path = assetPath(releaseRoot, descriptor) await verifyFile(path, descriptor, `${label} Artifact ${descriptor.id}`) paths.set(descriptor.id, path) } return Object.freeze({ manifest, manifestBytes, paths }) } export async function verifyImageRelease({ releaseRoot, recoveryPublicKeyPath, dshTarballPath, supportedTargetPath, environmentDefinitionPath }) { const root = resolve(releaseRoot) const recoveryPublicKey = (await readFile(resolve(recoveryPublicKeyPath), 'utf8')).trim() const keyringBytes = await readFile(join(root, 'keyring.json')) const keyring = verifyRecoveryKeyring( keyringBytes, JSON.parse(await readFile(join(root, 'keyring.sig.json'), 'utf8')), recoveryPublicKey, ) const stableBytes = await readFile(join(root, 'stable.json')) const stable = parseStable(stableBytes) verifyDetached( stableBytes, JSON.parse(await readFile(join(root, 'stable.sig.json'), 'utf8')), keyring.current.publicKey, 'Stable metadata', ) if (stable.keyringGeneration !== keyring.generation) throw new Error('Stable metadata does not match keyring generation') const target = validateSupportedTarget( await readFile(resolve(supportedTargetPath)), await readFile(resolve(environmentDefinitionPath)), ) if (stable.desired.dsh.version !== target.latestSupportedDsh || stable.desired.environment.version !== target.environment) { throw new Error('Stable metadata does not match the reviewed Supported Target') } assertChannelTarget(stable.artifacts, stable.targetSequence, 'Stable') const bootstrap = await verifiedManifest({ releaseRoot: root, stable, desired: stable.desired.bootstrap, keyring, parser: parseBootstrapManifest, label: 'Bootstrap', }) const environment = await verifiedManifest({ releaseRoot: root, stable, desired: stable.desired.environment, keyring, parser: parseEnvironmentManifest, label: 'Environment', }) const dshBytes = await readFile(resolve(dshTarballPath)) const actualIntegrity = `sha512-${createHash('sha512').update(dshBytes).digest('base64')}` if (actualIntegrity !== stable.desired.dsh.integrity) throw new Error('DSH tarball does not match signed npm integrity') return Object.freeze({ stable, keyring, bootstrap, environment, dshTarballPath: resolve(dshTarballPath) }) } const invoked = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url) if (invoked) { const [releaseRoot, recoveryPublicKeyPath, dshTarballPath, supportedTargetPath, environmentDefinitionPath] = process.argv.slice(2) if ([releaseRoot, recoveryPublicKeyPath, dshTarballPath, supportedTargetPath, environmentDefinitionPath].includes(undefined)) { console.error('usage: verify-image-release.mjs ') process.exit(64) } const verified = await verifyImageRelease({ releaseRoot, recoveryPublicKeyPath, dshTarballPath, supportedTargetPath, environmentDefinitionPath, }) process.stdout.write(`${JSON.stringify({ targetSequence: verified.stable.targetSequence, dshVersion: verified.stable.desired.dsh.version, environmentVersion: verified.environment.manifest.version, bootstrapVersion: verified.bootstrap.manifest.version, })}\n`) }