#!/usr/bin/env node // Verify a Predge signed record offline (Node 18+, no packages). // usage: node verify-record.mjs FILE [--keys FILE_OR_URL] [--offline] // Same checks and exit codes as verify_record.py (see CANONICALIZATION.md). import { readFileSync } from "node:fs"; import { createHash, createPublicKey, verify } from "node:crypto"; const KEYS_URL = "https://api.predge.io/.well-known/predge-keys.json"; // The Predge rule: sorted keys at every level, no whitespace. Strings use JSON.stringify, // which leaves non-ASCII characters as they are (UTF-8 on the wire). export const canonical = (v) => v === null || typeof v !== "object" ? JSON.stringify(v) : Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : `{${Object.keys(v) .filter((k) => v[k] !== undefined) .sort() .map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`) .join(",")}}`; const edVerify = (pubHex, sigHex, message) => { try { const key = createPublicKey({ format: "der", type: "spki", key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(pubHex, "hex")]), }); return verify(null, Buffer.from(message, "utf8"), key, Buffer.from(sigHex, "hex")); } catch { return false; } }; async function main(argv) { if (!argv[0] || argv[0].startsWith("-")) { console.log("usage: node verify-record.mjs FILE [--keys FILE_OR_URL] [--offline]"); return 64; } const offline = argv.includes("--offline"); const keysSrc = argv.includes("--keys") ? argv[argv.indexOf("--keys") + 1] : KEYS_URL; const doc = JSON.parse(readFileSync(argv[0], "utf8")); const env = doc.attestation && typeof doc.attestation === "object" ? doc.attestation : doc; const results = []; results.push(["payload rebuilds canonical", canonical(env.payload) === env.canonical]); const digest = createHash("sha256").update(env.canonical, "utf8").digest("hex"); if ("content_hash" in env) results.push(["content_hash = sha256(canonical)", env.content_hash === digest]); results.push(["signature over canonical", edVerify(env.public_key, env.signature, env.canonical)]); if ("envelope_signature" in env) { const { envelope_signature, ...rest } = env; results.push(["envelope_signature", edVerify(env.public_key, envelope_signature, canonical(rest))]); } if (!offline) { const keys = /^https?:\/\//.test(keysSrc) ? await (await fetch(keysSrc)).json() : JSON.parse(readFileSync(keysSrc, "utf8")); const pub = env.public_key.toLowerCase(); results.push([ "key listed and active in predge-keys.json", (keys.keys ?? []).some((k) => k.active && String(k.public_key).toLowerCase() === pub), ]); } console.log("content_hash (sha256 of canonical):", digest); console.log("kid:", env.public_key.slice(0, 16)); for (const [name, ok] of results) console.log(`${ok ? "PASS" : "FAIL"} ${name}`); return results.every(([, ok]) => ok) ? 0 : 1; } if (import.meta.url === `file://${process.argv[1]}`) { main(process.argv.slice(2)).then((code) => process.exit(code)); }