442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
import { generateKeyPairSync, sign, verify } from "node:crypto";
|
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import {
|
|
canonicalJsonDigest,
|
|
canonicalValueBytes,
|
|
} from "../../src/security/canonical-digest.ts";
|
|
import { computeProductionInputRoot } from "../../src/security/production-input-root.ts";
|
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
|
import * as releaseSigner from "../../scripts/release/sign-release.mjs";
|
|
|
|
const {
|
|
signReleaseCandidate,
|
|
validateTrustPolicy,
|
|
verifySignedReleaseEnvelope,
|
|
} = releaseSigner;
|
|
|
|
const h = (c: string) => c.repeat(64);
|
|
const roles = [
|
|
["approval", "portfolio-approval-decision/v1"],
|
|
["projection", "portfolio-approval-projection/v1"],
|
|
["privacyWorkload", "portfolio-privacy-workload/v1"],
|
|
["policy", "portfolio-policy-attestation/v1"],
|
|
["toolImage", "portfolio-tool-image-provenance/v1"],
|
|
["release", "portfolio-release-signing/v1"],
|
|
["publisher", "portfolio-publisher-fencing/v1"],
|
|
] as const;
|
|
|
|
function keys(): Record<string, { publicKey: any; privateKey: any }> {
|
|
return Object.fromEntries(
|
|
roles.map(([role]) => [role, generateKeyPairSync("ed25519")]),
|
|
) as Record<string, { publicKey: any; privateKey: any }>;
|
|
}
|
|
|
|
function policy(keyPairs: ReturnType<typeof keys>): any {
|
|
return {
|
|
schema: "portfolio-trust-policy/v1",
|
|
policyVersion: "2026-09-02",
|
|
publicationFrozen: false,
|
|
roots: Object.fromEntries(
|
|
roles.map(([role, scope]) => [
|
|
role,
|
|
{
|
|
keyId: `${role}-key`,
|
|
issuerEpoch: 2,
|
|
scope,
|
|
publicKeyPem: keyPairs[role]!.publicKey.export({
|
|
type: "spki",
|
|
format: "pem",
|
|
}),
|
|
revokedEpochs: [1],
|
|
compromiseResponse:
|
|
role === "approval"
|
|
? ["freeze-publication", "audit-checkpoint", "fresh-approvals"]
|
|
: role === "publisher"
|
|
? [
|
|
"freeze-publication",
|
|
"rotate-credential",
|
|
"advance-fence",
|
|
"reconcile-staging",
|
|
]
|
|
: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
|
|
function envelope(
|
|
role: string,
|
|
scope: string,
|
|
keyPairs: ReturnType<typeof keys>,
|
|
payload: Record<string, unknown> = {},
|
|
) {
|
|
const schema = {
|
|
projection: "portfolio-approval-projection-attestation/v1",
|
|
privacyWorkload: "portfolio-privacy-workload-attestation/v1",
|
|
policy: "portfolio-policy-attestation/v1",
|
|
toolImage: "portfolio-tool-image-provenance/v1",
|
|
}[role];
|
|
const body = {
|
|
schema,
|
|
keyId: `${role}-key`,
|
|
issuerEpoch: 2,
|
|
scope,
|
|
issuedAt: "2026-09-02T00:00:00Z",
|
|
expiresAt: "2026-09-03T00:00:00Z",
|
|
...payload,
|
|
};
|
|
return {
|
|
payload: body,
|
|
signature: {
|
|
algorithm: "Ed25519",
|
|
encoding: "base64url-nopad",
|
|
value: sign(
|
|
null,
|
|
canonicalValueBytes(body),
|
|
keyPairs[role]!.privateKey,
|
|
).toString("base64url"),
|
|
},
|
|
};
|
|
}
|
|
|
|
function fixture() {
|
|
const keyPairs = keys();
|
|
const inputDigests = {
|
|
PortfolioContent: h("a"),
|
|
PublicAssetManifest: h("b"),
|
|
PreparedAssetSet: h("c"),
|
|
SiteConfig: h("d"),
|
|
};
|
|
const releaseManifest = {
|
|
schema: "portfolio-release-manifest/v1",
|
|
launchMode: "private-source-initial-launch",
|
|
buildId: "r1",
|
|
inputDigests,
|
|
productionInputRoot: computeProductionInputRoot({
|
|
portfolioContentDigest: h("a"),
|
|
publicAssetManifestDigest: h("b"),
|
|
preparedAssetSetDigest: h("c"),
|
|
siteConfigDigest: h("d"),
|
|
}),
|
|
artifacts: [
|
|
{ path: "index.html", sha256: h("6"), bytes: 42, encoding: "identity" },
|
|
],
|
|
deployFiles: ["index.html"],
|
|
sourceCommit: h("7"),
|
|
sourceTree: h("8"),
|
|
approvedRecordRoot: h("9"),
|
|
approvedAssetRoot: h("e"),
|
|
approvalProofSetDigest: h("f"),
|
|
approvalProjectionAttestationDigest: h("2"),
|
|
scanEvidenceDigest: h("3"),
|
|
policyVersions: { approval: "1", privacy: "1", release: "1" },
|
|
toolVersions: { node: "22" },
|
|
predecessorManifestDigest: null,
|
|
currentManifestDigest: null,
|
|
issuedAt: "2026-09-02T00:00:00Z",
|
|
expiresAt: "2026-09-02T12:00:00Z",
|
|
minimumBoundExpiry: "2026-09-02T12:00:00Z",
|
|
};
|
|
const candidate = {
|
|
schema: "portfolio-candidate-release-manifest/v1",
|
|
releaseManifest,
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
sourceClosureDigest: h("4"),
|
|
publicRefMap: {},
|
|
lfsOids: [],
|
|
releaseArchives: [],
|
|
buildProvenanceDigest: h("5"),
|
|
privacyPolicyDigest: h("a"),
|
|
toolImageDigests: [h("b"), h("c")],
|
|
};
|
|
const evidence = {
|
|
schema: "portfolio-scan-evidence/v1",
|
|
coverageCount: 1,
|
|
passedCount: 1,
|
|
checks: [
|
|
{
|
|
inputDigest: h("5"),
|
|
checkId: "bytes",
|
|
normalizedOutputDigest: h("6"),
|
|
normalizedMembers: [`bytes:${h("7")}`],
|
|
workerReports: [h("8"), h("9")],
|
|
matcherResult: h("a"),
|
|
ruleSetDigest: h("b"),
|
|
appliedRuleIds: ["denylist-v1"],
|
|
},
|
|
],
|
|
};
|
|
const evidenceDigest = canonicalJsonDigest(
|
|
"portfolio-scan-evidence/v1",
|
|
evidence,
|
|
);
|
|
candidate.releaseManifest.scanEvidenceDigest = evidenceDigest;
|
|
const candidateDigest = canonicalJsonDigest(
|
|
"portfolio-candidate-release-manifest/v1",
|
|
candidate,
|
|
);
|
|
const attestations = {
|
|
projection: envelope("projection", roles[1][1], keyPairs, {
|
|
digest: h("2"),
|
|
productionInputRoot: releaseManifest.productionInputRoot,
|
|
approvedRecordRoot: h("9"),
|
|
approvedAssetRoot: h("e"),
|
|
approvalProofSetDigest: h("f"),
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
}),
|
|
privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, {
|
|
digest: h("2"),
|
|
candidateDigest,
|
|
evidenceDigest,
|
|
}),
|
|
policy: envelope("policy", roles[3][1], keyPairs, {
|
|
digest: h("a"),
|
|
policyVersion: "2026-09-02",
|
|
}),
|
|
toolImage: envelope("toolImage", roles[4][1], keyPairs, {
|
|
digest: h("b"),
|
|
imageDigests: [h("b"), h("c")],
|
|
}),
|
|
};
|
|
return {
|
|
keyPairs,
|
|
candidate,
|
|
evidence,
|
|
candidateDigest,
|
|
evidenceDigest,
|
|
attestations,
|
|
trustPolicy: policy(keyPairs),
|
|
};
|
|
}
|
|
|
|
describe("external scoped release signing", () => {
|
|
test("signs exact canonical candidate bytes with an external Ed25519 key", async () => {
|
|
const f = fixture();
|
|
const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-"));
|
|
const keyPath = join(dir, "release.pem");
|
|
await writeFile(
|
|
keyPath,
|
|
f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }),
|
|
{ mode: 0o400 },
|
|
);
|
|
const result = await signReleaseCandidate({
|
|
...f,
|
|
releaseKeyPath: keyPath,
|
|
now: "2026-09-02T01:00:00Z",
|
|
protectedState: {
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
currentManifestDigest: null,
|
|
publisherFence: 4,
|
|
},
|
|
});
|
|
expect(result.payloadDigest).toBe(f.candidateDigest);
|
|
expect(
|
|
verify(
|
|
null,
|
|
canonicalValueBytes(f.candidate),
|
|
f.keyPairs.release!.publicKey,
|
|
Buffer.from(result.signature.value, "base64url"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
verifySignedReleaseEnvelope({
|
|
signedRelease: result,
|
|
trustPolicy: f.trustPolicy,
|
|
now: "2026-09-02T01:00:00Z",
|
|
protectedState: {
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
currentManifestDigest: null,
|
|
publisherFence: 4,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
schema: "portfolio-verified-release-admission/v1",
|
|
manifestDigest: f.candidateDigest,
|
|
expiresAt: "2026-09-02T12:00:00Z",
|
|
predecessorManifestDigest: null,
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
expectedFence: 5,
|
|
signatureVerified: true,
|
|
});
|
|
});
|
|
|
|
test("verified admission rejects stale publisher state and substituted signed payloads", async () => {
|
|
const f = fixture();
|
|
const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-"));
|
|
const keyPath = join(dir, "release.pem");
|
|
await writeFile(
|
|
keyPath,
|
|
f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }),
|
|
{ mode: 0o400 },
|
|
);
|
|
const state = {
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
currentManifestDigest: null,
|
|
publisherFence: 4,
|
|
};
|
|
const signedRelease = await signReleaseCandidate({
|
|
...f,
|
|
releaseKeyPath: keyPath,
|
|
now: "2026-09-02T01:00:00Z",
|
|
protectedState: state,
|
|
});
|
|
expect(() =>
|
|
verifySignedReleaseEnvelope({
|
|
signedRelease,
|
|
trustPolicy: f.trustPolicy,
|
|
now: "2026-09-02T01:00:00Z",
|
|
protectedState: { ...state, latestLedgerRevision: 8 },
|
|
}),
|
|
).toThrow(/stale/i);
|
|
expect(() =>
|
|
verifySignedReleaseEnvelope({
|
|
signedRelease: {
|
|
...signedRelease,
|
|
payload: { ...signedRelease.payload, latestLedgerRevision: 8 },
|
|
},
|
|
trustPolicy: f.trustPolicy,
|
|
now: "2026-09-02T01:00:00Z",
|
|
protectedState: state,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("refuses repository-resident keys and non-external key material", async () => {
|
|
const f = fixture();
|
|
await expect(
|
|
signReleaseCandidate({
|
|
...f,
|
|
releaseKeyPath: join(process.cwd(), "release.pem"),
|
|
now: "2026-09-02T01:00:00Z",
|
|
protectedState: {
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
currentManifestDigest: null,
|
|
publisherFence: 4,
|
|
},
|
|
}),
|
|
).rejects.toThrow(/outside|external/i);
|
|
});
|
|
|
|
test.each(roles)(
|
|
"requires current non-revoked %s issuer epoch and exact scope",
|
|
(role) => {
|
|
const f = fixture();
|
|
const mutated = structuredClone(f.trustPolicy);
|
|
mutated.roots[role].issuerEpoch = 1;
|
|
expect(() => validateTrustPolicy(mutated)).toThrow(/revoked|epoch/i);
|
|
const wrongScope = structuredClone(f.trustPolicy);
|
|
wrongScope.roots[role].scope = "wrong/v1";
|
|
expect(() => validateTrustPolicy(wrongScope)).toThrow(/scope/i);
|
|
},
|
|
);
|
|
|
|
test("requires explicit compromise recovery semantics for approval and publisher roots", () => {
|
|
const f = fixture();
|
|
const approval = structuredClone(f.trustPolicy);
|
|
approval.roots.approval.compromiseResponse = ["freeze-publication"];
|
|
expect(() => validateTrustPolicy(approval)).toThrow(
|
|
/checkpoint|fresh approvals/i,
|
|
);
|
|
const publisher = structuredClone(f.trustPolicy);
|
|
publisher.roots.publisher.compromiseResponse = [
|
|
"freeze-publication",
|
|
"rotate-credential",
|
|
];
|
|
expect(() => validateTrustPolicy(publisher)).toThrow(/fence|staging/i);
|
|
});
|
|
|
|
test.each([
|
|
[
|
|
"stale ledger",
|
|
(f: ReturnType<typeof fixture>) => ({
|
|
protectedState: {
|
|
latestLedgerHeadHash: h("9"),
|
|
latestLedgerRevision: 7,
|
|
currentManifestDigest: null,
|
|
publisherFence: 4,
|
|
},
|
|
}),
|
|
],
|
|
[
|
|
"expired candidate",
|
|
(f: ReturnType<typeof fixture>) => ({ now: "2026-09-02T12:00:00Z" }),
|
|
],
|
|
[
|
|
"substituted artifact",
|
|
(f: ReturnType<typeof fixture>) => ({
|
|
candidate: {
|
|
...f.candidate,
|
|
releaseManifest: {
|
|
...f.candidate.releaseManifest,
|
|
artifacts: [
|
|
{ ...f.candidate.releaseManifest.artifacts[0], bytes: 43 },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
[
|
|
"forged workload epoch",
|
|
(f: ReturnType<typeof fixture>) => ({
|
|
attestations: {
|
|
...f.attestations,
|
|
privacyWorkload: {
|
|
...f.attestations.privacyWorkload,
|
|
payload: {
|
|
...f.attestations.privacyWorkload.payload,
|
|
issuerEpoch: 1,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
[
|
|
"projection mismatch",
|
|
(f: ReturnType<typeof fixture>) => ({
|
|
candidate: {
|
|
...f.candidate,
|
|
releaseManifest: {
|
|
...f.candidate.releaseManifest,
|
|
productionInputRoot: h("9"),
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
])("rejects %s before signing", async (_name, mutate) => {
|
|
const f = fixture();
|
|
const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-"));
|
|
const keyPath = join(dir, "release.pem");
|
|
await writeFile(
|
|
keyPath,
|
|
f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }),
|
|
{ mode: 0o400 },
|
|
);
|
|
await expect(
|
|
signReleaseCandidate({
|
|
...f,
|
|
releaseKeyPath: keyPath,
|
|
now: "2026-09-02T01:00:00Z",
|
|
protectedState: {
|
|
latestLedgerHeadHash: h("1"),
|
|
latestLedgerRevision: 7,
|
|
currentManifestDigest: null,
|
|
publisherFence: 4,
|
|
},
|
|
...mutate(f),
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|