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