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 & { 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}`); }