From 65762a6331d5900815dbba7508f90a70d3e1aa3a Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:52:20 -0700 Subject: [PATCH] feat(spec): independent JavaScript verifier for korg-ledger@v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add spec/korg-ledger-v1/js/ — a dependency-free, isomorphic (Node + browser via Web Crypto) verifier plus a conformance harness mirroring conformance.py. It reproduces every frozen tip hash byte-for-byte (including the non-ASCII and astral surrogate-pair canonicalization edge cases) and cross-validates against the Rust korg-verify crate on a real Ed25519-signed receipt: Python mints the signature, Rust and JavaScript both re-verify it. This makes the 'three genuinely independent implementations' claim TRUE rather than aspirational — Python (conformance.py) + JavaScript (js/verify.mjs) + Rust (korg-verify): three languages, three codepaths, one shared frozen oracle. Update the spec README/SPEC to state that honestly: drop the folded 'thumper' impl and the ad-hoc launch-site JS, list the three tested/reproducible implementations with their one-command conformance checks. npm-ready as @korgg/ledger-verify (npx korg-verify-js ). --- spec/korg-ledger-v1/README.md | 42 ++- spec/korg-ledger-v1/SPEC.md | 6 +- spec/korg-ledger-v1/js/README.md | 63 +++++ spec/korg-ledger-v1/js/bin.mjs | 5 + spec/korg-ledger-v1/js/conformance.mjs | 76 ++++++ spec/korg-ledger-v1/js/package.json | 41 +++ spec/korg-ledger-v1/js/verify.mjs | 338 +++++++++++++++++++++++++ 7 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 spec/korg-ledger-v1/js/README.md create mode 100644 spec/korg-ledger-v1/js/bin.mjs create mode 100644 spec/korg-ledger-v1/js/conformance.mjs create mode 100644 spec/korg-ledger-v1/js/package.json create mode 100644 spec/korg-ledger-v1/js/verify.mjs diff --git a/spec/korg-ledger-v1/README.md b/spec/korg-ledger-v1/README.md index 2289597..604beac 100644 --- a/spec/korg-ledger-v1/README.md +++ b/spec/korg-ledger-v1/README.md @@ -19,7 +19,8 @@ the key), not merely tamper-evident. |---|---| | [`SPEC.md`](./SPEC.md) | the normative specification (canonicalization, preimage, chaining, HMAC, verify + DAG algorithms) | | [`vectors/`](./vectors/) + [`conformance.json`](./conformance.json) | the golden conformance vectors with **frozen tip hashes** — the cross-language oracle | -| [`conformance.py`](./conformance.py) | a dependency-free reference verifier (the executable oracle) | +| [`conformance.py`](./conformance.py) | a dependency-free Python reference verifier (the executable oracle) | +| [`js/`](./js/) | a dependency-free JavaScript verifier (`verify.mjs`) + its conformance harness, for Node and the browser | ## Conformance @@ -31,22 +32,35 @@ An implementation in any language is **conformant** iff, given the vectors in 2. flags each tampered vector at the named `seq`; 3. fails an HMAC vector verified without the key. -Run the reference: `python3 conformance.py` (exit 0 = conformant). +Run a reference — each is one command, exit 0 = conformant: -## Conformant implementations - -Four independent implementations reproduce the frozen tips — the spec is real -and multi-language, not one app's detail: +```sh +python3 conformance.py # Python +node js/conformance.mjs # JavaScript +cargo test -p korg-verify # Rust (from the korg workspace root) +``` -| Implementation | Language | Where | -|---|---|---| -| **korg-registry** | Rust | `korg/crates/korg-registry/src/ledger_chain.rs` — chains every `CapabilityJournal` event on append | -| **korgex** | Python | `korgex/src/ledger_spec.py` — `korgex verify` over agent journals | -| **thumper** | Rust | `thumper/src/ledger/chain.rs` — chains every self-heal recovery session | -| **Ledger Explorer** | JavaScript | the launch site — recomputes the chain in-browser; tamper a journal and watch it break | +## Conformant implementations -The PyO3 `korg-bridge` writes through the chained `korg-registry` journal, so -any Python caller (korgex, KorgChat) inherits a conformant journal for free. +**Three genuinely independent implementations** — three languages, three separate +codepaths written from this spec — reproduce the frozen tips. That is what makes +the spec real and multi-language rather than one app's internal detail: + +| Implementation | Language | Where | Conformance | +|---|---|---|---| +| **Python reference** | Python | [`conformance.py`](./conformance.py) — dependency-free, stdlib only | `python3 conformance.py` | +| **JavaScript** | JavaScript | [`js/verify.mjs`](./js/verify.mjs) — dependency-free, Web Crypto, Node + browser | `node js/conformance.mjs` | +| **Rust** | Rust | [`korg-verify`](../../crates/korg-verify) (`cargo install korg-verify`), built on the publishable [`korg-ledger`](../../crates/korg-ledger) crate | `cargo test -p korg-verify` | + +Each reproduces every intact vector's frozen `tip_entry_hash` and flags every +tampered vector — so a green check on one is corroborated by two independent +others. The same Ed25519-signed receipt verifies under all three: Python mints +the signature, Rust and JavaScript re-verify it. + +The repo's writers conform by construction: the Rust core +(`crates/korg-registry`, `crates/korg-ledger`) chains every `CapabilityJournal` +event on append, and the PyO3 `korg-bridge` writes through it, so any Python +caller (korgex, KorgChat) inherits a conformant journal for free. ## Implementing it (5 steps) diff --git a/spec/korg-ledger-v1/SPEC.md b/spec/korg-ledger-v1/SPEC.md index fb0d510..c3b2f83 100644 --- a/spec/korg-ledger-v1/SPEC.md +++ b/spec/korg-ledger-v1/SPEC.md @@ -103,8 +103,10 @@ truncation sound (cutting at seq N never orphans a survivor). - the HMAC vector uses key `"korg-conformance-key"`; verifying it with no key MUST fail. -Run the reference harness: `python3 spec/korg-ledger-v1/conformance.py` -(exit 0 = conformant). Regenerate vectors: `python3 spec/korg-ledger-v1/_generate_vectors.py`. +Run a conformance harness (exit 0 = conformant): `python3 spec/korg-ledger-v1/conformance.py` +(Python), `node spec/korg-ledger-v1/js/conformance.mjs` (JavaScript), or +`cargo test -p korg-verify` (Rust). Regenerate vectors: +`python3 spec/korg-ledger-v1/_generate_vectors.py`. ## 7. v1 scope / non-goals diff --git a/spec/korg-ledger-v1/js/README.md b/spec/korg-ledger-v1/js/README.md new file mode 100644 index 0000000..b8c2bf3 --- /dev/null +++ b/spec/korg-ledger-v1/js/README.md @@ -0,0 +1,63 @@ +# @korgg/ledger-verify + +The **JavaScript** implementation of **korg-ledger@v1** — one of three independent +verifiers (alongside the Python reference and the Rust `korg-verify` crate), each +written from [the spec](../SPEC.md) and checked against the same frozen +[conformance vectors](../vectors/). Tamper one byte and all three reject it. + +- **Zero dependencies.** Uses only the Web Crypto standard (`crypto.subtle`). +- **Isomorphic.** The same `verify.mjs` runs in Node (≥18) and the browser. +- **No network, no trust in the producing tool.** A receipt verifies (or doesn't) + from its bytes alone. + +## CLI + +```sh +npx @korgg/ledger-verify [--key ] [--pubkey ] [--json] +# or, from a checkout: +node verify.mjs deliverable.korgreceipt.json +``` + +Exit code: `0` valid · `1` invalid/tampered · `2` usage/parse error. + +``` +✓ receipt VALID — 6 events, hash-chain + DAG intact · signed by b251a84c2d23d318… +``` + +`--pubkey ` *pins* the expected signer and rejects any other key, so a green +check proves authorship against a key you already trust — not merely against the +one the receipt happens to carry. + +## Library + +```js +import { verifyText, verifyChain, canonicalize } from "@korgg/ledger-verify"; + +const verdict = await verifyText(receiptText, { pinPubkey: "b251a84c…" }); +verdict.valid; // boolean +``` + +In the browser, import the same module and pass the receipt text — Web Crypto does +the SHA-256 / HMAC / Ed25519. (Ed25519 in `crypto.subtle` requires a recent +runtime: Node ≥18.4 and current Chrome/Safari/Firefox; the chain + DAG checks work +everywhere.) + +## What a green verdict proves + +The recorded events hash-chain intact and link in a well-formed causal DAG +(tamper-evident), the receipt's tip matches the chain head, and — if signed — the +holder of the named key attests to that exact tip. It does **not** prove *when* it +happened (needs an external time anchor) or that the key maps to a real-world +identity (the relying party pins that with `--pubkey`). + +## Conformance + +```sh +npm test # node conformance.mjs — reproduces the frozen tip hashes; exit 0 = conformant +``` + +This is the executable oracle: an intact vector must reproduce its frozen +`tip_entry_hash`, a tampered vector must be flagged at the named `seq`. The same +manifest ([`../conformance.json`](../conformance.json)) drives the Python +([`../conformance.py`](../conformance.py)) and Rust +([`../../../crates/korg-verify`](../../../crates/korg-verify)) implementations. diff --git a/spec/korg-ledger-v1/js/bin.mjs b/spec/korg-ledger-v1/js/bin.mjs new file mode 100644 index 0000000..02a41db --- /dev/null +++ b/spec/korg-ledger-v1/js/bin.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node +// Thin CLI entrypoint for `npx @korgg/ledger-verify`. Kept separate from +// verify.mjs so that module stays shebang-free and browser-importable. +import { cli } from "./verify.mjs"; +process.exit(await cli(process.argv.slice(2))); diff --git a/spec/korg-ledger-v1/js/conformance.mjs b/spec/korg-ledger-v1/js/conformance.mjs new file mode 100644 index 0000000..9ad1888 --- /dev/null +++ b/spec/korg-ledger-v1/js/conformance.mjs @@ -0,0 +1,76 @@ +// korg-ledger@v1 — JS conformance harness (the executable oracle for verify.mjs). +// +// Mirrors ../conformance.py: reads the same ../conformance.json manifest and the +// same frozen ../vectors/, and is conformant iff it reproduces every intact +// vector's tip_entry_hash and flags every tampered vector. Plus a handful of +// canonicalization edge-case assertions (the place JS and Python most easily +// diverge: non-ASCII and astral-plane escaping). +// +// node conformance.mjs # exit 0 = this JS impl reproduces the vectors + +import { readFileSync } from "node:fs"; +import { canonicalize, chainHash, verifyChain } from "./verify.mjs"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const here = (p) => new URL(p, import.meta.url); + +function read(name) { + const text = readFileSync(here(`../vectors/${name}`), "utf8"); + return text + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l)); +} + +function assertCanon(value, expected, label) { + const got = dec.decode(canonicalize(value)); + const ok = got === expected; + console.log(` [${ok ? "PASS" : "FAIL"}] canon ${label.padEnd(20)} ${ok ? "" : `got ${got} want ${expected}`}`); + return ok ? 0 : 1; +} + +async function run() { + let failures = 0; + + // §2 canonicalization edge cases (match Python json.dumps(ensure_ascii=True) / Rust). + failures += assertCanon({ z: [3, 2], a: { y: 1, x: 2 } }, '{"a":{"x":2,"y":1},"z":[3,2]}', "sorted+compact"); + failures += assertCanon({ a: "é" }, '{"a":"\\u00e9"}', "non-ascii"); + failures += assertCanon({ a: "𝄞" }, '{"a":"\\ud834\\udd1e"}', "astral surrogate-pair"); + + // The frozen vectors — the cross-impl oracle. + const manifest = JSON.parse(readFileSync(here("../conformance.json"), "utf8")); + if (manifest.spec_version !== "korg-ledger@v1") throw new Error("unexpected spec_version"); + + for (const v of manifest.vectors) { + const events = read(v.file); + const key = v.key ? enc.encode(v.key) : null; + const errors = await verifyChain(events, key); + let ok = true; + let detail = ""; + if (v.verify === "intact") { + if (errors.length) { + ok = false; + detail = `expected intact, got ${JSON.stringify(errors)}`; + } else if ((await chainHash(events[events.length - 1], key)) !== v.tip_entry_hash) { + ok = false; + detail = "tip_entry_hash not reproduced"; + } + } else { + if (!errors.length) { + ok = false; + detail = "expected tampered, verified clean"; + } else if (!errors.some((e) => e.includes(v.error_contains))) { + ok = false; + detail = `errors ${JSON.stringify(errors)} missing ${JSON.stringify(v.error_contains)}`; + } + } + console.log(` [${ok ? "PASS" : "FAIL"}] ${v.file.padEnd(26)} ${v.verify.padEnd(8)} ${detail}`); + if (!ok) failures++; + } + + console.log(`\nkorg-ledger@v1 conformance (js): ${failures ? `${failures} FAILURE(S)` : "PASS"}`); + return failures ? 1 : 0; +} + +process.exit(await run()); diff --git a/spec/korg-ledger-v1/js/package.json b/spec/korg-ledger-v1/js/package.json new file mode 100644 index 0000000..62889fe --- /dev/null +++ b/spec/korg-ledger-v1/js/package.json @@ -0,0 +1,41 @@ +{ + "name": "@korgg/ledger-verify", + "version": "0.1.0", + "description": "Independent, dependency-free verifier for korg-ledger@v1 receipts and journals (hash chain + causal DAG + Ed25519 tip signature). Runs in Node and the browser via Web Crypto.", + "type": "module", + "main": "verify.mjs", + "exports": { + ".": "./verify.mjs" + }, + "bin": { + "korg-verify-js": "bin.mjs" + }, + "files": [ + "verify.mjs", + "bin.mjs", + "README.md" + ], + "scripts": { + "test": "node conformance.mjs" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "korg", + "korg-ledger", + "tamper-evident", + "hash-chain", + "verification", + "ed25519", + "audit", + "provenance" + ], + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/New1Direction/korg", + "directory": "spec/korg-ledger-v1/js" + }, + "homepage": "https://github.com/New1Direction/korg/tree/main/spec/korg-ledger-v1" +} diff --git a/spec/korg-ledger-v1/js/verify.mjs b/spec/korg-ledger-v1/js/verify.mjs new file mode 100644 index 0000000..07c10bd --- /dev/null +++ b/spec/korg-ledger-v1/js/verify.mjs @@ -0,0 +1,338 @@ +// korg-ledger@v1 — independent JavaScript verifier (the third implementation). +// +// Dependency-free and isomorphic: runs in Node (>=18) and the browser using only +// the Web Crypto standard (`globalThis.crypto.subtle`) — no npm packages, no +// build step. It reproduces the spec's canonicalization + hash-chain from scratch +// (canonical text: ../SPEC.md) and is conformant iff it reproduces the frozen +// tip hashes in ../conformance.json byte-for-byte. The Rust core (korg-registry / +// korg-verify) and the Python reference (../conformance.py) reproduce the same +// vectors — three independent codepaths, one shared oracle. +// +// CLI: node verify.mjs [--key ] [--pubkey ] [--json] +// exit 0 = valid · 1 = invalid/tampered · 2 = usage/parse error + +const GENESIS = "0".repeat(64); +const HASH_FIELDS = ["entry_hash"]; // fields excluded from the hash preimage + +const subtle = globalThis.crypto && globalThis.crypto.subtle; +const enc = new TextEncoder(); + +// ── §2 Canonicalization ───────────────────────────────────────────────────── +// JSON, object keys sorted by code point, no insignificant whitespace, non-ASCII +// (and anything outside printable 0x20..0x7e) escaped as lowercase \uXXXX. Output +// is pure ASCII, so there is no UTF-8 encoding ambiguity across languages. + +function canonicalJsonString(s) { + let out = '"'; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); // UTF-16 code unit (surrogate pairs → two \uXXXX, matching Python ensure_ascii) + switch (c) { + case 0x22: out += '\\"'; break; + case 0x5c: out += "\\\\"; break; + case 0x0a: out += "\\n"; break; + case 0x0d: out += "\\r"; break; + case 0x09: out += "\\t"; break; + case 0x08: out += "\\b"; break; + case 0x0c: out += "\\f"; break; + default: + if (c >= 0x20 && c <= 0x7e) out += s[i]; + else out += "\\u" + c.toString(16).padStart(4, "0"); + } + } + return out + '"'; +} + +function canonicalString(value) { + if (value === null) return "null"; + if (value === true) return "true"; + if (value === false) return "false"; + const t = typeof value; + if (t === "number") { + if (!Number.isInteger(value)) { + throw new Error(`floats are out of korg-ledger@v1 canonicalization scope: ${value}`); + } + return String(value); + } + if (t === "string") return canonicalJsonString(value); + if (Array.isArray(value)) return "[" + value.map(canonicalString).join(",") + "]"; + if (t === "object") { + // Default sort is by UTF-16 code unit, which equals code-point order for the + // BMP identifier keys korg emits (matches Python sort_keys / Rust keys.sort()). + const keys = Object.keys(value).sort(); + return "{" + keys.map((k) => canonicalJsonString(k) + ":" + canonicalString(value[k])).join(",") + "}"; + } + throw new Error(`unsupported JSON value of type ${t}`); +} + +/** Canonical byte encoding of a JSON value (pure ASCII). */ +export function canonicalize(value) { + return enc.encode(canonicalString(value)); +} + +function toHex(buf) { + const b = new Uint8Array(buf); + let s = ""; + for (const x of b) s += x.toString(16).padStart(2, "0"); + return s; +} + +function hexToBytes(hex) { + if (typeof hex !== "string" || hex.length % 2 !== 0) return null; + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + const v = parseInt(hex.substr(i * 2, 2), 16); + if (Number.isNaN(v)) return null; + out[i] = v; + } + return out; +} + +// ── §3 entry_hash ──────────────────────────────────────────────────────────── +// preimage = canonicalize(event minus entry_hash); SHA-256, or HMAC-SHA256 when a +// key is present. `prev_hash` is kept in the preimage — that is the chain link. + +/** @param {object} event @param {Uint8Array|null} keyBytes @returns {Promise} lowercase hex */ +export async function chainHash(event, keyBytes = null) { + const obj = { ...event }; + for (const f of HASH_FIELDS) delete obj[f]; + const data = canonicalize(obj); + if (keyBytes != null) { + const key = await subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return toHex(await subtle.sign("HMAC", key, data)); + } + return toHex(await subtle.digest("SHA-256", data)); +} + +// ── §5 Verification ────────────────────────────────────────────────────────── + +/** Recompute the hash-chain. Returns [] iff intact; each error names a seq_id. */ +export async function verifyChain(events, keyBytes = null) { + const errors = []; + let expectedPrev = GENESIS; + for (const e of events) { + const sid = e.seq_id; + const stored = e.entry_hash; + if (stored == null) { + errors.push(`seq ${sid}: missing entry_hash (event is not chained)`); + expectedPrev = null; + continue; + } + if (e.prev_hash !== expectedPrev) { + errors.push(`seq ${sid}: prev_hash breaks the chain (an event was inserted, deleted, or reordered)`); + } + if ((await chainHash(e, keyBytes)) !== stored) { + errors.push(`seq ${sid}: entry_hash mismatch (content was tampered)`); + } + expectedPrev = stored; + } + return errors; +} + +/** Check the causal DAG: unique seq_ids and strictly-earlier triggered_by links. */ +export function verifyDag(events) { + const errors = []; + const seqs = events.map((e) => e.seq_id).filter((s) => typeof s === "number"); + const seqset = new Set(seqs); + if (seqset.size !== seqs.length) errors.push("duplicate seq_id present"); + for (const e of events) { + const tb = e.triggered_by; + if (typeof tb !== "number") continue; + const sid = e.seq_id; + if (!seqset.has(tb)) errors.push(`seq ${sid}: triggered_by ${tb} does not exist`); + else if (typeof sid === "number" && tb >= sid) { + errors.push(`seq ${sid}: triggered_by ${tb} is not strictly earlier`); + } + } + return errors; +} + +/** + * Verify an Ed25519 signature over the RAW tip-hash bytes (the 32 decoded bytes, + * not the hex string) — matching the Rust verifier and `sign_tip`. Any malformed + * input or unsupported algorithm returns false rather than throwing. + */ +export async function verifyTipSig(pubkeyHex, tipHex, sigHex) { + try { + const pk = hexToBytes(pubkeyHex); + const msg = hexToBytes(tipHex); + const sig = hexToBytes(sigHex); + if (!pk || !msg || !sig || pk.length !== 32 || sig.length !== 64) return false; + const key = await subtle.importKey("raw", pk, { name: "Ed25519" }, false, ["verify"]); + return await subtle.verify({ name: "Ed25519" }, key, sig, msg); + } catch { + return false; + } +} + +/** Verify a list of events as a journal: hash chain + causal DAG. */ +export async function verifyJournal(events, keyBytes = null) { + const errors = await verifyChain(events, keyBytes); + const dag = verifyDag(events); + const chainOk = errors.length === 0; + const dagOk = dag.length === 0; + return { + valid: chainOk && dagOk, + kind: "journal", + event_count: events.length, + chain_ok: chainOk, + dag_ok: dagOk, + tip_ok: true, + signature_ok: null, + signer: null, + errors: errors.concat(dag), + }; +} + +/** + * Verify a receipt object: embedded events (chain + DAG), the recorded tip matches + * the chain head, and — if signed — the Ed25519 signature is valid for that tip. + * `pinPubkey` requires the signer to equal a key the relying party already trusts. + */ +export async function verifyReceipt(receipt, { key = null, pinPubkey = null } = {}) { + const events = Array.isArray(receipt.events) ? receipt.events : []; + const errors = await verifyChain(events, key); + const dag = verifyDag(events); + const chainOk = errors.length === 0; + const dagOk = dag.length === 0; + for (const e of dag) errors.push(e); + + const claimedTip = typeof receipt.tip === "string" ? receipt.tip : null; + const head = events.length ? events[events.length - 1].entry_hash : null; + let tipOk; + if (claimedTip == null) tipOk = true; + else if (head == null) tipOk = false; + else tipOk = claimedTip === head; + if (!tipOk) errors.push("recorded tip does not match the chain head"); + + let signatureOk = null; + let signer = null; + if (receipt.signature) { + const pubkey = receipt.signature.pubkey || ""; + const sigHex = receipt.signature.sig || ""; + let ok = await verifyTipSig(pubkey, claimedTip || "", sigHex); + signer = pubkey; + if (!ok) errors.push("signature does not verify for the recorded tip"); + if (pinPubkey != null && pinPubkey !== pubkey) { + ok = false; + errors.push(`signer ${pubkey} does not match the pinned key ${pinPubkey}`); + } + signatureOk = ok; + } else if (pinPubkey != null) { + signatureOk = false; + errors.push(`receipt is unsigned but signer ${pinPubkey} was required`); + } + + const valid = chainOk && dagOk && tipOk && signatureOk !== false; + return { + valid, + kind: "receipt", + event_count: events.length, + chain_ok: chainOk, + dag_ok: dagOk, + tip_ok: tipOk, + signature_ok: signatureOk, + signer, + errors, + }; +} + +/** Parse a journal from a JSON array or JSON Lines. */ +export function loadEvents(text) { + const trimmed = text.trimStart(); + if (trimmed.startsWith("[")) return JSON.parse(text); + const out = []; + text.split("\n").forEach((line, i) => { + const t = line.trim(); + if (!t) return; + try { + out.push(JSON.parse(t)); + } catch (e) { + throw new Error(`line ${i + 1}: ${e.message}`); + } + }); + return out; +} + +/** Auto-detect a receipt vs a journal and verify accordingly. */ +export async function verifyText(text, { key = null, pinPubkey = null } = {}) { + const trimmed = text.trimStart(); + if (trimmed.startsWith("{")) { + let v; + try { + v = JSON.parse(text); + } catch { + v = null; + } + if (v && typeof v === "object" && !Array.isArray(v)) { + const isReceipt = + v.events !== undefined || (typeof v.schema === "string" && v.schema.startsWith("korgex-receipt")); + if (isReceipt) return verifyReceipt(v, { key, pinPubkey }); + return verifyJournal([v], key); + } + } + return verifyJournal(loadEvents(text), key); +} + +// ── CLI (Node only) ────────────────────────────────────────────────────────── + +export async function cli(argv) { + const { readFileSync } = await import("node:fs"); + let file = null; + let key = null; + let pin = null; + let jsonOut = false; + const HELP = + "korg-verify (js) — verify a korg receipt or journal (hash chain + causal DAG + Ed25519)\n\n" + + "USAGE:\n node verify.mjs [--key ] [--pubkey ] [--json]\n\n" + + "EXIT: 0 VALID 1 INVALID/tampered 2 usage/parse error\n"; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--key") key = enc.encode(argv[++i] ?? ""); + else if (a === "--pubkey") pin = argv[++i] ?? null; + else if (a === "--json") jsonOut = true; + else if (a === "-h" || a === "--help") { + process.stdout.write(HELP); + return 2; + } else if (!a.startsWith("-") && file === null) file = a; + else { + process.stderr.write(`unknown argument: ${a}\n\n${HELP}`); + return 2; + } + } + if (!file) { + process.stderr.write(HELP); + return 2; + } + let text; + try { + text = readFileSync(file, "utf8"); + } catch (e) { + process.stderr.write(`cannot read ${file}: ${e.message}\n`); + return 2; + } + let v; + try { + v = await verifyText(text, { key, pinPubkey: pin }); + } catch (e) { + process.stderr.write(`parse error: ${e.message}\n`); + return 2; + } + if (jsonOut) { + process.stdout.write(JSON.stringify(v) + "\n"); + } else if (v.valid) { + const signed = v.signer && v.signature_ok ? ` · signed by ${v.signer.slice(0, 16)}…` : ""; + process.stdout.write(` ✓ ${v.kind} VALID — ${v.event_count} events, hash-chain + DAG intact${signed}\n ${file}\n`); + } else { + process.stdout.write(` ✗ ${v.kind} INVALID — ${v.errors.length} problem(s):\n`); + for (const e of v.errors.slice(0, 8)) process.stdout.write(` - ${e}\n`); + } + return v.valid ? 0 : 1; +} + +// Run the CLI only when invoked directly as a Node script (browser/import-safe). +if (typeof process !== "undefined" && process.argv?.[1]) { + const { pathToFileURL } = await import("node:url"); + if (import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(await cli(process.argv.slice(2))); + } +}