magenta-canon 0.2.0 → 0.3.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,7 +161,10 @@ 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
 
@@ -222,11 +225,14 @@ REFUSED_CALL isError=true :: Blocked by Magenta capability gate: exceeds delega
222
225
  {"name":"refund","args":{"amount_cents":8900,"order_id":"4471"}} ← only the allowed call
223
226
  # the blocked $250 refund is ABSENT — it never reached the tool.
224
227
 
225
- $ npx magenta-canon verify bundle.json
228
+ $ npx magenta-canon verify bundle.json --expected-witness-key witness.pub
229
+ witness-key source: INDEPENDENT (supplied by caller)
226
230
  [PASS] STH signature verifies
231
+ [PASS] STH witness key matches INDEPENDENTLY supplied key
227
232
  [PASS] receipt chain intact
233
+ [PASS] receipt issuer signatures verify (2/2)
228
234
  [PASS] recomputed Merkle root == signed STH root
229
- RESULT: VERIFIED (exit code 0)
235
+ RESULT: ORIGIN AND INTEGRITY VERIFIED (exit code 0)
230
236
  ```
231
237
 
232
238
  ## Security model
@@ -3,7 +3,7 @@
3
3
  "scripts/demo-control-plane.js": "3357c8de8e3fe32e73e0be99a3bb02179dff3ee041854d4ee6f8d135e4d8dcbf",
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",
@@ -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]))
@@ -1,4 +1,4 @@
1
- # Magenta Verification Spec v1
1
+ # Magenta Verification Spec v1.1
2
2
 
3
3
  **What this is:** the complete, language-agnostic recipe for verifying a Magenta
4
4
  agent-action record **without trusting — or running — the Magenta server.** If you
@@ -108,6 +108,46 @@ operator cannot make the recomputed root match an STH the auditor *already holds
108
108
  unless they also control the witness key and every mirror of that STH. So checks
109
109
  (1)+(3)+(4) against a pre-mirrored STH expose insider tampering.
110
110
 
111
+ ### 5.5 Receipt issuer signatures (enforced — spec v1.1)
112
+
113
+ Where receipts carry `issuer_pubkey` + `receipt_signature`, the verifier MUST
114
+ check each signature: Ed25519 over the UTF-8 bytes of the lowercase-hex
115
+ canonical hash (§6) of the receipt body **excluding `receipt_signature`**.
116
+ A missing, malformed, mismatched, or wrong-key signature is a verification
117
+ failure. A mixed bundle (some receipts signed, some not) is a verification
118
+ failure. A bundle whose receipts carry no issuer fields at all remains
119
+ verifiable at the integrity level, but the verifier MUST surface the skipped
120
+ issuer-authentication check — it must never imply issuer authenticity it did
121
+ not check.
122
+
123
+ ### 5.6 Verdict levels (spec v1.1) — what was actually proven
124
+
125
+ A bundle that contains its own witness key can always be **internally**
126
+ self-consistent: an attacker who generates a fresh witness key can fabricate a
127
+ complete history that passes every mathematical check. Internal consistency
128
+ and origin authenticity are therefore SEPARATE claims, and the verifier emits
129
+ layered verdicts that never conflate them:
130
+
131
+ | Verdict | Meaning |
132
+ |---|---|
133
+ | `ORIGIN AND INTEGRITY VERIFIED` | All checks passed **and** the STH witness key matched a key supplied independently of the bundle (`--expected-witness-key`, a pinned key, or a mirror record). |
134
+ | `INTEGRITY VERIFIED` | All checks passed, but the witness key came from the bundle itself. Proves internal consistency only — NOT who produced the history. |
135
+ | `INCOMPLETE EVIDENCE` | Structurally valid, but the bundle proves no action: zero receipts and no inclusion proof. An empty checkpoint must never pass as action evidence (explicit `--allow-empty-log` exists for checkpoint diagnostics and is labeled as such). |
136
+ | `VERIFICATION FAILED` | A performed check failed, or a caller-required element (exact `receipt_hash`, `action_hash`, or minimum receipt count) is absent. |
137
+
138
+ Rules: the expected witness key MUST come from outside the bundle and MUST be
139
+ validated (64 lowercase hex chars) — malformed trusted-key input fails closed.
140
+ The verifier MUST report where its witness key came from (caller vs bundle),
141
+ MUST list skipped checks, and MUST NOT describe a bundle-provided key as
142
+ "mirrored", "pinned", or "trusted".
143
+
144
+ ### 5.7 Required-evidence claims
145
+
146
+ A party verifying a *disputed action* should require the specific evidence,
147
+ not merely a consistent log: `--require-receipt <receipt_hash>`,
148
+ `--require-action-hash <hash>`, and/or `--min-receipts <n>`. Absence of a
149
+ required element is `VERIFICATION FAILED`, not a softer verdict.
150
+
111
151
  ---
112
152
 
113
153
  ## 6. Canonical JSON details
@@ -5,15 +5,21 @@ what deliberately does not, and how to verify the package locally.
5
5
 
6
6
  > **Status: published on npm.** The current published release is **`0.1.13`**,
7
7
  > carried on **both** the `latest` and `next` dist-tags (CJ published `0.1.13`
8
- > and explicitly aligned both tags). This refresh prepares **`0.2.0`** — the
9
- > **lean precompiled artifact**: the package now ships compiled CommonJS
10
- > (`dist/`) executed directly by Node (no runtime `tsx`/`esbuild`/TypeScript),
11
- > the hosted Express/Passport/PostgreSQL plane no longer ships, runtime
12
- > dependencies are **2** (`tweetnacl`, `zod`), a consumer install is
13
- > **3 packages with zero install scripts**, and the tarball is
14
- > **49 files / ~124.7 kB packed / ~468 kB unpacked**. `0.2.0` is **not yet
15
- > published**; publishing remains an explicitly-authorized step done on an
16
- > authenticated machine.
8
+ > and explicitly aligned both tags). This refresh prepares **`0.3.0`** — the
9
+ > **truth-hardened verifier** (spec v1.1): layered verdicts (`ORIGIN AND
10
+ > INTEGRITY VERIFIED` / `INTEGRITY VERIFIED` / `INCOMPLETE EVIDENCE` /
11
+ > `VERIFICATION FAILED`), independently pinned witness keys
12
+ > (`--expected-witness-key`), empty-evidence rejection, and enforced receipt
13
+ > issuer signatures. `0.2.0` (the lean precompiled artifact) is **published**
14
+ > and carried on both tags. `0.3.0` is **not yet published**; publishing
15
+ > remains an explicitly-authorized step done on an authenticated machine.
16
+ >
17
+ > **Verdict-semantics migration (0.3.0):** scripts that grepped
18
+ > `RESULT: VERIFIED` must match the new verdicts — `RESULT: INTEGRITY
19
+ > VERIFIED` (no independent key supplied) or `RESULT: ORIGIN AND INTEGRITY
20
+ > VERIFIED` (key supplied via `--expected-witness-key <file-or-hex>`).
21
+ > Zero-receipt bundles now exit `2` (`INCOMPLETE EVIDENCE`) instead of
22
+ > verifying. Exit codes: `0` either verified level, `1` failed, `2` incomplete.
17
23
 
18
24
  ## Release posture
19
25
 
@@ -49,7 +55,8 @@ npm dist-tag add magenta-canon@<version> next
49
55
  | `0.1.11` | docs-only: README/packaging copy aligned with the published dist-tags (`latest` tracks current; plain `npx magenta-canon` works) |
50
56
  | `0.1.12` | the **full trust loop** release: external STH mirror (`mirror` CLI: append/verify/check/--self-test) + mirror-feed operator helper (`mirror-feed`) + operational runbook; supply-chain remediation (`npm audit` 15→**0**, runtime deps 19→17, `ws` removed, `@anthropic-ai/sdk` demoted to dev with a lazy key-gated bridge, express transitives patched via overrides, drizzle-orm 0.45.2, tarball narrowed); intake/gateway test-hygiene; plus the Windows ESM subprocess-path fix + first Windows CI job — **published** |
51
57
  | `0.1.13` | **security hardening** (no API change): constant-time `INTERNAL_API_KEY` comparison; trust-key custody regression tests (root/intermediate never transit, actor key never persisted, key-returning routes fail closed) pinning the design Socket's AI heuristic flagged on `server/routes.ts`; threat-model docs (`SECURITY_MODEL.md` "Trust-key custody") + Socket dependency-alert mapping (`SUPPLY_CHAIN_REVIEW.md` §8) |
52
- | `0.2.0` | the **lean precompiled artifact** (supply-chain cleanroom, PRs #23–#25): minimal in-process demo control plane (the hosted Express/Passport/Postgres plane no longer ships); explicit `files` whitelist; org-storage and org-schema splits keep `pg`/drizzle out of the lean closure; **precompiled CommonJS `dist/`** built by `npm run build:lean` at `prepack` (deterministic, sha256 `MANIFEST.json`); runtime deps 17 → **2** (`tweetnacl`, `zod`); consumer install 111 → **3** packages; **zero install scripts**; no runtime `tsx`/`esbuild`/TypeScript; closure + precompile regression guards in `scripts/lean-closure.test.ts` — **not yet published** |
58
+ | `0.2.0` | the **lean precompiled artifact** (supply-chain cleanroom, PRs #23–#25): minimal in-process demo control plane (the hosted Express/Passport/Postgres plane no longer ships); explicit `files` whitelist; org-storage and org-schema splits keep `pg`/drizzle out of the lean closure; **precompiled CommonJS `dist/`** built by `npm run build:lean` at `prepack` (deterministic, sha256 `MANIFEST.json`); runtime deps 17 → **2** (`tweetnacl`, `zod`); consumer install 111 → **3** packages; **zero install scripts**; no runtime `tsx`/`esbuild`/TypeScript; closure + precompile regression guards in `scripts/lean-closure.test.ts` — **published**; `latest`/`next` aligned |
59
+ | `0.3.0` | **verifier truth hardening** (spec v1.1, PR #27): layered verdicts; `--expected-witness-key` (independently pinned witness identity — a bundle's own key can no longer masquerade as trusted); `INCOMPLETE EVIDENCE` for zero-receipt bundles; `--require-receipt`/`--require-action-hash`/`--min-receipts` claim pinning; **receipt issuer signatures enforced**; `--json` machine output; 21-test adversarial battery; demo now pins the witness key and earns `ORIGIN AND INTEGRITY VERIFIED`. Plus: Durable Witness architecture report, MVAR v1 receipt-standard DRAFT + schema/vectors, public verifier-page design — **not yet published** |
53
60
 
54
61
  **Verified on macOS / Linux / Windows (current release `0.1.11`; `0.1.12` re-proven via installed-tarball smoke):**
55
62
  `npx magenta-canon demo` completes the full
@@ -247,23 +254,23 @@ MCP gateway, and a **local-file external STH mirror foundation** with an
247
254
  operator runbook. **Not** included: hosted mirror service, automated network
248
255
  publishing, production multi-tenant SaaS, production durability guarantees.
249
256
 
250
- ## Verifying the `0.2.0` artifact (release checklist)
257
+ ## Verifying the release artifact (checklist — current target `0.3.0`)
251
258
 
252
259
  From a clean checkout of the release commit:
253
260
 
254
261
  ```bash
