magenta-canon 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ MCP host (Claude Code, Claude Desktop, Cursor, …) and downstream MCP tools, an
19
19
 
20
20
  ```bash
21
21
  npx magenta-canon demo # full local proof loop: allow · block · witness · verify · tamper
22
- npx magenta-canon verify --self-test # verifier self-check: one VERIFIED pass and one tamper FAIL
22
+ npx magenta-canon verify --self-test # verifier self-check: all verdict levels incl. a tamper FAIL
23
23
  npx magenta-canon gateway <config.json> # the stdio MCP capability gateway
24
24
  npx magenta-canon mirror --self-test # external STH mirror: catch operator history rewrites
25
25
  ```
@@ -161,12 +161,35 @@ DOWNSTREAM_LOG=/tmp/downstream-calls.log \
161
161
 
162
162
  # 3. publish evidence and verify it independently
163
163
  curl -s :5000/api/trust/evidence > bundle.json
164
- npx magenta-canon verify bundle.json # RESULT: VERIFIED
164
+ npx magenta-canon verify bundle.json --expected-witness-key witness.pub
165
+ # → RESULT: ORIGIN AND INTEGRITY VERIFIED
166
+ # without --expected-witness-key the honest maximum is: RESULT: INTEGRITY VERIFIED
167
+ # (the bundle can vouch for its math, not for who produced it)
165
168
  ```
166
169
  </details>
167
170
 
168
171
  Full walkthrough: [`docs/MCP_GATEWAY.md`](docs/MCP_GATEWAY.md).
169
172
 
173
+ ### Durable evidence (file ledger) — new in 0.4.0
174
+
175
+ By default evidence lives in memory (the demo's fresh-universe behavior). For
176
+ evidence that **survives a process restart**, point the gateway/control plane
177
+ at an append-only file ledger and pin the witness identity:
178
+
179
+ ```bash
180
+ MAGENTA_LEDGER_FILE=/var/lib/magenta/ledger.jsonl \
181
+ MAGENTA_WITNESS_SECRET=<hex> MAGENTA_WITNESS_PUBLIC=<hex> \
182
+ <your magenta process>
183
+ ```
184
+
185
+ Every receipt and signed tree head is fsync-committed before it is reported
186
+ recorded; on restart the ledger is strictly replayed (chain, issuer
187
+ signatures, Merkle roots, checkpoint history) or startup fails loudly. One
188
+ exclusive writer per ledger; corruption is refused, never repaired silently.
189
+ This is single-writer local/reference durability — see
190
+ [`docs/FILE_LEDGER.md`](docs/FILE_LEDGER.md) for the format, lock recovery,
191
+ crash-tail policy, backup guidance, and limitations.
192
+
170
193
  ### From the repo vs. as a CLI
171
194
 
172
195
  - **From a repo checkout** (above): `npm install` then `npm run demo`.
@@ -222,11 +245,14 @@ REFUSED_CALL isError=true :: Blocked by Magenta capability gate: exceeds delega
222
245
  {"name":"refund","args":{"amount_cents":8900,"order_id":"4471"}} ← only the allowed call
223
246
  # the blocked $250 refund is ABSENT — it never reached the tool.
224
247
 
225
- $ npx magenta-canon verify bundle.json
248
+ $ npx magenta-canon verify bundle.json --expected-witness-key witness.pub
249
+ witness-key source: INDEPENDENT (supplied by caller)
226
250
  [PASS] STH signature verifies
251
+ [PASS] STH witness key matches INDEPENDENTLY supplied key
227
252
  [PASS] receipt chain intact
253
+ [PASS] receipt issuer signatures verify (2/2)
228
254
  [PASS] recomputed Merkle root == signed STH root
229
- RESULT: VERIFIED (exit code 0)
255
+ RESULT: ORIGIN AND INTEGRITY VERIFIED (exit code 0)
230
256
  ```
231
257
 
232
258
  ## Security model
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "package.json": "8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a",
3
- "scripts/demo-control-plane.js": "3357c8de8e3fe32e73e0be99a3bb02179dff3ee041854d4ee6f8d135e4d8dcbf",
3
+ "scripts/demo-control-plane.js": "6d58bd7e816029b3c23b8c3b5f8812a5bd8ae1f4b5d6a70851eeaf6cb577dbc1",
4
4
  "scripts/intake-cli.js": "189014db22d6fccb034ed1a93ec11efe8905be820bc468366c68bb2cabaf8a97",
