345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
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";
|
|
|
|
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;
|
|
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: typeof APPROVAL_PROJECTION_ATTESTATION_SCHEMA;
|
|
keyId: string;
|
|
issuerEpoch: number;
|
|
scope: typeof APPROVAL_PROJECTION_ATTESTATION_SCOPE;
|
|
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 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 ||
|
|
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]]));
|
|
|
|
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 },
|
|
),
|
|
earliestApprovalExpiry: expiries.sort()[0] ?? null,
|
|
};
|
|
}
|
|
|
|
export function verifyApprovalProjectionAttestation(
|
|
envelopeValue: unknown,
|
|
trust: {
|
|
now: string;
|
|
latestLedgerHeadHash: string;
|
|
latestLedgerRevision: number;
|
|
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.");
|
|
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 !== 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.");
|
|
}
|
|
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,
|
|
);
|
|
}
|