304 lines
9.5 KiB
TypeScript
304 lines
9.5 KiB
TypeScript
import { generateKeyPairSync, sign, verify } from "node:crypto";
|
|
import { 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;
|
|
trustPolicy: Record<string, unknown>;
|
|
verifySignedReleaseEnvelope(input: {
|
|
signedRelease: any;
|
|
trustPolicy: any;
|
|
now: string;
|
|
protectedState: any;
|
|
}): any;
|
|
protectedState: {
|
|
latestLedgerHeadHash: string;
|
|
latestLedgerRevision: number;
|
|
currentManifestDigest: string | null;
|
|
publisherFence: number;
|
|
releaseTrustPolicyDigest: string;
|
|
};
|
|
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");
|
|
const trustPolicy = {
|
|
keyId: "release-test-key",
|
|
issuerEpoch: 7,
|
|
scope: "portfolio-release-signing/v1",
|
|
publicKeyPem,
|
|
};
|
|
await writeFile(trustPolicyPath, canonicalValueBytes(trustPolicy));
|
|
|
|
const protectedState = {
|
|
latestLedgerHeadHash: digest("9"),
|
|
latestLedgerRevision: 11,
|
|
currentManifestDigest: null,
|
|
publisherFence: 0,
|
|
releaseTrustPolicyDigest: canonicalJsonDigest(
|
|
"portfolio-trust-policy/v1",
|
|
trustPolicy,
|
|
),
|
|
};
|
|
|
|
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"),
|
|
publicObjectClosureDigest: digest("b"),
|
|
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,
|
|
trustPolicy,
|
|
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"]>,
|
|
trustPolicy?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
await mkdir(join(root, "control"), { recursive: true });
|
|
await mkdir(join(root, ".control"), { recursive: true });
|
|
await mkdir(join(root, "inbox"), { recursive: true });
|
|
if (trustPolicy !== undefined)
|
|
await writeFile(
|
|
join(root, ".control", "release-trust.json"),
|
|
canonicalValueBytes(trustPolicy),
|
|
);
|
|
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,
|
|
releaseTrustPolicyDigest: state?.releaseTrustPolicyDigest ?? digest("8"),
|
|
activeTransaction: null,
|
|
}),
|
|
);
|
|
}
|