255
262
  npm ci && npm run check && npm test # 0 tsc errors; full suite green
256
263
  npm run build:lean && npm run build:lean # deterministic: dist/MANIFEST.json identical
257
- npm pack # prepack rebuilds dist/; expect magenta-canon-0.2.0.tgz
258
- tar -tzf magenta-canon-0.2.0.tgz # 49 files; no .ts, no .map, no tests, no server/routes.ts
264
+ npm pack # prepack rebuilds dist/; expect magenta-canon-0.3.0.tgz
265
+ tar -tzf magenta-canon-0.3.0.tgz # ~50 files; no .ts, no .map, no tests, no server/routes.ts
259
266
  ```
260
267
 
261
268
  Then in an empty directory:
262
269
 
263
270
  ```bash
264
- npm init -y && npm i ../path/to/magenta-canon-0.2.0.tgz
271
+ npm init -y && npm i ../path/to/magenta-canon-0.3.0.tgz
265
272
  ls node_modules # exactly: magenta-canon tweetnacl zod
266
- npx magenta-canon verify --self-test # RESULT: VERIFIED + tamper VERIFICATION FAILED
273
+ npx magenta-canon verify --self-test # all verdict levels incl. tamper VERIFICATION FAILED
267
274
  npx magenta-canon mirror --self-test # [PASS] x3
