magenta-canon 0.4.0 → 0.6.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
@@ -22,6 +22,7 @@ npx magenta-canon demo # full local proof loop: allow · block
22
22
  npx magenta-canon verify --self-test # verifier self-check: all verdict levels incl. a tamper FAIL
23
23
  npx magenta-canon gateway <config.json> # the stdio MCP capability gateway
24
24
  npx magenta-canon mirror --self-test # external STH mirror: catch operator history rewrites
25
+ npx magenta-canon sentinel pack # Sentinel Mesh: invariant-check the packed artifact
25
26
  ```
26
27
 
27
28
  Magenta Canon is a proven **reference implementation**, not production-hosted infra
@@ -190,6 +191,71 @@ This is single-writer local/reference durability — see
190
191
  [`docs/FILE_LEDGER.md`](docs/FILE_LEDGER.md) for the format, lock recovery,
191
192
  crash-tail policy, backup guidance, and limitations.
192
193
 
194
+ ### Witness identity & authorized key rotation — new in 0.5.0
195
+
196
+ Instead of raw env keys, the witness can hold its identity in an **encrypted
197
+ keystore** (scrypt → AES-256-GCM, header-bound AAD) and **rotate its signing
198
+ key under cryptographic authorization** — the outgoing key signs the rotation,
199
+ the incoming key countersigns, and the signed rotation record is committed
200
+ **into the evidence ledger itself** before the new key signs anything:
201
+
202
+ ```bash
203
+ MAGENTA_LEDGER_FILE=/var/lib/magenta/ledger.jsonl \
204
+ MAGENTA_WITNESS_KEYFILE=/var/lib/magenta/witness.keystore \
205
+ MAGENTA_WITNESS_PASSPHRASE=<operator passphrase> \
206
+ <your magenta process>
207
+ ```
208
+
209
+ **Key material alone never grants authority — a validated, durably committed
210
+ rotation record does.** Verifiers replay the rotation chain from a pinned
211
+ anchor: historical checkpoints validate against the key that was authorized
212
+ for their epoch, substitution/rollback/forks are refused, and
213
+ `--expected-witness-key` still earns `ORIGIN AND INTEGRITY VERIFIED` across
214
+ rotations from the *original* pinned key. A crash at any point in the rotation
215
+ ceremony reconciles deterministically at next boot (recover the committed
216
+ fact, abandon the orphaned key, or fail closed — never silent activation).
217
+
218
+ **Scope honesty:** this is local/reference custody (file keystore, not
219
+ KMS/HSM), and it covers the **witness** identity only — founder/receipt-issuer
220
+ key custody is a separate forthcoming lane (in file-ledger mode the issuer key
221
+ still regenerates on restart; old evidence keeps verifying). Keystore format,
222
+ rotation protocol, boot-reconciliation table, and operator procedures:
223
+ [`docs/WITNESS_IDENTITY.md`](docs/WITNESS_IDENTITY.md) (ships in the package).
224
+
225
+ ### Sentinel Mesh foundation — new in 0.6.0
226
+
227
+ Magenta Canon 0.6.0 introduces an **executable Sentinel Mesh** that protects
228
+ repository state, npm artifacts, dependency boundaries, release promotion,
229
+ witness identity, and cryptographically authorized witness-key rotation:
230
+ versioned invariants, deterministic repository / artifact / witness
231
+ evaluation, evidence-preserving violations, and scoped release-promotion
232
+ blocking.
233
+
234
+ ```bash
235
+ magenta-canon sentinel repository # invariant-check a git repository
236
+ magenta-canon sentinel artifact <tgz|dir> # invariant-check a packed artifact
237
+ magenta-canon sentinel pack # npm pack, then check the result
238
+ magenta-canon sentinel witness --ledger ledger.jsonl # witness rotation-authority check
239
+ magenta-canon sentinel invariants # list the invariant registry
240
+ ```
241
+
242
+ The **Witness & Rotation Sentinel** (MC-WIT-001…011) watches the 0.5.0
243
+ rotation-authority model itself: pinned-anchor continuity, dual-signed
244
+ rotation validity, exact epoch boundaries, epoch-correct STH signing,
245
+ retired-key resurrection / pre-activation refusal, and keystore↔ledger
246
+ agreement — re-derived independently from the published wire formats, so it
247
+ can disagree with a wrong (or compromised) server.
248
+
249
+ Sentinels are read-only, redacting (paths + fingerprints, never secret
250
+ bodies), deterministic, network-free, and emit `magenta-sentinel-violation/1`
251
+ evidence records; exit codes map to a scoped promotion decision (`eligible` /
252
+ `blocked` / `requires-independent-review`). Records at this layer are
253
+ **unsigned local diagnostics**, and the freeze is a release/build promotion
254
+ gate — **not** a production kill switch, a hosted Sentinel service, or
255
+ complete Mesh coverage (ledger/execution/configuration sentinels are future
256
+ lanes). Honest scope and the full model:
257
+ [`docs/SENTINEL_MESH.md`](docs/SENTINEL_MESH.md).
258
+
193
259
  ### From the repo vs. as a CLI
194
260
 
195
261
  - **From a repo checkout** (above): `npm install` then `npm run demo`.
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * magenta-canon — CLI entry point for the open-source reference implementation.
4
4
  *
@@ -48,6 +48,8 @@ Usage:
48
48
  verify <mirror>, check <mirror> <bundle>, --self-test.
49
49
  magenta-canon mirror-feed ... One feed cycle for a mirror operator:
50
50
  (--bundle <file> | --url <evidence-url>) --mirror <file>.
51
+ magenta-canon sentinel <cmd> Sentinel Mesh: repository | artifact <tgz|dir> |
52
+ pack | invariants (deterministic invariant checks).
51
53
  magenta-canon --help Show this help.
52
54
  magenta-canon --version Print the version.
53
55
 
@@ -110,6 +112,11 @@ switch (sub) {
110
112
  // Plain-Node operator helper; delegates all mirror semantics to `mirror`.
111
113
  run(process.execPath, [path.join(ROOT, "scripts", "mirror-feed.mjs"), ...rest], { cwd: process.cwd() });
112
114
  break;
115
+ case "sentinel":
116
+ // Sentinel Mesh foundation: deterministic invariant evaluation over a
117
+ // repository or a packed artifact (read-only; unsigned local diagnostics).
118
+ runTs("scripts/magenta-sentinel.ts", rest, { cwd: process.cwd() });
119
+ break;
113
120
  case "-v":
114
121
  case "--version":
115
122
  case "version":
@@ -1,15 +1,16 @@
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-sentinel.js": "0280d7584524201551c61a93beb1f2ee4964e76b0e44eb7c9d15a0f5a95cafdc",
7
+ "scripts/magenta-verify.js": "0bb65ef6ff1350789eecc4f0434ca94dcf3a89930f8ef1d62cc38100e18094ba",
7
8
  "scripts/mcp-gateway.js": "335923004b73511fe42ea0fc6f2e567afe8018dc95464a79027e4c2537e30de1",
8
9
  "server/agent-policy.js": "20dd9150f8244c33aaf14ecdf3a09f471210960d246cda9ab8d5abe0e8433518",
9
10
  "server/agent-record.js": "790bbfa2a10601c2bdcd98873e6e41babdf96d1df7c7ca34f19791de4c8b860f",
10
11
  "server/crypto.js": "ebbcb2929e7b3651e4ec04c9fbf3dcb656e3ae0d30bf8864f3642f72f3be2f39",
11
12
  "server/execution-receipts.js": "171a506df7d1e99f3370de9a41c1a4f0b21b38d1f02edc78d9c44e68c93dbee4",
12
- "server/file-ledger-store.js": "92dc58d313be183e53817455ee9fd4ab7cb2f3a171343de66f5db26e0f139d79",
13
+ "server/file-ledger-store.js": "216fe644e1753a5527bec6ccf58175d22ec695c006e1927dc3090be11df9c124",
13
14
  "server/intake/categories.js": "e9860eee0e2e0fe96c7ade165e5e068fae0874de2c7d39ff796efc91e713013b",
14
15
  "server/intake/engine.js": "abb8feacd925520cbf01acc2e932d9ebb037a3d016b053b7b10deaf8f545a605",
15
16
  "server/intake/gateway-intake.js": "40514fa489b031c29725e9b837e83998217bbc1ae84cf9259154a16b1faae2e8",
@@ -24,12 +25,21 @@
24
25
  "server/ledger-store.js": "fc8a46f925908764bac7a076cffa08053af4c1f93237bee408eb0ed1e61eeca2",
25
26
  "server/ledger.js": "9bf8f132e08d636070d2ede568b2eaf75810be245c90ecd5979d2d4de69af934",
26
27
  "server/persistence.js": "2e6b1d0b1c74babbd581780b028c2593256c5ec96c2d60f4c25159e3f54e4e3f",
28
+ "server/sentinel/artifact-sentinel.js": "93bae917af313c9247f8062af148e7c25ccfd16d0507fa85e755c64056715f8d",
29
+ "server/sentinel/promotion.js": "68ac5e54eb8c07d5c75ba0d2325d595b1583557840cc46322b2053dd9924a0be",
30
+ "server/sentinel/repository-sentinel.js": "722e973c8860cf281b29db8c4a22ec3cf473afcb088247916e8278d5e11638cf",
31
+ "server/sentinel/rules.js": "ab24435df4f6f5b10f8b721fde37fe7fa894453648f17f04923cab56020c459f",
32
+ "server/sentinel/tar-reader.js": "ded811eefebf3e4afa332d1bc0a48318cc22ad824222eb15b2dc08eb51048272",
33
+ "server/sentinel/violation.js": "70d44ae5eb43a560fada399b31eac1a2beccb66916340e1f3e9b549731776b38",
34
+ "server/sentinel/witness-sentinel.js": "f024dbe9fb505c7520ec466ed31bf9b3284c3c3066dbd287121d2e4397d3fb0a",
27
35
  "server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
28
- "server/transparency-log.js": "826dbae845726853b0736d10546a2c65596357530b82af0d1a781ac368c69a79",
36
+ "server/transparency-log.js": "7f238edc5cad4edc2c70a7b3e1977197cf2087e77789d181ffae0035f948391b",
29
37
  "server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
30
- "server/witness.js": "1963401588a48817f7e478d3824a4752d8a3f438f277ba4443158abcedfd6a47",
38
+ "server/witness-identity.js": "9dd5b6e63aefa00171a838d1cd27e3c09ddcb7d39512427ebe537763fe5fc42e",
39
+ "server/witness.js": "b1a8209d4dae4ffafc3ca4a158474b369250472ba80cef03bd92048282be91a4",
31
40
  "shared/canonical.js": "476cee4b5c5702e8a2a198e79c2639cf8ff84000ffb92f68a04433ed2a2f5484",
32
41
  "shared/certificates.js": "7844e71a38e1b1324586c7d054b2f5c15222b86446318d1e6dea9bab504a4a73",
42
+ "shared/invariants.js": "414a2aac034c9b28d0e32fc4afefb9b9ab723ed465b4aba1f8da98dab08363d4",
33
43
  "shared/schema.js": "f89190990e3826e44c722d5e8f5b39fd59b4d3fb9ec047229930a17604e4646c",
34
44
  "shared/terminating-kernel.js": "8e2aa68d47d6780e4a70822482e3e410924e9d1af843f301a487da0af37ed047"
35
45
  }
@@ -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) {
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || function (mod) {
20
+ if (mod && mod.__esModule) return mod;
21
+ var result = {};
22
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
23
+ __setModuleDefault(result, mod);
24
+ return result;
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ /**
28
+ * magenta-sentinel — Sentinel Mesh CLI (foundation layer).
29
+ *
30
+ * magenta-canon sentinel repository [--root <dir>] [--json]
31
+ * magenta-canon sentinel artifact <tgz|package-dir> [--expected-version <v>] [--json]
32
+ * magenta-canon sentinel pack [--json] (npm pack, then evaluate the tarball)
33
+ *
34
+ * Exit codes: 0 eligible/diagnostic-only · 1 blocked · 2 requires-independent-review
35
+ * 3 usage / subject unreadable.
36
+ *
37
+ * Honesty: all records emitted here are UNSIGNED LOCAL DIAGNOSTICS. They are
38
+ * structured findings, not third-party-verifiable attestations.
39
+ */
40
+ const node_child_process_1 = require("node:child_process");
41
+ const node_fs_1 = require("node:fs");
42
+ const path = __importStar(require("node:path"));
43
+ const repository_sentinel_1 = require("../server/sentinel/repository-sentinel");
44
+ const artifact_sentinel_1 = require("../server/sentinel/artifact-sentinel");
45
+ const witness_sentinel_1 = require("../server/sentinel/witness-sentinel");
46
+ const promotion_1 = require("../server/sentinel/promotion");
47
+ const invariants_1 = require("../shared/invariants");
48
+ const USAGE = `magenta-sentinel — deterministic invariant evaluation (Sentinel Mesh foundation)
49
+
50
+ Usage:
51
+ sentinel repository [--root <dir>] [--json]
52
+ sentinel artifact <tarball.tgz | package-dir> [--expected-version <v>] [--json]
53
+ sentinel pack [--json]
54
+ sentinel witness --ledger <file> [--keystore <file>] [--passphrase-env <VAR>]
55
+ [--anchor <pubkey-hex>] [--json]
56
+ sentinel invariants [--json]
57
+
58
+ Exit: 0 eligible/diagnostic-only · 1 blocked · 2 requires review · 3 usage/unreadable
59
+ `;
60
+ function flag(args, name) {
61
+ const i = args.indexOf(name);
62
+ if (i >= 0) {
63
+ args.splice(i, 1);
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ function option(args, name) {
69
+ const i = args.indexOf(name);
70
+ if (i >= 0 && i + 1 < args.length) {
71
+ const v = args[i + 1];
72
+ args.splice(i, 2);
73
+ return v;
74
+ }
75
+ return undefined;
76
+ }
77
+ function printHuman(kind, subject, result, promo) {
78
+ console.log(`magenta-sentinel ${kind}`);
79
+ console.log(` subject: ${subject}`);
80
+ console.log(` invariants evaluated: ${result.evaluated_invariants.length} — passed ${result.passed.length}, failed ${result.failed.length}`);
81
+ for (const f of result.findings) {
82
+ console.log(` [FAIL] ${f.invariant_id} (${f.severity}, ${f.disposition})`);
83
+ console.log(` observed: ${f.observed}`);
84
+ console.log(` paths: ${f.paths.join(", ") || "-"}`);
85
+ }
86
+ for (const v of result.violations) {
87
+ console.log(` evidence: ${v.invariant_id} record ${v.record_hash.slice(0, 16)}… (${v.assurance})`);
88
+ }
89
+ if (result.findings.length === 0)
90
+ console.log(" all evaluated invariants hold");
91
+ console.log(` PROMOTION: ${promo.decision.toUpperCase()}${promo.evidence_complete ? "" : " (evidence INCOMPLETE)"}`);
92
+ }
93
+ function exitFor(promo) {
94
+ switch (promo.decision) {
95
+ case "eligible":
96
+ case "diagnostic-only": return 0;
97
+ case "blocked": return 1;
98
+ case "requires-independent-review": return 2;
99
+ }
100
+ }
101
+ const argv = process.argv.slice(2);
102
+ const sub = argv.shift();
103
+ const json = flag(argv, "--json");
104
+ try {
105
+ if (sub === "repository") {
106
+ const root = option(argv, "--root") ?? process.cwd();
107
+ const res = (0, repository_sentinel_1.runRepositorySentinel)({ repoRoot: path.resolve(root) });
108
+ const promo = (0, promotion_1.decidePromotion)({ subject: `repository:${res.subject_identity}`, findings: res.findings, violations: res.violations });
109
+ if (json)
110
+ console.log(JSON.stringify({ result: res, promotion: promo }, null, 2));
111
+ else
112
+ printHuman("repository", res.subject_identity, res, promo);
113
+ process.exit(exitFor(promo));
114
+ }
115
+ else if (sub === "artifact") {
116
+ const target = argv.find((a) => !a.startsWith("-"));
117
+ if (!target) {
118
+ console.error("sentinel artifact: missing <tarball|dir>\n\n" + USAGE);
119
+ process.exit(3);
120
+ }
121
+ const expectedVersion = option(argv, "--expected-version");
122
+ const res = (0, artifact_sentinel_1.runArtifactSentinel)(path.resolve(target), { expectedVersion });
123
+ const promo = (0, promotion_1.decidePromotion)({ subject: `artifact:${res.subject_identity}`, findings: res.findings, violations: res.violations });
124
+ if (json)
125
+ console.log(JSON.stringify({ result: res, promotion: promo }, null, 2));
126
+ else
127
+ printHuman("artifact", `${res.artifact_name ?? "?"}@${res.artifact_version ?? "?"} (${res.subject_identity.slice(0, 19)}…, ${res.file_count} files)`, res, promo);
128
+ process.exit(exitFor(promo));
129
+ }
130
+ else if (sub === "pack") {
131
+ const out = (0, node_child_process_1.execFileSync)("npm", ["pack", "--silent"], { cwd: process.cwd(), encoding: "utf8", shell: process.platform === "win32" });
132
+ const tgz = out.trim().split(/\r?\n/).pop();
133
+ if (!(0, node_fs_1.existsSync)(tgz)) {
134
+ console.error(`sentinel pack: npm pack did not produce ${tgz}`);
135
+ process.exit(3);
136
+ }
137
+ const res = (0, artifact_sentinel_1.runArtifactSentinel)(path.resolve(tgz));
138
+ const promo = (0, promotion_1.decidePromotion)({ subject: `artifact:${res.subject_identity}`, findings: res.findings, violations: res.violations });
139
+ if (json)
140
+ console.log(JSON.stringify({ result: res, promotion: promo, tarball: tgz }, null, 2));
141
+ else {
142
+ console.log(` packed: ${tgz}`);
143
+ printHuman("artifact", `${res.artifact_name}@${res.artifact_version}`, res, promo);
144
+ }
145
+ process.exit(exitFor(promo));
146
+ }
147
+ else if (sub === "witness") {
148
+ const ledger = option(argv, "--ledger");
149
+ if (!ledger) {
150
+ console.error("sentinel witness: missing --ledger <file>\n\n" + USAGE);
151
+ process.exit(3);
152
+ }
153
+ const keystore = option(argv, "--keystore");
154
+ const res = (0, witness_sentinel_1.runWitnessSentinel)({
155
+ ledgerPath: path.resolve(ledger),
156
+ keystorePath: keystore ? path.resolve(keystore) : undefined,
157
+ passphraseEnv: option(argv, "--passphrase-env"),
158
+ anchorPubkey: option(argv, "--anchor"),
159
+ });
160
+ const promo = (0, promotion_1.decidePromotion)({ subject: `witness:${res.subject_identity}`, findings: res.findings, violations: res.violations });
161
+ if (json)
162
+ console.log(JSON.stringify({ result: res, promotion: promo }, null, 2));
163
+ else {
164
+ printHuman("witness", `${res.subject_identity.slice(0, 27)}… (${res.ledger_sth_count} STH, ${res.ledger_rotation_count} rotation(s), tip ${res.tip_fingerprint ?? "-"})`, res, promo);
165
+ for (const n of res.not_evaluated)
166
+ console.log(` [NOT EVALUATED] ${n.invariant_id}: ${n.reason}`);
167
+ if (res.incomplete_tail_ignored)
168
+ console.log(" note: incomplete trailing ledger line ignored (crash-tail semantics)");
169
+ }
170
+ process.exit(exitFor(promo));
171
+ }
172
+ else if (sub === "invariants") {
173
+ if (json)
174
+ console.log(JSON.stringify(invariants_1.INVARIANTS, null, 2));
175
+ else
176
+ for (const i of invariants_1.INVARIANTS)
177
+ console.log(`${i.id} v${i.version} ${i.status.padEnd(9)} ${i.severity.padEnd(8)} ${i.disposition.padEnd(26)} ${i.name}`);
178
+ process.exit(0);
179
+ }
180
+ else {
181
+ process.stdout.write(USAGE);
182
+ process.exit(sub === undefined || sub === "--help" || sub === "-h" ? 0 : 3);
183
+ }
184
+ }
185
+ catch (e) {
186
+ if (e instanceof artifact_sentinel_1.TarError) {
187
+ console.error(`sentinel: artifact unreadable (refusing to guess): ${e.message}`);
188
+ process.exit(3);
189
+ }
190
+ console.error(`sentinel: ${e.message}`);
191
+ process.exit(3);
192
+ }
@@ -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()}`;