5
5
  "scripts/magenta-mirror.js": "cf701065f7a6e20f7f44a9b815396aeb5fe031c3f003bcd793b8014ea8801b85",
6
- "scripts/magenta-verify.js": "3c41d176a435ae8f53dd49c2418f63be5ad82de7e26fbe9f436a6545f521d331",
6
+ "scripts/magenta-verify.js": "f79c60944bef65926530c2d0fd5cc35df10319c5831764b035e547b6bfebe715",
7
7
  "scripts/mcp-gateway.js": "335923004b73511fe42ea0fc6f2e567afe8018dc95464a79027e4c2537e30de1",
8
8
  "server/agent-policy.js": "20dd9150f8244c33aaf14ecdf3a09f471210960d246cda9ab8d5abe0e8433518",
9
9
  "server/agent-record.js": "790bbfa2a10601c2bdcd98873e6e41babdf96d1df7c7ca34f19791de4c8b860f",
10
10
  "server/crypto.js": "ebbcb2929e7b3651e4ec04c9fbf3dcb656e3ae0d30bf8864f3642f72f3be2f39",
11
11
  "server/execution-receipts.js": "171a506df7d1e99f3370de9a41c1a4f0b21b38d1f02edc78d9c44e68c93dbee4",
12
+ "server/file-ledger-store.js": "92dc58d313be183e53817455ee9fd4ab7cb2f3a171343de66f5db26e0f139d79",
12
13
  "server/intake/categories.js": "e9860eee0e2e0fe96c7ade165e5e068fae0874de2c7d39ff796efc91e713013b",
13
14
  "server/intake/engine.js": "abb8feacd925520cbf01acc2e932d9ebb037a3d016b053b7b10deaf8f545a605",
14
15
  "server/intake/gateway-intake.js": "40514fa489b031c29725e9b837e83998217bbc1ae84cf9259154a16b1faae2e8",
@@ -20,11 +21,13 @@
20
21
  "server/intake/rationale.js": "3ea64a1430828ae5a06b41f4e93d06cdd667850d6a2ff9a1e266f98bdfc6c404",
21
22
  "server/intake/types.js": "014891c6c8360648d6954b3185ae92d56be625484bf2f0d0f71dac271a59e23b",
22
23
  "server/intake/witness.js": "588ad166f4870d65a6ed334c70aaa2deb1d6d96ec97d889902a9eda7528bd34f",
24
+ "server/ledger-store.js": "fc8a46f925908764bac7a076cffa08053af4c1f93237bee408eb0ed1e61eeca2",
25
+ "server/ledger.js": "9bf8f132e08d636070d2ede568b2eaf75810be245c90ecd5979d2d4de69af934",
23
26
  "server/persistence.js": "2e6b1d0b1c74babbd581780b028c2593256c5ec96c2d60f4c25159e3f54e4e3f",
24
- "server/storage.js": "4f1660e9542dcb157148f561efd757d8aa452730c3a09064d3b1314feb16e8c9",
25
- "server/transparency-log.js": "42d0d9fd3de9c60389ca882b546d9784e6ef0003895898dd5958d763d54f2f6b",
27
+ "server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
28
+ "server/transparency-log.js": "826dbae845726853b0736d10546a2c65596357530b82af0d1a781ac368c69a79",
26
29
  "server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
27
- "server/witness.js": "e94384a35a4cfce39990e580eb5b572ef9a2d4c4d9bad1a2ed9b5382aba64b1b",
30
+ "server/witness.js": "1963401588a48817f7e478d3824a4752d8a3f438f277ba4443158abcedfd6a47",
28
31
  "shared/canonical.js": "476cee4b5c5702e8a2a198e79c2639cf8ff84000ffb92f68a04433ed2a2f5484",
29
32
  "shared/certificates.js": "7844e71a38e1b1324586c7d054b2f5c15222b86446318d1e6dea9bab504a4a73",
30
33
  "shared/schema.js": "f89190990e3826e44c722d5e8f5b39fd59b4d3fb9ec047229930a17604e4646c",
