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

@@ -140,85 +140,68 @@ async function writeOnce(root, digest, bytes) {
}
}
export function reconcileScan({
expectedChecks,
reports,
matcherResults,
candidate,
}) {
if (
!Array.isArray(expectedChecks) ||
!Array.isArray(reports) ||
!Array.isArray(matcherResults) ||
expectedChecks.length === 0
)
function closureDigest(value, schema, label) {
const closure = object(value, label);
exact(closure, ["schema", "members"], label);
if (closure.schema !== schema || !Array.isArray(closure.members))
throw new Error(`${label} is invalid.`);
return canonicalJsonDigest(schema, closure);
}
export function reconcileScan(input) {
const request = object(input, "reconciliation request");
exact(
request,
[
"sourceClosure",
"publicObjectClosure",
"reports",
"matcherResults",
"candidate",
],
"reconciliation request",
);
const {
sourceClosure,
publicObjectClosure,
reports,
matcherResults,
candidate,
} = request;
if (!Array.isArray(reports) || !Array.isArray(matcherResults))
throw new Error("Reconciliation input is invalid.");
const ordered = [...expectedChecks].sort((a, b) =>
Buffer.from(`${a.inputDigest}\0${a.checkId}`).compare(
Buffer.from(`${b.inputDigest}\0${b.checkId}`),
),
const sourceClosureDigest = closureDigest(
sourceClosure,
"portfolio-publication-closure/v1",
"source closure",
);
const publicObjectClosureDigest = closureDigest(
publicObjectClosure,
"portfolio-public-object-closure/v1",
"public object closure",
);
if (
!stringsEqual(expectedChecks, ordered) ||
new Set(expectedChecks.map((v) => `${v.inputDigest}\0${v.checkId}`))
.size !== expectedChecks.length
)
throw new Error("Expected check closure must be unique and byte sorted.");
if (
reports.length !== expectedChecks.length * 2 ||
matcherResults.length !== expectedChecks.length
)
throw new Error("Missing or extra privacy evidence.");
candidate?.sourceClosureDigest !== sourceClosureDigest ||
candidate?.publicObjectClosureDigest !== publicObjectClosureDigest
) {
throw new Error(
"Candidate does not bind the exact canonical source/public closure.",
);
}
if (publicObjectClosure.members.length !== 0) {
throw new Error(
"Verified privacy scanners are unavailable; every non-empty public object closure fails closed.",
);
}
if (reports.length !== 0 || matcherResults.length !== 0)
throw new Error(
"Empty public object closure cannot carry fabricated scan evidence.",
);
const checks = [];
expectedChecks.forEach((expected, index) => {
if (!isSha256Hex(expected.inputDigest) || !safeToken.test(expected.checkId))
throw new Error("Expected check is invalid.");
const a = validateWorker(reports[index * 2], "worker-a");
const b = validateWorker(reports[index * 2 + 1], "worker-b");
const match = validateMatcher(matcherResults[index]);
for (const item of [a, b, match])
if (
item.inputDigest !== expected.inputDigest ||
item.checkId !== expected.checkId
)
throw new Error(
"Privacy evidence is missing, reordered, or bound to another check.",
);
if (a.imageDigest === b.imageDigest || a.toolchain === b.toolchain)
throw new Error("Privacy workers are not heterogeneous.");
if (
a.verdict !== "pass" ||
b.verdict !== "pass" ||
match.verdict !== "pass"
)
throw new Error("Every worker and matcher must independently pass.");
if (
a.normalizedOutputDigest !== b.normalizedOutputDigest ||
a.normalizedOutputDigest !== match.normalizedOutputDigest ||
!stringsEqual(a.normalizedMembers, b.normalizedMembers) ||
!stringsEqual(a.normalizedMembers, match.normalizedMembers) ||
!stringsEqual(match.workerImageDigests, [a.imageDigest, b.imageDigest])
)
throw new Error("Independent normalized evidence disagrees.");
checks.push({
inputDigest: expected.inputDigest,
checkId: expected.checkId,
normalizedOutputDigest: a.normalizedOutputDigest,
normalizedMembers: a.normalizedMembers,
workerReports: [
canonicalJsonDigest("portfolio-privacy-worker-report/v1", a),
canonicalJsonDigest("portfolio-privacy-worker-report/v1", b),
],
matcherResult: canonicalJsonDigest(
"portfolio-privacy-matcher-result/v1",
match,
),
ruleSetDigest: match.ruleSetDigest,
appliedRuleIds: match.appliedRuleIds,
});
});
const evidence = {
schema: "portfolio-scan-evidence/v1",
sourceClosureDigest,
publicObjectClosureDigest,
coverageCount: checks.length,
passedCount: checks.length,
checks,

View File

@@ -107,6 +107,18 @@ export function liveContainerStatus() {
};
}
export function requireLiveContainerValidation(value) {
if (
value?.schema !== "portfolio-live-container-validation/v1" ||
value.status !== "verified"
) {
throw new Error(
"Live privacy scanners are not verified; pending or unsupported scanner state is a hard release failure.",
);
}
return value;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href

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")

View File

@@ -1,55 +1,110 @@
#!/usr/bin/env node
import { createPublicKey } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createPublicKey } from "node:crypto";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { computeApprovalClosure } from '../../src/security/approval-closure.ts';
import { canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../src/security/canonical-digest.ts';
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
import {
APPROVAL_PROJECTION_ATTESTATION_SCHEMA,
APPROVAL_PROJECTION_ATTESTATION_SCOPE,
computeApprovalClosure,
} from "../../src/security/approval-closure.ts";
import {
canonicalValueBytes,
isSha256Hex,
parseCanonicalJson,
} from "../../src/security/canonical-digest.ts";
import { computeProductionInputRoot } from "../../src/security/production-input-root.ts";
const expectedRequestFields = [
'schema', 'now', 'latestLedgerHeadHash', 'latestLedgerRevision', 'trustedKeys', 'contentRecords', 'assetRecords', 'inputDigests',
"schema",
"latestLedgerHeadHash",
"latestLedgerRevision",
"trustedKeys",
"ledgerProof",
"contentRecords",
"assetRecords",
"inputDigests",
].sort();
const expectedKeyFields = ['keyId', 'issuerEpoch', 'scope', 'authorizedRoles', 'publicKeyPem'].sort();
const expectedInputs = ['PortfolioContent', 'PreparedAssetSet', 'PublicAssetManifest', 'SiteConfig'];
const expectedKeyFields = [
"keyId",
"issuerEpoch",
"scope",
"authorizedRoles",
"publicKeyPem",
].sort();
const expectedInputs = [
"PortfolioContent",
"PreparedAssetSet",
"PublicAssetManifest",
"SiteConfig",
];
function exactObject(value, fields, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(fields)) throw new Error(`${label} contains missing or unknown fields.`);
if (value === null || typeof value !== "object" || Array.isArray(value))
throw new Error(`${label} must be an object.`);
if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(fields))
throw new Error(`${label} contains missing or unknown fields.`);
return value;
}
function requestPath(args) {
if (args.length !== 2 || args[0] !== '--request' || args[1].length === 0) throw new Error('Usage: verify-approvals.mjs --request <canonical-request.json>');
if (args.length !== 2 || args[0] !== "--request" || args[1].length === 0)
throw new Error(
"Usage: verify-approvals.mjs --request <canonical-request.json>",
);
return args[1];
}
export function verifyApprovalRequest(source) {
const request = exactObject(parseCanonicalJson(source), expectedRequestFields, 'request');
if (request.schema !== 'portfolio-approval-verification-request/v1') throw new Error('Request schema is invalid.');
if (!Array.isArray(request.trustedKeys) || !Array.isArray(request.contentRecords) || !Array.isArray(request.assetRecords)) throw new Error('Approval record lists are invalid.');
function verifyApprovalRequestAt(source, now) {
const request = exactObject(
parseCanonicalJson(source),
expectedRequestFields,
"request",
);
if (request.schema !== "portfolio-approval-verification-request/v1")
throw new Error("Request schema is invalid.");
if (
!Array.isArray(request.trustedKeys) ||
!Array.isArray(request.contentRecords) ||
!Array.isArray(request.assetRecords)
)
throw new Error("Approval record lists are invalid.");
const trustedKeys = request.trustedKeys.map((candidate) => {
const key = exactObject(candidate, expectedKeyFields, 'trusted key');
if (key.scope !== 'portfolio-approval-decision/v1' || typeof key.keyId !== 'string' ||
!Number.isSafeInteger(key.issuerEpoch) || !Array.isArray(key.authorizedRoles) ||
key.authorizedRoles.some((role) => typeof role !== 'string') || typeof key.publicKeyPem !== 'string') {
throw new Error('Trusted approval key is invalid.');
const key = exactObject(candidate, expectedKeyFields, "trusted key");
if (
key.scope !== "portfolio-approval-decision/v1" ||
typeof key.keyId !== "string" ||
!Number.isSafeInteger(key.issuerEpoch) ||
!Array.isArray(key.authorizedRoles) ||
key.authorizedRoles.some((role) => typeof role !== "string") ||
typeof key.publicKeyPem !== "string"
) {
throw new Error("Trusted approval key is invalid.");
}
return {
keyId: key.keyId, issuerEpoch: key.issuerEpoch, scope: key.scope,
authorizedRoles: key.authorizedRoles, publicKey: createPublicKey(key.publicKeyPem),
keyId: key.keyId,
issuerEpoch: key.issuerEpoch,
scope: key.scope,
authorizedRoles: key.authorizedRoles,
publicKey: createPublicKey(key.publicKeyPem),
};
});
const inputDigests = exactObject(request.inputDigests, expectedInputs, 'inputDigests');
if (Object.values(inputDigests).some((digest) => !isSha256Hex(digest))) throw new Error('Input digest is invalid.');
const inputDigests = exactObject(
request.inputDigests,
expectedInputs,
"inputDigests",
);
if (Object.values(inputDigests).some((digest) => !isSha256Hex(digest)))
throw new Error("Input digest is invalid.");
const closure = computeApprovalClosure({
contentRecords: request.contentRecords,
assetRecords: request.assetRecords,
trust: {
now: request.now,
now,
latestLedgerHeadHash: request.latestLedgerHeadHash,
latestLedgerRevision: request.latestLedgerRevision,
trustedKeys,
ledgerProof: request.ledgerProof,
},
});
const productionInputRoot = computeProductionInputRoot({
@@ -59,7 +114,9 @@ export function verifyApprovalRequest(source) {
siteConfigDigest: inputDigests.SiteConfig,
});
return {
schema: 'portfolio-approval-verification-result/v1',
schema: "portfolio-approval-verification-result/v1",
projectionSchema: APPROVAL_PROJECTION_ATTESTATION_SCHEMA,
projectionScope: APPROVAL_PROJECTION_ATTESTATION_SCOPE,
approvedRecordRoot: closure.approvedRecordRoot,
approvedAssetRoot: closure.approvedAssetRoot,
approvalProofSetDigest: closure.approvalProofSetDigest,
@@ -70,16 +127,36 @@ export function verifyApprovalRequest(source) {
};
}
export function verifyApprovalRequest(source) {
return verifyApprovalRequestAt(source, new Date().toISOString());
}
export function verifyApprovalRequestForTest(source, clock) {
if (
clock === null ||
typeof clock !== "object" ||
Object.keys(clock).length !== 1 ||
typeof clock.now !== "string"
) {
throw new Error("Test-only approval clock is invalid.");
}
return verifyApprovalRequestAt(source, clock.now);
}
async function main() {
const path = requestPath(process.argv.slice(2));
const result = verifyApprovalRequest(await readFile(path));
process.stdout.write(Buffer.from(canonicalValueBytes(result)));
process.stdout.write('\n');
process.stdout.write("\n");
}
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
const message = error instanceof Error ? error.message : 'Unknown verification failure.';
const message =
error instanceof Error ? error.message : "Unknown verification failure.";
process.stderr.write(`Approval verification failed: ${message}\n`);
process.exitCode = 1;
});