323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import type { KeyLike } from "node:crypto";
|
|
import {
|
|
canonicalJsonDigest,
|
|
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[];
|
|
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",
|
|
] 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));
|
|
}
|
|
|
|
function verifyEnvelopeSignature(
|
|
envelopeValue: unknown,
|
|
trust: ApprovalTrustPolicy,
|
|
): ApprovalEnvelope {
|
|
const envelope = validateApprovalEnvelope(envelopeValue);
|
|
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.");
|
|
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;
|
|
}
|