@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.
- package/CHANGELOG.md +425 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +82 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/audit-tools.js
CHANGED
|
@@ -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
|
+
};
|