@blamejs/core 0.7.4 → 0.7.18

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.
Files changed (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
@@ -1,677 +1,677 @@
1
- "use strict";
2
- /**
3
- * audit-tools — operator tooling on top of the audit chain.
4
- *
5
- * Four operations the compliance matrix calls for:
6
- *
7
- * archive(opts) Bundle audit rows older than `before` into a
8
- * PQC-encrypted archive with chain proof + a
9
- * covering signed checkpoint. Live rows untouched.
10
- * exportSlice(opts) Auditor-shaped slice (date range / subject /
11
- * action filter) with chain proof. Live rows
12
- * untouched. Purpose: deliver an evidence bundle
13
- * to an external auditor without surrendering the
14
- * whole log.
15
- * verifyBundle(opts) Round-trip integrity: decrypt the bundle, walk
16
- * the chain math across the contained rows,
17
- * verify the covering checkpoint signature
18
- * (archive bundles only).
19
- * purge(opts) Confirmation-gated deletion of live audit rows
20
- * already captured in a verified archive bundle.
21
- * Inserts a purge-anchor so live audit.verify()
22
- * keeps working post-purge — the anchor's
23
- * lastRowHash becomes the new chain origin.
24
- *
25
- * Bundle layout (POSIX-flat directory; matches the backup-bundle shape
26
- * so operators see one mental model for "encrypted blamejs bundle"):
27
- *
28
- * <out>/manifest.json Canonical-JSON manifest. Includes format,
29
- * kind, range (firstCounter/lastCounter/
30
- * firstRecordedAt/lastRecordedAt/
31
- * firstRowHash/lastRowHash), rowCount, the
32
- * per-blob salts, the framework version, and
33
- * (archive only) a copy of the covering
34
- * checkpoint row plus its public-key
35
- * fingerprint.
36
- * <out>/rows.enc PQC-encrypted JSONL — one row per line,
37
- * monotonic-counter ASC, sealed form (rowHash
38
- * stays computable from disk bytes). Each
39
- * field in a sealed column is the on-disk
40
- * ciphertext, not the plaintext, so the chain
41
- * recomputes byte-for-byte.
42
- * <out>/checkpoint.enc Archive only. PQC-encrypted JSON of the
43
- * covering audit_checkpoints row.
44
- *
45
- * `kind="archive"` bundles always include a covering checkpoint
46
- * (atMonotonicCounter >= lastCounter) so the anchor signature
47
- * tamper-evidences the whole archive. `kind="export"` bundles are
48
- * auditor evidence; the chain math is self-contained but the
49
- * upstream signature anchor is optional (auditors typically follow
50
- * up with an `audit.verify` call against the live system to confirm
51
- * the slice still chains).
52
- */
53
-
54
- var fs = require("fs");
55
- var path = require("path");
56
- var pkg = require("../package.json");
57
- var atomicFile = require("./atomic-file");
58
- var auditChain = require("./audit-chain");
59
- var canonicalJson = require("./canonical-json");
60
- var auditSign = require("./audit-sign");
61
- var backupCrypto = require("./backup/crypto");
62
- var clusterStorage = require("./cluster-storage");
63
- var lazyRequire = require("./lazy-require");
64
- var jsonSafe = require("./safe-json");
65
- var { defineClass } = require("./framework-error");
66
-
67
- var FRAMEWORK_VERSION = (pkg && pkg.version) || "unknown";
68
-
69
- // Lazy `db` — db requires audit at top-of-file, audit transitively
70
- // reaches into audit-tools via the operator-supplied default fns,
71
- // so importing db at audit-tools' top would close the cycle. Lazy
72
- // keeps the load order one-way.
73
- var db = lazyRequire(function () { return require("./db"); });
74
-
75
- var AuditToolsError = defineClass("AuditToolsError", { alwaysPermanent: true });
76
-
77
- var BUNDLE_FORMAT = "blamejs-audit-bundle-v1";
78
- var KIND_ARCHIVE = "archive";
79
- var KIND_EXPORT = "export";
80
- var VALID_KINDS = { archive: true, export: true };
81
-
82
- // ---- Helpers ----
83
-
84
- function _toMs(value) {
85
- if (value == null) return null;
86
- if (typeof value === "number") return value;
87
- if (value instanceof Date) return value.getTime();
88
- if (typeof value === "string") {
89
- var ms = Date.parse(value);
90
- if (isNaN(ms)) {
91
- throw new AuditToolsError("audit-tools/bad-date",
92
- "invalid date value: " + value);
93
- }
94
- return ms;
95
- }
96
- throw new AuditToolsError("audit-tools/bad-date",
97
- "date must be a number, Date, or parseable string");
98
- }
99
-
100
- function _requirePassphrase(passphrase) {
101
- if (!Buffer.isBuffer(passphrase) && typeof passphrase !== "string") {
102
- throw new AuditToolsError("audit-tools/no-passphrase",
103
- "opts.passphrase is required (Buffer or string)");
104
- }
105
- if (passphrase.length === 0) {
106
- throw new AuditToolsError("audit-tools/no-passphrase",
107
- "opts.passphrase must be non-empty");
108
- }
109
- }
110
-
111
- function _requireOutDir(outDir, kind) {
112
- if (typeof outDir !== "string" || outDir.length === 0) {
113
- throw new AuditToolsError("audit-tools/no-outdir",
114
- kind + ": opts.out is required");
115
- }
116
- if (fs.existsSync(outDir)) {
117
- throw new AuditToolsError("audit-tools/outdir-exists",
118
- kind + ": out already exists: " + outDir +
119
- " (refusing to overwrite — pick a fresh path)");
120
- }
121
- }
122
-
123
- // Canonical-JSON via the shared lib/canonical-json walker — same bytes
124
- // as audit-chain.canonicalize, config-drift._stableStringify, and
125
- // pagination._canonicalize for the same input. Pre-v0.6.67 each site
126
- // had its own copy of the walk, all carrying the same silent-loss bug
127
- // for Date / Buffer / Map / Set / BigInt / circular refs.
128
- function _canonicalize(value) { return canonicalJson.stringify(value); }
129
-
130
- // Convert a single audit_log row to its on-disk-canonical JSON shape.
131
- // Buffers become hex strings (matches audit-chain.canonicalize). Used
132
- // so JSONL written into rows.enc has the exact bytes a verifier needs
133
- // to recompute rowHash.
134
- function _rowToWireForm(row) {
135
- var out = {};
136
- var keys = Object.keys(row);
137
- for (var i = 0; i < keys.length; i++) {
138
- var k = keys[i];
139
- var v = row[k];
140
- if (Buffer.isBuffer(v)) out[k] = "hex:" + v.toString("hex");
141
- else if (v instanceof Uint8Array) out[k] = "hex:" + Buffer.from(v).toString("hex");
142
- else if (v === undefined) out[k] = null;
143
- else out[k] = v;
144
- }
145
- return out;
146
- }
147
-
148
- function _wireFormToRow(wire) {
149
- var out = {};
150
- var keys = Object.keys(wire);
151
- for (var i = 0; i < keys.length; i++) {
152
- var k = keys[i];
153
- var v = wire[k];
154
- if (typeof v === "string" && v.indexOf("hex:") === 0) {
155
- out[k] = Buffer.from(v.slice(4), "hex");
156
- } else {
157
- out[k] = v;
158
- }
159
- }
160
- return out;
161
- }
162
-
163
- // Walk a slice of audit rows recomputing their hash chain. Returns
164
- // { ok, rowsVerified, breakAt? }. The starting prevHash is the caller's
165
- // responsibility — for archive/export slices it's the row preceding the
166
- // slice's first row (which is itself in the bundle's manifest as a
167
- // witness, or ZERO_HASH for slices that start at counter=1).
168
- function _verifyChainSlice(rows, startPrevHash) {
169
- var prevHash = startPrevHash;
170
- for (var i = 0; i < rows.length; i++) {
171
- var row = rows[i];
172
- if (row.prevHash !== prevHash) {
173
- return {
174
- ok: false, rowsVerified: i, breakAt: i,
175
- reason: "prevHash mismatch",
176
- expected: prevHash,
177
- actual: row.prevHash,
178
- };
179
- }
180
- var fields = Object.assign({}, row);
181
- delete fields.prevHash;
182
- delete fields.rowHash;
183
- delete fields.nonce;
184
- delete fields.fencingToken;
185
- var nonceBuf = Buffer.isBuffer(row.nonce) ? row.nonce : Buffer.from(row.nonce);
186
- var computed = auditChain.computeRowHash(prevHash, fields, nonceBuf);
187
- if (computed !== row.rowHash) {
188
- return {
189
- ok: false, rowsVerified: i, breakAt: i,
190
- reason: "rowHash mismatch",
191
- expected: computed,
192
- actual: row.rowHash,
193
- };
194
- }
195
- prevHash = row.rowHash;
196
- }
197
- return { ok: true, rowsVerified: rows.length, lastHash: prevHash };
198
- }
199
-
200
- // Read all audit rows from the operator's reader. Defaults to a
201
- // cluster-storage reader so the tooling works in both single-node and
202
- // cluster deployments without the caller knowing which mode is active.
203
- async function _defaultReadRows(criteria) {
204
- var sql = 'SELECT * FROM "audit_log"';
205
- var conds = [];
206
- var params = [];
207
- if (criteria.fromMs != null) { conds.push("recordedAt >= ?"); params.push(criteria.fromMs); }
208
- if (criteria.toMs != null) { conds.push("recordedAt <= ?"); params.push(criteria.toMs); }
209
- if (criteria.beforeMs != null) { conds.push("recordedAt < ?"); params.push(criteria.beforeMs); }
210
- if (criteria.action) { conds.push("action = ?"); params.push(criteria.action); }
211
- if (criteria.firstCounter != null) { conds.push("monotonicCounter >= ?"); params.push(criteria.firstCounter); }
212
- if (criteria.lastCounter != null) { conds.push("monotonicCounter <= ?"); params.push(criteria.lastCounter); }
213
- if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
214
- sql += " ORDER BY monotonicCounter ASC";
215
- return clusterStorage.executeAll(sql, params);
216
- }
217
-
218
- async function _defaultReadCoveringCheckpoint(lastCounter) {
219
- return clusterStorage.executeOne(
220
- "SELECT * FROM audit_checkpoints " +
221
- "WHERE atMonotonicCounter >= ? " +
222
- "ORDER BY atMonotonicCounter ASC LIMIT 1",
223
- [lastCounter]
224
- );
225
- }
226
-
227
- async function _defaultReadPredecessorRowHash(firstCounter) {
228
- if (firstCounter <= 1) return auditChain.ZERO_HASH;
229
- var row = await clusterStorage.executeOne(
230
- "SELECT rowHash FROM audit_log WHERE monotonicCounter = ?",
231
- [firstCounter - 1]
232
- );
233
- if (!row) {
234
- // First row of the slice is right after a purged range. Read the
235
- // purge anchor's lastRowHash instead.
236
- var anchor = await clusterStorage.executeOne(
237
- "SELECT lastPurgedRowHash, lastPurgedCounter FROM _blamejs_audit_purge_anchor " +
238
- "WHERE scope = 'audit'"
239
- );
240
- if (anchor && Number(anchor.lastPurgedCounter) === firstCounter - 1) {
241
- return anchor.lastPurgedRowHash;
242
- }
243
- throw new AuditToolsError("audit-tools/no-predecessor",
244
- "predecessor row at counter=" + (firstCounter - 1) + " missing — chain proof would be ungrounded");
245
- }
246
- return row.rowHash;
247
- }
248
-
249
- // ---- Bundle writer ----
250
-
251
- async function _writeBundle(args) {
252
- var outDir = args.outDir;
253
- var kind = args.kind;
254
- var rows = args.rows;
255
- var checkpoint = args.checkpoint || null;
256
- var passphrase = args.passphrase;
257
- var predecessorRowHash = args.predecessorRowHash;
258
-
259
- atomicFile.ensureDir(outDir);
260
-
261
- var firstRow = rows[0];
262
- var lastRow = rows[rows.length - 1];
263
-
264
- // 1. Encrypt the rows JSONL
265
- var jsonl = rows.map(function (r) {
266
- return JSON.stringify(_rowToWireForm(r));
267
- }).join("\n") + "\n";
268
- var rowsEnc = await backupCrypto.encryptWithFreshSalt(jsonl, passphrase);
269
- atomicFile.writeSync(path.join(outDir, "rows.enc"), rowsEnc.encrypted, { fileMode: 0o600 });
270
-
271
- // 2. (archive) Encrypt the checkpoint JSON
272
- var checkpointSalt = null;
273
- if (checkpoint) {
274
- var ckptJson = _canonicalize(_rowToWireForm(checkpoint));
275
- var ckptEnc = await backupCrypto.encryptWithFreshSalt(ckptJson, passphrase);
276
- atomicFile.writeSync(path.join(outDir, "checkpoint.enc"), ckptEnc.encrypted, { fileMode: 0o600 });
277
- checkpointSalt = ckptEnc.salt;
278
- }
279
-
280
- // 3. Build manifest
281
- var manifest = {
282
- format: BUNDLE_FORMAT,
283
- kind: kind,
284
- createdAt: Date.now(),
285
- frameworkVersion: FRAMEWORK_VERSION,
286
- rowCount: rows.length,
287
- range: {
288
- firstCounter: Number(firstRow.monotonicCounter),
289
- lastCounter: Number(lastRow.monotonicCounter),
290
- firstRecordedAt: Number(firstRow.recordedAt),
291
- lastRecordedAt: Number(lastRow.recordedAt),
292
- firstRowHash: String(firstRow.rowHash),
293
- lastRowHash: String(lastRow.rowHash),
294
- predecessorRowHash: String(predecessorRowHash),
295
- },
296
- salts: {
297
- rows: rowsEnc.salt,
298
- checkpoint: checkpointSalt,
299
- },
300
- checksum: {
301
- rowsSha3_512: backupCrypto.checksum(rowsEnc.encrypted),
302
- checkpointSha3_512: checkpointSalt
303
- ? backupCrypto.checksum(fs.readFileSync(path.join(outDir, "checkpoint.enc")))
304
- : null,
305
- },
306
- };
307
- if (checkpoint) {
308
- manifest.checkpoint = {
309
- atMonotonicCounter: Number(checkpoint.atMonotonicCounter),
310
- atRowHash: String(checkpoint.atRowHash),
311
- publicKeyFingerprint: String(checkpoint.publicKeyFingerprint),
312
- checkpointId: String(checkpoint._id),
313
- };
314
- }
315
- var manifestPath = path.join(outDir, "manifest.json");
316
- atomicFile.writeSync(manifestPath, _canonicalize(manifest), { fileMode: 0o600 });
317
- return { manifest: manifest, manifestPath: manifestPath };
318
- }
319
-
320
- // ---- Bundle reader ----
321
-
322
- async function _readBundle(inDir, passphrase) {
323
- if (typeof inDir !== "string" || !fs.existsSync(inDir)) {
324
- throw new AuditToolsError("audit-tools/no-bundle",
325
- "bundle directory does not exist: " + inDir);
326
- }
327
- var manifestPath = path.join(inDir, "manifest.json");
328
- if (!fs.existsSync(manifestPath)) {
329
- throw new AuditToolsError("audit-tools/no-manifest",
330
- "manifest.json missing in " + inDir);
331
- }
332
- var manifest = jsonSafe.parse(fs.readFileSync(manifestPath, "utf8"));
333
- if (!manifest || manifest.format !== BUNDLE_FORMAT) {
334
- throw new AuditToolsError("audit-tools/bad-format",
335
- "manifest.format is not " + BUNDLE_FORMAT);
336
- }
337
- if (!VALID_KINDS[manifest.kind]) {
338
- throw new AuditToolsError("audit-tools/bad-kind",
339
- "manifest.kind must be one of " + Object.keys(VALID_KINDS).join(", "));
340
- }
341
-
342
- var rowsEncPath = path.join(inDir, "rows.enc");
343
- if (!fs.existsSync(rowsEncPath)) {
344
- throw new AuditToolsError("audit-tools/no-rows-blob",
345
- "rows.enc missing in " + inDir);
346
- }
347
- var rowsEnc = fs.readFileSync(rowsEncPath);
348
- if (manifest.checksum && manifest.checksum.rowsSha3_512 &&
349
- backupCrypto.checksum(rowsEnc) !== manifest.checksum.rowsSha3_512) {
350
- throw new AuditToolsError("audit-tools/rows-checksum-mismatch",
351
- "rows.enc checksum does not match manifest — bundle was tampered with");
352
- }
353
- var rowsPlainBuf = await backupCrypto.decryptWithPassphrase(rowsEnc, passphrase, manifest.salts.rows);
354
- var rowsPlain = rowsPlainBuf.toString("utf8");
355
- var lines = rowsPlain.split("\n").filter(function (l) { return l.length > 0; });
356
- var rows = lines.map(function (l) { return _wireFormToRow(jsonSafe.parse(l)); });
357
-
358
- var checkpoint = null;
359
- if (manifest.kind === KIND_ARCHIVE) {
360
- var ckptPath = path.join(inDir, "checkpoint.enc");
361
- if (!fs.existsSync(ckptPath)) {
362
- throw new AuditToolsError("audit-tools/no-checkpoint-blob",
363
- "checkpoint.enc missing in " + inDir + " (archive bundles must include the covering checkpoint)");
364
- }
365
- var ckptEnc = fs.readFileSync(ckptPath);
366
- if (manifest.checksum && manifest.checksum.checkpointSha3_512 &&
367
- backupCrypto.checksum(ckptEnc) !== manifest.checksum.checkpointSha3_512) {
368
- throw new AuditToolsError("audit-tools/checkpoint-checksum-mismatch",
369
- "checkpoint.enc checksum does not match manifest");
370
- }
371
- var ckptPlain = (await backupCrypto.decryptWithPassphrase(ckptEnc, passphrase, manifest.salts.checkpoint))
372
- .toString("utf8");
373
- checkpoint = _wireFormToRow(jsonSafe.parse(ckptPlain));
374
- }
375
-
376
- return { manifest: manifest, rows: rows, checkpoint: checkpoint };
377
- }
378
-
379
- // ---- Public ops ----
380
-
381
- async function archive(opts) {
382
- opts = opts || {};
383
- _requirePassphrase(opts.passphrase);
384
- _requireOutDir(opts.out, "archive");
385
- var beforeMs = _toMs(opts.before);
386
- if (beforeMs == null) {
387
- throw new AuditToolsError("audit-tools/no-before",
388
- "archive: opts.before is required (date older than which rows are archived)");
389
- }
390
- var readRows = opts.readRows || _defaultReadRows;
391
- var readCovering = opts.readCoveringCheckpoint || _defaultReadCoveringCheckpoint;
392
- var readPredecessorHash = opts.readPredecessorRowHash || _defaultReadPredecessorRowHash;
393
-
394
- var rows = await readRows({ beforeMs: beforeMs });
395
- if (rows.length === 0) {
396
- throw new AuditToolsError("audit-tools/empty",
397
- "archive: no audit rows match (before=" + new Date(beforeMs).toISOString() + ")");
398
- }
399
- var lastCounter = Number(rows[rows.length - 1].monotonicCounter);
400
- var firstCounter = Number(rows[0].monotonicCounter);
401
-
402
- var checkpoint = await readCovering(lastCounter);
403
- if (!checkpoint) {
404
- throw new AuditToolsError("audit-tools/no-covering-checkpoint",
405
- "archive: no signed checkpoint covers counter=" + lastCounter +
406
- " — run audit.checkpoint() before archiving so the bundle has an off-chain anchor");
407
- }
408
-
409
- var predecessorRowHash = await readPredecessorHash(firstCounter);
410
-
411
- var written = await _writeBundle({
412
- outDir: opts.out,
413
- kind: KIND_ARCHIVE,
414
- rows: rows,
415
- checkpoint: checkpoint,
416
- passphrase: opts.passphrase,
417
- predecessorRowHash: predecessorRowHash,
418
- });
419
-
420
- return {
421
- manifest: written.manifest,
422
- manifestPath: written.manifestPath,
423
- outDir: opts.out,
424
- rowCount: rows.length,
425
- range: written.manifest.range,
426
- };
427
- }
428
-
429
- async function exportSlice(opts) {
430
- opts = opts || {};
431
- _requirePassphrase(opts.passphrase);
432
- _requireOutDir(opts.out, "export");
433
- var fromMs = _toMs(opts.from);
434
- var toMs = _toMs(opts.to);
435
- var readRows = opts.readRows || _defaultReadRows;
436
- var readPredecessorHash = opts.readPredecessorRowHash || _defaultReadPredecessorRowHash;
437
-
438
- var criteria = {};
439
- if (fromMs != null) criteria.fromMs = fromMs;
440
- if (toMs != null) criteria.toMs = toMs;
441
- if (opts.action) criteria.action = opts.action;
442
-
443
- var rows = await readRows(criteria);
444
- if (rows.length === 0) {
445
- throw new AuditToolsError("audit-tools/empty",
446
- "export: no audit rows match criteria");
447
- }
448
- // For an export the slice may be non-contiguous in counter space (e.g.
449
- // filtered by action). Reject non-contiguous slices because chain
450
- // verification can't ground a non-contiguous sequence.
451
- for (var i = 1; i < rows.length; i++) {
452
- var prev = Number(rows[i - 1].monotonicCounter);
453
- var cur = Number(rows[i].monotonicCounter);
454
- if (cur !== prev + 1) {
455
- throw new AuditToolsError("audit-tools/non-contiguous",
456
- "export: slice is non-contiguous in monotonicCounter (" + prev + " → " + cur + "). " +
457
- "Filtered exports break chain proof; use date-range filters that cover all rows in the range.");
458
- }
459
- }
460
- var firstCounter = Number(rows[0].monotonicCounter);
461
- var predecessorRowHash = await readPredecessorHash(firstCounter);
462
-
463
- var written = await _writeBundle({
464
- outDir: opts.out,
465
- kind: KIND_EXPORT,
466
- rows: rows,
467
- checkpoint: null,
468
- passphrase: opts.passphrase,
469
- predecessorRowHash: predecessorRowHash,
470
- });
471
-
472
- return {
473
- manifest: written.manifest,
474
- manifestPath: written.manifestPath,
475
- outDir: opts.out,
476
- rowCount: rows.length,
477
- range: written.manifest.range,
478
- };
479
- }
480
-
481
- async function verifyBundle(opts) {
482
- opts = opts || {};
483
- _requirePassphrase(opts.passphrase);
484
- if (typeof opts.in !== "string") {
485
- throw new AuditToolsError("audit-tools/no-indir",
486
- "verifyBundle: opts.in is required (bundle directory)");
487
- }
488
- var read = await _readBundle(opts.in, opts.passphrase);
489
-
490
- // 1. Walk the chain math across the slice.
491
- var chainResult = _verifyChainSlice(read.rows, read.manifest.range.predecessorRowHash);
492
- if (!chainResult.ok) {
493
- return {
494
- ok: false,
495
- kind: read.manifest.kind,
496
- rowsVerified: chainResult.rowsVerified,
497
- breakAt: chainResult.breakAt,
498
- reason: "chain " + chainResult.reason +
499
- " (counter=" + Number(read.rows[chainResult.breakAt].monotonicCounter) + ")",
500
- expected: chainResult.expected,
501
- actual: chainResult.actual,
502
- };
503
- }
504
-
505
- // 2. Confirm the stored firstRowHash + lastRowHash match the slice
506
- if (read.rows[0].rowHash !== read.manifest.range.firstRowHash) {
507
- return {
508
- ok: false, kind: read.manifest.kind, rowsVerified: read.rows.length,
509
- reason: "manifest.range.firstRowHash does not match first row's rowHash",
510
- };
511
- }
512
- if (read.rows[read.rows.length - 1].rowHash !== read.manifest.range.lastRowHash) {
513
- return {
514
- ok: false, kind: read.manifest.kind, rowsVerified: read.rows.length,
515
- reason: "manifest.range.lastRowHash does not match last row's rowHash",
516
- };
517
- }
518
-
519
- // 3. (archive only) verify the covering checkpoint signature
520
- if (read.manifest.kind === KIND_ARCHIVE) {
521
- if (!read.checkpoint) {
522
- return { ok: false, kind: KIND_ARCHIVE, reason: "checkpoint missing from archive bundle" };
523
- }
524
- if (Number(read.checkpoint.atMonotonicCounter) < Number(read.manifest.range.lastCounter)) {
525
- return {
526
- ok: false, kind: KIND_ARCHIVE,
527
- reason: "checkpoint atMonotonicCounter (" + read.checkpoint.atMonotonicCounter +
528
- ") < archive lastCounter (" + read.manifest.range.lastCounter + ")",
529
- };
530
- }
531
- if (opts.verifyCheckpointSignature !== false) {
532
- var verifier = opts.verifySignature || _defaultVerifyCheckpointSignature;
533
- var sigOk = verifier(read.checkpoint);
534
- if (!sigOk) {
535
- return {
536
- ok: false, kind: KIND_ARCHIVE,
537
- reason: "checkpoint ML-DSA signature verification failed (auditor's audit-sign public key may differ from archive's; pass opts.verifySignature to override)",
538
- };
539
- }
540
- }
541
- }
542
-
543
- return {
544
- ok: true,
545
- kind: read.manifest.kind,
546
- rowsVerified: read.rows.length,
547
- range: read.manifest.range,
548
- manifest: read.manifest,
549
- rows: opts.includeRows ? read.rows : undefined,
550
- };
551
- }
552
-
553
- function _defaultVerifyCheckpointSignature(checkpoint) {
554
- // Use the locally-loaded audit-sign keypair. Auditors verifying an
555
- // archive on a different machine will need to pass opts.verifySignature
556
- // with their own loaded public key. The framework deliberately doesn't
557
- // ship public keys inside the bundle — the public key fingerprint in
558
- // the checkpoint row is the verifier's lookup key.
559
- try {
560
- var pub = auditSign.getPublicKey();
561
- var fp = auditSign.getPublicKeyFingerprint();
562
- if (fp !== checkpoint.publicKeyFingerprint) return false;
563
- var payload = Buffer.from(
564
- "blamejs-audit-checkpoint-v1\n" +
565
- String(checkpoint.atMonotonicCounter) + "\n" +
566
- checkpoint.atRowHash + "\n" +
567
- String(checkpoint.createdAt),
568
- "utf8"
569
- );
570
- var sig = Buffer.isBuffer(checkpoint.signature) ? checkpoint.signature : Buffer.from(checkpoint.signature);
571
- return auditSign.verify(payload, sig, pub);
572
- } catch (_e) { return false; }
573
- }
574
-
575
- async function purge(opts) {
576
- opts = opts || {};
577
- if (opts.confirm !== true) {
578
- throw new AuditToolsError("audit-tools/no-confirm",
579
- "purge: opts.confirm must be exactly true — destructive operation requires explicit acknowledgement");
580
- }
581
- if (typeof opts.archive !== "string") {
582
- throw new AuditToolsError("audit-tools/no-archive",
583
- "purge: opts.archive is required (path to a verified archive bundle)");
584
- }
585
- _requirePassphrase(opts.passphrase);
586
-
587
- // 1. Verify the archive bundle. Refuses with a clear reason if not ok.
588
- var v = await verifyBundle({
589
- in: opts.archive,
590
- passphrase: opts.passphrase,
591
- verifySignature: opts.verifySignature, // auditor pubkey override
592
- });
593
- if (!v.ok) {
594
- throw new AuditToolsError("audit-tools/archive-not-ok",
595
- "purge: archive failed verification: " + v.reason);
596
- }
597
- if (v.kind !== KIND_ARCHIVE) {
598
- throw new AuditToolsError("audit-tools/wrong-kind",
599
- "purge: bundle kind is '" + v.kind + "', must be 'archive'");
600
- }
601
-
602
- // 2. Refuse if the archive doesn't start at the next purge point. Keeps
603
- // the chain anchor monotonic — operators can't jump-purge a middle range.
604
- var readAnchor = opts.readAnchor || _defaultReadPurgeAnchor;
605
- var anchor = await readAnchor();
606
- var expectedFirstCounter = anchor ? Number(anchor.lastPurgedCounter) + 1 : 1;
607
- if (Number(v.range.firstCounter) !== expectedFirstCounter) {
608
- throw new AuditToolsError("audit-tools/non-monotonic-purge",
609
- "purge: archive's firstCounter=" + v.range.firstCounter +
610
- " does not match expected next-purge counter=" + expectedFirstCounter +
611
- " (purges must be contiguous from the chain origin or last anchor)");
612
- }
613
- if (anchor && v.range.predecessorRowHash !== anchor.lastPurgedRowHash) {
614
- throw new AuditToolsError("audit-tools/anchor-mismatch",
615
- "purge: archive's predecessorRowHash does not match the prior purge anchor's lastPurgedRowHash");
616
- }
617
-
618
- // 3. Apply the deletion + new anchor write. This is the only mutation
619
- // path — operator-supplied for testability; default executes against
620
- // the live cluster-storage.
621
- var apply = opts.apply || _defaultApplyPurge;
622
- var result = await apply({
623
- lastPurgedCounter: Number(v.range.lastCounter),
624
- lastPurgedRowHash: v.range.lastRowHash,
625
- archiveBundleId: v.manifest.checkpoint && v.manifest.checkpoint.checkpointId
626
- || ("manifest:" + v.range.lastCounter),
627
- purgedAt: Date.now(),
628
- });
629
-
630
- return {
631
- purged: true,
632
- rowsDeleted: result.rowsDeleted,
633
- checkpointsDeleted: result.checkpointsDeleted,
634
- lastPurgedCounter: Number(v.range.lastCounter),
635
- lastPurgedRowHash: v.range.lastRowHash,
636
- archiveBundleId: result.archiveBundleId,
637
- };
638
- }
639
-
640
- async function _defaultReadPurgeAnchor() {
641
- return clusterStorage.executeOne(
642
- "SELECT * FROM _blamejs_audit_purge_anchor WHERE scope = 'audit'"
643
- );
644
- }
645
-
646
- async function _defaultApplyPurge(args) {
647
- var del = await db().purgeAuditChain({ lastPurgedCounter: args.lastPurgedCounter });
648
- // UPSERT the single-row anchor. SQLite + Postgres both support
649
- // INSERT ... ON CONFLICT(scope) DO UPDATE.
650
- await clusterStorage.execute(
651
- "INSERT INTO _blamejs_audit_purge_anchor " +
652
- "(scope, lastPurgedCounter, lastPurgedRowHash, archiveBundleId, purgedAt) " +
653
- "VALUES ('audit', ?, ?, ?, ?) " +
654
- "ON CONFLICT(scope) DO UPDATE SET " +
655
- "lastPurgedCounter = excluded.lastPurgedCounter, " +
656
- "lastPurgedRowHash = excluded.lastPurgedRowHash, " +
657
- "archiveBundleId = excluded.archiveBundleId, " +
658
- "purgedAt = excluded.purgedAt",
659
- [args.lastPurgedCounter, args.lastPurgedRowHash, args.archiveBundleId, args.purgedAt]
660
- );
661
- return {
662
- rowsDeleted: del.rowsDeleted,
663
- checkpointsDeleted: del.checkpointsDeleted,
664
- archiveBundleId: args.archiveBundleId,
665
- };
666
- }
667
-
668
- module.exports = {
669
- archive: archive,
670
- exportSlice: exportSlice,
671
- verifyBundle: verifyBundle,
672
- purge: purge,
673
- BUNDLE_FORMAT: BUNDLE_FORMAT,
674
- KIND_ARCHIVE: KIND_ARCHIVE,
675
- KIND_EXPORT: KIND_EXPORT,
676
- AuditToolsError: AuditToolsError,
677
- };
1
+ "use strict";
2
+ /**
3
+ * audit-tools — operator tooling on top of the audit chain.
4
+ *
5
+ * Four operations the compliance matrix calls for:
6
+ *
7
+ * archive(opts) Bundle audit rows older than `before` into a
8
+ * PQC-encrypted archive with chain proof + a
9
+ * covering signed checkpoint. Live rows untouched.
10
+ * exportSlice(opts) Auditor-shaped slice (date range / subject /
11
+ * action filter) with chain proof. Live rows
12
+ * untouched. Purpose: deliver an evidence bundle
13
+ * to an external auditor without surrendering the
14
+ * whole log.
15
+ * verifyBundle(opts) Round-trip integrity: decrypt the bundle, walk
16
+ * the chain math across the contained rows,
17
+ * verify the covering checkpoint signature
18
+ * (archive bundles only).
19
+ * purge(opts) Confirmation-gated deletion of live audit rows
20
+ * already captured in a verified archive bundle.
21
+ * Inserts a purge-anchor so live audit.verify()
22
+ * keeps working post-purge — the anchor's
23
+ * lastRowHash becomes the new chain origin.
24
+ *
25
+ * Bundle layout (POSIX-flat directory; matches the backup-bundle shape
26
+ * so operators see one mental model for "encrypted blamejs bundle"):
27
+ *
28
+ * <out>/manifest.json Canonical-JSON manifest. Includes format,
29
+ * kind, range (firstCounter/lastCounter/
30
+ * firstRecordedAt/lastRecordedAt/
31
+ * firstRowHash/lastRowHash), rowCount, the
32
+ * per-blob salts, the framework version, and
33
+ * (archive only) a copy of the covering
34
+ * checkpoint row plus its public-key
35
+ * fingerprint.
36
+ * <out>/rows.enc PQC-encrypted JSONL — one row per line,
37
+ * monotonic-counter ASC, sealed form (rowHash
38
+ * stays computable from disk bytes). Each
39
+ * field in a sealed column is the on-disk
40
+ * ciphertext, not the plaintext, so the chain
41
+ * recomputes byte-for-byte.
42
+ * <out>/checkpoint.enc Archive only. PQC-encrypted JSON of the
43
+ * covering audit_checkpoints row.
44
+ *
45
+ * `kind="archive"` bundles always include a covering checkpoint
46
+ * (atMonotonicCounter >= lastCounter) so the anchor signature
47
+ * tamper-evidences the whole archive. `kind="export"` bundles are
48
+ * auditor evidence; the chain math is self-contained but the
49
+ * upstream signature anchor is optional (auditors typically follow
50
+ * up with an `audit.verify` call against the live system to confirm
51
+ * the slice still chains).
52
+ */
53
+
54
+ var fs = require("fs");
55
+ var path = require("path");
56
+ var pkg = require("../package.json");
57
+ var atomicFile = require("./atomic-file");
58
+ var auditChain = require("./audit-chain");
59
+ var canonicalJson = require("./canonical-json");
60
+ var auditSign = require("./audit-sign");
61
+ var backupCrypto = require("./backup/crypto");
62
+ var clusterStorage = require("./cluster-storage");
63
+ var lazyRequire = require("./lazy-require");
64
+ var jsonSafe = require("./safe-json");
65
+ var { defineClass } = require("./framework-error");
66
+
67
+ var FRAMEWORK_VERSION = (pkg && pkg.version) || "unknown";
68
+
69
+ // Lazy `db` — db requires audit at top-of-file, audit transitively
70
+ // reaches into audit-tools via the operator-supplied default fns,
71
+ // so importing db at audit-tools' top would close the cycle. Lazy
72
+ // keeps the load order one-way.
73
+ var db = lazyRequire(function () { return require("./db"); });
74
+
75
+ var AuditToolsError = defineClass("AuditToolsError", { alwaysPermanent: true });
76
+
77
+ var BUNDLE_FORMAT = "blamejs-audit-bundle-v1";
78
+ var KIND_ARCHIVE = "archive";
79
+ var KIND_EXPORT = "export";
80
+ var VALID_KINDS = { archive: true, export: true };
81
+
82
+ // ---- Helpers ----
83
+
84
+ function _toMs(value) {
85
+ if (value == null) return null;
86
+ if (typeof value === "number") return value;
87
+ if (value instanceof Date) return value.getTime();
88
+ if (typeof value === "string") {
89
+ var ms = Date.parse(value);
90
+ if (isNaN(ms)) {
91
+ throw new AuditToolsError("audit-tools/bad-date",
92
+ "invalid date value: " + value);
93
+ }
94
+ return ms;
95
+ }
96
+ throw new AuditToolsError("audit-tools/bad-date",
97
+ "date must be a number, Date, or parseable string");
98
+ }
99
+
100
+ function _requirePassphrase(passphrase) {
101
+ if (!Buffer.isBuffer(passphrase) && typeof passphrase !== "string") {
102
+ throw new AuditToolsError("audit-tools/no-passphrase",
103
+ "opts.passphrase is required (Buffer or string)");
104
+ }
105
+ if (passphrase.length === 0) {
106
+ throw new AuditToolsError("audit-tools/no-passphrase",
107
+ "opts.passphrase must be non-empty");
108
+ }
109
+ }
110
+
111
+ function _requireOutDir(outDir, kind) {
112
+ if (typeof outDir !== "string" || outDir.length === 0) {
113
+ throw new AuditToolsError("audit-tools/no-outdir",
114
+ kind + ": opts.out is required");
115
+ }
116
+ if (fs.existsSync(outDir)) {
117
+ throw new AuditToolsError("audit-tools/outdir-exists",
118
+ kind + ": out already exists: " + outDir +
119
+ " (refusing to overwrite — pick a fresh path)");
120
+ }
121
+ }
122
+
123
+ // Canonical-JSON via the shared lib/canonical-json walker — same bytes
124
+ // as audit-chain.canonicalize, config-drift._stableStringify, and
125
+ // pagination._canonicalize for the same input. Pre-v0.6.67 each site
126
+ // had its own copy of the walk, all carrying the same silent-loss bug
127
+ // for Date / Buffer / Map / Set / BigInt / circular refs.
128
+ function _canonicalize(value) { return canonicalJson.stringify(value); }
129
+
130
+ // Convert a single audit_log row to its on-disk-canonical JSON shape.
131
+ // Buffers become hex strings (matches audit-chain.canonicalize). Used
132
+ // so JSONL written into rows.enc has the exact bytes a verifier needs
133
+ // to recompute rowHash.
134
+ function _rowToWireForm(row) {
135
+ var out = {};
136
+ var keys = Object.keys(row);
137
+ for (var i = 0; i < keys.length; i++) {
138
+ var k = keys[i];
139
+ var v = row[k];
140
+ if (Buffer.isBuffer(v)) out[k] = "hex:" + v.toString("hex");
141
+ else if (v instanceof Uint8Array) out[k] = "hex:" + Buffer.from(v).toString("hex");
142
+ else if (v === undefined) out[k] = null;
143
+ else out[k] = v;
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function _wireFormToRow(wire) {
149
+ var out = {};
150
+ var keys = Object.keys(wire);
151
+ for (var i = 0; i < keys.length; i++) {
152
+ var k = keys[i];
153
+ var v = wire[k];
154
+ if (typeof v === "string" && v.indexOf("hex:") === 0) {
155
+ out[k] = Buffer.from(v.slice(4), "hex");
156
+ } else {
157
+ out[k] = v;
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ // Walk a slice of audit rows recomputing their hash chain. Returns
164
+ // { ok, rowsVerified, breakAt? }. The starting prevHash is the caller's
165
+ // responsibility — for archive/export slices it's the row preceding the
166
+ // slice's first row (which is itself in the bundle's manifest as a
167
+ // witness, or ZERO_HASH for slices that start at counter=1).
168
+ function _verifyChainSlice(rows, startPrevHash) {
169
+ var prevHash = startPrevHash;
170
+ for (var i = 0; i < rows.length; i++) {
171
+ var row = rows[i];
172
+ if (row.prevHash !== prevHash) {
173
+ return {
174
+ ok: false, rowsVerified: i, breakAt: i,
175
+ reason: "prevHash mismatch",
176
+ expected: prevHash,
177
+ actual: row.prevHash,
178
+ };
179
+ }
180
+ var fields = Object.assign({}, row);
181
+ delete fields.prevHash;
182
+ delete fields.rowHash;
183
+ delete fields.nonce;
184
+ delete fields.fencingToken;
185
+ var nonceBuf = Buffer.isBuffer(row.nonce) ? row.nonce : Buffer.from(row.nonce);
186
+ var computed = auditChain.computeRowHash(prevHash, fields, nonceBuf);
187
+ if (computed !== row.rowHash) {
188
+ return {
189
+ ok: false, rowsVerified: i, breakAt: i,
190
+ reason: "rowHash mismatch",
191
+ expected: computed,
192
+ actual: row.rowHash,
193
+ };
194
+ }
195
+ prevHash = row.rowHash;
196
+ }
197
+ return { ok: true, rowsVerified: rows.length, lastHash: prevHash };
198
+ }
199
+
200
+ // Read all audit rows from the operator's reader. Defaults to a
201
+ // cluster-storage reader so the tooling works in both single-node and
202
+ // cluster deployments without the caller knowing which mode is active.
203
+ async function _defaultReadRows(criteria) {
204
+ var sql = 'SELECT * FROM "audit_log"';
205
+ var conds = [];
206
+ var params = [];
207
+ if (criteria.fromMs != null) { conds.push("recordedAt >= ?"); params.push(criteria.fromMs); }
208
+ if (criteria.toMs != null) { conds.push("recordedAt <= ?"); params.push(criteria.toMs); }
209
+ if (criteria.beforeMs != null) { conds.push("recordedAt < ?"); params.push(criteria.beforeMs); }
210
+ if (criteria.action) { conds.push("action = ?"); params.push(criteria.action); }
211
+ if (criteria.firstCounter != null) { conds.push("monotonicCounter >= ?"); params.push(criteria.firstCounter); }
212
+ if (criteria.lastCounter != null) { conds.push("monotonicCounter <= ?"); params.push(criteria.lastCounter); }
213
+ if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
214
+ sql += " ORDER BY monotonicCounter ASC";
215
+ return clusterStorage.executeAll(sql, params);
216
+ }
217
+
218
+ async function _defaultReadCoveringCheckpoint(lastCounter) {
219
+ return clusterStorage.executeOne(
220
+ "SELECT * FROM audit_checkpoints " +
221
+ "WHERE atMonotonicCounter >= ? " +
222
+ "ORDER BY atMonotonicCounter ASC LIMIT 1",
223
+ [lastCounter]
224
+ );
225
+ }
226
+
227
+ async function _defaultReadPredecessorRowHash(firstCounter) {
228
+ if (firstCounter <= 1) return auditChain.ZERO_HASH;
229
+ var row = await clusterStorage.executeOne(
230
+ "SELECT rowHash FROM audit_log WHERE monotonicCounter = ?",
231
+ [firstCounter - 1]
232
+ );
233
+ if (!row) {
234
+ // First row of the slice is right after a purged range. Read the
235
+ // purge anchor's lastRowHash instead.
236
+ var anchor = await clusterStorage.executeOne(
237
+ "SELECT lastPurgedRowHash, lastPurgedCounter FROM _blamejs_audit_purge_anchor " +
238
+ "WHERE scope = 'audit'"
239
+ );
240
+ if (anchor && Number(anchor.lastPurgedCounter) === firstCounter - 1) {
241
+ return anchor.lastPurgedRowHash;
242
+ }
243
+ throw new AuditToolsError("audit-tools/no-predecessor",
244
+ "predecessor row at counter=" + (firstCounter - 1) + " missing — chain proof would be ungrounded");
245
+ }
246
+ return row.rowHash;
247
+ }
248
+
249
+ // ---- Bundle writer ----
250
+
251
+ async function _writeBundle(args) {
252
+ var outDir = args.outDir;
253
+ var kind = args.kind;
254
+ var rows = args.rows;
255
+ var checkpoint = args.checkpoint || null;
256
+ var passphrase = args.passphrase;
257
+ var predecessorRowHash = args.predecessorRowHash;
258
+
259
+ atomicFile.ensureDir(outDir);
260
+
261
+ var firstRow = rows[0];
262
+ var lastRow = rows[rows.length - 1];
263
+
264
+ // 1. Encrypt the rows JSONL
265
+ var jsonl = rows.map(function (r) {
266
+ return JSON.stringify(_rowToWireForm(r));
267
+ }).join("\n") + "\n";
268
+ var rowsEnc = await backupCrypto.encryptWithFreshSalt(jsonl, passphrase);
269
+ atomicFile.writeSync(path.join(outDir, "rows.enc"), rowsEnc.encrypted, { fileMode: 0o600 });
270
+
271
+ // 2. (archive) Encrypt the checkpoint JSON
272
+ var checkpointSalt = null;
273
+ if (checkpoint) {
274
+ var ckptJson = _canonicalize(_rowToWireForm(checkpoint));
275
+ var ckptEnc = await backupCrypto.encryptWithFreshSalt(ckptJson, passphrase);
276
+ atomicFile.writeSync(path.join(outDir, "checkpoint.enc"), ckptEnc.encrypted, { fileMode: 0o600 });
277
+ checkpointSalt = ckptEnc.salt;
278
+ }
279
+
280
+ // 3. Build manifest
281
+ var manifest = {
282
+ format: BUNDLE_FORMAT,
283
+ kind: kind,
284
+ createdAt: Date.now(),
285
+ frameworkVersion: FRAMEWORK_VERSION,
286
+ rowCount: rows.length,
287
+ range: {
288
+ firstCounter: Number(firstRow.monotonicCounter),
289
+ lastCounter: Number(lastRow.monotonicCounter),
290
+ firstRecordedAt: Number(firstRow.recordedAt),
291
+ lastRecordedAt: Number(lastRow.recordedAt),
292
+ firstRowHash: String(firstRow.rowHash),
293
+ lastRowHash: String(lastRow.rowHash),
294
+ predecessorRowHash: String(predecessorRowHash),
295
+ },
296
+ salts: {
297
+ rows: rowsEnc.salt,
298
+ checkpoint: checkpointSalt,
299
+ },
300
+ checksum: {
301
+ rowsSha3_512: backupCrypto.checksum(rowsEnc.encrypted),
302
+ checkpointSha3_512: checkpointSalt
303
+ ? backupCrypto.checksum(fs.readFileSync(path.join(outDir, "checkpoint.enc")))
304
+ : null,
305
+ },
306
+ };
307
+ if (checkpoint) {
308
+ manifest.checkpoint = {
309
+ atMonotonicCounter: Number(checkpoint.atMonotonicCounter),
310
+ atRowHash: String(checkpoint.atRowHash),
311
+ publicKeyFingerprint: String(checkpoint.publicKeyFingerprint),
312
+ checkpointId: String(checkpoint._id),
313
+ };
314
+ }
315
+ var manifestPath = path.join(outDir, "manifest.json");
316
+ atomicFile.writeSync(manifestPath, _canonicalize(manifest), { fileMode: 0o600 });
317
+ return { manifest: manifest, manifestPath: manifestPath };
318
+ }
319
+
320
+ // ---- Bundle reader ----
321
+
322
+ async function _readBundle(inDir, passphrase) {
323
+ if (typeof inDir !== "string" || !fs.existsSync(inDir)) {
324
+ throw new AuditToolsError("audit-tools/no-bundle",
325
+ "bundle directory does not exist: " + inDir);
326
+ }
327
+ var manifestPath = path.join(inDir, "manifest.json");
328
+ if (!fs.existsSync(manifestPath)) {
329
+ throw new AuditToolsError("audit-tools/no-manifest",
330
+ "manifest.json missing in " + inDir);
331
+ }
332
+ var manifest = jsonSafe.parse(fs.readFileSync(manifestPath, "utf8"));
333
+ if (!manifest || manifest.format !== BUNDLE_FORMAT) {
334
+ throw new AuditToolsError("audit-tools/bad-format",
335
+ "manifest.format is not " + BUNDLE_FORMAT);
336
+ }
337
+ if (!VALID_KINDS[manifest.kind]) {
338
+ throw new AuditToolsError("audit-tools/bad-kind",
339
+ "manifest.kind must be one of " + Object.keys(VALID_KINDS).join(", "));
340
+ }
341
+
342
+ var rowsEncPath = path.join(inDir, "rows.enc");
343
+ if (!fs.existsSync(rowsEncPath)) {
344
+ throw new AuditToolsError("audit-tools/no-rows-blob",
345
+ "rows.enc missing in " + inDir);
346
+ }
347
+ var rowsEnc = fs.readFileSync(rowsEncPath);
348
+ if (manifest.checksum && manifest.checksum.rowsSha3_512 &&
349
+ backupCrypto.checksum(rowsEnc) !== manifest.checksum.rowsSha3_512) {
350
+ throw new AuditToolsError("audit-tools/rows-checksum-mismatch",
351
+ "rows.enc checksum does not match manifest — bundle was tampered with");
352
+ }
353
+ var rowsPlainBuf = await backupCrypto.decryptWithPassphrase(rowsEnc, passphrase, manifest.salts.rows);
354
+ var rowsPlain = rowsPlainBuf.toString("utf8");
355
+ var lines = rowsPlain.split("\n").filter(function (l) { return l.length > 0; });
356
+ var rows = lines.map(function (l) { return _wireFormToRow(jsonSafe.parse(l)); });
357
+
358
+ var checkpoint = null;
359
+ if (manifest.kind === KIND_ARCHIVE) {
360
+ var ckptPath = path.join(inDir, "checkpoint.enc");
361
+ if (!fs.existsSync(ckptPath)) {
362
+ throw new AuditToolsError("audit-tools/no-checkpoint-blob",
363
+ "checkpoint.enc missing in " + inDir + " (archive bundles must include the covering checkpoint)");
364
+ }
365
+ var ckptEnc = fs.readFileSync(ckptPath);
366
+ if (manifest.checksum && manifest.checksum.checkpointSha3_512 &&
367
+ backupCrypto.checksum(ckptEnc) !== manifest.checksum.checkpointSha3_512) {
368
+ throw new AuditToolsError("audit-tools/checkpoint-checksum-mismatch",
369
+ "checkpoint.enc checksum does not match manifest");
370
+ }
371
+ var ckptPlain = (await backupCrypto.decryptWithPassphrase(ckptEnc, passphrase, manifest.salts.checkpoint))
372
+ .toString("utf8");
373
+ checkpoint = _wireFormToRow(jsonSafe.parse(ckptPlain));
374
+ }
375
+
376
+ return { manifest: manifest, rows: rows, checkpoint: checkpoint };
377
+ }
378
+
379
+ // ---- Public ops ----
380
+
381
+ async function archive(opts) {
382
+ opts = opts || {};
383
+ _requirePassphrase(opts.passphrase);
384
+ _requireOutDir(opts.out, "archive");
385
+ var beforeMs = _toMs(opts.before);
386
+ if (beforeMs == null) {
387
+ throw new AuditToolsError("audit-tools/no-before",
388
+ "archive: opts.before is required (date older than which rows are archived)");
389
+ }
390
+ var readRows = opts.readRows || _defaultReadRows;
391
+ var readCovering = opts.readCoveringCheckpoint || _defaultReadCoveringCheckpoint;
392
+ var readPredecessorHash = opts.readPredecessorRowHash || _defaultReadPredecessorRowHash;
393
+
394
+ var rows = await readRows({ beforeMs: beforeMs });
395
+ if (rows.length === 0) {
396
+ throw new AuditToolsError("audit-tools/empty",
397
+ "archive: no audit rows match (before=" + new Date(beforeMs).toISOString() + ")");
398
+ }
399
+ var lastCounter = Number(rows[rows.length - 1].monotonicCounter);
400
+ var firstCounter = Number(rows[0].monotonicCounter);
401
+
402
+ var checkpoint = await readCovering(lastCounter);
403
+ if (!checkpoint) {
404
+ throw new AuditToolsError("audit-tools/no-covering-checkpoint",
405
+ "archive: no signed checkpoint covers counter=" + lastCounter +
406
+ " — run audit.checkpoint() before archiving so the bundle has an off-chain anchor");
407
+ }
408
+
409
+ var predecessorRowHash = await readPredecessorHash(firstCounter);
410
+
411
+ var written = await _writeBundle({
412
+ outDir: opts.out,
413
+ kind: KIND_ARCHIVE,
414
+ rows: rows,
415
+ checkpoint: checkpoint,
416
+ passphrase: opts.passphrase,
417
+ predecessorRowHash: predecessorRowHash,
418
+ });
419
+
420
+ return {
421
+ manifest: written.manifest,
422
+ manifestPath: written.manifestPath,
423
+ outDir: opts.out,
424
+ rowCount: rows.length,
425
+ range: written.manifest.range,
426
+ };
427
+ }
428
+
429
+ async function exportSlice(opts) {
430
+ opts = opts || {};
431
+ _requirePassphrase(opts.passphrase);
432
+ _requireOutDir(opts.out, "export");
433
+ var fromMs = _toMs(opts.from);
434
+ var toMs = _toMs(opts.to);
435
+ var readRows = opts.readRows || _defaultReadRows;
436
+ var readPredecessorHash = opts.readPredecessorRowHash || _defaultReadPredecessorRowHash;
437
+
438
+ var criteria = {};
439
+ if (fromMs != null) criteria.fromMs = fromMs;
440
+ if (toMs != null) criteria.toMs = toMs;
441
+ if (opts.action) criteria.action = opts.action;
442
+
443
+ var rows = await readRows(criteria);
444
+ if (rows.length === 0) {
445
+ throw new AuditToolsError("audit-tools/empty",
446
+ "export: no audit rows match criteria");
447
+ }
448
+ // For an export the slice may be non-contiguous in counter space (e.g.
449
+ // filtered by action). Reject non-contiguous slices because chain
450
+ // verification can't ground a non-contiguous sequence.
451
+ for (var i = 1; i < rows.length; i++) {
452
+ var prev = Number(rows[i - 1].monotonicCounter);
453
+ var cur = Number(rows[i].monotonicCounter);
454
+ if (cur !== prev + 1) {
455
+ throw new AuditToolsError("audit-tools/non-contiguous",
456
+ "export: slice is non-contiguous in monotonicCounter (" + prev + " → " + cur + "). " +
457
+ "Filtered exports break chain proof; use date-range filters that cover all rows in the range.");
458
+ }
459
+ }
460
+ var firstCounter = Number(rows[0].monotonicCounter);
461
+ var predecessorRowHash = await readPredecessorHash(firstCounter);
462
+
463
+ var written = await _writeBundle({
464
+ outDir: opts.out,
465
+ kind: KIND_EXPORT,
466
+ rows: rows,
467
+ checkpoint: null,
468
+ passphrase: opts.passphrase,
469
+ predecessorRowHash: predecessorRowHash,
470
+ });
471
+
472
+ return {
473
+ manifest: written.manifest,
474
+ manifestPath: written.manifestPath,
475
+ outDir: opts.out,
476
+ rowCount: rows.length,
477
+ range: written.manifest.range,
478
+ };
479
+ }
480
+
481
+ async function verifyBundle(opts) {
482
+ opts = opts || {};
483
+ _requirePassphrase(opts.passphrase);
484
+ if (typeof opts.in !== "string") {
485
+ throw new AuditToolsError("audit-tools/no-indir",
486
+ "verifyBundle: opts.in is required (bundle directory)");
487
+ }
488
+ var read = await _readBundle(opts.in, opts.passphrase);
489
+
490
+ // 1. Walk the chain math across the slice.
491
+ var chainResult = _verifyChainSlice(read.rows, read.manifest.range.predecessorRowHash);
492
+ if (!chainResult.ok) {
493
+ return {
494
+ ok: false,
495
+ kind: read.manifest.kind,
496
+ rowsVerified: chainResult.rowsVerified,
497
+ breakAt: chainResult.breakAt,
498
+ reason: "chain " + chainResult.reason +
499
+ " (counter=" + Number(read.rows[chainResult.breakAt].monotonicCounter) + ")",
500
+ expected: chainResult.expected,
501
+ actual: chainResult.actual,
502
+ };
503
+ }
504
+
505
+ // 2. Confirm the stored firstRowHash + lastRowHash match the slice
506
+ if (read.rows[0].rowHash !== read.manifest.range.firstRowHash) {
507
+ return {
508
+ ok: false, kind: read.manifest.kind, rowsVerified: read.rows.length,
509
+ reason: "manifest.range.firstRowHash does not match first row's rowHash",
510
+ };
511
+ }
512
+ if (read.rows[read.rows.length - 1].rowHash !== read.manifest.range.lastRowHash) {
513
+ return {
514
+ ok: false, kind: read.manifest.kind, rowsVerified: read.rows.length,
515
+ reason: "manifest.range.lastRowHash does not match last row's rowHash",
516
+ };
517
+ }
518
+
519
+ // 3. (archive only) verify the covering checkpoint signature
520
+ if (read.manifest.kind === KIND_ARCHIVE) {
521
+ if (!read.checkpoint) {
522
+ return { ok: false, kind: KIND_ARCHIVE, reason: "checkpoint missing from archive bundle" };
523
+ }
524
+ if (Number(read.checkpoint.atMonotonicCounter) < Number(read.manifest.range.lastCounter)) {
525
+ return {
526
+ ok: false, kind: KIND_ARCHIVE,
527
+ reason: "checkpoint atMonotonicCounter (" + read.checkpoint.atMonotonicCounter +
528
+ ") < archive lastCounter (" + read.manifest.range.lastCounter + ")",
529
+ };
530
+ }
531
+ if (opts.verifyCheckpointSignature !== false) {
532
+ var verifier = opts.verifySignature || _defaultVerifyCheckpointSignature;
533
+ var sigOk = verifier(read.checkpoint);
534
+ if (!sigOk) {
535
+ return {
536
+ ok: false, kind: KIND_ARCHIVE,
537
+ reason: "checkpoint ML-DSA signature verification failed (auditor's audit-sign public key may differ from archive's; pass opts.verifySignature to override)",
538
+ };
539
+ }
540
+ }
541
+ }
542
+
543
+ return {
544
+ ok: true,
545
+ kind: read.manifest.kind,
546
+ rowsVerified: read.rows.length,
547
+ range: read.manifest.range,
548
+ manifest: read.manifest,
549
+ rows: opts.includeRows ? read.rows : undefined,
550
+ };
551
+ }
552
+
553
+ function _defaultVerifyCheckpointSignature(checkpoint) {
554
+ // Use the locally-loaded audit-sign keypair. Auditors verifying an
555
+ // archive on a different machine will need to pass opts.verifySignature
556
+ // with their own loaded public key. The framework deliberately doesn't
557
+ // ship public keys inside the bundle — the public key fingerprint in
558
+ // the checkpoint row is the verifier's lookup key.
559
+ try {
560
+ var pub = auditSign.getPublicKey();
561
+ var fp = auditSign.getPublicKeyFingerprint();
562
+ if (fp !== checkpoint.publicKeyFingerprint) return false;
563
+ var payload = Buffer.from(
564
+ "blamejs-audit-checkpoint-v1\n" +
565
+ String(checkpoint.atMonotonicCounter) + "\n" +
566
+ checkpoint.atRowHash + "\n" +
567
+ String(checkpoint.createdAt),
568
+ "utf8"
569
+ );
570
+ var sig = Buffer.isBuffer(checkpoint.signature) ? checkpoint.signature : Buffer.from(checkpoint.signature);
571
+ return auditSign.verify(payload, sig, pub);
572
+ } catch (_e) { return false; }
573
+ }
574
+
575
+ async function purge(opts) {
576
+ opts = opts || {};
577
+ if (opts.confirm !== true) {
578
+ throw new AuditToolsError("audit-tools/no-confirm",
579
+ "purge: opts.confirm must be exactly true — destructive operation requires explicit acknowledgement");
580
+ }
581
+ if (typeof opts.archive !== "string") {
582
+ throw new AuditToolsError("audit-tools/no-archive",
583
+ "purge: opts.archive is required (path to a verified archive bundle)");
584
+ }
585
+ _requirePassphrase(opts.passphrase);
586
+
587
+ // 1. Verify the archive bundle. Refuses with a clear reason if not ok.
588
+ var v = await verifyBundle({
589
+ in: opts.archive,
590
+ passphrase: opts.passphrase,
591
+ verifySignature: opts.verifySignature, // auditor pubkey override
592
+ });
593
+ if (!v.ok) {
594
+ throw new AuditToolsError("audit-tools/archive-not-ok",
595
+ "purge: archive failed verification: " + v.reason);
596
+ }
597
+ if (v.kind !== KIND_ARCHIVE) {
598
+ throw new AuditToolsError("audit-tools/wrong-kind",
599
+ "purge: bundle kind is '" + v.kind + "', must be 'archive'");
600
+ }
601
+
602
+ // 2. Refuse if the archive doesn't start at the next purge point. Keeps
603
+ // the chain anchor monotonic — operators can't jump-purge a middle range.
604
+ var readAnchor = opts.readAnchor || _defaultReadPurgeAnchor;
605
+ var anchor = await readAnchor();
606
+ var expectedFirstCounter = anchor ? Number(anchor.lastPurgedCounter) + 1 : 1;
607
+ if (Number(v.range.firstCounter) !== expectedFirstCounter) {
608
+ throw new AuditToolsError("audit-tools/non-monotonic-purge",
609
+ "purge: archive's firstCounter=" + v.range.firstCounter +
610
+ " does not match expected next-purge counter=" + expectedFirstCounter +
611
+ " (purges must be contiguous from the chain origin or last anchor)");
612
+ }
613
+ if (anchor && v.range.predecessorRowHash !== anchor.lastPurgedRowHash) {
614
+ throw new AuditToolsError("audit-tools/anchor-mismatch",
615
+ "purge: archive's predecessorRowHash does not match the prior purge anchor's lastPurgedRowHash");
616
+ }
617
+
618
+ // 3. Apply the deletion + new anchor write. This is the only mutation
619
+ // path — operator-supplied for testability; default executes against
620
+ // the live cluster-storage.
621
+ var apply = opts.apply || _defaultApplyPurge;
622
+ var result = await apply({
623
+ lastPurgedCounter: Number(v.range.lastCounter),
624
+ lastPurgedRowHash: v.range.lastRowHash,
625
+ archiveBundleId: v.manifest.checkpoint && v.manifest.checkpoint.checkpointId
626
+ || ("manifest:" + v.range.lastCounter),
627
+ purgedAt: Date.now(),
628
+ });
629
+
630
+ return {
631
+ purged: true,
632
+ rowsDeleted: result.rowsDeleted,
633
+ checkpointsDeleted: result.checkpointsDeleted,
634
+ lastPurgedCounter: Number(v.range.lastCounter),
635
+ lastPurgedRowHash: v.range.lastRowHash,
636
+ archiveBundleId: result.archiveBundleId,
637
+ };
638
+ }
639
+
640
+ async function _defaultReadPurgeAnchor() {
641
+ return clusterStorage.executeOne(
642
+ "SELECT * FROM _blamejs_audit_purge_anchor WHERE scope = 'audit'"
643
+ );
644
+ }
645
+
646
+ async function _defaultApplyPurge(args) {
647
+ var del = await db().purgeAuditChain({ lastPurgedCounter: args.lastPurgedCounter });
648
+ // UPSERT the single-row anchor. SQLite + Postgres both support
649
+ // INSERT ... ON CONFLICT(scope) DO UPDATE.
650
+ await clusterStorage.execute(
651
+ "INSERT INTO _blamejs_audit_purge_anchor " +
652
+ "(scope, lastPurgedCounter, lastPurgedRowHash, archiveBundleId, purgedAt) " +
653
+ "VALUES ('audit', ?, ?, ?, ?) " +
654
+ "ON CONFLICT(scope) DO UPDATE SET " +
655
+ "lastPurgedCounter = excluded.lastPurgedCounter, " +
656
+ "lastPurgedRowHash = excluded.lastPurgedRowHash, " +
657
+ "archiveBundleId = excluded.archiveBundleId, " +
658
+ "purgedAt = excluded.purgedAt",
659
+ [args.lastPurgedCounter, args.lastPurgedRowHash, args.archiveBundleId, args.purgedAt]
660
+ );
661
+ return {
662
+ rowsDeleted: del.rowsDeleted,
663
+ checkpointsDeleted: del.checkpointsDeleted,
664
+ archiveBundleId: args.archiveBundleId,
665
+ };
666
+ }
667
+
668
+ module.exports = {
669
+ archive: archive,
670
+ exportSlice: exportSlice,
671
+ verifyBundle: verifyBundle,
672
+ purge: purge,
673
+ BUNDLE_FORMAT: BUNDLE_FORMAT,
674
+ KIND_ARCHIVE: KIND_ARCHIVE,
675
+ KIND_EXPORT: KIND_EXPORT,
676
+ AuditToolsError: AuditToolsError,
677
+ };