198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import { generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
|
|
import { canonicalJsonDigest, canonicalValueBytes, byteDigest } from '../../src/security/canonical-digest.ts';
|
|
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
|
|
|
|
const digest = (character: string) => character.repeat(64);
|
|
|
|
export interface ReleaseFixture {
|
|
distDir: string;
|
|
signedManifestPath: string;
|
|
sbomPath: string;
|
|
trustPolicyPath: string;
|
|
verifierModulePath: string;
|
|
verifySignedReleaseEnvelope(input: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }): any;
|
|
protectedState: {
|
|
latestLedgerHeadHash: string;
|
|
latestLedgerRevision: number;
|
|
currentManifestDigest: string | null;
|
|
publisherFence: number;
|
|
};
|
|
manifestDigest: string;
|
|
buildId: string;
|
|
expiresAt: string;
|
|
signFor(input: {
|
|
buildId: string;
|
|
distDir: string;
|
|
predecessorManifestDigest: string | null;
|
|
publisherFence: number;
|
|
issuedAt?: string;
|
|
expiresAt?: string;
|
|
outputPath: string;
|
|
}): Promise<{ manifestDigest: string; signedManifestPath: string }>;
|
|
}
|
|
|
|
export async function createReleaseFixture(root: string): Promise<ReleaseFixture> {
|
|
const distDir = join(root, 'dist');
|
|
await mkdir(join(distDir, 'assets'), { recursive: true });
|
|
await writeFile(join(distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
|
|
await writeFile(join(distDir, 'assets/app.js'), 'console.log("portfolio");\n');
|
|
const sbomPath = join(root, 'sbom.cdx.json');
|
|
await writeFile(sbomPath, '{"bomFormat":"CycloneDX","specVersion":"1.6"}\n');
|
|
|
|
const keys = generateKeyPairSync('ed25519');
|
|
const publicKeyPem = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
const trustPolicyPath = join(root, 'release-trust.json');
|
|
await writeFile(trustPolicyPath, canonicalValueBytes({
|
|
keyId: 'release-test-key', issuerEpoch: 7, scope: 'portfolio-release-signing/v1', publicKeyPem,
|
|
}));
|
|
|
|
const verifierModulePath = join(root, 'real-release-verifier.mjs');
|
|
await writeFile(verifierModulePath, `
|
|
import { createHash, verify } from 'node:crypto';
|
|
|
|
function canonical(value) {
|
|
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
|
|
}
|
|
function digest(schema, value) {
|
|
return createHash('sha256').update(canonical({ schema, value })).digest('hex');
|
|
}
|
|
function fail(message) { throw new Error(message); }
|
|
|
|
export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, protectedState }) {
|
|
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') fail('bad envelope schema');
|
|
if (signedRelease.signature?.algorithm !== 'Ed25519' || signedRelease.signature?.encoding !== 'base64url-nopad') fail('bad signature metadata');
|
|
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch || signedRelease.signature?.scope !== trustPolicy.scope) fail('untrusted signer');
|
|
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
|
|
if (signature.length !== 64 || !verify(null, Buffer.from(canonical(signedRelease.payload)), trustPolicy.publicKeyPem, signature)) fail('invalid release signature');
|
|
const manifestDigest = digest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
|
|
if (manifestDigest !== signedRelease.payloadDigest) fail('candidate digest mismatch');
|
|
const candidate = signedRelease.payload;
|
|
if (candidate.latestLedgerHeadHash !== protectedState.latestLedgerHeadHash || candidate.latestLedgerRevision !== protectedState.latestLedgerRevision) fail('stale ledger');
|
|
if (candidate.releaseManifest.currentManifestDigest !== protectedState.currentManifestDigest) fail('stale predecessor');
|
|
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) fail('expired release');
|
|
return {
|
|
schema: 'portfolio-verified-release-admission/v1',
|
|
manifestDigest,
|
|
manifestPayload: candidate.releaseManifest,
|
|
expiresAt: candidate.releaseManifest.expiresAt,
|
|
predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
|
|
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
|
|
latestLedgerRevision: candidate.latestLedgerRevision,
|
|
expectedFence: protectedState.publisherFence + 1,
|
|
signatureVerified: true,
|
|
};
|
|
}
|
|
`);
|
|
await chmod(verifierModulePath, 0o500);
|
|
|
|
const protectedState = {
|
|
latestLedgerHeadHash: digest('9'), latestLedgerRevision: 11, currentManifestDigest: null, publisherFence: 0,
|
|
};
|
|
|
|
const verifySignedReleaseEnvelope = ({ signedRelease, trustPolicy, now, protectedState: state }: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }) => {
|
|
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') throw new Error('bad envelope schema');
|
|
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch ||
|
|
signedRelease.signature?.scope !== trustPolicy.scope) throw new Error('untrusted signer');
|
|
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
|
|
if (signature.length !== 64 || !verify(null, canonicalValueBytes(signedRelease.payload), trustPolicy.publicKeyPem, signature)) throw new Error('invalid release signature');
|
|
const manifestDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
|
|
if (manifestDigest !== signedRelease.payloadDigest) throw new Error('candidate digest mismatch');
|
|
const candidate = signedRelease.payload;
|
|
if (candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash || candidate.latestLedgerRevision !== state.latestLedgerRevision) throw new Error('stale ledger');
|
|
if (candidate.releaseManifest.currentManifestDigest !== state.currentManifestDigest) throw new Error('stale predecessor');
|
|
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) throw new Error('expired release');
|
|
return {
|
|
schema: 'portfolio-verified-release-admission/v1', manifestDigest, manifestPayload: candidate.releaseManifest,
|
|
expiresAt: candidate.releaseManifest.expiresAt, predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
|
|
latestLedgerHeadHash: candidate.latestLedgerHeadHash, latestLedgerRevision: candidate.latestLedgerRevision,
|
|
expectedFence: state.publisherFence + 1, signatureVerified: true,
|
|
};
|
|
};
|
|
|
|
const signFor = async (input: {
|
|
buildId: string;
|
|
distDir: string;
|
|
predecessorManifestDigest: string | null;
|
|
publisherFence: number;
|
|
issuedAt?: string;
|
|
expiresAt?: string;
|
|
outputPath: string;
|
|
}) => {
|
|
const artifactPaths = ['assets/app.js', 'index.html'];
|
|
const artifacts = await Promise.all(artifactPaths.map(async (path) => {
|
|
const bytes = await readFile(join(input.distDir, path));
|
|
return { path, sha256: byteDigest(bytes), bytes: bytes.length, encoding: 'identity' as const };
|
|
}));
|
|
const inputDigests = {
|
|
PortfolioContent: digest('a'), PublicAssetManifest: digest('b'), PreparedAssetSet: digest('c'), SiteConfig: digest('d'),
|
|
};
|
|
const issuedAt = input.issuedAt ?? '2026-09-02T00:00:00Z';
|
|
const expiresAt = input.expiresAt ?? '2026-09-03T00:00:00Z';
|
|
const releaseManifest = {
|
|
schema: 'portfolio-release-manifest/v1' as const,
|
|
launchMode: 'private-source-initial-launch' as const,
|
|
buildId: input.buildId,
|
|
inputDigests,
|
|
productionInputRoot: computeProductionInputRoot({
|
|
portfolioContentDigest: inputDigests.PortfolioContent,
|
|
publicAssetManifestDigest: inputDigests.PublicAssetManifest,
|
|
preparedAssetSetDigest: inputDigests.PreparedAssetSet,
|
|
siteConfigDigest: inputDigests.SiteConfig,
|
|
}),
|
|
artifacts,
|
|
deployFiles: artifactPaths,
|
|
sourceCommit: digest('e'), sourceTree: digest('f'), approvedRecordRoot: digest('1'), approvedAssetRoot: digest('2'),
|
|
approvalProofSetDigest: digest('3'), approvalProjectionAttestationDigest: digest('4'), scanEvidenceDigest: digest('5'),
|
|
policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' },
|
|
predecessorManifestDigest: input.predecessorManifestDigest,
|
|
currentManifestDigest: input.predecessorManifestDigest,
|
|
issuedAt, expiresAt, minimumBoundExpiry: expiresAt,
|
|
};
|
|
const candidate = {
|
|
schema: 'portfolio-candidate-release-manifest/v1',
|
|
releaseManifest,
|
|
latestLedgerHeadHash: protectedState.latestLedgerHeadHash,
|
|
latestLedgerRevision: protectedState.latestLedgerRevision,
|
|
sourceClosureDigest: digest('6'), publicRefMap: [], lfsOids: [], releaseArchives: [],
|
|
buildProvenanceDigest: digest('7'), privacyPolicyDigest: digest('8'), toolImageDigests: [digest('0')],
|
|
};
|
|
const payloadDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', candidate);
|
|
const signature = sign(null, canonicalValueBytes(candidate), keys.privateKey).toString('base64url');
|
|
const signedRelease = {
|
|
schema: 'portfolio-signed-release-manifest/v1', payload: candidate, payloadDigest,
|
|
signature: {
|
|
algorithm: 'Ed25519', encoding: 'base64url-nopad', keyId: 'release-test-key', issuerEpoch: 7,
|
|
scope: 'portfolio-release-signing/v1', value: signature,
|
|
},
|
|
};
|
|
await writeFile(input.outputPath, canonicalValueBytes(signedRelease));
|
|
return { manifestDigest: payloadDigest, signedManifestPath: input.outputPath };
|
|
};
|
|
|
|
const signedManifestPath = join(root, 'signed-release.json');
|
|
const initial = await signFor({ buildId: 'release-a', distDir, predecessorManifestDigest: null, publisherFence: 0, outputPath: signedManifestPath });
|
|
return {
|
|
distDir, signedManifestPath, sbomPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState,
|
|
manifestDigest: initial.manifestDigest, buildId: 'release-a', expiresAt: '2026-09-03T00:00:00Z', signFor,
|
|
};
|
|
}
|
|
|
|
export async function initializeReleaseRoot(root: string, state?: Partial<ReleaseFixture['protectedState']>): Promise<void> {
|
|
await mkdir(join(root, 'control'), { recursive: true });
|
|
await writeFile(join(root, '.portfolio-release-root'), 'portfolio-release-root/v1\n');
|
|
await writeFile(join(root, 'control/state.json'), canonicalValueBytes({
|
|
schema: 'portfolio-release-control-state/v1',
|
|
currentManifestDigest: state?.currentManifestDigest ?? null,
|
|
currentReleaseId: null,
|
|
publisherFence: state?.publisherFence ?? 0,
|
|
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest('9'),
|
|
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
|
|
activeTransaction: null,
|
|
}));
|
|
}
|