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 +10 -4
- package/dist/MANIFEST.json +1 -1
- package/dist/scripts/magenta-verify.js +235 -17
- package/docs/MAGENTA_VERIFICATION_SPEC.md +41 -1
- package/docs/NPM_PACKAGING.md +22 -15
- package/docs/SECURITY_MODEL.md +10 -0
- package/package.json +1 -1
- package/scripts/demo.mjs +14 -5
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:
|
|
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
|
|
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
|
package/dist/MANIFEST.json
CHANGED
|
@@ -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": "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
if (
|
|
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 (
|
|
383
|
+
if (argv.length === 0 || argv[0] === "--self-test") {
|
|
184
384
|
selfTest();
|
|
185
385
|
return;
|
|
186
386
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
package/docs/NPM_PACKAGING.md
CHANGED
|
@@ -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.
|
|
9
|
-
> **
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
>
|
|
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` — **
|
|
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.
|
|
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.
|
|
258
|
-
tar -tzf magenta-canon-0.
|
|
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.
|
|
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 #
|
|
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
|
```
|
package/docs/SECURITY_MODEL.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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);
|