#!/usr/bin/env node /* * ARION AER-1 verifier (independent, Node.js, zero dependencies) * * An independent reimplementation of the AER-1 receipt/chain/workflow * verification rules in draft-zambo-aer1-09 ("AER-1: A Portable Execution * Receipt for AI Agent Tool Calls"), written from the draft text. * * Implements: * - Section 3/4/5/6 : core receipt checks (members, types, provenance, * strict base64 + strict UTF-8 canonical bytes, * output commitment recomputation) * - Kit profile : reference-producer profile (canonical payload is a * JSON object carrying an "inputs" member) * - Disinterested : optional anchor tier (log / leaf_hash / anchored_at / * proof), enforced when an anchor uses that shape * - Section 7.1/7.2 : -07/-08 hash-chained job timelines (canonical-JSON * entry digest binding seq, job_id, close, id, tool, * provenance_class, output_hash; genesis, contiguity, * one-job, closing, and link rules) * - Section 7.4 : the historical -06 construction is implemented as a * distinct labeled function; -06 timelines MUST NOT * verify under the -07/-08 rules * - Section 7.3 : checkFinalEntryCommitment() compares a published * bare-digest commitment against the recomputed * final entry digest (-08 style); and the -09 chain * commitment artifact — verifyChainCommitment() * binds job_id / final_entry_digest / entry_count / * published_at, mirroring the kit's check verbatim * - Section 8/8.1/8.2: workflow receipts, Merkle root over step receipt_ids * (leaf = SHA-256(utf8(receipt_id)), odd-level last * duplication), seq contiguity, duplicate-receipt_id * rejection * * CLI: * node verify.js [--strict] [--chain06] * one JSON document; the kind is auto-detected: * - JSON array -> Section 7 timeline * - object type verifiable-workflow-receipt -> Section 8 workflow * - object with "timeline"/"entries" -> wrapped timeline * - otherwise -> Section 3 receipt * every *.json file inside (sorted), auto-detected per file * --strict certifier-strictness profile for receipts: id must be a * UUID v4 with RFC 4122 variant, receipt_schema_version must * be exactly "0.3", numeric offsets are range-checked per * component. Default mode matches the kit's documented core * check (lowercase UUID, any non-empty schema version). * --chain06 interpret a timeline under the historical -06 construction * (entry digest = SHA-256 of the decoded canonical_bytes). * Exit code 0 when every input verifies, 1 otherwise. Every FAIL line * names the failing check. * * Disclosure: this file was written by ARION, an autonomous agent. It is an * independent reimplementation from the draft text; the conformance kit's * own runners were consulted ONLY to pin corpus conventions the draft does * not specify (see REPORT.md, "Ambiguities and resolutions"). */ "use strict"; const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); /* ------------------------------------------------------------------ * * Primitives * * ------------------------------------------------------------------ */ function sha256hex(buf) { return crypto.createHash("sha256").update(buf).digest("hex"); } // Strict UTF-8 decode (Section 4): non-UTF-8 canonical bytes fail closed. const UTF8 = new TextDecoder("utf-8", { fatal: true }); function decodeUtf8Strict(buf) { try { UTF8.decode(buf); return true; } catch (e) { return false; } } // Base64 surface check. The kit convention (documented in the runners) is // "alphabet plus at most two pad chars, total length a multiple of 4"; // non-canonical trailing pad bits are tolerated (Python's // base64.b64decode(validate=True) behaves the same). Draft -10 / kit >= // 7b4d4a5 removed the trailing-"\n" tolerance (Python \Z semantics). const B64_RE = /^[A-Za-z0-9+/]*={0,2}$/; const B64_STRICT_RE = /^[A-Za-z0-9+/]*={0,2}$/; function decodeBase64Strict(s, strict) { const re = strict ? B64_STRICT_RE : B64_RE; if (typeof s !== "string" || !re.test(s) || s.length % 4 !== 0) return null; return Buffer.from(s, "base64"); } /* ------------------------------------------------------------------ * * Field validators (Section 3) * * ------------------------------------------------------------------ */ // Receipt id: stable UUID. Kit convention: lowercase hex. Strict mode: // UUID v4 with the RFC 4122 variant bits (the certifier convention; the // adversarial corpus pins it). const UUID_LOOSE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; // output_hash / leaf_hash: "sha256:" + 64 lowercase hex chars. const HASH_RE = /^sha256:[0-9a-f]{64}$/; // Provenance classes (Section 5): the two fixed labels, or // "EXECUTED BY " where is the recording system's own // uppercase name — the draft says the first label names the recording // system and other implementations substitute their own system name. const PROV_RE = /^(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)$/; const PROV_STRICT_RE = /^(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)$/; // RFC 3339 timestamp surface. const RFC3339_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/; const RFC3339_STRICT_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/; const TS_PARTS_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/; function isLeapYear(y) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; } function daysInMonth(y, m) { if (m === 2) return isLeapYear(y) ? 29 : 28; if (m === 4 || m === 6 || m === 9 || m === 11) return 30; return 31; } // Calendar validation (Section 3: "the timestamp MUST denote a valid // calendar date and time"). Kit mode bounds the numeric UTC offset to // strictly less than 24h total; strict mode bounds hours <= 23 and // minutes <= 59 per component. function isCalendarDateTime(s, strict) { const m = TS_PARTS_RE.exec(s.replace("Z", "+00:00")); if (!m) return false; const year = +m[1], mon = +m[2], day = +m[3]; const hh = +m[4], mi = +m[5], ss = +m[6]; if (year < 1 || year > 9999) return false; if (mon < 1 || mon > 12) return false; if (day < 1 || day > daysInMonth(year, mon)) return false; if (hh > 23 || mi > 59 || ss > 59) return false; const off = m[8]; if (off !== "Z") { const oh = +off.slice(1, 3), om = +off.slice(4, 6); if (oh > 23 || om > 59) return false; const total = (off[0] === "-" ? -1 : 1) * (oh * 60 + om); if (!(total > -1440 && total < 1440)) return false; } return true; } function isRfc3339Format(s, strict) { if (typeof s !== "string") return false; const re = strict ? RFC3339_STRICT_RE : RFC3339_RE; return re.test(s); } function isRfc3339(s, strict) { return isRfc3339Format(s, strict) && isCalendarDateTime(s, strict); } function isObject(v) { return typeof v === "object" && v !== null && !Array.isArray(v); } function isNonEmptyString(v) { return typeof v === "string" && v.length > 0; } /* ------------------------------------------------------------------ * * Section 3/4/5/6 — core receipt check * * * * Returns a list of failure reasons; [] means conformant. * * ------------------------------------------------------------------ */ const CORE_MEMBERS = ["id", "receipt_schema_version", "created_at", "tool", "provenance_class", "canonical_bytes", "output_hash", "verification_status"]; function checkReceipt(receipt, opts) { const strict = !!(opts && opts.strict); const failures = []; if (!isObject(receipt)) return ["receipt is not a JSON object"]; for (const field of CORE_MEMBERS) { if (!(field in receipt)) failures.push("missing required member: " + field); } if (failures.length) return failures; // id: stable UUID. Default: lowercase UUID shape (kit rule, justified by // the Section 8.1 leaf hashing receipt_id bytes verbatim). Strict mode: // UUID v4 with RFC 4122 variant (certifier convention). if (typeof receipt.id !== "string" || !(strict ? UUID_V4_RE : UUID_LOOSE_RE).test(receipt.id)) { failures.push(strict ? "id is not a UUID v4" : "id is not a lowercase UUID"); } // receipt_schema_version: Section 3 documents "0.3" as the reference // implementation's value. Core mode requires a non-empty string // (a different producer may legitimately carry another version); // strict mode pins "0.3" exactly. if (strict) { if (receipt.receipt_schema_version !== "0.3") { failures.push("receipt_schema_version is not 0.3"); } } else if (!isNonEmptyString(receipt.receipt_schema_version)) { failures.push("receipt_schema_version is not a non-empty string"); } if (typeof receipt.created_at !== "string" || !isRfc3339Format(receipt.created_at, strict)) { failures.push("created_at is not RFC 3339"); } else if (!isCalendarDateTime(receipt.created_at, strict)) { // RFC 3339 shape alone is not enough: "2026-02-30" matches the // pattern but is not a real calendar date (Section 3). Fail closed. failures.push("created_at is not a valid calendar date/time"); } const tool = receipt.tool; if (!isObject(tool) || !["name", "version", "scope"].every( (k) => typeof tool[k] === "string" && tool[k].length > 0)) { failures.push("tool is not an object with name/version/scope strings"); } const p = receipt.provenance_class; if (typeof p !== "string" || !(strict ? PROV_STRICT_RE : PROV_RE).test(p)) { failures.push("provenance_class is not a known class"); } // canonical_bytes: strict base64, then strict UTF-8 (Section 4). let raw = null; raw = decodeBase64Strict(receipt.canonical_bytes, strict); if (raw === null) { failures.push("canonical_bytes is not valid base64"); } else if (!decodeUtf8Strict(raw)) { failures.push("canonical_bytes is not valid UTF-8"); raw = null; } // output_hash: format, then recomputation (Section 6 step 3). const oh = receipt.output_hash; if (typeof oh !== "string" || !HASH_RE.test(oh)) { failures.push("output_hash is not sha256: + lowercase hex digest"); } else if (raw !== null) { const want = "sha256:" + sha256hex(raw); if (oh !== want) { failures.push("output_hash does not match sha256(canonical_bytes)"); } } if (receipt.verification_status !== "verified") { failures.push("verification_status is not verified"); } // Version agreement + provenance binding (reference runner checks // 9a/9c, draft -13). Legacy envelopes (outer 0.2/0.3 with no inner // version commitment) pass; otherwise the inner payload MUST commit // a receipt_schema_version equal to the outer one, and a committed // issuer_claims.provenance_class MUST equal the outer value. if (raw !== null) { let inner = null; try { inner = JSON.parse(Buffer.from(raw).toString("utf8")); } catch (e) { inner = null; } const innerVersion = isObject(inner) ? inner.receipt_schema_version : undefined; const outerVersion = receipt.receipt_schema_version; const legacyProbe = innerVersion !== undefined ? innerVersion : outerVersion; const isLegacy = legacyProbe !== undefined && (legacyProbe === "0.2" || legacyProbe === "0.3"); if (!isLegacy) { if (innerVersion !== undefined && outerVersion !== undefined) { if (String(innerVersion) !== String(outerVersion)) { failures.push("receipt_schema_version: outer " + JSON.stringify(outerVersion) + " does not match committed " + JSON.stringify(innerVersion)); } } else { failures.push("receipt_schema_version: one side missing (outer=" + JSON.stringify(outerVersion) + " committed=" + JSON.stringify(innerVersion) + ")"); } if (isObject(inner)) { const ic = isObject(inner.issuer_claims) ? inner.issuer_claims.provenance_class : undefined; const opc = receipt.provenance_class; if (ic !== undefined && opc !== undefined) { if (ic !== opc) { failures.push("provenance binding: outer " + JSON.stringify(opc) + " does not match committed " + JSON.stringify(ic)); } } else { failures.push("provenance binding: one side missing (outer=" + JSON.stringify(opc) + " committed=" + JSON.stringify(ic) + ")"); } } else { failures.push("provenance binding: canonical payload did not decode to an object"); } } } // Section 3.1 metadata_hash (draft -14): REQUIRED when // receipt_schema_version is "0.4" or later; whenever present a // verifier MUST recompute the canonical metadata block and compare. const verGeq = (a, b) => { const pa = String(a).split(".").map(Number), pb = String(b).split(".").map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d !== 0) return d > 0; } return true; }; const hasMeta = "metadata_hash" in receipt; if (!hasMeta) { if (typeof receipt.receipt_schema_version === "string" && verGeq(receipt.receipt_schema_version, "0.4")) { failures.push("metadata_hash is required at receipt_schema_version 0.4 or later"); } } else { const mh = receipt.metadata_hash; if (typeof mh !== "string" || !HASH_RE.test(mh)) { failures.push("metadata_hash is not sha256: + lowercase hex digest"); } else { const meta = { created_at: receipt.created_at, id: receipt.id, output_hash: receipt.output_hash, provenance_class: receipt.provenance_class, receipt_schema_version: receipt.receipt_schema_version, tool: isObject(receipt.tool) ? { name: receipt.tool.name, scope: receipt.tool.scope, version: receipt.tool.version, } : receipt.tool, }; const want = "sha256:" + sha256hex(Buffer.from(canonicalJson(meta), "utf8")); if (mh !== want) { failures.push("metadata_hash does not match sha256(canonical metadata block)"); } } } return failures; } /* ------------------------------------------------------------------ * * Reference-producer profile (kit convention): the canonical payload * * must be a JSON object carrying an "inputs" member. Core-valid * * receipts may still fail this; the boundary is intentional. * * ------------------------------------------------------------------ */ function checkReferenceProfile(receipt, opts) { const strict = !!(opts && opts.strict); if (!isObject(receipt)) return ["receipt is not a JSON object"]; const raw = decodeBase64Strict(receipt.canonical_bytes, strict); if (raw === null) return ["canonical_bytes is not valid base64"]; if (!decodeUtf8Strict(raw)) return ["canonical_bytes is not valid UTF-8"]; let payload; try { payload = JSON.parse(raw.toString("utf8")); } catch (e) { return ["canonical payload is not JSON"]; } if (!isObject(payload)) return ["canonical payload is not a JSON object"]; if (!("inputs" in payload)) { return ["reference-producer payload omits the 'inputs' member"]; } return []; } /* ------------------------------------------------------------------ * * Disinterested-tier anchor check (kit convention for anchored/). * * * * The draft's Section 10 anchor is opaque and additive; the kit's * * disinterested tier defines a concrete shape: anchor members exactly * * {log, leaf_hash, anchored_at, proof}, leaf_hash = "sha256:" + SHA-256 * * of the raw canonical bytes. This check runs only when the receipt's * * anchor uses the tier's vocabulary (any of those member names present); * * other anchor envelopes (e.g. the Nostr anchor in Section 10) are * * opaque and out of scope for offline verification. * * ------------------------------------------------------------------ */ const TIER_MEMBERS = ["anchored_at", "leaf_hash", "log", "proof"]; function anchorUsesTierShape(receipt) { const a = isObject(receipt) ? receipt.anchor : null; if (!isObject(a)) return false; return TIER_MEMBERS.some((k) => k in a); } function checkDisinterestedTier(receipt, opts) { const strict = !!(opts && opts.strict); const failures = []; // -09 (draft Section 3): the strict id profile applies wherever the // disinterested tier is claimed — the id MUST be a UUID v4 with RFC // 4122 variant bits, not merely a lowercase UUID-shaped string. The // -09 kit enforces this inside the tier check with the reason // "id is not a UUID v4 (strict profile)"; mirrored verbatim. const rid = isObject(receipt) ? receipt.id : null; if (typeof rid !== "string" || !UUID_V4_RE.test(rid)) { failures.push("id is not a UUID v4 (strict profile)"); } const anchor = isObject(receipt) ? receipt.anchor : null; if (!isObject(anchor)) return ["anchor is not an object"]; if (Object.keys(anchor).sort().join(",") !== TIER_MEMBERS.slice().sort().join(",")) { failures.push("anchor has members other than log/leaf_hash/anchored_at/proof"); } if (!isNonEmptyString(anchor.log)) { failures.push("anchor.log is not a non-empty string"); } const leaf = anchor.leaf_hash; if (typeof leaf !== "string" || !HASH_RE.test(leaf)) { failures.push("anchor.leaf_hash is not sha256: + lowercase hex digest"); } else { const raw = decodeBase64Strict(receipt.canonical_bytes, strict); if (raw === null) { failures.push("canonical_bytes is not valid base64"); } else if (!decodeUtf8Strict(raw)) { failures.push("canonical_bytes is not valid UTF-8 base64"); } else { const want = "sha256:" + sha256hex(raw); if (leaf !== want) { failures.push("anchor.leaf_hash does not match canonical_bytes"); } } } if (!isRfc3339(anchor.anchored_at, strict)) { failures.push("anchor.anchored_at is not a valid calendar date/time"); } if (!isObject(anchor.proof)) failures.push("anchor.proof is not an object"); return failures; } /* ------------------------------------------------------------------ * * Section 10 + 10.1 — "aer1-anchor" Table-4 envelope (normative as of * * draft -12; unchanged in -13). Members exactly {type, version, * * receipt_id, anchored_digest, anchor_type, anchored_at, proof}; the * * privacy bound = no members beyond Table 4. Offline legs enforced: * * envelope shape, per-anchor_type proof member shapes, and 10.1 step * * 2 (recompute the anchored digest and compare). Remote legs (10.1 * * step 3: relay resolution, OTS verification, witness retrieval) are * * deployment facts and shape-checked only. "One or more external * * anchors": receipt.anchor may be an array. * * * * Draft ambiguity noted: Table 4 calls anchored_digest "lowercase * * hex" while the Section 3 output_hash it carries is "sha256:"- * * prefixed; this check takes the table literally (bare 64-hex). * * ------------------------------------------------------------------ */ const AER1_ANCHOR_MEMBERS = ["anchored_at", "anchored_digest", "anchor_type", "proof", "receipt_id", "type", "version"]; const BARE_HEX_RE = /^[0-9a-f]{64}$/; function anchorUsesAer1Shape(anchor) { const a = Array.isArray(anchor) ? anchor[0] : anchor; return isObject(a) && (a.type === "aer1-anchor" || "anchored_digest" in a || "anchor_type" in a); } function checkAer1Anchors(doc, opts) { const strict = !!(opts && opts.strict); const failures = []; const anchor = isObject(doc) ? doc.anchor : null; const list = Array.isArray(anchor) ? anchor : [anchor]; for (const a of list) { if (!isObject(a)) { failures.push("anchor element is not an object"); continue; } if (Object.keys(a).sort().join(",") !== AER1_ANCHOR_MEMBERS.slice().sort().join(",")) { failures.push("anchor has members other than Table 4 " + "(type/version/receipt_id/anchored_digest/anchor_type/anchored_at/proof)"); } if (a.type !== "aer1-anchor") failures.push("anchor.type is not \"aer1-anchor\""); if (a.version !== "1") failures.push("anchor.version is not \"1\""); // 10.1 step 1: receipt_id names the anchored document. const ridRe = strict ? UUID_V4_RE : UUID_LOOSE_RE; if (typeof a.receipt_id !== "string" || !ridRe.test(a.receipt_id)) { failures.push("anchor.receipt_id is not a UUID"); } else if (typeof doc.id === "string" && a.receipt_id !== doc.id) { failures.push("anchor.receipt_id does not match receipt id"); } const hasDigest = typeof a.anchored_digest === "string" && BARE_HEX_RE.test(a.anchored_digest); if (!hasDigest) { failures.push("anchor.anchored_digest is not a lowercase hex digest"); } if (!isRfc3339(a.anchored_at, strict)) { failures.push("anchor.anchored_at is not a valid calendar date/time"); } // anchor_type + proof member shapes (10.1 step 3, offline part). const proof = a.proof; if (!isObject(proof)) { failures.push("anchor.proof is not an object"); } else if (a.anchor_type === "nostr") { if (typeof proof.event_id !== "string" || !BARE_HEX_RE.test(proof.event_id)) failures.push("anchor.proof.event_id is not 64 lowercase hex"); if (typeof proof.pubkey !== "string" || !BARE_HEX_RE.test(proof.pubkey)) failures.push("anchor.proof.pubkey is not 64 lowercase hex"); if (!Array.isArray(proof.relays) || proof.relays.length === 0 || !proof.relays.every(isNonEmptyString)) failures.push("anchor.proof.relays is not a non-empty string array"); } else if (a.anchor_type === "ots") { if (!isNonEmptyString(proof.ots_proof) && !isNonEmptyString(proof.ots_request)) failures.push("anchor.proof carries neither ots_proof nor ots_request"); } else if (a.anchor_type === "witness") { if (!isNonEmptyString(proof.witness_url)) failures.push("anchor.proof.witness_url is not a non-empty string"); if (!isNonEmptyString(proof.witness_statement)) failures.push("anchor.proof.witness_statement is not a non-empty string"); } else { failures.push("anchor.anchor_type is not nostr/ots/witness"); } // 10.1 step 2: compare against the recomputed/declared digest where // one is offline-available. if (hasDigest) { if (typeof doc.canonical_bytes === "string") { const raw = decodeBase64Strict(doc.canonical_bytes, strict); if (raw !== null && decodeUtf8Strict(raw) && a.anchored_digest !== sha256hex(raw)) { failures.push("anchor.anchored_digest does not match recomputed output digest"); } } else if (typeof doc.merkle_root === "string") { const mr = doc.merkle_root.replace(/^sha256:/, ""); if (BARE_HEX_RE.test(mr) && a.anchored_digest !== mr) { failures.push("anchor.anchored_digest does not match merkle_root"); } } else if (typeof doc.final_entry_digest === "string") { const fd = doc.final_entry_digest.replace(/^sha256:/, ""); if (BARE_HEX_RE.test(fd) && a.anchored_digest !== fd) { failures.push("anchor.anchored_digest does not match final_entry_digest"); } } } } return failures; } /* ------------------------------------------------------------------ * * Section 7.1 — canonical JSON for the -07/-08 entry digest * * * * Members sorted by Unicode code point, no whitespace outside * * strings, integer-valued seq serialized in integer form, non-ASCII * * escaped as \uXXXX (surrogate pairs for non-BMP). * * ------------------------------------------------------------------ */ // Escape a JSON string body per Section 7.1: JSON.stringify already emits // RFC 8259 escapes for ", \, and control characters; every remaining // non-ASCII UTF-16 code unit is then escaped as \uXXXX, which also handles // non-BMP characters (they serialize as a surrogate pair). function escapeCanonicalString(str) { const quoted = JSON.stringify(str); let out = ""; for (let i = 0; i < quoted.length; i++) { const code = quoted.charCodeAt(i); // >= 0x7f: draft -10 escapes U+007F (DELETE) like the reference // (Python json.dumps ensure_ascii=True). out += code >= 0x7f ? "\\u" + code.toString(16).padStart(4, "0") : quoted[i]; } return out; } // Serialize a JSON value canonically: object keys sorted by Unicode code // point, no whitespace. Numbers with integer value serialize in integer // form (1, never 1.0). function canonicalJson(value) { if (value === null) return "null"; if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") { if (Number.isInteger(value)) return String(value); return String(value); // non-integers never reach a valid digest } if (typeof value === "string") return escapeCanonicalString(value); if (Array.isArray(value)) { return "[" + value.map(canonicalJson).join(",") + "]"; } if (isObject(value)) { const keys = Object.keys(value).sort((a, b) => { // sort by UTF-16 code unit order == Unicode code point order if (a < b) return -1; if (a > b) return 1; return 0; }); return "{" + keys.map((k) => escapeCanonicalString(k) + ":" + canonicalJson(value[k])).join(",") + "}"; } throw new Error("unserializable value in canonical JSON"); } // The -07/-08 entry digest (Section 7.1). Throws on invalid base64 // (fail closed, per Section 7.1's strict-decode requirement). function entryDigestV07(entry, strict) { const raw = decodeBase64Strict(entry.canonical_bytes, strict); if (raw === null) throw new Error("canonical_bytes is not valid base64"); const payload = { close: entry.close === undefined ? false : entry.close, id: entry.id, job_id: entry.job_id, output_hash: sha256hex(raw), prev_digest: entry.prev_digest, provenance_class: entry.provenance_class, seq: entry.seq, tool: entry.tool, }; return sha256hex(Buffer.from(canonicalJson(payload), "utf8")); } /* ------------------------------------------------------------------ * * Section 7.2 — chain rules (-07/-08 construction) * * * * Returns a list of failure reasons; [] means valid. Structural * * failures (non-object entry, bad base64, missing members, non-integer * * seq) abort immediately, fail closed. * * ------------------------------------------------------------------ */ const GENESIS = "0".repeat(64); const CHAIN_STRING_MEMBERS = ["id", "tool", "provenance_class", "job_id", "prev_digest"]; function verifyChainV07(timeline, opts) { const strict = !!(opts && opts.strict); if (!Array.isArray(timeline) || timeline.length === 0) { return ["timeline is not a non-empty list"]; } const failures = []; let job = null; let prev = null; for (let i = 0; i < timeline.length; i++) { const entry = timeline[i]; if (!isObject(entry)) return ["entry " + i + " is not an object"]; if (decodeBase64Strict(entry.canonical_bytes, strict) === null) { return ["entry " + i + " canonical_bytes is not valid base64"]; } for (const m of CHAIN_STRING_MEMBERS) { if (!isNonEmptyString(entry[m])) return ["entry " + i + " missing " + m]; } const seq = entry.seq; if (typeof seq !== "number" || !Number.isInteger(seq)) { return ["entry " + i + " seq is not an integer"]; } // Sequence: first entry seq MUST be 1, then contiguous (Section 7.2). if (seq !== i + 1) { failures.push("entry " + i + " seq " + seq + " breaks contiguity (expected " + (i + 1) + ")"); } // One job per timeline. if (job === null) job = entry.job_id; else if (entry.job_id !== job) { failures.push("entry " + i + " job_id mixes jobs"); } // Genesis / link checks against recomputed digests. const pd = entry.prev_digest; if (i === 0) { if (pd !== GENESIS) { failures.push("entry 0 prev_digest is not 64 zero characters"); } } else if (pd !== prev) { failures.push("entry " + i + " prev_digest does not match the digest of entry " + (i - 1)); } // Closing: close must be boolean; true only on the last entry. if ("close" in entry && typeof entry.close !== "boolean") { failures.push("entry " + i + " close is not a boolean"); } else if (i < timeline.length - 1 && entry.close === true) { failures.push("entry " + i + " carries close:true before the last entry"); } prev = entryDigestV07(entry, strict); } const last = timeline[timeline.length - 1]; if (!isObject(last) || last.close !== true) { failures.push("last entry does not carry close: true"); } return failures; } /* ------------------------------------------------------------------ * * Historical -06 construction (Section 7.4): entry digest = SHA-256 * * of the base64-decoded canonical_bytes alone. A verifier MUST * * distinguish this from -07/-08 and MUST NOT report a -06 chain as * * verifying under the current construction. Provided here strictly as * * the labeled legacy check. * * ------------------------------------------------------------------ */ function verifyChainV06(timeline, opts) { const strict = !!(opts && opts.strict); if (!Array.isArray(timeline) || timeline.length === 0) { return ["timeline is not a non-empty list"]; } const failures = []; let prev = null; for (let i = 0; i < timeline.length; i++) { const entry = timeline[i]; if (!isObject(entry)) { failures.push("entry " + i + " is not an object"); break; } const raw = decodeBase64Strict(entry.canonical_bytes, strict); if (raw === null) { failures.push("entry " + i + " canonical_bytes is not valid base64"); break; } const digest = sha256hex(raw); const pd = entry.prev_digest; if (i === 0) { if (typeof pd !== "string" || pd !== GENESIS) { failures.push("entry 0 prev_digest is not 64 zero characters"); } } else if (typeof pd !== "string" || pd !== prev) { failures.push("entry " + i + " prev_digest does not match the digest of entry " + (i - 1)); } prev = digest; } const last = timeline[timeline.length - 1]; if (isObject(last) && last.close !== true) { failures.push("last entry does not carry close: true"); } return failures; } /* ------------------------------------------------------------------ * * Section 7.3 — external truncation commitment * * * * Chain mechanics alone cannot detect truncation with re-linking; the * * draft recommends the external commitment bind the FINAL chain entry * * digest. checkFinalEntryCommitment() recomputes the last entry's * * -07/-08 digest and compares it with the published commitment. * * ------------------------------------------------------------------ */ function finalEntryDigestV07(timeline, opts) { const strict = !!(opts && opts.strict); if (!Array.isArray(timeline) || timeline.length === 0) return null; const last = timeline[timeline.length - 1]; if (!isObject(last)) return null; try { return entryDigestV07(last, strict); } catch (e) { return null; } } function checkFinalEntryCommitment(timeline, commitment, opts) { const failures = []; if (typeof commitment !== "string" || !/^[0-9a-f]{64}$/.test(commitment)) { failures.push("external commitment is not a lowercase hex SHA-256 digest"); return failures; } const digest = finalEntryDigestV07(timeline, opts); if (digest === null) { failures.push("cannot recompute the final entry digest"); } else if (digest !== commitment) { failures.push("external commitment does not bind the final entry digest " + "(expected " + commitment + ", recomputed " + digest + ")"); } return failures; } /* ------------------------------------------------------------------ * * Section 7.3 (-09) — chain commitment artifact * * * * A chain commitment is a JSON document published OUTSIDE the chain * * carrying job_id (non-empty string, equal to every entry's job_id), * * final_entry_digest (64 lowercase hex, equal to the Section 7.1 * * recomputed digest of the last entry), entry_count (integer, equal to * * the timeline length), and published_at (RFC 3339 with a real * * calendar date/time). Additional members are ignored. * * * * Mirrors the conformance kit's verifyChainCommitment() exactly — the * * failure-reason strings are verbatim because the corpus pins them. * * Ordering: member-shape failures are collected first and abort the * * binding checks; then entry_count binding, per-entry job_id, and the * * digest recompute, which fails closed the way verifyChainV07 does. * * ------------------------------------------------------------------ */ function verifyChainCommitment(timeline, commitment, opts) { const strict = !!(opts && opts.strict); if (!Array.isArray(timeline) || !timeline.length) { return ["timeline is not a non-empty list"]; } if (!isObject(commitment)) { return ["commitment is not a JSON object"]; } const failures = []; const jobId = commitment.job_id; if (!isNonEmptyString(jobId)) { failures.push("commitment job_id is not a non-empty string"); } const digest = commitment.final_entry_digest; if (typeof digest !== "string" || !HEX64_RE.test(digest)) { failures.push("commitment final_entry_digest is not 64 lowercase " + "hex characters"); } const count = commitment.entry_count; // __float__ marker: entry_count was written as 5.0 in JSON; JSON.parse // loses the int/float distinction so loaders mark it (draft -10). if ((count !== null && typeof count === "object" && count.__float__ !== undefined) || typeof count !== "number" || !Number.isInteger(count)) { failures.push("commitment entry_count is not an integer"); } const published = commitment.published_at; if (!isRfc3339Format(published, strict)) { failures.push("commitment published_at is not RFC 3339"); } else if (!isCalendarDateTime(published, strict)) { failures.push("commitment published_at is not a valid calendar " + "date/time"); } if (failures.length) return failures; // Member shapes are sound; now bind them to the timeline. if (count !== timeline.length) { failures.push("commitment entry_count " + count + " does not match the timeline length " + timeline.length); } for (let i = 0; i < timeline.length; i++) { const entry = timeline[i]; if (!isObject(entry) || !isNonEmptyString(entry.job_id)) { failures.push("entry " + i + " has no usable job_id"); } else if (entry.job_id !== jobId) { failures.push("entry " + i + " job_id does not match the commitment job_id"); } } // Recompute the last entry's digest, fail closed the way // verifyChainV07 does (strict base64 plus the length % 4 guard). const last = timeline[timeline.length - 1]; let want = null; try { const cb = isObject(last) ? last.canonical_bytes : undefined; if (decodeBase64Strict(cb, strict) === null) { throw new Error("bad base64"); } want = entryDigestV07(last, strict); } catch (e) { failures.push("final entry digest could not be recomputed: " + e.message); } if (want !== null && digest !== want) { failures.push("commitment final_entry_digest does not match the " + "recomputed digest of the last entry"); } return failures; } /* ------------------------------------------------------------------ * * Section 8.1 — Merkle root over ordered step receipt_id values * * * * leaf = SHA-256 over UTF-8 bytes of receipt_id; internal node = * * SHA-256 over raw left || raw right digests; odd level duplicates the * * last digest; empty input -> SHA-256 of the empty string. * * ------------------------------------------------------------------ */ function merkleRoot(receiptIds) { if (!receiptIds.length) return sha256hex(Buffer.alloc(0)); let level = receiptIds.map((rid) => crypto.createHash("sha256").update(rid, "utf8").digest()); while (level.length > 1) { if (level.length % 2) level.push(level[level.length - 1]); const next = []; for (let i = 0; i < level.length; i += 2) { next.push(crypto.createHash("sha256") .update(Buffer.concat([level[i], level[i + 1]])).digest()); } level = next; } return level[0].toString("hex"); } /* ------------------------------------------------------------------ * * Section 8.2 (offline part) — workflow structural checks * * * * Steps 1-2 of the procedure (resolving each step receipt over HTTP and * * verifying it independently) need network access and stay the caller's * * job; this function verifies the offline mechanics: steps is a * * non-empty array; each step carries an integer seq and a string * * receipt_id; seq values are exactly 1..n in order; no two steps share * * a receipt_id (the duplicate-receipt_id collision rule, Section 8.2 * * step 3); merkle_root equals the recomputed Section 8.1 root. * * ------------------------------------------------------------------ */ function verifyWorkflowSteps(w) { if (!isObject(w)) return ["workflow is not a JSON object"]; const steps = w.steps; if (!Array.isArray(steps) || steps.length === 0) { return ["workflow steps is not a non-empty list"]; } const failures = []; for (let i = 0; i < steps.length; i++) { const step = steps[i]; if (!isObject(step)) { failures.push("step " + (i + 1) + " is not an object"); continue; } if (typeof step.seq !== "number" || !Number.isInteger(step.seq)) { failures.push("step " + (i + 1) + " seq is not an integer"); } if (typeof step.receipt_id !== "string") { failures.push("step " + (i + 1) + " receipt_id is not a string"); } } if (failures.length) return failures; for (let i = 0; i < steps.length; i++) { if (steps[i].seq !== i + 1) { failures.push("step seq values are not 1.." + steps.length + " in order (index " + i + " carries seq " + steps[i].seq + ")"); break; } } const seen = new Set(); for (const step of steps) { if (seen.has(step.receipt_id)) { failures.push("duplicate receipt_id: " + step.receipt_id); break; } seen.add(step.receipt_id); } const root = merkleRoot(steps.map((s) => s.receipt_id)); if (typeof w.merkle_root !== "string" || w.merkle_root !== root) { failures.push("merkle_root does not match the recomputed Section 8.1 root"); } return failures; } /* ------------------------------------------------------------------ * * Section 8 (full member check) — workflow receipt files * * ------------------------------------------------------------------ */ const HEX64_RE = /^[0-9a-f]{64}$/; function checkWorkflow(w, opts) { const strict = !!(opts && opts.strict); const failures = []; if (!isObject(w)) return ["workflow is not a JSON object"]; for (const field of ["type", "version", "workflow_id", "receipt_id", "session_id", "goal", "status", "steps", "merkle_root", "output_hash", "verify_url"]) { if (!(field in w)) failures.push("missing required member: " + field); } if (failures.length) return failures; if (w.type !== "verifiable-workflow-receipt") { failures.push("type is not \"verifiable-workflow-receipt\""); } if (!isNonEmptyString(w.version)) { failures.push("version is not a non-empty string"); } const uuidRe = strict ? UUID_V4_RE : UUID_LOOSE_RE; if (typeof w.workflow_id !== "string" || !uuidRe.test(w.workflow_id)) { failures.push("workflow_id is not a UUID"); } if (typeof w.receipt_id !== "string" || !uuidRe.test(w.receipt_id)) { failures.push("receipt_id is not a UUID"); } for (const k of ["session_id", "goal", "status"]) { if (!isNonEmptyString(w[k])) failures.push(k + " is not a non-empty string"); } if (typeof w.merkle_root !== "string" || !HEX64_RE.test(w.merkle_root)) { failures.push("merkle_root is not a lowercase hex SHA-256 digest"); } if (typeof w.output_hash !== "string" || !HEX64_RE.test(w.output_hash)) { failures.push("output_hash is not a lowercase hex SHA-256 digest"); } if (!isNonEmptyString(w.verify_url) || !/^https?:\/\//.test(w.verify_url)) { failures.push("verify_url is not an http(s) URL"); } if (Array.isArray(w.steps)) { for (let i = 0; i < w.steps.length; i++) { const s = w.steps[i]; if (!isObject(s)) { failures.push("step " + (i + 1) + " is not an object"); continue; } for (const field of ["seq", "tool", "receipt_id", "receipt_hash", "started_at", "ended_at", "status"]) { if (!(field in s)) { failures.push("step " + (i + 1) + " missing member: " + field); } } if (typeof s.seq !== "number" || !Number.isInteger(s.seq)) { failures.push("step " + (i + 1) + " seq is not an integer"); } if (!isNonEmptyString(s.tool)) { failures.push("step " + (i + 1) + " tool is not a non-empty string"); } if (typeof s.receipt_id !== "string" || !uuidRe.test(s.receipt_id)) { failures.push("step " + (i + 1) + " receipt_id is not a UUID"); } if (typeof s.receipt_hash !== "string" || !HEX64_RE.test(s.receipt_hash)) { failures.push("step " + (i + 1) + " receipt_hash is not hex SHA-256"); } if (!isRfc3339(s.started_at, strict)) { failures.push("step " + (i + 1) + " started_at is not RFC 3339"); } if (!isRfc3339(s.ended_at, strict)) { failures.push("step " + (i + 1) + " ended_at is not RFC 3339"); } if (!isNonEmptyString(s.status)) { failures.push("step " + (i + 1) + " status is not a non-empty string"); } } } failures.push(...verifyWorkflowSteps(w)); return failures; } /* ------------------------------------------------------------------ * * Top-level dispatch * * ------------------------------------------------------------------ */ // Verify one parsed document. kind: "auto" | "receipt" | "chain" | // "chain06" | "workflow". Returns {kind, failures:[{check, reason}]}. function verifyDocument(doc, opts) { opts = opts || {}; const results = []; const push = (check, reasons) => { for (const r of reasons) results.push({ check, reason: r }); }; let kind = opts.kind || "auto"; if (kind === "auto") { if (Array.isArray(doc)) kind = "chain"; else if (isObject(doc) && (Array.isArray(doc.timeline) || Array.isArray(doc.entries))) kind = "chain"; else if (isObject(doc) && doc.type === "verifiable-workflow-receipt") kind = "workflow"; else kind = "receipt"; } if (kind === "chain" || kind === "chain06") { const tl = Array.isArray(doc) ? doc : (isObject(doc) ? (doc.timeline || doc.entries) : null); push(kind === "chain06" ? "chain(-06)" : "chain(-07/-08)", kind === "chain06" ? verifyChainV06(tl, opts) : verifyChainV07(tl, opts)); // Section 7.3: if the document wraps the timeline in an object that // publishes a final-entry commitment, check the binding. if (isObject(doc) && typeof doc.final_entry_digest === "string") { push("final-entry-commitment", checkFinalEntryCommitment(tl, doc.final_entry_digest, opts)); } // Section 7.3 (-09): a document carrying a "commitment" member runs // the chain commitment artifact check. Independent of the -08 bare // digest above: a doc with both members gets both checks. if (isObject(doc) && "commitment" in doc) { push("chain-commitment(-09)", verifyChainCommitment(tl, doc.commitment, opts)); } if (isObject(doc) && anchorUsesAer1Shape(doc.anchor)) { push("aer1-anchor", checkAer1Anchors(doc, opts)); } return { kind, failures: results }; } if (kind === "workflow") { push("workflow", checkWorkflow(doc, opts)); if (anchorUsesAer1Shape(doc.anchor)) { push("aer1-anchor", checkAer1Anchors(doc, opts)); } return { kind, failures: results }; } // Receipt. push("core", checkReceipt(doc, opts)); if (opts.profile) push("reference-profile", checkReferenceProfile(doc, opts)); // Anchors: the -12/-13 Table-4 envelope takes precedence when its // vocabulary is present; the kit's disinterested tier is enforced for // its own shape or under --anchor; other envelopes stay opaque. if (anchorUsesAer1Shape(doc.anchor)) { push("aer1-anchor", checkAer1Anchors(doc, opts)); } else if (opts.anchor === true || anchorUsesTierShape(doc)) { push("disinterested-tier", checkDisinterestedTier(doc, opts)); } return { kind, failures: results }; } function verifyFile(file, opts) { let doc; const text = fs.readFileSync(file, "utf8"); try { doc = JSON.parse(text); } catch (e) { return { file, kind: "?", failures: [{ check: "parse", reason: "not valid JSON: " + e.message }] }; } const r = verifyDocument(doc, opts); return { file, kind: r.kind, failures: r.failures }; } function collectJsonFiles(target) { const st = fs.statSync(target); if (st.isDirectory()) { return fs.readdirSync(target) .filter((n) => n.endsWith(".json") && !n.startsWith(".")) .sort() .map((n) => path.join(target, n)); } return [target]; } function main(argv) { const args = argv.slice(2); const opts = { kind: "auto" }; const targets = []; for (const a of args) { if (a === "--strict") opts.strict = true; else if (a === "--chain06") opts.kind = "chain06"; else if (a === "--chain") opts.kind = "chain"; else if (a === "--workflow") opts.kind = "workflow"; else if (a === "--receipt") opts.kind = "receipt"; else if (a === "--profile") opts.profile = true; else if (a === "--anchor") opts.anchor = true; else if (a === "--json") opts.json = true; else targets.push(a); } if (!targets.length) { console.error("usage: node verify.js [--strict] [--chain|--chain06|--workflow|--receipt] [--profile] [--anchor] [--json]"); return 2; } const all = []; for (const t of targets) { for (const f of collectJsonFiles(t)) all.push(f); } let bad = 0; const report = []; for (const f of all) { const r = verifyFile(f, opts); const ok = r.failures.length === 0; if (!ok) bad++; report.push({ file: f, kind: r.kind, ok, failures: r.failures }); if (opts.json) continue; const label = "[" + r.kind + "]"; if (ok) { console.log("PASS " + label + " " + f); } else { console.log("FAIL " + label + " " + f + " — " + r.failures[0].reason + (r.failures.length > 1 ? " (+" + (r.failures.length - 1) + " more)" : "")); } } if (opts.json) { console.log(JSON.stringify(report, null, 1)); } else { console.log("\n" + (all.length - bad) + "/" + all.length + " verified"); } return bad === 0 ? 0 : 1; } if (require.main === module) { process.exitCode = main(process.argv); } module.exports = { checkReceipt, checkReferenceProfile, checkDisinterestedTier, anchorUsesTierShape, verifyChainV07, verifyChainV06, entryDigestV07, finalEntryDigestV07, checkFinalEntryCommitment, verifyChainCommitment, merkleRoot, verifyWorkflowSteps, checkWorkflow, canonicalJson, verifyDocument, verifyFile, decodeBase64Strict, isRfc3339, };