@blamejs/core 0.14.27 → 0.15.1
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 +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +158 -77
- package/lib/atomic-file.js +29 -1
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +228 -100
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +22 -0
- package/lib/consent.js +82 -29
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +387 -91
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +517 -256
- package/lib/db-schema.js +209 -44
- package/lib/db.js +202 -95
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/framework-error.js +11 -0
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +596 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +14 -0
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +293 -154
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +31 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/outbox.js +136 -82
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/retention.js +82 -39
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +116 -72
- package/lib/sql.js +3885 -0
- package/lib/static.js +45 -7
- package/lib/subject.js +89 -49
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +16 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/static.js
CHANGED
|
@@ -126,12 +126,32 @@ var DEFAULTS = Object.freeze({
|
|
|
126
126
|
// serve event is the audit-worthy act, not a precursor.
|
|
127
127
|
auditSuccess: true,
|
|
128
128
|
auditFailures: true,
|
|
129
|
+
// mountType — declares what KIND of content this mount serves, so
|
|
130
|
+
// the stored-XSS-relevant defaults follow the typing instead of being
|
|
131
|
+
// hand-flipped per mount (v0.15.0):
|
|
132
|
+
// "curated" (default) — operator-controlled assets (CSS / JS
|
|
133
|
+
// bundles / fonts / images). Inline render is required
|
|
134
|
+
// and safe because the operator authored the bytes;
|
|
135
|
+
// forceAttachmentForNonText defaults OFF.
|
|
136
|
+
// "user-content" — files written by end users / untrusted uploaders.
|
|
137
|
+
// A served .html / .js / .svg here is a stored-XSS
|
|
138
|
+
// vector, so forceAttachmentForNonText defaults ON —
|
|
139
|
+
// risky inline MIMEs are forced to download unless a
|
|
140
|
+
// sanitizer gate vouches for them (see
|
|
141
|
+
// `_shouldForceAttachment`). This is the conditional
|
|
142
|
+
// flip: a curated asset dir is never blindly forced to
|
|
143
|
+
// download; only a mount the operator TYPED as
|
|
144
|
+
// user-content gets the strict default.
|
|
145
|
+
// An explicit forceAttachmentForNonText always overrides the
|
|
146
|
+
// mountType-derived default.
|
|
147
|
+
mountType: "curated",
|
|
129
148
|
// forceAttachmentForNonText — stored-XSS defense for user-upload
|
|
130
|
-
// directories. Default OFF
|
|
131
|
-
// (CSS / JS bundles / fonts
|
|
132
|
-
// user-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
149
|
+
// directories. Default follows mountType: OFF for "curated" mounts
|
|
150
|
+
// (operator-curated CSS / JS bundles / fonts need inline render), ON for
|
|
151
|
+
// "user-content" mounts so HTML / JS / SVG without a sanitizer / PDF /
|
|
152
|
+
// archives are forced to download. Set explicitly to override the
|
|
153
|
+
// mountType-derived default either way. See `_shouldForceAttachment`
|
|
154
|
+
// below for the safe-render allowlist.
|
|
135
155
|
forceAttachmentForNonText: false,
|
|
136
156
|
// Companion knobs — when forceAttachmentForNonText is on, allow
|
|
137
157
|
// image/svg+xml inline render IF an SVG sanitizer gate is wired
|
|
@@ -547,6 +567,15 @@ function _validateCreateOpts(opts) {
|
|
|
547
567
|
validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
|
|
548
568
|
validateOpts.optionalBoolean(opts.safeAttachmentForRiskyMimes,
|
|
549
569
|
"staticServe.create: safeAttachmentForRiskyMimes", StaticServeError);
|
|
570
|
+
// mountType — config-time enum. A typo ("usercontent", "uploads")
|
|
571
|
+
// would silently fall back to the curated default and serve untrusted
|
|
572
|
+
// HTML inline, so THROW at boot rather than mis-type the mount.
|
|
573
|
+
if (opts.mountType !== undefined &&
|
|
574
|
+
opts.mountType !== "curated" && opts.mountType !== "user-content") {
|
|
575
|
+
throw _err("BAD_OPT",
|
|
576
|
+
"staticServe.create: mountType must be 'curated' (default) or " +
|
|
577
|
+
"'user-content'; got " + JSON.stringify(opts.mountType));
|
|
578
|
+
}
|
|
550
579
|
validateOpts.optionalBoolean(opts.forceAttachmentForNonText,
|
|
551
580
|
"staticServe.create: forceAttachmentForNonText", StaticServeError);
|
|
552
581
|
validateOpts.optionalBoolean(opts.safeRenderSvg,
|
|
@@ -684,7 +713,7 @@ function create(opts) {
|
|
|
684
713
|
"maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
|
|
685
714
|
"bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
|
|
686
715
|
"contentSafety", "contentSafetyDisabledReason",
|
|
687
|
-
"forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
|
|
716
|
+
"mountType", "forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
|
|
688
717
|
], "staticServe.create");
|
|
689
718
|
_validateCreateOpts(opts);
|
|
690
719
|
var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
@@ -738,7 +767,16 @@ function create(opts) {
|
|
|
738
767
|
var auditFailures = cfg.auditFailures;
|
|
739
768
|
var acceptRanges = cfg.acceptRanges;
|
|
740
769
|
var safeAttachment = !!cfg.safeAttachmentForRiskyMimes;
|
|
741
|
-
|
|
770
|
+
// forceAttachmentForNonText default follows mountType (v0.15.0): a
|
|
771
|
+
// mount TYPED "user-content" forces risky inline MIMEs to download by
|
|
772
|
+
// default (stored-XSS defense for untrusted uploads); a "curated" mount
|
|
773
|
+
// keeps inline render. An explicit forceAttachmentForNonText overrides
|
|
774
|
+
// the mountType-derived default either way. The conditional flip never
|
|
775
|
+
// blindly force-attaches a curated asset dir.
|
|
776
|
+
var mountType = opts.mountType || "curated";
|
|
777
|
+
var forceAttachmentForNonText = opts.forceAttachmentForNonText !== undefined
|
|
778
|
+
? !!opts.forceAttachmentForNonText
|
|
779
|
+
: (mountType === "user-content");
|
|
742
780
|
var allowSvgRender = cfg.safeRenderSvg !== false;
|
|
743
781
|
var allowPdfRender = !!cfg.safeRenderPdf;
|
|
744
782
|
var perActorCap = cfg.maxBytesPerActorPerWindowMs;
|
package/lib/subject.js
CHANGED
|
@@ -40,11 +40,22 @@ var { sha3Hash } = require("./crypto");
|
|
|
40
40
|
var cryptoField = require("./crypto-field");
|
|
41
41
|
var audit = require("./audit");
|
|
42
42
|
var cluster = require("./cluster");
|
|
43
|
+
var safeSql = require("./safe-sql");
|
|
44
|
+
var sql = require("./sql");
|
|
43
45
|
var lazyRequire = require("./lazy-require");
|
|
44
46
|
|
|
45
47
|
var db = lazyRequire(function () { return require("./db"); });
|
|
46
48
|
var legalHold = lazyRequire(function () { return require("./legal-hold"); });
|
|
47
49
|
|
|
50
|
+
// Local-SQLite framework tables for the Art. 18 restriction flag + the
|
|
51
|
+
// erasure marker. These run against the b.db() handle directly, so the
|
|
52
|
+
// b.sql builders carry { quoteName: true } to emit the quoted local name
|
|
53
|
+
// (no clusterStorage prefix rewrite on this path). The names are literals
|
|
54
|
+
// for the same reason db.js declares them as literals — they ARE the
|
|
55
|
+
// canonical local table identifiers.
|
|
56
|
+
var RESTRICTIONS_TABLE = "_blamejs_subject_restrictions"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
|
|
57
|
+
var ERASURES_TABLE = "_blamejs_subject_erasures"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
|
|
58
|
+
|
|
48
59
|
// Required acknowledgements before subject.erase will run. Operator must
|
|
49
60
|
// explicitly attest each one to confirm no statutory retention or active
|
|
50
61
|
// litigation hold blocks the deletion.
|
|
@@ -138,15 +149,13 @@ function exportData(subjectId, opts) {
|
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
function _findRowsForSubject(tableName, subjectField, subjectId) {
|
|
141
|
-
var
|
|
142
|
-
if (
|
|
143
|
-
// The schema has a derived hash for the subjectField — look up via
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return db().from(tableName).where(pred).all();
|
|
149
|
-
}
|
|
152
|
+
var cand = db().hashCandidatesFor(tableName, subjectField, subjectId);
|
|
153
|
+
if (cand) {
|
|
154
|
+
// The schema has a derived hash for the subjectField — look up via it,
|
|
155
|
+
// dual-reading across the keyed-MAC flip (whereIn matches both the active
|
|
156
|
+
// keyed-MAC digest and the legacy salted-sha3 digest a pre-flip row
|
|
157
|
+
// carries) so the subject's pre-flip rows are not silently skipped.
|
|
158
|
+
return db().from(tableName).whereIn(cand.field, cand.values).all();
|
|
150
159
|
}
|
|
151
160
|
// No derived hash — assume subjectField is raw, do direct equality
|
|
152
161
|
var rawPred = {};
|
|
@@ -211,7 +220,7 @@ function rectify(subjectId, opts) {
|
|
|
211
220
|
rowId: opts.id,
|
|
212
221
|
requestReason: opts.reason,
|
|
213
222
|
});
|
|
214
|
-
throw new Error("subject.rectify: row not found in '" + opts.table + "'
|
|
223
|
+
throw new Error("subject.rectify: row not found in '" + opts.table + "' for _id '" + opts.id + "'");
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
var changedKeys = Object.keys(opts.changes);
|
|
@@ -330,19 +339,18 @@ function erase(subjectId, opts) {
|
|
|
330
339
|
|
|
331
340
|
for (var t = 0; t < tables.length; t++) {
|
|
332
341
|
var spec = tables[t];
|
|
333
|
-
var
|
|
334
|
-
var
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
pred = {}; pred[spec.subjectField] = subjectId;
|
|
341
|
-
}
|
|
342
|
+
var cand = db().hashCandidatesFor(spec.name, spec.subjectField, subjectId);
|
|
343
|
+
var delQb = db().from(spec.name);
|
|
344
|
+
if (cand) {
|
|
345
|
+
// Dual-read across the keyed-MAC flip so erasure matches (and deletes)
|
|
346
|
+
// the subject's pre-flip rows carrying the legacy salted-sha3 digest —
|
|
347
|
+
// a GDPR erasure that skips un-migrated rows would leave PII behind.
|
|
348
|
+
delQb.whereIn(cand.field, cand.values);
|
|
342
349
|
} else {
|
|
343
|
-
|
|
350
|
+
var delPred = {}; delPred[spec.subjectField] = subjectId;
|
|
351
|
+
delQb.where(delPred);
|
|
344
352
|
}
|
|
345
|
-
var deleted =
|
|
353
|
+
var deleted = delQb.deleteMany();
|
|
346
354
|
totalDeleted += deleted;
|
|
347
355
|
perTable[spec.name] = deleted;
|
|
348
356
|
}
|
|
@@ -450,20 +458,18 @@ function eraseHard(subjectId, opts) {
|
|
|
450
458
|
db().transaction(function () {
|
|
451
459
|
for (var t = 0; t < tables.length; t++) {
|
|
452
460
|
var spec = tables[t];
|
|
453
|
-
var
|
|
454
|
-
var
|
|
455
|
-
if (
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
} else {
|
|
460
|
-
pred = {}; pred[spec.subjectField] = subjectId;
|
|
461
|
-
}
|
|
461
|
+
var cand = db().hashCandidatesFor(spec.name, spec.subjectField, subjectId);
|
|
462
|
+
var findQb = db().from(spec.name);
|
|
463
|
+
if (cand) {
|
|
464
|
+
// Dual-read across the keyed-MAC flip so per-row-key destruction +
|
|
465
|
+
// erasure covers the subject's pre-flip (legacy salted-sha3) rows too.
|
|
466
|
+
findQb.whereIn(cand.field, cand.values);
|
|
462
467
|
} else {
|
|
463
|
-
|
|
468
|
+
var rawPred = {}; rawPred[spec.subjectField] = subjectId;
|
|
469
|
+
findQb.where(rawPred);
|
|
464
470
|
}
|
|
465
471
|
// Find rows so we can destroy their per-row keys before delete.
|
|
466
|
-
var rows =
|
|
472
|
+
var rows = findQb.all();
|
|
467
473
|
if (cryptoField.hasPerRowKey(spec.name)) {
|
|
468
474
|
for (var r = 0; r < rows.length; r++) {
|
|
469
475
|
var rowId = rows[r]._id;
|
|
@@ -473,12 +479,22 @@ function eraseHard(subjectId, opts) {
|
|
|
473
479
|
}
|
|
474
480
|
}
|
|
475
481
|
}
|
|
476
|
-
var
|
|
482
|
+
var delQb2 = db().from(spec.name);
|
|
483
|
+
if (cand) {
|
|
484
|
+
delQb2.whereIn(cand.field, cand.values);
|
|
485
|
+
} else {
|
|
486
|
+
var delPred3 = {}; delPred3[spec.subjectField] = subjectId;
|
|
487
|
+
delQb2.where(delPred3);
|
|
488
|
+
}
|
|
489
|
+
var deleted = delQb2.deleteMany();
|
|
477
490
|
totalDeleted += deleted;
|
|
478
491
|
perTable[spec.name] = deleted;
|
|
479
492
|
// REINDEX the table so B-tree pages holding the deleted row's
|
|
480
493
|
// index entries are rebuilt — closes the erase-vacuum residual class.
|
|
481
|
-
|
|
494
|
+
// REINDEX is a sqlite maintenance verb with no b.sql builder; the
|
|
495
|
+
// table identifier is quoted through b.safeSql so the name is safe by
|
|
496
|
+
// construction (it comes from FRAMEWORK_SCHEMA / the subject-table set).
|
|
497
|
+
try { db().runSql("REINDEX " + safeSql.quoteIdentifier(spec.name, "sqlite", { allowReserved: true })); }
|
|
482
498
|
catch (_e) { /* cluster mode / unsupported dialect */ }
|
|
483
499
|
}
|
|
484
500
|
_markErased(subjectId);
|
|
@@ -536,20 +552,31 @@ function restrict(subjectId, opts) {
|
|
|
536
552
|
if (!opts || typeof opts.on !== "boolean") {
|
|
537
553
|
throw new Error("subject.restrict requires { on: true|false }");
|
|
538
554
|
}
|
|
539
|
-
var
|
|
540
|
-
"
|
|
541
|
-
|
|
555
|
+
var restrictSelBuilt = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
556
|
+
.columns(["subjectIdHash"])
|
|
557
|
+
.where("subjectIdHash", _subjectHash(subjectId))
|
|
558
|
+
.toSql();
|
|
559
|
+
var restrictSelStmt = db().prepare(restrictSelBuilt.sql);
|
|
560
|
+
var existing = restrictSelStmt.get.apply(restrictSelStmt, restrictSelBuilt.params);
|
|
542
561
|
|
|
543
562
|
if (opts.on) {
|
|
544
563
|
if (!existing) {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
564
|
+
var restrictInsBuilt = sql.insert(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
565
|
+
.values({
|
|
566
|
+
subjectIdHash: _subjectHash(subjectId),
|
|
567
|
+
since: Date.now(),
|
|
568
|
+
reason: opts.reason || null,
|
|
569
|
+
})
|
|
570
|
+
.toSql();
|
|
571
|
+
var restrictInsStmt = db().prepare(restrictInsBuilt.sql);
|
|
572
|
+
restrictInsStmt.run.apply(restrictInsStmt, restrictInsBuilt.params);
|
|
548
573
|
}
|
|
549
574
|
} else if (existing) {
|
|
550
|
-
|
|
551
|
-
"
|
|
552
|
-
|
|
575
|
+
var restrictDelBuilt = sql.delete(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
576
|
+
.where("subjectIdHash", _subjectHash(subjectId))
|
|
577
|
+
.toSql();
|
|
578
|
+
var restrictDelStmt = db().prepare(restrictDelBuilt.sql);
|
|
579
|
+
restrictDelStmt.run.apply(restrictDelStmt, restrictDelBuilt.params);
|
|
553
580
|
}
|
|
554
581
|
|
|
555
582
|
_writeAudit("subject.restrict", subjectId, "success", {
|
|
@@ -581,9 +608,15 @@ function restrict(subjectId, opts) {
|
|
|
581
608
|
*/
|
|
582
609
|
function isRestricted(subjectId) {
|
|
583
610
|
if (!subjectId) return false;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
611
|
+
// Presence check — project the PK column (b.sql columns must be real
|
|
612
|
+
// identifiers, not a `SELECT 1` literal); a matched row is truthy.
|
|
613
|
+
var built = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
614
|
+
.columns(["subjectIdHash"])
|
|
615
|
+
.where("subjectIdHash", _subjectHash(subjectId))
|
|
616
|
+
.limit(1)
|
|
617
|
+
.toSql();
|
|
618
|
+
var stmt = db().prepare(built.sql);
|
|
619
|
+
var row = stmt.get.apply(stmt, built.params);
|
|
587
620
|
return !!row;
|
|
588
621
|
}
|
|
589
622
|
|
|
@@ -629,9 +662,16 @@ function recordObjection(subjectId, opts) {
|
|
|
629
662
|
// ---- Internal helpers ----
|
|
630
663
|
|
|
631
664
|
function _markErased(subjectId) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
665
|
+
// "INSERT OR REPLACE" is the sqlite upsert idiom — express it portably as
|
|
666
|
+
// INSERT … ON CONFLICT(subjectIdHash) DO UPDATE SET erasedAt = EXCLUDED.erasedAt
|
|
667
|
+
// (the row is keyed by subjectIdHash; a re-erase just refreshes the timestamp).
|
|
668
|
+
var built = sql.upsert(ERASURES_TABLE, { dialect: "sqlite", quoteName: true })
|
|
669
|
+
.values({ subjectIdHash: _subjectHash(subjectId), erasedAt: Date.now() })
|
|
670
|
+
.onConflict(["subjectIdHash"])
|
|
671
|
+
.doUpdateFromExcluded(["erasedAt"])
|
|
672
|
+
.toSql();
|
|
673
|
+
var stmt = db().prepare(built.sql);
|
|
674
|
+
stmt.run.apply(stmt, built.params);
|
|
635
675
|
}
|
|
636
676
|
|
|
637
677
|
function _subjectHash(subjectId) {
|
package/lib/vault/index.js
CHANGED
|
@@ -71,6 +71,7 @@ var { boot } = require("../log");
|
|
|
71
71
|
var safeBuffer = require("../safe-buffer");
|
|
72
72
|
var safeJson = require("../safe-json");
|
|
73
73
|
var observability = require("../observability");
|
|
74
|
+
var frameworkFiles = require("../framework-files");
|
|
74
75
|
var vaultPassphraseSource = require("./passphrase-source");
|
|
75
76
|
var vaultWrap = require("./wrap");
|
|
76
77
|
var { defineClass } = require("../framework-error");
|
|
@@ -99,8 +100,8 @@ var log = boot("vault");
|
|
|
99
100
|
function resolvePaths(dataDir) {
|
|
100
101
|
return {
|
|
101
102
|
dataDir: dataDir,
|
|
102
|
-
plaintext: nodePath.join(dataDir,
|
|
103
|
-
sealed: nodePath.join(dataDir, "
|
|
103
|
+
plaintext: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey")),
|
|
104
|
+
sealed: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey") + ".sealed"),
|
|
104
105
|
derivedHashSalt: nodePath.join(dataDir, "vault.derived-hash-salt"),
|
|
105
106
|
derivedHashMacKey: nodePath.join(dataDir, "vault.derived-hash-mac.sealed"),
|
|
106
107
|
};
|
|
@@ -38,13 +38,14 @@
|
|
|
38
38
|
var nodeFs = require("node:fs");
|
|
39
39
|
var nodePath = require("node:path");
|
|
40
40
|
var atomicFile = require("../atomic-file");
|
|
41
|
+
var frameworkFiles = require("../framework-files");
|
|
41
42
|
var vaultWrap = require("./wrap");
|
|
42
43
|
var { defineClass } = require("../framework-error");
|
|
43
44
|
|
|
44
45
|
var VaultPassphraseError = defineClass("VaultPassphraseError", { alwaysPermanent: true });
|
|
45
46
|
|
|
46
|
-
var PLAINTEXT_NAME =
|
|
47
|
-
var SEALED_NAME = "
|
|
47
|
+
var PLAINTEXT_NAME = frameworkFiles.fileName("vaultKey");
|
|
48
|
+
var SEALED_NAME = frameworkFiles.fileName("vaultKey") + ".sealed";
|
|
48
49
|
|
|
49
50
|
function _paths(dataDir) {
|
|
50
51
|
return {
|
package/lib/vault/rotate.js
CHANGED
|
@@ -52,12 +52,13 @@ var nodeFs = require("node:fs");
|
|
|
52
52
|
var nodePath = require("node:path");
|
|
53
53
|
var { DatabaseSync } = require("node:sqlite");
|
|
54
54
|
var atomicFile = require("../atomic-file");
|
|
55
|
-
var
|
|
55
|
+
var sql = require("../sql");
|
|
56
56
|
var C = require("../constants");
|
|
57
57
|
var cryptoField = require("../crypto-field");
|
|
58
58
|
var bCrypto = require("../crypto");
|
|
59
59
|
var vaultAad = require("../vault-aad");
|
|
60
60
|
var dbSchema = require("../db-schema");
|
|
61
|
+
var frameworkFiles = require("../framework-files");
|
|
61
62
|
var lazyRequire = require("../lazy-require");
|
|
62
63
|
var { boot } = require("../log");
|
|
63
64
|
var numericBounds = require("../numeric-bounds");
|
|
@@ -97,18 +98,30 @@ var DEFAULT_DRIFT_SAMPLE_LIMIT = 100;
|
|
|
97
98
|
var DEFAULT_VERIFY_SAMPLE_MIN = 5;
|
|
98
99
|
var DEFAULT_VERIFY_SAMPLE_FRAC = 0.01;
|
|
99
100
|
|
|
101
|
+
// The catalog/PRAGMA statements all compose through b.sql's narrow audited
|
|
102
|
+
// catalog sub-API (b.sql.catalog / b.sql.pragma) - the only path that emits
|
|
103
|
+
// an sqlite_master reference or a PRAGMA verb, allowlisting exactly the
|
|
104
|
+
// statements the key-rotation walk needs and refusing every other internal
|
|
105
|
+
// identifier / PRAGMA verb. Each returns { sql, params }; the node:sqlite
|
|
106
|
+
// handle takes the params positionally.
|
|
107
|
+
function _all(db, built) {
|
|
108
|
+
var stmt = db.prepare(built.sql);
|
|
109
|
+
return built.params.length > 0 ? stmt.all.apply(stmt, built.params) : stmt.all();
|
|
110
|
+
}
|
|
111
|
+
function _get(db, built) {
|
|
112
|
+
var stmt = db.prepare(built.sql);
|
|
113
|
+
return built.params.length > 0 ? stmt.get.apply(stmt, built.params) : stmt.get();
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
function _listLiveTables(db) {
|
|
101
|
-
return db.
|
|
102
|
-
"SELECT name FROM sqlite_master " +
|
|
103
|
-
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
104
|
-
).all().map(function (r) { return r.name; });
|
|
117
|
+
return _all(db, sql.catalog.listTables()).map(function (r) { return r.name; });
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
function _listLiveColumns(db, table) {
|
|
108
121
|
// PRAGMA table_info — table name comes from sqlite_master so it's
|
|
109
|
-
// already validated as an existing identifier.
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
// already validated as an existing identifier; b.sql.catalog.tableInfo
|
|
123
|
+
// quotes it by construction.
|
|
124
|
+
return _all(db, sql.catalog.tableInfo(table)).map(function (c) { return c.name; });
|
|
112
125
|
}
|
|
113
126
|
|
|
114
127
|
function _knownColumnsFor(schema, infraColumns) {
|
|
@@ -201,12 +214,13 @@ function validateSchemaMatch(db, opts) {
|
|
|
201
214
|
}
|
|
202
215
|
if (unknown.length === 0) continue;
|
|
203
216
|
|
|
204
|
-
var
|
|
205
|
-
|
|
206
|
-
|
|
217
|
+
var sampleBuilt = sql.select(table, { dialect: "sqlite", quoteName: true })
|
|
218
|
+
.columns(unknown)
|
|
219
|
+
.limit(sampleLimit)
|
|
220
|
+
.toSql();
|
|
207
221
|
var sampled;
|
|
208
222
|
try {
|
|
209
|
-
sampled = db
|
|
223
|
+
sampled = _all(db, sampleBuilt);
|
|
210
224
|
} catch (e) {
|
|
211
225
|
warnings.push({
|
|
212
226
|
kind: "sample_failed",
|
|
@@ -312,7 +326,8 @@ function verify(opts) {
|
|
|
312
326
|
var schema = cryptoField.getSchema(table);
|
|
313
327
|
if (!schema || !Array.isArray(schema.sealedFields) || schema.sealedFields.length === 0) continue;
|
|
314
328
|
|
|
315
|
-
var totalRow = db.
|
|
329
|
+
var totalRow = _get(db, sql.select(table, { dialect: "sqlite", quoteName: true })
|
|
330
|
+
.count("*", "n").toSql());
|
|
316
331
|
var total = totalRow ? totalRow.n : 0;
|
|
317
332
|
if (total === 0) continue;
|
|
318
333
|
|
|
@@ -320,10 +335,10 @@ function verify(opts) {
|
|
|
320
335
|
if (sampleN > total) sampleN = total;
|
|
321
336
|
|
|
322
337
|
// RANDOM() is fine for a sampler — we're picking representative rows,
|
|
323
|
-
// not building cryptographic randomness.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
338
|
+
// not building cryptographic randomness. b.sql.catalog.sampleRandom is
|
|
339
|
+
// the audited ORDER BY RANDOM() form (the general builder has no random-
|
|
340
|
+
// order clause); columns omitted -> `*`.
|
|
341
|
+
var sampled = _all(db, sql.catalog.sampleRandom(table, null, { limit: sampleN }));
|
|
327
342
|
|
|
328
343
|
var foundOldFail = !oldKeys; // when no oldKeys supplied, this check is N/A
|
|
329
344
|
var verifiedRows = 0;
|
|
@@ -529,15 +544,19 @@ function _walkAndReSeal(node, oldKeys, newKeys) {
|
|
|
529
544
|
return { value: node, changed: false };
|
|
530
545
|
}
|
|
531
546
|
|
|
532
|
-
|
|
547
|
+
// Transaction-control statements only (BEGIN / COMMIT / ROLLBACK) - fixed
|
|
548
|
+
// keywords, no identifier / value, so they stay verbatim rather than route
|
|
549
|
+
// through b.sql (the builder has no transaction-control verb). The param is
|
|
550
|
+
// named `stmtText` so it does not shadow the module-level `sql` builder.
|
|
551
|
+
function _runStmt(db, stmtText) { db.prepare(stmtText).run(); }
|
|
533
552
|
|
|
534
553
|
function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
var
|
|
540
|
-
|
|
554
|
+
// Every statement composes through b.sql (sqlite dialect, quoteName so
|
|
555
|
+
// the concrete handle's table is quoted, not left bare for a cluster
|
|
556
|
+
// rewrite that does not apply here). Identifiers are validated + quoted
|
|
557
|
+
// by construction; the cursor bound (_id) + LIMIT bind as ? placeholders.
|
|
558
|
+
var total = _get(db, sql.select(table, { dialect: "sqlite", quoteName: true })
|
|
559
|
+
.count("*", "n").whereNotNull(column).toSql()).n;
|
|
541
560
|
if (total === 0) return 0;
|
|
542
561
|
|
|
543
562
|
// AAD-bound tables (registerTable({aad:true})) seal each cell under a
|
|
@@ -548,36 +567,54 @@ function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
|
|
|
548
567
|
var aadMode = !!(schema && schema.aad);
|
|
549
568
|
var rowIdField = aadMode ? schema.rowIdField : null;
|
|
550
569
|
var needRid = aadMode && rowIdField && rowIdField !== "_id";
|
|
551
|
-
var qrid = needRid ? safeSql.quoteIdentifier(rowIdField, "sqlite") : null;
|
|
552
570
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
571
|
+
// Keyset-cursor page over (_id) ascending. The projected columns are read
|
|
572
|
+
// by their REAL names off the result row (no AS alias) - the column value
|
|
573
|
+
// is row[column], the row-id value is row[rowIdField]. The SQL text is
|
|
574
|
+
// constant across the loop (only the bound _id-cursor changes; LIMIT is a
|
|
575
|
+
// builder-inlined integer literal, validated non-negative), so prepare
|
|
576
|
+
// once + re-run with the fresh cursor param positionally. The SELECT
|
|
577
|
+
// carries exactly one `?` (the _id cursor); the UPDATE carries two (the
|
|
578
|
+
// resealed value + the _id).
|
|
579
|
+
var selCols = ["_id", column];
|
|
580
|
+
if (needRid) selCols.push(rowIdField);
|
|
581
|
+
var selBuilt = sql.select(table, { dialect: "sqlite", quoteName: true })
|
|
582
|
+
.columns(selCols)
|
|
583
|
+
.whereNotNull(column)
|
|
584
|
+
.whereOp("_id", ">", "")
|
|
585
|
+
.orderBy("_id")
|
|
586
|
+
.limit(batchSize)
|
|
587
|
+
.toSql();
|
|
588
|
+
var sel = db.prepare(selBuilt.sql);
|
|
589
|
+
var updBuilt = sql.update(table, { dialect: "sqlite", quoteName: true })
|
|
590
|
+
.set(column, "")
|
|
591
|
+
.where("_id", "")
|
|
592
|
+
.toSql();
|
|
593
|
+
var upd = db.prepare(updBuilt.sql);
|
|
558
594
|
|
|
559
595
|
var processed = 0;
|
|
560
596
|
var lastId = "";
|
|
561
597
|
while (true) {
|
|
562
|
-
var rows = sel.all(lastId
|
|
598
|
+
var rows = sel.all(lastId);
|
|
563
599
|
if (rows.length === 0) break;
|
|
564
600
|
|
|
565
601
|
dbSchema.runInTransaction(db, function () {
|
|
566
602
|
for (var i = 0; i < rows.length; i++) {
|
|
567
603
|
var row = rows[i];
|
|
568
|
-
|
|
569
|
-
if (
|
|
604
|
+
var cellVal = row[column];
|
|
605
|
+
if (typeof cellVal !== "string") continue;
|
|
606
|
+
if (aadMode && vaultAad.isAadSealed(cellVal)) {
|
|
570
607
|
// Rebuild the exact AAD the seal side used. cryptoField._aadParts
|
|
571
608
|
// reads row[schema.rowIdField]; feed it the rowIdField value we
|
|
572
|
-
// selected (
|
|
609
|
+
// selected (row[rowIdField], or _id when rowIdField IS _id).
|
|
573
610
|
var rowForAad = {};
|
|
574
|
-
rowForAad[rowIdField] = needRid ? row
|
|
611
|
+
rowForAad[rowIdField] = needRid ? row[rowIdField] : row._id;
|
|
575
612
|
var aad = cryptoField._aadParts(schema, table, column, rowForAad);
|
|
576
|
-
upd.run(vaultAad.resealRoot(
|
|
577
|
-
} else if (
|
|
613
|
+
upd.run(vaultAad.resealRoot(cellVal, aad, roots.oldRootJson, roots.newRootJson), row._id);
|
|
614
|
+
} else if (cellVal.indexOf(C.VAULT_PREFIX) === 0) {
|
|
578
615
|
// Plain vault: cell (non-AAD table, or a legacy pre-AAD cell in
|
|
579
616
|
// an AAD table that the next sealRow upgrades).
|
|
580
|
-
upd.run(_reSealValue(
|
|
617
|
+
upd.run(_reSealValue(cellVal, roots.oldKeys, roots.newKeys), row._id);
|
|
581
618
|
}
|
|
582
619
|
}
|
|
583
620
|
});
|
|
@@ -589,23 +626,33 @@ function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
|
|
|
589
626
|
}
|
|
590
627
|
|
|
591
628
|
function _rotateOverflow(db, table, oldKeys, newKeys, batchSize, progress, warnings) {
|
|
592
|
-
var
|
|
593
|
-
var cols = db.prepare("PRAGMA table_info(" + qt + ")").all();
|
|
629
|
+
var cols = _all(db, sql.catalog.tableInfo(table));
|
|
594
630
|
if (!cols.some(function (c) { return c.name === "data"; })) return 0;
|
|
595
631
|
|
|
596
|
-
var total = db.
|
|
632
|
+
var total = _get(db, sql.select(table, { dialect: "sqlite", quoteName: true })
|
|
633
|
+
.count("*", "n").whereNotNull("data").toSql()).n;
|
|
597
634
|
if (total === 0) return 0;
|
|
598
635
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
636
|
+
// Same keyset cursor as _rotateColumn over the overflow `data` JSON
|
|
637
|
+
// column: one bound `?` (the _id cursor), builder-inlined LIMIT literal.
|
|
638
|
+
var selBuilt = sql.select(table, { dialect: "sqlite", quoteName: true })
|
|
639
|
+
.columns(["_id", "data"])
|
|
640
|
+
.whereNotNull("data")
|
|
641
|
+
.whereOp("_id", ">", "")
|
|
642
|
+
.orderBy("_id")
|
|
643
|
+
.limit(batchSize)
|
|
644
|
+
.toSql();
|
|
645
|
+
var sel = db.prepare(selBuilt.sql);
|
|
646
|
+
var updBuilt = sql.update(table, { dialect: "sqlite", quoteName: true })
|
|
647
|
+
.set("data", "")
|
|
648
|
+
.where("_id", "")
|
|
649
|
+
.toSql();
|
|
650
|
+
var upd = db.prepare(updBuilt.sql);
|
|
604
651
|
|
|
605
652
|
var processed = 0;
|
|
606
653
|
var lastId = "";
|
|
607
654
|
while (true) {
|
|
608
|
-
var rows = sel.all(lastId
|
|
655
|
+
var rows = sel.all(lastId);
|
|
609
656
|
if (rows.length === 0) break;
|
|
610
657
|
|
|
611
658
|
_runStmt(db, "BEGIN");
|
|
@@ -689,10 +736,10 @@ async function rotate(opts) {
|
|
|
689
736
|
var progress = opts.progressCallback;
|
|
690
737
|
var warnings = [];
|
|
691
738
|
var paths = Object.assign({
|
|
692
|
-
encryptedDb:
|
|
693
|
-
dbKeySealed:
|
|
694
|
-
vaultKeyPlain:
|
|
695
|
-
vaultKeySealed: "
|
|
739
|
+
encryptedDb: frameworkFiles.fileName("dbEnc"),
|
|
740
|
+
dbKeySealed: frameworkFiles.fileName("dbKeyEnc"),
|
|
741
|
+
vaultKeyPlain: frameworkFiles.fileName("vaultKey"),
|
|
742
|
+
vaultKeySealed: frameworkFiles.fileName("vaultKey") + ".sealed",
|
|
696
743
|
additionalSealed: [],
|
|
697
744
|
verbatimFiles: [],
|
|
698
745
|
verbatimDirs: [],
|
|
@@ -849,18 +896,15 @@ async function rotate(opts) {
|
|
|
849
896
|
|
|
850
897
|
var db = new DatabaseSync(tmpDbPath);
|
|
851
898
|
try {
|
|
852
|
-
|
|
853
|
-
|
|
899
|
+
db.prepare(sql.pragma("journal_mode", "WAL").sql).run();
|
|
900
|
+
db.prepare(sql.pragma("synchronous", "NORMAL").sql).run();
|
|
854
901
|
|
|
855
902
|
// Walk tables. For each, re-seal every column declared sealed
|
|
856
903
|
// by the field-crypto registry, plus the overflow `data` JSON
|
|
857
904
|
// column if present.
|
|
858
905
|
var tablesToRotate = Array.isArray(opts.tables) && opts.tables.length > 0
|
|
859
906
|
? opts.tables.slice()
|
|
860
|
-
: db
|
|
861
|
-
"SELECT name FROM sqlite_master " +
|
|
862
|
-
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
863
|
-
).all().map(function (r) { return r.name; });
|
|
907
|
+
: _listLiveTables(db);
|
|
864
908
|
|
|
865
909
|
// Serialized roots threaded to the AAD reseal path; oldRootJson /
|
|
866
910
|
// newRootJson match b.vault.getKeysJson() so rotated AAD cells unseal
|
|
@@ -869,15 +913,11 @@ async function rotate(opts) {
|
|
|
869
913
|
|
|
870
914
|
for (var ti = 0; ti < tablesToRotate.length; ti++) {
|
|
871
915
|
var table = tablesToRotate[ti];
|
|
872
|
-
var tableExists = db.
|
|
873
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?"
|
|
874
|
-
).get(table);
|
|
916
|
+
var tableExists = _get(db, sql.catalog.tableExists(table));
|
|
875
917
|
if (!tableExists) continue;
|
|
876
918
|
|
|
877
919
|
var schema = cryptoField.getSchema(table);
|
|
878
|
-
var liveCols = db
|
|
879
|
-
'PRAGMA table_info("' + table.replace(/"/g, '""') + '")'
|
|
880
|
-
).all().map(function (c) { return c.name; });
|
|
920
|
+
var liveCols = _listLiveColumns(db, table);
|
|
881
921
|
var liveColSet = Object.create(null);
|
|
882
922
|
for (var lc = 0; lc < liveCols.length; lc++) liveColSet[liveCols[lc]] = true;
|
|
883
923
|
|
|
@@ -894,7 +934,7 @@ async function rotate(opts) {
|
|
|
894
934
|
if (tableRows > 0) { tablesProcessed++; totalRowsProcessed += tableRows; }
|
|
895
935
|
}
|
|
896
936
|
|
|
897
|
-
|
|
937
|
+
db.prepare(sql.pragma("wal_checkpoint", "TRUNCATE").sql).run();
|
|
898
938
|
} finally {
|
|
899
939
|
db.close();
|
|
900
940
|
}
|