130 lines
3.8 KiB
TypeScript
130 lines
3.8 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
|
import { readHead } from "../lib/ledger.js";
|
|
import { verifyMessage } from "../lib/keys.js";
|
|
|
|
function mustExist(p: string, label: string) {
|
|
if (!fs.existsSync(p)) {
|
|
const err = new Error(`${label} not found: ${p}`);
|
|
(err as { exitCode?: number }).exitCode = 2;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
type ReceiptEnvelope = Record<string, unknown> & {
|
|
hash_alg?: string;
|
|
blake3?: string;
|
|
sha256?: string;
|
|
prev_blake3?: string | null;
|
|
|
|
plan_file?: string | null;
|
|
plan_sha256?: string | null;
|
|
plan_blake3?: string | null;
|
|
|
|
sig_alg?: string;
|
|
signer_pub?: string;
|
|
signature?: string;
|
|
signed_at?: string;
|
|
};
|
|
|
|
function stripForHash(env: ReceiptEnvelope) {
|
|
const {
|
|
hash_alg: _hash_alg,
|
|
blake3: _blake3,
|
|
sha256: _sha256,
|
|
sig_alg: _sig_alg,
|
|
signer_pub: _signer_pub,
|
|
signature: _signature,
|
|
signed_at: _signed_at,
|
|
...body
|
|
} = env;
|
|
return body;
|
|
}
|
|
|
|
export async function verifyReceipt(
|
|
receiptPath: string,
|
|
opts: { head?: boolean; plan?: boolean; sig?: boolean } = {}
|
|
) {
|
|
const abs = path.resolve(receiptPath);
|
|
mustExist(abs, "Receipt");
|
|
|
|
const raw = fs.readFileSync(abs, "utf8");
|
|
const env = JSON.parse(raw) as ReceiptEnvelope;
|
|
|
|
const failures: string[] = [];
|
|
|
|
if (env.hash_alg !== "blake3+sha256") {
|
|
failures.push(`hash_alg mismatch (got ${String(env.hash_alg)})`);
|
|
}
|
|
|
|
const body = stripForHash(env);
|
|
const blake3_calc = hashBlake3Hex(body);
|
|
const sha256_calc = hashSha256Hex(body);
|
|
|
|
if (env.blake3 !== blake3_calc) failures.push("blake3 mismatch");
|
|
if (env.sha256 !== sha256_calc) failures.push("sha256 mismatch");
|
|
|
|
if (opts.plan) {
|
|
const planFile = env.plan_file ? String(env.plan_file) : "";
|
|
const planSha = env.plan_sha256 ? String(env.plan_sha256) : "";
|
|
const planBlake = env.plan_blake3 ? String(env.plan_blake3) : "";
|
|
|
|
if (!planFile) {
|
|
failures.push("receipt has no plan_file");
|
|
} else {
|
|
const planAbs = path.resolve(planFile);
|
|
if (!fs.existsSync(planAbs)) {
|
|
failures.push(`plan file not found: ${planFile}`);
|
|
} else {
|
|
try {
|
|
const planRaw = fs.readFileSync(planAbs, "utf8");
|
|
const plan = JSON.parse(planRaw) as unknown;
|
|
const sha = hashSha256Hex(plan);
|
|
const b3 = hashBlake3Hex(plan);
|
|
if (planSha && sha !== planSha) failures.push("plan_sha256 mismatch");
|
|
if (planBlake && b3 !== planBlake) failures.push("plan_blake3 mismatch");
|
|
} catch (e) {
|
|
failures.push(`failed to read/parse plan: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (opts.head) {
|
|
const head = readHead();
|
|
if (!head) {
|
|
failures.push("HEAD.json not found or invalid");
|
|
} else {
|
|
const rel = path.relative(process.cwd(), abs) || abs;
|
|
if (head.file !== rel) {
|
|
failures.push(`receipt is not HEAD.file (HEAD.file=${head.file})`);
|
|
}
|
|
if (head.blake3 !== env.blake3) {
|
|
failures.push("HEAD.blake3 mismatch");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (opts.sig) {
|
|
if (env.sig_alg !== "ed25519" || !env.signature || !env.signer_pub) {
|
|
failures.push("signature fields missing (need sig_alg, signer_pub, signature)");
|
|
} else {
|
|
const msg = new TextEncoder().encode(String(env.blake3 ?? ""));
|
|
const ok = await verifyMessage(msg, String(env.signature), String(env.signer_pub));
|
|
if (!ok) failures.push("signature verification failed");
|
|
}
|
|
}
|
|
|
|
if (failures.length) {
|
|
const err = new Error("Receipt verification failed:\n- " + failures.join("\n- "));
|
|
(err as { exitCode?: number }).exitCode = 5;
|
|
throw err;
|
|
}
|
|
|
|
console.log(`OK receipt ${receiptPath}`);
|
|
console.log(`blake3=${env.blake3}`);
|
|
console.log(`sha256=${env.sha256}`);
|
|
if (opts.sig && env.signature) console.log(`sig=ok signer_pub=${env.signer_pub}`);
|
|
}
|