793 lines
26 KiB
JavaScript
793 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
|
import { readFile, realpath, stat as fileStat } from "node:fs/promises";
|
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import {
|
|
canonicalJsonDigest,
|
|
canonicalValueBytes,
|
|
isSha256Hex,
|
|
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: {
|
|
scope: "portfolio-approval-decision/v1",
|
|
recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"],
|
|
},
|
|
projection: {
|
|
scope: APPROVAL_PROJECTION_ATTESTATION_SCOPE,
|
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
|
},
|
|
privacyWorkload: {
|
|
scope: "portfolio-privacy-workload/v1",
|
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
|
},
|
|
policy: {
|
|
scope: "portfolio-policy-attestation/v1",
|
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
|
},
|
|
toolImage: {
|
|
scope: "portfolio-tool-image-provenance/v1",
|
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
|
},
|
|
release: {
|
|
scope: "portfolio-release-signing/v1",
|
|
recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"],
|
|
},
|
|
publisher: {
|
|
scope: "portfolio-publisher-fencing/v1",
|
|
recovery: [
|
|
"freeze-publication",
|
|
"rotate-credential",
|
|
"advance-fence",
|
|
"reconcile-staging",
|
|
],
|
|
},
|
|
};
|
|
const rootFields = [
|
|
"keyId",
|
|
"issuerEpoch",
|
|
"scope",
|
|
"publicKeyPem",
|
|
"revokedEpochs",
|
|
"compromiseResponse",
|
|
];
|
|
const policyFields = ["schema", "policyVersion", "publicationFrozen", "roots"];
|
|
const candidateFields = [
|
|
"schema",
|
|
"releaseManifest",
|
|
"latestLedgerHeadHash",
|
|
"latestLedgerRevision",
|
|
"sourceClosureDigest",
|
|
"publicObjectClosureDigest",
|
|
"publicRefMap",
|
|
"lfsOids",
|
|
"releaseArchives",
|
|
"buildProvenanceDigest",
|
|
"privacyPolicyDigest",
|
|
"toolImageDigests",
|
|
];
|
|
const signatureFields = [
|
|
"algorithm",
|
|
"encoding",
|
|
"keyId",
|
|
"issuerEpoch",
|
|
"scope",
|
|
"value",
|
|
];
|
|
const attestationPayloadFields = {
|
|
privacyWorkload: [
|
|
"schema",
|
|
"keyId",
|
|
"issuerEpoch",
|
|
"scope",
|
|
"issuedAt",
|
|
"expiresAt",
|
|
"digest",
|
|
"candidateDigest",
|
|
"evidenceDigest",
|
|
],
|
|
policy: [
|
|
"schema",
|
|
"keyId",
|
|
"issuerEpoch",
|
|
"scope",
|
|
"issuedAt",
|
|
"expiresAt",
|
|
"digest",
|
|
"policyVersion",
|
|
],
|
|
toolImage: [
|
|
"schema",
|
|
"keyId",
|
|
"issuerEpoch",
|
|
"scope",
|
|
"issuedAt",
|
|
"expiresAt",
|
|
"digest",
|
|
"imageDigests",
|
|
],
|
|
};
|
|
const signedFields = ["schema", "payload", "payloadDigest", "signature"];
|
|
const timestamp = /^\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 object(value, label) {
|
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
throw new Error(`${label} must be an object.`);
|
|
return value;
|
|
}
|
|
function exact(value, fields, label) {
|
|
if (
|
|
JSON.stringify(Object.keys(value).sort()) !==
|
|
JSON.stringify([...fields].sort())
|
|
)
|
|
throw new Error(`${label} contains missing or unknown fields.`);
|
|
}
|
|
function digest(value, label) {
|
|
if (!isSha256Hex(value))
|
|
throw new Error(`${label} is not a canonical SHA-256 digest.`);
|
|
}
|
|
function compare(left, right) {
|
|
return Buffer.from(left).compare(Buffer.from(right));
|
|
}
|
|
|
|
export function validateTrustPolicy(value) {
|
|
const policy = object(value, "trust policy");
|
|
exact(policy, policyFields, "trust policy");
|
|
if (
|
|
policy.schema !== "portfolio-trust-policy/v1" ||
|
|
typeof policy.policyVersion !== "string" ||
|
|
!safeToken.test(policy.policyVersion) ||
|
|
typeof policy.publicationFrozen !== "boolean"
|
|
)
|
|
throw new Error("Trust policy metadata is invalid.");
|
|
const roots = object(policy.roots, "trust roots");
|
|
exact(roots, Object.keys(rootContract), "trust roots");
|
|
const seenKeys = new Set();
|
|
const seenPublicKeys = new Set();
|
|
for (const [role, contract] of Object.entries(rootContract)) {
|
|
const root = object(roots[role], `${role} root`);
|
|
exact(root, rootFields, `${role} root`);
|
|
if (
|
|
typeof root.keyId !== "string" ||
|
|
!safeToken.test(root.keyId) ||
|
|
seenKeys.has(root.keyId) ||
|
|
!Number.isSafeInteger(root.issuerEpoch) ||
|
|
root.issuerEpoch < 1
|
|
)
|
|
throw new Error(`${role} key ID or issuer epoch is invalid.`);
|
|
seenKeys.add(root.keyId);
|
|
if (root.scope !== contract.scope)
|
|
throw new Error(`${role} trust-root scope is invalid.`);
|
|
if (typeof root.publicKeyPem !== "string")
|
|
throw new Error(`${role} public key is invalid.`);
|
|
const key = createPublicKey(root.publicKeyPem);
|
|
if (key.asymmetricKeyType !== "ed25519")
|
|
throw new Error(`${role} key must be Ed25519.`);
|
|
const publicFingerprint = key
|
|
.export({ type: "spki", format: "der" })
|
|
.toString("base64");
|
|
if (seenPublicKeys.has(publicFingerprint))
|
|
throw new Error("Trust roots must use distinct protected keys.");
|
|
seenPublicKeys.add(publicFingerprint);
|
|
if (
|
|
!Array.isArray(root.revokedEpochs) ||
|
|
root.revokedEpochs.some(
|
|
(epoch) => !Number.isSafeInteger(epoch) || epoch < 1,
|
|
) ||
|
|
new Set(root.revokedEpochs).size !== root.revokedEpochs.length
|
|
)
|
|
throw new Error(`${role} revocation list is invalid.`);
|
|
if (root.revokedEpochs.includes(root.issuerEpoch))
|
|
throw new Error(`${role} current issuer epoch is revoked.`);
|
|
if (
|
|
!Array.isArray(root.compromiseResponse) ||
|
|
contract.recovery.some(
|
|
(action) => !root.compromiseResponse.includes(action),
|
|
)
|
|
) {
|
|
const detail =
|
|
role === "approval"
|
|
? "checkpoint audit and fresh approvals"
|
|
: role === "publisher"
|
|
? "fence advance and staging reconciliation"
|
|
: "fresh evidence";
|
|
throw new Error(`${role} compromise response lacks ${detail}.`);
|
|
}
|
|
}
|
|
return policy;
|
|
}
|
|
|
|
function verifyAttestation(value, root, expectedSchema, role, now) {
|
|
const envelope = object(value, "attestation");
|
|
exact(envelope, ["payload", "signature"], "attestation");
|
|
const payload = object(envelope.payload, "attestation payload");
|
|
exact(payload, attestationPayloadFields[role], "attestation payload");
|
|
const signature = object(envelope.signature, "attestation signature");
|
|
exact(signature, ["algorithm", "encoding", "value"], "attestation signature");
|
|
if (
|
|
payload.schema !== expectedSchema ||
|
|
payload.keyId !== root.keyId ||
|
|
payload.issuerEpoch !== root.issuerEpoch ||
|
|
payload.scope !== root.scope
|
|
)
|
|
throw new Error(
|
|
"Attestation schema, scope, key, or issuer epoch is not trusted.",
|
|
);
|
|
if (
|
|
typeof payload.issuedAt !== "string" ||
|
|
typeof payload.expiresAt !== "string" ||
|
|
!timestamp.test(payload.issuedAt) ||
|
|
!timestamp.test(payload.expiresAt) ||
|
|
Date.parse(payload.issuedAt) > Date.parse(now) ||
|
|
Date.parse(now) >= Date.parse(payload.expiresAt)
|
|
)
|
|
throw new Error("Attestation is not current at trusted time.");
|
|
for (const field of [
|
|
"digest",
|
|
"productionInputRoot",
|
|
"approvedRecordRoot",
|
|
"approvedAssetRoot",
|
|
"approvalProofSetDigest",
|
|
"latestLedgerHeadHash",
|
|
"candidateDigest",
|
|
"evidenceDigest",
|
|
])
|
|
if (field in payload) digest(payload[field], `attestation ${field}`);
|
|
if (
|
|
"latestLedgerRevision" in payload &&
|
|
(!Number.isSafeInteger(payload.latestLedgerRevision) ||
|
|
payload.latestLedgerRevision < 0)
|
|
)
|
|
throw new Error("Attestation ledger revision is invalid.");
|
|
if (
|
|
"imageDigests" in payload &&
|
|
(!Array.isArray(payload.imageDigests) ||
|
|
payload.imageDigests.length < 2 ||
|
|
payload.imageDigests.some((item) => !isSha256Hex(item)))
|
|
)
|
|
throw new Error("Attested image digests are invalid.");
|
|
if (
|
|
signature.algorithm !== "Ed25519" ||
|
|
signature.encoding !== "base64url-nopad" ||
|
|
typeof signature.value !== "string" ||
|
|
!/^[A-Za-z0-9_-]{86}$/u.test(signature.value)
|
|
)
|
|
throw new Error("Attestation signature metadata is invalid.");
|
|
const bytes = Buffer.from(signature.value, "base64url");
|
|
if (
|
|
bytes.toString("base64url") !== signature.value ||
|
|
!verify(
|
|
null,
|
|
canonicalValueBytes(payload),
|
|
createPublicKey(root.publicKeyPem),
|
|
bytes,
|
|
)
|
|
)
|
|
throw new Error("Attestation signature is invalid.");
|
|
return payload;
|
|
}
|
|
|
|
function validateCandidate(value, now) {
|
|
const candidate = object(value, "candidate");
|
|
exact(candidate, candidateFields, "candidate");
|
|
if (candidate.schema !== "portfolio-candidate-release-manifest/v1")
|
|
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 (
|
|
!Number.isSafeInteger(candidate.latestLedgerRevision) ||
|
|
candidate.latestLedgerRevision < 0
|
|
)
|
|
throw new Error("latestLedgerRevision is invalid.");
|
|
const refs = object(candidate.publicRefMap, "publicRefMap");
|
|
for (const [name, hash] of Object.entries(refs))
|
|
if (!safeToken.test(name) || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(hash))
|
|
throw new Error("Public ref map is invalid.");
|
|
if (
|
|
!Array.isArray(candidate.lfsOids) ||
|
|
candidate.lfsOids.some((oid) => !/^sha256:[a-f0-9]{64}$/u.test(oid)) ||
|
|
JSON.stringify(candidate.lfsOids) !==
|
|
JSON.stringify([...candidate.lfsOids].sort(compare)) ||
|
|
new Set(candidate.lfsOids).size !== candidate.lfsOids.length
|
|
)
|
|
throw new Error("LFS closure is invalid.");
|
|
if (!Array.isArray(candidate.releaseArchives))
|
|
throw new Error("Release archive closure is invalid.");
|
|
for (const archive of candidate.releaseArchives) {
|
|
const record = object(archive, "release archive");
|
|
exact(record, ["path", "sha256"], "release archive");
|
|
if (
|
|
typeof record.path !== "string" ||
|
|
record.path.startsWith("/") ||
|
|
record.path.includes("..")
|
|
)
|
|
throw new Error("Release archive path is invalid.");
|
|
digest(record.sha256, "release archive digest");
|
|
}
|
|
const archiveKeys = candidate.releaseArchives.map(
|
|
(archive) => `${archive.path}\0${archive.sha256}`,
|
|
);
|
|
if (
|
|
new Set(archiveKeys).size !== archiveKeys.length ||
|
|
JSON.stringify(archiveKeys) !==
|
|
JSON.stringify([...archiveKeys].sort(compare))
|
|
)
|
|
throw new Error("Release archive closure must be unique and byte sorted.");
|
|
if (
|
|
!Array.isArray(candidate.toolImageDigests) ||
|
|
candidate.toolImageDigests.length < 2 ||
|
|
candidate.toolImageDigests.some((value) => !isSha256Hex(value)) ||
|
|
new Set(candidate.toolImageDigests).size !==
|
|
candidate.toolImageDigests.length ||
|
|
JSON.stringify(candidate.toolImageDigests) !==
|
|
JSON.stringify([...candidate.toolImageDigests].sort(compare))
|
|
)
|
|
throw new Error("Tool image closure must contain unique, sorted digests.");
|
|
const releaseManifest = validateReleaseManifest(candidate.releaseManifest, {
|
|
now,
|
|
});
|
|
return { candidate, releaseManifest };
|
|
}
|
|
|
|
function validateEvidence(value, expectedDigest) {
|
|
const evidence = object(value, "scan evidence");
|
|
exact(
|
|
evidence,
|
|
[
|
|
"schema",
|
|
"sourceClosureDigest",
|
|
"publicObjectClosureDigest",
|
|
"coverageCount",
|
|
"passedCount",
|
|
"checks",
|
|
],
|
|
"scan evidence",
|
|
);
|
|
if (
|
|
evidence.schema !== "portfolio-scan-evidence/v1" ||
|
|
!Number.isSafeInteger(evidence.coverageCount) ||
|
|
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");
|
|
exact(
|
|
check,
|
|
[
|
|
"inputDigest",
|
|
"checkId",
|
|
"normalizedOutputDigest",
|
|
"normalizedMembers",
|
|
"workerReports",
|
|
"matcherResult",
|
|
"ruleSetDigest",
|
|
"appliedRuleIds",
|
|
],
|
|
"scan evidence check",
|
|
);
|
|
digest(check.inputDigest, "scan input digest");
|
|
digest(check.normalizedOutputDigest, "normalized output digest");
|
|
digest(check.matcherResult, "matcher result digest");
|
|
digest(check.ruleSetDigest, "rule set digest");
|
|
if (
|
|
!safeToken.test(check.checkId) ||
|
|
!Array.isArray(check.normalizedMembers) ||
|
|
check.normalizedMembers.some(
|
|
(item) => !/^[a-z][a-z0-9-]*:[a-f0-9]{64}$/u.test(item),
|
|
) ||
|
|
!Array.isArray(check.workerReports) ||
|
|
check.workerReports.length !== 2 ||
|
|
check.workerReports.some((item) => !isSha256Hex(item))
|
|
)
|
|
throw new Error("Scan evidence check is invalid.");
|
|
if (
|
|
!Array.isArray(check.appliedRuleIds) ||
|
|
check.appliedRuleIds.length === 0 ||
|
|
check.appliedRuleIds.some((item) => !safeToken.test(item)) ||
|
|
new Set(check.appliedRuleIds).size !== check.appliedRuleIds.length
|
|
)
|
|
throw new Error("Applied matcher rule IDs are invalid.");
|
|
keys.push(`${check.inputDigest}\0${check.checkId}`);
|
|
}
|
|
if (
|
|
new Set(keys).size !== keys.length ||
|
|
JSON.stringify(keys) !== JSON.stringify([...keys].sort(compare))
|
|
)
|
|
throw new Error("Scan evidence checks must be unique and byte sorted.");
|
|
if (
|
|
canonicalJsonDigest("portfolio-scan-evidence/v1", evidence) !==
|
|
expectedDigest
|
|
)
|
|
throw new Error("Scan evidence bytes were substituted.");
|
|
return evidence;
|
|
}
|
|
|
|
function validateProtectedState(state) {
|
|
const value = object(state, "protected state");
|
|
exact(
|
|
value,
|
|
[
|
|
"latestLedgerHeadHash",
|
|
"latestLedgerRevision",
|
|
"currentManifestDigest",
|
|
"publisherFence",
|
|
],
|
|
"protected state",
|
|
);
|
|
digest(value.latestLedgerHeadHash, "protected ledger head");
|
|
if (
|
|
!Number.isSafeInteger(value.latestLedgerRevision) ||
|
|
value.latestLedgerRevision < 0 ||
|
|
!Number.isSafeInteger(value.publisherFence) ||
|
|
value.publisherFence < 0 ||
|
|
(value.currentManifestDigest !== null &&
|
|
!isSha256Hex(value.currentManifestDigest))
|
|
)
|
|
throw new Error("Protected state is invalid.");
|
|
return value;
|
|
}
|
|
|
|
function verifyCandidateAuthority({
|
|
candidate,
|
|
evidence,
|
|
attestations,
|
|
trustPolicy,
|
|
now,
|
|
protectedState,
|
|
sourceClosure,
|
|
publicObjectClosure,
|
|
}) {
|
|
if (trustPolicy.publicationFrozen)
|
|
throw new Error("Publication is frozen by trust policy.");
|
|
if (typeof now !== "string" || !timestamp.test(now))
|
|
throw new Error("Trusted current time is invalid.");
|
|
const { candidate: record, releaseManifest } = validateCandidate(
|
|
candidate,
|
|
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
|
|
)
|
|
throw new Error("Candidate is stale relative to the protected ledger.");
|
|
if (
|
|
releaseManifest.currentManifestDigest !== state.currentManifestDigest ||
|
|
releaseManifest.predecessorManifestDigest !== state.currentManifestDigest
|
|
)
|
|
throw new Error(
|
|
"Candidate predecessor does not equal protected current manifest.",
|
|
);
|
|
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,
|
|
);
|
|
const evidenceDigest = canonicalJsonDigest(
|
|
"portfolio-scan-evidence/v1",
|
|
evidence,
|
|
);
|
|
const projectionRoot = trustPolicy.roots.projection;
|
|
const projection = verifyApprovalProjectionAttestation(
|
|
attestations.projection,
|
|
{
|
|
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,
|
|
trustPolicy.roots.privacyWorkload,
|
|
"portfolio-privacy-workload-attestation/v1",
|
|
"privacyWorkload",
|
|
now,
|
|
);
|
|
const policy = verifyAttestation(
|
|
attestations.policy,
|
|
trustPolicy.roots.policy,
|
|
"portfolio-policy-attestation/v1",
|
|
"policy",
|
|
now,
|
|
);
|
|
const tools = verifyAttestation(
|
|
attestations.toolImage,
|
|
trustPolicy.roots.toolImage,
|
|
"portfolio-tool-image-provenance/v1",
|
|
"toolImage",
|
|
now,
|
|
);
|
|
for (const [field, expected] of [
|
|
["productionInputRoot", releaseManifest.productionInputRoot],
|
|
["approvedRecordRoot", releaseManifest.approvedRecordRoot],
|
|
["approvedAssetRoot", releaseManifest.approvedAssetRoot],
|
|
["approvalProofSetDigest", releaseManifest.approvalProofSetDigest],
|
|
["liveLedgerHeadHash", record.latestLedgerHeadHash],
|
|
["liveLedgerRevision", record.latestLedgerRevision],
|
|
]) {
|
|
if (projection[field] !== expected)
|
|
throw new Error(`Approval projection mismatch: ${field}.`);
|
|
}
|
|
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 ||
|
|
workload.evidenceDigest !== evidenceDigest
|
|
)
|
|
throw new Error(
|
|
"Privacy workload does not bind exact candidate and evidence bytes.",
|
|
);
|
|
if (
|
|
policy.digest !== record.privacyPolicyDigest ||
|
|
policy.policyVersion !== trustPolicy.policyVersion
|
|
)
|
|
throw new Error("Privacy policy attestation is stale or substituted.");
|
|
if (
|
|
!Array.isArray(tools.imageDigests) ||
|
|
JSON.stringify(tools.imageDigests) !==
|
|
JSON.stringify(record.toolImageDigests)
|
|
)
|
|
throw new Error("Tool-image provenance does not bind exact images.");
|
|
return { record, releaseManifest, state, candidateDigest, evidenceDigest };
|
|
}
|
|
|
|
async function externalPrivateKey(path) {
|
|
if (typeof path !== "string" || !isAbsolute(path))
|
|
throw new Error("Release key path must be an external absolute path.");
|
|
const workspace = await realpath(process.cwd());
|
|
const lexicalRelative = relative(workspace, resolve(path));
|
|
if (
|
|
lexicalRelative === "" ||
|
|
(!lexicalRelative.startsWith("..") && !isAbsolute(lexicalRelative))
|
|
)
|
|
throw new Error(
|
|
"Release signing key must live outside the repository/workspace.",
|
|
);
|
|
const canonicalPath = await realpath(path);
|
|
const rel = relative(workspace, canonicalPath);
|
|
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
|
|
throw new Error(
|
|
"Release signing key must live outside the repository/workspace.",
|
|
);
|
|
const info = await fileStat(canonicalPath);
|
|
if (!info.isFile() || (info.mode & 0o077) !== 0)
|
|
throw new Error("External release key must be a private owner-only file.");
|
|
const key = createPrivateKey(await readFile(canonicalPath));
|
|
if (key.asymmetricKeyType !== "ed25519")
|
|
throw new Error("Release key must be Ed25519.");
|
|
return key;
|
|
}
|
|
|
|
async function signReleaseCandidateAt(input, now) {
|
|
const trustPolicy = validateTrustPolicy(input.trustPolicy);
|
|
const verified = verifyCandidateAuthority({ ...input, trustPolicy, now });
|
|
const key = await externalPrivateKey(input.releaseKeyPath);
|
|
const releaseRoot = trustPolicy.roots.release;
|
|
const bytes = canonicalValueBytes(verified.record);
|
|
const signatureBytes = sign(null, bytes, key);
|
|
if (
|
|
!verify(
|
|
null,
|
|
bytes,
|
|
createPublicKey(releaseRoot.publicKeyPem),
|
|
signatureBytes,
|
|
)
|
|
)
|
|
throw new Error(
|
|
"External key does not match the active release trust root.",
|
|
);
|
|
return {
|
|
schema: "portfolio-signed-release-manifest/v1",
|
|
payload: verified.record,
|
|
payloadDigest: verified.candidateDigest,
|
|
signature: {
|
|
algorithm: "Ed25519",
|
|
encoding: "base64url-nopad",
|
|
keyId: releaseRoot.keyId,
|
|
issuerEpoch: releaseRoot.issuerEpoch,
|
|
scope: releaseRoot.scope,
|
|
value: signatureBytes.toString("base64url"),
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
protectedState,
|
|
}) {
|
|
const trustPolicy = validateTrustPolicy(trustValue);
|
|
const envelope = object(signedRelease, "signed release");
|
|
exact(envelope, signedFields, "signed release");
|
|
if (envelope.schema !== "portfolio-signed-release-manifest/v1")
|
|
throw new Error("Signed release schema is invalid.");
|
|
const signature = object(envelope.signature, "release signature");
|
|
exact(signature, signatureFields, "release signature");
|
|
const root = trustPolicy.roots.release;
|
|
if (
|
|
signature.algorithm !== "Ed25519" ||
|
|
signature.encoding !== "base64url-nopad" ||
|
|
signature.keyId !== root.keyId ||
|
|
signature.issuerEpoch !== root.issuerEpoch ||
|
|
signature.scope !== root.scope ||
|
|
typeof signature.value !== "string"
|
|
)
|
|
throw new Error(
|
|
"Release signature key, epoch, scope, or encoding is invalid.",
|
|
);
|
|
const { candidate, releaseManifest } = validateCandidate(
|
|
envelope.payload,
|
|
now,
|
|
);
|
|
const payloadDigest = canonicalJsonDigest(
|
|
"portfolio-candidate-release-manifest/v1",
|
|
candidate,
|
|
);
|
|
if (envelope.payloadDigest !== payloadDigest)
|
|
throw new Error("Signed release payload digest is invalid.");
|
|
const bytes = Buffer.from(signature.value, "base64url");
|
|
if (
|
|
bytes.toString("base64url") !== signature.value ||
|
|
!verify(
|
|
null,
|
|
canonicalValueBytes(candidate),
|
|
createPublicKey(root.publicKeyPem),
|
|
bytes,
|
|
)
|
|
)
|
|
throw new Error("Release signature is invalid.");
|
|
const state = validateProtectedState(protectedState);
|
|
if (
|
|
candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
|
candidate.latestLedgerRevision !== state.latestLedgerRevision ||
|
|
releaseManifest.currentManifestDigest !== state.currentManifestDigest ||
|
|
releaseManifest.predecessorManifestDigest !== state.currentManifestDigest
|
|
)
|
|
throw new Error("Signed release is stale relative to protected state.");
|
|
return {
|
|
schema: "portfolio-verified-release-admission/v1",
|
|
manifestDigest: payloadDigest,
|
|
manifestPayload: releaseManifest,
|
|
expiresAt: releaseManifest.expiresAt,
|
|
predecessorManifestDigest: releaseManifest.predecessorManifestDigest,
|
|
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
|
|
latestLedgerRevision: candidate.latestLedgerRevision,
|
|
expectedFence: state.publisherFence + 1,
|
|
signatureVerified: true,
|
|
};
|
|
}
|
|
|
|
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")
|
|
throw new Error(
|
|
"Usage: sign-release.mjs --request <canonical-request.json> --key <external-key.pem>",
|
|
);
|
|
const request = parseCanonicalJson(await readFile(resolve(args[1])));
|
|
const signed = await signReleaseCandidate({
|
|
...object(request, "sign request"),
|
|
releaseKeyPath: resolve(args[3]),
|
|
});
|
|
process.stdout.write(Buffer.from(canonicalValueBytes(signed)));
|
|
process.stdout.write("\n");
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
main().catch((error) => {
|
|
process.stderr.write(
|
|
`Release signing failed: ${error instanceof Error ? error.message : "unknown error"}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|