import { createHash } from 'node:crypto'; import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; import { byteDigest, canonicalJsonDigest, canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../../src/security/canonical-digest.ts'; import { validatePublicPath } from '../../../src/security/production-input-root.ts'; import { validateReleaseManifest } from '../../../src/security/release-manifest.ts'; const encoder = new TextEncoder(); const reservedPrefix = '.release/'; const safeToken = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; export function exactObject(value, fields, label) { if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`); const actual = Object.keys(value).sort(); const expected = [...fields].sort(); if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) { throw new Error(`${label} contains missing or unknown fields.`); } return value; } export function assertToken(value, label) { if (typeof value !== 'string' || !safeToken.test(value)) throw new Error(`${label} is invalid.`); return value; } export function assertTimestamp(value, label) { if (typeof value !== 'string' || !timestamp.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} is invalid.`); return value; } export function assertDigest(value, label) { if (!isSha256Hex(value)) throw new Error(`${label} is invalid.`); return value; } export async function readCanonicalFile(path, label) { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular file.`); return parseCanonicalJson(await readFile(path)); } export async function loadVerifier(modulePath) { if (!isAbsolute(modulePath)) throw new Error('Verifier module path must be absolute.'); const info = await lstat(modulePath); if (!info.isFile() || info.isSymbolicLink()) throw new Error('Verifier module must be a regular file.'); const module = await import(/* @vite-ignore */ pathToFileURL(await realpath(modulePath)).href); if (typeof module.verifySignedReleaseEnvelope !== 'function') throw new Error('Verifier module does not expose verifySignedReleaseEnvelope.'); return module.verifySignedReleaseEnvelope; } export function validateProtectedState(value) { const state = exactObject(value, ['latestLedgerHeadHash', 'latestLedgerRevision', 'currentManifestDigest', 'publisherFence'], 'protected state'); assertDigest(state.latestLedgerHeadHash, 'protected ledger head'); if (!Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0 || !Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 || (state.currentManifestDigest !== null && !isSha256Hex(state.currentManifestDigest))) throw new Error('Protected state is invalid.'); return state; } export function validateAdmission(value, protectedState, now) { const admission = exactObject(value, [ 'schema', 'manifestDigest', 'manifestPayload', 'expiresAt', 'predecessorManifestDigest', 'latestLedgerHeadHash', 'latestLedgerRevision', 'expectedFence', 'signatureVerified', ], 'verified release admission'); if (admission.schema !== 'portfolio-verified-release-admission/v1' || admission.signatureVerified !== true) { throw new Error('Release signature was not verified.'); } assertDigest(admission.manifestDigest, 'manifest digest'); assertTimestamp(now, 'trusted time'); assertTimestamp(admission.expiresAt, 'admission expiry'); if (Date.parse(now) >= Date.parse(admission.expiresAt)) throw new Error('Release admission is expired.'); if (admission.predecessorManifestDigest !== null) assertDigest(admission.predecessorManifestDigest, 'predecessor manifest digest'); const state = validateProtectedState(protectedState); if (admission.latestLedgerHeadHash !== state.latestLedgerHeadHash || admission.latestLedgerRevision !== state.latestLedgerRevision) { throw new Error('Release admission is stale relative to the protected ledger.'); } if (admission.predecessorManifestDigest !== state.currentManifestDigest || admission.expectedFence !== state.publisherFence + 1) { throw new Error('Release admission predecessor or fence is stale.'); } const manifest = validateReleaseManifest(admission.manifestPayload, { now }); if (manifest.expiresAt !== admission.expiresAt || manifest.currentManifestDigest !== admission.predecessorManifestDigest || manifest.predecessorManifestDigest !== admission.predecessorManifestDigest) throw new Error('Admission does not bind the embedded release manifest.'); return { admission, manifest }; } export async function verifySignedRelease({ signedManifestPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState, now }) { const [signedRelease, trustPolicy] = await Promise.all([ readCanonicalFile(signedManifestPath, 'signed release'), readCanonicalFile(trustPolicyPath, 'trust policy'), ]); const verifier = verifySignedReleaseEnvelope ?? await loadVerifier(verifierModulePath); if (typeof verifier !== 'function') throw new Error('A release signature verifier is required.'); const result = await verifier({ signedRelease, trustPolicy, now, protectedState }); const verified = validateAdmission(result, protectedState, now); return { ...verified, signedRelease, signedReleaseBytes: canonicalValueBytes(signedRelease) }; } export async function enumerateRegularFiles(root) { const rootPath = await realpath(root); const output = []; async function visit(directory, prefix = '') { const entries = await readdir(directory, { withFileTypes: true }); entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name))); for (const entry of entries) { const path = join(directory, entry.name); const publicPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`; if (entry.isSymbolicLink()) throw new Error(`Symlink is forbidden in release closure: ${publicPath}`); if (entry.isDirectory()) await visit(path, publicPath); else if (entry.isFile()) output.push({ path, publicPath }); else throw new Error(`Non-regular release input is forbidden: ${publicPath}`); } } await visit(rootPath); return output; } function splitTarPath(path) { const bytes = encoder.encode(path); if (bytes.length <= 100) return { name: path, prefix: '' }; const segments = path.split('/'); for (let index = segments.length - 1; index > 0; index -= 1) { const prefix = segments.slice(0, index).join('/'); const name = segments.slice(index).join('/'); if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100) return { name, prefix }; } throw new Error(`Release path is too long for deterministic ustar: ${path}`); } function writeString(target, offset, length, value) { const bytes = encoder.encode(value); if (bytes.length > length) throw new Error('Tar header value exceeds its field.'); target.set(bytes, offset); } function writeOctal(target, offset, length, value) { const text = value.toString(8).padStart(length - 1, '0'); if (text.length !== length - 1) throw new Error('Tar numeric field overflow.'); writeString(target, offset, length, `${text}\0`); } function tarHeader(path, size) { const header = new Uint8Array(512); const { name, prefix } = splitTarPath(path); writeString(header, 0, 100, name); writeOctal(header, 100, 8, 0o444); writeOctal(header, 108, 8, 0); writeOctal(header, 116, 8, 0); writeOctal(header, 124, 12, size); writeOctal(header, 136, 12, 0); header.fill(0x20, 148, 156); writeString(header, 156, 1, '0'); writeString(header, 257, 6, 'ustar\0'); writeString(header, 263, 2, '00'); writeString(header, 345, 155, prefix); const checksum = header.reduce((sum, byte) => sum + byte, 0); const checksumText = checksum.toString(8).padStart(6, '0'); writeString(header, 148, 8, `${checksumText}\0 `); return header; } export function createDeterministicTar(files) { const ordered = [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); const seen = new Set(); const chunks = []; for (const file of ordered) { validatePublicPath(file.path); if (seen.has(file.path)) throw new Error(`Duplicate release archive path: ${file.path}`); seen.add(file.path); const bytes = new Uint8Array(file.bytes); chunks.push(tarHeader(file.path, bytes.length), bytes); const remainder = bytes.length % 512; if (remainder !== 0) chunks.push(new Uint8Array(512 - remainder)); } chunks.push(new Uint8Array(1024)); return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); } function readString(bytes, start, length) { const end = bytes.indexOf(0, start); return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(start, end === -1 || end > start + length ? start + length : end)); } function readOctal(bytes, start, length, label) { const text = readString(bytes, start, length).trim(); if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`); const value = Number.parseInt(text, 8); if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid tar ${label}.`); return value; } export function parseDeterministicTar(source) { const bytes = new Uint8Array(source); const files = []; const seen = new Set(); let offset = 0; let zeroBlocks = 0; while (offset + 512 <= bytes.length) { const header = bytes.subarray(offset, offset + 512); offset += 512; if (header.every((byte) => byte === 0)) { zeroBlocks += 1; if (zeroBlocks === 2) break; continue; } if (zeroBlocks !== 0) throw new Error('Malformed tar end marker.'); const expectedChecksum = readOctal(header, 148, 8, 'checksum'); const checksumHeader = new Uint8Array(header); checksumHeader.fill(0x20, 148, 156); if (checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum) throw new Error('Tar checksum mismatch.'); if (readString(header, 257, 6) !== 'ustar' || String.fromCharCode(header[156] ?? 0) !== '0') throw new Error('Only deterministic regular ustar entries are accepted.'); const name = readString(header, 0, 100); const prefix = readString(header, 345, 155); const path = prefix === '' ? name : `${prefix}/${name}`; validatePublicPath(path); if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`); seen.add(path); const size = readOctal(header, 124, 12, 'size'); if (offset + size > bytes.length) throw new Error('Truncated tar entry.'); files.push({ path, bytes: bytes.slice(offset, offset + size) }); offset += Math.ceil(size / 512) * 512; } if (zeroBlocks !== 2 || offset !== bytes.length) throw new Error('Tar archive has trailing or missing data.'); return files; } export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes }) { const artifacts = [...manifest.artifacts].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); return { schema: 'portfolio-release-package/v1', buildId: manifest.buildId, releaseId: `${manifest.buildId}-${admission.manifestDigest}`, manifestDigest: admission.manifestDigest, predecessorManifestDigest: admission.predecessorManifestDigest, expiresAt: admission.expiresAt, expectedFence: admission.expectedFence, latestLedgerHeadHash: admission.latestLedgerHeadHash, latestLedgerRevision: admission.latestLedgerRevision, signedManifest: { path: '.release/signed-release-manifest.json', sha256: byteDigest(signedBytes), bytes: signedBytes.length }, sbom: { path: '.release/sbom.cdx.json', sha256: byteDigest(sbomBytes), bytes: sbomBytes.length }, artifacts, }; } export function validatePackageFiles(files) { const byPath = new Map(files.map((file) => [file.path, file])); const indexFile = byPath.get('.release/package-index.json'); if (indexFile === undefined) throw new Error('Release package index is missing.'); const index = exactObject(parseCanonicalJson(indexFile.bytes), [ 'schema', 'buildId', 'releaseId', 'manifestDigest', 'predecessorManifestDigest', 'expiresAt', 'expectedFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'signedManifest', 'sbom', 'artifacts', ], 'release package index'); if (index.schema !== 'portfolio-release-package/v1') throw new Error('Release package schema is invalid.'); assertToken(index.buildId, 'package buildId'); assertToken(index.releaseId, 'package releaseId'); assertDigest(index.manifestDigest, 'package manifest digest'); assertTimestamp(index.expiresAt, 'package expiry'); if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1) throw new Error('Package fence is invalid.'); const metadata = [index.signedManifest, index.sbom]; for (const record of metadata) { exactObject(record, ['path', 'sha256', 'bytes'], 'package metadata record'); const file = byPath.get(record.path); if (file === undefined || byteDigest(file.bytes) !== record.sha256 || file.bytes.length !== record.bytes) throw new Error('Package metadata hash or size mismatch.'); } if (!Array.isArray(index.artifacts) || index.artifacts.length === 0) throw new Error('Package artifact closure is empty.'); const expectedPaths = new Set(['.release/package-index.json', '.release/signed-release-manifest.json', '.release/sbom.cdx.json']); for (const artifact of index.artifacts) { exactObject(artifact, ['path', 'sha256', 'bytes', 'encoding'], 'package artifact'); if (typeof artifact.path !== 'string' || artifact.path.startsWith(reservedPrefix)) throw new Error('Package artifact uses a reserved path.'); const file = byPath.get(artifact.path); if (file === undefined || byteDigest(file.bytes) !== artifact.sha256 || file.bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`); expectedPaths.add(artifact.path); } if (expectedPaths.size !== byPath.size || [...byPath.keys()].some((path) => !expectedPaths.has(path))) throw new Error('Release package files do not exactly match the signed closure.'); return { index, byPath }; } export async function writeArchiveFiles(files, targetRoot) { await mkdir(targetRoot, { recursive: false, mode: 0o700 }); const root = resolve(targetRoot); for (const file of files) { const destination = resolve(root, file.path); if (relative(root, destination).startsWith(`..${sep}`) || destination === root) throw new Error('Archive entry escapes staging root.'); await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); await writeFile(destination, file.bytes, { flag: 'wx', mode: 0o600 }); } } export async function makeImmutable(root, uid, gid) { if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0) throw new Error('Release owner UID/GID is invalid.'); async function visit(path) { const info = await lstat(path); if (info.isSymbolicLink()) throw new Error('Symlink found in immutable release.'); if (info.uid !== uid || info.gid !== gid) throw new Error(`Release ownership mismatch at ${path}.`); if (info.isDirectory()) { for (const entry of await readdir(path)) await visit(join(path, entry)); await chmod(path, 0o555); } else if (info.isFile()) await chmod(path, 0o444); else throw new Error('Immutable release contains a non-file entry.'); } await visit(root); } export async function assertReleaseRoot(rootPath) { if (typeof rootPath !== 'string' || !isAbsolute(rootPath)) throw new Error('Release root must be an explicit absolute path.'); const normalized = resolve(rootPath); if (normalized === '/' || normalized.split('/').filter(Boolean).length < 2) throw new Error('Broad release root is forbidden.'); const info = await lstat(normalized); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Release root must be a real dedicated directory.'); const marker = await readFile(join(normalized, '.portfolio-release-root'), 'utf8').catch((error) => { if (error?.code === 'ENOENT') throw new Error('Release root sentinel is missing or invalid.'); throw error; }); if (marker !== 'portfolio-release-root/v1\n') throw new Error('Release root sentinel is missing or invalid.'); return normalized; } export async function atomicCanonicalWrite(path, value, mode = 0o600) { const temporary = `${path}.next-${process.pid}`; const handle = await open(temporary, 'wx', mode); try { await handle.writeFile(canonicalValueBytes(value)); await handle.sync(); } finally { await handle.close(); } await rename(temporary, path); } export async function safeRemoveStage(root, stageName) { assertToken(stageName, 'stage name'); const staging = join(root, 'staging'); const target = join(staging, stageName); const rel = relative(staging, target); if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('Stage cleanup escaped its root.'); await rm(target, { recursive: true, force: true }); } export function digestBytes(bytes) { return createHash('sha256').update(bytes).digest('hex'); }