268
275
  npx magenta-canon demo # allow / block / VERIFIED / tamper FAILED
269
276
  ```
@@ -66,6 +66,16 @@ Concretely, proven in `docs/MCP_GATEWAY_RUN.md` and `docs/VERIFICATION_RUN.md`:
66
66
  `scripts/magenta-verify.ts` imports only `node:crypto`, `node:fs`, `tweetnacl`
67
67
  (test-enforced) and follows `docs/MAGENTA_VERIFICATION_SPEC.md`. An auditor
68
68
  should read the spec / re-implement the verifier rather than trust ours blindly.
69
+ 5. **Integrity and origin are separate claims (spec v1.1 verdicts).** A bundle
70
+ carrying its own witness key can always be made internally self-consistent
71
+ by an attacker holding a fresh key. The verifier therefore reports
72
+ `INTEGRITY VERIFIED` when its only key source is the bundle, and
73
+ `ORIGIN AND INTEGRITY VERIFIED` only when the witness key matched one
74
+ supplied independently (`--expected-witness-key`, a pinned key, or a mirror
75
+ record). Empty (zero-receipt) checkpoints never verify as action evidence
76
+ (`INCOMPLETE EVIDENCE`), and receipt issuer signatures are cryptographically
77
+ enforced when present. Auditors verifying a disputed action should also pin
78
+ the claim itself: `--require-receipt <hash>` / `--require-action-hash <hash>`.
69
79
 
70
80
  ## Trust-key custody (root/intermediate never leave; actor key is caller-owned)
71
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magenta-canon",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "A verifiable MCP accountability gateway for AI-agent tool calls: allows authorized calls, blocks unauthorized calls, records both, and produces cryptographic evidence anyone can verify.",
package/scripts/demo.mjs CHANGED
@@ -86,6 +86,7 @@ const F = {
86
86
  downstream: path.join(tmp, "downstream-calls.log"),
87
87
  bundle: path.join(tmp, "evidence-bundle.json"),
88
88
  tampered: path.join(tmp, "tampered-bundle.json"),
89
+ witnessKey: path.join(tmp, "witness-key.pub"),
89
90
  config: path.join(tmp, "gateway.config.json"),
90
91
  serverLog: path.join(tmp, "control-plane.log"),
91
92
  };
@@ -279,12 +280,19 @@ async function main() {
279
280
 
280
281
  // ── 6. independent verification ─────────────────────────────────────────────
281
282
  step("6/7", "Independent verification (magenta-verify — imports nothing from the server)…");
282
- const ver = await capture(process.execPath, [...scriptArgv("scripts/magenta-verify.ts"), F.bundle]);
283
+ // The operator PINS the witness key of the universe they just created and
284
+ // hands it to the verifier independently of the bundle (--expected-witness-key)
285
+ // — the same flow an external auditor uses with a mirrored/pinned key. This
286
+ // earns the strongest verdict: ORIGIN AND INTEGRITY VERIFIED. Without the
287
+ // pinned key the verifier honestly reports INTEGRITY VERIFIED only.
288
+ writeFileSync(F.witnessKey, bundle.witness_pubkey);
289
+ const ver = await capture(process.execPath, [...scriptArgv("scripts/magenta-verify.ts"), F.bundle,
290
+ "--expected-witness-key", F.witnessKey]);
283
291
  for (const line of ver.out.trim().split("\n")) sub(dim("│ ") + line);
284
- if (ver.code !== 0 || !/RESULT: VERIFIED/.test(ver.out)) {
285
- die("evidence did NOT verify — the proof loop is broken.", ver.err);
292
+ if (ver.code !== 0 || !/RESULT: ORIGIN AND INTEGRITY VERIFIED/.test(ver.out)) {
293
+ die("evidence did NOT fully verify — the proof loop is broken.", ver.err);
286
294
  }
287
- sub(`${OK} ${grn("VERIFIED")} — anyone can re-run this against the published bundle`);
295
+ sub(`${OK} ${grn("ORIGIN AND INTEGRITY VERIFIED")} — anyone can re-run this against the published bundle + pinned key`);
288
296
 
289
297
  // ── 7. negative control: tamper, expect failure ─────────────────────────────
290
298
  step("7/7", "Negative control — tamper with one byte of the evidence (must FAIL)…");
@@ -297,7 +305,8 @@ async function main() {
297
305
  writeFileSync(F.tampered, JSON.stringify(tampered, null, 2));
298
306
  sub(dim(`(edited receipt_hash …${h.slice(-8)} → …${last.receipt_hash.slice(-8)})`));
299
307
 
300
- const verBad = await capture(process.execPath, [...scriptArgv("scripts/magenta-verify.ts"), F.tampered]);
308
+ const verBad = await capture(process.execPath, [...scriptArgv("scripts/magenta-verify.ts"), F.tampered,
309
+ "--expected-witness-key", F.witnessKey]);
301
310
  for (const line of verBad.out.trim().split("\n")) {
302
311
  const l = /\[FAIL\]|VERIFICATION FAILED/.test(line) ? red(line) : line;
303
312
  sub(dim("│ ") + l);