magenta-canon 0.3.0 → 0.5.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
@@ -170,6 +170,57 @@ npx magenta-canon verify bundle.json --expected-witness-key witness.pub
170
170
 
171
171
  Full walkthrough: [`docs/MCP_GATEWAY.md`](docs/MCP_GATEWAY.md).
172
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
+
193
+ ### Witness identity & authorized key rotation — new in 0.5.0
194
+
195
+ Instead of raw env keys, the witness can hold its identity in an **encrypted
196
+ keystore** (scrypt → AES-256-GCM, header-bound AAD) and **rotate its signing
197
+ key under cryptographic authorization** — the outgoing key signs the rotation,
198
+ the incoming key countersigns, and the signed rotation record is committed
199
+ **into the evidence ledger itself** before the new key signs anything:
200
+
201
+ ```bash
202
+ MAGENTA_LEDGER_FILE=/var/lib/magenta/ledger.jsonl \
203
+ MAGENTA_WITNESS_KEYFILE=/var/lib/magenta/witness.keystore \
204
+ MAGENTA_WITNESS_PASSPHRASE=<operator passphrase> \
205
+ <your magenta process>
206
+ ```
207
+
208
+ **Key material alone never grants authority — a validated, durably committed
209
+ rotation record does.** Verifiers replay the rotation chain from a pinned
210
+ anchor: historical checkpoints validate against the key that was authorized
211
+ for their epoch, substitution/rollback/forks are refused, and
212
+ `--expected-witness-key` still earns `ORIGIN AND INTEGRITY VERIFIED` across
213
+ rotations from the *original* pinned key. A crash at any point in the rotation
214
+ ceremony reconciles deterministically at next boot (recover the committed
215
+ fact, abandon the orphaned key, or fail closed — never silent activation).
216
+
217
+ **Scope honesty:** this is local/reference custody (file keystore, not
218
+ KMS/HSM), and it covers the **witness** identity only — founder/receipt-issuer
219
+ key custody is a separate forthcoming lane (in file-ledger mode the issuer key
220
+ still regenerates on restart; old evidence keeps verifying). Keystore format,
221
+ rotation protocol, boot-reconciliation table, and operator procedures:
222
+ [`docs/WITNESS_IDENTITY.md`](docs/WITNESS_IDENTITY.md) (ships in the package).
223
+
173
224
  ### From the repo vs. as a CLI
174
225
 
175
226
  - **From a repo checkout** (above): `npm install` then `npm run demo`.
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "package.json": "8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a",
3
- "scripts/demo-control-plane.js": "3357c8de8e3fe32e73e0be99a3bb02179dff3ee041854d4ee6f8d135e4d8dcbf",
3
+ "scripts/demo-control-plane.js": "3a559f39d17c1a403988e731e053d0d071cbd41ab595fd6246910f852337ab2f",
4
4
  "scripts/intake-cli.js": "189014db22d6fccb034ed1a93ec11efe8905be820bc468366c68bb2cabaf8a97",
5
5
  "scripts/magenta-mirror.js": "cf701065f7a6e20f7f44a9b815396aeb5fe031c3f003bcd793b8014ea8801b85",
6
- "scripts/magenta-verify.js": "f79c60944bef65926530c2d0fd5cc35df10319c5831764b035e547b6bfebe715",
6
+ "scripts/magenta-verify.js": "0bb65ef6ff1350789eecc4f0434ca94dcf3a89930f8ef1d62cc38100e18094ba",
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": "216fe644e1753a5527bec6ccf58175d22ec695c006e1927dc3090be11df9c124",
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,14 @@
20
21
  "server/intake/rationale.js": "3ea64a1430828ae5a06b41f4e93d06cdd667850d6a2ff9a1e266f98bdfc6c404",
21
22
  "server/intake/types.js": "014891c6c8360648d6954b3185ae92d56be625484bf2f0d0f71dac271a59e23b",
22
23
  "server/intake/witness.js": "588ad166f4870d65a6ed334c70aaa2deb1d6d96ec97d889902a9eda7528bd34f",
24
+ "server/ledger-store.js": "fc8a46f925908764bac7a076cffa08053af4c1f93237bee408eb0ed1e61eeca2",
25
+ "server/ledger.js": "9bf8f132e08d636070d2ede568b2eaf75810be245c90ecd5979d2d4de69af934",
23
26
  "server/persistence.js": "2e6b1d0b1c74babbd581780b028c2593256c5ec96c2d60f4c25159e3f54e4e3f",
24
- "server/storage.js": "4f1660e9542dcb157148f561efd757d8aa452730c3a09064d3b1314feb16e8c9",
25
- "server/transparency-log.js": "42d0d9fd3de9c60389ca882b546d9784e6ef0003895898dd5958d763d54f2f6b",
27
+ "server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
28
+ "server/transparency-log.js": "7f238edc5cad4edc2c70a7b3e1977197cf2087e77789d181ffae0035f948391b",
26
29
  "server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
