@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.
Files changed (134) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +158 -77
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +228 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +82 -29
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +293 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. 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"); });
@@ -178,10 +217,24 @@ var SEAL_ENVELOPE_RANK = Object.freeze({
178
217
  // row-secret. Named once so the seal-side AAD (materializePerRowKey),
179
218
  // the read-side AAD (unsealRow's K_row fetch), and rotate's reseal all
180
219
  // quote the byte-identical (table, rowId, column, schemaVersion) tuple.
181
- 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
182
225
  var PER_ROW_KEYS_COLUMN = "wrappedKey";
183
226
  var PER_ROW_KEYS_SCHEMA_VERSION = "1";
184
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
+
185
238
  // Build the canonical AAD parts for a row-secret wrap in
186
239
  // _blamejs_per_row_keys. One source of truth so seal / unseal / rotate
187
240
  // never drift. `rowId` is the app row's _id (the same value
@@ -300,11 +353,20 @@ function registerTable(name, opts) {
300
353
  var rowIdField = typeof opts.rowIdField === "string" && opts.rowIdField.length > 0
301
354
  ? opts.rowIdField : "id";
302
355
  var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
303
- 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";
304
366
  if (derivedHashMode !== "salted-sha3" && derivedHashMode !== "hmac-shake256") {
305
367
  throw new CryptoFieldError("crypto-field/bad-derived-hash-mode",
306
- "registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
307
- "'hmac-shake256', got " + JSON.stringify(derivedHashMode));
368
+ "registerTable: derivedHashMode must be 'hmac-shake256' (default) or " +
369
+ "'salted-sha3', got " + JSON.stringify(derivedHashMode));
308
370
  }
309
371
  var derivedHashes = Object.assign({}, opts.derivedHashes || {});
310
372
  for (var col in derivedHashes) {
@@ -391,23 +453,47 @@ function _assertSealEnvelopeFloor(table, aadOn) {
391
453
  var DERIVED_HASH_BYTES = 32;
392
454
 
393
455
  // Compute the indexed-lookup digest for a derived-hash column.
394
- // - "salted-sha3" (default): SHA3-512 over <per-deployment salt> + ns
395
- // + value (128 hex). Deterministic per deployment.
396
- // - "hmac-shake256": SHAKE256(<vault-sealed MAC key> || ns + value)
397
- // truncated to 32 bytes (64 hex). The key is a vault-derived secret,
398
- // NOT a static salt, so an attacker who recovers the salt alone
399
- // can't correlate two low-entropy plaintexts; the sponge has no
400
- // length-extension weakness. (b.crypto.hmacSha3 (HMAC-SHA3-512) was
401
- // considered; SHAKE256(key||msg) is chosen for the fixed-width keyed
402
- // digest with the same MAC-grade guarantee.) FIPS 202; NIST SP
403
- // 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.
404
472
  function _computeDerivedHash(spec, tableMode, ns, normalized) {
405
- var mode = (spec && spec.mode) || tableMode || "salted-sha3";
473
+ var mode = _resolveDerivedHashMode(spec, tableMode);
406
474
  if (mode === "hmac-shake256") {
407
475
  var macKey = vault.getDerivedHashMacKey();
408
476
  return kdf(Buffer.concat([macKey, Buffer.from(ns + normalized, "utf8")]),
409
477
  DERIVED_HASH_BYTES).toString("hex");
410
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) {
411
497
  return sha3Hash(vault.getDerivedHashSalt().toString("hex") + ns + normalized);
412
498
  }
413
499
 
@@ -584,6 +670,33 @@ function namespaceFor(table, field, registered) {
584
670
  *
585
671
  * b.cryptoField.computeDerived("users", "email", null); // → null
586
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
+
587
700
  function computeDerived(table, sourceField, sourceValue) {
588
701
  if (sourceValue === undefined || sourceValue === null) return null;
589
702
  var s = schemas[table];
@@ -592,9 +705,7 @@ function computeDerived(table, sourceField, sourceValue) {
592
705
  for (var derivedField in s.derivedHashes) {
593
706
  var spec = s.derivedHashes[derivedField];
594
707
  if (spec.from === sourceField) {
595
- var ns = namespaceFor(table, sourceField, s.hashNamespaces);
596
- var normalized = spec.normalize ? spec.normalize(sourceValue) : String(sourceValue);
597
- return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
708
+ return _derivedHashResult(s, table, derivedField, spec, sourceField, sourceValue);
598
709
  }
599
710
  }
600
711
  return null;
@@ -614,18 +725,50 @@ function computeDerived(table, sourceField, sourceValue) {
614
725
  // tuple are refused for `cooldownMs` with a typed CryptoFieldRateError and
615
726
  // a distinct system.crypto.unseal_rate_exceeded audit row.
616
727
  //
617
- // Default OFFwhen no cap is configured, unsealRow behaves exactly as
618
- // before (null-the-field + audit-only). Composes the same timestamp-array
619
- // sliding-window shape used by b.mail.server.rateLimit (_pruneWindow):
620
- // 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.
621
735
  //
622
736
  // CWE-307 (Improper Restriction of Excessive Authentication Attempts —
623
737
  // generalized here to excessive decryption-oracle attempts); OWASP ASVS
624
738
  // v5 §2.2.1 (anti-automation); NIST SP 800-63B §5.2.2 (rate limiting).
625
- 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
626
749
  var _rateFailWindows = new Map(); // "actor\x00table\x00column" → [tsMs, ...]
627
750
  var _rateCooldowns = new Map(); // same key → cooldownUntilMs
628
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
+
629
772
  // Tuple key. \x00 is not a legal column / table identifier byte and is
630
773
  // vanishingly unlikely in an actor id, so the join is unambiguous; the
631
774
  // composite is only ever a Map key (never an object property), so no
@@ -641,23 +784,25 @@ function _rateKey(actor, table, column) {
641
784
  * @compliance hipaa, gdpr, pci-dss
642
785
  * @related b.cryptoField.unsealRow, b.cryptoField.clearRateCapForTest
643
786
  *
644
- * Opt into a per-(actor, table, column) cap on sealed-column unseal
645
- * FAILURES. By default (unconfigured) `unsealRow` only nulls the field
646
- * and emits `system.crypto.unseal_failed` on a forged-ciphertext read
647
- * an attacker who can write `vault:<crafted>` payloads can hammer the
648
- * KEM-decapsulation / AEAD-verify oracle indefinitely, and only an
649
- * off-band operator alert rule catches the burst. With a cap configured,
650
- * once a single tuple accrues `threshold` failures inside `windowMs`,
651
- * every subsequent `unsealRow` touching that tuple is REFUSED for
652
- * `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
653
794
  * `system.crypto.unseal_rate_exceeded` audit row, bounding the oracle.
654
- *
655
- * Pass `null` (or `{ disabled: true }`) to turn the cap back off. This is
656
- * a behaviour-changing refusal gate, so it is opt-in: unconfigured
657
- * deployments keep today's audit-only behaviour with full back-compat.
658
- * Validation is config-time / entry-point tier bad `threshold` /
659
- * `windowMs` / `cooldownMs` THROW so an operator catches the typo at
660
- * 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.
661
806
  *
662
807
  * CWE-307 (excessive-attempt restriction); OWASP ASVS v5 §2.2.1;
663
808
  * NIST SP 800-63B §5.2.2.
@@ -780,20 +925,19 @@ function _rateNoteFailure(actor, table, column) {
780
925
  * @status experimental
781
926
  * @related b.cryptoField.configureUnsealRateCap
782
927
  *
783
- * Test-only helper. Disables the unseal-failure rate cap and drops every
784
- * in-flight sliding-window + cooldown entry so a fixture can re-configure
785
- * the cap between cases. Operator code never calls this — production
786
- * 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.
787
933
  *
788
934
  * @example
789
935
  * b.cryptoField.configureUnsealRateCap({ threshold: 3 });
790
936
  * b.cryptoField.clearRateCapForTest();
791
- * // cap is off again; windows + cooldowns cleared
937
+ * // cap is back at the secure default; windows + cooldowns cleared
792
938
  */
793
939
  function clearRateCapForTest() {
794
- _rateCap = null;
795
- _rateFailWindows.clear();
796
- _rateCooldowns.clear();
940
+ _installDefaultRateCap();
797
941
  }
798
942
 
799
943
  // ---- Row sealing / unsealing ----
@@ -896,7 +1040,7 @@ function sealRow(table, row, opts) {
896
1040
  "' is AAD-bound (registerTable({aad:true})); the row's identity " +
897
1041
  "column '" + s.rowIdField + "' must be populated BEFORE sealRow. " +
898
1042
  "Generate the primary key first (e.g. uuid / sequence INSERT … RETURNING), " +
899
- "set row." + s.rowIdField + ", then sealRow.");
1043
+ "set row." + s.rowIdField + ", then sealRow."); // allow:hand-rolled-sql — error-message prose, not SQL
900
1044
  }
901
1045
  }
902
1046
 
@@ -916,10 +1060,11 @@ function sealRow(table, row, opts) {
916
1060
  // Idempotent: an already-K_row-sealed value passes through.
917
1061
  if (isRowSealed(out[field])) continue;
918
1062
  var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
919
- // Coerce to a string the same way the vault.aad path does, then
920
- // encode as UTF-8 bytes for the AEAD (split out so the byte
921
- // coercion is Buffer.from(str, "utf8"), not Buffer.from(String(...))).
922
- 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]);
923
1068
  out[field] = ROW_PREFIX +
924
1069
  encryptPacked(Buffer.from(plainStr, "utf8"), kRow, cellAad).toString("base64");
925
1070
  } else if (s.aad) {
@@ -927,12 +1072,12 @@ function sealRow(table, row, opts) {
927
1072
  if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
928
1073
  continue;
929
1074
  }
930
- out[field] = vaultAad.seal(String(out[field]),
1075
+ out[field] = vaultAad.seal(_encodeTyped(out[field]),
931
1076
  _aadParts(s, table, field, out));
932
1077
  } else {
933
1078
  // allow:seal-without-aad — plain-mode legacy table; operator
934
1079
  // opts into AAD via registerTable({aad:true})
935
- out[field] = vault.seal(String(out[field]));
1080
+ out[field] = vault.seal(_encodeTyped(out[field]));
936
1081
  }
937
1082
  }
938
1083
 
@@ -980,15 +1125,17 @@ function _aadParts(schema, table, column, row) {
980
1125
  * makes the unwrap throw → the field nulls + `system.crypto.unseal_failed`
981
1126
  * fires, which is correct: shredded data reads as absent.
982
1127
  *
983
- * When an unseal-failure rate cap is configured via
984
- * `configureUnsealRateCap` (default off), repeated forged-ciphertext
985
- * failures for a single `(actor, table, column)` tuple trip a cooldown:
986
- * once tripped, this call THROWS `CryptoFieldRateError` and emits a
987
- * distinct `system.crypto.unseal_rate_exceeded` audit instead of
988
- * exercising the decryption oracle again (CWE-307). `actor` identifies
989
- * the caller for that tuple (e.g. session subject / API key id); it
990
- * defaults to an anonymous bucket when omitted, and is ignored entirely
991
- * 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).
992
1139
  *
993
1140
  * @example
994
1141
  * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
@@ -1035,9 +1182,13 @@ function unsealRow(table, row, actor, dbHandle) {
1035
1182
  var prep = (dbHandle && typeof dbHandle.prepare === "function")
1036
1183
  ? dbHandle.prepare.bind(dbHandle)
1037
1184
  : db().prepare;
1038
- wrap = prep(
1039
- 'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1040
- ).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);
1041
1192
  } catch (_e) {
1042
1193
  return null;
1043
1194
  }
@@ -1053,7 +1204,7 @@ function unsealRow(table, row, actor, dbHandle) {
1053
1204
  // rules off it): "row" = K_row cell, "aad" = vault.aad: cell on an
1054
1205
  // AAD table, "plain" otherwise.
1055
1206
  var shape = isRowSealed(out[field]) ? "row" : (s.aad ? "aad" : "plain");
1056
- // 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
1057
1208
  // from prior forged-ciphertext failures, refuse before touching the
1058
1209
  // decryption oracle again (CWE-307). No-op when the cap is disabled.
1059
1210
  if (_rateInCooldown(capActor, table, field)) {
@@ -1082,14 +1233,14 @@ function unsealRow(table, row, actor, dbHandle) {
1082
1233
  "' is unavailable (shredded or never materialized)");
1083
1234
  }
1084
1235
  var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
1085
- unsealed = decryptPacked(
1236
+ unsealed = _decodeTyped(decryptPacked(
1086
1237
  Buffer.from(out[field].slice(ROW_PREFIX.length), "base64"), kRow, cellAad
1087
- ).toString("utf8");
1238
+ ).toString("utf8"));
1088
1239
  } else if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
1089
- unsealed = vaultAad.unseal(out[field],
1090
- _aadParts(s, table, field, out));
1240
+ unsealed = _decodeTyped(vaultAad.unseal(out[field],
1241
+ _aadParts(s, table, field, out)));
1091
1242
  } else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
1092
- unsealed = vault.unseal(out[field]);
1243
+ unsealed = _decodeTyped(vault.unseal(out[field]));
1093
1244
  } else {
1094
1245
  // Not a sealed value — pass through.
1095
1246
  unsealed = out[field];
@@ -1117,7 +1268,7 @@ function unsealRow(table, row, actor, dbHandle) {
1117
1268
  },
1118
1269
  });
1119
1270
  } catch (_e) { /* drop-silent */ }
1120
- // Opt-in rate cap: account this failure against the (actor,
1271
+ // Default-on rate cap: account this failure against the (actor,
1121
1272
  // table, column) tuple. When it trips the threshold, arm the
1122
1273
  // cooldown + emit the distinct rate-exceeded audit once on the
1123
1274
  // transition. No-op when the cap is disabled.
@@ -1140,9 +1291,91 @@ function unsealRow(table, row, actor, dbHandle) {
1140
1291
  }
1141
1292
  }
1142
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
+
1143
1307
  return out;
1144
1308
  }
1145
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
+
1146
1379
  // ---- Erasure (GDPR Art. 17 / "right to be forgotten") ----
1147
1380
  //
1148
1381
  // eraseRow(table, row) returns a tombstoned copy of the row: every
@@ -1277,6 +1510,16 @@ function eraseRow(table, row) {
1277
1510
  * every encryption uses a fresh random nonce, so the ciphertext alone
1278
1511
  * cannot anchor a query.
1279
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
+ *
1280
1523
  * @example
1281
1524
  * b.cryptoField.registerTable("users", {
1282
1525
  * sealedFields: ["email"],
@@ -1294,14 +1537,52 @@ function lookupHash(table, field, value) {
1294
1537
  for (var derivedField in s.derivedHashes) {
1295
1538
  var spec = s.derivedHashes[derivedField];
1296
1539
  if (spec.from === field) {
1297
- var ns = namespaceFor(table, field, s.hashNamespaces);
1298
- var normalized = spec.normalize ? spec.normalize(value) : String(value);
1299
- return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
1540
+ return _derivedHashResult(s, table, derivedField, spec, field, value);
1300
1541
  }
1301
1542
  }
1302
1543
  return null;
1303
1544
  }
1304
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
+
1305
1586
  /**
1306
1587
  * @primitive b.cryptoField.declareColumnResidency
1307
1588
  * @signature b.cryptoField.declareColumnResidency(table, opts)
@@ -1658,9 +1939,13 @@ function materializePerRowKey(table, rowId, dbHandle) {
1658
1939
  }
1659
1940
  var ridStr = String(rowId);
1660
1941
  // Existing secret? Unwrap + re-derive to support idempotent UPSERTs.
1661
- var existing = dbHandle.prepare(
1662
- 'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1663
- ).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);
1664
1949
  if (existing) {
1665
1950
  return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
1666
1951
  }
@@ -1677,10 +1962,17 @@ function materializePerRowKey(table, rowId, dbHandle) {
1677
1962
  // _id is the rotation pipeline's pagination/UPDATE key (the natural
1678
1963
  // identity is the composite (tableName, rowId)). A fresh token keeps
1679
1964
  // it unique per registry row.
1680
- dbHandle.prepare(
1681
- 'INSERT INTO "' + PER_ROW_KEYS_TABLE + '" (_id, tableName, rowId, wrappedKey, createdAt) ' +
1682
- 'VALUES (?, ?, ?, ?, ?)'
1683
- ).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);
1684
1976
  return kRow;
1685
1977
  }
1686
1978
 
@@ -1739,9 +2031,12 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1739
2031
  throw new CryptoFieldError("crypto-field/destroy-per-row-key-no-db",
1740
2032
  "destroyPerRowKey: dbHandle (b.db) is required");
1741
2033
  }
1742
- var result = dbHandle.prepare(
1743
- 'DELETE FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1744
- ).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);
1745
2040
  return { destroyed: (result && result.changes) || 0 };
1746
2041
  }
1747
2042
 
@@ -1797,6 +2092,7 @@ module.exports = {
1797
2092
  computeDerived: computeDerived,
1798
2093
  computeNamespacedHash: computeNamespacedHash,
1799
2094
  lookupHash: lookupHash,
2095
+ lookupHashCandidates: lookupHashCandidates,
1800
2096
  clearForTest: clearForTest,
1801
2097
  declareColumnResidency: declareColumnResidency,
1802
2098
  getColumnResidency: getColumnResidency,