feat: verify canonical portfolio release provenance
This commit is contained in:
2382
package-lock.json
generated
2382
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,12 +31,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"canonicalize": "2.1.0",
|
||||||
"three": "^0.185.1",
|
"three": "^0.185.1",
|
||||||
"vue": "^3.5.42",
|
"vue": "^3.5.42",
|
||||||
"zod": "^4.5.4"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.13.0",
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
"@cyclonedx/cyclonedx-npm": "6.0.1",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.8",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"@vue/server-renderer": "^3.5.42",
|
"@vue/server-renderer": "^3.5.42",
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
"eslint": "^10.9.1",
|
"eslint": "^10.9.1",
|
||||||
"file-type": "^22.0.2",
|
"file-type": "^22.0.2",
|
||||||
"happy-dom": "^20.12.0",
|
"happy-dom": "^20.12.0",
|
||||||
|
"license-checker-rseidelsohn": "4.4.2",
|
||||||
"lighthouse": "^13.4.1",
|
"lighthouse": "^13.4.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
|
|||||||
86
scripts/release/verify-approvals.mjs
Normal file
86
scripts/release/verify-approvals.mjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createPublicKey } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
import { computeApprovalClosure } from '../../src/security/approval-closure.ts';
|
||||||
|
import { canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../src/security/canonical-digest.ts';
|
||||||
|
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
|
||||||
|
|
||||||
|
const expectedRequestFields = [
|
||||||
|
'schema', 'now', 'latestLedgerHeadHash', 'latestLedgerRevision', 'trustedKeys', 'contentRecords', 'assetRecords', 'inputDigests',
|
||||||
|
].sort();
|
||||||
|
const expectedKeyFields = ['keyId', 'issuerEpoch', 'scope', 'authorizedRoles', 'publicKeyPem'].sort();
|
||||||
|
const expectedInputs = ['PortfolioContent', 'PreparedAssetSet', 'PublicAssetManifest', 'SiteConfig'];
|
||||||
|
|
||||||
|
function exactObject(value, fields, label) {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
||||||
|
if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(fields)) throw new Error(`${label} contains missing or unknown fields.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPath(args) {
|
||||||
|
if (args.length !== 2 || args[0] !== '--request' || args[1].length === 0) throw new Error('Usage: verify-approvals.mjs --request <canonical-request.json>');
|
||||||
|
return args[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyApprovalRequest(source) {
|
||||||
|
const request = exactObject(parseCanonicalJson(source), expectedRequestFields, 'request');
|
||||||
|
if (request.schema !== 'portfolio-approval-verification-request/v1') throw new Error('Request schema is invalid.');
|
||||||
|
if (!Array.isArray(request.trustedKeys) || !Array.isArray(request.contentRecords) || !Array.isArray(request.assetRecords)) throw new Error('Approval record lists are invalid.');
|
||||||
|
const trustedKeys = request.trustedKeys.map((candidate) => {
|
||||||
|
const key = exactObject(candidate, expectedKeyFields, 'trusted key');
|
||||||
|
if (key.scope !== 'portfolio-approval-decision/v1' || typeof key.keyId !== 'string' ||
|
||||||
|
!Number.isSafeInteger(key.issuerEpoch) || !Array.isArray(key.authorizedRoles) ||
|
||||||
|
key.authorizedRoles.some((role) => typeof role !== 'string') || typeof key.publicKeyPem !== 'string') {
|
||||||
|
throw new Error('Trusted approval key is invalid.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
keyId: key.keyId, issuerEpoch: key.issuerEpoch, scope: key.scope,
|
||||||
|
authorizedRoles: key.authorizedRoles, publicKey: createPublicKey(key.publicKeyPem),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const inputDigests = exactObject(request.inputDigests, expectedInputs, 'inputDigests');
|
||||||
|
if (Object.values(inputDigests).some((digest) => !isSha256Hex(digest))) throw new Error('Input digest is invalid.');
|
||||||
|
const closure = computeApprovalClosure({
|
||||||
|
contentRecords: request.contentRecords,
|
||||||
|
assetRecords: request.assetRecords,
|
||||||
|
trust: {
|
||||||
|
now: request.now,
|
||||||
|
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
||||||
|
latestLedgerRevision: request.latestLedgerRevision,
|
||||||
|
trustedKeys,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const productionInputRoot = computeProductionInputRoot({
|
||||||
|
portfolioContentDigest: inputDigests.PortfolioContent,
|
||||||
|
publicAssetManifestDigest: inputDigests.PublicAssetManifest,
|
||||||
|
preparedAssetSetDigest: inputDigests.PreparedAssetSet,
|
||||||
|
siteConfigDigest: inputDigests.SiteConfig,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schema: 'portfolio-approval-verification-result/v1',
|
||||||
|
approvedRecordRoot: closure.approvedRecordRoot,
|
||||||
|
approvedAssetRoot: closure.approvedAssetRoot,
|
||||||
|
approvalProofSetDigest: closure.approvalProofSetDigest,
|
||||||
|
productionInputRoot,
|
||||||
|
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
||||||
|
latestLedgerRevision: request.latestLedgerRevision,
|
||||||
|
earliestApprovalExpiry: closure.earliestApprovalExpiry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const path = requestPath(process.argv.slice(2));
|
||||||
|
const result = verifyApprovalRequest(await readFile(path));
|
||||||
|
process.stdout.write(Buffer.from(canonicalValueBytes(result)));
|
||||||
|
process.stdout.write('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
main().catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown verification failure.';
|
||||||
|
process.stderr.write(`Approval verification failed: ${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
167
src/security/approval-closure.ts
Normal file
167
src/security/approval-closure.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type { KeyLike } from 'node:crypto';
|
||||||
|
import { canonicalJsonDigest, isSha256Hex, verifyEd25519Envelope, type Sha256Hex } from './canonical-digest.ts';
|
||||||
|
import {
|
||||||
|
type ApprovalEnvelope,
|
||||||
|
type ApprovalTrustPolicy,
|
||||||
|
verifyApprovalEnvelope,
|
||||||
|
} from './approval-envelope.ts';
|
||||||
|
import { computeProductionInputRoot } from './production-input-root.ts';
|
||||||
|
|
||||||
|
export interface ContentApprovalRecord {
|
||||||
|
recordType: string;
|
||||||
|
publicRecordId: string;
|
||||||
|
approvedPublicRecordHash: string;
|
||||||
|
envelope: ApprovalEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetApprovalRecord {
|
||||||
|
assetId: string;
|
||||||
|
approvedManifestRecordHash: string;
|
||||||
|
sanitizedOutputHash: string;
|
||||||
|
envelope: ApprovalEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalClosure {
|
||||||
|
approvedRecordRoot: Sha256Hex;
|
||||||
|
approvedAssetRoot: Sha256Hex;
|
||||||
|
approvalProofSetDigest: Sha256Hex;
|
||||||
|
earliestApprovalExpiry: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalProjectionAttestationPayload {
|
||||||
|
schema: 'portfolio-approval-projection-attestation/v1';
|
||||||
|
keyId: string;
|
||||||
|
issuerEpoch: number;
|
||||||
|
scope: 'portfolio-approval-projection-attestation/v1';
|
||||||
|
approvedRecordRoot: string;
|
||||||
|
approvedAssetRoot: string;
|
||||||
|
approvalProofSetDigest: string;
|
||||||
|
inputDigests: { PortfolioContent: string; PublicAssetManifest: string; PreparedAssetSet: string; SiteConfig: string };
|
||||||
|
productionInputRoot: string;
|
||||||
|
liveLedgerHeadHash: string;
|
||||||
|
liveLedgerRevision: number;
|
||||||
|
policyVersion: string;
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
earliestApprovalExpiry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalProjectionAttestationEnvelope {
|
||||||
|
schema: 'portfolio-approval-projection-attestation-envelope/v1';
|
||||||
|
payload: ApprovalProjectionAttestationPayload;
|
||||||
|
signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
function utf8TupleCompare(left: string[], right: string[]): number {
|
||||||
|
for (let field = 0; field < Math.max(left.length, right.length); field += 1) {
|
||||||
|
const a = encoder.encode(left[field] ?? '');
|
||||||
|
const b = encoder.encode(right[field] ?? '');
|
||||||
|
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
||||||
|
if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0);
|
||||||
|
}
|
||||||
|
if (a.length !== b.length) return a.length - b.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeApprovalClosure(input: {
|
||||||
|
contentRecords: ContentApprovalRecord[];
|
||||||
|
assetRecords: AssetApprovalRecord[];
|
||||||
|
trust: ApprovalTrustPolicy;
|
||||||
|
}): ApprovalClosure {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const expiries: string[] = [];
|
||||||
|
const contentTuples = input.contentRecords.map((record) => {
|
||||||
|
const typedId = record.publicRecordId;
|
||||||
|
if (ids.has(typedId)) throw new Error('Duplicate content approval ID.');
|
||||||
|
ids.add(typedId);
|
||||||
|
if (!isSha256Hex(record.approvedPublicRecordHash)) throw new Error('Content record hash is invalid.');
|
||||||
|
const payload = verifyApprovalEnvelope(record.envelope, input.trust);
|
||||||
|
if (payload.recordClass !== 'content' || payload.recordType !== record.recordType ||
|
||||||
|
payload.publicRecordIdOrAssetId !== record.publicRecordId ||
|
||||||
|
payload.approvedPublicRecordHashOrManifestRecordHash !== record.approvedPublicRecordHash ||
|
||||||
|
payload.sanitizedOutputHashOrNull !== null) {
|
||||||
|
throw new Error('Content approval does not bind the exact emitted record.');
|
||||||
|
}
|
||||||
|
expiries.push(payload.expiresAt);
|
||||||
|
const envelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', record.envelope);
|
||||||
|
return [record.recordType, record.publicRecordId, record.approvedPublicRecordHash, envelopeDigest] as const;
|
||||||
|
}).sort((a, b) => utf8TupleCompare([a[0], a[1]], [b[0], b[1]]));
|
||||||
|
|
||||||
|
const assetTuples = input.assetRecords.map((record) => {
|
||||||
|
const typedId = record.assetId;
|
||||||
|
if (ids.has(typedId)) throw new Error('Duplicate asset approval ID.');
|
||||||
|
ids.add(typedId);
|
||||||
|
if (!isSha256Hex(record.approvedManifestRecordHash) || !isSha256Hex(record.sanitizedOutputHash)) throw new Error('Asset record hash is invalid.');
|
||||||
|
const payload = verifyApprovalEnvelope(record.envelope, input.trust);
|
||||||
|
if (payload.recordClass !== 'asset' || payload.recordType !== '' ||
|
||||||
|
payload.publicRecordIdOrAssetId !== record.assetId ||
|
||||||
|
payload.approvedPublicRecordHashOrManifestRecordHash !== record.approvedManifestRecordHash ||
|
||||||
|
payload.sanitizedOutputHashOrNull !== record.sanitizedOutputHash) {
|
||||||
|
throw new Error('Asset approval does not bind the exact emitted record and bytes.');
|
||||||
|
}
|
||||||
|
expiries.push(payload.expiresAt);
|
||||||
|
const envelopeDigest = canonicalJsonDigest('portfolio-approval-envelope-digest/v1', record.envelope);
|
||||||
|
return [record.assetId, record.approvedManifestRecordHash, record.sanitizedOutputHash, envelopeDigest] as const;
|
||||||
|
}).sort((a, b) => utf8TupleCompare([a[0]], [b[0]]));
|
||||||
|
|
||||||
|
const proofList = [
|
||||||
|
...contentTuples.map((tuple) => ['content', tuple[0], tuple[1], tuple[3]] as const),
|
||||||
|
...assetTuples.map((tuple) => ['asset', '', tuple[0], tuple[3]] as const),
|
||||||
|
].sort((a, b) => utf8TupleCompare([a[0], a[1], a[2]], [b[0], b[1], b[2]]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvedRecordRoot: canonicalJsonDigest('portfolio-record-closure/v1', { records: contentTuples }),
|
||||||
|
approvedAssetRoot: canonicalJsonDigest('portfolio-asset-closure/v1', { assets: assetTuples }),
|
||||||
|
approvalProofSetDigest: canonicalJsonDigest('portfolio-approval-proof-set/v1', { envelopes: proofList }),
|
||||||
|
earliestApprovalExpiry: expiries.sort()[0] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyApprovalProjectionAttestation(
|
||||||
|
envelopeValue: unknown,
|
||||||
|
trust: {
|
||||||
|
now: string;
|
||||||
|
latestLedgerHeadHash: string;
|
||||||
|
latestLedgerRevision: number;
|
||||||
|
trustedKey: { keyId: string; issuerEpoch: number; scope: 'portfolio-approval-projection-attestation/v1'; publicKey: KeyLike; revoked?: boolean };
|
||||||
|
},
|
||||||
|
): ApprovalProjectionAttestationPayload {
|
||||||
|
if (envelopeValue === null || typeof envelopeValue !== 'object' || Array.isArray(envelopeValue)) throw new Error('Projection envelope is invalid.');
|
||||||
|
const envelope = envelopeValue as Record<string, unknown>;
|
||||||
|
if (JSON.stringify(Object.keys(envelope).sort()) !== JSON.stringify(['payload', 'schema', 'signature'])) throw new Error('Projection envelope contains unknown fields.');
|
||||||
|
if (envelope.schema !== 'portfolio-approval-projection-attestation-envelope/v1') throw new Error('Projection envelope schema is invalid.');
|
||||||
|
if (envelope.payload === null || typeof envelope.payload !== 'object' || Array.isArray(envelope.payload)) throw new Error('Projection payload is invalid.');
|
||||||
|
const payload = envelope.payload as unknown as ApprovalProjectionAttestationPayload;
|
||||||
|
const payloadFields = [
|
||||||
|
'schema', 'keyId', 'issuerEpoch', 'scope', 'approvedRecordRoot', 'approvedAssetRoot', 'approvalProofSetDigest',
|
||||||
|
'inputDigests', 'productionInputRoot', 'liveLedgerHeadHash', 'liveLedgerRevision', 'policyVersion', 'issuedAt',
|
||||||
|
'expiresAt', 'earliestApprovalExpiry',
|
||||||
|
].sort();
|
||||||
|
if (JSON.stringify(Object.keys(payload).sort()) !== JSON.stringify(payloadFields)) throw new Error('Projection payload contains missing or unknown fields.');
|
||||||
|
if (payload.schema !== 'portfolio-approval-projection-attestation/v1' || payload.scope !== 'portfolio-approval-projection-attestation/v1') throw new Error('Projection payload schema/scope is invalid.');
|
||||||
|
if (trust.trustedKey.revoked || trust.trustedKey.scope !== payload.scope || trust.trustedKey.keyId !== payload.keyId || trust.trustedKey.issuerEpoch !== payload.issuerEpoch) {
|
||||||
|
throw new Error('Projection key, scope, or issuer epoch is not trusted.');
|
||||||
|
}
|
||||||
|
for (const digest of [payload.approvedRecordRoot, payload.approvedAssetRoot, payload.approvalProofSetDigest, payload.productionInputRoot, payload.liveLedgerHeadHash]) {
|
||||||
|
if (!isSha256Hex(digest)) throw new Error('Projection digest field is invalid.');
|
||||||
|
}
|
||||||
|
const inputKeys = Object.keys(payload.inputDigests).sort();
|
||||||
|
if (JSON.stringify(inputKeys) !== JSON.stringify(['PortfolioContent', 'PreparedAssetSet', 'PublicAssetManifest', 'SiteConfig']) ||
|
||||||
|
Object.values(payload.inputDigests).some((digest) => !isSha256Hex(digest))) throw new Error('Projection input digest closure is invalid.');
|
||||||
|
const expectedRoot = computeProductionInputRoot({
|
||||||
|
portfolioContentDigest: payload.inputDigests.PortfolioContent,
|
||||||
|
publicAssetManifestDigest: payload.inputDigests.PublicAssetManifest,
|
||||||
|
preparedAssetSetDigest: payload.inputDigests.PreparedAssetSet,
|
||||||
|
siteConfigDigest: payload.inputDigests.SiteConfig,
|
||||||
|
});
|
||||||
|
if (expectedRoot !== payload.productionInputRoot) throw new Error('Projection does not bind the exact four-input root.');
|
||||||
|
if (payload.liveLedgerHeadHash !== trust.latestLedgerHeadHash || payload.liveLedgerRevision !== trust.latestLedgerRevision) throw new Error('Projection live-ledger state is stale.');
|
||||||
|
for (const [label, value] of [['now', trust.now], ['issuedAt', payload.issuedAt], ['expiresAt', payload.expiresAt], ['earliestApprovalExpiry', payload.earliestApprovalExpiry]] as const) {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} timestamp is invalid.`);
|
||||||
|
}
|
||||||
|
if (Date.parse(payload.issuedAt) > Date.parse(trust.now) || Date.parse(trust.now) >= Date.parse(payload.expiresAt) ||
|
||||||
|
Date.parse(payload.expiresAt) > Date.parse(payload.earliestApprovalExpiry)) throw new Error('Projection is outside its trusted expiry bounds.');
|
||||||
|
return verifyEd25519Envelope(envelope as unknown as ApprovalProjectionAttestationEnvelope, trust.trustedKey);
|
||||||
|
}
|
||||||
143
src/security/approval-envelope.ts
Normal file
143
src/security/approval-envelope.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { KeyLike } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
assertWellFormedNfcString,
|
||||||
|
isSha256Hex,
|
||||||
|
parseCanonicalJson,
|
||||||
|
verifyEd25519Envelope,
|
||||||
|
} from './canonical-digest.ts';
|
||||||
|
|
||||||
|
export interface ApprovalPayload {
|
||||||
|
schema: 'portfolio-approval-payload/v1';
|
||||||
|
keyId: string;
|
||||||
|
issuerEpoch: number;
|
||||||
|
authorizedRole: string;
|
||||||
|
evidenceFingerprints: string[];
|
||||||
|
recordClass: 'content' | 'asset';
|
||||||
|
recordType: string;
|
||||||
|
publicRecordIdOrAssetId: string;
|
||||||
|
approvedPublicRecordHashOrManifestRecordHash: string;
|
||||||
|
sanitizedOutputHashOrNull: string | null;
|
||||||
|
decision: 'approve' | 'reject' | 'revoke';
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
previousLedgerHeadHash: string;
|
||||||
|
ledgerRevision: number;
|
||||||
|
policyVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalEnvelope {
|
||||||
|
schema: 'portfolio-approval-envelope/v1';
|
||||||
|
payload: ApprovalPayload;
|
||||||
|
signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalTrustKey {
|
||||||
|
keyId: string;
|
||||||
|
issuerEpoch: number;
|
||||||
|
scope: 'portfolio-approval-decision/v1';
|
||||||
|
authorizedRoles: string[];
|
||||||
|
publicKey: KeyLike;
|
||||||
|
revoked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalTrustPolicy {
|
||||||
|
now: string;
|
||||||
|
latestLedgerHeadHash: string;
|
||||||
|
latestLedgerRevision: number;
|
||||||
|
trustedKeys: ApprovalTrustKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadKeys = [
|
||||||
|
'schema', 'keyId', 'issuerEpoch', 'authorizedRole', 'evidenceFingerprints', 'recordClass', 'recordType',
|
||||||
|
'publicRecordIdOrAssetId', 'approvedPublicRecordHashOrManifestRecordHash', 'sanitizedOutputHashOrNull',
|
||||||
|
'decision', 'issuedAt', 'expiresAt', 'previousLedgerHeadHash', 'ledgerRevision', 'policyVersion',
|
||||||
|
] as const;
|
||||||
|
const envelopeKeys = ['schema', 'payload', 'signature'] as const;
|
||||||
|
const signatureKeys = ['algorithm', 'encoding', 'value'] as const;
|
||||||
|
const utcTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
||||||
|
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u;
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||||
|
throw new Error(`${label} contains missing or unknown fields.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validTimestamp(value: unknown, label: string): asserts value is string {
|
||||||
|
if (typeof value !== 'string' || !utcTimestamp.test(value) || Number.isNaN(Date.parse(value))) {
|
||||||
|
throw new Error(`${label} must be a UTC RFC 3339 timestamp.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateApprovalEnvelope(value: unknown): ApprovalEnvelope {
|
||||||
|
const outer = record(value, 'approval envelope');
|
||||||
|
exactKeys(outer, envelopeKeys, 'approval envelope');
|
||||||
|
if (outer.schema !== 'portfolio-approval-envelope/v1') throw new Error('Approval envelope schema is invalid.');
|
||||||
|
const payload = record(outer.payload, 'approval payload');
|
||||||
|
exactKeys(payload, payloadKeys, 'approval payload');
|
||||||
|
const signature = record(outer.signature, 'approval signature');
|
||||||
|
exactKeys(signature, signatureKeys, 'approval signature');
|
||||||
|
|
||||||
|
if (payload.schema !== 'portfolio-approval-payload/v1') throw new Error('Approval payload schema is invalid.');
|
||||||
|
for (const key of ['keyId', 'authorizedRole', 'publicRecordIdOrAssetId', 'policyVersion'] as const) {
|
||||||
|
const field = payload[key];
|
||||||
|
if (typeof field !== 'string' || !safeToken.test(field)) throw new Error(`${key} is invalid.`);
|
||||||
|
assertWellFormedNfcString(field, key);
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(payload.issuerEpoch) || (payload.issuerEpoch as number) < 1) throw new Error('issuerEpoch is invalid.');
|
||||||
|
if (!Number.isSafeInteger(payload.ledgerRevision) || (payload.ledgerRevision as number) < 0) throw new Error('ledgerRevision is invalid.');
|
||||||
|
if (payload.recordClass !== 'content' && payload.recordClass !== 'asset') throw new Error('recordClass is invalid.');
|
||||||
|
if (typeof payload.recordType !== 'string') throw new Error('recordType is invalid.');
|
||||||
|
assertWellFormedNfcString(payload.recordType, 'recordType');
|
||||||
|
if (payload.recordClass === 'content' && !safeToken.test(payload.recordType)) throw new Error('Content recordType is required.');
|
||||||
|
if (payload.recordClass === 'asset' && payload.recordType !== '') throw new Error('Asset recordType must be empty.');
|
||||||
|
if (!isSha256Hex(payload.approvedPublicRecordHashOrManifestRecordHash) || !isSha256Hex(payload.previousLedgerHeadHash)) {
|
||||||
|
throw new Error('Approval hash field is invalid.');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload.evidenceFingerprints) || payload.evidenceFingerprints.length === 0 ||
|
||||||
|
payload.evidenceFingerprints.some((item) => !isSha256Hex(item)) ||
|
||||||
|
new Set(payload.evidenceFingerprints).size !== payload.evidenceFingerprints.length) {
|
||||||
|
throw new Error('evidenceFingerprints must be a unique, non-empty digest list.');
|
||||||
|
}
|
||||||
|
if (payload.recordClass === 'content' && payload.sanitizedOutputHashOrNull !== null) throw new Error('Content approval cannot bind sanitized bytes.');
|
||||||
|
if (payload.recordClass === 'asset' && !isSha256Hex(payload.sanitizedOutputHashOrNull)) throw new Error('Asset approval must bind sanitized bytes.');
|
||||||
|
if (!['approve', 'reject', 'revoke'].includes(payload.decision as string)) throw new Error('Decision is invalid.');
|
||||||
|
validTimestamp(payload.issuedAt, 'issuedAt');
|
||||||
|
validTimestamp(payload.expiresAt, 'expiresAt');
|
||||||
|
if (Date.parse(payload.issuedAt) >= Date.parse(payload.expiresAt)) throw new Error('Approval expiry must follow issue time.');
|
||||||
|
if (signature.algorithm !== 'Ed25519' || signature.encoding !== 'base64url-nopad' || typeof signature.value !== 'string') {
|
||||||
|
throw new Error('Approval signature metadata is invalid.');
|
||||||
|
}
|
||||||
|
return outer as unknown as ApprovalEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApprovalEnvelopeJson(source: string | Uint8Array): ApprovalEnvelope {
|
||||||
|
return validateApprovalEnvelope(parseCanonicalJson(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyApprovalEnvelope(envelopeValue: unknown, trust: ApprovalTrustPolicy): ApprovalPayload {
|
||||||
|
const envelope = validateApprovalEnvelope(envelopeValue);
|
||||||
|
validTimestamp(trust.now, 'trusted now');
|
||||||
|
if (!isSha256Hex(trust.latestLedgerHeadHash) || !Number.isSafeInteger(trust.latestLedgerRevision)) {
|
||||||
|
throw new Error('Protected live-ledger state is invalid.');
|
||||||
|
}
|
||||||
|
const matchingKeys = trust.trustedKeys.filter((candidate) =>
|
||||||
|
candidate.keyId === envelope.payload.keyId && candidate.issuerEpoch === envelope.payload.issuerEpoch);
|
||||||
|
if (matchingKeys.length !== 1) throw new Error('Approval trust policy must contain exactly one matching key epoch.');
|
||||||
|
const key = matchingKeys[0];
|
||||||
|
if (key === undefined || key.revoked || key.scope !== 'portfolio-approval-decision/v1') throw new Error('Approval key, scope, or epoch is not trusted.');
|
||||||
|
if (!key.authorizedRoles.includes(envelope.payload.authorizedRole)) throw new Error('Approval role is not authorized.');
|
||||||
|
if (envelope.payload.ledgerRevision >= trust.latestLedgerRevision) throw new Error('Approval is not anchored before the protected live-ledger revision.');
|
||||||
|
if (Date.parse(trust.now) < Date.parse(envelope.payload.issuedAt) || Date.parse(trust.now) >= Date.parse(envelope.payload.expiresAt)) {
|
||||||
|
throw new Error('Approval is not current at the trusted time.');
|
||||||
|
}
|
||||||
|
if (envelope.payload.decision !== 'approve') throw new Error('Only a current approval decision can enter the production closure.');
|
||||||
|
return verifyEd25519Envelope(envelope, key);
|
||||||
|
}
|
||||||
218
src/security/canonical-digest.ts
Normal file
218
src/security/canonical-digest.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { createHash, verify, type KeyLike } from 'node:crypto';
|
||||||
|
import canonicalize from 'canonicalize';
|
||||||
|
|
||||||
|
export type Sha256Hex = string & { readonly __sha256Hex: unique symbol };
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||||
|
|
||||||
|
export function isSha256Hex(value: unknown): value is Sha256Hex {
|
||||||
|
return typeof value === 'string' && sha256Pattern.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertWellFormedNfcString(value: string, label = 'string'): void {
|
||||||
|
if (value !== value.normalize('NFC')) throw new Error(`${label} must be NFC-normalized.`);
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const unit = value.charCodeAt(index);
|
||||||
|
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||||
|
const next = value.charCodeAt(index + 1);
|
||||||
|
if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) throw new Error(`${label} contains an unpaired surrogate.`);
|
||||||
|
index += 1;
|
||||||
|
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
||||||
|
throw new Error(`${label} contains an unpaired surrogate.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCanonicalValue(value: unknown, path = '$'): void {
|
||||||
|
if (value === null || typeof value === 'boolean') return;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
assertWellFormedNfcString(value, path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) throw new Error(`${path} must contain a finite JSON number.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((member, index) => validateCanonicalValue(member, `${path}[${index}]`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) {
|
||||||
|
throw new Error(`${path} must contain only plain JSON values.`);
|
||||||
|
}
|
||||||
|
for (const [key, member] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
assertWellFormedNfcString(key, `${path} key`);
|
||||||
|
if (member === undefined) throw new Error(`${path}.${key} may not be undefined.`);
|
||||||
|
validateCanonicalValue(member, `${path}.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalValueBytes(value: unknown): Uint8Array {
|
||||||
|
validateCanonicalValue(value);
|
||||||
|
const serialized = canonicalize(value);
|
||||||
|
if (serialized === undefined) throw new Error('Value cannot be represented as canonical JSON.');
|
||||||
|
return encoder.encode(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalJsonBytes(schema: string, value: unknown): Uint8Array {
|
||||||
|
assertWellFormedNfcString(schema, 'schema');
|
||||||
|
return canonicalValueBytes({ schema, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byteDigest(bytes: Uint8Array): Sha256Hex {
|
||||||
|
return createHash('sha256').update(bytes).digest('hex') as Sha256Hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalJsonDigest(schema: string, value: unknown): Sha256Hex {
|
||||||
|
return byteDigest(canonicalJsonBytes(schema, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
class StrictJsonParser {
|
||||||
|
private index = 0;
|
||||||
|
private readonly source: string;
|
||||||
|
constructor(source: string) {
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(): unknown {
|
||||||
|
this.whitespace();
|
||||||
|
const value = this.value();
|
||||||
|
this.whitespace();
|
||||||
|
if (this.index !== this.source.length) throw new Error('Trailing data after JSON value.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private whitespace(): void {
|
||||||
|
while (/\s/u.test(this.source[this.index] ?? '')) this.index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private value(): unknown {
|
||||||
|
const token = this.source[this.index];
|
||||||
|
if (token === '{') return this.object();
|
||||||
|
if (token === '[') return this.array();
|
||||||
|
if (token === '"') return this.string();
|
||||||
|
for (const [literal, value] of [['true', true], ['false', false], ['null', null]] as const) {
|
||||||
|
if (this.source.startsWith(literal, this.index)) {
|
||||||
|
this.index += literal.length;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const match = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/uy;
|
||||||
|
match.lastIndex = this.index;
|
||||||
|
const result = match.exec(this.source);
|
||||||
|
if (result === null) throw new Error(`Invalid JSON token at offset ${this.index}.`);
|
||||||
|
this.index = match.lastIndex;
|
||||||
|
const number = Number(result[0]);
|
||||||
|
if (!Number.isFinite(number)) throw new Error('JSON number must be finite.');
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string(): string {
|
||||||
|
const start = this.index;
|
||||||
|
this.index += 1;
|
||||||
|
let escaped = false;
|
||||||
|
while (this.index < this.source.length) {
|
||||||
|
const character = this.source[this.index] ?? '';
|
||||||
|
this.index += 1;
|
||||||
|
if (!escaped && character === '"') {
|
||||||
|
const decoded = JSON.parse(this.source.slice(start, this.index)) as string;
|
||||||
|
assertWellFormedNfcString(decoded, 'JSON string');
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
if (!escaped && character === '\\') escaped = true;
|
||||||
|
else escaped = false;
|
||||||
|
}
|
||||||
|
throw new Error('Unterminated JSON string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private object(): Record<string, unknown> {
|
||||||
|
this.index += 1;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
const keys = new Set<string>();
|
||||||
|
this.whitespace();
|
||||||
|
if (this.source[this.index] === '}') {
|
||||||
|
this.index += 1;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
if (this.source[this.index] !== '"') throw new Error('Object key must be a JSON string.');
|
||||||
|
const key = this.string();
|
||||||
|
if (keys.has(key)) throw new Error(`Duplicate JSON key: ${key}`);
|
||||||
|
keys.add(key);
|
||||||
|
this.whitespace();
|
||||||
|
if (this.source[this.index] !== ':') throw new Error('Missing colon after object key.');
|
||||||
|
this.index += 1;
|
||||||
|
this.whitespace();
|
||||||
|
Object.defineProperty(result, key, { value: this.value(), enumerable: true, configurable: true, writable: true });
|
||||||
|
this.whitespace();
|
||||||
|
const delimiter = this.source[this.index];
|
||||||
|
this.index += 1;
|
||||||
|
if (delimiter === '}') return result;
|
||||||
|
if (delimiter !== ',') throw new Error('Invalid object delimiter.');
|
||||||
|
this.whitespace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private array(): unknown[] {
|
||||||
|
this.index += 1;
|
||||||
|
const result: unknown[] = [];
|
||||||
|
this.whitespace();
|
||||||
|
if (this.source[this.index] === ']') {
|
||||||
|
this.index += 1;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
result.push(this.value());
|
||||||
|
this.whitespace();
|
||||||
|
const delimiter = this.source[this.index];
|
||||||
|
this.index += 1;
|
||||||
|
if (delimiter === ']') return result;
|
||||||
|
if (delimiter !== ',') throw new Error('Invalid array delimiter.');
|
||||||
|
this.whitespace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCanonicalJson(source: string | Uint8Array): unknown {
|
||||||
|
const text = typeof source === 'string'
|
||||||
|
? source
|
||||||
|
: new TextDecoder('utf-8', { fatal: true }).decode(source);
|
||||||
|
const value = new StrictJsonParser(text).parse();
|
||||||
|
validateCanonicalValue(value);
|
||||||
|
const canonical = new TextDecoder().decode(canonicalValueBytes(value));
|
||||||
|
if (canonical !== text) throw new Error('JSON input is not the unique RFC 8785 encoding.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenericSignedEnvelope<T extends { keyId: string; issuerEpoch: number }> {
|
||||||
|
payload: T;
|
||||||
|
signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvelopeTrust {
|
||||||
|
keyId: string;
|
||||||
|
issuerEpoch: number;
|
||||||
|
publicKey: KeyLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEd25519Envelope<T extends { keyId: string; issuerEpoch: number }>(
|
||||||
|
envelope: GenericSignedEnvelope<T>,
|
||||||
|
trust: EnvelopeTrust,
|
||||||
|
): T {
|
||||||
|
if (envelope.signature.algorithm !== 'Ed25519' || envelope.signature.encoding !== 'base64url-nopad') {
|
||||||
|
throw new Error('Signature algorithm or encoding is not approved.');
|
||||||
|
}
|
||||||
|
if (envelope.payload.keyId !== trust.keyId || envelope.payload.issuerEpoch !== trust.issuerEpoch) {
|
||||||
|
throw new Error('Envelope key ID or issuer epoch is not trusted.');
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9_-]{86}$/u.test(envelope.signature.value)) throw new Error('Signature is not canonical unpadded base64url.');
|
||||||
|
const signature = Buffer.from(envelope.signature.value, 'base64url');
|
||||||
|
if (signature.length !== 64 || signature.toString('base64url') !== envelope.signature.value) {
|
||||||
|
throw new Error('Signature has an alternate or invalid encoding.');
|
||||||
|
}
|
||||||
|
if (!verify(null, canonicalValueBytes(envelope.payload), trust.publicKey, signature)) {
|
||||||
|
throw new Error('Ed25519 signature verification failed.');
|
||||||
|
}
|
||||||
|
return envelope.payload;
|
||||||
|
}
|
||||||
110
src/security/production-input-root.ts
Normal file
110
src/security/production-input-root.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { assertWellFormedNfcString, byteDigest, canonicalJsonDigest, isSha256Hex, type Sha256Hex } from './canonical-digest.ts';
|
||||||
|
|
||||||
|
export interface ProductionInputDigests {
|
||||||
|
portfolioContentDigest: string;
|
||||||
|
publicAssetManifestDigest: string;
|
||||||
|
preparedAssetSetDigest: string;
|
||||||
|
siteConfigDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedAssetMember {
|
||||||
|
assetId: string;
|
||||||
|
publicPath: string;
|
||||||
|
mediaType: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicAssetManifestRecord {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
sha256: string;
|
||||||
|
mediaType: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8Compare(left: string[], right: string[]): number {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
||||||
|
const a = encoder.encode(left[index] ?? '');
|
||||||
|
const b = encoder.encode(right[index] ?? '');
|
||||||
|
const length = Math.min(a.length, b.length);
|
||||||
|
for (let byte = 0; byte < length; byte += 1) if (a[byte] !== b[byte]) return (a[byte] ?? 0) - (b[byte] ?? 0);
|
||||||
|
if (a.length !== b.length) return a.length - b.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePublicPath(path: string): void {
|
||||||
|
assertWellFormedNfcString(path, 'public path');
|
||||||
|
if (path !== path.normalize('NFC') || path.includes('\\') || path.startsWith('/') || path.endsWith('/') ||
|
||||||
|
path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
||||||
|
throw new Error(`Invalid canonical public path: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePreparedAssetSetDigest(
|
||||||
|
members: PreparedAssetMember[],
|
||||||
|
expectedManifest?: PublicAssetManifestRecord[],
|
||||||
|
): Sha256Hex {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const foldedPaths = new Set<string>();
|
||||||
|
const tuples = members.map((member) => {
|
||||||
|
validatePublicPath(member.publicPath);
|
||||||
|
if (member.assetId !== member.assetId.normalize('NFC') || ids.has(member.assetId)) throw new Error('Prepared asset IDs must be unique and NFC.');
|
||||||
|
const folded = member.publicPath.toLocaleLowerCase('en-US');
|
||||||
|
if (paths.has(member.publicPath) || foldedPaths.has(folded)) throw new Error('Prepared asset paths collide.');
|
||||||
|
ids.add(member.assetId); paths.add(member.publicPath); foldedPaths.add(folded);
|
||||||
|
for (const dimension of [member.width, member.height, member.durationMs]) {
|
||||||
|
if (dimension !== null && (!Number.isSafeInteger(dimension) || dimension <= 0)) throw new Error('Prepared asset dimensions/duration are invalid.');
|
||||||
|
}
|
||||||
|
return [member.assetId, member.publicPath, member.mediaType, member.width, member.height, member.durationMs, byteDigest(member.bytes)] as const;
|
||||||
|
}).sort((a, b) => utf8Compare([a[0], a[1]], [b[0], b[1]]));
|
||||||
|
if (expectedManifest !== undefined) {
|
||||||
|
const expectedIds = new Set<string>();
|
||||||
|
const expectedPaths = new Set<string>();
|
||||||
|
const byId = new Map(members.map((member) => [member.assetId, member]));
|
||||||
|
if (expectedManifest.length !== members.length) throw new Error('PreparedAssetSet does not match the public manifest count.');
|
||||||
|
for (const expected of expectedManifest) {
|
||||||
|
if (expectedIds.has(expected.id) || expectedPaths.has(expected.fileName.toLocaleLowerCase('en-US'))) throw new Error('Public manifest contains duplicate IDs or paths.');
|
||||||
|
expectedIds.add(expected.id); expectedPaths.add(expected.fileName.toLocaleLowerCase('en-US'));
|
||||||
|
const member = byId.get(expected.id);
|
||||||
|
if (member === undefined || member.publicPath !== expected.fileName || member.mediaType !== expected.mediaType ||
|
||||||
|
member.width !== expected.width || member.height !== expected.height || member.durationMs !== expected.durationMs ||
|
||||||
|
byteDigest(member.bytes) !== expected.sha256) throw new Error('Prepared asset does not match its exact manifest record.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return canonicalJsonDigest('portfolio-prepared-asset-set/v1', { members: tuples });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeProductionInputRoot(digests: ProductionInputDigests): Sha256Hex {
|
||||||
|
for (const digest of Object.values(digests)) if (!isSha256Hex(digest)) throw new Error('Production input digest is invalid.');
|
||||||
|
return canonicalJsonDigest('portfolio-production-input-root/v1', {
|
||||||
|
inputs: [
|
||||||
|
['PortfolioContent', digests.portfolioContentDigest],
|
||||||
|
['PublicAssetManifest', digests.publicAssetManifestDigest],
|
||||||
|
['PreparedAssetSet', digests.preparedAssetSetDigest],
|
||||||
|
['SiteConfig', digests.siteConfigDigest],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digestProductionInputs(input: {
|
||||||
|
portfolioContent: unknown;
|
||||||
|
publicAssetManifest: unknown;
|
||||||
|
preparedAssetMembers: PreparedAssetMember[];
|
||||||
|
siteConfig: unknown;
|
||||||
|
}): ProductionInputDigests & { productionInputRoot: Sha256Hex } {
|
||||||
|
const digests = {
|
||||||
|
portfolioContentDigest: canonicalJsonDigest('portfolio-content/v1', input.portfolioContent),
|
||||||
|
publicAssetManifestDigest: canonicalJsonDigest('portfolio-public-asset-manifest/v1', input.publicAssetManifest),
|
||||||
|
preparedAssetSetDigest: computePreparedAssetSetDigest(input.preparedAssetMembers),
|
||||||
|
siteConfigDigest: canonicalJsonDigest('portfolio-site-config/v1', input.siteConfig),
|
||||||
|
};
|
||||||
|
return { ...digests, productionInputRoot: computeProductionInputRoot(digests) };
|
||||||
|
}
|
||||||
124
src/security/release-manifest.ts
Normal file
124
src/security/release-manifest.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { isSha256Hex } from './canonical-digest.ts';
|
||||||
|
import { computeProductionInputRoot, validatePublicPath } from './production-input-root.ts';
|
||||||
|
|
||||||
|
export interface ReleaseArtifact {
|
||||||
|
path: string;
|
||||||
|
sha256: string;
|
||||||
|
bytes: number;
|
||||||
|
encoding: 'identity' | 'gzip' | 'br';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseManifest {
|
||||||
|
schema: 'portfolio-release-manifest/v1';
|
||||||
|
launchMode: 'private-source-initial-launch';
|
||||||
|
buildId: string;
|
||||||
|
inputDigests: { PortfolioContent: string; PublicAssetManifest: string; PreparedAssetSet: string; SiteConfig: string };
|
||||||
|
productionInputRoot: string;
|
||||||
|
artifacts: ReleaseArtifact[];
|
||||||
|
deployFiles: string[];
|
||||||
|
sourceCommit: string;
|
||||||
|
sourceTree: string;
|
||||||
|
approvedRecordRoot: string;
|
||||||
|
approvedAssetRoot: string;
|
||||||
|
approvalProofSetDigest: string;
|
||||||
|
approvalProjectionAttestationDigest: string;
|
||||||
|
scanEvidenceDigest: string;
|
||||||
|
policyVersions: Record<string, string>;
|
||||||
|
toolVersions: Record<string, string>;
|
||||||
|
predecessorManifestDigest: string | null;
|
||||||
|
currentManifestDigest: string | null;
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
minimumBoundExpiry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestFields = [
|
||||||
|
'schema', 'launchMode', 'buildId', 'inputDigests', 'productionInputRoot', 'artifacts', 'deployFiles', 'sourceCommit',
|
||||||
|
'sourceTree', 'approvedRecordRoot', 'approvedAssetRoot', 'approvalProofSetDigest', 'approvalProjectionAttestationDigest',
|
||||||
|
'scanEvidenceDigest', 'policyVersions', 'toolVersions', 'predecessorManifestDigest', 'currentManifestDigest', 'issuedAt',
|
||||||
|
'expiresAt', 'minimumBoundExpiry',
|
||||||
|
] as const;
|
||||||
|
const digestFields = [
|
||||||
|
'productionInputRoot', 'approvedRecordRoot', 'approvedAssetRoot', 'approvalProofSetDigest',
|
||||||
|
'approvalProjectionAttestationDigest', 'scanEvidenceDigest',
|
||||||
|
] as const;
|
||||||
|
const inputFields = ['PortfolioContent', 'PublicAssetManifest', 'PreparedAssetSet', 'SiteConfig'] as const;
|
||||||
|
const artifactFields = ['path', 'sha256', 'bytes', 'encoding'] as const;
|
||||||
|
const utcTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
||||||
|
|
||||||
|
function object(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(value: Record<string, unknown>, fields: readonly string[], label: string): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...fields].sort();
|
||||||
|
if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) {
|
||||||
|
throw new Error(`${label} contains missing or unknown fields.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value: unknown, label: string): asserts value is string {
|
||||||
|
if (typeof value !== 'string' || !utcTimestamp.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} is not a UTC RFC 3339 timestamp.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionMap(value: unknown, label: string): void {
|
||||||
|
const map = object(value, label);
|
||||||
|
if (Object.keys(map).length === 0) throw new Error(`${label} cannot be empty.`);
|
||||||
|
for (const [key, version] of Object.entries(map)) {
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(key) || typeof version !== 'string' || version.length === 0 ||
|
||||||
|
key !== key.normalize('NFC') || version !== version.normalize('NFC')) throw new Error(`${label} contains an invalid entry.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateReleaseManifest(value: unknown, trust: { now: string }): ReleaseManifest {
|
||||||
|
const manifest = object(value, 'release manifest');
|
||||||
|
exact(manifest, manifestFields, 'release manifest');
|
||||||
|
if (manifest.schema !== 'portfolio-release-manifest/v1' || manifest.launchMode !== 'private-source-initial-launch') {
|
||||||
|
throw new Error('Release manifest schema or launch mode is invalid.');
|
||||||
|
}
|
||||||
|
if (typeof manifest.buildId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(manifest.buildId)) throw new Error('buildId is invalid.');
|
||||||
|
const inputs = object(manifest.inputDigests, 'inputDigests');
|
||||||
|
exact(inputs, inputFields, 'inputDigests');
|
||||||
|
for (const field of inputFields) if (!isSha256Hex(inputs[field])) throw new Error(`${field} digest is invalid.`);
|
||||||
|
for (const field of digestFields) if (!isSha256Hex(manifest[field])) throw new Error(`${field} is invalid.`);
|
||||||
|
const expectedInputRoot = computeProductionInputRoot({
|
||||||
|
portfolioContentDigest: inputs.PortfolioContent as string,
|
||||||
|
publicAssetManifestDigest: inputs.PublicAssetManifest as string,
|
||||||
|
preparedAssetSetDigest: inputs.PreparedAssetSet as string,
|
||||||
|
siteConfigDigest: inputs.SiteConfig as string,
|
||||||
|
});
|
||||||
|
if (manifest.productionInputRoot !== expectedInputRoot) throw new Error('productionInputRoot does not bind the exact four inputs.');
|
||||||
|
if (!Array.isArray(manifest.artifacts) || !Array.isArray(manifest.deployFiles) || manifest.artifacts.length === 0) throw new Error('Artifact closure is invalid.');
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const folded = new Set<string>();
|
||||||
|
for (const raw of manifest.artifacts) {
|
||||||
|
const artifact = object(raw, 'artifact');
|
||||||
|
exact(artifact, artifactFields, 'artifact');
|
||||||
|
if (typeof artifact.path !== 'string') throw new Error('Artifact path is invalid.');
|
||||||
|
validatePublicPath(artifact.path);
|
||||||
|
const lower = artifact.path.toLocaleLowerCase('en-US');
|
||||||
|
if (paths.has(artifact.path) || folded.has(lower)) throw new Error('Artifact path is duplicated or case-fold colliding.');
|
||||||
|
paths.add(artifact.path); folded.add(lower);
|
||||||
|
if (!isSha256Hex(artifact.sha256) || !Number.isSafeInteger(artifact.bytes) || (artifact.bytes as number) < 0 ||
|
||||||
|
!['identity', 'gzip', 'br'].includes(artifact.encoding as string)) throw new Error('Artifact metadata is invalid.');
|
||||||
|
}
|
||||||
|
const deployFiles = manifest.deployFiles as unknown[];
|
||||||
|
if (deployFiles.some((path) => typeof path !== 'string') || new Set(deployFiles).size !== deployFiles.length ||
|
||||||
|
deployFiles.length !== paths.size || deployFiles.some((path) => !paths.has(path as string))) {
|
||||||
|
throw new Error('Deploy file set must exactly equal the artifact closure.');
|
||||||
|
}
|
||||||
|
if (typeof manifest.sourceCommit !== 'string' || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(manifest.sourceCommit) ||
|
||||||
|
typeof manifest.sourceTree !== 'string' || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(manifest.sourceTree)) throw new Error('Source commit/tree is invalid.');
|
||||||
|
for (const field of ['predecessorManifestDigest', 'currentManifestDigest'] as const) {
|
||||||
|
if (manifest[field] !== null && !isSha256Hex(manifest[field])) throw new Error(`${field} is invalid.`);
|
||||||
|
}
|
||||||
|
versionMap(manifest.policyVersions, 'policyVersions');
|
||||||
|
versionMap(manifest.toolVersions, 'toolVersions');
|
||||||
|
timestamp(manifest.issuedAt, 'issuedAt'); timestamp(manifest.expiresAt, 'expiresAt');
|
||||||
|
timestamp(manifest.minimumBoundExpiry, 'minimumBoundExpiry'); timestamp(trust.now, 'trusted now');
|
||||||
|
if (Date.parse(manifest.issuedAt) > Date.parse(trust.now) || Date.parse(trust.now) >= Date.parse(manifest.expiresAt)) throw new Error('Manifest is not current at the trusted time.');
|
||||||
|
if (Date.parse(manifest.expiresAt) > Date.parse(manifest.minimumBoundExpiry)) throw new Error('Manifest expiry exceeds its minimum bound.');
|
||||||
|
return manifest as unknown as ReleaseManifest;
|
||||||
|
}
|
||||||
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