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 +31 -0
- package/dist/MANIFEST.json +6 -5
- package/dist/scripts/demo-control-plane.js +25 -0
- package/dist/scripts/magenta-verify.js +76 -2
- package/dist/server/file-ledger-store.js +86 -1
- package/dist/server/transparency-log.js +42 -7
- package/dist/server/witness-identity.js +406 -0
- package/dist/server/witness.js +113 -1
- package/docs/NPM_PACKAGING.md +19 -17
- package/docs/SECURITY_MODEL.md +9 -0
- package/docs/WITNESS_IDENTITY.md +134 -0
- package/package.json +2 -1
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`.
|
package/dist/MANIFEST.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package.json": "8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a",
|
|
3
|
-
"scripts/demo-control-plane.js": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
28
|
+
"server/transparency-log.js": "7f238edc5cad4edc2c70a7b3e1977197cf2087e77789d181ffae0035f948391b",
|
|
29
29
|
"server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
|
|
30
|
-
"server/witness.js": "
|
|
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 (
|
|
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, "
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
package/dist/server/witness.js
CHANGED
|
@@ -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;
|
package/docs/NPM_PACKAGING.md
CHANGED
|
@@ -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.
|
|
7
|
-
> carried on **both** the `latest` and `next`
|
|
8
|
-
>
|
|
9
|
-
>
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
> issuer
|
|
14
|
-
>
|
|
15
|
-
> remains an explicitly-authorized step done on an authenticated
|
|
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.
|
|
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` — **
|
|
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.
|
|
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.
|
|
267
|
-
tar -tzf magenta-canon-0.
|
|
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.
|
|
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: **
|
|
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`).
|
package/docs/SECURITY_MODEL.md
CHANGED
|
@@ -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.
|
|
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",
|