315 lines
9.8 KiB
TypeScript
315 lines
9.8 KiB
TypeScript
import { generateKeyPairSync, sign } from "node:crypto";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import {
|
|
canonicalJsonDigest,
|
|
canonicalValueBytes,
|
|
} from "../../src/security/canonical-digest.ts";
|
|
import {
|
|
computePreparedAssetSetDigest,
|
|
computeProductionInputRoot,
|
|
} from "../../src/security/production-input-root.ts";
|
|
import { validateReleaseManifest } from "../../src/security/release-manifest.ts";
|
|
// @ts-expect-error The executable JavaScript verifier intentionally has no browser-facing declaration.
|
|
import { verifyApprovalRequestForTest } from "../../scripts/release/verify-approvals.mjs";
|
|
|
|
const h = (c: string) => c.repeat(64);
|
|
|
|
describe("production input and private-source release manifest", () => {
|
|
test("hashes an exact manifest-bound PreparedAssetSet and rejects missing or extra members", () => {
|
|
const bytes = new TextEncoder().encode("image");
|
|
const member = {
|
|
assetId: "asset-one",
|
|
publicPath: "asset-one.webp",
|
|
mediaType: "image/webp",
|
|
width: 10,
|
|
height: 20,
|
|
durationMs: null,
|
|
bytes,
|
|
};
|
|
const manifest = [
|
|
{
|
|
id: "asset-one",
|
|
fileName: "asset-one.webp",
|
|
sha256:
|
|
"6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d",
|
|
mediaType: "image/webp",
|
|
width: 10,
|
|
height: 20,
|
|
durationMs: null,
|
|
},
|
|
];
|
|
expect(computePreparedAssetSetDigest([member], manifest)).toMatch(
|
|
/^[a-f0-9]{64}$/,
|
|
);
|
|
expect(() => computePreparedAssetSetDigest([], manifest)).toThrow();
|
|
expect(() =>
|
|
computePreparedAssetSetDigest(
|
|
[
|
|
member,
|
|
{ ...member, assetId: "asset-two", publicPath: "asset-two.webp" },
|
|
],
|
|
manifest,
|
|
),
|
|
).toThrow();
|
|
});
|
|
|
|
test("matches the fixed four-label input-root vector", () => {
|
|
expect(
|
|
computeProductionInputRoot({
|
|
portfolioContentDigest: h("a"),
|
|
publicAssetManifestDigest: h("b"),
|
|
preparedAssetSetDigest: h("c"),
|
|
siteConfigDigest: h("d"),
|
|
}),
|
|
).toBe("38707dd72ded582a346048ff66a1625cdaca91f6536dd27c935bc3b8af262ae8");
|
|
});
|
|
|
|
test("validates an exact initial-launch private-source manifest", () => {
|
|
const inputDigests = {
|
|
PortfolioContent: h("a"),
|
|
PublicAssetManifest: h("b"),
|
|
PreparedAssetSet: h("c"),
|
|
SiteConfig: h("d"),
|
|
};
|
|
const manifest = {
|
|
schema: "portfolio-release-manifest/v1",
|
|
launchMode: "private-source-initial-launch",
|
|
buildId: "release-2026-09-02",
|
|
inputDigests,
|
|
productionInputRoot: computeProductionInputRoot({
|
|
portfolioContentDigest: h("a"),
|
|
publicAssetManifestDigest: h("b"),
|
|
preparedAssetSetDigest: h("c"),
|
|
siteConfigDigest: h("d"),
|
|
}),
|
|
artifacts: [
|
|
{ path: "index.html", sha256: h("e"), bytes: 42, encoding: "identity" },
|
|
],
|
|
deployFiles: ["index.html"],
|
|
sourceCommit: h("f"),
|
|
sourceTree: h("0"),
|
|
approvedRecordRoot: h("1"),
|
|
approvedAssetRoot: h("2"),
|
|
approvalProofSetDigest: h("3"),
|
|
approvalProjectionAttestationDigest: h("4"),
|
|
scanEvidenceDigest: h("5"),
|
|
policyVersions: { approval: "1", privacy: "1", release: "1" },
|
|
toolVersions: { node: "22.22.1" },
|
|
predecessorManifestDigest: null,
|
|
currentManifestDigest: null,
|
|
issuedAt: "2026-09-02T00:00:00Z",
|
|
expiresAt: "2026-09-03T00:00:00Z",
|
|
minimumBoundExpiry: "2026-09-03T00:00:00Z",
|
|
};
|
|
expect(
|
|
validateReleaseManifest(manifest, { now: "2026-09-02T01:00:00Z" }),
|
|
).toEqual(manifest);
|
|
expect(
|
|
canonicalJsonDigest("portfolio-release-manifest/v1", manifest),
|
|
).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
test.each([
|
|
["public Git target", { publicGitTarget: "https://example.com/repo.git" }],
|
|
["extra deploy file", { deployFiles: ["index.html", "extra.txt"] }],
|
|
[
|
|
"case-fold collision",
|
|
{
|
|
artifacts: [
|
|
{ path: "A.js", sha256: h("e"), bytes: 1, encoding: "identity" },
|
|
{ path: "a.js", sha256: h("f"), bytes: 1, encoding: "identity" },
|
|
],
|
|
deployFiles: ["A.js", "a.js"],
|
|
},
|
|
],
|
|
["expired manifest", { expiresAt: "2026-09-02T00:30:00Z" }],
|
|
])("rejects %s", (_label, override) => {
|
|
const inputDigests = {
|
|
PortfolioContent: h("a"),
|
|
PublicAssetManifest: h("b"),
|
|
PreparedAssetSet: h("c"),
|
|
SiteConfig: h("d"),
|
|
};
|
|
const baseline = {
|
|
schema: "portfolio-release-manifest/v1",
|
|
launchMode: "private-source-initial-launch",
|
|
buildId: "release",
|
|
inputDigests,
|
|
productionInputRoot: computeProductionInputRoot({
|
|
portfolioContentDigest: h("a"),
|
|
publicAssetManifestDigest: h("b"),
|
|
preparedAssetSetDigest: h("c"),
|
|
siteConfigDigest: h("d"),
|
|
}),
|
|
artifacts: [
|
|
{ path: "index.html", sha256: h("e"), bytes: 42, encoding: "identity" },
|
|
],
|
|
deployFiles: ["index.html"],
|
|
sourceCommit: h("f"),
|
|
sourceTree: h("0"),
|
|
approvedRecordRoot: h("1"),
|
|
approvedAssetRoot: h("2"),
|
|
approvalProofSetDigest: h("3"),
|
|
approvalProjectionAttestationDigest: h("4"),
|
|
scanEvidenceDigest: h("5"),
|
|
policyVersions: { approval: "1", privacy: "1", release: "1" },
|
|
toolVersions: { node: "22" },
|
|
predecessorManifestDigest: null,
|
|
currentManifestDigest: null,
|
|
issuedAt: "2026-09-02T00:00:00Z",
|
|
expiresAt: "2026-09-03T00:00:00Z",
|
|
minimumBoundExpiry: "2026-09-03T00:00:00Z",
|
|
};
|
|
expect(() =>
|
|
validateReleaseManifest(
|
|
{ ...baseline, ...override },
|
|
{ now: "2026-09-02T01:00:00Z" },
|
|
),
|
|
).toThrow();
|
|
});
|
|
|
|
test("approval verifier requires an authenticated exact ledger and rejects replay after revoke", () => {
|
|
const keys = generateKeyPairSync("ed25519");
|
|
const approvalPayload = {
|
|
schema: "portfolio-approval-payload/v1",
|
|
keyId: "approval-key",
|
|
issuerEpoch: 1,
|
|
authorizedRole: "approver",
|
|
evidenceFingerprints: [h("1")],
|
|
recordClass: "content",
|
|
recordType: "profile",
|
|
publicRecordIdOrAssetId: "profile-main",
|
|
approvedPublicRecordHashOrManifestRecordHash: h("2"),
|
|
sanitizedOutputHashOrNull: null,
|
|
decision: "approve",
|
|
issuedAt: "2026-09-01T00:00:00Z",
|
|
expiresAt: "2026-09-04T00:00:00Z",
|
|
previousLedgerHeadHash: h("0"),
|
|
ledgerRevision: 1,
|
|
policyVersion: "policy-1",
|
|
};
|
|
const envelope = {
|
|
schema: "portfolio-approval-envelope/v1",
|
|
payload: approvalPayload,
|
|
signature: {
|
|
algorithm: "Ed25519",
|
|
encoding: "base64url-nopad",
|
|
value: sign(
|
|
null,
|
|
canonicalValueBytes(approvalPayload),
|
|
keys.privateKey,
|
|
).toString("base64url"),
|
|
},
|
|
};
|
|
const envelopeDigest = canonicalJsonDigest(
|
|
"portfolio-approval-envelope-digest/v1",
|
|
envelope,
|
|
);
|
|
const head = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", {
|
|
previousLedgerHeadHash: h("0"),
|
|
ledgerRevision: 1,
|
|
envelopeDigest,
|
|
});
|
|
const request = {
|
|
schema: "portfolio-approval-verification-request/v1",
|
|
latestLedgerHeadHash: head,
|
|
latestLedgerRevision: 1,
|
|
trustedKeys: [
|
|
{
|
|
keyId: "approval-key",
|
|
issuerEpoch: 1,
|
|
scope: "portfolio-approval-decision/v1",
|
|
authorizedRoles: ["approver"],
|
|
publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }),
|
|
},
|
|
],
|
|
ledgerProof: {
|
|
schema: "portfolio-approval-ledger-proof/v1",
|
|
genesisHeadHash: h("0"),
|
|
entries: [envelope],
|
|
},
|
|
contentRecords: [
|
|
{
|
|
recordType: "profile",
|
|
publicRecordId: "profile-main",
|
|
approvedPublicRecordHash: h("2"),
|
|
envelope,
|
|
},
|
|
],
|
|
assetRecords: [],
|
|
inputDigests: {
|
|
PortfolioContent: h("a"),
|
|
PublicAssetManifest: h("b"),
|
|
PreparedAssetSet: h("c"),
|
|
SiteConfig: h("d"),
|
|
},
|
|
};
|
|
const result = verifyApprovalRequestForTest(canonicalValueBytes(request), {
|
|
now: "2026-09-02T00:00:00Z",
|
|
});
|
|
expect(Object.keys(result).sort()).toEqual([
|
|
"approvalProofSetDigest",
|
|
"approvedAssetRoot",
|
|
"approvedRecordRoot",
|
|
"earliestApprovalExpiry",
|
|
"latestLedgerHeadHash",
|
|
"latestLedgerRevision",
|
|
"productionInputRoot",
|
|
"projectionSchema",
|
|
"projectionScope",
|
|
"schema",
|
|
]);
|
|
const serialized = new TextDecoder().decode(canonicalValueBytes(result));
|
|
expect(serialized).not.toContain("evidenceFingerprints");
|
|
|
|
const revokePayload = {
|
|
...approvalPayload,
|
|
decision: "revoke",
|
|
issuedAt: "2026-09-02T01:00:00Z",
|
|
ledgerRevision: 2,
|
|
previousLedgerHeadHash: head,
|
|
};
|
|
const revoke = {
|
|
schema: "portfolio-approval-envelope/v1",
|
|
payload: revokePayload,
|
|
signature: {
|
|
algorithm: "Ed25519",
|
|
encoding: "base64url-nopad",
|
|
value: sign(
|
|
null,
|
|
canonicalValueBytes(revokePayload),
|
|
keys.privateKey,
|
|
).toString("base64url"),
|
|
},
|
|
};
|
|
const revokeDigest = canonicalJsonDigest(
|
|
"portfolio-approval-envelope-digest/v1",
|
|
revoke,
|
|
);
|
|
const revokedHead = canonicalJsonDigest(
|
|
"portfolio-approval-ledger-entry/v1",
|
|
{
|
|
previousLedgerHeadHash: head,
|
|
ledgerRevision: 2,
|
|
envelopeDigest: revokeDigest,
|
|
},
|
|
);
|
|
expect(() =>
|
|
verifyApprovalRequestForTest(
|
|
canonicalValueBytes({
|
|
...request,
|
|
latestLedgerHeadHash: revokedHead,
|
|
latestLedgerRevision: 2,
|
|
ledgerProof: {
|
|
schema: "portfolio-approval-ledger-proof/v1",
|
|
genesisHeadHash: h("0"),
|
|
entries: [envelope, revoke],
|
|
},
|
|
}),
|
|
{ now: "2026-09-02T02:00:00Z" },
|
|
),
|
|
).toThrow(/latest|revoke|supersed/i);
|
|
});
|
|
});
|