fix: close release provenance and privacy gaps
This commit is contained in:
@@ -1,101 +1,314 @@
|
||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
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';
|
||||
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 { verifyApprovalRequest } from '../../scripts/release/verify-approvals.mjs';
|
||||
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}$/);
|
||||
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();
|
||||
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("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', () => {
|
||||
test("validates an exact initial-launch private-source manifest", () => {
|
||||
const inputDigests = {
|
||||
PortfolioContent: h('a'), PublicAssetManifest: h('b'), PreparedAssetSet: h('c'), SiteConfig: h('d'),
|
||||
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'),
|
||||
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',
|
||||
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}$/);
|
||||
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',
|
||||
["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"),
|
||||
};
|
||||
expect(() => validateReleaseManifest({ ...baseline, ...override }, { now: '2026-09-02T01:00:00Z' })).toThrow();
|
||||
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('CLI verifier emits only canonical public roots from a signed external approval request', () => {
|
||||
const keys = generateKeyPairSync('ed25519');
|
||||
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('8'),
|
||||
ledgerRevision: 1, policyVersion: 'policy-1',
|
||||
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') },
|
||||
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', now: '2026-09-02T00:00:00Z',
|
||||
latestLedgerHeadHash: h('9'), latestLedgerRevision: 2,
|
||||
trustedKeys: [{ keyId: 'approval-key', issuerEpoch: 1, scope: 'portfolio-approval-decision/v1', authorizedRoles: ['approver'], publicKeyPem: keys.publicKey.export({ type: 'spki', format: 'pem' }) }],
|
||||
contentRecords: [{ recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope }],
|
||||
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') },
|
||||
inputDigests: {
|
||||
PortfolioContent: h("a"),
|
||||
PublicAssetManifest: h("b"),
|
||||
PreparedAssetSet: h("c"),
|
||||
SiteConfig: h("d"),
|
||||
},
|
||||
};
|
||||
const result = verifyApprovalRequest(canonicalValueBytes(request));
|
||||
const result = verifyApprovalRequestForTest(canonicalValueBytes(request), {
|
||||
now: "2026-09-02T00:00:00Z",
|
||||
});
|
||||
expect(Object.keys(result).sort()).toEqual([
|
||||
'approvalProofSetDigest', 'approvedAssetRoot', 'approvedRecordRoot', 'earliestApprovalExpiry',
|
||||
'latestLedgerHeadHash', 'latestLedgerRevision', 'productionInputRoot', 'schema',
|
||||
"approvalProofSetDigest",
|
||||
"approvedAssetRoot",
|
||||
"approvedRecordRoot",
|
||||
"earliestApprovalExpiry",
|
||||
"latestLedgerHeadHash",
|
||||
"latestLedgerRevision",
|
||||
"productionInputRoot",
|
||||
"projectionSchema",
|
||||
"projectionScope",
|
||||
"schema",
|
||||
]);
|
||||
const serialized = new TextDecoder().decode(canonicalValueBytes(result));
|
||||
expect(serialized).not.toContain('evidenceFingerprints');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user