@blamejs/core 0.7.18 → 0.7.20

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 (169) hide show
  1. package/CHANGELOG.md +427 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  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-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  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 +350 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  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/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +399 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/fetch-metadata.js +129 -0
  86. package/lib/middleware/health.js +392 -392
  87. package/lib/middleware/index.js +85 -79
  88. package/lib/middleware/rate-limit.js +358 -358
  89. package/lib/middleware/request-id.js +61 -61
  90. package/lib/middleware/request-log.js +168 -168
  91. package/lib/middleware/require-auth.js +104 -104
  92. package/lib/middleware/security-headers.js +121 -116
  93. package/lib/middleware/sse.js +166 -166
  94. package/lib/migrations.js +383 -383
  95. package/lib/mtls-ca.js +518 -518
  96. package/lib/mtls-engine-default.js +481 -481
  97. package/lib/network-dns.js +632 -632
  98. package/lib/network-heartbeat.js +290 -290
  99. package/lib/network-nts.js +574 -574
  100. package/lib/network-proxy.js +265 -265
  101. package/lib/network-tls.js +328 -328
  102. package/lib/network.js +233 -233
  103. package/lib/notify.js +612 -612
  104. package/lib/ntp-check.js +229 -229
  105. package/lib/numeric-bounds.js +111 -111
  106. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  107. package/lib/object-store/azure-blob.js +488 -488
  108. package/lib/object-store/gcs-bucket-ops.js +351 -351
  109. package/lib/object-store/gcs.js +519 -519
  110. package/lib/object-store/http-put.js +153 -153
  111. package/lib/object-store/index.js +197 -197
  112. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  113. package/lib/object-store/sigv4.js +903 -903
  114. package/lib/observability.js +151 -151
  115. package/lib/otel-export.js +269 -269
  116. package/lib/pagination.js +464 -464
  117. package/lib/parsers/index.js +80 -80
  118. package/lib/parsers/safe-env.js +642 -642
  119. package/lib/parsers/safe-ini.js +292 -292
  120. package/lib/parsers/safe-toml.js +784 -784
  121. package/lib/parsers/safe-xml.js +390 -390
  122. package/lib/parsers/safe-yaml.js +1015 -1015
  123. package/lib/permissions.js +708 -708
  124. package/lib/pqc-agent.js +87 -87
  125. package/lib/pqc-gate.js +279 -279
  126. package/lib/protobuf-encoder.js +190 -190
  127. package/lib/protocol-dispatcher.js +161 -161
  128. package/lib/pubsub-redis.js +167 -167
  129. package/lib/pubsub.js +429 -429
  130. package/lib/queue-local.js +476 -476
  131. package/lib/queue-redis.js +745 -745
  132. package/lib/queue-sqs.js +319 -319
  133. package/lib/queue.js +695 -695
  134. package/lib/redis-client.js +519 -519
  135. package/lib/request-helpers.js +340 -340
  136. package/lib/restore-bundle.js +237 -237
  137. package/lib/restore-rollback.js +259 -259
  138. package/lib/restore.js +409 -409
  139. package/lib/retry.js +376 -376
  140. package/lib/router.js +748 -748
  141. package/lib/safe-async.js +735 -735
  142. package/lib/safe-buffer.js +237 -237
  143. package/lib/safe-json.js +541 -541
  144. package/lib/safe-schema.js +1266 -1266
  145. package/lib/safe-url.js +159 -159
  146. package/lib/scheduler.js +706 -706
  147. package/lib/security-assert.js +373 -373
  148. package/lib/seeders.js +618 -618
  149. package/lib/session.js +535 -478
  150. package/lib/slug.js +269 -269
  151. package/lib/ssrf-guard.js +401 -401
  152. package/lib/static.js +7 -5
  153. package/lib/storage.js +471 -471
  154. package/lib/subject.js +281 -281
  155. package/lib/template.js +791 -791
  156. package/lib/testing.js +798 -798
  157. package/lib/time.js +310 -310
  158. package/lib/totp.js +302 -302
  159. package/lib/tracing.js +494 -494
  160. package/lib/uuid.js +132 -132
  161. package/lib/validate-opts.js +340 -340
  162. package/lib/vault/index.js +308 -308
  163. package/lib/vault/rotate.js +784 -784
  164. package/lib/vault/wrap.js +296 -296
  165. package/lib/vendor/noble-ciphers.cjs +9 -9
  166. package/lib/webhook.js +595 -595
  167. package/lib/websocket.js +1048 -1048
  168. package/package.json +77 -77
  169. 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
+ };