fix: close release provenance and privacy gaps

This commit is contained in:
2026-09-02 11:25:52 +03:00
parent 7da2b72933
commit 79e5d04395
17 changed files with 2271 additions and 704 deletions

View File

@@ -1,23 +1,24 @@
import type { KeyLike } from 'node:crypto';
import type { KeyLike } from "node:crypto";
import {
canonicalJsonDigest,
assertWellFormedNfcString,
isSha256Hex,
parseCanonicalJson,
verifyEd25519Envelope,
} from './canonical-digest.ts';
} from "./canonical-digest.ts";
export interface ApprovalPayload {
schema: 'portfolio-approval-payload/v1';
schema: "portfolio-approval-payload/v1";
keyId: string;
issuerEpoch: number;
authorizedRole: string;
evidenceFingerprints: string[];
recordClass: 'content' | 'asset';
recordClass: "content" | "asset";
recordType: string;
publicRecordIdOrAssetId: string;
approvedPublicRecordHashOrManifestRecordHash: string;
sanitizedOutputHashOrNull: string | null;
decision: 'approve' | 'reject' | 'revoke';
decision: "approve" | "reject" | "revoke";
issuedAt: string;
expiresAt: string;
previousLedgerHeadHash: string;
@@ -26,15 +27,19 @@ export interface ApprovalPayload {
}
export interface ApprovalEnvelope {
schema: 'portfolio-approval-envelope/v1';
schema: "portfolio-approval-envelope/v1";
payload: ApprovalPayload;
signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string };
signature: {
algorithm: "Ed25519";
encoding: "base64url-nopad";
value: string;
};
}
export interface ApprovalTrustKey {
keyId: string;
issuerEpoch: number;
scope: 'portfolio-approval-decision/v1';
scope: "portfolio-approval-decision/v1";
authorizedRoles: string[];
publicKey: KeyLike;
revoked?: boolean;
@@ -45,99 +50,273 @@ export interface ApprovalTrustPolicy {
latestLedgerHeadHash: string;
latestLedgerRevision: number;
trustedKeys: ApprovalTrustKey[];
ledgerProof: ApprovalLedgerProof;
}
export interface ApprovalLedgerProof {
schema: "portfolio-approval-ledger-proof/v1";
genesisHeadHash: string;
entries: ApprovalEnvelope[];
}
const payloadKeys = [
'schema', 'keyId', 'issuerEpoch', 'authorizedRole', 'evidenceFingerprints', 'recordClass', 'recordType',
'publicRecordIdOrAssetId', 'approvedPublicRecordHashOrManifestRecordHash', 'sanitizedOutputHashOrNull',
'decision', 'issuedAt', 'expiresAt', 'previousLedgerHeadHash', 'ledgerRevision', 'policyVersion',
"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 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.`);
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 {
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])) {
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))) {
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');
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) {
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.`);
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 (
!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 (
!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.');
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 {
export function parseApprovalEnvelopeJson(
source: string | Uint8Array,
): ApprovalEnvelope {
return validateApprovalEnvelope(parseCanonicalJson(source));
}
export function verifyApprovalEnvelope(envelopeValue: unknown, trust: ApprovalTrustPolicy): ApprovalPayload {
function verifyEnvelopeSignature(
envelopeValue: unknown,
trust: ApprovalTrustPolicy,
): ApprovalEnvelope {
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 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);
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.");
verifyEd25519Envelope(envelope, key);
return envelope;
}
function typedDecisionId(payload: ApprovalPayload): string {
return `${payload.recordClass}\0${payload.recordType}\0${payload.publicRecordIdOrAssetId}`;
}
export function verifyApprovalLedger(
trust: ApprovalTrustPolicy,
): Map<string, ApprovalEnvelope> {
validTimestamp(trust.now, "trusted now");
if (
!isSha256Hex(trust.latestLedgerHeadHash) ||
!Number.isSafeInteger(trust.latestLedgerRevision) ||
trust.latestLedgerRevision < 0
) {
throw new Error("Protected live-ledger state is invalid.");
}
const proof = trust.ledgerProof;
if (
proof === null ||
typeof proof !== "object" ||
Array.isArray(proof) ||
JSON.stringify(Object.keys(proof).sort()) !==
JSON.stringify(["entries", "genesisHeadHash", "schema"]) ||
proof.schema !== "portfolio-approval-ledger-proof/v1" ||
!isSha256Hex(proof.genesisHeadHash) ||
!Array.isArray(proof.entries) ||
proof.entries.length !== trust.latestLedgerRevision
)
throw new Error("Authenticated approval ledger proof is incomplete.");
let head = proof.genesisHeadHash;
const latest = new Map<string, ApprovalEnvelope>();
proof.entries.forEach((raw, index) => {
const envelope = verifyEnvelopeSignature(raw, trust);
const revision = index + 1;
if (
envelope.payload.ledgerRevision !== revision ||
envelope.payload.previousLedgerHeadHash !== head ||
Date.parse(envelope.payload.issuedAt) > Date.parse(trust.now)
)
throw new Error(
"Approval ledger chain is not contiguous through the protected revision.",
);
const envelopeDigest = canonicalJsonDigest(
"portfolio-approval-envelope-digest/v1",
envelope,
);
head = canonicalJsonDigest("portfolio-approval-ledger-entry/v1", {
previousLedgerHeadHash: head,
ledgerRevision: revision,
envelopeDigest,
});
latest.set(typedDecisionId(envelope.payload), envelope);
});
if (head !== trust.latestLedgerHeadHash)
throw new Error(
"Approval ledger proof does not authenticate the protected head.",
);
return latest;
}
export function verifyApprovalEnvelope(
envelopeValue: unknown,
trust: ApprovalTrustPolicy,
): ApprovalPayload {
const envelope = verifyEnvelopeSignature(envelopeValue, trust);
const latest = verifyApprovalLedger(trust).get(
typedDecisionId(envelope.payload),
);
if (
latest === undefined ||
canonicalJsonDigest("portfolio-approval-envelope-digest/v1", latest) !==
canonicalJsonDigest("portfolio-approval-envelope-digest/v1", envelope)
) {
throw new Error(
"Approval is not the latest authenticated decision for this record.",
);
}
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("The latest decision is reject/revoke, not approval.");
return envelope.payload;
}