fix: close release provenance and privacy gaps
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
import type { KeyLike } from 'node:crypto';
|
||||
import { canonicalJsonDigest, isSha256Hex, verifyEd25519Envelope, type Sha256Hex } from './canonical-digest.ts';
|
||||
import type { KeyLike } from "node:crypto";
|
||||
import {
|
||||
canonicalJsonDigest,
|
||||
isSha256Hex,
|
||||
verifyEd25519Envelope,
|
||||
type Sha256Hex,
|
||||
} from "./canonical-digest.ts";
|
||||
import {
|
||||
type ApprovalEnvelope,
|
||||
type ApprovalTrustPolicy,
|
||||
verifyApprovalLedger,
|
||||
verifyApprovalEnvelope,
|
||||
} from './approval-envelope.ts';
|
||||
import { computeProductionInputRoot } from './production-input-root.ts';
|
||||
} from "./approval-envelope.ts";
|
||||
import { computeProductionInputRoot } from "./production-input-root.ts";
|
||||
|
||||
export const APPROVAL_PROJECTION_ATTESTATION_SCHEMA =
|
||||
"portfolio-approval-projection-attestation/v1" as const;
|
||||
export const APPROVAL_PROJECTION_ATTESTATION_SCOPE =
|
||||
APPROVAL_PROJECTION_ATTESTATION_SCHEMA;
|
||||
|
||||
export interface ContentApprovalRecord {
|
||||
recordType: string;
|
||||
@@ -29,14 +40,19 @@ export interface ApprovalClosure {
|
||||
}
|
||||
|
||||
export interface ApprovalProjectionAttestationPayload {
|
||||
schema: 'portfolio-approval-projection-attestation/v1';
|
||||
schema: typeof APPROVAL_PROJECTION_ATTESTATION_SCHEMA;
|
||||
keyId: string;
|
||||
issuerEpoch: number;
|
||||
scope: 'portfolio-approval-projection-attestation/v1';
|
||||
scope: typeof APPROVAL_PROJECTION_ATTESTATION_SCOPE;
|
||||
approvedRecordRoot: string;
|
||||
approvedAssetRoot: string;
|
||||
approvalProofSetDigest: string;
|
||||
inputDigests: { PortfolioContent: string; PublicAssetManifest: string; PreparedAssetSet: string; SiteConfig: string };
|
||||
inputDigests: {
|
||||
PortfolioContent: string;
|
||||
PublicAssetManifest: string;
|
||||
PreparedAssetSet: string;
|
||||
SiteConfig: string;
|
||||
};
|
||||
productionInputRoot: string;
|
||||
liveLedgerHeadHash: string;
|
||||
liveLedgerRevision: number;
|
||||
@@ -47,16 +63,20 @@ export interface ApprovalProjectionAttestationPayload {
|
||||
}
|
||||
|
||||
export interface ApprovalProjectionAttestationEnvelope {
|
||||
schema: 'portfolio-approval-projection-attestation-envelope/v1';
|
||||
schema: "portfolio-approval-projection-attestation-envelope/v1";
|
||||
payload: ApprovalProjectionAttestationPayload;
|
||||
signature: { algorithm: 'Ed25519'; encoding: 'base64url-nopad'; value: string };
|
||||
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] ?? '');
|
||||
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);
|
||||
}
|
||||
@@ -70,51 +90,122 @@ export function computeApprovalClosure(input: {
|
||||
assetRecords: AssetApprovalRecord[];
|
||||
trust: ApprovalTrustPolicy;
|
||||
}): ApprovalClosure {
|
||||
const latestLedger = verifyApprovalLedger(input.trust);
|
||||
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 ||
|
||||
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]]));
|
||||
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 !== '' ||
|
||||
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]]));
|
||||
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),
|
||||
...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]]));
|
||||
|
||||
const supplied = new Set([
|
||||
...input.contentRecords.map(
|
||||
(record) => `content\0${record.recordType}\0${record.publicRecordId}`,
|
||||
),
|
||||
...input.assetRecords.map((record) => `asset\0\0${record.assetId}`),
|
||||
]);
|
||||
const required = new Set(
|
||||
[...latestLedger.entries()]
|
||||
.filter(
|
||||
([, envelope]) =>
|
||||
envelope.payload.decision === "approve" &&
|
||||
Date.parse(input.trust.now) < Date.parse(envelope.payload.expiresAt),
|
||||
)
|
||||
.map(([typedId]) => typedId),
|
||||
);
|
||||
if (
|
||||
supplied.size !== required.size ||
|
||||
[...supplied].some((typedId) => !required.has(typedId))
|
||||
) {
|
||||
throw new Error(
|
||||
"Approval records are not the exact latest authenticated approved ledger projection.",
|
||||
);
|
||||
}
|
||||
|
||||
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 }),
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -125,43 +216,129 @@ export function verifyApprovalProjectionAttestation(
|
||||
now: string;
|
||||
latestLedgerHeadHash: string;
|
||||
latestLedgerRevision: number;
|
||||
trustedKey: { keyId: string; issuerEpoch: number; scope: 'portfolio-approval-projection-attestation/v1'; publicKey: KeyLike; revoked?: boolean };
|
||||
trustedKey: {
|
||||
keyId: string;
|
||||
issuerEpoch: number;
|
||||
scope: typeof APPROVAL_PROJECTION_ATTESTATION_SCOPE;
|
||||
publicKey: KeyLike;
|
||||
revoked?: boolean;
|
||||
};
|
||||
},
|
||||
): ApprovalProjectionAttestationPayload {
|
||||
if (envelopeValue === null || typeof envelopeValue !== 'object' || Array.isArray(envelopeValue)) throw new Error('Projection envelope is invalid.');
|
||||
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;
|
||||
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',
|
||||
"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.');
|
||||
if (
|
||||
JSON.stringify(Object.keys(payload).sort()) !==
|
||||
JSON.stringify(payloadFields)
|
||||
)
|
||||
throw new Error("Projection payload contains missing or unknown fields.");
|
||||
if (
|
||||
payload.schema !== APPROVAL_PROJECTION_ATTESTATION_SCHEMA ||
|
||||
payload.scope !== APPROVAL_PROJECTION_ATTESTATION_SCOPE
|
||||
)
|
||||
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.');
|
||||
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.');
|
||||
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 (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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user