@@ -33,6 +33,7 @@ const node_crypto_1 = require("node:crypto");
33
33
  const storage_1 = require("../server/storage");
34
34
  const witness_1 = require("../server/witness");
35
35
  const agent_record_1 = require("../server/agent-record");
36
+ const witness_2 = require("../server/witness");
36
37
  const trust_bootstrap_1 = require("../server/trust-bootstrap");
37
38
  const PORT = Number(process.env.PORT ?? 0);
38
39
  const HOST = "127.0.0.1";
@@ -164,6 +165,13 @@ const server = (0, node_http_1.createServer)(async (req, res) => {
164
165
  sendJson(res, 500, { error: "internal_error", message: e instanceof Error ? e.message : String(e) });
165
166
  }
166
167
  });
168
+ // File-ledger restart support: rebuild the witness's derived leaf view from
169
+ // any receipts the durable store replayed (no-op in the default memory mode).
170
+ void (async () => {
171
+ const restored = await storage_1.storage.getExecutionReceipts();
172
+ if (restored.length > 0)
173
+ (0, witness_2.rebuildWitnessFromReceipts)([...restored].reverse().map((r) => r.receipt_hash));
174
+ })();
167
175
  server.listen(PORT, HOST, () => {
168
176
  const addr = server.address();
169
177
  const port = typeof addr === "object" && addr ? addr.port : PORT;
@@ -18,12 +18,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.verifyBundle = exports.verifyConsistency = exports.verifyReceiptChain = exports.verifySTH = exports.verifyInclusion = exports.merkleRoot = exports.hashLeaf = exports.chainHash = exports.canonicalHash = exports.canonicalize = exports.VERSION = void 0;
21
+ exports.verifyBundle = exports.verifyReceiptIssuerSignature = exports.verifyConsistency = exports.verifyReceiptChain = exports.verifySTH = exports.verifyInclusion = exports.merkleRoot = exports.hashLeaf = exports.chainHash = exports.canonicalHash = exports.canonicalize = exports.VERSION = void 0;
22
22
  const node_crypto_1 = require("node:crypto");
23
23
  const node_fs_1 = require("node:fs");
24
24
  const tweetnacl_1 = __importDefault(require("tweetnacl"));
25
25
  /** Verifier version — emitted in output so a verification run is self-describing. */
26
- exports.VERSION = "magenta-verify/1.0.0 (spec v1)";
26
+ exports.VERSION = "magenta-verify/1.1.0 (spec v1.1)";
27
27
  // ── §1 primitives ──────────────────────────────────────────────────────────
28
28
  const utf8 = (s) => Buffer.from(s, "utf8");
29
29
  const fromHex = (h) => Buffer.from(h, "hex");
@@ -109,17 +109,79 @@ function verifyConsistency(allLeaves, older, newer) {
109
109
  merkleRoot(allLeaves.slice(0, newer.tree_size)) === newer.root_hash);
110
110
  }
111
111
  exports.verifyConsistency = verifyConsistency;
112
- function verifyBundle(b) {
112
+ const HEX64 = /^[0-9a-f]{64}$/;
113
+ /** Receipt issuer signature (spec §2): Ed25519 over the canonical receipt body
114
+ * (all fields except receipt_signature), message = UTF-8 of the hex hash. */
115
+ function verifyReceiptIssuerSignature(receipt) {
116
+ const { receipt_signature, issuer_pubkey } = receipt;
117
+ if (typeof receipt_signature !== "string" || typeof issuer_pubkey !== "string")
118
+ return false;
119
+ const body = {};
120
+ for (const k of Object.keys(receipt))
121
+ if (k !== "receipt_signature")
122
+ body[k] = receipt[k];
123
+ try {
124
+ return tweetnacl_1.default.sign.detached.verify(utf8((0, exports.canonicalHash)(body)), fromHex(receipt_signature), fromHex(issuer_pubkey));
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ exports.verifyReceiptIssuerSignature = verifyReceiptIssuerSignature;
131
+ function verifyBundle(b, opts = {}) {
113
132
  const checks = [];
133
+ const skipped = [];
134
+ const reasons = [];
114
135
  const add = (name, pass, detail = "") => checks.push({ name, pass, detail });
136
+ const skip = (name, reason) => skipped.push({ name, reason });
137
+ // ── witness-key trust input (§5.6): the expected key must be independent ──
138
+ let witnessKeySource = "none";
139
+ if (opts.expectedWitnessKey !== undefined) {
140
+ witnessKeySource = "cli";
141
+ // Malformed trusted-key input fails closed: a bad key must never silently
142
+ // downgrade to bundle-trust.
143
+ if (!HEX64.test(opts.expectedWitnessKey)) {
144
+ add("expected witness key well-formed", false, "expected key is not 64 lowercase hex chars — failing closed");
145
+ }
146
+ }
147
+ else if (b.sth || b.witness_pubkey) {
148
+ witnessKeySource = "bundle";
149
+ }
115
150
  if (b.sth) {
116
- const expectedKey = b.witness_pubkey ?? b.sth.witness_pubkey;
117
151
  add("STH signature verifies", verifySTH(b.sth), `root ${b.sth.root_hash.slice(0, 16)}… size ${b.sth.tree_size}`);
118
- add("STH bound to expected witness key", b.sth.witness_pubkey === expectedKey, b.sth.witness_pubkey === expectedKey ? "matches mirrored key" : "WITNESS KEY MISMATCH");
152
+ // Internal coherence: the bundle's top-level key must agree with the STH's.
153
+ if (b.witness_pubkey !== undefined) {
154
+ add("bundle witness_pubkey == STH witness key", b.witness_pubkey === b.sth.witness_pubkey, b.witness_pubkey === b.sth.witness_pubkey ? "bundle is internally coherent" : "BUNDLE KEY FIELDS DISAGREE");
155
+ }
156
+ if (witnessKeySource === "cli" && HEX64.test(opts.expectedWitnessKey)) {
157
+ const match = b.sth.witness_pubkey === opts.expectedWitnessKey;
158
+ add("STH witness key matches INDEPENDENTLY supplied key", match, match ? "key supplied via --expected-witness-key matches" : "WITNESS KEY MISMATCH — bundle was NOT signed by the trusted witness");
159
+ }
160
+ else if (witnessKeySource === "bundle") {
161
+ skip("witness key trust", "key taken from the bundle itself — supply --expected-witness-key for origin assurance");
162
+ }
163
+ }
164
+ else if (witnessKeySource === "cli") {
165
+ add("STH present for expected-key comparison", false, "an expected witness key was supplied but the bundle has no STH");
119
166
  }
167
+ const receiptCount = b.receipts?.length ?? 0;
120
168
  if (b.receipts && b.receipts.length) {
121
169
  const chain = verifyReceiptChain(b.receipts);
122
170
  add("receipt chain intact", chain.valid, chain.error ?? `${b.receipts.length} receipts`);
171
+ // §2 issuer signatures: enforced when present; absence is surfaced, never hidden.
172
+ const withSig = b.receipts.filter((r) => r.receipt_signature !== undefined
173
+ || r.issuer_pubkey !== undefined);
174
+ if (withSig.length > 0) {
175
+ const bad = withSig.filter((r) => !verifyReceiptIssuerSignature(r));
176
+ add(`receipt issuer signatures verify (${withSig.length - bad.length}/${withSig.length})`, bad.length === 0, bad.length === 0 ? "each receipt is Ed25519-signed by its issuer over the canonical body"
177
+ : `INVALID issuer signature on ${bad.length} receipt(s)`);
178
+ if (withSig.length < b.receipts.length) {
179
+ add("all receipts carry issuer signatures", false, `${b.receipts.length - withSig.length} receipt(s) missing issuer signature while others are signed`);
180
+ }
181
+ }
182
+ else {
183
+ skip("receipt issuer signatures", "no receipt carries issuer_pubkey/receipt_signature — issuer authentication not established");
184
+ }
123
185
  if (b.sth) {
124
186
  const leaves = b.receipts.map((r) => (0, exports.hashLeaf)(r.receipt_hash));
125
187
  const root = merkleRoot(leaves);
@@ -130,6 +192,18 @@ function verifyBundle(b) {
130
192
  }
131
193
  }
132
194
  }
195
+ // ── caller-required evidence (§5.7): absence of required proof is a FAILURE ──
196
+ if (opts.minReceipts !== undefined) {
197
+ add(`bundle contains >= ${opts.minReceipts} receipt(s)`, receiptCount >= opts.minReceipts, `${receiptCount} present`);
198
+ }
199
+ if (opts.requireReceiptHash !== undefined) {
200
+ const found = (b.receipts ?? []).some((r) => r.receipt_hash === opts.requireReceiptHash);
201
+ add("required receipt_hash present in bundle", found, found ? `receipt ${opts.requireReceiptHash.slice(0, 16)}… is in the verified chain` : "REQUIRED RECEIPT ABSENT");
202
+ }
203
+ if (opts.requireActionHash !== undefined) {
204
+ const found = (b.receipts ?? []).some((r) => r.action_hash === opts.requireActionHash);
205
+ add("required action_hash present in bundle", found, found ? "a receipt commits to the required action" : "REQUIRED ACTION ABSENT");
206
+ }
133
207
  if (b.inclusion) {
134
208
  const inc = verifyInclusion(b.inclusion.leaf_hash, b.inclusion.proof, b.inclusion.root_hash);
135
209
  add(`inclusion proof for leaf #${b.inclusion.index}`, inc, "leaf is provably in the log under the root");
@@ -148,15 +222,127 @@ function verifyBundle(b) {
148
222
  add("reveal target exists", false, `no receipt at index ${b.reveal.index}`);
149
223
  }
150
224
  }
151
- return { checks, allPassed: checks.length > 0 && checks.every((c) => c.pass) };
225
+ const allPassed = checks.length > 0 && checks.every((c) => c.pass);
226
+ // ── verdict assignment (never silently upgraded) ────────────────────────────
227
+ let verdict;
228
+ if (!allPassed) {
229
+ verdict = "VERIFICATION FAILED";
230
+ reasons.push(checks.some((c) => !c.pass) ? "one or more performed checks failed" : "no checks could be performed");
231
+ }
232
+ else if (receiptCount === 0 && !b.inclusion && !opts.allowEmptyLog) {
233
+ verdict = "INCOMPLETE EVIDENCE";
234
+ reasons.push("bundle proves no action: zero receipts and no inclusion proof (use --allow-empty-log for checkpoint-only diagnostics)");
235
+ }
236
+ else if (witnessKeySource === "cli") {
237
+ verdict = "ORIGIN AND INTEGRITY VERIFIED";
238
+ reasons.push("all checks passed and the witness key matched an independently supplied trusted key");
239
+ }
240
+ else {
241
+ verdict = "INTEGRITY VERIFIED";
242
+ reasons.push("all checks passed, but the witness key came from the bundle itself — a forger with a fresh key can fabricate a self-consistent history; supply --expected-witness-key (or corroborate via an external mirror) for origin assurance");
243
+ if (receiptCount === 0 && opts.allowEmptyLog) {
244
+ reasons.push("EMPTY LOG accepted in explicit diagnostic mode (--allow-empty-log): this verifies a checkpoint, not any action");
245
+ }
246
+ }
247
+ if (witnessKeySource === "cli" && verdict === "ORIGIN AND INTEGRITY VERIFIED" && receiptCount === 0 && opts.allowEmptyLog) {
248
+ reasons.push("EMPTY LOG accepted in explicit diagnostic mode (--allow-empty-log): this verifies a checkpoint, not any action");
249
+ }
250
+ return { checks, skipped, allPassed, verdict, witnessKeySource, reasons };
152
251
  }
153
252
  exports.verifyBundle = verifyBundle;
154
253
  // ── CLI ───────────────────────────────────────────────────────────────────────
254
+ const USAGE = `usage: magenta-canon verify <bundle.json> [options]
255
+ magenta-canon verify --self-test
256
+
257
+ options:
258
+ --expected-witness-key <file-or-hex> independently trusted witness public key.
259
+ REQUIRED for "ORIGIN AND INTEGRITY VERIFIED";
260
+ without it the best result is
261
+ "INTEGRITY VERIFIED" (bundle-trusted key).
262
+ --require-receipt <hash> fail unless this exact receipt_hash is present
263
+ --require-action-hash <hash> fail unless a receipt commits to this action_hash
264
+ --min-receipts <n> fail unless the bundle has at least n receipts
265
+ --allow-empty-log diagnostic mode: permit zero-receipt checkpoints
266
+ --json machine-readable outcome on stdout
267
+ --version print verifier version
268
+
269
+ exit codes: 0 verified (either level) · 1 verification failed · 2 incomplete evidence / usage
270
+ `;
155
271
  function printResult(r) {
156
272
  console.log(` verifier: ${exports.VERSION}`);
273
+ console.log(` witness-key source: ${r.witnessKeySource === "cli" ? "INDEPENDENT (supplied by caller)"
274
+ : r.witnessKeySource === "bundle" ? "bundle-provided (NOT independently trusted)" : "none"}`);
157
275
  for (const c of r.checks)
158
276
  console.log(` [${c.pass ? "PASS" : "FAIL"}] ${c.name}${c.detail ? ` — ${c.detail}` : ""}`);
159
- console.log(`\n RESULT: ${r.allPassed ? "VERIFIED" : "VERIFICATION FAILED"}`);
277
+ for (const s of r.skipped)
278
+ console.log(` [SKIP] ${s.name} — ${s.reason}`);
279
+ for (const reason of r.reasons)
280
+ console.log(` note: ${reason}`);
281
+ console.log(`\n RESULT: ${r.verdict}`);
282
+ }
283
+ const exitCodeFor = (v) => v === "VERIFICATION FAILED" ? 1 : v === "INCOMPLETE EVIDENCE" ? 2 : 0;
284
+ /** Accept a 64-hex key directly or a path to a file containing one. Fails closed. */
285
+ function loadExpectedKey(arg) {
286
+ const raw = (() => {
287
+ try {
288
+ return (0, node_fs_1.readFileSync)(arg, "utf8");
289
+ }
290
+ catch {
291
+ return arg;
292
+ }
293
+ })();
294
+ return raw.trim().toLowerCase();
295
+ }
296
+ function parseCliArgs(argv) {
297
+ const opts = {};
298
+ let bundlePath;
299
+ let json = false;
300
+ for (let i = 0; i < argv.length; i++) {
301
+ const a = argv[i];
302
+ switch (a) {
303
+ case "--expected-witness-key": {
304
+ const v = argv[++i];
305
+ if (!v)
306
+ return { error: "--expected-witness-key requires a value" };
307
+ opts.expectedWitnessKey = loadExpectedKey(v);
308
+ break;
309
+ }
310
+ case "--require-receipt": {
311
+ const v = argv[++i];
312
+ if (!v)
313
+ return { error: "--require-receipt requires a value" };
314
+ opts.requireReceiptHash = v.toLowerCase();
315
+ break;
316
+ }
317
+ case "--require-action-hash": {
318
+ const v = argv[++i];
319
+ if (!v)
320
+ return { error: "--require-action-hash requires a value" };
321
+ opts.requireActionHash = v.toLowerCase();
322
+ break;
323
+ }
324
+ case "--min-receipts": {
325
+ const v = Number(argv[++i]);
326
+ if (!Number.isInteger(v) || v < 0)
327
+ return { error: "--min-receipts requires a non-negative integer" };
328
+ opts.minReceipts = v;
329
+ break;
330
+ }
331
+ case "--allow-empty-log":
332
+ opts.allowEmptyLog = true;
333
+ break;
334
+ case "--json":
335
+ json = true;
336
+ break;
337
+ default:
338
+ if (a.startsWith("-"))
339
+ return { error: `unknown option '${a}'` };
340
+ if (bundlePath)
341
+ return { error: "multiple bundle paths given" };
342
+ bundlePath = a;
343
+ }
344
+ }
345
+ return { bundlePath, opts, json };
160
346
  }
161
347
  function selfTest() {
162
348
  // Build a tiny log + STH with an ephemeral witness key, entirely self-contained.
@@ -167,27 +353,59 @@ function selfTest() {
167
353
  const root_hash = merkleRoot(leaves);
168
354
  const sth = { tree_size: 3, root_hash, timestamp: "2026-05-31T00:00:00Z", witness_pubkey, signature: "" };
169
355
  sth.signature = Buffer.from(tweetnacl_1.default.sign.detached(utf8(sthMessage(sth)), kp.secretKey)).toString("hex");
170
- console.log("── self-test: valid evidence ──");
171
- printResult(verifyBundle({ witness_pubkey, sth, inclusion: { index: 1, leaf_hash: leaves[1], proof: [
356
+ const valid = { witness_pubkey, sth, inclusion: { index: 1, leaf_hash: leaves[1], proof: [
172
357
  { sibling: leaves[0], right: false }, { sibling: leaves[2], right: true },
173
- ], root_hash } }));
358
+ ], root_hash } };
359
+ console.log("── self-test: valid evidence, bundle-trusted key (integrity only) ──");
360
+ printResult(verifyBundle(valid));
361
+ console.log("\n── self-test: valid evidence + independently pinned key (full origin) ──");
362
+ printResult(verifyBundle(valid, { expectedWitnessKey: witness_pubkey }));
363
+ console.log("\n── self-test: ATTACK — fabricated history under a fresh key (must NOT gain origin) ──");
364
+ const attacker = tweetnacl_1.default.sign.keyPair();
365
+ const forgedKey = Buffer.from(attacker.publicKey).toString("hex");
366
+ const forgedSth = { ...sth, witness_pubkey: forgedKey, signature: "" };
367
+ forgedSth.signature = Buffer.from(tweetnacl_1.default.sign.detached(utf8(sthMessage(forgedSth)), attacker.secretKey)).toString("hex");
368
+ printResult(verifyBundle({ witness_pubkey: forgedKey, sth: forgedSth, inclusion: valid.inclusion }, { expectedWitnessKey: witness_pubkey }));
369
+ console.log("\n── self-test: empty checkpoint without --allow-empty-log (must be INCOMPLETE) ──");
370
+ const emptyRoot = merkleRoot([]);
371
+ const emptySth = { tree_size: 0, root_hash: emptyRoot, timestamp: "2026-05-31T00:00:00Z", witness_pubkey, signature: "" };
372
+ emptySth.signature = Buffer.from(tweetnacl_1.default.sign.detached(utf8(sthMessage(emptySth)), kp.secretKey)).toString("hex");
373
+ printResult(verifyBundle({ witness_pubkey, sth: emptySth, receipts: [] }));
174
374
  console.log("\n── self-test: tampered STH root (must FAIL) ──");
175
375
  printResult(verifyBundle({ witness_pubkey, sth: { ...sth, root_hash: "00".repeat(32) } }));
176
376
  }
177
377
  function main() {
178
- const arg = process.argv[2];
179
- if (arg === "--version") {
378
+ const argv = process.argv.slice(2);
379
+ if (argv[0] === "--version") {
180
380
  console.log(exports.VERSION);
181
381
  return;
182
382
  }
183
- if (!arg || arg === "--self-test") {
383
+ if (argv.length === 0 || argv[0] === "--self-test") {
184
384
  selfTest();
185
385
  return;
186
386
  }
187
- const bundle = JSON.parse((0, node_fs_1.readFileSync)(arg, "utf8"));
188
- const result = verifyBundle(bundle);
189
- printResult(result);
190
- process.exit(result.allPassed ? 0 : 1);
387
+ if (argv[0] === "--help" || argv[0] === "-h") {
388
+ process.stdout.write(USAGE);
389
+ return;
390
+ }
391
+ const parsed = parseCliArgs(argv);
392
+ if ("error" in parsed) {
393
+ console.error(`magenta-verify: ${parsed.error}\n\n${USAGE}`);
394
+ process.exit(2);
395
+ }
396
+ if (!parsed.bundlePath) {
397
+ console.error(`magenta-verify: missing <bundle.json>\n\n${USAGE}`);
398
+ process.exit(2);
399
+ }
400
+ const bundle = JSON.parse((0, node_fs_1.readFileSync)(parsed.bundlePath, "utf8"));
401
+ const result = verifyBundle(bundle, parsed.opts);
402
+ if (parsed.json) {
403
+ console.log(JSON.stringify({ version: exports.VERSION, ...result }, null, 2));
404
+ }
405
+ else {
406
+ printResult(result);
407
+ }
408
+ process.exit(exitCodeFor(result.verdict));
191
409
  }
192
410
  // Run as CLI only when executed directly (not when imported by tests).
193
411
  if (process.argv[1] && /magenta-verify\.[cm]?[jt]s$/.test(process.argv[1]))