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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { canonicalJsonDigest } from "../../src/security/canonical-digest.ts";
|
||||
|
||||
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||
import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs";
|
||||
@@ -16,10 +18,24 @@ import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs";
|
||||
import * as privacyRunner from "../../scripts/release/run-privacy-workers.mjs";
|
||||
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||
import { reconcileScan } from "../../scripts/release/reconcile-scan.mjs";
|
||||
// @ts-expect-error container worker is executable JavaScript
|
||||
import { inspectObject } from "../../containers/privacy-worker-a/worker.mjs";
|
||||
// @ts-expect-error container matcher is executable JavaScript
|
||||
import { matchReports } from "../../containers/privacy-matcher/matcher.mjs";
|
||||
|
||||
const { buildWorkerCommand, validateScanObjectDescriptor } = privacyRunner;
|
||||
const {
|
||||
buildWorkerCommand,
|
||||
validateScanObjectDescriptor,
|
||||
requireLiveContainerValidation,
|
||||
} = privacyRunner;
|
||||
|
||||
const h = (c: string) => c.repeat(64);
|
||||
const sourceClosure = (members: unknown[] = []) => {
|
||||
return { schema: "portfolio-publication-closure/v1", members };
|
||||
};
|
||||
const publicClosure = (members: unknown[] = []) => {
|
||||
return { schema: "portfolio-public-object-closure/v1", members };
|
||||
};
|
||||
|
||||
function report(
|
||||
workerId: "worker-a" | "worker-b",
|
||||
@@ -56,6 +72,124 @@ function matcher(override: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("isolated privacy closure and reconciliation", () => {
|
||||
test("fails closed while live scanners are pending and for every non-empty public object closure", () => {
|
||||
expect(() =>
|
||||
requireLiveContainerValidation({
|
||||
schema: "portfolio-live-container-validation/v1",
|
||||
status: "pending",
|
||||
reason: "not verified",
|
||||
}),
|
||||
).toThrow(/pending|verified/i);
|
||||
const canary = Buffer.from("PRIVATE-PROJECT-NAME\nTOKEN=secret-canary");
|
||||
const inputDigest = createHash("sha256").update(canary).digest("hex");
|
||||
expect(
|
||||
inspectObject(
|
||||
{
|
||||
schema: "portfolio-privacy-worker-request/v1",
|
||||
workerId: "worker-a",
|
||||
imageDigest: h("a"),
|
||||
inputDigest,
|
||||
checkId: "secret",
|
||||
},
|
||||
canary,
|
||||
).verdict,
|
||||
).not.toBe("pass");
|
||||
const pythonReport = report("worker-b", {
|
||||
inputDigest,
|
||||
checkId: "secret",
|
||||
verdict: "unsupported",
|
||||
});
|
||||
expect(() =>
|
||||
matchReports({
|
||||
reports: [
|
||||
inspectObject(
|
||||
{
|
||||
schema: "portfolio-privacy-worker-request/v1",
|
||||
workerId: "worker-a",
|
||||
imageDigest: h("a"),
|
||||
inputDigest,
|
||||
checkId: "secret",
|
||||
},
|
||||
canary,
|
||||
),
|
||||
pythonReport,
|
||||
],
|
||||
rules: [{ id: "private-name-v1", deniedMemberDigests: [] }],
|
||||
ruleSetDigest: h("4"),
|
||||
}),
|
||||
).toThrow(/pass|unsupported|agree/i);
|
||||
const source = sourceClosure();
|
||||
const publicObjectClosure = publicClosure([
|
||||
{
|
||||
objectId: "private-leak.txt",
|
||||
inputDigest,
|
||||
requiredChecks: ["secret"],
|
||||
},
|
||||
]);
|
||||
expect(() =>
|
||||
reconcileScan({
|
||||
sourceClosure: source,
|
||||
publicObjectClosure,
|
||||
reports: [
|
||||
report("worker-a", { inputDigest, checkId: "secret" }),
|
||||
report("worker-b", { inputDigest, checkId: "secret" }),
|
||||
],
|
||||
matcherResults: [matcher({ inputDigest, checkId: "secret" })],
|
||||
candidate: {
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
buildId: "r1",
|
||||
sourceClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-publication-closure/v1",
|
||||
{ schema: source.schema, members: source.members },
|
||||
),
|
||||
publicObjectClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-public-object-closure/v1",
|
||||
publicObjectClosure,
|
||||
),
|
||||
},
|
||||
}),
|
||||
).toThrow(/unavailable|non-empty|scanner/i);
|
||||
});
|
||||
|
||||
test("derives exact scan coverage from canonical closures instead of caller expectedChecks", () => {
|
||||
const source = sourceClosure();
|
||||
const publicObjectClosure = publicClosure();
|
||||
const candidate = {
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
buildId: "r1",
|
||||
sourceClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-publication-closure/v1",
|
||||
source,
|
||||
),
|
||||
publicObjectClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-public-object-closure/v1",
|
||||
publicObjectClosure,
|
||||
),
|
||||
};
|
||||
const result = reconcileScan({
|
||||
sourceClosure: source,
|
||||
publicObjectClosure,
|
||||
reports: [],
|
||||
matcherResults: [],
|
||||
candidate,
|
||||
});
|
||||
expect(result.evidence).toMatchObject({
|
||||
coverageCount: 0,
|
||||
passedCount: 0,
|
||||
sourceClosureDigest: candidate.sourceClosureDigest,
|
||||
publicObjectClosureDigest: candidate.publicObjectClosureDigest,
|
||||
});
|
||||
expect(() =>
|
||||
reconcileScan({
|
||||
sourceClosure: source,
|
||||
publicObjectClosure,
|
||||
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||
reports: [],
|
||||
matcherResults: [],
|
||||
candidate,
|
||||
}),
|
||||
).toThrow(/missing|extra|unknown|caller|expectedChecks/i);
|
||||
});
|
||||
test("enumerates an exact byte-sorted closure and rejects unsafe filesystem members", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "portfolio-closure-"));
|
||||
await mkdir(join(root, "nested"));
|
||||
@@ -142,14 +276,25 @@ describe("isolated privacy closure and reconciliation", () => {
|
||||
).toThrowError(expect.not.stringContaining(secret));
|
||||
});
|
||||
|
||||
test("accepts only ordered heterogeneous agreement for every expected check", () => {
|
||||
test("accepts only the exact empty public object closure while scanners are unavailable", () => {
|
||||
const source = sourceClosure();
|
||||
const publicObjectClosure = publicClosure();
|
||||
const result = reconcileScan({
|
||||
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||
reports: [report("worker-a"), report("worker-b")],
|
||||
matcherResults: [matcher()],
|
||||
sourceClosure: source,
|
||||
publicObjectClosure,
|
||||
reports: [],
|
||||
matcherResults: [],
|
||||
candidate: {
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
buildId: "r1",
|
||||
sourceClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-publication-closure/v1",
|
||||
source,
|
||||
),
|
||||
publicObjectClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-public-object-closure/v1",
|
||||
publicObjectClosure,
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(result.evidenceDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||
@@ -217,13 +362,24 @@ describe("isolated privacy closure and reconciliation", () => {
|
||||
});
|
||||
|
||||
test("writes canonical evidence and candidate objects content-addressed without overwrite", async () => {
|
||||
const source = sourceClosure();
|
||||
const publicObjectClosure = publicClosure();
|
||||
const result = reconcileScan({
|
||||
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||
reports: [report("worker-a"), report("worker-b")],
|
||||
matcherResults: [matcher()],
|
||||
sourceClosure: source,
|
||||
publicObjectClosure,
|
||||
reports: [],
|
||||
matcherResults: [],
|
||||
candidate: {
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
buildId: "r1",
|
||||
sourceClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-publication-closure/v1",
|
||||
source,
|
||||
),
|
||||
publicObjectClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-public-object-closure/v1",
|
||||
publicObjectClosure,
|
||||
),
|
||||
},
|
||||
});
|
||||
const root = await mkdtemp(join(tmpdir(), "portfolio-evidence-"));
|
||||
|
||||
79
tests/security/release-authority.test.ts
Normal file
79
tests/security/release-authority.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { mkdtemp, mkdir, 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 {
|
||||
verifySignedRelease,
|
||||
verifySignedReleaseForTest,
|
||||
} from "../../scripts/deploy/lib/release-common.mjs";
|
||||
|
||||
const h = (c: string) => c.repeat(64);
|
||||
|
||||
describe("protected release verification authority", () => {
|
||||
test("production seam rejects caller-selected verifier, policy path, and time", async () => {
|
||||
await expect(
|
||||
verifySignedRelease({
|
||||
signedManifestPath: "/not-used",
|
||||
authorityRoot: "/not-used",
|
||||
verifierModulePath: "/tmp/evil.mjs",
|
||||
trustPolicyPath: "/tmp/evil.json",
|
||||
now: "2000-01-01T00:00:00Z",
|
||||
protectedState: {},
|
||||
} as never),
|
||||
).rejects.toThrow(/unknown|caller|production/i);
|
||||
});
|
||||
|
||||
test("test seam still requires the protected trust-policy digest", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "portfolio-release-authority-"));
|
||||
await mkdir(join(root, ".control"));
|
||||
const signedManifestPath = join(root, "signed.json");
|
||||
const trustPolicyPath = join(root, ".control", "release-trust.json");
|
||||
const trustPolicy = {
|
||||
schema: "portfolio-trust-policy/v1",
|
||||
policyVersion: "test",
|
||||
};
|
||||
await writeFile(
|
||||
signedManifestPath,
|
||||
canonicalValueBytes({ schema: "signed" }),
|
||||
);
|
||||
await writeFile(trustPolicyPath, canonicalValueBytes(trustPolicy));
|
||||
const protectedState = {
|
||||
latestLedgerHeadHash: h("1"),
|
||||
latestLedgerRevision: 1,
|
||||
currentManifestDigest: null,
|
||||
publisherFence: 0,
|
||||
releaseTrustPolicyDigest: h("9"),
|
||||
};
|
||||
await expect(
|
||||
verifySignedReleaseForTest({
|
||||
signedManifestPath,
|
||||
trustPolicyPath,
|
||||
protectedState,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
verifySignedReleaseEnvelope: () => {
|
||||
throw new Error("must not reach verifier");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/trust.policy|authority|digest/i);
|
||||
protectedState.releaseTrustPolicyDigest = canonicalJsonDigest(
|
||||
"portfolio-trust-policy/v1",
|
||||
trustPolicy,
|
||||
);
|
||||
await expect(
|
||||
verifySignedReleaseForTest({
|
||||
signedManifestPath,
|
||||
trustPolicyPath,
|
||||
protectedState,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
verifySignedReleaseEnvelope: () => {
|
||||
throw new Error("reached-bound-verifier");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("reached-bound-verifier");
|
||||
});
|
||||
});
|
||||
@@ -9,19 +9,22 @@ import {
|
||||
canonicalValueBytes,
|
||||
} from "../../src/security/canonical-digest.ts";
|
||||
import { computeProductionInputRoot } from "../../src/security/production-input-root.ts";
|
||||
import { APPROVAL_PROJECTION_ATTESTATION_SCOPE } from "../../src/security/approval-closure.ts";
|
||||
// @ts-expect-error executable release modules intentionally have no browser declarations
|
||||
import * as releaseSigner from "../../scripts/release/sign-release.mjs";
|
||||
|
||||
const {
|
||||
signReleaseCandidate,
|
||||
signReleaseCandidateForTest,
|
||||
validateTrustPolicy,
|
||||
verifySignedReleaseEnvelope,
|
||||
verifySignedReleaseEnvelopeForTest,
|
||||
} = releaseSigner;
|
||||
|
||||
const h = (c: string) => c.repeat(64);
|
||||
const roles = [
|
||||
["approval", "portfolio-approval-decision/v1"],
|
||||
["projection", "portfolio-approval-projection/v1"],
|
||||
["projection", APPROVAL_PROJECTION_ATTESTATION_SCOPE],
|
||||
["privacyWorkload", "portfolio-privacy-workload/v1"],
|
||||
["policy", "portfolio-policy-attestation/v1"],
|
||||
["toolImage", "portfolio-tool-image-provenance/v1"],
|
||||
@@ -91,6 +94,9 @@ function envelope(
|
||||
...payload,
|
||||
};
|
||||
return {
|
||||
...(role === "projection"
|
||||
? { schema: "portfolio-approval-projection-attestation-envelope/v1" }
|
||||
: {}),
|
||||
payload: body,
|
||||
signature: {
|
||||
algorithm: "Ed25519",
|
||||
@@ -142,12 +148,27 @@ function fixture() {
|
||||
expiresAt: "2026-09-02T12:00:00Z",
|
||||
minimumBoundExpiry: "2026-09-02T12:00:00Z",
|
||||
};
|
||||
const sourceClosure = {
|
||||
schema: "portfolio-publication-closure/v1",
|
||||
members: [],
|
||||
};
|
||||
const publicObjectClosure = {
|
||||
schema: "portfolio-public-object-closure/v1",
|
||||
members: [],
|
||||
};
|
||||
const candidate = {
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
releaseManifest,
|
||||
latestLedgerHeadHash: h("1"),
|
||||
latestLedgerRevision: 7,
|
||||
sourceClosureDigest: h("4"),
|
||||
sourceClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-publication-closure/v1",
|
||||
sourceClosure,
|
||||
),
|
||||
publicObjectClosureDigest: canonicalJsonDigest(
|
||||
"portfolio-public-object-closure/v1",
|
||||
publicObjectClosure,
|
||||
),
|
||||
publicRefMap: {},
|
||||
lfsOids: [],
|
||||
releaseArchives: [],
|
||||
@@ -157,40 +178,40 @@ function fixture() {
|
||||
};
|
||||
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"],
|
||||
},
|
||||
],
|
||||
sourceClosureDigest: candidate.sourceClosureDigest,
|
||||
publicObjectClosureDigest: candidate.publicObjectClosureDigest,
|
||||
coverageCount: 0,
|
||||
passedCount: 0,
|
||||
checks: [],
|
||||
};
|
||||
const evidenceDigest = canonicalJsonDigest(
|
||||
"portfolio-scan-evidence/v1",
|
||||
evidence,
|
||||
);
|
||||
candidate.releaseManifest.scanEvidenceDigest = evidenceDigest;
|
||||
const projection = envelope("projection", roles[1][1], keyPairs, {
|
||||
approvedRecordRoot: h("9"),
|
||||
approvedAssetRoot: h("e"),
|
||||
approvalProofSetDigest: h("f"),
|
||||
inputDigests,
|
||||
productionInputRoot: releaseManifest.productionInputRoot,
|
||||
liveLedgerHeadHash: h("1"),
|
||||
liveLedgerRevision: 7,
|
||||
policyVersion: "2026-09-02",
|
||||
expiresAt: "2026-09-02T12:00:00Z",
|
||||
earliestApprovalExpiry: "2026-09-02T12:00:00Z",
|
||||
});
|
||||
candidate.releaseManifest.approvalProjectionAttestationDigest =
|
||||
canonicalJsonDigest(
|
||||
"portfolio-approval-projection-attestation-envelope/v1",
|
||||
projection,
|
||||
);
|
||||
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,
|
||||
}),
|
||||
projection,
|
||||
privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, {
|
||||
digest: h("2"),
|
||||
candidateDigest,
|
||||
@@ -207,6 +228,8 @@ function fixture() {
|
||||
};
|
||||
return {
|
||||
keyPairs,
|
||||
sourceClosure,
|
||||
publicObjectClosure,
|
||||
candidate,
|
||||
evidence,
|
||||
candidateDigest,
|
||||
@@ -217,6 +240,54 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("external scoped release signing", () => {
|
||||
test("production signer/verifier reject caller-selected time and signer reconstructs exact source/public closure", 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 },
|
||||
);
|
||||
await expect(
|
||||
signReleaseCandidate({
|
||||
...f,
|
||||
releaseKeyPath: keyPath,
|
||||
now: "2000-01-01T00:00:00Z",
|
||||
protectedState: {
|
||||
latestLedgerHeadHash: h("1"),
|
||||
latestLedgerRevision: 7,
|
||||
currentManifestDigest: null,
|
||||
publisherFence: 4,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/now|unknown|caller/i);
|
||||
const nonEmptyPublic = {
|
||||
schema: "portfolio-public-object-closure/v1",
|
||||
members: [
|
||||
{
|
||||
objectId: "private.txt",
|
||||
inputDigest: h("d"),
|
||||
requiredChecks: ["secret"],
|
||||
},
|
||||
],
|
||||
};
|
||||
await expect(
|
||||
signReleaseCandidateForTest({
|
||||
...f,
|
||||
publicObjectClosure: nonEmptyPublic,
|
||||
releaseKeyPath: keyPath,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
protectedState: {
|
||||
latestLedgerHeadHash: h("1"),
|
||||
latestLedgerRevision: 7,
|
||||
currentManifestDigest: null,
|
||||
publisherFence: 4,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/closure|digest|non-empty/i);
|
||||
});
|
||||
|
||||
test("signs exact canonical candidate bytes with an external Ed25519 key", async () => {
|
||||
const f = fixture();
|
||||
const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-"));
|
||||
@@ -226,7 +297,7 @@ describe("external scoped release signing", () => {
|
||||
f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }),
|
||||
{ mode: 0o400 },
|
||||
);
|
||||
const result = await signReleaseCandidate({
|
||||
const result = await signReleaseCandidateForTest({
|
||||
...f,
|
||||
releaseKeyPath: keyPath,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
@@ -247,7 +318,7 @@ describe("external scoped release signing", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
verifySignedReleaseEnvelope({
|
||||
verifySignedReleaseEnvelopeForTest({
|
||||
signedRelease: result,
|
||||
trustPolicy: f.trustPolicy,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
@@ -285,14 +356,14 @@ describe("external scoped release signing", () => {
|
||||
currentManifestDigest: null,
|
||||
publisherFence: 4,
|
||||
};
|
||||
const signedRelease = await signReleaseCandidate({
|
||||
const signedRelease = await signReleaseCandidateForTest({
|
||||
...f,
|
||||
releaseKeyPath: keyPath,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
protectedState: state,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedReleaseEnvelope({
|
||||
verifySignedReleaseEnvelopeForTest({
|
||||
signedRelease,
|
||||
trustPolicy: f.trustPolicy,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
@@ -300,7 +371,7 @@ describe("external scoped release signing", () => {
|
||||
}),
|
||||
).toThrow(/stale/i);
|
||||
expect(() =>
|
||||
verifySignedReleaseEnvelope({
|
||||
verifySignedReleaseEnvelopeForTest({
|
||||
signedRelease: {
|
||||
...signedRelease,
|
||||
payload: { ...signedRelease.payload, latestLedgerRevision: 8 },
|
||||
@@ -315,7 +386,7 @@ describe("external scoped release signing", () => {
|
||||
test("refuses repository-resident keys and non-external key material", async () => {
|
||||
const f = fixture();
|
||||
await expect(
|
||||
signReleaseCandidate({
|
||||
signReleaseCandidateForTest({
|
||||
...f,
|
||||
releaseKeyPath: join(process.cwd(), "release.pem"),
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
@@ -424,7 +495,7 @@ describe("external scoped release signing", () => {
|
||||
{ mode: 0o400 },
|
||||
);
|
||||
await expect(
|
||||
signReleaseCandidate({
|
||||
signReleaseCandidateForTest({
|
||||
...f,
|
||||
releaseKeyPath: keyPath,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
|
||||
@@ -1,86 +1,242 @@
|
||||
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 { computeApprovalClosure, verifyApprovalProjectionAttestation } from '../../src/security/approval-closure.ts';
|
||||
import { type ApprovalPayload } from '../../src/security/approval-envelope.ts';
|
||||
import { canonicalJsonDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
||||
import {
|
||||
computeApprovalClosure,
|
||||
verifyApprovalProjectionAttestation,
|
||||
} from "../../src/security/approval-closure.ts";
|
||||
import { type ApprovalPayload } from "../../src/security/approval-envelope.ts";
|
||||
import {
|
||||
canonicalJsonDigest,
|
||||
canonicalValueBytes,
|
||||
} from "../../src/security/canonical-digest.ts";
|
||||
|
||||
const h = (c: string) => c.repeat(64);
|
||||
const keys = generateKeyPairSync('ed25519');
|
||||
const trust = {
|
||||
now: '2026-09-02T00:00:00Z', latestLedgerHeadHash: h('9'), latestLedgerRevision: 3,
|
||||
trustedKeys: [{ keyId: 'key', issuerEpoch: 1, scope: 'portfolio-approval-decision/v1' as const, authorizedRoles: ['approver'], publicKey: keys.publicKey }],
|
||||
};
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
function envelope(payload: ApprovalPayload) {
|
||||
return {
|
||||
schema: 'portfolio-approval-envelope/v1' as const,
|
||||
schema: "portfolio-approval-envelope/v1" as const,
|
||||
payload,
|
||||
signature: { algorithm: 'Ed25519' as const, encoding: 'base64url-nopad' as const, value: sign(null, canonicalValueBytes(payload), keys.privateKey).toString('base64url') },
|
||||
signature: {
|
||||
algorithm: "Ed25519" as const,
|
||||
encoding: "base64url-nopad" as const,
|
||||
value: sign(null, canonicalValueBytes(payload), keys.privateKey).toString(
|
||||
"base64url",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
function base(recordClass: 'content' | 'asset', id: string): ApprovalPayload {
|
||||
function base(
|
||||
recordClass: "content" | "asset",
|
||||
id: string,
|
||||
ledgerRevision: number,
|
||||
previousLedgerHeadHash: string,
|
||||
): ApprovalPayload {
|
||||
return {
|
||||
schema: 'portfolio-approval-payload/v1', keyId: 'key', issuerEpoch: 1, authorizedRole: 'approver',
|
||||
evidenceFingerprints: [h('1')], recordClass, recordType: recordClass === 'content' ? 'profile' : '',
|
||||
publicRecordIdOrAssetId: id, approvedPublicRecordHashOrManifestRecordHash: h(recordClass === 'content' ? '2' : '3'),
|
||||
sanitizedOutputHashOrNull: recordClass === 'asset' ? h('4') : null, decision: 'approve',
|
||||
issuedAt: '2026-09-01T00:00:00Z', expiresAt: '2026-10-01T00:00:00Z', previousLedgerHeadHash: h('8'),
|
||||
ledgerRevision: 2, policyVersion: 'p1',
|
||||
schema: "portfolio-approval-payload/v1",
|
||||
keyId: "key",
|
||||
issuerEpoch: 1,
|
||||
authorizedRole: "approver",
|
||||
evidenceFingerprints: [h("1")],
|
||||
recordClass,
|
||||
recordType: recordClass === "content" ? "profile" : "",
|
||||
publicRecordIdOrAssetId: id,
|
||||
approvedPublicRecordHashOrManifestRecordHash: h(
|
||||
recordClass === "content" ? "2" : "3",
|
||||
),
|
||||
sanitizedOutputHashOrNull: recordClass === "asset" ? h("4") : null,
|
||||
decision: "approve",
|
||||
issuedAt: "2026-09-01T00:00:00Z",
|
||||
expiresAt: "2026-10-01T00:00:00Z",
|
||||
previousLedgerHeadHash,
|
||||
ledgerRevision,
|
||||
policyVersion: "p1",
|
||||
};
|
||||
}
|
||||
|
||||
describe('approval closure', () => {
|
||||
test('computes exact sorted record, asset, and proof roots', () => {
|
||||
const content = base('content', 'profile-main');
|
||||
const asset = base('asset', 'asset-main');
|
||||
const result = computeApprovalClosure({
|
||||
contentRecords: [{ recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope: envelope(content) }],
|
||||
assetRecords: [{ assetId: 'asset-main', approvedManifestRecordHash: h('3'), sanitizedOutputHash: h('4'), envelope: envelope(asset) }],
|
||||
trust,
|
||||
function ledgerTrust(entries: ReturnType<typeof envelope>[]) {
|
||||
let head = h("0");
|
||||
entries.forEach((entry, index) => {
|
||||
const envelopeDigest = canonicalJsonDigest(
|
||||
"portfolio-approval-envelope-digest/v1",
|
||||
entry,
|
||||
);
|
||||
head = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", {
|
||||
previousLedgerHeadHash: head,
|
||||
ledgerRevision: index + 1,
|
||||
envelopeDigest,
|
||||
});
|
||||
const contentEnvelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', envelope(content));
|
||||
const assetEnvelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', envelope(asset));
|
||||
expect(result.approvedRecordRoot).toBe(canonicalJsonDigest('portfolio-record-closure/v1', {
|
||||
records: [['profile', 'profile-main', h('2'), contentEnvelopeDigest]],
|
||||
}));
|
||||
expect(result.approvedAssetRoot).toBe(canonicalJsonDigest('portfolio-asset-closure/v1', {
|
||||
assets: [['asset-main', h('3'), h('4'), assetEnvelopeDigest]],
|
||||
}));
|
||||
expect(result.approvalProofSetDigest).toBe(canonicalJsonDigest('portfolio-approval-proof-set/v1', {
|
||||
envelopes: [['asset', '', 'asset-main', assetEnvelopeDigest], ['content', 'profile', 'profile-main', contentEnvelopeDigest]],
|
||||
}));
|
||||
});
|
||||
return {
|
||||
now: "2026-09-02T00:00:00Z",
|
||||
latestLedgerHeadHash: head,
|
||||
latestLedgerRevision: entries.length,
|
||||
trustedKeys: [
|
||||
{
|
||||
keyId: "key",
|
||||
issuerEpoch: 1,
|
||||
scope: "portfolio-approval-decision/v1" as const,
|
||||
authorizedRoles: ["approver"],
|
||||
publicKey: keys.publicKey,
|
||||
},
|
||||
],
|
||||
ledgerProof: {
|
||||
schema: "portfolio-approval-ledger-proof/v1" as const,
|
||||
genesisHeadHash: h("0"),
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("approval closure", () => {
|
||||
test("computes exact sorted record, asset, and proof roots", () => {
|
||||
const content = base("content", "profile-main", 1, h("0"));
|
||||
const contentEnvelope = envelope(content);
|
||||
const head1 = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", {
|
||||
previousLedgerHeadHash: h("0"),
|
||||
ledgerRevision: 1,
|
||||
envelopeDigest: canonicalJsonDigest(
|
||||
"portfolio-approval-envelope-digest/v1",
|
||||
contentEnvelope,
|
||||
),
|
||||
});
|
||||
const asset = base("asset", "asset-main", 2, head1);
|
||||
const assetEnvelope = envelope(asset);
|
||||
const result = computeApprovalClosure({
|
||||
contentRecords: [
|
||||
{
|
||||
recordType: "profile",
|
||||
publicRecordId: "profile-main",
|
||||
approvedPublicRecordHash: h("2"),
|
||||
envelope: contentEnvelope,
|
||||
},
|
||||
],
|
||||
assetRecords: [
|
||||
{
|
||||
assetId: "asset-main",
|
||||
approvedManifestRecordHash: h("3"),
|
||||
sanitizedOutputHash: h("4"),
|
||||
envelope: assetEnvelope,
|
||||
},
|
||||
],
|
||||
trust: ledgerTrust([contentEnvelope, assetEnvelope]),
|
||||
});
|
||||
const contentEnvelopeDigest = canonicalJsonDigest(
|
||||
"portfolio-approval-envelope-digest/v1",
|
||||
envelope(content),
|
||||
);
|
||||
const assetEnvelopeDigest = canonicalJsonDigest(
|
||||
"portfolio-approval-envelope-digest/v1",
|
||||
envelope(asset),
|
||||
);
|
||||
expect(result.approvedRecordRoot).toBe(
|
||||
canonicalJsonDigest("portfolio-record-closure/v1", {
|
||||
records: [["profile", "profile-main", h("2"), contentEnvelopeDigest]],
|
||||
}),
|
||||
);
|
||||
expect(result.approvedAssetRoot).toBe(
|
||||
canonicalJsonDigest("portfolio-asset-closure/v1", {
|
||||
assets: [["asset-main", h("3"), h("4"), assetEnvelopeDigest]],
|
||||
}),
|
||||
);
|
||||
expect(result.approvalProofSetDigest).toBe(
|
||||
canonicalJsonDigest("portfolio-approval-proof-set/v1", {
|
||||
envelopes: [
|
||||
["asset", "", "asset-main", assetEnvelopeDigest],
|
||||
["content", "profile", "profile-main", contentEnvelopeDigest],
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects duplicate IDs and stale protected ledger input', () => {
|
||||
const content = base('content', 'profile-main');
|
||||
const record = { recordType: 'profile', publicRecordId: 'profile-main', approvedPublicRecordHash: h('2'), envelope: envelope(content) };
|
||||
expect(() => computeApprovalClosure({ contentRecords: [record, record], assetRecords: [], trust })).toThrow();
|
||||
expect(() => computeApprovalClosure({ contentRecords: [record], assetRecords: [], trust: { ...trust, latestLedgerRevision: 1 } })).toThrow();
|
||||
test("rejects duplicate IDs and stale protected ledger input", () => {
|
||||
const content = base("content", "profile-main", 1, h("0"));
|
||||
const record = {
|
||||
recordType: "profile",
|
||||
publicRecordId: "profile-main",
|
||||
approvedPublicRecordHash: h("2"),
|
||||
envelope: envelope(content),
|
||||
};
|
||||
const trust = ledgerTrust([record.envelope]);
|
||||
expect(() =>
|
||||
computeApprovalClosure({
|
||||
contentRecords: [record, record],
|
||||
assetRecords: [],
|
||||
trust,
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
computeApprovalClosure({
|
||||
contentRecords: [record],
|
||||
assetRecords: [],
|
||||
trust: { ...trust, latestLedgerRevision: 0 },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('verifies a scoped projection attestation against protected state and trusted time', () => {
|
||||
test("verifies a scoped projection attestation against protected state and trusted time", () => {
|
||||
const payload = {
|
||||
schema: 'portfolio-approval-projection-attestation/v1' as const,
|
||||
keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1' as const,
|
||||
approvedRecordRoot: h('1'), approvedAssetRoot: h('2'), approvalProofSetDigest: h('3'),
|
||||
inputDigests: { PortfolioContent: h('4'), PublicAssetManifest: h('5'), PreparedAssetSet: h('6'), SiteConfig: h('7') },
|
||||
productionInputRoot: 'e19fd4083ba973074449e5ecc9364c2b339eadea32e61d8dd2f0d8e3d6e91c85',
|
||||
liveLedgerHeadHash: h('8'), liveLedgerRevision: 42, policyVersion: 'projection-policy-1',
|
||||
issuedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z', earliestApprovalExpiry: '2026-09-04T00:00:00Z',
|
||||
schema: "portfolio-approval-projection-attestation/v1" as const,
|
||||
keyId: "projection-key",
|
||||
issuerEpoch: 4,
|
||||
scope: "portfolio-approval-projection-attestation/v1" as const,
|
||||
approvedRecordRoot: h("1"),
|
||||
approvedAssetRoot: h("2"),
|
||||
approvalProofSetDigest: h("3"),
|
||||
inputDigests: {
|
||||
PortfolioContent: h("4"),
|
||||
PublicAssetManifest: h("5"),
|
||||
PreparedAssetSet: h("6"),
|
||||
SiteConfig: h("7"),
|
||||
},
|
||||
productionInputRoot:
|
||||
"e19fd4083ba973074449e5ecc9364c2b339eadea32e61d8dd2f0d8e3d6e91c85",
|
||||
liveLedgerHeadHash: h("8"),
|
||||
liveLedgerRevision: 42,
|
||||
policyVersion: "projection-policy-1",
|
||||
issuedAt: "2026-09-02T00:00:00Z",
|
||||
expiresAt: "2026-09-03T00:00:00Z",
|
||||
earliestApprovalExpiry: "2026-09-04T00:00:00Z",
|
||||
};
|
||||
const projectionKeys = generateKeyPairSync('ed25519');
|
||||
const projectionKeys = generateKeyPairSync("ed25519");
|
||||
const attestation = {
|
||||
schema: 'portfolio-approval-projection-attestation-envelope/v1' as const,
|
||||
schema: "portfolio-approval-projection-attestation-envelope/v1" as const,
|
||||
payload,
|
||||
signature: { algorithm: 'Ed25519' as const, encoding: 'base64url-nopad' as const, value: sign(null, canonicalValueBytes(payload), projectionKeys.privateKey).toString('base64url') },
|
||||
signature: {
|
||||
algorithm: "Ed25519" as const,
|
||||
encoding: "base64url-nopad" as const,
|
||||
value: sign(
|
||||
null,
|
||||
canonicalValueBytes(payload),
|
||||
projectionKeys.privateKey,
|
||||
).toString("base64url"),
|
||||
},
|
||||
};
|
||||
expect(verifyApprovalProjectionAttestation(attestation, {
|
||||
now: '2026-09-02T01:00:00Z', latestLedgerHeadHash: h('8'), latestLedgerRevision: 42,
|
||||
trustedKey: { keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1', publicKey: projectionKeys.publicKey },
|
||||
}).liveLedgerRevision).toBe(42);
|
||||
expect(() => verifyApprovalProjectionAttestation(attestation, {
|
||||
now: '2026-09-02T01:00:00Z', latestLedgerHeadHash: h('9'), latestLedgerRevision: 42,
|
||||
trustedKey: { keyId: 'projection-key', issuerEpoch: 4, scope: 'portfolio-approval-projection-attestation/v1', publicKey: projectionKeys.publicKey },
|
||||
})).toThrow();
|
||||
expect(
|
||||
verifyApprovalProjectionAttestation(attestation, {
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
latestLedgerHeadHash: h("8"),
|
||||
latestLedgerRevision: 42,
|
||||
trustedKey: {
|
||||
keyId: "projection-key",
|
||||
issuerEpoch: 4,
|
||||
scope: "portfolio-approval-projection-attestation/v1",
|
||||
publicKey: projectionKeys.publicKey,
|
||||
},
|
||||
}).liveLedgerRevision,
|
||||
).toBe(42);
|
||||
expect(() =>
|
||||
verifyApprovalProjectionAttestation(attestation, {
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
latestLedgerHeadHash: h("9"),
|
||||
latestLedgerRevision: 42,
|
||||
trustedKey: {
|
||||
keyId: "projection-key",
|
||||
issuerEpoch: 4,
|
||||
scope: "portfolio-approval-projection-attestation/v1",
|
||||
publicKey: projectionKeys.publicKey,
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,82 +1,137 @@
|
||||
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 {
|
||||
type ApprovalPayload,
|
||||
parseApprovalEnvelopeJson,
|
||||
verifyApprovalEnvelope,
|
||||
} from '../../src/security/approval-envelope.ts';
|
||||
import { canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
||||
} from "../../src/security/approval-envelope.ts";
|
||||
import {
|
||||
canonicalJsonDigest,
|
||||
canonicalValueBytes,
|
||||
} from "../../src/security/canonical-digest.ts";
|
||||
|
||||
const hash = (character: string) => character.repeat(64);
|
||||
const payload = (): ApprovalPayload => ({
|
||||
schema: 'portfolio-approval-payload/v1',
|
||||
keyId: 'approval-2026',
|
||||
schema: "portfolio-approval-payload/v1",
|
||||
keyId: "approval-2026",
|
||||
issuerEpoch: 3,
|
||||
authorizedRole: 'portfolio-approver',
|
||||
evidenceFingerprints: [hash('a')],
|
||||
recordClass: 'content',
|
||||
recordType: 'profile',
|
||||
publicRecordIdOrAssetId: 'profile-main',
|
||||
approvedPublicRecordHashOrManifestRecordHash: hash('b'),
|
||||
authorizedRole: "portfolio-approver",
|
||||
evidenceFingerprints: [hash("a")],
|
||||
recordClass: "content",
|
||||
recordType: "profile",
|
||||
publicRecordIdOrAssetId: "profile-main",
|
||||
approvedPublicRecordHashOrManifestRecordHash: hash("b"),
|
||||
sanitizedOutputHashOrNull: null,
|
||||
decision: 'approve',
|
||||
issuedAt: '2026-09-01T08:00:00Z',
|
||||
expiresAt: '2026-10-01T08:00:00Z',
|
||||
previousLedgerHeadHash: hash('c'),
|
||||
ledgerRevision: 12,
|
||||
policyVersion: 'approval-policy-1',
|
||||
decision: "approve",
|
||||
issuedAt: "2026-09-01T08:00:00Z",
|
||||
expiresAt: "2026-10-01T08:00:00Z",
|
||||
previousLedgerHeadHash: hash("0"),
|
||||
ledgerRevision: 1,
|
||||
policyVersion: "approval-policy-1",
|
||||
});
|
||||
|
||||
function signedEnvelope(overrides: Partial<ApprovalPayload> = {}) {
|
||||
const keys = generateKeyPairSync('ed25519');
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const signedPayload = { ...payload(), ...overrides };
|
||||
const signature = sign(null, canonicalValueBytes(signedPayload), keys.privateKey).toString('base64url');
|
||||
const signature = sign(
|
||||
null,
|
||||
canonicalValueBytes(signedPayload),
|
||||
keys.privateKey,
|
||||
).toString("base64url");
|
||||
return {
|
||||
envelope: {
|
||||
schema: 'portfolio-approval-envelope/v1' as const,
|
||||
schema: "portfolio-approval-envelope/v1" as const,
|
||||
payload: signedPayload,
|
||||
signature: { algorithm: 'Ed25519' as const, encoding: 'base64url-nopad' as const, value: signature },
|
||||
signature: {
|
||||
algorithm: "Ed25519" as const,
|
||||
encoding: "base64url-nopad" as const,
|
||||
value: signature,
|
||||
},
|
||||
},
|
||||
publicKey: keys.publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
describe('approval envelopes', () => {
|
||||
test('verifies an exact, current, scoped Ed25519 approval', () => {
|
||||
function trustFor(
|
||||
envelope: ReturnType<typeof signedEnvelope>["envelope"],
|
||||
publicKey: ReturnType<typeof signedEnvelope>["publicKey"],
|
||||
) {
|
||||
const envelopeDigest = canonicalJsonDigest(
|
||||
"portfolio-approval-envelope-digest/v1",
|
||||
envelope,
|
||||
);
|
||||
const latestLedgerHeadHash = canonicalJsonDigest(
|
||||
"portfolio-approval-ledger-entry/v1",
|
||||
{
|
||||
previousLedgerHeadHash: hash("0"),
|
||||
ledgerRevision: 1,
|
||||
envelopeDigest,
|
||||
},
|
||||
);
|
||||
return {
|
||||
now: "2026-09-02T00:00:00Z",
|
||||
latestLedgerHeadHash,
|
||||
latestLedgerRevision: 1,
|
||||
trustedKeys: [
|
||||
{
|
||||
keyId: "approval-2026",
|
||||
issuerEpoch: 3,
|
||||
scope: "portfolio-approval-decision/v1" as const,
|
||||
authorizedRoles: ["portfolio-approver"],
|
||||
publicKey,
|
||||
},
|
||||
],
|
||||
ledgerProof: {
|
||||
schema: "portfolio-approval-ledger-proof/v1" as const,
|
||||
genesisHeadHash: hash("0"),
|
||||
entries: [envelope],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("approval envelopes", () => {
|
||||
test("verifies an exact, current, scoped Ed25519 approval", () => {
|
||||
const { envelope, publicKey } = signedEnvelope();
|
||||
const verified = verifyApprovalEnvelope(envelope, {
|
||||
now: '2026-09-02T00:00:00Z',
|
||||
latestLedgerHeadHash: hash('d'),
|
||||
latestLedgerRevision: 13,
|
||||
trustedKeys: [{
|
||||
keyId: 'approval-2026', issuerEpoch: 3, scope: 'portfolio-approval-decision/v1',
|
||||
authorizedRoles: ['portfolio-approver'], publicKey,
|
||||
}],
|
||||
});
|
||||
expect(verified.publicRecordIdOrAssetId).toBe('profile-main');
|
||||
const verified = verifyApprovalEnvelope(
|
||||
envelope,
|
||||
trustFor(envelope, publicKey),
|
||||
);
|
||||
expect(verified.publicRecordIdOrAssetId).toBe("profile-main");
|
||||
});
|
||||
|
||||
test.each([
|
||||
['expired', { expiresAt: '2026-09-01T09:00:00Z' }],
|
||||
['non-UTC timestamp', { issuedAt: '2026-09-01T10:00:00+02:00' }],
|
||||
['wrong epoch', { issuerEpoch: 2 }],
|
||||
['rejection', { decision: 'reject' }],
|
||||
] as const)('rejects a %s envelope', (_label, override) => {
|
||||
["expired", { expiresAt: "2026-09-01T09:00:00Z" }],
|
||||
["non-UTC timestamp", { issuedAt: "2026-09-01T10:00:00+02:00" }],
|
||||
["wrong epoch", { issuerEpoch: 2 }],
|
||||
["rejection", { decision: "reject" }],
|
||||
] as const)("rejects a %s envelope", (_label, override) => {
|
||||
const { envelope, publicKey } = signedEnvelope(override);
|
||||
expect(() => verifyApprovalEnvelope(envelope, {
|
||||
now: '2026-09-02T00:00:00Z', latestLedgerHeadHash: hash('d'), latestLedgerRevision: 13,
|
||||
trustedKeys: [{ keyId: 'approval-2026', issuerEpoch: 3, scope: 'portfolio-approval-decision/v1', authorizedRoles: ['portfolio-approver'], publicKey }],
|
||||
})).toThrow();
|
||||
expect(() =>
|
||||
verifyApprovalEnvelope(envelope, trustFor(envelope, publicKey)),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('rejects unknown fields, duplicate keys, and alternate signature encodings at ingest', () => {
|
||||
test("rejects unknown fields, duplicate keys, and alternate signature encodings at ingest", () => {
|
||||
const { envelope } = signedEnvelope();
|
||||
expect(() => parseApprovalEnvelopeJson(JSON.stringify({ ...envelope, extra: true }))).toThrow();
|
||||
expect(() => parseApprovalEnvelopeJson('{"schema":"portfolio-approval-envelope/v1","schema":"portfolio-approval-envelope/v1"}')).toThrow();
|
||||
expect(() => parseApprovalEnvelopeJson(JSON.stringify({
|
||||
...envelope,
|
||||
signature: { ...envelope.signature, value: `${envelope.signature.value}=` },
|
||||
}))).toThrow();
|
||||
expect(() =>
|
||||
parseApprovalEnvelopeJson(JSON.stringify({ ...envelope, extra: true })),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseApprovalEnvelopeJson(
|
||||
'{"schema":"portfolio-approval-envelope/v1","schema":"portfolio-approval-envelope/v1"}',
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseApprovalEnvelopeJson(
|
||||
JSON.stringify({
|
||||
...envelope,
|
||||
signature: {
|
||||
...envelope.signature,
|
||||
value: `${envelope.signature.value}=`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user