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,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,
);
}