feat: isolate privacy scanning and release signing
This commit is contained in:
700
scripts/release/sign-release.mjs
Normal file
700
scripts/release/sign-release.mjs
Normal file
@@ -0,0 +1,700 @@
|
||||
#!/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";
|
||||
|
||||
const rootContract = {
|
||||
approval: {
|
||||
scope: "portfolio-approval-decision/v1",
|
||||
recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"],
|
||||
},
|
||||
projection: {
|
||||
scope: "portfolio-approval-projection/v1",
|
||||
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",
|
||||
"publicRefMap",
|
||||
"lfsOids",
|
||||
"releaseArchives",
|
||||
"buildProvenanceDigest",
|
||||
"privacyPolicyDigest",
|
||||
"toolImageDigests",
|
||||
];
|
||||
const signatureFields = [
|
||||
"algorithm",
|
||||
"encoding",
|
||||
"keyId",
|
||||
"issuerEpoch",
|
||||
"scope",
|
||||
"value",
|
||||
];
|
||||
const attestationPayloadFields = {
|
||||
projection: [
|
||||
"schema",
|
||||
"keyId",
|
||||
"issuerEpoch",
|
||||
"scope",
|
||||
"issuedAt",
|
||||
"expiresAt",
|
||||
"digest",
|
||||
"productionInputRoot",
|
||||
"approvedRecordRoot",
|
||||
"approvedAssetRoot",
|
||||
"approvalProofSetDigest",
|
||||
"latestLedgerHeadHash",
|
||||
"latestLedgerRevision",
|
||||
],
|
||||
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.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", "coverageCount", "passedCount", "checks"],
|
||||
"scan evidence",
|
||||
);
|
||||
if (
|
||||
evidence.schema !== "portfolio-scan-evidence/v1" ||
|
||||
!Number.isSafeInteger(evidence.coverageCount) ||
|
||||
evidence.coverageCount < 1 ||
|
||||
evidence.passedCount !== evidence.coverageCount ||
|
||||
!Array.isArray(evidence.checks) ||
|
||||
evidence.checks.length !== evidence.coverageCount
|
||||
)
|
||||
throw new Error("Scan evidence coverage is incomplete.");
|
||||
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,
|
||||
}) {
|
||||
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);
|
||||
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.",
|
||||
);
|
||||
validateEvidence(evidence, releaseManifest.scanEvidenceDigest);
|
||||
const candidateDigest = canonicalJsonDigest(
|
||||
"portfolio-candidate-release-manifest/v1",
|
||||
record,
|
||||
);
|
||||
const evidenceDigest = canonicalJsonDigest(
|
||||
"portfolio-scan-evidence/v1",
|
||||
evidence,
|
||||
);
|
||||
const projection = verifyAttestation(
|
||||
attestations.projection,
|
||||
trustPolicy.roots.projection,
|
||||
"portfolio-approval-projection-attestation/v1",
|
||||
"projection",
|
||||
now,
|
||||
);
|
||||
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],
|
||||
["latestLedgerHeadHash", record.latestLedgerHeadHash],
|
||||
["latestLedgerRevision", record.latestLedgerRevision],
|
||||
]) {
|
||||
if (projection[field] !== expected)
|
||||
throw new Error(`Approval projection mismatch: ${field}.`);
|
||||
}
|
||||
if (projection.digest !== 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;
|
||||
}
|
||||
|
||||
export async function signReleaseCandidate(input) {
|
||||
const trustPolicy = validateTrustPolicy(input.trustPolicy);
|
||||
const verified = verifyCandidateAuthority({ ...input, trustPolicy });
|
||||
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 function verifySignedReleaseEnvelope({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user