feat: verify canonical portfolio release provenance
This commit is contained in:
86
scripts/release/verify-approvals.mjs
Normal file
86
scripts/release/verify-approvals.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
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';
|
||||
|
||||
const expectedRequestFields = [
|
||||
'schema', 'now', 'latestLedgerHeadHash', 'latestLedgerRevision', 'trustedKeys', 'contentRecords', 'assetRecords', 'inputDigests',
|
||||
].sort();
|
||||
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.`);
|
||||
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>');
|
||||
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.');
|
||||
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.');
|
||||
}
|
||||
return {
|
||||
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 closure = computeApprovalClosure({
|
||||
contentRecords: request.contentRecords,
|
||||
assetRecords: request.assetRecords,
|
||||
trust: {
|
||||
now: request.now,
|
||||
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
||||
latestLedgerRevision: request.latestLedgerRevision,
|
||||
trustedKeys,
|
||||
},
|
||||
});
|
||||
const productionInputRoot = computeProductionInputRoot({
|
||||
portfolioContentDigest: inputDigests.PortfolioContent,
|
||||
publicAssetManifestDigest: inputDigests.PublicAssetManifest,
|
||||
preparedAssetSetDigest: inputDigests.PreparedAssetSet,
|
||||
siteConfigDigest: inputDigests.SiteConfig,
|
||||
});
|
||||
return {
|
||||
schema: 'portfolio-approval-verification-result/v1',
|
||||
approvedRecordRoot: closure.approvedRecordRoot,
|
||||
approvedAssetRoot: closure.approvedAssetRoot,
|
||||
approvalProofSetDigest: closure.approvalProofSetDigest,
|
||||
productionInputRoot,
|
||||
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
||||
latestLedgerRevision: request.latestLedgerRevision,
|
||||
earliestApprovalExpiry: closure.earliestApprovalExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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.';
|
||||
process.stderr.write(`Approval verification failed: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user