feat: verify canonical portfolio release provenance
This commit is contained in:
101
tests/integration/release-manifest.test.ts
Normal file
101
tests/integration/release-manifest.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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 { verifyApprovalRequest } 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('CLI verifier emits only canonical public roots from a signed external approval request', () => {
|
||||
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',
|
||||
};
|
||||
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 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 }],
|
||||
assetRecords: [],
|
||||
inputDigests: { PortfolioContent: h('a'), PublicAssetManifest: h('b'), PreparedAssetSet: h('c'), SiteConfig: h('d') },
|
||||
};
|
||||
const result = verifyApprovalRequest(canonicalValueBytes(request));
|
||||
expect(Object.keys(result).sort()).toEqual([
|
||||
'approvalProofSetDigest', 'approvedAssetRoot', 'approvedRecordRoot', 'earliestApprovalExpiry',
|
||||
'latestLedgerHeadHash', 'latestLedgerRevision', 'productionInputRoot', 'schema',
|
||||
]);
|
||||
const serialized = new TextDecoder().decode(canonicalValueBytes(result));
|
||||
expect(serialized).not.toContain('evidenceFingerprints');
|
||||
});
|
||||
});
|
||||
86
tests/unit/approval-closure.test.ts
Normal file
86
tests/unit/approval-closure.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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';
|
||||
|
||||
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 }],
|
||||
};
|
||||
function envelope(payload: ApprovalPayload) {
|
||||
return {
|
||||
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') },
|
||||
};
|
||||
}
|
||||
function base(recordClass: 'content' | 'asset', id: 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',
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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('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',
|
||||
};
|
||||
const projectionKeys = generateKeyPairSync('ed25519');
|
||||
const attestation = {
|
||||
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') },
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
82
tests/unit/approval-envelope.test.ts
Normal file
82
tests/unit/approval-envelope.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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';
|
||||
|
||||
const hash = (character: string) => character.repeat(64);
|
||||
const payload = (): ApprovalPayload => ({
|
||||
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'),
|
||||
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',
|
||||
});
|
||||
|
||||
function signedEnvelope(overrides: Partial<ApprovalPayload> = {}) {
|
||||
const keys = generateKeyPairSync('ed25519');
|
||||
const signedPayload = { ...payload(), ...overrides };
|
||||
const signature = sign(null, canonicalValueBytes(signedPayload), keys.privateKey).toString('base64url');
|
||||
return {
|
||||
envelope: {
|
||||
schema: 'portfolio-approval-envelope/v1' as const,
|
||||
payload: signedPayload,
|
||||
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', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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) => {
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
34
tests/unit/canonical-digest.test.ts
Normal file
34
tests/unit/canonical-digest.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
byteDigest,
|
||||
canonicalJsonBytes,
|
||||
canonicalJsonDigest,
|
||||
parseCanonicalJson,
|
||||
} from '../../src/security/canonical-digest.ts';
|
||||
|
||||
describe('canonical digest primitives', () => {
|
||||
test('matches an independently fixed RFC 8785 byte and digest vector', () => {
|
||||
const bytes = canonicalJsonBytes('portfolio-test/v1', { z: 1, a: 'é' });
|
||||
|
||||
expect(new TextDecoder().decode(bytes)).toBe(
|
||||
'{"schema":"portfolio-test/v1","value":{"a":"é","z":1}}',
|
||||
);
|
||||
expect(canonicalJsonDigest('portfolio-test/v1', { z: 1, a: 'é' })).toBe(
|
||||
'2d38a0324bcda2075f853db9972f6ce34ecb8072a85ec579b7c0e89be38df76a',
|
||||
);
|
||||
expect(byteDigest(new TextEncoder().encode('abc'))).toBe(
|
||||
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['duplicate keys', '{"a":1,"a":2}'],
|
||||
['non-canonical whitespace', '{ "a":1}'],
|
||||
['alternate number encoding', '{"a":1.0}'],
|
||||
['non-NFC string', '{"a":"e\\u0301"}'],
|
||||
['unpaired surrogate', '{"a":"\\ud800"}'],
|
||||
])('rejects %s at JSON ingest', (_label, source) => {
|
||||
expect(() => parseCanonicalJson(source)).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user