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 +30 -4
- package/dist/MANIFEST.json +8 -5
- package/dist/scripts/demo-control-plane.js +8 -0
- package/dist/scripts/magenta-verify.js +235 -17
- package/dist/server/file-ledger-store.js +411 -0
- package/dist/server/ledger-store.js +118 -0
- package/dist/server/ledger.js +38 -0
- package/dist/server/storage.js +40 -28
- package/dist/server/transparency-log.js +33 -6
- package/dist/server/witness.js +9 -1
- package/docs/FILE_LEDGER.md +111 -0
- package/docs/MAGENTA_VERIFICATION_SPEC.md +41 -1
- package/docs/NPM_PACKAGING.md +24 -15
- package/docs/SECURITY_MODEL.md +10 -0
- package/package.json +2 -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,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
|
|
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
|
package/dist/MANIFEST.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package.json": "8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a",
|
|
3
|
-
"scripts/demo-control-plane.js": "
|
|
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": "
|
|
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": "
|
|
25
|
-
"server/transparency-log.js": "
|
|
27
|
+
"server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
|
|
28
|
+
"server/transparency-log.js": "826dbae845726853b0736d10546a2c65596357530b82af0d1a781ac368c69a79",
|
|
26
29
|
"server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
|
|
27
|
-
"server/witness.js": "
|
|
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.
|
|
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]))
|