magenta-canon 0.4.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
@@ -190,6 +190,37 @@ This is single-writer local/reference durability — see
190
190
  [`docs/FILE_LEDGER.md`](docs/FILE_LEDGER.md) for the format, lock recovery,
191
191
  crash-tail policy, backup guidance, and limitations.
192
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
+
193
224
  ### From the repo vs. as a CLI
194
225
 
195
226
  - **From a repo checkout** (above): `npm install` then `npm run demo`.
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "package.json": "8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a",
3
- "scripts/demo-control-plane.js": "6d58bd7e816029b3c23b8c3b5f8812a5bd8ae1f4b5d6a70851eeaf6cb577dbc1",
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": "92dc58d313be183e53817455ee9fd4ab7cb2f3a171343de66f5db26e0f139d79",
12
+ "server/file-ledger-store.js": "216fe644e1753a5527bec6ccf58175d22ec695c006e1927dc3090be11df9c124",
13
13
  "server/intake/categories.js": "e9860eee0e2e0fe96c7ade165e5e068fae0874de2c7d39ff796efc91e713013b",
14
14
  "server/intake/engine.js": "abb8feacd925520cbf01acc2e932d9ebb037a3d016b053b7b10deaf8f545a605",
15
15
  "server/intake/gateway-intake.js": "40514fa489b031c29725e9b837e83998217bbc1ae84cf9259154a16b1faae2e8",
@@ -25,9 +25,10 @@
25
25
  "server/ledger.js": "9bf8f132e08d636070d2ede568b2eaf75810be245c90ecd5979d2d4de69af934",
26
26
  "server/persistence.js": "2e6b1d0b1c74babbd581780b028c2593256c5ec96c2d60f4c25159e3f54e4e3f",
27
27
  "server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
28
- "server/transparency-log.js": "826dbae845726853b0736d10546a2c65596357530b82af0d1a781ac368c69a79",
28
+ "server/transparency-log.js": "7f238edc5cad4edc2c70a7b3e1977197cf2087e77789d181ffae0035f948391b",
29
29
  "server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
30
- "server/witness.js": "1963401588a48817f7e478d3824a4752d8a3f438f277ba4443158abcedfd6a47",
30
+ "server/witness-identity.js": "9dd5b6e63aefa00171a838d1cd27e3c09ddcb7d39512427ebe537763fe5fc42e",
31
+ "server/witness.js": "b1a8209d4dae4ffafc3ca4a158474b369250472ba80cef03bd92048282be91a4",
31
32
  "shared/canonical.js": "476cee4b5c5702e8a2a198e79c2639cf8ff84000ffb92f68a04433ed2a2f5484",
32
33
  "shared/certificates.js": "7844e71a38e1b1324586c7d054b2f5c15222b86446318d1e6dea9bab504a4a73",
33
34
  "shared/schema.js": "f89190990e3826e44c722d5e8f5b39fd59b4d3fb9ec047229930a17604e4646c",
@@ -34,6 +34,8 @@ 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
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");
37
39
  const trust_bootstrap_1 = require("../server/trust-bootstrap");
38
40
  const PORT = Number(process.env.PORT ?? 0);
39
41
  const HOST = "127.0.0.1";