27
- "server/witness.js": "e94384a35a4cfce39990e580eb5b572ef9a2d4c4d9bad1a2ed9b5382aba64b1b",
30
+ "server/witness-identity.js": "9dd5b6e63aefa00171a838d1cd27e3c09ddcb7d39512427ebe537763fe5fc42e",
31
+ "server/witness.js": "b1a8209d4dae4ffafc3ca4a158474b369250472ba80cef03bd92048282be91a4",
28
32
  "shared/canonical.js": "476cee4b5c5702e8a2a198e79c2639cf8ff84000ffb92f68a04433ed2a2f5484",
29
33
  "shared/certificates.js": "7844e71a38e1b1324586c7d054b2f5c15222b86446318d1e6dea9bab504a4a73",
30
34
  "shared/schema.js": "f89190990e3826e44c722d5e8f5b39fd59b4d3fb9ec047229930a17604e4646c",
@@ -33,6 +33,9 @@ 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");
37
+ const file_ledger_store_1 = require("../server/file-ledger-store");
38
+ const ledger_1 = require("../server/ledger");
36
39
  const trust_bootstrap_1 = require("../server/trust-bootstrap");
37
40
  const PORT = Number(process.env.PORT ?? 0);
38
41
  const HOST = "127.0.0.1";
@@ -152,18 +155,48 @@ const server = (0, node_http_1.createServer)(async (req, res) => {
152
155
  // ── GET /api/trust/evidence ──────────────────────────────────────────────
153
156
  if (method === "GET" && url === "/api/trust/evidence") {
154
157
  const receipts = [...(await storage_1.storage.getExecutionReceipts())].reverse(); // creation order
158
+ const rotations = ledger_1.sharedLedgerStore instanceof file_ledger_store_1.FileLedgerStore ? ledger_1.sharedLedgerStore.allRotations() : [];
155
159
  return sendJson(res, 200, {
156
160
  witness_pubkey: witness_1.witnessLog.witnessPublicKey,
157
161
  sth: witness_1.witnessLog.latestSTH() ?? null,
158
162
  receipts,
163
+ ...(rotations.length > 0 ? { rotations } : {}),
159
164
  });
160
165
  }
166
+ // ── POST /internal/witness/rotate ────────────────────────────────────────
167
+ // Full continuity protocol (keystore + durable ledger required): mint →
168
+ // durably commit the signed rotation record → activate → switch epoch.
169
+ if (method === "POST" && url === "/internal/witness/rotate") {
170
+ if (!internalAuthOk(req, res))
171
+ return;
172
+ let body;
173
+ try {
174
+ body = (await readBody(req));
175
+ }
176
+ catch (e) {
177
+ return sendJson(res, 400, { error: "bad_request", message: e.message });
178
+ }
179
+ try {
180
+ const record = (0, witness_2.rotateWitness)(body?.reason ?? "operator-initiated rotation");
181
+ return sendJson(res, 200, { rotated: true, rotation_id: record.rotation_id, new_version: record.new_version, new_pubkey: record.new_pubkey });
182
+ }
183
+ catch (e) {
184
+ return sendJson(res, 409, { error: "rotation_refused", message: e.message });
185
+ }
186
+ }
161
187
  sendJson(res, 404, { error: "not_found", message: `${method} ${url} is not served by the demo control plane` });
162
188
  }
163
189
  catch (e) {
164
190
  sendJson(res, 500, { error: "internal_error", message: e instanceof Error ? e.message : String(e) });
165
191
  }
166
192
  });
