164 lines
5.0 KiB
JavaScript
164 lines
5.0 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createPublicKey } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import {
|
|
APPROVAL_PROJECTION_ATTESTATION_SCHEMA,
|
|
APPROVAL_PROJECTION_ATTESTATION_SCOPE,
|
|
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",
|
|
"latestLedgerHeadHash",
|
|
"latestLedgerRevision",
|
|
"trustedKeys",
|
|
"ledgerProof",
|
|
"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];
|
|
}
|
|
|
|
function verifyApprovalRequestAt(source, now) {
|
|
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,
|
|
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
|
latestLedgerRevision: request.latestLedgerRevision,
|
|
trustedKeys,
|
|
ledgerProof: request.ledgerProof,
|
|
},
|
|
});
|
|
const productionInputRoot = computeProductionInputRoot({
|
|
portfolioContentDigest: inputDigests.PortfolioContent,
|
|
publicAssetManifestDigest: inputDigests.PublicAssetManifest,
|
|
preparedAssetSetDigest: inputDigests.PreparedAssetSet,
|
|
siteConfigDigest: inputDigests.SiteConfig,
|
|
});
|
|
return {
|
|
schema: "portfolio-approval-verification-result/v1",
|
|
projectionSchema: APPROVAL_PROJECTION_ATTESTATION_SCHEMA,
|
|
projectionScope: APPROVAL_PROJECTION_ATTESTATION_SCOPE,
|
|
approvedRecordRoot: closure.approvedRecordRoot,
|
|
approvedAssetRoot: closure.approvedAssetRoot,
|
|
approvalProofSetDigest: closure.approvalProofSetDigest,
|
|
productionInputRoot,
|
|
latestLedgerHeadHash: request.latestLedgerHeadHash,
|
|
latestLedgerRevision: request.latestLedgerRevision,
|
|
earliestApprovalExpiry: closure.earliestApprovalExpiry,
|
|
};
|
|
}
|
|
|
|
export function verifyApprovalRequest(source) {
|
|
return verifyApprovalRequestAt(source, new Date().toISOString());
|
|
}
|
|
|
|
export function verifyApprovalRequestForTest(source, clock) {
|
|
if (
|
|
clock === null ||
|
|
typeof clock !== "object" ||
|
|
Object.keys(clock).length !== 1 ||
|
|
typeof clock.now !== "string"
|
|
) {
|
|
throw new Error("Test-only approval clock is invalid.");
|
|
}
|
|
return verifyApprovalRequestAt(source, clock.now);
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|