magenta-canon 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/MANIFEST.json +7 -4
- package/dist/scripts/demo-control-plane.js +8 -0
- package/dist/server/file-ledger-store.js +411 -0
- package/dist/server/ledger-store.js +118 -0
- package/dist/server/ledger.js +38 -0
- package/dist/server/storage.js +40 -28
- package/dist/server/transparency-log.js +33 -6
- package/dist/server/witness.js +9 -1
- package/docs/FILE_LEDGER.md +111 -0
- package/docs/NPM_PACKAGING.md +8 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -170,6 +170,26 @@ npx magenta-canon verify bundle.json --expected-witness-key witness.pub
|
|
|
170
170
|
|
|
171
171
|
Full walkthrough: [`docs/MCP_GATEWAY.md`](docs/MCP_GATEWAY.md).
|
|
172
172
|
|
|
173
|
+
### Durable evidence (file ledger) — new in 0.4.0
|
|
174
|
+
|
|
175
|
+
By default evidence lives in memory (the demo's fresh-universe behavior). For
|
|
176
|
+
evidence that **survives a process restart**, point the gateway/control plane
|
|
177
|
+
at an append-only file ledger and pin the witness identity:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
MAGENTA_LEDGER_FILE=/var/lib/magenta/ledger.jsonl \
|
|
181
|
+
MAGENTA_WITNESS_SECRET=<hex> MAGENTA_WITNESS_PUBLIC=<hex> \
|
|
182
|
+
<your magenta process>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Every receipt and signed tree head is fsync-committed before it is reported
|
|
186
|
+
recorded; on restart the ledger is strictly replayed (chain, issuer
|
|
187
|
+
signatures, Merkle roots, checkpoint history) or startup fails loudly. One
|
|
188
|
+
exclusive writer per ledger; corruption is refused, never repaired silently.
|
|
189
|
+
This is single-writer local/reference durability — see
|
|
190
|
+
[`docs/FILE_LEDGER.md`](docs/FILE_LEDGER.md) for the format, lock recovery,
|
|
191
|
+
crash-tail policy, backup guidance, and limitations.
|
|
192
|
+
|
|
173
193
|
### From the repo vs. as a CLI
|
|
174
194
|
|
|
175
195
|
- **From a repo checkout** (above): `npm install` then `npm run demo`.
|
package/dist/MANIFEST.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package.json": "8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a",
|
|
3
|
-
"scripts/demo-control-plane.js": "
|
|
3
|
+
"scripts/demo-control-plane.js": "6d58bd7e816029b3c23b8c3b5f8812a5bd8ae1f4b5d6a70851eeaf6cb577dbc1",
|
|
4
4
|
"scripts/intake-cli.js": "189014db22d6fccb034ed1a93ec11efe8905be820bc468366c68bb2cabaf8a97",
|
|
5
5
|
"scripts/magenta-mirror.js": "cf701065f7a6e20f7f44a9b815396aeb5fe031c3f003bcd793b8014ea8801b85",
|
|
6
6
|
"scripts/magenta-verify.js": "f79c60944bef65926530c2d0fd5cc35df10319c5831764b035e547b6bfebe715",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"server/agent-record.js": "790bbfa2a10601c2bdcd98873e6e41babdf96d1df7c7ca34f19791de4c8b860f",
|
|
10
10
|
"server/crypto.js": "ebbcb2929e7b3651e4ec04c9fbf3dcb656e3ae0d30bf8864f3642f72f3be2f39",
|
|
11
11
|
"server/execution-receipts.js": "171a506df7d1e99f3370de9a41c1a4f0b21b38d1f02edc78d9c44e68c93dbee4",
|
|
12
|
+
"server/file-ledger-store.js": "92dc58d313be183e53817455ee9fd4ab7cb2f3a171343de66f5db26e0f139d79",
|
|
12
13
|
"server/intake/categories.js": "e9860eee0e2e0fe96c7ade165e5e068fae0874de2c7d39ff796efc91e713013b",
|
|
13
14
|
"server/intake/engine.js": "abb8feacd925520cbf01acc2e932d9ebb037a3d016b053b7b10deaf8f545a605",
|
|
14
15
|
"server/intake/gateway-intake.js": "40514fa489b031c29725e9b837e83998217bbc1ae84cf9259154a16b1faae2e8",
|
|
@@ -20,11 +21,13 @@
|
|
|
20
21
|
"server/intake/rationale.js": "3ea64a1430828ae5a06b41f4e93d06cdd667850d6a2ff9a1e266f98bdfc6c404",
|
|
21
22
|
"server/intake/types.js": "014891c6c8360648d6954b3185ae92d56be625484bf2f0d0f71dac271a59e23b",
|
|
22
23
|
"server/intake/witness.js": "588ad166f4870d65a6ed334c70aaa2deb1d6d96ec97d889902a9eda7528bd34f",
|
|
24
|
+
"server/ledger-store.js": "fc8a46f925908764bac7a076cffa08053af4c1f93237bee408eb0ed1e61eeca2",
|
|
25
|
+
"server/ledger.js": "9bf8f132e08d636070d2ede568b2eaf75810be245c90ecd5979d2d4de69af934",
|
|
23
26
|
"server/persistence.js": "2e6b1d0b1c74babbd581780b028c2593256c5ec96c2d60f4c25159e3f54e4e3f",
|
|
24
|
-
"server/storage.js": "
|
|
25
|
-
"server/transparency-log.js": "
|
|
27
|
+
"server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
|
|
28
|
+
"server/transparency-log.js": "826dbae845726853b0736d10546a2c65596357530b82af0d1a781ac368c69a79",
|
|
26
29
|
"server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
|
|
27
|
-
"server/witness.js": "
|
|
30
|
+
"server/witness.js": "1963401588a48817f7e478d3824a4752d8a3f438f277ba4443158abcedfd6a47",
|
|
28
31
|
"shared/canonical.js": "476cee4b5c5702e8a2a198e79c2639cf8ff84000ffb92f68a04433ed2a2f5484",
|
|
29
32
|
"shared/certificates.js": "7844e71a38e1b1324586c7d054b2f5c15222b86446318d1e6dea9bab504a4a73",
|
|
30
33
|
"shared/schema.js": "f89190990e3826e44c722d5e8f5b39fd59b4d3fb9ec047229930a17604e4646c",
|
|
@@ -33,6 +33,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
33
33
|
const storage_1 = require("../server/storage");
|
|
34
34
|
const witness_1 = require("../server/witness");
|
|
35
35
|
const agent_record_1 = require("../server/agent-record");
|
|
36
|
+
const witness_2 = require("../server/witness");
|
|
36
37
|
const trust_bootstrap_1 = require("../server/trust-bootstrap");
|
|
37
38
|
const PORT = Number(process.env.PORT ?? 0);
|
|
38
39
|
const HOST = "127.0.0.1";
|
|
@@ -164,6 +165,13 @@ const server = (0, node_http_1.createServer)(async (req, res) => {
|
|
|
164
165
|
sendJson(res, 500, { error: "internal_error", message: e instanceof Error ? e.message : String(e) });
|
|
165
166
|
}
|
|
166
167
|
});
|
|
168
|
+
// File-ledger restart support: rebuild the witness's derived leaf view from
|
|
169
|
+
// any receipts the durable store replayed (no-op in the default memory mode).
|
|
170
|
+
void (async () => {
|
|
171
|
+
const restored = await storage_1.storage.getExecutionReceipts();
|
|
172
|
+
if (restored.length > 0)
|
|
173
|
+
(0, witness_2.rebuildWitnessFromReceipts)([...restored].reverse().map((r) => r.receipt_hash));
|
|
174
|
+
})();
|
|
167
175
|
server.listen(PORT, HOST, () => {
|
|
168
176
|
const addr = server.address();
|
|
169
177
|
const port = typeof addr === "object" && addr ? addr.port : PORT;
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* FILE LEDGER STORE — Durable Witness PR 2.
|
|
4
|
+
*
|
|
5
|
+
* Append-only, fsync-committed, exclusively-locked file implementation of the
|
|
6
|
+
* LedgerStore contract (server/ledger-store.ts). This is the first lane where
|
|
7
|
+
* Magenta evidence survives a real process restart:
|
|
8
|
+
*
|
|
9
|
+
* action → receipt appended → write + fsync → "committed" returned
|
|
10
|
+
* → restart → strict replay → same chain, same Merkle root, same STH
|
|
11
|
+
* history → evidence still verifies (or startup FAILS LOUDLY).
|
|
12
|
+
*
|
|
13
|
+
* THE COMMIT GUARANTEE: a receipt or STH checkpoint is reported committed
|
|
14
|
+
* only after its record has been written and fsync'd. The in-memory view
|
|
15
|
+
* advances only after the durable write succeeds. A failed write surfaces an
|
|
16
|
+
* error, leaves the in-memory state unchanged, and never silently degrades
|
|
17
|
+
* to memory-only mode.
|
|
18
|
+
*
|
|
19
|
+
* FILE FORMAT — canonical JSON Lines (auditable with `less`):
|
|
20
|
+
* {"v":1,"t":"header","genesis":"<64hex>","created":"<iso>"}
|
|
21
|
+
* {"v":1,"t":"receipt","seq":<n>,"payload":{...receipt...},"payload_hash":"<canonicalHash(payload)>"}
|
|
22
|
+
* {"v":1,"t":"sth","payload":{...sth...},"payload_hash":"<canonicalHash(payload)>"}
|
|
23
|
+
* One record per line, no pretty-printing, bounded line size (MAX_LINE bytes).
|
|
24
|
+
* Unknown versions/types and malformed committed records FAIL CLOSED. The
|
|
25
|
+
* store never reinterprets, repairs, re-sorts, or silently drops history.
|
|
26
|
+
*
|
|
27
|
+
* CRASH-TAIL POLICY (strict, two distinct cases):
|
|
28
|
+
* - INCOMPLETE FINAL TAIL (no terminating newline, or the final
|
|
29
|
+
* newline-terminated fragment is not parseable JSON): provably never a
|
|
30
|
+
* committed record (commit = full line + "\n" + fsync). It is QUARANTINED
|
|
31
|
+
* to `<ledger>.tail-quarantine-<ts>` and truncated, with a loud audit log.
|
|
32
|
+
* - COMPLETE BUT INVALID RECORD (parses, but bad hash / broken chain /
|
|
33
|
+
* duplicate / conflicting STH / bad signature), anywhere including last:
|
|
34
|
+
* CORRUPTION → startup refuses with line number and reason. No automatic
|
|
35
|
+
* truncation, no blank universe, no backup auto-selection.
|
|
36
|
+
*
|
|
37
|
+
* EXCLUSIVE WRITER: `<ledger>.lock` created with O_EXCL, containing
|
|
38
|
+
* {pid, hostname, created, nonce, ledger}. A second writer fails before
|
|
39
|
+
* accepting any traffic. A stale lock is REPORTED with its contents and the
|
|
40
|
+
* exact operator recovery step — never auto-deleted on a PID heuristic.
|
|
41
|
+
*
|
|
42
|
+
* APPEND-ONLY SEMANTICS FOR LIFECYCLE METHODS: clearReceipts/clearSths
|
|
43
|
+
* (test-isolation hooks on the memory store) THROW here — a durable evidence
|
|
44
|
+
* ledger refuses destructive clears. restoreReceipts accepts only a snapshot
|
|
45
|
+
* matching the current committed tail (the mutation-rollback path after a
|
|
46
|
+
* FAILED append is a no-op by construction); restoring any other state would
|
|
47
|
+
* rewrite history and throws.
|
|
48
|
+
*
|
|
49
|
+
* SNAPSHOT COHERENCE: all writes are synchronous under the single-threaded
|
|
50
|
+
* event loop, so reads can never observe a torn receipt/STH combination;
|
|
51
|
+
* snapshotReceipts() copies, exactly like the memory store.
|
|
52
|
+
*
|
|
53
|
+
* Scope honesty: single-writer, single-host, reference-grade durability.
|
|
54
|
+
* NOT multi-tenant, NOT hosted production infrastructure — that is the
|
|
55
|
+
* PostgresLedgerStore lane (architecture §2.2 option A).
|
|
56
|
+
*/
|
|
57
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
58
|
+
if (k2 === undefined) k2 = k;
|
|
59
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
60
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
61
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
62
|
+
}
|
|
63
|
+
Object.defineProperty(o, k2, desc);
|
|
64
|
+
}) : (function(o, m, k, k2) {
|
|
65
|
+
if (k2 === undefined) k2 = k;
|
|
66
|
+
o[k2] = m[k];
|
|
67
|
+
}));
|
|
68
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
69
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
70
|
+
}) : function(o, v) {
|
|
71
|
+
o["default"] = v;
|
|
72
|
+
});
|
|
73
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
74
|
+
if (mod && mod.__esModule) return mod;
|
|
75
|
+
var result = {};
|
|
76
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
77
|
+
__setModuleDefault(result, mod);
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
81
|
+
exports.FileLedgerStore = exports.LedgerLockedError = exports.LedgerCorruptionError = void 0;
|
|
82
|
+
const fs = __importStar(require("node:fs"));
|
|
83
|
+
const path = __importStar(require("node:path"));
|
|
84
|
+
const os = __importStar(require("node:os"));
|
|
85
|
+
const node_crypto_1 = require("node:crypto");
|
|
86
|
+
const canonical_1 = require("../shared/canonical");
|
|
87
|
+
const crypto_1 = require("./crypto");
|
|
88
|
+
const transparency_log_1 = require("./transparency-log");
|
|
89
|
+
const FORMAT_VERSION = 1;
|
|
90
|
+
const MAX_LINE = 1024 * 1024; // 1 MiB per record — bounded, fail-closed
|
|
91
|
+
class LedgerCorruptionError extends Error {
|
|
92
|
+
line;
|
|
93
|
+
constructor(line, reason) {
|
|
94
|
+
super(`file-ledger: CORRUPTION at record ${line}: ${reason} — startup refused. ` +
|
|
95
|
+
`History is never repaired automatically; restore from a verified backup or investigate the record.`);
|
|
96
|
+
this.line = line;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
exports.LedgerCorruptionError = LedgerCorruptionError;
|
|
100
|
+
class LedgerLockedError extends Error {
|
|
101
|
+
constructor(lockPath, holder) {
|
|
102
|
+
super(`file-ledger: ledger is locked by another writer (${holder}). ` +
|
|
103
|
+
`If that process is dead, verify it first, then remove the lock file manually: ${lockPath}. ` +
|
|
104
|
+
`This is a deliberate operator action and is never done automatically.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
exports.LedgerLockedError = LedgerLockedError;
|
|
108
|
+
const realFileOps = {
|
|
109
|
+
appendAndSync(fd, data) {
|
|
110
|
+
fs.writeSync(fd, data);
|
|
111
|
+
fs.fsyncSync(fd);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
115
|
+
class FileLedgerStore {
|
|
116
|
+
filePath;
|
|
117
|
+
genesis;
|
|
118
|
+
ops;
|
|
119
|
+
receipts = [];
|
|
120
|
+
index = new Map();
|
|
121
|
+
lastHash;
|
|
122
|
+
sths = [];
|
|
123
|
+
sthBySize = new Map(); // tree_size -> root_hash (one root per size)
|
|
124
|
+
leaves = []; // replay-validation view only
|
|
125
|
+
fd;
|
|
126
|
+
lockPath;
|
|
127
|
+
lockNonce;
|
|
128
|
+
closed = false;
|
|
129
|
+
constructor(filePath, genesis = canonical_1.GENESIS_HASH, ops = realFileOps) {
|
|
130
|
+
this.filePath = filePath;
|
|
131
|
+
this.genesis = genesis;
|
|
132
|
+
this.ops = ops;
|
|
133
|
+
this.lastHash = genesis;
|
|
134
|
+
if (!path.isAbsolute(filePath)) {
|
|
135
|
+
throw new Error(`file-ledger: MAGENTA_LEDGER_FILE must be an absolute path (got '${filePath}') — relative paths change meaning with the working directory.`);
|
|
136
|
+
}
|
|
137
|
+
const dir = path.dirname(filePath);
|
|
138
|
+
if (!fs.existsSync(dir)) {
|
|
139
|
+
throw new Error(`file-ledger: parent directory does not exist: ${dir} — create it deliberately (the ledger never invents directories).`);
|
|
140
|
+
}
|
|
141
|
+
// ── exclusive writer lock (atomic O_EXCL; never auto-stolen) ────────────
|
|
142
|
+
this.lockPath = `${filePath}.lock`;
|
|
143
|
+
this.lockNonce = (0, node_crypto_1.randomBytes)(8).toString("hex");
|
|
144
|
+
try {
|
|
145
|
+
const lockFd = fs.openSync(this.lockPath, "wx", 0o600);
|
|
146
|
+
fs.writeSync(lockFd, JSON.stringify({
|
|
147
|
+
pid: process.pid, hostname: os.hostname(),
|
|
148
|
+
created: new Date().toISOString(), nonce: this.lockNonce, ledger: filePath,
|
|
149
|
+
}));
|
|
150
|
+
fs.fsyncSync(lockFd);
|
|
151
|
+
fs.closeSync(lockFd);
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
if (e.code === "EEXIST") {
|
|
155
|
+
let holder = "unreadable lock file";
|
|
156
|
+
try {
|
|
157
|
+
const lk = JSON.parse(fs.readFileSync(this.lockPath, "utf8"));
|
|
158
|
+
const alive = (() => { try {
|
|
159
|
+
process.kill(lk.pid, 0);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
} })();
|
|
165
|
+
holder = `pid ${lk.pid}@${lk.hostname}, created ${lk.created}, pid ${alive ? "STILL RUNNING" : "not found on this host (possibly stale — verify before removing)"}`;
|
|
166
|
+
}
|
|
167
|
+
catch { /* malformed lock also fails closed */ }
|
|
168
|
+
throw new LedgerLockedError(this.lockPath, holder);
|
|
169
|
+
}
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
// ── open + strict replay ────────────────────────────────────────────────
|
|
173
|
+
try {
|
|
174
|
+
const existed = fs.existsSync(filePath);
|
|
175
|
+
this.fd = fs.openSync(filePath, existed ? "r+" : "ax", 0o600);
|
|
176
|
+
if (!existed) {
|
|
177
|
+
this.writeRecord({ v: FORMAT_VERSION, t: "header", genesis, created: new Date().toISOString() });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.replay();
|
|
181
|
+
// position for appends
|
|
182
|
+
const end = fs.fstatSync(this.fd).size;
|
|
183
|
+
fs.closeSync(this.fd);
|
|
184
|
+
this.fd = fs.openSync(filePath, "a");
|
|
185
|
+
void end;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
this.releaseLock();
|
|
190
|
+
throw e;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// ── durable append protocol: serialize → append → fsync → THEN update memory
|
|
194
|
+
writeRecord(rec) {
|
|
195
|
+
if (this.closed)
|
|
196
|
+
throw new Error("file-ledger: store is closed");
|
|
197
|
+
const line = JSON.stringify(rec) + "\n";
|
|
198
|
+
if (Buffer.byteLength(line) > MAX_LINE) {
|
|
199
|
+
throw new Error(`file-ledger: record exceeds the ${MAX_LINE}-byte line bound — refused`);
|
|
200
|
+
}
|
|
201
|
+
this.ops.appendAndSync(this.fd, line); // throws on append/flush/fsync failure
|
|
202
|
+
}
|
|
203
|
+
async appendReceipt(receipt) {
|
|
204
|
+
// Candidate validation (the PR-1 contract activates here):
|
|
205
|
+
const r = receipt;
|
|
206
|
+
if (typeof r.receipt_hash !== "string" || !HEX64.test(r.receipt_hash)) {
|
|
207
|
+
throw new Error("file-ledger: receipt_hash missing/malformed — refused");
|
|
208
|
+
}
|
|
209
|
+
if (this.index.has(receipt.receipt_hash)) {
|
|
210
|
+
throw new Error(`file-ledger: duplicate receipt_hash ${receipt.receipt_hash.slice(0, 16)}… — append-only ledger refuses duplicates`);
|
|
211
|
+
}
|
|
212
|
+
if (r.previous_receipt_hash !== this.lastHash) {
|
|
213
|
+
throw new Error(`file-ledger: previous_receipt_hash does not match the committed tail — refused (expected ${this.lastHash.slice(0, 16)}…)`);
|
|
214
|
+
}
|
|
215
|
+
const payload = { ...receipt };
|
|
216
|
+
this.writeRecord({
|
|
217
|
+
v: FORMAT_VERSION, t: "receipt", seq: this.receipts.length + 1,
|
|
218
|
+
payload, payload_hash: (0, canonical_1.canonicalHash)(payload),
|
|
219
|
+
});
|
|
220
|
+
// Only after the durable write: advance the committed view.
|
|
221
|
+
this.receipts.push(receipt);
|
|
222
|
+
this.index.set(receipt.receipt_hash, receipt);
|
|
223
|
+
this.lastHash = receipt.receipt_hash;
|
|
224
|
+
this.leaves.push((0, transparency_log_1.hashLeaf)(receipt.receipt_hash));
|
|
225
|
+
}
|
|
226
|
+
appendSth(sth) {
|
|
227
|
+
if (typeof sth.tree_size !== "number" || sth.tree_size < 0) {
|
|
228
|
+
throw new Error("file-ledger: STH tree_size malformed — refused");
|
|
229
|
+
}
|
|
230
|
+
const existing = this.sthBySize.get(sth.tree_size);
|
|
231
|
+
if (existing !== undefined && existing !== sth.root_hash) {
|
|
232
|
+
throw new Error(`file-ledger: conflicting STH at tree_size ${sth.tree_size} — equivocation refused`);
|
|
233
|
+
}
|
|
234
|
+
const payload = { ...sth };
|
|
235
|
+
this.writeRecord({ v: FORMAT_VERSION, t: "sth", payload, payload_hash: (0, canonical_1.canonicalHash)(payload) });
|
|
236
|
+
this.sths.push(sth);
|
|
237
|
+
this.sthBySize.set(sth.tree_size, sth.root_hash);
|
|
238
|
+
}
|
|
239
|
+
// ── reads (same copy semantics as the memory store) ───────────────────────
|
|
240
|
+
async getReceiptsNewestFirst(limit) {
|
|
241
|
+
const out = [...this.receipts].reverse();
|
|
242
|
+
return limit ? out.slice(0, limit) : out;
|
|
243
|
+
}
|
|
244
|
+
async getReceiptByHash(receiptHash) {
|
|
245
|
+
return this.index.get(receiptHash);
|
|
246
|
+
}
|
|
247
|
+
async getLastReceiptHash() {
|
|
248
|
+
return this.lastHash;
|
|
249
|
+
}
|
|
250
|
+
latestSth() {
|
|
251
|
+
return this.sths[this.sths.length - 1];
|
|
252
|
+
}
|
|
253
|
+
allSths() {
|
|
254
|
+
return this.sths.slice();
|
|
255
|
+
}
|
|
256
|
+
snapshotReceipts() {
|
|
257
|
+
return { receipts: [...this.receipts], index: new Map(this.index), lastReceiptHash: this.lastHash };
|
|
258
|
+
}
|
|
259
|
+
// ── append-only refusals ───────────────────────────────────────────────────
|
|
260
|
+
clearReceipts() {
|
|
261
|
+
throw new Error("file-ledger: append-only evidence ledger refuses destructive clearReceipts() — use a fresh ledger file for a fresh universe");
|
|
262
|
+
}
|
|
263
|
+
clearSths() {
|
|
264
|
+
throw new Error("file-ledger: append-only evidence ledger refuses destructive clearSths()");
|
|
265
|
+
}
|
|
266
|
+
restoreReceipts(snapshot) {
|
|
267
|
+
// The mutation-rollback path restores the snapshot taken BEFORE a failed
|
|
268
|
+
// append; a failed durable append never advanced state, so an identical
|
|
269
|
+
// tail is a no-op. Anything else would rewrite committed history.
|
|
270
|
+
if (snapshot.lastReceiptHash === this.lastHash && snapshot.receipts.length === this.receipts.length)
|
|
271
|
+
return;
|
|
272
|
+
throw new Error("file-ledger: restoreReceipts would rewrite committed append-only history — refused");
|
|
273
|
+
}
|
|
274
|
+
/** Release the writer lock and close the file. Idempotent. */
|
|
275
|
+
close() {
|
|
276
|
+
if (this.closed)
|
|
277
|
+
return;
|
|
278
|
+
this.closed = true;
|
|
279
|
+
try {
|
|
280
|
+
fs.closeSync(this.fd);
|
|
281
|
+
}
|
|
282
|
+
catch { /* already closed */ }
|
|
283
|
+
this.releaseLock();
|
|
284
|
+
}
|
|
285
|
+
releaseLock() {
|
|
286
|
+
try {
|
|
287
|
+
const lk = JSON.parse(fs.readFileSync(this.lockPath, "utf8"));
|
|
288
|
+
if (lk.nonce === this.lockNonce)
|
|
289
|
+
fs.unlinkSync(this.lockPath);
|
|
290
|
+
}
|
|
291
|
+
catch { /* lock already gone or not ours — leave it */ }
|
|
292
|
+
}
|
|
293
|
+
// ── strict startup replay ──────────────────────────────────────────────────
|
|
294
|
+
replay() {
|
|
295
|
+
const raw = fs.readFileSync(this.filePath, "utf8");
|
|
296
|
+
let body = raw;
|
|
297
|
+
// Incomplete final tail: bytes after the last newline can never be a
|
|
298
|
+
// committed record (commit = line + "\n" + fsync). Quarantine + truncate.
|
|
299
|
+
const lastNl = raw.lastIndexOf("\n");
|
|
300
|
+
const tail = raw.slice(lastNl + 1);
|
|
301
|
+
if (tail.length > 0) {
|
|
302
|
+
this.quarantineTail(tail, lastNl + 1);
|
|
303
|
+
body = raw.slice(0, lastNl + 1);
|
|
304
|
+
}
|
|
305
|
+
else if (lastNl >= 0) {
|
|
306
|
+
// The final newline-terminated line might still be a torn write that
|
|
307
|
+
// happens to end in "\n"? No: writes are single appendAndSync calls of
|
|
308
|
+
// full lines; a torn final line is only possible WITHOUT its newline.
|
|
309
|
+
// A complete line that fails to parse is corruption (handled below).
|
|
310
|
+
}
|
|
311
|
+
const lines = body.length ? body.split("\n").slice(0, -1) : [];
|
|
312
|
+
let recNo = 0;
|
|
313
|
+
let headerSeen = false;
|
|
314
|
+
let witnessKey;
|
|
315
|
+
for (const line of lines) {
|
|
316
|
+
recNo++;
|
|
317
|
+
if (Buffer.byteLength(line) > MAX_LINE)
|
|
318
|
+
throw new LedgerCorruptionError(recNo, "record exceeds line bound");
|
|
319
|
+
let rec;
|
|
320
|
+
try {
|
|
321
|
+
rec = JSON.parse(line);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// A complete (newline-terminated) record that does not parse, with or
|
|
325
|
+
// without records after it, is corruption — never auto-truncated.
|
|
326
|
+
throw new LedgerCorruptionError(recNo, "committed record is not valid JSON");
|
|
327
|
+
}
|
|
328
|
+
if (rec.v !== FORMAT_VERSION)
|
|
329
|
+
throw new LedgerCorruptionError(recNo, `unsupported format version ${rec.v}`);
|
|
330
|
+
if (rec.t === "header") {
|
|
331
|
+
if (recNo !== 1)
|
|
332
|
+
throw new LedgerCorruptionError(recNo, "header record not first");
|
|
333
|
+
if (rec.genesis !== this.genesis)
|
|
334
|
+
throw new LedgerCorruptionError(recNo, `genesis mismatch (file ${rec.genesis}, expected ${this.genesis})`);
|
|
335
|
+
headerSeen = true;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!headerSeen)
|
|
339
|
+
throw new LedgerCorruptionError(recNo, "missing header record");
|
|
340
|
+
if (!rec.payload || typeof rec.payload !== "object")
|
|
341
|
+
throw new LedgerCorruptionError(recNo, "missing payload");
|
|
342
|
+
if (rec.payload_hash !== (0, canonical_1.canonicalHash)(rec.payload))
|
|
343
|
+
throw new LedgerCorruptionError(recNo, "payload_hash mismatch (record altered on disk)");
|
|
344
|
+
if (rec.t === "receipt") {
|
|
345
|
+
const rc = rec.payload;
|
|
346
|
+
const rr = rec.payload;
|
|
347
|
+
if (rec.seq !== this.receipts.length + 1)
|
|
348
|
+
throw new LedgerCorruptionError(recNo, `sequence mismatch (got ${rec.seq}, expected ${this.receipts.length + 1})`);
|
|
349
|
+
if (typeof rr.receipt_hash !== "string" || !HEX64.test(rr.receipt_hash))
|
|
350
|
+
throw new LedgerCorruptionError(recNo, "receipt_hash malformed");
|
|
351
|
+
if (this.index.has(rc.receipt_hash))
|
|
352
|
+
throw new LedgerCorruptionError(recNo, "duplicate receipt_hash");
|
|
353
|
+
if (rr.previous_receipt_hash !== this.lastHash)
|
|
354
|
+
throw new LedgerCorruptionError(recNo, "previous_receipt_hash breaks the chain");
|
|
355
|
+
// receipt_hash recomputation (chain layer)
|
|
356
|
+
if (typeof rr.execution_hash === "string" &&
|
|
357
|
+
(0, canonical_1.chainHash)(rr.previous_receipt_hash, rr.execution_hash) !== rc.receipt_hash) {
|
|
358
|
+
throw new LedgerCorruptionError(recNo, "receipt_hash does not recompute from previous_receipt_hash + execution_hash");
|
|
359
|
+
}
|
|
360
|
+
// issuer signature (Ed25519 over canonical body minus receipt_signature)
|
|
361
|
+
if (typeof rr.receipt_signature === "string" && typeof rr.issuer_pubkey === "string") {
|
|
362
|
+
const bodyNoSig = {};
|
|
363
|
+
for (const k of Object.keys(rr))
|
|
364
|
+
if (k !== "receipt_signature")
|
|
365
|
+
bodyNoSig[k] = rr[k];
|
|
366
|
+
if (!(0, crypto_1.verify)((0, canonical_1.canonicalHash)(bodyNoSig), rr.receipt_signature, rr.issuer_pubkey)) {
|
|
367
|
+
throw new LedgerCorruptionError(recNo, "issuer signature invalid");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
this.receipts.push(rc);
|
|
371
|
+
this.index.set(rc.receipt_hash, rc);
|
|
372
|
+
this.lastHash = rc.receipt_hash;
|
|
373
|
+
this.leaves.push((0, transparency_log_1.hashLeaf)(rc.receipt_hash));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (rec.t === "sth") {
|
|
377
|
+
const sth = rec.payload;
|
|
378
|
+
if (!(0, transparency_log_1.verifySTH)(sth))
|
|
379
|
+
throw new LedgerCorruptionError(recNo, "STH signature invalid");
|
|
380
|
+
if (witnessKey === undefined)
|
|
381
|
+
witnessKey = sth.witness_pubkey;
|
|
382
|
+
else if (sth.witness_pubkey !== witnessKey)
|
|
383
|
+
throw new LedgerCorruptionError(recNo, "witness key changed mid-ledger (rotation records are a later lane)");
|
|
384
|
+
if (sth.tree_size > this.leaves.length)
|
|
385
|
+
throw new LedgerCorruptionError(recNo, `STH tree_size ${sth.tree_size} exceeds receipts seen (${this.leaves.length})`);
|
|
386
|
+
const existing = this.sthBySize.get(sth.tree_size);
|
|
387
|
+
if (existing !== undefined && existing !== sth.root_hash)
|
|
388
|
+
throw new LedgerCorruptionError(recNo, `conflicting STH roots at tree_size ${sth.tree_size} (equivocation at rest)`);
|
|
389
|
+
if ((0, transparency_log_1.merkleRoot)(this.leaves.slice(0, sth.tree_size)) !== sth.root_hash) {
|
|
390
|
+
throw new LedgerCorruptionError(recNo, "STH root_hash does not recompute from the receipt chain");
|
|
391
|
+
}
|
|
392
|
+
this.sths.push(sth);
|
|
393
|
+
this.sthBySize.set(sth.tree_size, sth.root_hash);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
throw new LedgerCorruptionError(recNo, `unknown record type '${rec.t}'`);
|
|
397
|
+
}
|
|
398
|
+
if (lines.length > 0 && !headerSeen)
|
|
399
|
+
throw new LedgerCorruptionError(1, "missing header record");
|
|
400
|
+
}
|
|
401
|
+
quarantineTail(tail, offset) {
|
|
402
|
+
const qPath = `${this.filePath}.tail-quarantine-${Date.now()}`;
|
|
403
|
+
fs.writeFileSync(qPath, tail, { mode: 0o600 });
|
|
404
|
+
fs.truncateSync(this.filePath, offset);
|
|
405
|
+
// Loud, deterministic, auditable — an operator can inspect the quarantined bytes.
|
|
406
|
+
console.error(`[file-ledger] AUDIT: incomplete uncommitted tail (${Buffer.byteLength(tail)} bytes at offset ${offset}) ` +
|
|
407
|
+
`quarantined to ${qPath} and truncated. This tail never completed its commit (no terminating newline); ` +
|
|
408
|
+
`committed history is unaffected.`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
exports.FileLedgerStore = FileLedgerStore;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LEDGER STORE — the durable-evidence seam (Durable Witness PR 1).
|
|
4
|
+
*
|
|
5
|
+
* This interface is the storage contract for Magenta's evidence ledger: the
|
|
6
|
+
* ordered execution-receipt chain and the signed-tree-head (STH) checkpoint
|
|
7
|
+
* history. It exists so durability can be added behind a seam (FileLedgerStore,
|
|
8
|
+
* PostgresLedgerStore — see docs/roadmaps/DURABLE_WITNESS_ARCHITECTURE.md)
|
|
9
|
+
* without rewriting the transparency log, witness, receipts, verifier,
|
|
10
|
+
* gateway, or any public API surface.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THE STORE IS NOT (semantics stay in their existing layers):
|
|
13
|
+
* - it does NOT generate keys, sign anything, or compute receipt hashes;
|
|
14
|
+
* - it does NOT decide authorization or policy outcomes;
|
|
15
|
+
* - it does NOT verify evidence;
|
|
16
|
+
* - it does NOT repair, re-sort, or rewrite history — it preserves exactly
|
|
17
|
+
* what was appended, in the order it was appended, and propagates errors
|
|
18
|
+
* rather than suppressing them.
|
|
19
|
+
*
|
|
20
|
+
* Merkle LEAVES deliberately do NOT live here: they are a derivable view of
|
|
21
|
+
* the receipt sequence (architecture §2.2) and remain in TransparencyLog,
|
|
22
|
+
* rebuilt from receipts on boot when persistence is enabled.
|
|
23
|
+
*
|
|
24
|
+
* SYNC/ASYNC BOUNDARY (deliberate, staged):
|
|
25
|
+
* - Receipt operations are Promise-based — every existing call site already
|
|
26
|
+
* awaits them (IStorage has been async since inception), so a future
|
|
27
|
+
* PostgresLedgerStore slots in without caller churn.
|
|
28
|
+
* - STH operations are synchronous today because TransparencyLog.append()
|
|
29
|
+
* is synchronous and is called fire-and-forget from witnessReceipt().
|
|
30
|
+
* EVOLUTION POINT (PR 4, Postgres lane): when STH writes become durable,
|
|
31
|
+
* witnessing moves behind an async ledger service; the interface gains
|
|
32
|
+
* async STH variants THEN, alongside the fail-closed integration the
|
|
33
|
+
* architecture prescribes — not speculatively now.
|
|
34
|
+
*
|
|
35
|
+
* ATOMIC APPEND CONTRACT (normative for future durable implementations):
|
|
36
|
+
* - A receipt is either durably committed exactly once, or not recorded at
|
|
37
|
+
* all; "recorded" means COMMITTED (the durable store must not acknowledge
|
|
38
|
+
* before its transaction/fsync boundary).
|
|
39
|
+
* - Sequence is allocated by the store (array position today; DB identity /
|
|
40
|
+
* file offset later) — never by an external in-memory counter.
|
|
41
|
+
* - `receipt_hash` is unique: a durable store MUST reject a duplicate
|
|
42
|
+
* append (constraint violation), and MUST verify the incoming receipt's
|
|
43
|
+
* `previous_receipt_hash` equals the current tail before committing.
|
|
44
|
+
* - One STH per tree_size: a durable store MUST make equivocation at rest a
|
|
45
|
+
* constraint violation (tree_size as primary key).
|
|
46
|
+
* - Partial failure must surface as an error to the caller; the store never
|
|
47
|
+
* silently retries into an inconsistent state, and a failed append leaves
|
|
48
|
+
* the tail unchanged so a retry is safe.
|
|
49
|
+
* MemoryLedgerStore intentionally does NOT enforce the duplicate/previous-
|
|
50
|
+
* hash checks above: it byte-for-byte preserves the behavior the in-memory
|
|
51
|
+
* ledger has always had (push, index, advance tail), because PR 1 is
|
|
52
|
+
* non-semantic. The contract text governs PR 2+ implementations.
|
|
53
|
+
*/
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.MemoryLedgerStore = void 0;
|
|
56
|
+
const canonical_1 = require("../shared/canonical");
|
|
57
|
+
/**
|
|
58
|
+
* In-memory implementation — behavior-identical to the state previously held
|
|
59
|
+
* inline by MemStorage (receipts) and TransparencyLog (sthHistory).
|
|
60
|
+
* Deterministic order, no I/O, no environment variables, no background work.
|
|
61
|
+
*/
|
|
62
|
+
class MemoryLedgerStore {
|
|
63
|
+
receipts = [];
|
|
64
|
+
index = new Map();
|
|
65
|
+
lastHash;
|
|
66
|
+
sths = [];
|
|
67
|
+
constructor(genesisHash = canonical_1.GENESIS_HASH) {
|
|
68
|
+
this.lastHash = genesisHash;
|
|
69
|
+
this.genesis = genesisHash;
|
|
70
|
+
}
|
|
71
|
+
genesis;
|
|
72
|
+
async appendReceipt(receipt) {
|
|
73
|
+
// Exact historical semantics: push, index by hash, advance tail.
|
|
74
|
+
this.receipts.push(receipt);
|
|
75
|
+
this.index.set(receipt.receipt_hash, receipt);
|
|
76
|
+
this.lastHash = receipt.receipt_hash;
|
|
77
|
+
}
|
|
78
|
+
async getReceiptsNewestFirst(limit) {
|
|
79
|
+
const out = [...this.receipts].reverse();
|
|
80
|
+
return limit ? out.slice(0, limit) : out;
|
|
81
|
+
}
|
|
82
|
+
async getReceiptByHash(receiptHash) {
|
|
83
|
+
return this.index.get(receiptHash);
|
|
84
|
+
}
|
|
85
|
+
async getLastReceiptHash() {
|
|
86
|
+
return this.lastHash;
|
|
87
|
+
}
|
|
88
|
+
appendSth(sth) {
|
|
89
|
+
this.sths.push(sth);
|
|
90
|
+
}
|
|
91
|
+
latestSth() {
|
|
92
|
+
return this.sths[this.sths.length - 1];
|
|
93
|
+
}
|
|
94
|
+
allSths() {
|
|
95
|
+
return this.sths.slice();
|
|
96
|
+
}
|
|
97
|
+
clearReceipts() {
|
|
98
|
+
this.receipts = [];
|
|
99
|
+
this.index = new Map();
|
|
100
|
+
this.lastHash = this.genesis;
|
|
101
|
+
}
|
|
102
|
+
clearSths() {
|
|
103
|
+
this.sths = [];
|
|
104
|
+
}
|
|
105
|
+
snapshotReceipts() {
|
|
106
|
+
return {
|
|
107
|
+
receipts: [...this.receipts],
|
|
108
|
+
index: new Map(this.index),
|
|
109
|
+
lastReceiptHash: this.lastHash,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
restoreReceipts(snapshot) {
|
|
113
|
+
this.receipts = snapshot.receipts;
|
|
114
|
+
this.index = snapshot.index;
|
|
115
|
+
this.lastHash = snapshot.lastReceiptHash;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
exports.MemoryLedgerStore = MemoryLedgerStore;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sharedLedgerStore = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* LEDGER COMPOSITION POINT — selects the process-wide evidence-ledger store.
|
|
6
|
+
*
|
|
7
|
+
* Modes (explicit, never accidental):
|
|
8
|
+
* - MEMORY (default): no env var set. MemStorage and TransparencyLog each
|
|
9
|
+
* construct their own MemoryLedgerStore — the historical behavior.
|
|
10
|
+
* - FILE: MAGENTA_LEDGER_FILE=/absolute/path/to/magenta-ledger.jsonl
|
|
11
|
+
* ONE FileLedgerStore instance owns BOTH halves (receipts + STH
|
|
12
|
+
* checkpoints) in one append-only file under one writer lock — receipts
|
|
13
|
+
* and checkpoints can never split across two state universes.
|
|
14
|
+
*
|
|
15
|
+
* NO AMBIGUOUS DUAL AUTHORITY: the legacy whole-state dump
|
|
16
|
+
* (MAGENTA_STATE_FILE) also persists receipt state. Setting both variables is
|
|
17
|
+
* a configuration error and FAILS STARTUP — evidence must have exactly one
|
|
18
|
+
* durable authority. Migration from a legacy state file is a documented,
|
|
19
|
+
* deliberate operator action (docs/FILE_LEDGER.md), not an automatic merge.
|
|
20
|
+
*
|
|
21
|
+
* NO SILENT FALLBACK: if MAGENTA_LEDGER_FILE is set but the ledger cannot be
|
|
22
|
+
* opened (bad path, lock held, corruption), the error propagates and the
|
|
23
|
+
* process fails to start. A configured durable ledger never degrades to a
|
|
24
|
+
* blank in-memory universe.
|
|
25
|
+
*/
|
|
26
|
+
const file_ledger_store_1 = require("./file-ledger-store");
|
|
27
|
+
function resolveSharedLedgerStore() {
|
|
28
|
+
const file = process.env.MAGENTA_LEDGER_FILE;
|
|
29
|
+
if (!file)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (process.env.MAGENTA_STATE_FILE) {
|
|
32
|
+
throw new Error("ledger: MAGENTA_LEDGER_FILE and MAGENTA_STATE_FILE are both set — refusing to start with two " +
|
|
33
|
+
"independent persistence authorities over receipt state. Unset one (see docs/FILE_LEDGER.md for migration).");
|
|
34
|
+
}
|
|
35
|
+
return new file_ledger_store_1.FileLedgerStore(file);
|
|
36
|
+
}
|
|
37
|
+
/** Shared durable store when file mode is configured; undefined in memory mode. */
|
|
38
|
+
exports.sharedLedgerStore = resolveSharedLedgerStore();
|
package/dist/server/storage.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.resetStorage = exports.storage = exports.MemStorage = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
const canonical_1 = require("../shared/canonical");
|
|
6
|
+
const ledger_store_1 = require("./ledger-store");
|
|
7
|
+
const ledger_1 = require("./ledger");
|
|
6
8
|
const schema_1 = require("../shared/schema");
|
|
7
9
|
// Genesis hash for first audit entry
|
|
8
10
|
const GENESIS_HASH = "GENESIS";
|
|
@@ -52,9 +54,10 @@ class MemStorage {
|
|
|
52
54
|
revocationChain;
|
|
53
55
|
revokedPubkeys;
|
|
54
56
|
lastRevocationHash;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
// Durable Witness PR 1: the evidence-ledger receipt chain lives behind the
|
|
58
|
+
// LedgerStore seam (see server/ledger-store.ts). MemoryLedgerStore preserves
|
|
59
|
+
// the exact historical in-memory semantics.
|
|
60
|
+
ledger;
|
|
58
61
|
usedNonces; // nonce -> timestamp
|
|
59
62
|
secretKeyStore; // pubkey -> secretKey (server-side only)
|
|
60
63
|
policyBundles; // keyed by `${policy_id}:${version}`
|
|
@@ -103,9 +106,9 @@ class MemStorage {
|
|
|
103
106
|
this.revocationChain = [];
|
|
104
107
|
this.revokedPubkeys = new Set();
|
|
105
108
|
this.lastRevocationHash = canonical_1.GENESIS_HASH;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this.
|
|
109
|
+
// FILE mode (MAGENTA_LEDGER_FILE) shares ONE durable store with the
|
|
110
|
+
// witness; MEMORY mode keeps the historical per-owner in-memory store.
|
|
111
|
+
this.ledger = ledger_1.sharedLedgerStore ?? new ledger_store_1.MemoryLedgerStore(canonical_1.GENESIS_HASH);
|
|
109
112
|
this.usedNonces = new Map();
|
|
110
113
|
this.secretKeyStore = new Map();
|
|
111
114
|
this.policyBundles = new Map();
|
|
@@ -147,9 +150,7 @@ class MemStorage {
|
|
|
147
150
|
this.revocationChain = [];
|
|
148
151
|
this.revokedPubkeys = new Set();
|
|
149
152
|
this.lastRevocationHash = canonical_1.GENESIS_HASH;
|
|
150
|
-
this.
|
|
151
|
-
this.receiptIndex = new Map();
|
|
152
|
-
this.lastReceiptHash = canonical_1.GENESIS_HASH;
|
|
153
|
+
this.ledger.clearReceipts();
|
|
153
154
|
this.usedNonces = new Map();
|
|
154
155
|
this.secretKeyStore = new Map();
|
|
155
156
|
this.policyBundles = new Map();
|
|
@@ -1037,19 +1038,16 @@ class MemStorage {
|
|
|
1037
1038
|
// SOVEREIGN TRUST ARCHITECTURE: Execution Receipts
|
|
1038
1039
|
// ========================================================================
|
|
1039
1040
|
async appendExecutionReceipt(receipt) {
|
|
1040
|
-
this.
|
|
1041
|
-
this.receiptIndex.set(receipt.receipt_hash, receipt);
|
|
1042
|
-
this.lastReceiptHash = receipt.receipt_hash;
|
|
1041
|
+
return this.ledger.appendReceipt(receipt);
|
|
1043
1042
|
}
|
|
1044
1043
|
async getExecutionReceipts(limit) {
|
|
1045
|
-
|
|
1046
|
-
return limit ? receipts.slice(0, limit) : receipts;
|
|
1044
|
+
return this.ledger.getReceiptsNewestFirst(limit);
|
|
1047
1045
|
}
|
|
1048
1046
|
async getExecutionReceipt(receiptHash) {
|
|
1049
|
-
return this.
|
|
1047
|
+
return this.ledger.getReceiptByHash(receiptHash);
|
|
1050
1048
|
}
|
|
1051
1049
|
async getLastReceiptHash() {
|
|
1052
|
-
return this.
|
|
1050
|
+
return this.ledger.getLastReceiptHash();
|
|
1053
1051
|
}
|
|
1054
1052
|
// ========================================================================
|
|
1055
1053
|
// SOVEREIGN TRUST ARCHITECTURE: Nonce Replay Protection
|
|
@@ -1142,9 +1140,14 @@ class MemStorage {
|
|
|
1142
1140
|
certificates: new Map(this.certificates),
|
|
1143
1141
|
rootCertPubkey: this.rootCertPubkey,
|
|
1144
1142
|
secretKeyStore: new Map(this.secretKeyStore),
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1143
|
+
...(() => {
|
|
1144
|
+
const l = this.ledger.snapshotReceipts();
|
|
1145
|
+
return {
|
|
1146
|
+
executionReceipts: l.receipts,
|
|
1147
|
+
receiptIndex: l.index,
|
|
1148
|
+
lastReceiptHash: l.lastReceiptHash,
|
|
1149
|
+
};
|
|
1150
|
+
})(),
|
|
1148
1151
|
policyBundles: new Map(this.policyBundles),
|
|
1149
1152
|
};
|
|
1150
1153
|
}
|
|
@@ -1164,9 +1167,11 @@ class MemStorage {
|
|
|
1164
1167
|
this.certificates = snapshot.certificates;
|
|
1165
1168
|
this.rootCertPubkey = snapshot.rootCertPubkey;
|
|
1166
1169
|
this.secretKeyStore = snapshot.secretKeyStore;
|
|
1167
|
-
this.
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
+
this.ledger.restoreReceipts({
|
|
1171
|
+
receipts: snapshot.executionReceipts,
|
|
1172
|
+
index: snapshot.receiptIndex,
|
|
1173
|
+
lastReceiptHash: snapshot.lastReceiptHash,
|
|
1174
|
+
});
|
|
1170
1175
|
this.policyBundles = snapshot.policyBundles;
|
|
1171
1176
|
}
|
|
1172
1177
|
// ========================================================================
|
|
@@ -1215,9 +1220,14 @@ class MemStorage {
|
|
|
1215
1220
|
revocationChain: this.revocationChain,
|
|
1216
1221
|
revokedPubkeys: Array.from(this.revokedPubkeys),
|
|
1217
1222
|
lastRevocationHash: this.lastRevocationHash,
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1223
|
+
...(() => {
|
|
1224
|
+
const l = this.ledger.snapshotReceipts();
|
|
1225
|
+
return {
|
|
1226
|
+
executionReceipts: l.receipts,
|
|
1227
|
+
receiptIndex: Array.from(l.index),
|
|
1228
|
+
lastReceiptHash: l.lastReceiptHash,
|
|
1229
|
+
};
|
|
1230
|
+
})(),
|
|
1221
1231
|
usedNonces: Array.from(this.usedNonces),
|
|
1222
1232
|
secretKeyStore: Array.from(this.secretKeyStore),
|
|
1223
1233
|
policyBundles: Array.from(this.policyBundles),
|
|
@@ -1263,9 +1273,11 @@ class MemStorage {
|
|
|
1263
1273
|
this.revocationChain = s.revocationChain;
|
|
1264
1274
|
this.revokedPubkeys = new Set(s.revokedPubkeys);
|
|
1265
1275
|
this.lastRevocationHash = s.lastRevocationHash;
|
|
1266
|
-
this.
|
|
1267
|
-
|
|
1268
|
-
|
|
1276
|
+
this.ledger.restoreReceipts({
|
|
1277
|
+
receipts: s.executionReceipts,
|
|
1278
|
+
index: new Map(s.receiptIndex),
|
|
1279
|
+
lastReceiptHash: s.lastReceiptHash,
|
|
1280
|
+
});
|
|
1269
1281
|
this.usedNonces = new Map(s.usedNonces);
|
|
1270
1282
|
this.secretKeyStore = new Map(s.secretKeyStore);
|
|
1271
1283
|
this.policyBundles = new Map(s.policyBundles);
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
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
|
+
const ledger_store_1 = require("./ledger-store");
|
|
40
41
|
const crypto_2 = require("./crypto");
|
|
41
42
|
// ----------------------------------------------------------------------------
|
|
42
43
|
// Merkle primitives (RFC 6962 domain separation: 0x00 leaves, 0x01 nodes)
|
|
@@ -111,8 +112,12 @@ function sthMessage(tree_size, root_hash, timestamp, witness_pubkey) {
|
|
|
111
112
|
return `magenta-sth-v1|${tree_size}|${root_hash}|${timestamp}|${witness_pubkey}`;
|
|
112
113
|
}
|
|
113
114
|
class TransparencyLog {
|
|
115
|
+
/** Merkle leaves are a DERIVED view of the receipt sequence (rebuilt from
|
|
116
|
+
* receipts on boot when persistence is enabled) — they deliberately stay
|
|
117
|
+
* in-memory here rather than in the LedgerStore (architecture §2.2). */
|
|
114
118
|
leaves = [];
|
|
115
|
-
|
|
119
|
+
/** STH checkpoint history lives behind the LedgerStore seam (PR 1). */
|
|
120
|
+
store;
|
|
116
121
|
witnessPub;
|
|
117
122
|
witnessSec;
|
|
118
123
|
/**
|
|
@@ -120,10 +125,11 @@ class TransparencyLog {
|
|
|
120
125
|
* or a second host). If omitted, a fresh independent key is generated — the
|
|
121
126
|
* point is that it is NOT the trust root key.
|
|
122
127
|
*/
|
|
123
|
-
constructor(witnessKey) {
|
|
128
|
+
constructor(witnessKey, store) {
|
|
124
129
|
const kp = witnessKey ?? (0, crypto_2.generateKeyPair)();
|
|
125
130
|
this.witnessPub = kp.publicKey;
|
|
126
131
|
this.witnessSec = kp.secretKey;
|
|
132
|
+
this.store = store ?? new ledger_store_1.MemoryLedgerStore();
|
|
127
133
|
}
|
|
128
134
|
get witnessPublicKey() {
|
|
129
135
|
return this.witnessPub;
|
|
@@ -133,7 +139,7 @@ class TransparencyLog {
|
|
|
133
139
|
const leaf = hashLeaf(receiptHash);
|
|
134
140
|
this.leaves.push(leaf);
|
|
135
141
|
const sth = this.signTreeHead();
|
|
136
|
-
this.
|
|
142
|
+
this.store.appendSth(sth);
|
|
137
143
|
return { index: this.leaves.length - 1, sth };
|
|
138
144
|
}
|
|
139
145
|
signTreeHead() {
|
|
@@ -146,7 +152,7 @@ class TransparencyLog {
|
|
|
146
152
|
}
|
|
147
153
|
/** The latest signed tree head (what you mirror to an external auditor). */
|
|
148
154
|
latestSTH() {
|
|
149
|
-
return this.
|
|
155
|
+
return this.store.latestSth();
|
|
150
156
|
}
|
|
151
157
|
/** Inclusion proof for a given leaf index, against the current root. */
|
|
152
158
|
proveInclusion(index) {
|
|
@@ -162,14 +168,35 @@ class TransparencyLog {
|
|
|
162
168
|
size() {
|
|
163
169
|
return this.leaves.length;
|
|
164
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Rebuild the DERIVED leaf view from replayed receipt hashes (file-ledger
|
|
173
|
+
* restart). Does NOT sign anything — the durable store already holds the
|
|
174
|
+
* committed STH history. Asserts the rebuilt view matches the latest
|
|
175
|
+
* committed checkpoint (boot-time corruption detection).
|
|
176
|
+
*/
|
|
177
|
+
rebuildLeaves(receiptHashes) {
|
|
178
|
+
if (this.leaves.length > 0) {
|
|
179
|
+
throw new Error("transparency-log: rebuildLeaves on a non-empty log — refusing to overwrite the live view");
|
|
180
|
+
}
|
|
181
|
+
this.leaves = receiptHashes.map(hashLeaf);
|
|
182
|
+
const latest = this.store.latestSth();
|
|
183
|
+
if (latest && latest.witness_pubkey !== this.witnessPub) {
|
|
184
|
+
throw new Error("transparency-log: the durable ledger's STH history was signed by a DIFFERENT witness key than this " +
|
|
185
|
+
"process holds — a restart regenerated the witness identity. Pin MAGENTA_WITNESS_SECRET/MAGENTA_WITNESS_PUBLIC " +
|
|
186
|
+
"to the original key (witness continuity is required for origin verification); refusing to continue incoherently.");
|
|
187
|
+
}
|
|
188
|
+
if (latest && merkleRoot(this.leaves.slice(0, latest.tree_size)) !== latest.root_hash) {
|
|
189
|
+
throw new Error("transparency-log: rebuilt leaf view does not match the committed STH root — ledger/receipt divergence");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
165
192
|
/** Clear the log (fresh universe / test isolation). Keeps the witness key. */
|
|
166
193
|
reset() {
|
|
167
194
|
this.leaves = [];
|
|
168
|
-
this.
|
|
195
|
+
this.store.clearSths();
|
|
169
196
|
}
|
|
170
197
|
/** For export/persistence: the raw leaves and STH history. */
|
|
171
198
|
exportState() {
|
|
172
|
-
return { leaves: this.leaves.slice(), sthHistory: this.
|
|
199
|
+
return { leaves: this.leaves.slice(), sthHistory: this.store.allSths(), witnessPub: this.witnessPub };
|
|
173
200
|
}
|
|
174
201
|
}
|
|
175
202
|
exports.TransparencyLog = TransparencyLog;
|
package/dist/server/witness.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
19
|
exports.rebuildWitnessFromReceipts = exports.witnessReceipt = exports.resetWitnessLog = exports.witnessLog = void 0;
|
|
20
20
|
const transparency_log_1 = require("./transparency-log");
|
|
21
|
+
const ledger_1 = require("./ledger");
|
|
21
22
|
function loadWitnessKey() {
|
|
22
23
|
const secretKey = process.env.MAGENTA_WITNESS_SECRET;
|
|
23
24
|
const publicKey = process.env.MAGENTA_WITNESS_PUBLIC;
|
|
@@ -25,7 +26,7 @@ function loadWitnessKey() {
|
|
|
25
26
|
return { publicKey, secretKey };
|
|
26
27
|
return undefined;
|
|
27
28
|
}
|
|
28
|
-
exports.witnessLog = new transparency_log_1.TransparencyLog(loadWitnessKey());
|
|
29
|
+
exports.witnessLog = new transparency_log_1.TransparencyLog(loadWitnessKey(), ledger_1.sharedLedgerStore);
|
|
29
30
|
/** Reset the witness log (fresh universe / test isolation). Keeps the witness key. */
|
|
30
31
|
function resetWitnessLog() {
|
|
31
32
|
exports.witnessLog.reset();
|
|
@@ -56,6 +57,13 @@ exports.witnessReceipt = witnessReceipt;
|
|
|
56
57
|
function rebuildWitnessFromReceipts(receiptHashes) {
|
|
57
58
|
if (exports.witnessLog.size() > 0)
|
|
58
59
|
return; // already populated this process
|
|
60
|
+
if (exports.witnessLog.latestSTH() !== undefined) {
|
|
61
|
+
// Durable store already replayed committed STH history (file-ledger
|
|
62
|
+
// restart): rebuild ONLY the derived leaf view. Re-signing here would
|
|
63
|
+
// append duplicate checkpoints on every boot.
|
|
64
|
+
exports.witnessLog.rebuildLeaves(receiptHashes);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
59
67
|
for (const h of receiptHashes) {
|
|
60
68
|
try {
|
|
61
69
|
exports.witnessLog.append(h);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# File Ledger — durable local evidence (`MAGENTA_LEDGER_FILE`)
|
|
2
|
+
|
|
3
|
+
The file ledger makes Magenta's evidence survive a process restart:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
action → receipt appended → write + fsync → "committed" returned
|
|
7
|
+
→ restart → strict replay → same chain, same Merkle root, same STH history
|
|
8
|
+
→ evidence still verifies (or startup FAILS LOUDLY)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Scope honesty:** single-writer, single-host, reference-grade durability for
|
|
12
|
+
operators running the lean gateway/control plane. It is NOT multi-tenant and
|
|
13
|
+
NOT hosted production infrastructure — that is the PostgreSQL ledger lane.
|
|
14
|
+
|
|
15
|
+
## Modes
|
|
16
|
+
|
|
17
|
+
| Mode | Selection | Behavior |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| **Memory** (default) | no env var | historical behavior; evidence lives for the process lifetime |
|
|
20
|
+
| **File** | `MAGENTA_LEDGER_FILE=/absolute/path/magenta-ledger.jsonl` | one durable append-only ledger owns BOTH receipts and STH checkpoints, under one writer lock |
|
|
21
|
+
|
|
22
|
+
Rules:
|
|
23
|
+
- The path MUST be absolute. The parent directory must already exist (the
|
|
24
|
+
ledger never invents directories). Files are created `0600` where supported.
|
|
25
|
+
- A configured but unopenable ledger (bad path, lock held, corruption)
|
|
26
|
+
**fails startup**. There is no silent fallback to a blank in-memory universe.
|
|
27
|
+
- `MAGENTA_LEDGER_FILE` together with the legacy whole-state dump
|
|
28
|
+
(`MAGENTA_STATE_FILE`) is a **configuration error** — evidence has exactly
|
|
29
|
+
one durable authority. To migrate from a legacy state file: boot once with
|
|
30
|
+
only `MAGENTA_STATE_FILE` and export your evidence bundle, then start a
|
|
31
|
+
fresh file ledger and keep the legacy file as a read-only archive. (The
|
|
32
|
+
legacy dump remains supported for non-ledger state; it is simply never
|
|
33
|
+
combined with the file ledger.)
|
|
34
|
+
- **Pin the witness identity**: set `MAGENTA_WITNESS_SECRET` /
|
|
35
|
+
`MAGENTA_WITNESS_PUBLIC` when using file mode. A restart that regenerates
|
|
36
|
+
the witness key cannot continue a ledger signed under the old key — boot
|
|
37
|
+
refuses with a clear error. Durable receipts with a discontinuous witness
|
|
38
|
+
identity would break origin verification, so this is enforced, not advised.
|
|
39
|
+
- The demo (`npx magenta-canon demo`) keeps using a fresh in-memory universe.
|
|
40
|
+
|
|
41
|
+
## File format (auditable with `less`)
|
|
42
|
+
|
|
43
|
+
Canonical JSON Lines, one record per line, 1 MiB line bound:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
{"v":1,"t":"header","genesis":"<64hex>","created":"<iso>"}
|
|
47
|
+
{"v":1,"t":"receipt","seq":1,"payload":{…receipt…},"payload_hash":"<canonicalHash(payload)>"}
|
|
48
|
+
{"v":1,"t":"sth","payload":{…signed tree head…},"payload_hash":"…"}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Unknown versions or record types fail closed. Records are never rewritten,
|
|
52
|
+
re-sorted, repaired, or silently dropped.
|
|
53
|
+
|
|
54
|
+
## The commit guarantee
|
|
55
|
+
|
|
56
|
+
A receipt or STH is reported committed only after its record is written and
|
|
57
|
+
`fsync`'d. The in-memory view advances only after the durable write succeeds.
|
|
58
|
+
A failed write surfaces an error, leaves state unchanged (retry is safe), and
|
|
59
|
+
never degrades to memory-only mode. Append enforces the ledger contract:
|
|
60
|
+
duplicate `receipt_hash` refused; `previous_receipt_hash` must equal the
|
|
61
|
+
committed tail; conflicting STH at an existing tree size refused
|
|
62
|
+
(equivocation), and the file ledger refuses destructive `clear*` operations.
|
|
63
|
+
|
|
64
|
+
## Exclusive writer lock
|
|
65
|
+
|
|
66
|
+
`<ledger>.lock` is created atomically (`O_EXCL`) containing
|
|
67
|
+
`{pid, hostname, created, nonce, ledger}`. A second writer fails before
|
|
68
|
+
serving any traffic.
|
|
69
|
+
|
|
70
|
+
**Stale-lock recovery (deliberate operator action — never automatic):**
|
|
71
|
+
1. Read the lock file; identify the holder pid/host/created.
|
|
72
|
+
2. Verify that process is genuinely dead (`ps`, host check).
|
|
73
|
+
3. Remove the lock file manually.
|
|
74
|
+
4. Start the new process; replay validates the whole ledger before serving.
|
|
75
|
+
|
|
76
|
+
A malformed lock file also fails closed and is left in place for inspection.
|
|
77
|
+
|
|
78
|
+
## Startup replay (strict)
|
|
79
|
+
|
|
80
|
+
Every committed record is re-validated: payload hash, receipt chain linkage,
|
|
81
|
+
`receipt_hash` recomputation, **issuer signatures**, duplicates, sequence
|
|
82
|
+
numbers, **STH signatures**, one-root-per-tree-size, **recomputed Merkle
|
|
83
|
+
roots**, and witness-key coherence. Any invalid committed record refuses
|
|
84
|
+
startup with the record number and reason. History is never repaired
|
|
85
|
+
automatically; never start a blank universe; never silently pick a backup.
|
|
86
|
+
|
|
87
|
+
## Crash-tail policy (two distinct cases)
|
|
88
|
+
|
|
89
|
+
- **Incomplete final tail** (no terminating newline — provably never
|
|
90
|
+
committed, since commit = full line + `\n` + fsync): quarantined to
|
|
91
|
+
`<ledger>.tail-quarantine-<timestamp>` and truncated, with a loud audit
|
|
92
|
+
log line. Committed history is unaffected.
|
|
93
|
+
- **Complete but invalid record** (parses, but bad hash / broken chain /
|
|
94
|
+
bad signature / conflicting STH), anywhere including last: **corruption —
|
|
95
|
+
startup refuses.** No automatic truncation.
|
|
96
|
+
|
|
97
|
+
## Backup
|
|
98
|
+
|
|
99
|
+
Copy the ledger file while the process is stopped (or accept that a live copy
|
|
100
|
+
may carry an uncommitted tail, which replay will quarantine). Restoring an
|
|
101
|
+
OLD backup re-exposes an earlier tree — external mirrors will flag the
|
|
102
|
+
regression; reconcile with your mirror operator before serving (see
|
|
103
|
+
`docs/STH_MIRROR.md`).
|
|
104
|
+
|
|
105
|
+
## Limitations / path to PostgreSQL
|
|
106
|
+
|
|
107
|
+
Single writer; no tenant separation; locks are advisory at the filesystem
|
|
108
|
+
level; sync writes briefly block the event loop per append (fine at
|
|
109
|
+
reference-grade volumes). The hosted, transactional, lease-protected,
|
|
110
|
+
fail-closed implementation is the PostgreSQL ledger lane
|
|
111
|
+
(`docs/roadmaps/DURABLE_WITNESS_ARCHITECTURE.md`).
|
package/docs/NPM_PACKAGING.md
CHANGED
|
@@ -5,7 +5,7 @@ what deliberately does not, and how to verify the package locally.
|
|
|
5
5
|
|
|
6
6
|
> **Status: published on npm.** The current published release is **`0.1.13`**,
|
|
7
7
|
> carried on **both** the `latest` and `next` dist-tags (CJ published `0.1.13`
|
|
8
|
-
> and explicitly aligned both tags). This refresh prepares **`0.
|
|
8
|
+
> and explicitly aligned both tags). This refresh prepares **`0.4.0`** — the
|
|
9
9
|
> **truth-hardened verifier** (spec v1.1): layered verdicts (`ORIGIN AND
|
|
10
10
|
> INTEGRITY VERIFIED` / `INTEGRITY VERIFIED` / `INCOMPLETE EVIDENCE` /
|
|
11
11
|
> `VERIFICATION FAILED`), independently pinned witness keys
|
|
@@ -56,7 +56,9 @@ npm dist-tag add magenta-canon@<version> next
|
|
|
56
56
|
| `0.1.12` | the **full trust loop** release: external STH mirror (`mirror` CLI: append/verify/check/--self-test) + mirror-feed operator helper (`mirror-feed`) + operational runbook; supply-chain remediation (`npm audit` 15→**0**, runtime deps 19→17, `ws` removed, `@anthropic-ai/sdk` demoted to dev with a lazy key-gated bridge, express transitives patched via overrides, drizzle-orm 0.45.2, tarball narrowed); intake/gateway test-hygiene; plus the Windows ESM subprocess-path fix + first Windows CI job — **published** |
|
|
57
57
|
| `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
58
|
| `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
|
-
| `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 — **
|
|
59
|
+
| `0.3.0` | **verifier truth hardening** (spec v1.1, PR #27): layered verdicts; `--expected-witness-key` (independently pinned witness identity — a bundle's own key can no longer masquerade as trusted); `INCOMPLETE EVIDENCE` for zero-receipt bundles; `--require-receipt`/`--require-action-hash`/`--min-receipts` claim pinning; **receipt issuer signatures enforced**; `--json` machine output; 21-test adversarial battery; demo now pins the witness key and earns `ORIGIN AND INTEGRITY VERIFIED`. Plus: Durable Witness architecture report, MVAR v1 receipt-standard DRAFT + schema/vectors, public verifier-page design — **published**; `latest`/`next` aligned |
|
|
60
|
+
| `0.4.0` | **durable file ledger** (Durable Witness PRs 1–2, #32/#33): `LedgerStore` seam; `FileLedgerStore` — append-only canonical-JSONL evidence ledger (`MAGENTA_LEDGER_FILE`), fsync-before-committed, exclusive writer lock with documented stale-lock recovery, strict boot replay (payload hashes, chain, issuer signatures, STH signatures, one-root-per-tree-size, recomputed Merkle roots), crash-tail quarantine vs corruption refusal, **witness-continuity enforcement** (regenerated witness identity refuses to continue an existing ledger), dual-authority prevention vs legacy `MAGENTA_STATE_FILE`; two-real-process restart proof (`ORIGIN AND INTEGRITY VERIFIED` after `SIGKILL`); ships `docs/FILE_LEDGER.md` — **not yet published** |
|
|
61
|
+
|
|
60
62
|
|
|
61
63
|
**Verified on macOS / Linux / Windows (current release `0.1.11`; `0.1.12` re-proven via installed-tarball smoke):**
|
|
62
64
|
`npx magenta-canon demo` completes the full
|
|
@@ -254,21 +256,21 @@ MCP gateway, and a **local-file external STH mirror foundation** with an
|
|
|
254
256
|
operator runbook. **Not** included: hosted mirror service, automated network
|
|
255
257
|
publishing, production multi-tenant SaaS, production durability guarantees.
|
|
256
258
|
|
|
257
|
-
## Verifying the release artifact (checklist — current target `0.
|
|
259
|
+
## Verifying the release artifact (checklist — current target `0.4.0`)
|
|
258
260
|
|
|
259
261
|
From a clean checkout of the release commit:
|
|
260
262
|
|
|
261
263
|
```bash
|
|
262
264
|
npm ci && npm run check && npm test # 0 tsc errors; full suite green
|
|
263
265
|
npm run build:lean && npm run build:lean # deterministic: dist/MANIFEST.json identical
|
|
264
|
-
npm pack # prepack rebuilds dist/; expect magenta-canon-0.
|
|
265
|
-
tar -tzf magenta-canon-0.
|
|
266
|
+
npm pack # prepack rebuilds dist/; expect magenta-canon-0.4.0.tgz
|
|
267
|
+
tar -tzf magenta-canon-0.4.0.tgz # 53 files; no .ts, no .map, no tests, no server/routes.ts, no ledger/lock files
|
|
266
268
|
```
|
|
267
269
|
|
|
268
270
|
Then in an empty directory:
|
|
269
271
|
|
|
270
272
|
```bash
|
|
271
|
-
npm init -y && npm i ../path/to/magenta-canon-0.
|
|
273
|
+
npm init -y && npm i ../path/to/magenta-canon-0.4.0.tgz
|
|
272
274
|
ls node_modules # exactly: magenta-canon tweetnacl zod
|
|
273
275
|
npx magenta-canon verify --self-test # all verdict levels incl. tamper VERIFICATION FAILED
|
|
274
276
|
npx magenta-canon mirror --self-test # [PASS] x3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magenta-canon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"docs/MCP_GATEWAY.md",
|
|
44
44
|
"docs/SECURITY_MODEL.md",
|
|
45
45
|
"docs/NPM_PACKAGING.md",
|
|
46
|
+
"docs/FILE_LEDGER.md",
|
|
46
47
|
"public/canon/schemas/constitutional-spine.schema.json",
|
|
47
48
|
"public/canon/spine/constitutional-spine.v1.json",
|
|
48
49
|
"README.md",
|