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

@@ -11,6 +11,11 @@ import {
parseCanonicalJson,
} from "../../src/security/canonical-digest.ts";
import { validateReleaseManifest } from "../../src/security/release-manifest.ts";
import {
APPROVAL_PROJECTION_ATTESTATION_SCHEMA,
APPROVAL_PROJECTION_ATTESTATION_SCOPE,
verifyApprovalProjectionAttestation,
} from "../../src/security/approval-closure.ts";
const rootContract = {
approval: {
@@ -18,7 +23,7 @@ const rootContract = {
recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"],
},
projection: {
scope: "portfolio-approval-projection/v1",
scope: APPROVAL_PROJECTION_ATTESTATION_SCOPE,
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
},
privacyWorkload: {
@@ -62,6 +67,7 @@ const candidateFields = [
"latestLedgerHeadHash",
"latestLedgerRevision",
"sourceClosureDigest",
"publicObjectClosureDigest",
"publicRefMap",
"lfsOids",
"releaseArchives",
@@ -78,21 +84,6 @@ const signatureFields = [
"value",
];
const attestationPayloadFields = {
projection: [
"schema",
"keyId",
"issuerEpoch",
"scope",
"issuedAt",
"expiresAt",
"digest",
"productionInputRoot",
"approvedRecordRoot",
"approvedAssetRoot",
"approvalProofSetDigest",
"latestLedgerHeadHash",
"latestLedgerRevision",
],
privacyWorkload: [
"schema",
"keyId",
@@ -293,6 +284,7 @@ function validateCandidate(value, now) {
throw new Error("Candidate schema is invalid.");
digest(candidate.latestLedgerHeadHash, "latestLedgerHeadHash");
digest(candidate.sourceClosureDigest, "sourceClosureDigest");
digest(candidate.publicObjectClosureDigest, "publicObjectClosureDigest");
digest(candidate.buildProvenanceDigest, "buildProvenanceDigest");
digest(candidate.privacyPolicyDigest, "privacyPolicyDigest");
if (
@@ -354,18 +346,30 @@ function validateEvidence(value, expectedDigest) {
const evidence = object(value, "scan evidence");
exact(
evidence,
["schema", "coverageCount", "passedCount", "checks"],
[
"schema",
"sourceClosureDigest",
"publicObjectClosureDigest",
"coverageCount",
"passedCount",
"checks",
],
"scan evidence",
);
if (
evidence.schema !== "portfolio-scan-evidence/v1" ||
!Number.isSafeInteger(evidence.coverageCount) ||
evidence.coverageCount < 1 ||
evidence.coverageCount < 0 ||
evidence.passedCount !== evidence.coverageCount ||
!Array.isArray(evidence.checks) ||
evidence.checks.length !== evidence.coverageCount
)
throw new Error("Scan evidence coverage is incomplete.");
digest(evidence.sourceClosureDigest, "scan source closure digest");
digest(
evidence.publicObjectClosureDigest,
"scan public object closure digest",
);
const keys = [];
for (const raw of evidence.checks) {
const check = object(raw, "scan evidence check");
@@ -452,6 +456,8 @@ function verifyCandidateAuthority({
trustPolicy,
now,
protectedState,
sourceClosure,
publicObjectClosure,
}) {
if (trustPolicy.publicationFrozen)
throw new Error("Publication is frozen by trust policy.");
@@ -462,6 +468,39 @@ function verifyCandidateAuthority({
now,
);
const state = validateProtectedState(protectedState);
const sourceRecord = object(sourceClosure, "source closure");
exact(sourceRecord, ["schema", "members"], "source closure");
if (
sourceRecord.schema !== "portfolio-publication-closure/v1" ||
!Array.isArray(sourceRecord.members)
)
throw new Error("Source closure is invalid.");
const publicRecord = object(publicObjectClosure, "public object closure");
exact(publicRecord, ["schema", "members"], "public object closure");
if (
publicRecord.schema !== "portfolio-public-object-closure/v1" ||
!Array.isArray(publicRecord.members)
)
throw new Error("Public object closure is invalid.");
const sourceClosureDigest = canonicalJsonDigest(
"portfolio-publication-closure/v1",
sourceRecord,
);
const publicObjectClosureDigest = canonicalJsonDigest(
"portfolio-public-object-closure/v1",
publicRecord,
);
if (
record.sourceClosureDigest !== sourceClosureDigest ||
record.publicObjectClosureDigest !== publicObjectClosureDigest
)
throw new Error(
"Candidate does not bind exact source/public closure bytes.",
);
if (publicRecord.members.length !== 0)
throw new Error(
"Verified scanners are unavailable; non-empty public object closure cannot be signed.",
);
if (
record.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
record.latestLedgerRevision !== state.latestLedgerRevision
@@ -474,7 +513,19 @@ function verifyCandidateAuthority({
throw new Error(
"Candidate predecessor does not equal protected current manifest.",
);
validateEvidence(evidence, releaseManifest.scanEvidenceDigest);
const validatedEvidence = validateEvidence(
evidence,
releaseManifest.scanEvidenceDigest,
);
if (
validatedEvidence.sourceClosureDigest !== sourceClosureDigest ||
validatedEvidence.publicObjectClosureDigest !== publicObjectClosureDigest ||
validatedEvidence.coverageCount !== 0 ||
validatedEvidence.checks.length !== 0
)
throw new Error(
"Scan evidence does not exactly cover the canonical empty public object closure.",
);
const candidateDigest = canonicalJsonDigest(
"portfolio-candidate-release-manifest/v1",
record,
@@ -483,12 +534,20 @@ function verifyCandidateAuthority({
"portfolio-scan-evidence/v1",
evidence,
);
const projection = verifyAttestation(
const projectionRoot = trustPolicy.roots.projection;
const projection = verifyApprovalProjectionAttestation(
attestations.projection,
trustPolicy.roots.projection,
"portfolio-approval-projection-attestation/v1",
"projection",
now,
{
now,
latestLedgerHeadHash: record.latestLedgerHeadHash,
latestLedgerRevision: record.latestLedgerRevision,
trustedKey: {
keyId: projectionRoot.keyId,
issuerEpoch: projectionRoot.issuerEpoch,
scope: APPROVAL_PROJECTION_ATTESTATION_SCOPE,
publicKey: createPublicKey(projectionRoot.publicKeyPem),
},
},
);
const workload = verifyAttestation(
attestations.privacyWorkload,
@@ -516,13 +575,18 @@ function verifyCandidateAuthority({
["approvedRecordRoot", releaseManifest.approvedRecordRoot],
["approvedAssetRoot", releaseManifest.approvedAssetRoot],
["approvalProofSetDigest", releaseManifest.approvalProofSetDigest],
["latestLedgerHeadHash", record.latestLedgerHeadHash],
["latestLedgerRevision", record.latestLedgerRevision],
["liveLedgerHeadHash", record.latestLedgerHeadHash],
["liveLedgerRevision", record.latestLedgerRevision],
]) {
if (projection[field] !== expected)
throw new Error(`Approval projection mismatch: ${field}.`);
}
if (projection.digest !== releaseManifest.approvalProjectionAttestationDigest)
if (
canonicalJsonDigest(
"portfolio-approval-projection-attestation-envelope/v1",
attestations.projection,
) !== releaseManifest.approvalProjectionAttestationDigest
)
throw new Error("Projection attestation digest binding is invalid.");
if (
workload.candidateDigest !== candidateDigest ||
@@ -572,9 +636,9 @@ async function externalPrivateKey(path) {
return key;
}
export async function signReleaseCandidate(input) {
async function signReleaseCandidateAt(input, now) {
const trustPolicy = validateTrustPolicy(input.trustPolicy);
const verified = verifyCandidateAuthority({ ...input, trustPolicy });
const verified = verifyCandidateAuthority({ ...input, trustPolicy, now });
const key = await externalPrivateKey(input.releaseKeyPath);
const releaseRoot = trustPolicy.roots.release;
const bytes = canonicalValueBytes(verified.record);
@@ -605,7 +669,20 @@ export async function signReleaseCandidate(input) {
};
}
export function verifySignedReleaseEnvelope({
export async function signReleaseCandidate(input) {
if (Object.prototype.hasOwnProperty.call(input, "now"))
throw new Error("Production signer does not accept caller-selected now.");
return signReleaseCandidateAt(input, new Date().toISOString());
}
export async function signReleaseCandidateForTest(input) {
if (typeof input?.now !== "string")
throw new Error("Test-only signer requires an explicit clock.");
const { now, ...rest } = input;
return signReleaseCandidateAt(rest, now);
}
function verifySignedReleaseEnvelopeAt({
signedRelease,
trustPolicy: trustValue,
now,
@@ -672,6 +749,21 @@ export function verifySignedReleaseEnvelope({
};
}
export function verifySignedReleaseEnvelope(input) {
if (Object.prototype.hasOwnProperty.call(input, "now"))
throw new Error("Production verifier does not accept caller-selected now.");
return verifySignedReleaseEnvelopeAt({
...input,
now: new Date().toISOString(),
});
}
export function verifySignedReleaseEnvelopeForTest(input) {
if (typeof input?.now !== "string")
throw new Error("Test-only verifier requires an explicit clock.");
return verifySignedReleaseEnvelopeAt(input);
}
async function main() {
const args = process.argv.slice(2);
if (args.length !== 4 || args[0] !== "--request" || args[2] !== "--key")