Files
Portfolio/scripts/release/sign-release.mjs

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;
});
}