feat: verify canonical portfolio release provenance
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user