@@ -153,12 +155,35 @@ const server = (0, node_http_1.createServer)(async (req, res) => {
153
155
  // ── GET /api/trust/evidence ──────────────────────────────────────────────
154
156
  if (method === "GET" && url === "/api/trust/evidence") {
155
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() : [];
156
159
  return sendJson(res, 200, {
157
160
  witness_pubkey: witness_1.witnessLog.witnessPublicKey,
158
161
  sth: witness_1.witnessLog.latestSTH() ?? null,
159
162
  receipts,
163
+ ...(rotations.length > 0 ? { rotations } : {}),
160
164
  });
161
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
+ }
162
187
  sendJson(res, 404, { error: "not_found", message: `${method} ${url} is not served by the demo control plane` });
163
188
  }
164
189
  catch (e) {
@@ -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
  }
@@ -86,6 +86,8 @@ const node_crypto_1 = require("node:crypto");
86
86
  const canonical_1 = require("../shared/canonical");
87
87
  const crypto_1 = require("./crypto");
88
88
  const transparency_log_1 = require("./transparency-log");
89
+ const witness_identity_1 = require("./witness-identity");
90
+ const canonical_2 = require("../shared/canonical");
89
91
  const FORMAT_VERSION = 1;
90
92
  const MAX_LINE = 1024 * 1024; // 1 MiB per record — bounded, fail-closed
91
93
  class LedgerCorruptionError extends Error {
@@ -121,6 +123,10 @@ class FileLedgerStore {
121
123
  lastHash;
122
124
  sths = [];
123
125
  sthBySize = new Map(); // tree_size -> root_hash (one root per size)
126
+ rotations = [];
127
+ activeWitnessKey; // anchored by first STH; advanced by rotations
128
+ activeWitnessVersion_ = 1;
129
+ lastRotationHash = canonical_2.GENESIS_HASH;
124
130
  leaves = []; // replay-validation view only
125
131
  fd;
126
132
  lockPath;
@@ -227,6 +233,9 @@ class FileLedgerStore {
227
233
  if (typeof sth.tree_size !== "number" || sth.tree_size < 0) {
228
234
  throw new Error("file-ledger: STH tree_size malformed — refused");
229
235
  }
236
+ if (this.activeWitnessKey !== undefined && sth.witness_pubkey !== this.activeWitnessKey) {
237
+ throw new Error("file-ledger: STH signed by a key that is not the active witness epoch — unauthorized substitution refused (commit a rotation record first)");
238
+ }
230
239
  const existing = this.sthBySize.get(sth.tree_size);
231
240
  if (existing !== undefined && existing !== sth.root_hash) {
232
241
  throw new Error(`file-ledger: conflicting STH at tree_size ${sth.tree_size} — equivocation refused`);
@@ -235,6 +244,57 @@ class FileLedgerStore {
235
244
  this.writeRecord({ v: FORMAT_VERSION, t: "sth", payload, payload_hash: (0, canonical_1.canonicalHash)(payload) });
236
245
  this.sths.push(sth);
237
246
  this.sthBySize.set(sth.tree_size, sth.root_hash);
247
+ if (this.activeWitnessKey === undefined)
248
+ this.activeWitnessKey = sth.witness_pubkey;
249
+ }
250
+ /**
251
+ * Durably commit a rotation continuity record. The record becomes part of
252
+ * the SAME append-only evidence ledger (single authority). Validation here
253
+ * is the write-side gate; replay re-validates the full chain.
254
+ */
255
+ appendRotation(record) {
256
+ if (this.activeWitnessKey === undefined) {
257
+ throw new Error("file-ledger: cannot rotate before any STH establishes the witness anchor");
258
+ }
259
+ const v = (0, witness_identity_1.validateRotationRecord)(record, {
260
+ activePubkey: this.activeWitnessKey,
261
+ activeVersion: this.activeWitnessVersion_,
262
+ previousRotationHash: this.lastRotationHash,
263
+ identityId: record.identity_id, // identity binding is enforced by the keystore + chain hashes
264
+ });
265
+ if (!v.valid)
266
+ throw new Error(`file-ledger: rotation refused — ${v.reason}`);
267
+ if (record.effective_tree_size !== this.leaves.length) {
268
+ throw new Error(`file-ledger: rotation boundary ${record.effective_tree_size} does not match the current ledger size ${this.leaves.length}`);
269
+ }
270
+ const latest = this.latestSth();
271
+ if (latest && record.last_old_sth_root !== latest.root_hash) {
272
+ throw new Error("file-ledger: rotation does not preserve the final old-key checkpoint root");
273
+ }
274
+ const payload = { ...record };
275
+ this.writeRecord({ v: FORMAT_VERSION, t: "rotation", payload, payload_hash: (0, canonical_1.canonicalHash)(payload) });
276
+ this.rotations.push(record);
277
+ this.activeWitnessKey = record.new_pubkey;
278
+ this.activeWitnessVersion_ = record.new_version;
279
+ this.lastRotationHash = record.record_hash;
280
+ }
281
+ allRotations() {
282
+ return this.rotations.slice();
283
+ }
284
+ activeWitnessPubkey() {
285
+ return this.activeWitnessKey;
286
+ }
287
+ activeWitnessVersion() {
288
+ return this.activeWitnessVersion_;
289
+ }
290
+ /** The validated epoch table (anchor + rotation boundaries). The single
291
+ * source of "which key is authorized for which tree size" at this node. */
292
+ witnessEpochs() {
293
+ if (this.sths.length === 0)
294
+ return undefined;
295
+ const anchor = this.sths[0].witness_pubkey;
296
+ const w = (0, witness_identity_1.walkRotationChain)(anchor, this.rotations);
297
+ return w.valid ? w.epochs : undefined; // replay already validated → valid by construction
238
298
  }
239
299
  // ── reads (same copy semantics as the memory store) ───────────────────────
240
300
  async getReceiptsNewestFirst(limit) {
@@ -373,6 +433,30 @@ class FileLedgerStore {
373
433
  this.leaves.push((0, transparency_log_1.hashLeaf)(rc.receipt_hash));
374
434
  continue;
375
435
  }
436
+ if (rec.t === "rotation") {
437
+ const rot = rec.payload;
438
+ if (witnessKey === undefined)
439
+ throw new LedgerCorruptionError(recNo, "rotation before any STH anchor");
440
+ const rv = (0, witness_identity_1.validateRotationRecord)(rot, {
441
+ activePubkey: witnessKey,
442
+ activeVersion: this.activeWitnessVersion_,
443
+ previousRotationHash: this.lastRotationHash,
444
+ identityId: rot.identity_id,
445
+ });
446
+ if (!rv.valid)
447
+ throw new LedgerCorruptionError(recNo, `rotation record invalid: ${rv.reason}`);
448
+ if (rot.effective_tree_size !== this.leaves.length)
449
+ throw new LedgerCorruptionError(recNo, "rotation boundary does not match ledger position");
450
+ const latestAtRotation = this.sths[this.sths.length - 1];
451
+ if (latestAtRotation && rot.last_old_sth_root !== latestAtRotation.root_hash) {
452
+ throw new LedgerCorruptionError(recNo, "rotation does not preserve the final old-key checkpoint");
453
+ }
454
+ this.rotations.push(rot);
455
+ witnessKey = rot.new_pubkey;
456
+ this.activeWitnessVersion_ = rot.new_version;
457
+ this.lastRotationHash = rot.record_hash;
458
+ continue;
459
+ }
376
460
  if (rec.t === "sth") {
377
461
  const sth = rec.payload;
378
462
  if (!(0, transparency_log_1.verifySTH)(sth))
@@ -380,7 +464,7 @@ class FileLedgerStore {
380
464
  if (witnessKey === undefined)
381
465
  witnessKey = sth.witness_pubkey;
382
466
  else if (sth.witness_pubkey !== witnessKey)
383
- throw new LedgerCorruptionError(recNo, "witness key changed mid-ledger (rotation records are a later lane)");
467
+ throw new LedgerCorruptionError(recNo, "STH signed by a key outside the authorized rotation chain (silent substitution)");
384
468
  if (sth.tree_size > this.leaves.length)
385
469
  throw new LedgerCorruptionError(recNo, `STH tree_size ${sth.tree_size} exceeds receipts seen (${this.leaves.length})`);
386
470
  const existing = this.sthBySize.get(sth.tree_size);
@@ -397,6 +481,7 @@ class FileLedgerStore {
397
481
  }
398
482
  if (lines.length > 0 && !headerSeen)
399
483
  throw new LedgerCorruptionError(1, "missing header record");
484
+ this.activeWitnessKey = witnessKey;
400
485
  }
401
486
  quarantineTail(tail, offset) {
402
487
  const qPath = `${this.filePath}.tail-quarantine-${Date.now()}`;
@@ -38,6 +38,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
38
38
  exports.verifyConsistency = exports.verifySTH = exports.TransparencyLog = exports.verifyInclusion = exports.inclusionProof = exports.merkleRoot = exports.hashLeaf = void 0;
39
39
  const crypto_1 = require("crypto");
40
40
  const ledger_store_1 = require("./ledger-store");
41
+ const witness_identity_1 = require("./witness-identity");
41
42
  const crypto_2 = require("./crypto");
42
43
  // ----------------------------------------------------------------------------
43
44
  // Merkle primitives (RFC 6962 domain separation: 0x00 leaves, 0x01 nodes)
@@ -134,6 +135,15 @@ class TransparencyLog {
134
135
  get witnessPublicKey() {
135
136
  return this.witnessPub;
136
137
  }
138
+ /**
139
+ * Switch to the next witness key epoch. The caller MUST have durably
140
+ * committed the rotation continuity record first (the ledger is the single
141
+ * authority over rotations); this only swaps the signing key.
142
+ */
143
+ rotateWitnessKey(next) {
144
+ this.witnessPub = next.publicKey;
145
+ this.witnessSec = next.secretKey;
146
+ }
137
147
  /** Append a receipt hash as a leaf and publish a new signed tree head. */
138
148
  append(receiptHash) {
139
149
  const leaf = hashLeaf(receiptHash);
@@ -180,13 +190,38 @@ class TransparencyLog {
180
190
  }
181
191
  this.leaves = receiptHashes.map(hashLeaf);
182
192
  const latest = this.store.latestSth();
183
- if (latest && latest.witness_pubkey !== this.witnessPub) {
184
- throw new Error("transparency-log: the durable ledger's STH history was signed by a DIFFERENT witness key than this " +
185
- "process holds a restart regenerated the witness identity. Pin MAGENTA_WITNESS_SECRET/MAGENTA_WITNESS_PUBLIC " +
186
- "to the original key (witness continuity is required for origin verification); refusing to continue incoherently.");
187
- }
188
- if (latest && merkleRoot(this.leaves.slice(0, latest.tree_size)) !== latest.root_hash) {
189
- throw new Error("transparency-log: rebuilt leaf view does not match the committed STH root — ledger/receipt divergence");
193
+ if (latest) {
194
+ // Epoch-aware continuity. If the store exposes a validated rotation epoch
195
+ // table, the latest STH must be signed by the key authorized for ITS
196
+ // tree-size epoch NOT necessarily the current live key (immediately
197
+ // after a committed rotation the newest checkpoint legitimately belongs
198
+ // to the retired epoch). Stores without rotations fall back to the
199
+ // single-key continuity guard.
200
+ const epochs = this.store.witnessEpochs?.();
201
+ if (epochs && epochs.length > 0) {
202
+ // (1) historical: the latest STH must be signed by the key authorized
203
+ // for ITS tree-size epoch (may be a retired epoch immediately after
204
+ // a committed rotation, before the first new-epoch STH).
205
+ const expected = (0, witness_identity_1.keyForTreeSize)(epochs, latest.tree_size);
206
+ if (latest.witness_pubkey !== expected) {
207
+ throw new Error("transparency-log: latest STH is signed by a key outside the authorized rotation epoch for its tree size — refusing (fail closed)");
208
+ }
209
+ // (2) continuity: this process's LIVE signing key must be the active
210
+ // epoch tip (else a regenerated/unreconciled witness identity would
211
+ // sign the next STH with the wrong key). Pin the key / reconcile.
212
+ const tip = epochs[epochs.length - 1].pubkey;
213
+ if (this.witnessPub !== tip) {
214
+ throw new Error("transparency-log: this process's live witness key is not the active rotation-epoch key (regenerated identity or unreconciled rotation) — refusing (fail closed)");
215
+ }
216
+ }
217
+ else if (latest.witness_pubkey !== this.witnessPub) {
218
+ throw new Error("transparency-log: the durable ledger's STH history was signed by a DIFFERENT witness key than this " +
219
+ "process holds — a restart regenerated the witness identity. Pin MAGENTA_WITNESS_SECRET/MAGENTA_WITNESS_PUBLIC " +
220
+ "(or use a keystore) to preserve witness continuity; refusing to continue incoherently.");
221
+ }
222
+ if (merkleRoot(this.leaves.slice(0, latest.tree_size)) !== latest.root_hash) {
223
+ throw new Error("transparency-log: rebuilt leaf view does not match the committed STH root — ledger/receipt divergence");
224
+ }
190
225
  }
191
226
  }
192
227
  /** Clear the log (fresh universe / test isolation). Keeps the witness key. */
@@ -0,0 +1,406 @@
1
+ "use strict";
2
+ /**
3
+ * WITNESS IDENTITY & ROTATION CONTINUITY — Durable Witness PR 3 (Lane A).
4
+ *
5
+ * Moves Magenta from "unexpected witness-key change is refused" to
6
+ * "legitimate rotation is cryptographically authorized, durably recorded,
7
+ * independently verifiable, and distinguishable from silent substitution".
8
+ *
9
+ * THREE SEPARATED RESPONSIBILITIES (the ledger does NOT generate keys or
10
+ * decide rotation policy):
11
+ * - WitnessKeyStore — encrypted-at-rest custody of the identity's key
12
+ * versions (this module; local/reference mode).
13
+ * - Rotation records — canonical signed continuity records, durably
14
+ * committed INTO THE EVIDENCE LEDGER (single
15
+ * authority: the same append-only file that holds
16
+ * receipts and STHs — no parallel custody journal).
17
+ * - TransparencyLog — signs STHs with whatever active key it is given;
18
+ * epoch switching happens via an explicit, validated
19
+ * rotation call.
20
+ *
21
+ * CUSTODY MODE (local/reference — NOT KMS/HSM):
22
+ * - whole-file encryption: scrypt(passphrase) → AES-256-GCM;
23
+ * - atomic create (O_EXCL, 0600) and atomic replace (tmp+rename);
24
+ * - fail closed on wrong passphrase / malformed file / bad MAC;
25
+ * - secret material is never logged, serialized into receipts/evidence,
26
+ * or included in error messages.
27
+ * Cloud KMS / customer-managed signing is a later lane
28
+ * (docs/roadmaps/WITNESS_KEY_CUSTODY_ARCHITECTURE.md).
29
+ *
30
+ * ROTATION RECORD (spec "magenta-rotation/1") binds:
31
+ * rotation_id · identity_id · old/new key version + pubkey · reason ·
32
+ * initiated_at/effective_at · effective_tree_size (the exact ledger
33
+ * boundary: STHs with tree_size <= boundary belong to the old epoch,
34
+ * greater to the new) · last_old_sth_root (the preserved final old-key
35
+ * checkpoint) · previous_rotation_hash (chain) · old-key AUTHORIZATION
36
+ * signature · new-key COUNTERSIGNATURE · record_hash.
37
+ * Anything less is treated as unauthorized substitution. Signatures are
38
+ * Ed25519 over the UTF-8 bytes of the canonical hash of the record without
39
+ * its signature/record_hash fields — the same contract as receipt issuer
40
+ * signatures, so the standalone verifier re-derives it without server code.
41
+ */
42
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
43
+ if (k2 === undefined) k2 = k;
44
+ var desc = Object.getOwnPropertyDescriptor(m, k);
45
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
46
+ desc = { enumerable: true, get: function() { return m[k]; } };
47
+ }
48
+ Object.defineProperty(o, k2, desc);
49
+ }) : (function(o, m, k, k2) {
50
+ if (k2 === undefined) k2 = k;
51
+ o[k2] = m[k];
52
+ }));
53
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
54
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
55
+ }) : function(o, v) {
56
+ o["default"] = v;
57
+ });
58
+ var __importStar = (this && this.__importStar) || function (mod) {
59
+ if (mod && mod.__esModule) return mod;
60
+ var result = {};
61
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
62
+ __setModuleDefault(result, mod);
63
+ return result;
64
+ };
65
+ Object.defineProperty(exports, "__esModule", { value: true });
66
+ exports.WitnessKeyStore = exports.keyForTreeSize = exports.walkRotationChain = exports.validateRotationRecord = exports.rotationRecordHash = exports.rotationSigningHash = exports.ROTATION_SPEC = void 0;
67
+ const fs = __importStar(require("node:fs"));
68
+ const path = __importStar(require("node:path"));
69
+ const node_crypto_1 = require("node:crypto");
70
+ const canonical_1 = require("../shared/canonical");
71
+ const crypto_1 = require("./crypto");
72
+ exports.ROTATION_SPEC = "magenta-rotation/1";
73
+ /** Canonical signing bytes: the record without signatures and record_hash. */
74
+ function rotationSigningHash(r) {
75
+ return (0, canonical_1.canonicalHash)(r);
76
+ }
77
+ exports.rotationSigningHash = rotationSigningHash;
78
+ function rotationRecordHash(r) {
79
+ return (0, canonical_1.canonicalHash)(r);
80
+ }
81
+ exports.rotationRecordHash = rotationRecordHash;
82
+ /** Validate one rotation record against the expected chain state. */
83
+ function validateRotationRecord(r, expected) {
84
+ const fail = (reason) => ({ valid: false, reason });
85
+ if (r.spec !== exports.ROTATION_SPEC)
86
+ return fail(`unsupported rotation spec '${r.spec}'`);
87
+ if (r.identity_id !== expected.identityId)
88
+ return fail("identity_id mismatch");
89
+ if (r.old_pubkey !== expected.activePubkey)
90
+ return fail("old_pubkey is not the currently active witness key");
91
+ if (r.old_version !== expected.activeVersion)
92
+ return fail("old_version is not the currently active key version");
93
+ if (r.new_version !== expected.activeVersion + 1)
94
+ return fail("new_version must increment by exactly 1 (no skipped versions)");
95
+ if (r.new_pubkey === r.old_pubkey)
96
+ return fail("rotation must change the key");
97
+ if (r.previous_rotation_hash !== expected.previousRotationHash)
98
+ return fail("previous_rotation_hash breaks the rotation chain");
99
+ if (!Number.isInteger(r.effective_tree_size) || r.effective_tree_size < 0)
100
+ return fail("effective_tree_size malformed");
101
+ const body = {
102
+ spec: r.spec, rotation_id: r.rotation_id, identity_id: r.identity_id,
103
+ old_version: r.old_version, old_pubkey: r.old_pubkey,
104
+ new_version: r.new_version, new_pubkey: r.new_pubkey,
105
+ reason: r.reason, initiated_at: r.initiated_at, effective_at: r.effective_at,
106
+ effective_tree_size: r.effective_tree_size, last_old_sth_root: r.last_old_sth_root,
107
+ previous_rotation_hash: r.previous_rotation_hash,
108
+ };
109
+ const signingHash = rotationSigningHash(body);
110
+ if (!(0, crypto_1.verify)(signingHash, r.old_key_signature, r.old_pubkey))
111
+ return fail("old-key authorization signature invalid");
112
+ if (!(0, crypto_1.verify)(signingHash, r.new_key_countersignature, r.new_pubkey))
113
+ return fail("new-key countersignature invalid");
114
+ if (rotationRecordHash({ ...body, old_key_signature: r.old_key_signature, new_key_countersignature: r.new_key_countersignature }) !== r.record_hash) {
115
+ return fail("record_hash mismatch");
116
+ }
117
+ return { valid: true };
118
+ }
119
+ exports.validateRotationRecord = validateRotationRecord;
120
+ /**
121
+ * Walk a rotation chain from a pinned anchor key. Returns the epoch table
122
+ * (which key is authoritative for which tree sizes) or a failure reason.
123
+ * Pure — usable by the verifier, the ledger replay, and mirrors alike.
124
+ */
125
+ function walkRotationChain(anchorPubkey, rotations) {
126
+ let activePubkey = anchorPubkey;
127
+ let activeVersion = rotations.length > 0 ? rotations[0].old_version : 1;
128
+ let prevHash = canonical_1.GENESIS_HASH;
129
+ let lastBoundary = -1;
130
+ const identityId = rotations.length > 0 ? rotations[0].identity_id : "";
131
+ const seenIds = new Set();
132
+ const epochs = [{ pubkey: anchorPubkey, fromExclusive: -1 }];
133
+ for (const r of rotations) {
134
+ if (seenIds.has(r.rotation_id))
135
+ return { valid: false, reason: `duplicate rotation_id ${r.rotation_id}` };
136
+ seenIds.add(r.rotation_id);
137
+ if (r.effective_tree_size <= lastBoundary)
138
+ return { valid: false, reason: "rotation boundaries must strictly increase (no replayed/reordered rotations)" };
139
+ const v = validateRotationRecord(r, { activePubkey, activeVersion, previousRotationHash: prevHash, identityId: r.identity_id === identityId ? identityId : "__mismatch__" });
140
+ if (!v.valid)
141
+ return { valid: false, reason: v.reason };
142
+ activePubkey = r.new_pubkey;
143
+ activeVersion = r.new_version;
144
+ prevHash = r.record_hash;
145
+ lastBoundary = r.effective_tree_size;
146
+ epochs.push({ pubkey: r.new_pubkey, fromExclusive: r.effective_tree_size });
147
+ }
148
+ return { valid: true, epochs, tipPubkey: activePubkey };
149
+ }
150
+ exports.walkRotationChain = walkRotationChain;
151
+ /** Which key must have signed an STH at the given tree size, per the chain. */
152
+ function keyForTreeSize(epochs, treeSize) {
153
+ let key = epochs[0].pubkey;
154
+ for (const e of epochs)
155
+ if (treeSize > e.fromExclusive)
156
+ key = e.pubkey;
157
+ return key;
158
+ }
159
+ exports.keyForTreeSize = keyForTreeSize;
160
+ // scrypt cost: N=2^16 (was 2^14) — OWASP-2023-range for interactive reference
161
+ // custody. Stored per-file (below), so it is tunable and forward-migratable.
162
+ const SCRYPT = { N: 65536, r: 8, p: 1, keyLen: 32 };
163
+ const SCRYPT_MAXMEM = 256 * 1024 * 1024; // headroom for N=2^16 (≈67MB working set)
164
+ const KEYSTORE_FORMAT = "magenta-witness-keystore/1";
165
+ /** Additional authenticated data binding the plaintext envelope header to the
166
+ * ciphertext, so kdf params / iv / format / identity cannot be altered
167
+ * without failing the GCM tag (fail closed). Deterministic from the header. */
168
+ function keystoreAad(header) {
169
+ return Buffer.from((0, canonical_1.canonicalHash)(header), "utf8");
170
+ }
171
+ function encryptPayload(payload, passphrase) {
172
+ const salt = (0, node_crypto_1.randomBytes)(16);
173
+ const iv = (0, node_crypto_1.randomBytes)(12);
174
+ const key = (0, node_crypto_1.scryptSync)(passphrase, salt, SCRYPT.keyLen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: SCRYPT_MAXMEM });
175
+ const header = {
176
+ keystore: KEYSTORE_FORMAT,
177
+ cipher: "aes-256-gcm",
178
+ kdf: { algo: "scrypt", N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, keyLen: SCRYPT.keyLen, salt: salt.toString("hex") },
179
+ iv: iv.toString("hex"),
180
+ identity_id: payload.identity_id,
181
+ };
182
+ const cipher = (0, node_crypto_1.createCipheriv)("aes-256-gcm", key, iv);
183
+ cipher.setAAD(keystoreAad(header)); // bind format/kdf/iv/identity into the tag
184
+ const ct = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
185
+ return JSON.stringify({ ...header, tag: cipher.getAuthTag().toString("hex"), data: ct.toString("hex") });
186
+ }
187
+ function decryptPayload(fileText, passphrase) {
188
+ let env;
189
+ try {
190
+ env = JSON.parse(fileText);
191
+ }
192
+ catch {
193
+ throw new Error("witness-keystore: file is not valid JSON — refusing (fail closed)");
194
+ }
195
+ if (env.keystore !== KEYSTORE_FORMAT)
196
+ throw new Error("witness-keystore: unsupported keystore format — refusing");
197
+ const kdf = env.kdf;
198
+ if (!kdf || typeof kdf.N !== "number" || typeof kdf.salt !== "string")
199
+ throw new Error("witness-keystore: malformed kdf header — refusing");
200
+ // bound the attacker-influenced cost params before deriving (DoS guard)
201
+ if (kdf.N > 1 << 20 || kdf.r > 32 || kdf.p > 16)
202
+ throw new Error("witness-keystore: kdf cost parameters out of bounds — refusing");
203
+ const key = (0, node_crypto_1.scryptSync)(passphrase, Buffer.from(kdf.salt, "hex"), kdf.keyLen, { N: kdf.N, r: kdf.r, p: kdf.p, maxmem: SCRYPT_MAXMEM });
204
+ const header = { keystore: env.keystore, cipher: env.cipher, kdf: env.kdf, iv: env.iv, identity_id: env.identity_id };
205
+ const decipher = (0, node_crypto_1.createDecipheriv)("aes-256-gcm", key, Buffer.from(env.iv, "hex"));
206
+ decipher.setAAD(keystoreAad(header)); // any header tampering → tag mismatch below
207
+ decipher.setAuthTag(Buffer.from(env.tag, "hex"));
208
+ let pt;
209
+ try {
210
+ pt = Buffer.concat([decipher.update(Buffer.from(env.data, "hex")), decipher.final()]);
211
+ }
212
+ catch {
213
+ throw new Error("witness-keystore: decryption/authentication failed (wrong passphrase or tampered file) — refusing");
214
+ }
215
+ return JSON.parse(pt.toString("utf8"));
216
+ }
217
+ /**
218
+ * Encrypted, versioned custody for ONE witness identity. The store never
219
+ * exposes secrets except through sign(); rotation here only MINTS keys and
220
+ * records — making a rotation AUTHORITATIVE is the ledger's act (durable
221
+ * commit of the record), after which activate() is called.
222
+ */
223
+ class WitnessKeyStore {
224
+ filePath;
225
+ passphrase;
226
+ payload;
227
+ constructor(filePath, passphrase, payload) {
228
+ this.filePath = filePath;
229
+ this.passphrase = passphrase;
230
+ this.payload = payload;
231
+ }
232
+ /** Create a brand-new identity (version 1 active). Fails if file exists. */
233
+ static create(filePath, passphrase) {
234
+ if (!path.isAbsolute(filePath))
235
+ throw new Error("witness-keystore: path must be absolute");
236
+ if (passphrase.length < 8)
237
+ throw new Error("witness-keystore: passphrase too short (min 8 chars)");
238
+ const kp = (0, crypto_1.generateKeyPair)();
239
+ const payload = {
240
+ store_version: 1,
241
+ identity_id: (0, canonical_1.canonicalHash)({ witness_identity_genesis: kp.publicKey }),
242
+ versions: [{ version: 1, pubkey: kp.publicKey, status: "active", created_at: new Date().toISOString(), activated_at_tree_size: 0 }],
243
+ secrets: { "1": kp.secretKey },
244
+ };
245
+ const fd = fs.openSync(filePath, "wx", 0o600); // atomic create, never overwrite
246
+ fs.writeSync(fd, encryptPayload(payload, passphrase));
247
+ fs.fsyncSync(fd);
248
+ fs.closeSync(fd);
249
+ return new WitnessKeyStore(filePath, passphrase, payload);
250
+ }
251
+ /** Open an existing identity. Fails closed on any custody problem. */
252
+ static open(filePath, passphrase) {
253
+ const text = fs.readFileSync(filePath, "utf8");
254
+ return new WitnessKeyStore(filePath, passphrase, decryptPayload(text, passphrase));
255
+ }
256
+ get identityId() { return this.payload.identity_id; }
257
+ activeVersion() {
258
+ const v = this.payload.versions.find((x) => x.status === "active");
259
+ if (!v)
260
+ throw new Error("witness-keystore: no active key version — refusing");
261
+ return { ...v };
262
+ }
263
+ versionByNumber(n) {
264
+ const v = this.payload.versions.find((x) => x.version === n);
265
+ return v ? { ...v } : undefined;
266
+ }
267
+ /** Active keypair for the TransparencyLog (secret stays inside the seam). */
268
+ activeKeypair() {
269
+ const v = this.activeVersion();
270
+ const secret = this.payload.secrets[String(v.version)];
271
+ if (!secret)
272
+ throw new Error("witness-keystore: active secret missing — refusing");
273
+ return { publicKey: v.pubkey, secretKey: secret };
274
+ }
275
+ /** Sign with a specific version (used to countersign during rotation). */
276
+ signWith(version, messageHash) {
277
+ const secret = this.payload.secrets[String(version)];
278
+ if (!secret)
279
+ throw new Error(`witness-keystore: no secret for version ${version}`);
280
+ return (0, crypto_1.sign)(messageHash, secret);
281
+ }
282
+ /**
283
+ * Mint a PENDING next key version and the fully signed rotation record for
284
+ * it. Does NOT activate: the caller must durably commit the record to the
285
+ * evidence ledger first, then call activate(record).
286
+ */
287
+ mintRotation(input) {
288
+ const oldV = this.activeVersion();
289
+ if (this.payload.versions.some((v) => v.status === "pending")) {
290
+ throw new Error("witness-keystore: a pending rotation already exists — resolve it first");
291
+ }
292
+ const next = (0, crypto_1.generateKeyPair)();
293
+ const newVersion = oldV.version + 1;
294
+ const now = new Date().toISOString();
295
+ const body = {
296
+ spec: exports.ROTATION_SPEC,
297
+ rotation_id: (0, node_crypto_1.randomBytes)(16).toString("hex"),
298
+ identity_id: this.payload.identity_id,
299
+ old_version: oldV.version,
300
+ old_pubkey: oldV.pubkey,
301
+ new_version: newVersion,
302
+ new_pubkey: next.publicKey,
303
+ reason: input.reason,
304
+ initiated_at: now,
305
+ effective_at: now,
306
+ effective_tree_size: input.effectiveTreeSize,
307
+ last_old_sth_root: input.lastOldSthRoot,
308
+ previous_rotation_hash: input.previousRotationHash,
309
+ };
310
+ const signingHash = rotationSigningHash(body);
311
+ const old_key_signature = this.signWith(oldV.version, signingHash);
312
+ const new_key_countersignature = (0, crypto_1.sign)(signingHash, next.secretKey);
313
+ const record = {
314
+ ...body,
315
+ old_key_signature,
316
+ new_key_countersignature,
317
+ record_hash: rotationRecordHash({ ...body, old_key_signature, new_key_countersignature }),
318
+ };
319
+ this.payload.versions.push({ version: newVersion, pubkey: next.publicKey, status: "pending", created_at: now });
320
+ this.payload.secrets[String(newVersion)] = next.secretKey;
321
+ this.persist();
322
+ return { record };
323
+ }
324
+ /** Activate a pending version AFTER its rotation record is durably committed. */
325
+ activate(record) {
326
+ const pending = this.payload.versions.find((v) => v.version === record.new_version && v.status === "pending");
327
+ if (!pending)
328
+ throw new Error("witness-keystore: no matching pending version to activate");
329
+ if (pending.pubkey !== record.new_pubkey)
330
+ throw new Error("witness-keystore: pending key does not match the committed rotation record");
331
+ const old = this.payload.versions.find((v) => v.version === record.old_version);
332
+ if (old) {
333
+ old.status = "retired";
334
+ old.retired_at_tree_size = record.effective_tree_size;
335
+ }
336
+ pending.status = "active";
337
+ pending.activated_at_tree_size = record.effective_tree_size;
338
+ this.persist();
339
+ }
340
+ /** Abandon a pending rotation that was never durably committed. */
341
+ abandonPending() {
342
+ const idx = this.payload.versions.findIndex((v) => v.status === "pending");
343
+ if (idx < 0)
344
+ return;
345
+ const v = this.payload.versions[idx];
346
+ delete this.payload.secrets[String(v.version)];
347
+ this.payload.versions.splice(idx, 1);
348
+ this.persist();
349
+ }
350
+ /**
351
+ * Deterministic boot reconciliation against the canonical ledger's committed
352
+ * rotation truth. Implements the sovereign rule: KEY MATERIAL ALONE NEVER
353
+ * GRANTS AUTHORITY — only a validated, durably committed rotation does;
354
+ * startup reconciles local bookkeeping to that committed fact and never
355
+ * guesses. Returns a NON-SECRET diagnostic.
356
+ *
357
+ * - ledger active == keystore active → in sync (abandon any orphan pending).
358
+ * - ledger active == keystore active+1 with a matching committed record and
359
+ * the exact pending key+secret → complete activation (recovery of
360
+ * already-committed authority, NOT a new authority decision).
361
+ * - keystore pending with NO committed record → abandon it.
362
+ * - any other disagreement (>1 ahead, pubkey/secret mismatch, missing
363
+ * material) → throw, fail closed.
364
+ */
365
+ reconcileToCommitted(ledger) {
366
+ const ksActive = this.activeVersion();
367
+ const pending = this.payload.versions.find((v) => v.status === "pending");
368
+ if (ledger.activeVersion === ksActive.version && ledger.activePubkey === ksActive.pubkey) {
369
+ if (pending) {
370
+ const v = pending.version;
371
+ this.abandonPending();
372
+ return { action: "abandoned-orphan-pending", detail: `abandoned orphan pending v${v} (no committed rotation backs it)` };
373
+ }
374
+ return { action: "in-sync", detail: `witness identity active at v${ksActive.version}` };
375
+ }
376
+ if (ledger.activeVersion === ksActive.version + 1) {
377
+ const rec = ledger.latestRecord;
378
+ if (!rec || rec.new_version !== ledger.activeVersion || rec.new_pubkey !== ledger.activePubkey) {
379
+ throw new Error("witness-keystore: ledger epoch is ahead but its committed rotation record is missing/incoherent — refusing to reconcile (fail closed)");
380
+ }
381
+ if (!pending || pending.version !== ledger.activeVersion) {
382
+ throw new Error(`witness-keystore: ledger committed a rotation to v${ledger.activeVersion} but this keystore holds no matching pending version — new key material missing (fail closed)`);
383
+ }
384
+ if (pending.pubkey !== rec.new_pubkey) {
385
+ throw new Error("witness-keystore: pending key does not match the committed rotation's new key — refusing (fail closed)");
386
+ }
387
+ if (!this.payload.secrets[String(pending.version)]) {
388
+ throw new Error("witness-keystore: committed rotation's new secret is absent from the keystore — refusing (fail closed)");
389
+ }
390
+ this.activate(rec); // bookkeeping recovery of an authority event that already happened durably
391
+ return { action: "recovered-committed-rotation", detail: `recovered committed rotation: v${rec.old_version} → v${rec.new_version}` };
392
+ }
393
+ throw new Error(`witness-keystore: irreconcilable witness state — keystore active v${ksActive.version}, ledger active v${ledger.activeVersion} ` +
394
+ `(${ledger.activePubkey === ksActive.pubkey ? "pubkey match" : "pubkey MISMATCH"}). Refusing to guess; investigate (fail closed).`);
395
+ }
396
+ persist() {
397
+ // atomic replace: tmp (0600) + fsync + rename
398
+ const tmp = `${this.filePath}.tmp`;
399
+ const fd = fs.openSync(tmp, "w", 0o600);
400
+ fs.writeSync(fd, encryptPayload(this.payload, this.passphrase));
401
+ fs.fsyncSync(fd);
402
+ fs.closeSync(fd);
403
+ fs.renameSync(tmp, this.filePath);
404
+ }
405
+ }
406
+ exports.WitnessKeyStore = WitnessKeyStore;
@@ -15,11 +15,62 @@
15
15
  * from the trust root (good), but not externally managed (mirror the STHs to
16
16
  * an outside party to get full insider-resistance).
17
17
  */
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
18
41
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.rebuildWitnessFromReceipts = exports.witnessReceipt = exports.resetWitnessLog = exports.witnessLog = void 0;
42
+ exports.rotateWitness = exports.rebuildWitnessFromReceipts = exports.witnessReceipt = exports.resetWitnessLog = exports.witnessLog = exports.witnessKeyStore = void 0;
20
43
  const transparency_log_1 = require("./transparency-log");
21
44
  const ledger_1 = require("./ledger");
45
+ const witness_identity_1 = require("./witness-identity");
46
+ const file_ledger_store_1 = require("./file-ledger-store");
47
+ const fs = __importStar(require("node:fs"));
48
+ /**
49
+ * KEYSTORE MODE (preferred for durable deployments): MAGENTA_WITNESS_KEYFILE +
50
+ * MAGENTA_WITNESS_PASSPHRASE select encrypted persistent custody — the witness
51
+ * identity survives restart WITHOUT raw key material in the environment, and
52
+ * key versions/rotations are managed by the store. ENV MODE (legacy/simple)
53
+ * and ephemeral mode are unchanged. Setting keystore + env keys together is a
54
+ * configuration error (no ambiguous identity authority).
55
+ */
56
+ exports.witnessKeyStore = (() => {
57
+ const file = process.env.MAGENTA_WITNESS_KEYFILE;
58
+ if (!file)
59
+ return undefined;
60
+ const pass = process.env.MAGENTA_WITNESS_PASSPHRASE;
61
+ if (!pass)
62
+ throw new Error("witness: MAGENTA_WITNESS_KEYFILE is set but MAGENTA_WITNESS_PASSPHRASE is not — refusing");
63
+ if (process.env.MAGENTA_WITNESS_SECRET || process.env.MAGENTA_WITNESS_PUBLIC) {
64
+ throw new Error("witness: keystore mode and env-key mode are both configured — exactly one identity authority is allowed");
65
+ }
66
+ if (process.env.MAGENTA_STATE_FILE) {
67
+ throw new Error("witness: MAGENTA_WITNESS_KEYFILE and the legacy MAGENTA_STATE_FILE are both set — the keystore owns the witness identity; the legacy whole-state file must not also claim persistence authority. Use MAGENTA_LEDGER_FILE for durable evidence.");
68
+ }
69
+ return fs.existsSync(file) ? witness_identity_1.WitnessKeyStore.open(file, pass) : witness_identity_1.WitnessKeyStore.create(file, pass);
70
+ })();
22
71
  function loadWitnessKey() {
72
+ if (exports.witnessKeyStore)
73
+ return exports.witnessKeyStore.activeKeypair();
23
74
  const secretKey = process.env.MAGENTA_WITNESS_SECRET;
24
75
  const publicKey = process.env.MAGENTA_WITNESS_PUBLIC;
25
76
  if (secretKey && publicKey)
@@ -27,6 +78,30 @@ function loadWitnessKey() {
27
78
  return undefined;
28
79
  }
29
80
  exports.witnessLog = new transparency_log_1.TransparencyLog(loadWitnessKey(), ledger_1.sharedLedgerStore);
81
+ // BOOT RECONCILIATION (sovereign rule): reconcile local keystore bookkeeping
82
+ // to the canonical ledger's COMMITTED rotation truth. Never activates a
83
+ // pending key on mere file presence — only completes an already-durably-
84
+ // committed rotation, abandons orphan pending keys, or fails closed. Runs once
85
+ // at import, BEFORE any leaf rebuild, so the live signing key is the active
86
+ // epoch. No-op outside keystore+file-ledger mode; idempotent across restarts.
87
+ if (exports.witnessKeyStore && ledger_1.sharedLedgerStore instanceof file_ledger_store_1.FileLedgerStore) {
88
+ const led = ledger_1.sharedLedgerStore;
89
+ const activePub = led.activeWitnessPubkey();
90
+ if (activePub !== undefined) {
91
+ const evt = exports.witnessKeyStore.reconcileToCommitted({
92
+ activePubkey: activePub,
93
+ activeVersion: led.activeWitnessVersion(),
94
+ latestRecord: led.allRotations().at(-1),
95
+ });
96
+ if (evt.action === "recovered-committed-rotation") {
97
+ exports.witnessLog.rotateWitnessKey(exports.witnessKeyStore.activeKeypair());
98
+ }
99
+ if (evt.action !== "in-sync") {
100
+ // non-secret diagnostic — operator-visible recovery audit
101
+ console.error(`[witness] boot reconciliation: ${evt.action} — ${evt.detail}`);
102
+ }
103
+ }
104
+ }
30
105
  /** Reset the witness log (fresh universe / test isolation). Keeps the witness key. */
31
106
  function resetWitnessLog() {
32
107
  exports.witnessLog.reset();
@@ -74,3 +149,40 @@ function rebuildWitnessFromReceipts(receiptHashes) {
74
149
  }
75
150
  }
76
151
  exports.rebuildWitnessFromReceipts = rebuildWitnessFromReceipts;
152
+ /**
153
+ * Execute one witness-key rotation under the full continuity protocol:
154
+ * mint (pending) → DURABLY COMMIT the signed record to the evidence ledger
155
+ * → activate in the keystore → switch the live signing key.
156
+ * Requires keystore custody AND a durable file ledger (the single rotation
157
+ * authority). A failed durable commit abandons the pending key — the new key
158
+ * never becomes authoritative before the record is committed.
159
+ */
160
+ function rotateWitness(reason) {
161
+ if (!exports.witnessKeyStore)
162
+ throw new Error("witness: rotation requires keystore custody (MAGENTA_WITNESS_KEYFILE)");
163
+ if (!(ledger_1.sharedLedgerStore instanceof file_ledger_store_1.FileLedgerStore)) {
164
+ throw new Error("witness: rotation requires the durable file ledger (MAGENTA_LEDGER_FILE) — rotations must be durably recorded before activation");
165
+ }
166
+ const ledger = ledger_1.sharedLedgerStore;
167
+ const latest = ledger.latestSth();
168
+ if (!latest)
169
+ throw new Error("witness: cannot rotate before any checkpoint exists");
170
+ const prevRotations = ledger.allRotations();
171
+ const { record } = exports.witnessKeyStore.mintRotation({
172
+ reason,
173
+ effectiveTreeSize: exports.witnessLog.size(),
174
+ lastOldSthRoot: latest.root_hash,
175
+ previousRotationHash: prevRotations.length ? prevRotations[prevRotations.length - 1].record_hash : "0".repeat(64),
176
+ });
177
+ try {
178
+ ledger.appendRotation(record); // durable commit — the authority event
179
+ }
180
+ catch (e) {
181
+ exports.witnessKeyStore.abandonPending(); // never activate an uncommitted rotation
182
+ throw e;
183
+ }
184
+ exports.witnessKeyStore.activate(record);
185
+ exports.witnessLog.rotateWitnessKey(exports.witnessKeyStore.activeKeypair());
186
+ return record;
187
+ }
188
+ exports.rotateWitness = rotateWitness;
@@ -3,16 +3,17 @@
3
3
  How Magenta Canon is packaged as an installable CLI, what ships in the tarball,
4
4
  what deliberately does not, and how to verify the package locally.
5
5
 
6
- > **Status: published on npm.** The current published release is **`0.1.13`**,
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.4.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.
6
+ > **Status: published on npm.** The current published release is **`0.4.0`**
7
+ > (the durable file ledger), carried on **both** the `latest` and `next`
8
+ > dist-tags. This refresh prepares **`0.5.0`** — **durable witness identity
9
+ > and cryptographically authorized rotation continuity**: an encrypted witness
10
+ > keystore (`MAGENTA_WITNESS_KEYFILE`/`MAGENTA_WITNESS_PASSPHRASE`), dual-signed
11
+ > rotation records committed into the evidence ledger, epoch-aware verification
12
+ > from a pinned anchor, and deterministic crash reconciliation at boot.
13
+ > Founder/receipt-issuer key custody is **not** part of 0.5.0 it remains a
14
+ > separate forthcoming durability lane. `0.5.0` is **not yet published**;
15
+ > publishing remains an explicitly-authorized step done on an authenticated
16
+ > machine.
16
17
  >
17
18
  > **Verdict-semantics migration (0.3.0):** scripts that grepped
18
19
  > `RESULT: VERIFIED` must match the new verdicts — `RESULT: INTEGRITY
@@ -23,7 +24,7 @@ what deliberately does not, and how to verify the package locally.
23
24
 
24
25
  ## Release posture
25
26
 
26
- The **`latest`** dist-tag tracks the current release (`0.1.13`), so a plain
27
+ The **`latest`** dist-tag tracks the current release (`0.4.0`), so a plain
27
28
  `npm i magenta-canon` resolves the current reference implementation; the **`next`**
28
29
  tag points at the same version. It remains a **proven reference implementation**,
29
30
  not production-hosted infrastructure yet — production durability and an
@@ -57,7 +58,8 @@ npm dist-tag add magenta-canon@<version> next
57
58
  | `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) |
58
59
  | `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
60
  | `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 — **published**; `latest`/`next` aligned |
60
- | `0.4.0` | **durable file ledger** (Durable Witness PRs 1–2, #32/#33): `LedgerStore` seam; `FileLedgerStore` — append-only canonical-JSONL evidence ledger (`MAGENTA_LEDGER_FILE`), fsync-before-committed, exclusive writer lock with documented stale-lock recovery, strict boot replay (payload hashes, chain, issuer signatures, STH signatures, one-root-per-tree-size, recomputed Merkle roots), crash-tail quarantine vs corruption refusal, **witness-continuity enforcement** (regenerated witness identity refuses to continue an existing ledger), dual-authority prevention vs legacy `MAGENTA_STATE_FILE`; two-real-process restart proof (`ORIGIN AND INTEGRITY VERIFIED` after `SIGKILL`); ships `docs/FILE_LEDGER.md` — **not yet published** |
61
+ | `0.4.0` | **durable file ledger** (Durable Witness PRs 1–2, #32/#33): `LedgerStore` seam; `FileLedgerStore` — append-only canonical-JSONL evidence ledger (`MAGENTA_LEDGER_FILE`), fsync-before-committed, exclusive writer lock with documented stale-lock recovery, strict boot replay (payload hashes, chain, issuer signatures, STH signatures, one-root-per-tree-size, recomputed Merkle roots), crash-tail quarantine vs corruption refusal, **witness-continuity enforcement** (regenerated witness identity refuses to continue an existing ledger), dual-authority prevention vs legacy `MAGENTA_STATE_FILE`; two-real-process restart proof (`ORIGIN AND INTEGRITY VERIFIED` after `SIGKILL`); ships `docs/FILE_LEDGER.md` — **published**; `latest`/`next` aligned |
62
+ | `0.5.0` | **durable witness identity + authorized rotation continuity** (Durable Witness PR 3, #35): encrypted witness keystore (`MAGENTA_WITNESS_KEYFILE`/`MAGENTA_WITNESS_PASSPHRASE`; scrypt N=2^16 with bounded params → AES-256-GCM with header-bound AAD, `O_EXCL` create, atomic replace); **rotation records committed into the evidence ledger** (`magenta-rotation/1`: old-key authorization + new-key countersignature + `effective_tree_size` epoch boundary + chain hash) — *key material alone never grants authority; a validated, durably committed rotation record does*; epoch-aware replay in server **and** standalone verifier (historical STHs validate against their epoch's key; rollback/substitution/forked chains refused); deterministic crash reconciliation at boot (recover committed fact / abandon orphan / fail closed — never silent activation); single-authority config matrix (keystore × env-keys × `MAGENTA_STATE_FILE` exclusions); ships `docs/WITNESS_IDENTITY.md`. **Limitation (documented):** witness identity only — founder/receipt-issuer custody is a separate forthcoming lane (issuer key still regenerates on restart in file-ledger mode; pre-restart evidence keeps verifying). **Migration:** none required — 0.4.0 env-key and memory modes are unchanged; the keystore is opt-in and mutually exclusive with `MAGENTA_WITNESS_SECRET`/`PUBLIC` and `MAGENTA_STATE_FILE`. **Also in this release (repo hygiene, #37/#38):** the legacy RealityOS heartbeat adapter is **deprecated and removed** — excluded from the supported product architecture, never required for Magenta verification or distribution (`docs/LEGACY_REALITYOS_HEARTBEAT.md`); a tracked legacy dev identity was removed with a secret-hygiene CI gate added (`reports/INCIDENT_TRACKED_DEV_IDENTITY.md`) — **not yet published** |
61
63
 
62
64
 
63
65
  **Verified on macOS / Linux / Windows (current release `0.1.11`; `0.1.12` re-proven via installed-tarball smoke):**
@@ -256,28 +258,28 @@ MCP gateway, and a **local-file external STH mirror foundation** with an
256
258
  operator runbook. **Not** included: hosted mirror service, automated network
257
259
  publishing, production multi-tenant SaaS, production durability guarantees.
258
260
 
259
- ## Verifying the release artifact (checklist — current target `0.4.0`)
261
+ ## Verifying the release artifact (checklist — current target `0.5.0`)
260
262
 
261
263
  From a clean checkout of the release commit:
262
264
 
263
265
  ```bash
264
266
  npm ci && npm run check && npm test # 0 tsc errors; full suite green
265
267
  npm run build:lean && npm run build:lean # deterministic: dist/MANIFEST.json identical
266
- npm pack # prepack rebuilds dist/; expect magenta-canon-0.4.0.tgz
267
- tar -tzf magenta-canon-0.4.0.tgz # 53 files; no .ts, no .map, no tests, no server/routes.ts, no ledger/lock files
268
+ npm pack # prepack rebuilds dist/; expect magenta-canon-0.5.0.tgz
269
+ tar -tzf magenta-canon-0.5.0.tgz # 55 files; no .ts, no .map, no tests, no server/routes.ts, no ledger/lock/keystore files
268
270
  ```
269
271
 
270
272
  Then in an empty directory:
271
273
 
272
274
  ```bash
273
- npm init -y && npm i ../path/to/magenta-canon-0.4.0.tgz
275
+ npm init -y && npm i ../path/to/magenta-canon-0.5.0.tgz
274
276
  ls node_modules # exactly: magenta-canon tweetnacl zod
275
277
  npx magenta-canon verify --self-test # all verdict levels incl. tamper VERIFICATION FAILED
276
278
  npx magenta-canon mirror --self-test # [PASS] x3
277
279
  npx magenta-canon demo # allow / block / VERIFIED / tamper FAILED
278
280
  ```
279
281
 
280
- Expected artifact metrics: **49 files · ~124.7 kB packed · ~468 kB unpacked ·
282
+ Expected artifact metrics: **55 files · ~157.5 kB packed · ~577.9 kB unpacked ·
281
283
  deps `tweetnacl`+`zod` · 3-package consumer install · 0 install scripts**. The
282
284
  single expected scanner heuristic is TweetNaCl's minified upstream distribution
283
285
  (retained deliberately — see `docs/SUPPLY_CHAIN_REVIEW.md`).
@@ -76,6 +76,15 @@ Concretely, proven in `docs/MCP_GATEWAY_RUN.md` and `docs/VERIFICATION_RUN.md`:
76
76
  (`INCOMPLETE EVIDENCE`), and receipt issuer signatures are cryptographically
77
77
  enforced when present. Auditors verifying a disputed action should also pin
78
78
  the claim itself: `--require-receipt <hash>` / `--require-action-hash <hash>`.
79
+ 6. **Witness continuity is durable; trust-root continuity is not (yet).** With
80
+ the file ledger + witness keystore, the witness identity and its
81
+ cryptographically authorized rotation chain survive restart, so old
82
+ evidence keeps verifying and origin verification holds from a pinned
83
+ anchor. The **founder/root receipt issuer remains memory-resident** and is
84
+ regenerated on restart — receipts issued after a restart carry a new issuer
85
+ key. Trust-root/issuer custody is a separate forthcoming durability lane.
86
+ Do not read "witness identity survives restart" as "all signing authority
87
+ survives restart."
79
88
 
80
89
  ## Trust-key custody (root/intermediate never leave; actor key is caller-owned)
81
90
 
@@ -0,0 +1,134 @@
1
+ # Witness Identity, Custody, and Rotation Continuity
2
+
3
+ How Magenta's witness key persists across restarts, how it is protected at
4
+ rest, and how a **legitimate** key rotation is distinguished — cryptographically
5
+ and durably — from a silent key substitution.
6
+
7
+ ## Identity model
8
+
9
+ | Concept | Meaning |
10
+ |---|---|
11
+ | **Witness identity** | A stable identity (`identity_id` = hash of the genesis public key) that outlives any single keypair. |
12
+ | **Key version** | Numbered Ed25519 keypairs (`1, 2, …`) with status `pending → active → retired` (and `revoked` reserved). Exactly one active version at a time; versions never skip. |
13
+ | **Epoch** | The range of tree sizes a key version is authoritative for. STHs with `tree_size <= effective_tree_size` of a rotation belong to the OLD epoch; greater to the NEW. |
14
+ | **Anchor** | The version-1 public key an auditor/mirror pins. All later epochs must chain to it by signed rotation records. |
15
+
16
+ ## Trust-root limitation (read this — important for honest claims)
17
+
18
+ 0.5.0 makes the **witness identity** durable and rotatable. It does **not**
19
+ yet make the **trust-root / receipt-issuer** identity durable. In file-ledger
20
+ mode the founder/root key lives in memory and is **regenerated on restart**
21
+ (and cannot be persisted alongside the ledger — `MAGENTA_LEDGER_FILE` +
22
+ `MAGENTA_STATE_FILE` is refused). Consequences:
23
+
24
+ - Old evidence still verifies after restart (witness identity + rotation
25
+ chain persist; STHs remain valid; receipts keep their original issuer
26
+ signatures).
27
+ - **Receipts recorded *after* a restart are signed by a NEW issuer key** until
28
+ a future trust-root custody lane lands.
29
+
30
+ > Witness continuity survives restart and cryptographically authorized
31
+ > rotation. Trust-root and receipt-issuer custody are separate forthcoming
32
+ > durability lanes; in the current file-ledger mode the issuer identity is
33
+ > regenerated after restart.
34
+
35
+ Do not describe this as full identity continuity or complete production key
36
+ custody.
37
+
38
+ ## Custody modes (exactly one identity authority)
39
+
40
+ | Mode | Selection | Notes |
41
+ |---|---|---|
42
+ | **Keystore** (preferred for durable deployments) | `MAGENTA_WITNESS_KEYFILE` + `MAGENTA_WITNESS_PASSPHRASE` | Encrypted at rest: scrypt (N=2^16, bounded/validated) → AES-256-GCM with **AAD binding the plaintext envelope** (format/version, KDF params, IV, identity_id) so header tampering fails the tag; `0600`, atomic create (`O_EXCL`, never overwrites an identity) and atomic replace. Wrong passphrase / tampered file / malformed file / out-of-bounds KDF cost all **fail closed**. Secrets never appear in logs, receipts, evidence, rotation records, or error messages (test-enforced). Combining the keystore with `MAGENTA_WITNESS_SECRET/PUBLIC` **or** `MAGENTA_STATE_FILE` is a startup error. **Windows:** `0600` is advisory (mode is ignored by the OS); `O_EXCL` and atomic rename still hold. |
43
+ | Env keys (legacy/simple) | `MAGENTA_WITNESS_SECRET/PUBLIC` | Unchanged. Cannot be combined with keystore mode (startup error). |
44
+ | Ephemeral (default) | none | Fresh key per process — demo/tests. |
45
+
46
+ **This is local/reference custody — NOT KMS/HSM.** Cloud KMS, OS credential
47
+ stores, and customer-managed signing follow the custody architecture lane
48
+ (`docs/roadmaps/WITNESS_KEY_CUSTODY_ARCHITECTURE.md`).
49
+
50
+ ## Rotation continuity record (`magenta-rotation/1`)
51
+
52
+ A canonical signed record binding: `rotation_id` · `identity_id` ·
53
+ old/new version + pubkey · `reason` · `initiated_at`/`effective_at` ·
54
+ **`effective_tree_size`** (the exact ledger boundary) ·
55
+ **`last_old_sth_root`** (the preserved final old-key checkpoint) ·
56
+ `previous_rotation_hash` (rotation chain) · **old-key authorization
57
+ signature** · **new-key countersignature** · `record_hash`. Signatures are
58
+ Ed25519 over the UTF-8 bytes of the canonical hash of the record without its
59
+ signature/hash fields — the same contract as receipt issuer signatures, so
60
+ any spec-conforming verifier re-derives it.
61
+
62
+ **Anything less is unauthorized substitution** — verifiers and mirrors must
63
+ treat a key change without a complete valid record as an attack, not a rotation.
64
+
65
+ ## Rotation protocol (order is the security property)
66
+
67
+ ```
68
+ 1. keystore mints the PENDING next version + the dual-signed record
69
+ 2. the record is DURABLY COMMITTED to the evidence ledger ← authority event
70
+ 3. the keystore activates the new version (old → retired)
71
+ 4. the transparency log switches its signing key (new epoch begins)
72
+ ```
73
+
74
+ - A failed durable commit **abandons the pending key** — a new key can never
75
+ become authoritative before its continuity record is committed.
76
+ - The evidence ledger is the **single rotation authority** (`rotation` records
77
+ live in the same append-only file as receipts and STHs — no parallel
78
+ custody journal, no dual authority).
79
+ - Rollback: an STH under a retired key is refused at append; re-activating an
80
+ old key requires a NEW dual-signed rotation back to it.
81
+
82
+ ## Boot reconciliation (crash recovery — the sovereign rule)
83
+
84
+ **Key material alone never grants authority. Only a validated, durably
85
+ committed rotation record does.** At startup, in keystore + file-ledger mode,
86
+ the keystore reconciles its local bookkeeping to the ledger's committed truth:
87
+
88
+ | State at boot | Action |
89
+ |---|---|
90
+ | keystore active == ledger active epoch | **in sync** (and any stray pending key is abandoned) |
91
+ | ledger committed a rotation to vN, keystore holds the exact matching pending vN (pubkey + secret) | **recover**: complete activation (the authority event already happened durably — this is bookkeeping, not a new decision); switch the live signing key to vN |
92
+ | keystore has a pending key with **no** committed rotation record | **abandon** it (durably; it can never activate without a fresh authorized rotation) |
93
+ | ledger >1 epoch ahead, pubkey/secret mismatch, or new key material missing | **fail closed** — refuse to guess |
94
+
95
+ This resolves the crash-after-commit-before-activation window: a legitimately
96
+ committed rotation that crashed before the keystore caught up is recovered
97
+ deterministically rather than wedging writes. Recovery emits a non-secret
98
+ audit line. Crash boundaries are exhaustively tested in
99
+ `server/rotation-crash-boundary.test.ts` (B2 abandon, B3 recover + idempotent
100
+ re-restart, B4 epoch-aware rebuild, plus missing/wrong-key fail-closed).
101
+
102
+ Note: a pending key is only ever persisted *after* it is fully signed
103
+ (`mintRotation` is atomic mint+sign+persist), so "a pending key with no
104
+ signatures" is not a reachable durable state.
105
+
106
+ ## Replay & verification
107
+
108
+ - **Ledger replay** re-validates every rotation record in sequence (chain
109
+ hash, version increment, dual signatures, boundary == ledger position,
110
+ preserved final old-key root) and enforces that every STH is signed by the
111
+ key that is active at its position. Deleting or reordering a rotation
112
+ record turns the ledger into refused corruption.
113
+ - **Standalone verifier**: evidence bundles may carry `rotations`. With
114
+ `--expected-witness-key <v1-anchor>`, the verifier walks the chain from the
115
+ pinned anchor (pure re-implementation — no server code) and checks the STH
116
+ against the authorized key **for its epoch**. Verdicts are unchanged:
117
+ `ORIGIN AND INTEGRITY VERIFIED` requires the pinned anchor + a valid chain;
118
+ bundle-anchored chains remain `INTEGRITY VERIFIED` with the skip surfaced.
119
+
120
+ ## Operator procedure
121
+
122
+ - **Rotate:** `POST /internal/witness/rotate` (INTERNAL_API_KEY-gated;
123
+ requires keystore + file-ledger modes) or the `rotateWitness(reason)` API.
124
+ - **Backup:** the keystore file and the ledger file together; the passphrase
125
+ separately. Restoring an old keystore alongside a newer ledger fails boot
126
+ (the active version cannot sign the current epoch) — restore matched pairs.
127
+ - **Compromise response:** rotate immediately (the old key signs its own
128
+ retirement — if the OLD key is already attacker-held, the attacker can also
129
+ rotate: this is the classic CA problem; mirrors + the anchor pin bound the
130
+ damage to a detectable fork). Revocation records and out-of-band
131
+ re-anchoring are the documented follow-up lane.
132
+ - **Limitations:** single identity per keystore; rotation requires the
133
+ durable file ledger; trust-root (issuer) custody is a separate lane;
134
+ passphrase loss is unrecoverable by design.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magenta-canon",
3
- "version": "0.4.0",
3
+ "version": "0.5.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.",
@@ -44,6 +44,7 @@
44
44
  "docs/SECURITY_MODEL.md",
45
45
  "docs/NPM_PACKAGING.md",
46
46
  "docs/FILE_LEDGER.md",
47
+ "docs/WITNESS_IDENTITY.md",
47
48
  "public/canon/schemas/constitutional-spine.schema.json",
48
49
  "public/canon/spine/constitutional-spine.v1.json",
49
50
  "README.md",