fix: close release provenance and privacy gaps
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user