193
+ // File-ledger restart support: rebuild the witness's derived leaf view from
194
+ // any receipts the durable store replayed (no-op in the default memory mode).
195
+ void (async () => {
196
+ const restored = await storage_1.storage.getExecutionReceipts();
197
+ if (restored.length > 0)
198
+ (0, witness_2.rebuildWitnessFromReceipts)([...restored].reverse().map((r) => r.receipt_hash));
199
+ })();
167
200
  server.listen(PORT, HOST, () => {
168
201
  const addr = server.address();
169
202
  const port = typeof addr === "object" && addr ? addr.port : PORT;
@@ -18,7 +18,7 @@ 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.verifyReceiptIssuerSignature = 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.verifyRotationChain = 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"));
@@ -109,6 +109,59 @@ 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
+ /** Validate a rotation chain from an anchor key. Pure re-implementation of
113
+ * spec magenta-rotation/1 (sigs are Ed25519 over utf8(canonicalHash(body))). */
114
+ function verifyRotationChain(anchorPubkey, rotations) {
115
+ const GENESIS = "0".repeat(64);
116
+ let active = anchorPubkey;
117
+ let activeVersion = rotations.length ? rotations[0].old_version : 1;
118
+ let prev = GENESIS;
119
+ let lastBoundary = -1;
120
+ const seen = new Set();
121
+ const epochs = [{ pubkey: anchorPubkey, fromExclusive: -1 }];
122
+ for (const r of rotations) {
123
+ if (r.spec !== "magenta-rotation/1")
124
+ return { valid: false, reason: `unsupported rotation spec '${r.spec}'` };
125
+ if (seen.has(r.rotation_id))
126
+ return { valid: false, reason: "duplicate rotation_id" };
127
+ seen.add(r.rotation_id);
128
+ if (r.old_pubkey !== active)
129
+ return { valid: false, reason: "rotation old_pubkey is not the active key" };
130
+ if (r.old_version !== activeVersion)
131
+ return { valid: false, reason: "rotation old_version mismatch" };
132
+ if (r.new_version !== activeVersion + 1)
133
+ return { valid: false, reason: "skipped key version" };
134
+ if (r.previous_rotation_hash !== prev)
135
+ return { valid: false, reason: "rotation chain hash broken" };
136
+ if (r.effective_tree_size <= lastBoundary)
137
+ return { valid: false, reason: "rotation boundaries must strictly increase" };
138
+ const body = {};
139
+ for (const k of Object.keys(r)) {
140
+ if (k !== "old_key_signature" && k !== "new_key_countersignature" && k !== "record_hash")
141
+ body[k] = r[k];
142
+ }
143
+ const h = (0, exports.canonicalHash)(body);
144
+ try {
145
+ if (!tweetnacl_1.default.sign.detached.verify(utf8(h), fromHex(r.old_key_signature), fromHex(r.old_pubkey)))
146
+ return { valid: false, reason: "old-key authorization signature invalid" };
147
+ if (!tweetnacl_1.default.sign.detached.verify(utf8(h), fromHex(r.new_key_countersignature), fromHex(r.new_pubkey)))
148
+ return { valid: false, reason: "new-key countersignature invalid" };
149
+ }
150
+ catch {
151
+ return { valid: false, reason: "malformed rotation signature material" };
152
+ }
153
+ if ((0, exports.canonicalHash)({ ...body, old_key_signature: r.old_key_signature, new_key_countersignature: r.new_key_countersignature }) !== r.record_hash) {
154
+ return { valid: false, reason: "rotation record_hash mismatch" };
155
+ }
156
+ active = r.new_pubkey;
157
+ activeVersion = r.new_version;
158
+ prev = r.record_hash;
159
+ lastBoundary = r.effective_tree_size;
160
+ epochs.push({ pubkey: r.new_pubkey, fromExclusive: r.effective_tree_size });
161
+ }
162
+ return { valid: true, epochs, tipPubkey: active };
163
+ }
164
+ exports.verifyRotationChain = verifyRotationChain;
112
165
  const HEX64 = /^[0-9a-f]{64}$/;
113
166
  /** Receipt issuer signature (spec §2): Ed25519 over the canonical receipt body
114
167
  * (all fields except receipt_signature), message = UTF-8 of the hex hash. */
@@ -150,10 +203,31 @@ function verifyBundle(b, opts = {}) {
150
203
  if (b.sth) {
151
204
  add("STH signature verifies", verifySTH(b.sth), `root ${b.sth.root_hash.slice(0, 16)}… size ${b.sth.tree_size}`);
152
205
  // Internal coherence: the bundle's top-level key must agree with the STH's.
206
+ // (With rotations, the top-level key is the chain TIP by construction.)
153
207
  if (b.witness_pubkey !== undefined) {
154
208
  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
209
  }
156
- if (witnessKeySource === "cli" && HEX64.test(opts.expectedWitnessKey)) {
210
+ if (b.rotations && b.rotations.length > 0) {
211
+ // Rotation continuity: the pinned key (or, integrity-only, the chain's
212
+ // own anchor) must chain by signed authorization+countersignature to
213
+ // the key that signed this STH at its tree size.
214
+ const anchor = (witnessKeySource === "cli" && HEX64.test(opts.expectedWitnessKey))
215
+ ? opts.expectedWitnessKey
216
+ : b.rotations[0].old_pubkey;
217
+ const chain = verifyRotationChain(anchor, b.rotations);
218
+ add(`rotation chain validates from ${witnessKeySource === "cli" ? "PINNED anchor" : "bundle anchor"} (${b.rotations.length} rotation(s))`, chain.valid, chain.valid ? `tip key …${chain.tipPubkey.slice(-12)}` : `ROTATION CHAIN INVALID: ${chain.reason}`);
219
+ if (chain.valid) {
220
+ let expected = chain.epochs[0].pubkey;
221
+ for (const e of chain.epochs)
222
+ if (b.sth.tree_size > e.fromExclusive)
223
+ expected = e.pubkey;
224
+ add("STH signed by the authorized key for its epoch", b.sth.witness_pubkey === expected, b.sth.witness_pubkey === expected ? "epoch boundary respected" : "STH KEY OUTSIDE THE AUTHORIZED ROTATION CHAIN");
225
+ }
226
+ if (witnessKeySource === "bundle") {
227
+ skip("witness key trust", "rotation chain anchored to the bundle's own first key — supply --expected-witness-key (the version-1 anchor) for origin assurance");
228
+ }
229
+ }
230
+ else if (witnessKeySource === "cli" && HEX64.test(opts.expectedWitnessKey)) {
157
231
  const match = b.sth.witness_pubkey === opts.expectedWitnessKey;
158
232
  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
233
  }