@blamejs/core 0.7.18 → 0.7.19

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 (167) hide show
  1. package/CHANGELOG.md +425 -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 +315 -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 +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. 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
+ };