132 lines
6.0 KiB
JavaScript
132 lines
6.0 KiB
JavaScript
#!/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 <release-dir> <recovery-root-public-key> <dsh-tarball> <supported-target.json> <environment-definition.json>')
|
|
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`)
|
|
}
|