@blamejs/core 0.14.26 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
@@ -34,11 +34,15 @@
34
34
  * vault root key — because K_row is gone everywhere it ever lived.
35
35
  *
36
36
  * Derived hashes (`derivedHashes`) provide indexed lookup for sealed
37
- * columns: a normalized SHA3 of the plaintext, salted by the vault's
38
- * per-deployment salt + a per-field namespace, so dictionary /
39
- * rainbow attacks across fields and across deployments fail. Sealed
40
- * columns without a derived hash are unindexable — queries on them
41
- * silently return zero rows.
37
+ * columns. The default digest is a keyed MAC
38
+ * (`hmac-shake256`: SHAKE256 under the vault's per-deployment MAC key) +
39
+ * a per-field namespace, so an attacker who recovers the salt alone
40
+ * cannot correlate low-entropy plaintexts across fields or across
41
+ * deployments. Operators keeping byte-compatibility with an existing
42
+ * salted index opt out per-table (`derivedHashMode: "salted-sha3"`) or
43
+ * per-column (`derivedHashes.<col>.mode`). Sealed columns without a
44
+ * derived hash are unindexable — queries on them silently return zero
45
+ * rows.
42
46
  *
43
47
  * Per-column residency (`declareColumnResidency`) declares EU / US /
44
48
  * global tags; the storage-write gate (`assertColumnResidency`)
@@ -56,6 +60,9 @@ var vault = require("./vault");
56
60
  var vaultAad = require("./vault-aad");
57
61
  var validateOpts = require("./validate-opts");
58
62
  var numericBounds = require("./numeric-bounds");
63
+ var safeJson = require("./safe-json");
64
+ var frameworkSchema = require("./framework-schema");
65
+ var sql = require("./sql");
59
66
  var { defineClass } = require("./framework-error");
60
67
  var { sha3Hash, kdf, generateBytes, encryptPacked, decryptPacked, generateToken } = require("./crypto");
61
68
  var { HASH_PREFIX, VAULT_PREFIX, ROW_PREFIX, TIME } = require("./constants");
@@ -67,6 +74,38 @@ var { HASH_PREFIX, VAULT_PREFIX, ROW_PREFIX, TIME } = require("./constants");
67
74
  var CryptoFieldRateError = defineClass("CryptoFieldRateError", { alwaysPermanent: true });
68
75
  var CryptoFieldError = defineClass("CryptoFieldError", { alwaysPermanent: true });
69
76
 
77
+ // Typed-value codec for sealed columns. Sealing previously String()-coerced
78
+ // every value before encryption, which silently corrupts a Buffer (lossy
79
+ // UTF-8 round-trip) or an object ("[object Object]"). This codec preserves
80
+ // byte/type fidelity through a sealed column so unseal restores the original
81
+ // type. Backward-compatible: a plain string is stored VERBATIM (pre-codec
82
+ // cells decode unchanged) - only a non-string value, or the rare string that
83
+ // itself begins with the sentinel, is wrapped. The NUL-led sentinel never
84
+ // occurs at the start of a normal stored string. number / boolean / bigint
85
+ // keep the existing String() contract (they round-trip as strings as before).
86
+ var TYPED_SENTINEL = String.fromCharCode(0) + "bjsv1:";
87
+
88
+ function _encodeTyped(value) {
89
+ if (typeof value === "string") {
90
+ return value.indexOf(TYPED_SENTINEL) === 0 ? TYPED_SENTINEL + "S:" + value : value;
91
+ }
92
+ if (Buffer.isBuffer(value)) return TYPED_SENTINEL + "B:" + value.toString("base64");
93
+ if (value instanceof Uint8Array) return TYPED_SENTINEL + "B:" + Buffer.from(value).toString("base64");
94
+ if (typeof value === "object" && value !== null) return TYPED_SENTINEL + "J:" + JSON.stringify(value);
95
+ return String(value);
96
+ }
97
+
98
+ function _decodeTyped(str) {
99
+ if (typeof str !== "string" || str.indexOf(TYPED_SENTINEL) !== 0) return str;
100
+ var body = str.slice(TYPED_SENTINEL.length);
101
+ var tag = body.slice(0, 2);
102
+ var payload = body.slice(2);
103
+ if (tag === "B:") return Buffer.from(payload, "base64");
104
+ if (tag === "J:") return safeJson.parse(payload); // plaintext is AEAD-verified; safeJson blocks proto-pollution defensively
105
+ if (tag === "S:") return payload;
106
+ return str; // unknown tag - return the raw decrypted string defensively
107
+ }
108
+
70
109
  var compliance = lazyRequire(function () { return require("./compliance"); });
71
110
  var db = lazyRequire(function () { return require("./db"); });
72
111
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -155,14 +194,47 @@ var perRowResidency = Object.create(null);
155
194
  // { tableName: { keySize, info } }
156
195
  var perRowKeyTables = Object.create(null);
157
196
 
197
+ // Seal-envelope strength ranking. A regulated posture can declare a
198
+ // sealEnvelopeFloor in b.compliance POSTURE_DEFAULTS; registerTable
199
+ // refuses a table that seals columns under a weaker envelope than the
200
+ // floor when that posture is the globally-pinned one. Higher rank =
201
+ // stronger binding:
202
+ // plain — vault.seal: XChaCha20-Poly1305 under the vault root,
203
+ // no AAD; a DB-write attacker can copy a cell to another
204
+ // row undetected (CWE-311 / CWE-326).
205
+ // aad — vault.aad.seal: AEAD-bound to (table,row,column,
206
+ // schemaVersion); a relocated cell fails Poly1305.
207
+ // per-row-key — K_row crypto-shred: aad binding PLUS a per-row key,
208
+ // so destroying the row-secret renders residue
209
+ // mathematically undecryptable.
210
+ var SEAL_ENVELOPE_RANK = Object.freeze({
211
+ "plain": 0,
212
+ "aad": 1,
213
+ "per-row-key": 2,
214
+ });
215
+
158
216
  // The framework registry table that holds each row's AAD-sealed
159
217
  // row-secret. Named once so the seal-side AAD (materializePerRowKey),
160
218
  // the read-side AAD (unsealRow's K_row fetch), and rotate's reseal all
161
219
  // quote the byte-identical (table, rowId, column, schemaVersion) tuple.
162
- var PER_ROW_KEYS_TABLE = "_blamejs_per_row_keys";
220
+ // Canonical LOGICAL name for the per-row-key registry. It is the AAD-tuple
221
+ // table component (so seal / unseal / rotate quote a byte-identical tuple)
222
+ // and the frameworkSchema.tableName key the local-handle SQL resolves
223
+ // through. allow:hand-rolled-sql — canonical logical-name declaration.
224
+ var PER_ROW_KEYS_TABLE = "_blamejs_per_row_keys"; // allow:hand-rolled-sql
163
225
  var PER_ROW_KEYS_COLUMN = "wrappedKey";
164
226
  var PER_ROW_KEYS_SCHEMA_VERSION = "1";
165
227
 
228
+ // The per-row-key registry is read/written against the LOCAL db() / dbHandle
229
+ // handle directly (not clusterStorage), so SQL composed for it uses the
230
+ // RESOLVED name (prefix-aware via frameworkSchema.tableName) and quoteName so
231
+ // b.sql emits the quoted identifier the single-node path expects — the same
232
+ // shape db-query.js's _sqlOpts and db.js's own local-handle b.sql calls use.
233
+ var _PER_ROW_SQL_OPTS = { dialect: "sqlite", quoteName: true };
234
+ function _perRowKeysTableName() {
235
+ return frameworkSchema.tableName(PER_ROW_KEYS_TABLE);
236
+ }
237
+
166
238
  // Build the canonical AAD parts for a row-secret wrap in
167
239
  // _blamejs_per_row_keys. One source of truth so seal / unseal / rotate
168
240
  // never drift. `rowId` is the app row's _id (the same value
@@ -232,6 +304,14 @@ function isRowSealed(value) {
232
304
  * hash namespaces. Subsequent `sealRow` / `unsealRow` / `eraseRow`
233
305
  * calls dispatch through this registry.
234
306
  *
307
+ * Seal-envelope floor: when a compliance posture that declares a
308
+ * `sealEnvelopeFloor` is globally pinned (`b.compliance.set` — today
309
+ * `hipaa` / `pci-dss` require at least an AAD-bound envelope), a table
310
+ * that seals columns under a weaker envelope throws
311
+ * `crypto-field/seal-envelope-below-floor` here at registration so the
312
+ * operator catches the under-protected schema at boot. Unpinned and
313
+ * non-regulated deployments register unchanged.
314
+ *
235
315
  * @opts
236
316
  * sealedFields: string[], // column names sealed via vault.seal
237
317
  * derivedHashes: { [hashCol]: { from: string, normalize?: fn } },
@@ -273,11 +353,20 @@ function registerTable(name, opts) {
273
353
  var rowIdField = typeof opts.rowIdField === "string" && opts.rowIdField.length > 0
274
354
  ? opts.rowIdField : "id";
275
355
  var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
276
- var derivedHashMode = opts.derivedHashMode || "salted-sha3";
356
+ // Derived-hash mode default-on flip (v0.15.0): the per-table default is
357
+ // the keyed MAC "hmac-shake256" (SHAKE256 under vault.getDerivedHashMacKey),
358
+ // so an attacker who recovers the per-deployment salt alone cannot
359
+ // correlate two low-entropy plaintexts across the indexed-lookup column.
360
+ // Operators who need the deterministic-per-deployment salted digest (e.g.
361
+ // to keep byte-compatibility with an existing salted-sha3 index) opt out
362
+ // explicitly with registerTable({ derivedHashMode: "salted-sha3" }), or
363
+ // per-column via derivedHashes.<col>.mode. GDPR Art. 4(5) pseudonymisation;
364
+ // HIPAA 45 CFR 164.514(b); FIPS 202; NIST SP 800-185.
365
+ var derivedHashMode = opts.derivedHashMode || "hmac-shake256";
277
366
  if (derivedHashMode !== "salted-sha3" && derivedHashMode !== "hmac-shake256") {
278
367
  throw new CryptoFieldError("crypto-field/bad-derived-hash-mode",
279
- "registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
280
- "'hmac-shake256', got " + JSON.stringify(derivedHashMode));
368
+ "registerTable: derivedHashMode must be 'hmac-shake256' (default) or " +
369
+ "'salted-sha3', got " + JSON.stringify(derivedHashMode));
281
370
  }
282
371
  var derivedHashes = Object.assign({}, opts.derivedHashes || {});
283
372
  for (var col in derivedHashes) {
@@ -289,8 +378,25 @@ function registerTable(name, opts) {
289
378
  "'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
290
379
  }
291
380
  }
381
+ var sealedFields = Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [];
382
+ // Seal-envelope floor gate. Only fires when ALL hold:
383
+ // (1) a posture is globally pinned (b.compliance.set) — read via
384
+ // compliance().current(), the same source the residency write
385
+ // gates read; an UNPINNED deployment is untouched (back-compat),
386
+ // (2) that posture declares a sealEnvelopeFloor in POSTURE_DEFAULTS
387
+ // (only regulated regimes do — hipaa / pci-dss), and
388
+ // (3) the table actually seals columns under an envelope WEAKER than
389
+ // the floor.
390
+ // A non-sealing table, an unpinned deployment, or a posture without a
391
+ // floor all pass through exactly as before. Config-time / entry-point
392
+ // tier: THROW so the operator catches the under-protected schema at
393
+ // boot rather than shipping PHI/PCI under a relocatable plain seal
394
+ // (CWE-311 / CWE-326).
395
+ if (sealedFields.length > 0) {
396
+ _assertSealEnvelopeFloor(name, aadOn);
397
+ }
292
398
  schemas[name] = {
293
- sealedFields: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
399
+ sealedFields: sealedFields,
294
400
  derivedHashes: derivedHashes,
295
401
  hashNamespaces: Object.assign({}, opts.hashNamespaces || {}),
296
402
  aad: aadOn,
@@ -300,28 +406,94 @@ function registerTable(name, opts) {
300
406
  };
301
407
  }
302
408
 
409
+ // _assertSealEnvelopeFloor — config-time guard for registerTable. Reads
410
+ // the globally-pinned posture (compliance().current()) and its declared
411
+ // sealEnvelopeFloor; throws when `table` seals columns under a weaker
412
+ // envelope. No-op when no posture is pinned, the posture declares no
413
+ // floor, or compliance isn't loaded — so unpinned/non-regulated
414
+ // deployments register exactly as before.
415
+ function _assertSealEnvelopeFloor(table, aadOn) {
416
+ var posture;
417
+ var floor;
418
+ try {
419
+ var c = compliance();
420
+ posture = c.current();
421
+ if (typeof posture !== "string" || posture.length === 0) return;
422
+ floor = c.postureDefault(posture, "sealEnvelopeFloor");
423
+ } catch (_e) {
424
+ // compliance not loaded / unavailable — record nothing, gate nothing.
425
+ return;
426
+ }
427
+ if (typeof floor !== "string" || !Object.prototype.hasOwnProperty.call(SEAL_ENVELOPE_RANK, floor)) {
428
+ return; // posture pins no recognised floor → back-compat pass-through
429
+ }
430
+ // Declared envelope for this table: per-row-key beats aad beats plain.
431
+ // declarePerRowKey may run before or after registerTable; honour it
432
+ // when it ran first.
433
+ var declared = perRowKeyTables[table] ? "per-row-key" : (aadOn ? "aad" : "plain");
434
+ if (SEAL_ENVELOPE_RANK[declared] < SEAL_ENVELOPE_RANK[floor]) {
435
+ throw new CryptoFieldError("crypto-field/seal-envelope-below-floor",
436
+ "registerTable: table '" + table + "' seals columns under the '" +
437
+ declared + "' envelope, but the pinned compliance posture '" +
438
+ posture + "' requires at least '" + floor + "'. " +
439
+ (floor === "aad"
440
+ ? "Pass registerTable({ aad: true, rowIdField: <pk> }) so each " +
441
+ "cell is AEAD-bound to (table, row, column) and cannot be " +
442
+ "relocated between rows"
443
+ : "Call b.cryptoField.declarePerRowKey('" + table + "', ...) " +
444
+ "before registerTable so each row gets a crypto-shred K_row") +
445
+ " (CWE-311 / CWE-326). Unpinned or non-regulated deployments are " +
446
+ "unaffected; this gate fires only under a posture that declares a " +
447
+ "sealEnvelopeFloor.");
448
+ }
449
+ }
450
+
303
451
  // Derived-hash digest width for the keyed (hmac-shake256) mode: 32
304
452
  // bytes -> 64 hex chars.
305
453
  var DERIVED_HASH_BYTES = 32;
306
454
 
307
455
  // Compute the indexed-lookup digest for a derived-hash column.
308
- // - "salted-sha3" (default): SHA3-512 over <per-deployment salt> + ns
309
- // + value (128 hex). Deterministic per deployment.
310
- // - "hmac-shake256": SHAKE256(<vault-sealed MAC key> || ns + value)
311
- // truncated to 32 bytes (64 hex). The key is a vault-derived secret,
312
- // NOT a static salt, so an attacker who recovers the salt alone
313
- // can't correlate two low-entropy plaintexts; the sponge has no
314
- // length-extension weakness. (b.crypto.hmacSha3 (HMAC-SHA3-512) was
315
- // considered; SHAKE256(key||msg) is chosen for the fixed-width keyed
316
- // digest with the same MAC-grade guarantee.) FIPS 202; NIST SP
317
- // 800-185; GDPR Art. 4(5) pseudonymisation; HIPAA 45 CFR 164.514(b).
456
+ // - "hmac-shake256" (registerTable default since v0.15.0):
457
+ // SHAKE256(<vault-sealed MAC key> || ns + value) truncated to 32 bytes
458
+ // (64 hex). The key is a vault-derived secret, NOT a static salt, so an
459
+ // attacker who recovers the salt alone can't correlate two low-entropy
460
+ // plaintexts; the sponge has no length-extension weakness.
461
+ // (b.crypto.hmacSha3 (HMAC-SHA3-512) was considered; SHAKE256(key||msg)
462
+ // is chosen for the fixed-width keyed digest with the same MAC-grade
463
+ // guarantee.) FIPS 202; NIST SP 800-185; GDPR Art. 4(5)
464
+ // pseudonymisation; HIPAA 45 CFR 164.514(b).
465
+ // - "salted-sha3" (opt-out / pre-v0.15.0 legacy index): SHA3-512 over
466
+ // <per-deployment salt> + ns + value (128 hex). Deterministic per
467
+ // deployment, byte-compatible with the legacy index.
468
+ // The bare-fallback (`|| "salted-sha3"`) applies only when NEITHER the
469
+ // per-column spec.mode NOR a table mode is supplied — an ad-hoc caller that
470
+ // named no mode; registerTable always records a derivedHashMode, so a
471
+ // registered table is never bare-fallthrough.
318
472
  function _computeDerivedHash(spec, tableMode, ns, normalized) {
319
- var mode = (spec && spec.mode) || tableMode || "salted-sha3";
473
+ var mode = _resolveDerivedHashMode(spec, tableMode);
320
474
  if (mode === "hmac-shake256") {
321
475
  var macKey = vault.getDerivedHashMacKey();
322
476
  return kdf(Buffer.concat([macKey, Buffer.from(ns + normalized, "utf8")]),
323
477
  DERIVED_HASH_BYTES).toString("hex");
324
478
  }
479
+ return _legacyDerivedHash(ns, normalized);
480
+ }
481
+
482
+ // Resolve the effective derived-hash mode for a (spec, tableMode) pair —
483
+ // per-column override beats the table mode beats the bare salted-sha3
484
+ // fallback (the ad-hoc-no-mode case; see _computeDerivedHash).
485
+ function _resolveDerivedHashMode(spec, tableMode) {
486
+ return (spec && spec.mode) || tableMode || "salted-sha3";
487
+ }
488
+
489
+ // The legacy (pre-v0.15.0 default) salted-sha3 digest — SHA3-512 over the
490
+ // per-deployment salt + namespace + normalized value (128 hex). Factored out
491
+ // so the dual-read LOOKUP path and the upgrade-on-read auto-migrate can
492
+ // recompute the OLD-default hash for a (ns, value) regardless of the table's
493
+ // current keyed-MAC mode: a row written before the default flipped still
494
+ // carries this digest in its derived-hash column, and a lookup that only
495
+ // computed the keyed-MAC would miss it.
496
+ function _legacyDerivedHash(ns, normalized) {
325
497
  return sha3Hash(vault.getDerivedHashSalt().toString("hex") + ns + normalized);
326
498
  }
327
499
 
@@ -498,6 +670,33 @@ function namespaceFor(table, field, registered) {
498
670
  *
499
671
  * b.cryptoField.computeDerived("users", "email", null); // → null
500
672
  */
673
+ // Build the derived-hash result for a (schema, derivedField, spec,
674
+ // sourceField, value) tuple — the single source of truth for both
675
+ // computeDerived and lookupHash. Returns `{ field, value, legacyValue? }`.
676
+ //
677
+ // value — the digest under the column's ACTIVE mode (keyed-MAC for a
678
+ // v0.15.0-default table; salted-sha3 when opted out). New
679
+ // writes index under this, so it stays the primary equality
680
+ // value every existing caller already reads.
681
+ // legacyValue — present ONLY when the active mode is the keyed MAC: the
682
+ // byte-form a row written under the PRE-v0.15.0 salted-sha3
683
+ // default would carry. A dual-read lookup matches EITHER
684
+ // value so the keyed-default flip doesn't silently lose
685
+ // pre-flip rows; the upgrade-on-read auto-migrate in
686
+ // unsealRow re-hashes a row found via the legacy digest.
687
+ function _derivedHashResult(s, table, derivedField, spec, sourceField, value) {
688
+ var ns = namespaceFor(table, sourceField, s.hashNamespaces);
689
+ var normalized = spec.normalize ? spec.normalize(value) : String(value);
690
+ var mode = _resolveDerivedHashMode(spec, s.derivedHashMode);
691
+ var primary = _computeDerivedHash(spec, s.derivedHashMode, ns, normalized);
692
+ var out = { field: derivedField, value: primary };
693
+ if (mode === "hmac-shake256") {
694
+ var legacy = _legacyDerivedHash(ns, normalized);
695
+ if (legacy !== primary) out.legacyValue = legacy;
696
+ }
697
+ return out;
698
+ }
699
+
501
700
  function computeDerived(table, sourceField, sourceValue) {
502
701
  if (sourceValue === undefined || sourceValue === null) return null;
503
702
  var s = schemas[table];
@@ -506,9 +705,7 @@ function computeDerived(table, sourceField, sourceValue) {
506
705
  for (var derivedField in s.derivedHashes) {
507
706
  var spec = s.derivedHashes[derivedField];
508
707
  if (spec.from === sourceField) {
509
- var ns = namespaceFor(table, sourceField, s.hashNamespaces);
510
- var normalized = spec.normalize ? spec.normalize(sourceValue) : String(sourceValue);
511
- return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
708
+ return _derivedHashResult(s, table, derivedField, spec, sourceField, sourceValue);
512
709
  }
513
710
  }
514
711
  return null;
@@ -528,18 +725,50 @@ function computeDerived(table, sourceField, sourceValue) {
528
725
  // tuple are refused for `cooldownMs` with a typed CryptoFieldRateError and
529
726
  // a distinct system.crypto.unseal_rate_exceeded audit row.
530
727
  //
531
- // Default OFFwhen no cap is configured, unsealRow behaves exactly as
532
- // before (null-the-field + audit-only). Composes the same timestamp-array
533
- // sliding-window shape used by b.mail.server.rateLimit (_pruneWindow):
534
- // count-based, lazily pruned on read, no background timer.
728
+ // Default-ON (v0.15.0)the cap is armed at module load with the
729
+ // DEFAULT_RATE_CAP below, so a forged-ciphertext unseal-oracle is bounded
730
+ // out of the box. Operators who want the prior audit-only behaviour opt
731
+ // out explicitly with configureUnsealRateCap(null) / { disabled: true }.
732
+ // Composes the same timestamp-array sliding-window shape used by
733
+ // b.mail.server.rateLimit (_pruneWindow): count-based, lazily pruned on
734
+ // read, no background timer.
535
735
  //
536
736
  // CWE-307 (Improper Restriction of Excessive Authentication Attempts —
537
737
  // generalized here to excessive decryption-oracle attempts); OWASP ASVS
538
738
  // v5 §2.2.1 (anti-automation); NIST SP 800-63B §5.2.2 (rate limiting).
539
- var _rateCap = null; // null = disabled
739
+ //
740
+ // DEFAULT_RATE_CAP — the secure baseline the cap arms with at module load.
741
+ // 10 forged-ciphertext failures for one (actor, table, column) inside a
742
+ // 1-minute window trip a 5-minute cooldown. Generous enough that no
743
+ // legitimate read pattern hits it (a real ciphertext never fails the
744
+ // AEAD), tight enough that an oracle-hammering attacker is shut off fast.
745
+ var DEFAULT_RATE_CAP_THRESHOLD = 10;
746
+ var DEFAULT_RATE_CAP_WINDOW_MS = TIME.minutes(1);
747
+ var DEFAULT_RATE_CAP_COOLDOWN_MS = TIME.minutes(5);
748
+ var _rateCap = null; // installed by _installDefaultRateCap() below
540
749
  var _rateFailWindows = new Map(); // "actor\x00table\x00column" → [tsMs, ...]
541
750
  var _rateCooldowns = new Map(); // same key → cooldownUntilMs
542
751
 
752
+ // Build the default cap record (Date.now clock, framework-audit sink).
753
+ // Separated so module-load and clearRateCapForTest install the identical
754
+ // secure baseline.
755
+ function _defaultRateCapRecord() {
756
+ return {
757
+ threshold: DEFAULT_RATE_CAP_THRESHOLD,
758
+ windowMs: DEFAULT_RATE_CAP_WINDOW_MS,
759
+ cooldownMs: DEFAULT_RATE_CAP_COOLDOWN_MS,
760
+ now: function () { return Date.now(); },
761
+ onAudit: null,
762
+ };
763
+ }
764
+ function _installDefaultRateCap() {
765
+ _rateCap = _defaultRateCapRecord();
766
+ _rateFailWindows.clear();
767
+ _rateCooldowns.clear();
768
+ }
769
+ // Arm the secure default at module load (security-on, not opt-in).
770
+ _installDefaultRateCap();
771
+
543
772
  // Tuple key. \x00 is not a legal column / table identifier byte and is
544
773
  // vanishingly unlikely in an actor id, so the join is unambiguous; the
545
774
  // composite is only ever a Map key (never an object property), so no
@@ -555,23 +784,25 @@ function _rateKey(actor, table, column) {
555
784
  * @compliance hipaa, gdpr, pci-dss
556
785
  * @related b.cryptoField.unsealRow, b.cryptoField.clearRateCapForTest
557
786
  *
558
- * Opt into a per-(actor, table, column) cap on sealed-column unseal
559
- * FAILURES. By default (unconfigured) `unsealRow` only nulls the field
560
- * and emits `system.crypto.unseal_failed` on a forged-ciphertext read
561
- * an attacker who can write `vault:<crafted>` payloads can hammer the
562
- * KEM-decapsulation / AEAD-verify oracle indefinitely, and only an
563
- * off-band operator alert rule catches the burst. With a cap configured,
564
- * once a single tuple accrues `threshold` failures inside `windowMs`,
565
- * every subsequent `unsealRow` touching that tuple is REFUSED for
566
- * `cooldownMs` with a `CryptoFieldRateError` and a distinct
787
+ * Tune the per-(actor, table, column) cap on sealed-column unseal
788
+ * FAILURES. The cap is ON BY DEFAULT (default-on, v0.15.0): the framework
789
+ * arms it at module load (threshold 10 / 1-minute window / 5-minute
790
+ * cooldown) so a forged-ciphertext oracle is bounded with no operator
791
+ * action. Once a single tuple accrues `threshold` failures inside
792
+ * `windowMs`, every subsequent `unsealRow` touching that tuple is REFUSED
793
+ * for `cooldownMs` with a `CryptoFieldRateError` and a distinct
567
794
  * `system.crypto.unseal_rate_exceeded` audit row, bounding the oracle.
568
- *
569
- * Pass `null` (or `{ disabled: true }`) to turn the cap back off. This is
570
- * a behaviour-changing refusal gate, so it is opt-in: unconfigured
571
- * deployments keep today's audit-only behaviour with full back-compat.
572
- * Validation is config-time / entry-point tier bad `threshold` /
573
- * `windowMs` / `cooldownMs` THROW so an operator catches the typo at
574
- * boot rather than silently disabling the cap.
795
+ * Without the cap, an attacker who can write `vault:<crafted>` payloads
796
+ * can hammer the KEM-decapsulation / AEAD-verify oracle indefinitely and
797
+ * only an off-band operator alert rule catches the burst.
798
+ *
799
+ * Pass an opts object to RAISE/lower the thresholds. Pass `null` (or
800
+ * `{ disabled: true }`) to turn the cap off entirely and fall back to
801
+ * audit-only (the pre-v0.15.0 behaviour) the documented opt-out for the
802
+ * rare deployment that needs an unbounded read path. Validation is
803
+ * config-time / entry-point tier — bad `threshold` / `windowMs` /
804
+ * `cooldownMs` THROW so an operator catches the typo at boot rather than
805
+ * silently mis-configuring the cap.
575
806
  *
576
807
  * CWE-307 (excessive-attempt restriction); OWASP ASVS v5 §2.2.1;
577
808
  * NIST SP 800-63B §5.2.2.
@@ -694,20 +925,19 @@ function _rateNoteFailure(actor, table, column) {
694
925
  * @status experimental
695
926
  * @related b.cryptoField.configureUnsealRateCap
696
927
  *
697
- * Test-only helper. Disables the unseal-failure rate cap and drops every
698
- * in-flight sliding-window + cooldown entry so a fixture can re-configure
699
- * the cap between cases. Operator code never calls this — production
700
- * deployments configure the cap once at boot.
928
+ * Test-only helper. Restores the secure DEFAULT cap (default-on baseline)
929
+ * and drops every in-flight sliding-window + cooldown entry so a fixture
930
+ * can re-configure the cap between cases from a known-good starting point.
931
+ * Operator code never calls this production deployments inherit the
932
+ * default cap at boot and tune or disable it via configureUnsealRateCap.
701
933
  *
702
934
  * @example
703
935
  * b.cryptoField.configureUnsealRateCap({ threshold: 3 });
704
936
  * b.cryptoField.clearRateCapForTest();
705
- * // cap is off again; windows + cooldowns cleared
937
+ * // cap is back at the secure default; windows + cooldowns cleared
706
938
  */
707
939
  function clearRateCapForTest() {
708
- _rateCap = null;
709
- _rateFailWindows.clear();
710
- _rateCooldowns.clear();
940
+ _installDefaultRateCap();
711
941
  }
712
942
 
713
943
  // ---- Row sealing / unsealing ----
@@ -810,7 +1040,7 @@ function sealRow(table, row, opts) {
810
1040
  "' is AAD-bound (registerTable({aad:true})); the row's identity " +
811
1041
  "column '" + s.rowIdField + "' must be populated BEFORE sealRow. " +
812
1042
  "Generate the primary key first (e.g. uuid / sequence INSERT … RETURNING), " +
813
- "set row." + s.rowIdField + ", then sealRow.");
1043
+ "set row." + s.rowIdField + ", then sealRow."); // allow:hand-rolled-sql — error-message prose, not SQL
814
1044
  }
815
1045
  }
816
1046
 
@@ -830,10 +1060,11 @@ function sealRow(table, row, opts) {
830
1060
  // Idempotent: an already-K_row-sealed value passes through.
831
1061
  if (isRowSealed(out[field])) continue;
832
1062
  var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
833
- // Coerce to a string the same way the vault.aad path does, then
834
- // encode as UTF-8 bytes for the AEAD (split out so the byte
835
- // coercion is Buffer.from(str, "utf8"), not Buffer.from(String(...))).
836
- var plainStr = String(out[field]);
1063
+ // Encode the value type-faithfully (Buffer / object preserved, not
1064
+ // String()-mangled), then UTF-8 to bytes for the AEAD. The typed
1065
+ // encoding of a string / base64 / JSON is pure ASCII-or-UTF8, so the
1066
+ // Buffer.from(str, "utf8") round-trips losslessly.
1067
+ var plainStr = _encodeTyped(out[field]);
837
1068
  out[field] = ROW_PREFIX +
838
1069
  encryptPacked(Buffer.from(plainStr, "utf8"), kRow, cellAad).toString("base64");
839
1070
  } else if (s.aad) {
@@ -841,12 +1072,12 @@ function sealRow(table, row, opts) {
841
1072
  if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
842
1073
  continue;
843
1074
  }
844
- out[field] = vaultAad.seal(String(out[field]),
1075
+ out[field] = vaultAad.seal(_encodeTyped(out[field]),
845
1076
  _aadParts(s, table, field, out));
846
1077
  } else {
847
1078
  // allow:seal-without-aad — plain-mode legacy table; operator
848
1079
  // opts into AAD via registerTable({aad:true})
849
- out[field] = vault.seal(String(out[field]));
1080
+ out[field] = vault.seal(_encodeTyped(out[field]));
850
1081
  }
851
1082
  }
852
1083
 
@@ -894,15 +1125,17 @@ function _aadParts(schema, table, column, row) {
894
1125
  * makes the unwrap throw → the field nulls + `system.crypto.unseal_failed`
895
1126
  * fires, which is correct: shredded data reads as absent.
896
1127
  *
897
- * When an unseal-failure rate cap is configured via
898
- * `configureUnsealRateCap` (default off), repeated forged-ciphertext
899
- * failures for a single `(actor, table, column)` tuple trip a cooldown:
900
- * once tripped, this call THROWS `CryptoFieldRateError` and emits a
901
- * distinct `system.crypto.unseal_rate_exceeded` audit instead of
902
- * exercising the decryption oracle again (CWE-307). `actor` identifies
903
- * the caller for that tuple (e.g. session subject / API key id); it
904
- * defaults to an anonymous bucket when omitted, and is ignored entirely
905
- * when no cap is configured (full back-compat for the 2-arg call).
1128
+ * The unseal-failure rate cap is ON BY DEFAULT (default-on, v0.15.0):
1129
+ * repeated forged-ciphertext failures for a single `(actor, table,
1130
+ * column)` tuple trip a cooldown (threshold 10 / 1-minute window /
1131
+ * 5-minute cooldown out of the box; tune or disable via
1132
+ * `configureUnsealRateCap`). Once tripped, this call THROWS
1133
+ * `CryptoFieldRateError` and emits a distinct
1134
+ * `system.crypto.unseal_rate_exceeded` audit instead of exercising the
1135
+ * decryption oracle again (CWE-307). `actor` identifies the caller for
1136
+ * that tuple (e.g. session subject / API key id); it defaults to an
1137
+ * anonymous bucket when omitted, and is ignored entirely when the cap is
1138
+ * disabled (full back-compat for the 2-arg call).
906
1139
  *
907
1140
  * @example
908
1141
  * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
@@ -949,9 +1182,13 @@ function unsealRow(table, row, actor, dbHandle) {
949
1182
  var prep = (dbHandle && typeof dbHandle.prepare === "function")
950
1183
  ? dbHandle.prepare.bind(dbHandle)
951
1184
  : db().prepare;
952
- wrap = prep(
953
- 'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
954
- ).get(table, kRowId);
1185
+ var wrapSelBuilt = sql.select(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
1186
+ .columns(["wrappedKey"])
1187
+ .where("tableName", table)
1188
+ .where("rowId", kRowId)
1189
+ .toSql();
1190
+ var wrapStmt = prep(wrapSelBuilt.sql);
1191
+ wrap = wrapStmt.get.apply(wrapStmt, wrapSelBuilt.params);
955
1192
  } catch (_e) {
956
1193
  return null;
957
1194
  }
@@ -967,7 +1204,7 @@ function unsealRow(table, row, actor, dbHandle) {
967
1204
  // rules off it): "row" = K_row cell, "aad" = vault.aad: cell on an
968
1205
  // AAD table, "plain" otherwise.
969
1206
  var shape = isRowSealed(out[field]) ? "row" : (s.aad ? "aad" : "plain");
970
- // Opt-in cap: if this (actor, table, column) tuple is in cooldown
1207
+ // Default-on cap: if this (actor, table, column) tuple is in cooldown
971
1208
  // from prior forged-ciphertext failures, refuse before touching the
972
1209
  // decryption oracle again (CWE-307). No-op when the cap is disabled.
973
1210
  if (_rateInCooldown(capActor, table, field)) {
@@ -996,14 +1233,14 @@ function unsealRow(table, row, actor, dbHandle) {
996
1233
  "' is unavailable (shredded or never materialized)");
997
1234
  }
998
1235
  var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
999
- unsealed = decryptPacked(
1236
+ unsealed = _decodeTyped(decryptPacked(
1000
1237
  Buffer.from(out[field].slice(ROW_PREFIX.length), "base64"), kRow, cellAad
1001
- ).toString("utf8");
1238
+ ).toString("utf8"));
1002
1239
  } else if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
1003
- unsealed = vaultAad.unseal(out[field],
1004
- _aadParts(s, table, field, out));
1240
+ unsealed = _decodeTyped(vaultAad.unseal(out[field],
1241
+ _aadParts(s, table, field, out)));
1005
1242
  } else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
1006
- unsealed = vault.unseal(out[field]);
1243
+ unsealed = _decodeTyped(vault.unseal(out[field]));
1007
1244
  } else {
1008
1245
  // Not a sealed value — pass through.
1009
1246
  unsealed = out[field];
@@ -1031,7 +1268,7 @@ function unsealRow(table, row, actor, dbHandle) {
1031
1268
  },
1032
1269
  });
1033
1270
  } catch (_e) { /* drop-silent */ }
1034
- // Opt-in rate cap: account this failure against the (actor,
1271
+ // Default-on rate cap: account this failure against the (actor,
1035
1272
  // table, column) tuple. When it trips the threshold, arm the
1036
1273
  // cooldown + emit the distinct rate-exceeded audit once on the
1037
1274
  // transition. No-op when the cap is disabled.
@@ -1054,9 +1291,91 @@ function unsealRow(table, row, actor, dbHandle) {
1054
1291
  }
1055
1292
  }
1056
1293
 
1294
+ // Upgrade-on-read auto-migrate for the keyed-MAC derived-hash default
1295
+ // flip (v0.15.0). A row written BEFORE the default moved from salted-sha3
1296
+ // to hmac-shake256 carries the legacy salted digest in its derived-hash
1297
+ // column; a keyed-only lookup would miss it (the dual-read in
1298
+ // lookupHashCandidates is what FINDS it). When such a row is unsealed and
1299
+ // we now hold the source plaintext, recompute the keyed-MAC digest and, if
1300
+ // the stored column still holds the legacy salted-sha3 value, re-write that
1301
+ // column to the keyed form so the row is keyed-indexed from now on and the
1302
+ // candidate set collapses back to a single value over time. Best-effort:
1303
+ // the returned row always carries the upgraded hash; the durable rewrite
1304
+ // happens only when a writable dbHandle is available + the row has an _id.
1305
+ _upgradeDerivedHashesOnRead(s, table, out, dbHandle);
1306
+
1057
1307
  return out;
1058
1308
  }
1059
1309
 
1310
+ // Re-hash any legacy-salted derived-hash columns on a just-unsealed row to
1311
+ // the active keyed-MAC form. Pure-detect + in-place upgrade on the returned
1312
+ // `out` object; when `dbHandle` exposes a writable .prepare(), the upgrade is
1313
+ // also persisted with one UPDATE per row keyed on `_id`. Never throws — a
1314
+ // failed durable rewrite leaves the row matchable via the legacy digest (the
1315
+ // dual-read still finds it next time).
1316
+ function _upgradeDerivedHashesOnRead(s, table, out, dbHandle) {
1317
+ if (!s.derivedHashes) return;
1318
+ var rowId = out._id != null ? String(out._id) : "";
1319
+ var upgrades = null; // { derivedField: keyedValue } to persist
1320
+ for (var derivedField in s.derivedHashes) {
1321
+ if (!Object.prototype.hasOwnProperty.call(s.derivedHashes, derivedField)) continue;
1322
+ var spec = s.derivedHashes[derivedField];
1323
+ // Only the keyed-MAC mode has a distinct legacy form to migrate from.
1324
+ if (_resolveDerivedHashMode(spec, s.derivedHashMode) !== "hmac-shake256") continue;
1325
+ var stored = out[derivedField];
1326
+ if (typeof stored !== "string" || stored.length === 0) continue;
1327
+ var plain = out[spec.from];
1328
+ if (plain === undefined || plain === null) continue; // source erased / absent — nothing to re-hash
1329
+ var ns = namespaceFor(table, spec.from, s.hashNamespaces);
1330
+ var normalized = spec.normalize ? spec.normalize(plain) : String(plain);
1331
+ var keyed = _computeDerivedHash(spec, s.derivedHashMode, ns, normalized);
1332
+ if (stored === keyed) continue; // already keyed-indexed
1333
+ var legacy = _legacyDerivedHash(ns, normalized);
1334
+ if (stored !== legacy) continue; // not the legacy digest — leave untouched
1335
+ // Found a legacy-indexed row: surface the keyed hash on the returned row
1336
+ // and queue the durable rewrite.
1337
+ out[derivedField] = keyed;
1338
+ if (!upgrades) upgrades = {};
1339
+ upgrades[derivedField] = keyed;
1340
+ }
1341
+ if (!upgrades) return;
1342
+ // Persist when we can resolve a writable local handle + have a row identity.
1343
+ // The derived-hash columns + the app table live on the LOCAL db (the same
1344
+ // handle the per-row-key registry uses); the rewrite is a plain UPDATE.
1345
+ if (rowId.length === 0) return;
1346
+ var handle = (dbHandle && typeof dbHandle.prepare === "function")
1347
+ ? dbHandle
1348
+ : _resolveLocalDbHandle();
1349
+ if (!handle) return;
1350
+ try {
1351
+ var updBuilt = sql.update(table, { dialect: "sqlite", quoteName: true })
1352
+ .set(upgrades)
1353
+ .where("_id", rowId)
1354
+ .toSql();
1355
+ var stmt = handle.prepare(updBuilt.sql);
1356
+ stmt.run.apply(stmt, updBuilt.params);
1357
+ } catch (_e) {
1358
+ // Best-effort — DB not initialized, read-only handle, or the app table
1359
+ // isn't on this handle (cluster mode where the row came from the external
1360
+ // backend). The returned row still carries the upgraded hash; the legacy
1361
+ // digest stays matchable via lookupHashCandidates until a writable read
1362
+ // path re-hashes it.
1363
+ }
1364
+ }
1365
+
1366
+ // Resolve the framework's local db handle for the upgrade-on-read rewrite.
1367
+ // Mirrors the K_row read path's fallback: prefer an explicit dbHandle, else
1368
+ // the framework's own db(). Returns null when no .prepare()-capable handle
1369
+ // is reachable (db not initialized yet) so the caller skips the durable write.
1370
+ function _resolveLocalDbHandle() {
1371
+ try {
1372
+ var inst = db();
1373
+ return (inst && typeof inst.prepare === "function") ? inst : null;
1374
+ } catch (_e) {
1375
+ return null;
1376
+ }
1377
+ }
1378
+
1060
1379
  // ---- Erasure (GDPR Art. 17 / "right to be forgotten") ----
1061
1380
  //
1062
1381
  // eraseRow(table, row) returns a tombstoned copy of the row: every
@@ -1191,6 +1510,16 @@ function eraseRow(table, row) {
1191
1510
  * every encryption uses a fresh random nonce, so the ciphertext alone
1192
1511
  * cannot anchor a query.
1193
1512
  *
1513
+ * `value` is the digest under the column's ACTIVE mode (keyed-MAC by
1514
+ * default since v0.15.0; salted-sha3 when opted out), so existing callers
1515
+ * that emit `where(result.field, result.value)` are unchanged. When the
1516
+ * active mode is the keyed MAC, the result ALSO carries `legacyValue` — the
1517
+ * byte-form a row written under the pre-v0.15.0 salted-sha3 default would
1518
+ * hold. Callers that can issue a match-EITHER query (or that prefer the
1519
+ * ready-made candidate list) use `b.cryptoField.lookupHashCandidates`; the
1520
+ * upgrade-on-read auto-migrate in `unsealRow` re-hashes any row found via
1521
+ * the legacy digest to the keyed-MAC form.
1522
+ *
1194
1523
  * @example
1195
1524
  * b.cryptoField.registerTable("users", {
1196
1525
  * sealedFields: ["email"],
@@ -1208,14 +1537,52 @@ function lookupHash(table, field, value) {
1208
1537
  for (var derivedField in s.derivedHashes) {
1209
1538
  var spec = s.derivedHashes[derivedField];
1210
1539
  if (spec.from === field) {
1211
- var ns = namespaceFor(table, field, s.hashNamespaces);
1212
- var normalized = spec.normalize ? spec.normalize(value) : String(value);
1213
- return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
1540
+ return _derivedHashResult(s, table, derivedField, spec, field, value);
1214
1541
  }
1215
1542
  }
1216
1543
  return null;
1217
1544
  }
1218
1545
 
1546
+ /**
1547
+ * @primitive b.cryptoField.lookupHashCandidates
1548
+ * @signature b.cryptoField.lookupHashCandidates(table, field, value)
1549
+ * @since 0.15.0
1550
+ * @compliance gdpr, hipaa
1551
+ * @related b.cryptoField.lookupHash, b.cryptoField.unsealRow
1552
+ *
1553
+ * Dual-read sibling of `lookupHash`. Returns `{ field, values }` where
1554
+ * `values` is the list of derived-hash digests that should ALL be treated
1555
+ * as a match for `value` — the digest under the column's active mode FIRST,
1556
+ * plus (when the active mode is the keyed MAC) the pre-v0.15.0 salted-sha3
1557
+ * digest a row written under the old default would carry. A caller that can
1558
+ * issue an `IN (…)` / `OR` equality over `field` finds both the new
1559
+ * keyed-indexed rows and the legacy salted-indexed rows in one query, so the
1560
+ * keyed-MAC default flip never silently drops pre-flip rows. Returns null
1561
+ * when no derived hash is declared for `field`.
1562
+ *
1563
+ * Pair it with the upgrade-on-read auto-migrate: `unsealRow` re-hashes any
1564
+ * row whose stored derived-hash matches the legacy digest to the keyed-MAC
1565
+ * form, so the candidate list shrinks back to a single value as rows are
1566
+ * read over time.
1567
+ *
1568
+ * @example
1569
+ * b.cryptoField.registerTable("users", {
1570
+ * sealedFields: ["email"],
1571
+ * derivedHashes: { emailHash: { from: "email" } },
1572
+ * });
1573
+ * var c = b.cryptoField.lookupHashCandidates("users", "email", "alice@example.com");
1574
+ * c.field; // → "emailHash"
1575
+ * c.values.length; // → 2 (keyed-MAC + legacy salted-sha3)
1576
+ * // → b.db.from("users").where(c.field, "IN", c.values)
1577
+ */
1578
+ function lookupHashCandidates(table, field, value) {
1579
+ var r = lookupHash(table, field, value);
1580
+ if (!r) return null;
1581
+ var values = [r.value];
1582
+ if (r.legacyValue && r.legacyValue !== r.value) values.push(r.legacyValue);
1583
+ return { field: r.field, values: values };
1584
+ }
1585
+
1219
1586
  /**
1220
1587
  * @primitive b.cryptoField.declareColumnResidency
1221
1588
  * @signature b.cryptoField.declareColumnResidency(table, opts)
@@ -1572,9 +1939,13 @@ function materializePerRowKey(table, rowId, dbHandle) {
1572
1939
  }
1573
1940
  var ridStr = String(rowId);
1574
1941
  // Existing secret? Unwrap + re-derive to support idempotent UPSERTs.
1575
- var existing = dbHandle.prepare(
1576
- 'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1577
- ).get(table, ridStr);
1942
+ var existingSelBuilt = sql.select(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
1943
+ .columns(["wrappedKey"])
1944
+ .where("tableName", table)
1945
+ .where("rowId", ridStr)
1946
+ .toSql();
1947
+ var existingStmt = dbHandle.prepare(existingSelBuilt.sql);
1948
+ var existing = existingStmt.get.apply(existingStmt, existingSelBuilt.params);
1578
1949
  if (existing) {
1579
1950
  return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
1580
1951
  }
@@ -1591,10 +1962,17 @@ function materializePerRowKey(table, rowId, dbHandle) {
1591
1962
  // _id is the rotation pipeline's pagination/UPDATE key (the natural
1592
1963
  // identity is the composite (tableName, rowId)). A fresh token keeps
1593
1964
  // it unique per registry row.
1594
- dbHandle.prepare(
1595
- 'INSERT INTO "' + PER_ROW_KEYS_TABLE + '" (_id, tableName, rowId, wrappedKey, createdAt) ' +
1596
- 'VALUES (?, ?, ?, ?, ?)'
1597
- ).run(generateToken(16), table, ridStr, sealed, Date.now());
1965
+ var insBuilt = sql.insert(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
1966
+ .values({
1967
+ _id: generateToken(16),
1968
+ tableName: table,
1969
+ rowId: ridStr,
1970
+ wrappedKey: sealed,
1971
+ createdAt: Date.now(),
1972
+ })
1973
+ .toSql();
1974
+ var insStmt = dbHandle.prepare(insBuilt.sql);
1975
+ insStmt.run.apply(insStmt, insBuilt.params);
1598
1976
  return kRow;
1599
1977
  }
1600
1978
 
@@ -1653,9 +2031,12 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1653
2031
  throw new CryptoFieldError("crypto-field/destroy-per-row-key-no-db",
1654
2032
  "destroyPerRowKey: dbHandle (b.db) is required");
1655
2033
  }
1656
- var result = dbHandle.prepare(
1657
- 'DELETE FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1658
- ).run(table, String(rowId));
2034
+ var delBuilt = sql.delete(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
2035
+ .where("tableName", table)
2036
+ .where("rowId", String(rowId))
2037
+ .toSql();
2038
+ var delStmt = dbHandle.prepare(delBuilt.sql);
2039
+ var result = delStmt.run.apply(delStmt, delBuilt.params);
1659
2040
  return { destroyed: (result && result.changes) || 0 };
1660
2041
  }
1661
2042
 
@@ -1711,6 +2092,7 @@ module.exports = {
1711
2092
  computeDerived: computeDerived,
1712
2093
  computeNamespacedHash: computeNamespacedHash,
1713
2094
  lookupHash: lookupHash,
2095
+ lookupHashCandidates: lookupHashCandidates,
1714
2096
  clearForTest: clearForTest,
1715
2097
  declareColumnResidency: declareColumnResidency,
1716
2098
  getColumnResidency: getColumnResidency,