@blamejs/core 0.14.19 → 0.14.20

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.
@@ -43,6 +43,7 @@
43
43
  * tests — production operators wire b.db / b.objectStore.
44
44
  */
45
45
 
46
+ var nodeCrypto = require("node:crypto");
46
47
  var lazyRequire = require("../lazy-require");
47
48
  var validateOpts = require("../validate-opts");
48
49
  var { AuthError } = require("../framework-error");
@@ -51,6 +52,50 @@ var sdJwtVcCore = lazyRequire(function () { return require("./sd-jwt-vc"); });
51
52
  var audit = lazyRequire(function () { return require("../audit"); });
52
53
  var observability = lazyRequire(function () { return require("../observability"); });
53
54
 
55
+ // EC curve → the KB-JWT alg the sd-jwt-vc core supports for it. P-521
56
+ // has no entry — the core's SUPPORTED_ALGS stops at ES384.
57
+ var _HOLDER_EC_CURVE_ALG = { prime256v1: "ES256", secp384r1: "ES384" };
58
+
59
+ // Resolve the KB-JWT signing alg from the holder key when the operator
60
+ // gives no explicit `algorithm`. A fixed default (the old "ES256") signed
61
+ // a non-EC-P256 holder key under a header alg that disagreed with the key
62
+ // — un-signable (Ed25519 / EC-P384) or a self-invalid KB-JWT a verifier
63
+ // rejects (any key whose sign succeeds under the wrong digest). Inferring
64
+ // from the key keeps the common EC-P256 → ES256 case unchanged while
65
+ // producing a self-consistent KB-JWT for every other supported key, and
66
+ // refuses a key type the core has no alg for (e.g. RSA) instead of
67
+ // emitting a broken presentation. An explicit `algorithm` is honoured and
68
+ // validated by the core against SUPPORTED_ALGS.
69
+ function _resolveHolderAlg(holderKey, explicitAlg) {
70
+ if (explicitAlg) return explicitAlg;
71
+ var keyObj = null;
72
+ try {
73
+ if (holderKey instanceof nodeCrypto.KeyObject) {
74
+ keyObj = holderKey;
75
+ } else if (typeof holderKey === "string" || Buffer.isBuffer(holderKey)) {
76
+ keyObj = nodeCrypto.createPrivateKey({ key: holderKey, format: "pem" });
77
+ } else if (holderKey && typeof holderKey === "object" && holderKey.kty) {
78
+ keyObj = nodeCrypto.createPrivateKey({ key: holderKey, format: "jwk" });
79
+ }
80
+ } catch (_e) {
81
+ keyObj = null; // unreadable key — let the signer surface the real error
82
+ }
83
+ if (!keyObj) return "ES256"; // preserve the historical default when the type can't be read
84
+ var kty = keyObj.asymmetricKeyType;
85
+ if (kty === "ec") {
86
+ var curve = (keyObj.asymmetricKeyDetails && keyObj.asymmetricKeyDetails.namedCurve) || "";
87
+ var ecAlg = _HOLDER_EC_CURVE_ALG[curve];
88
+ if (ecAlg) return ecAlg;
89
+ throw new AuthError("auth-sd-jwt-vc/holder-key-unsupported",
90
+ "holder.create: EC curve '" + curve + "' has no KB-JWT algorithm (use P-256 / P-384, Ed25519, or ML-DSA-87 / ML-DSA-65)");
91
+ }
92
+ if (kty === "ed25519" || kty === "ed448") return "EdDSA";
93
+ if (kty === "ml-dsa-87") return "ML-DSA-87";
94
+ if (kty === "ml-dsa-65") return "ML-DSA-65";
95
+ throw new AuthError("auth-sd-jwt-vc/holder-key-unsupported",
96
+ "holder.create: key type '" + String(kty) + "' has no KB-JWT algorithm (use EC P-256 / P-384, Ed25519, or ML-DSA-87 / ML-DSA-65; RSA is not supported for KB-JWT)");
97
+ }
98
+
54
99
  function _validateStorage(storage) {
55
100
  if (!storage || typeof storage !== "object") return false;
56
101
  return ["put", "get", "list", "delete"].every(function (m) {
@@ -96,7 +141,7 @@ function create(opts) {
96
141
  throw new AuthError("auth-sd-jwt-vc/no-key",
97
142
  "holder.create: holderKey required");
98
143
  }
99
- var algorithm = opts.algorithm || "ES256";
144
+ var algorithm = _resolveHolderAlg(opts.holderKey, opts.algorithm);
100
145
  var auditOn = opts.auditOn !== false;
101
146
 
102
147
  function _emitAudit(action, outcome, metadata) {
@@ -45,9 +45,19 @@
45
45
  var lazyRequire = require("./lazy-require");
46
46
  var vault = require("./vault");
47
47
  var vaultAad = require("./vault-aad");
48
+ var validateOpts = require("./validate-opts");
49
+ var numericBounds = require("./numeric-bounds");
50
+ var { defineClass } = require("./framework-error");
48
51
  var { sha3Hash, kdf } = require("./crypto");
49
52
  var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
50
53
 
54
+ // Typed refusal raised when a (actor, table, column) tuple exceeds the
55
+ // opt-in unseal-failure rate cap and is in cooldown. alwaysPermanent —
56
+ // the caller does not retry; the cooldown is a deliberate, time-bounded
57
+ // circuit-breaker, not a transient backend hiccup.
58
+ var CryptoFieldRateError = defineClass("CryptoFieldRateError", { alwaysPermanent: true });
59
+ var CryptoFieldError = defineClass("CryptoFieldError", { alwaysPermanent: true });
60
+
51
61
  var compliance = lazyRequire(function () { return require("./compliance"); });
52
62
  var db = lazyRequire(function () { return require("./db"); });
53
63
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -187,7 +197,8 @@ function registerTable(name, opts) {
187
197
  var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
188
198
  var derivedHashMode = opts.derivedHashMode || "salted-sha3";
189
199
  if (derivedHashMode !== "salted-sha3" && derivedHashMode !== "hmac-shake256") {
190
- throw new Error("registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
200
+ throw new CryptoFieldError("crypto-field/bad-derived-hash-mode",
201
+ "registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
191
202
  "'hmac-shake256', got " + JSON.stringify(derivedHashMode));
192
203
  }
193
204
  var derivedHashes = Object.assign({}, opts.derivedHashes || {});
@@ -195,7 +206,8 @@ function registerTable(name, opts) {
195
206
  if (!Object.prototype.hasOwnProperty.call(derivedHashes, col)) continue;
196
207
  var colMode = derivedHashes[col] && derivedHashes[col].mode;
197
208
  if (colMode !== undefined && colMode !== "salted-sha3" && colMode !== "hmac-shake256") {
198
- throw new Error("registerTable: derivedHashes." + col + ".mode must be " +
209
+ throw new CryptoFieldError("crypto-field/bad-derived-hash-col-mode",
210
+ "registerTable: derivedHashes." + col + ".mode must be " +
199
211
  "'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
200
212
  }
201
213
  }
@@ -285,14 +297,16 @@ function computeNamespacedHash(ns, value, opts) {
285
297
  opts = opts || {};
286
298
  var mode = opts.mode || "salted-sha3";
287
299
  if (mode !== "salted-sha3" && mode !== "hmac-shake256") {
288
- throw new Error("computeNamespacedHash: opts.mode must be 'salted-sha3' " +
300
+ throw new CryptoFieldError("crypto-field/bad-namespaced-hash-mode",
301
+ "computeNamespacedHash: opts.mode must be 'salted-sha3' " +
289
302
  "(default) or 'hmac-shake256', got " + JSON.stringify(mode));
290
303
  }
291
304
  var truncateBytes = opts.truncateBytes;
292
305
  if (truncateBytes !== undefined) {
293
306
  if (typeof truncateBytes !== "number" || !isFinite(truncateBytes) ||
294
307
  truncateBytes <= 0 || Math.floor(truncateBytes) !== truncateBytes) {
295
- throw new Error("computeNamespacedHash: opts.truncateBytes must be a " +
308
+ throw new CryptoFieldError("crypto-field/bad-truncate-bytes",
309
+ "computeNamespacedHash: opts.truncateBytes must be a " +
296
310
  "positive integer (bytes), got " + JSON.stringify(truncateBytes));
297
311
  }
298
312
  }
@@ -422,6 +436,202 @@ function computeDerived(table, sourceField, sourceValue) {
422
436
  return null;
423
437
  }
424
438
 
439
+ // ---- Unseal-failure rate cap (CWE-307) ----
440
+ //
441
+ // Opt-in brute-force / decryption-oracle throttle for the unsealRow read
442
+ // path. A DB-write attacker who can write `vault:<crafted>` /
443
+ // `vault.aad:<crafted>` payloads to sealed columns can force KEM
444
+ // decapsulation / AEAD verify on attacker-controlled bytes on every read.
445
+ // unsealRow already nulls the field + emits system.crypto.unseal_failed,
446
+ // but absent a cap the attacker can hammer the oracle indefinitely and
447
+ // only an off-band operator alert rule catches the burst. This adds an
448
+ // in-process per-(actor, table, column) sliding-window failure cap: past
449
+ // `threshold` failures inside `windowMs`, further unseal attempts for that
450
+ // tuple are refused for `cooldownMs` with a typed CryptoFieldRateError and
451
+ // a distinct system.crypto.unseal_rate_exceeded audit row.
452
+ //
453
+ // Default OFF — when no cap is configured, unsealRow behaves exactly as
454
+ // before (null-the-field + audit-only). Composes the same timestamp-array
455
+ // sliding-window shape used by b.mail.server.rateLimit (_pruneWindow):
456
+ // count-based, lazily pruned on read, no background timer.
457
+ //
458
+ // CWE-307 (Improper Restriction of Excessive Authentication Attempts —
459
+ // generalized here to excessive decryption-oracle attempts); OWASP ASVS
460
+ // v5 §2.2.1 (anti-automation); NIST SP 800-63B §5.2.2 (rate limiting).
461
+ var _rateCap = null; // null = disabled
462
+ var _rateFailWindows = new Map(); // "actor\x00table\x00column" → [tsMs, ...]
463
+ var _rateCooldowns = new Map(); // same key → cooldownUntilMs
464
+
465
+ // Tuple key. \x00 is not a legal column / table identifier byte and is
466
+ // vanishingly unlikely in an actor id, so the join is unambiguous; the
467
+ // composite is only ever a Map key (never an object property), so no
468
+ // prototype-pollution surface.
469
+ function _rateKey(actor, table, column) {
470
+ return String(actor) + "\x00" + table + "\x00" + column;
471
+ }
472
+
473
+ /**
474
+ * @primitive b.cryptoField.configureUnsealRateCap
475
+ * @signature b.cryptoField.configureUnsealRateCap(opts)
476
+ * @since 0.14.20
477
+ * @compliance hipaa, gdpr, pci-dss
478
+ * @related b.cryptoField.unsealRow, b.cryptoField.clearRateCapForTest
479
+ *
480
+ * Opt into a per-(actor, table, column) cap on sealed-column unseal
481
+ * FAILURES. By default (unconfigured) `unsealRow` only nulls the field
482
+ * and emits `system.crypto.unseal_failed` on a forged-ciphertext read —
483
+ * an attacker who can write `vault:<crafted>` payloads can hammer the
484
+ * KEM-decapsulation / AEAD-verify oracle indefinitely, and only an
485
+ * off-band operator alert rule catches the burst. With a cap configured,
486
+ * once a single tuple accrues `threshold` failures inside `windowMs`,
487
+ * every subsequent `unsealRow` touching that tuple is REFUSED for
488
+ * `cooldownMs` with a `CryptoFieldRateError` and a distinct
489
+ * `system.crypto.unseal_rate_exceeded` audit row, bounding the oracle.
490
+ *
491
+ * Pass `null` (or `{ disabled: true }`) to turn the cap back off. This is
492
+ * a behaviour-changing refusal gate, so it is opt-in: unconfigured
493
+ * deployments keep today's audit-only behaviour with full back-compat.
494
+ * Validation is config-time / entry-point tier — bad `threshold` /
495
+ * `windowMs` / `cooldownMs` THROW so an operator catches the typo at
496
+ * boot rather than silently disabling the cap.
497
+ *
498
+ * CWE-307 (excessive-attempt restriction); OWASP ASVS v5 §2.2.1;
499
+ * NIST SP 800-63B §5.2.2.
500
+ *
501
+ * @opts
502
+ * threshold: number, // failures within the window before refusal kicks in (positive int)
503
+ * windowMs: number, // sliding-window width in ms (positive int; default 60000)
504
+ * cooldownMs: number, // refusal duration once tripped (positive int; default windowMs)
505
+ * disabled: boolean, // pass true to turn the cap off (same as configureUnsealRateCap(null))
506
+ * now: function, // injected clock returning epoch ms; default Date.now (test seam)
507
+ * onAudit: function, // optional sink({ action, outcome, metadata }) for the rate audit (test seam)
508
+ *
509
+ * @example
510
+ * b.cryptoField.configureUnsealRateCap({ threshold: 5, windowMs: 60000, cooldownMs: 300000 });
511
+ * // ...after 5 forged-ciphertext unseal failures for one (actor, table, column):
512
+ * try { b.cryptoField.unsealRow("patients", forgedRow, "actor-42"); }
513
+ * catch (e) { e.code; } // → "crypto-field/unseal-rate-exceeded"
514
+ *
515
+ * b.cryptoField.configureUnsealRateCap(null); // → disable again
516
+ */
517
+ function configureUnsealRateCap(opts) {
518
+ if (opts === null || opts === undefined || opts.disabled === true) {
519
+ _rateCap = null;
520
+ _rateFailWindows.clear();
521
+ _rateCooldowns.clear();
522
+ return null;
523
+ }
524
+ validateOpts(opts, ["threshold", "windowMs", "cooldownMs", "disabled", "now", "onAudit"],
525
+ "cryptoField.configureUnsealRateCap");
526
+ // threshold is required (no default); presence-guard first, then validate
527
+ // the three positive-int bounds through the shared batch helper so the
528
+ // get-or-default and the bound checks each live in one place.
529
+ if (opts.threshold === undefined) {
530
+ throw new CryptoFieldRateError("crypto-field/bad-threshold",
531
+ "cryptoField.configureUnsealRateCap: opts.threshold is required and must be a positive finite integer");
532
+ }
533
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
534
+ ["threshold", "windowMs", "cooldownMs"],
535
+ "cryptoField.configureUnsealRateCap", CryptoFieldRateError, "crypto-field/bad-rate-cap-opt");
536
+ validateOpts.optionalFunction(opts.now,
537
+ "cryptoField.configureUnsealRateCap: opts.now", CryptoFieldRateError,
538
+ "crypto-field/bad-now");
539
+ validateOpts.optionalFunction(opts.onAudit,
540
+ "cryptoField.configureUnsealRateCap: opts.onAudit", CryptoFieldRateError,
541
+ "crypto-field/bad-on-audit");
542
+
543
+ var windowMs = opts.windowMs === undefined ? TIME.minutes(1) : opts.windowMs;
544
+ _rateCap = {
545
+ threshold: opts.threshold,
546
+ windowMs: windowMs,
547
+ cooldownMs: opts.cooldownMs === undefined ? windowMs : opts.cooldownMs,
548
+ now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
549
+ onAudit: typeof opts.onAudit === "function" ? opts.onAudit : null,
550
+ };
551
+ // Re-arming with a fresh config drops any in-flight windows/cooldowns
552
+ // so a config change can't leave a tuple stuck in an old cooldown.
553
+ _rateFailWindows.clear();
554
+ _rateCooldowns.clear();
555
+ return { threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs };
556
+ }
557
+
558
+ // Emit the distinct rate-exceeded audit. Drop-silent (hot-path sink): a
559
+ // throwing audit on the read path must not crash the request that
560
+ // triggered it. Honors the injected onAudit test sink when present,
561
+ // otherwise routes through the framework audit chain.
562
+ function _emitRateAudit(metadata) {
563
+ try {
564
+ if (_rateCap && _rateCap.onAudit) {
565
+ _rateCap.onAudit({ action: "system.crypto.unseal_rate_exceeded", outcome: "denied", metadata: metadata });
566
+ return;
567
+ }
568
+ audit().safeEmit({ action: "system.crypto.unseal_rate_exceeded", outcome: "denied", metadata: metadata });
569
+ } catch (_e) { /* drop-silent — audit best-effort */ }
570
+ }
571
+
572
+ // Pre-unseal gate. Returns true when the tuple is currently in cooldown
573
+ // (caller must refuse). No-op (returns false) when the cap is disabled.
574
+ // Prunes an expired cooldown lazily so the Map can't grow unbounded.
575
+ function _rateInCooldown(actor, table, column) {
576
+ if (!_rateCap) return false;
577
+ var key = _rateKey(actor, table, column);
578
+ var until = _rateCooldowns.get(key);
579
+ if (until === undefined) return false;
580
+ if (_rateCap.now() >= until) {
581
+ _rateCooldowns.delete(key);
582
+ _rateFailWindows.delete(key);
583
+ return false;
584
+ }
585
+ return true;
586
+ }
587
+
588
+ // Post-failure accounting. Records one failure timestamp for the tuple,
589
+ // prunes the sliding window, and arms a cooldown when the count reaches
590
+ // the threshold. Returns true when this failure tripped the cap (so the
591
+ // caller can emit the rate audit exactly once on the transition).
592
+ function _rateNoteFailure(actor, table, column) {
593
+ if (!_rateCap) return false;
594
+ var nowMs = _rateCap.now();
595
+ var key = _rateKey(actor, table, column);
596
+ var arr = _rateFailWindows.get(key);
597
+ if (!arr) { arr = []; _rateFailWindows.set(key, arr); }
598
+ // Prune entries older than the window (sliding-window via timestamp
599
+ // array — same shape as b.mail.server.rateLimit._pruneWindow).
600
+ var cutoff = nowMs - _rateCap.windowMs;
601
+ var drop = 0;
602
+ while (drop < arr.length && arr[drop] < cutoff) drop += 1;
603
+ if (drop > 0) arr.splice(0, drop);
604
+ arr.push(nowMs);
605
+ if (arr.length >= _rateCap.threshold) {
606
+ _rateCooldowns.set(key, nowMs + _rateCap.cooldownMs);
607
+ return true;
608
+ }
609
+ return false;
610
+ }
611
+
612
+ /**
613
+ * @primitive b.cryptoField.clearRateCapForTest
614
+ * @signature b.cryptoField.clearRateCapForTest()
615
+ * @since 0.14.20
616
+ * @status experimental
617
+ * @related b.cryptoField.configureUnsealRateCap
618
+ *
619
+ * Test-only helper. Disables the unseal-failure rate cap and drops every
620
+ * in-flight sliding-window + cooldown entry so a fixture can re-configure
621
+ * the cap between cases. Operator code never calls this — production
622
+ * deployments configure the cap once at boot.
623
+ *
624
+ * @example
625
+ * b.cryptoField.configureUnsealRateCap({ threshold: 3 });
626
+ * b.cryptoField.clearRateCapForTest();
627
+ * // cap is off again; windows + cooldowns cleared
628
+ */
629
+ function clearRateCapForTest() {
630
+ _rateCap = null;
631
+ _rateFailWindows.clear();
632
+ _rateCooldowns.clear();
633
+ }
634
+
425
635
  // ---- Row sealing / unsealing ----
426
636
 
427
637
  /**
@@ -485,7 +695,8 @@ function sealRow(table, row) {
485
695
  if (s.aad) {
486
696
  var rowId = out[s.rowIdField];
487
697
  if (rowId === undefined || rowId === null || String(rowId).length === 0) {
488
- throw new Error("cryptoField.sealRow: table '" + table +
698
+ throw new CryptoFieldError("crypto-field/seal-row-aad-rowid-missing",
699
+ "cryptoField.sealRow: table '" + table +
489
700
  "' is AAD-bound (registerTable({aad:true})); the row's identity " +
490
701
  "column '" + s.rowIdField + "' must be populated BEFORE sealRow. " +
491
702
  "Generate the primary key first (e.g. uuid / sequence INSERT … RETURNING), " +
@@ -533,10 +744,10 @@ function _aadParts(schema, table, column, row) {
533
744
 
534
745
  /**
535
746
  * @primitive b.cryptoField.unsealRow
536
- * @signature b.cryptoField.unsealRow(table, row)
747
+ * @signature b.cryptoField.unsealRow(table, row, actor?)
537
748
  * @since 0.4.0
538
749
  * @compliance hipaa, gdpr, pci-dss
539
- * @related b.cryptoField.sealRow, b.vault.unseal
750
+ * @related b.cryptoField.sealRow, b.vault.unseal, b.cryptoField.configureUnsealRateCap
540
751
  *
541
752
  * Returns a copy of `row` with every sealed column unwrapped via
542
753
  * `vault.unseal()`. Round-trips with `sealRow`. When `vault.unseal`
@@ -547,21 +758,45 @@ function _aadParts(schema, table, column, row) {
547
758
  * so downstream code sees "no value" instead of crashing the request.
548
759
  * The input row is never mutated.
549
760
  *
761
+ * When an unseal-failure rate cap is configured via
762
+ * `configureUnsealRateCap` (default off), repeated forged-ciphertext
763
+ * failures for a single `(actor, table, column)` tuple trip a cooldown:
764
+ * once tripped, this call THROWS `CryptoFieldRateError` and emits a
765
+ * distinct `system.crypto.unseal_rate_exceeded` audit instead of
766
+ * exercising the decryption oracle again (CWE-307). `actor` identifies
767
+ * the caller for that tuple (e.g. session subject / API key id); it
768
+ * defaults to an anonymous bucket when omitted, and is ignored entirely
769
+ * when no cap is configured (full back-compat for the 2-arg call).
770
+ *
550
771
  * @example
551
772
  * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
552
773
  * var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
553
774
  * var clear = b.cryptoField.unsealRow("patients", sealed);
554
775
  * clear.ssn; // → "123-45-6789"
555
776
  */
556
- function unsealRow(table, row) {
777
+ function unsealRow(table, row, actor) {
557
778
  if (!row) return row;
558
779
  var s = schemas[table];
559
780
  if (!s || s.sealedFields.length === 0) return row;
560
781
  var out = Object.assign({}, row);
782
+ var capActor = (actor === undefined || actor === null || String(actor).length === 0)
783
+ ? "_anon" : String(actor);
561
784
 
562
785
  for (var i = 0; i < s.sealedFields.length; i++) {
563
786
  var field = s.sealedFields[i];
564
787
  if (out[field]) {
788
+ // Opt-in cap: if this (actor, table, column) tuple is in cooldown
789
+ // from prior forged-ciphertext failures, refuse before touching the
790
+ // decryption oracle again (CWE-307). No-op when the cap is disabled.
791
+ if (_rateInCooldown(capActor, table, field)) {
792
+ _emitRateAudit({
793
+ table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
794
+ threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
795
+ });
796
+ throw new CryptoFieldRateError("crypto-field/unseal-rate-exceeded",
797
+ "cryptoField.unsealRow: unseal-failure rate cap tripped for (actor, '" + table +
798
+ "', '" + field + "') — refusing further unseal attempts during cooldown");
799
+ }
565
800
  var unsealed;
566
801
  try {
567
802
  // Auto-detect the envelope shape so an AAD-bound table that
@@ -599,6 +834,16 @@ function unsealRow(table, row) {
599
834
  },
600
835
  });
601
836
  } catch (_e) { /* drop-silent */ }
837
+ // Opt-in rate cap: account this failure against the (actor,
838
+ // table, column) tuple. When it trips the threshold, arm the
839
+ // cooldown + emit the distinct rate-exceeded audit once on the
840
+ // transition. No-op when the cap is disabled.
841
+ if (_rateNoteFailure(capActor, table, field)) {
842
+ _emitRateAudit({
843
+ table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
844
+ threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
845
+ });
846
+ }
602
847
  unsealed = null;
603
848
  }
604
849
  // Assign unconditionally. `unsealed` already carries the right value
@@ -806,21 +1051,25 @@ function lookupHash(table, field, value) {
806
1051
  */
807
1052
  function declareColumnResidency(table, opts) {
808
1053
  if (typeof table !== "string" || table.length === 0) {
809
- throw new Error("declareColumnResidency: table must be a non-empty string");
1054
+ throw new CryptoFieldError("crypto-field/residency-table-empty",
1055
+ "declareColumnResidency: table must be a non-empty string");
810
1056
  }
811
1057
  if (opts === null || opts === undefined || typeof opts !== "object" || Array.isArray(opts)) {
812
- throw new Error("declareColumnResidency: opts must be a plain object");
1058
+ throw new CryptoFieldError("crypto-field/residency-opts-not-object",
1059
+ "declareColumnResidency: opts must be a plain object");
813
1060
  }
814
1061
  var map = opts.columnResidency;
815
1062
  if (!map || typeof map !== "object" || Array.isArray(map)) {
816
- throw new Error("declareColumnResidency: opts.columnResidency must be an object");
1063
+ throw new CryptoFieldError("crypto-field/residency-map-not-object",
1064
+ "declareColumnResidency: opts.columnResidency must be an object");
817
1065
  }
818
1066
  var entry = Object.create(null);
819
1067
  for (var col in map) {
820
1068
  if (!Object.prototype.hasOwnProperty.call(map, col)) continue;
821
1069
  var tag = map[col];
822
1070
  if (typeof tag !== "string" || tag.length === 0) {
823
- throw new Error("declareColumnResidency: column '" + col +
1071
+ throw new CryptoFieldError("crypto-field/residency-tag-empty",
1072
+ "declareColumnResidency: column '" + col +
824
1073
  "' residency tag must be a non-empty string");
825
1074
  }
826
1075
  entry[col] = tag;
@@ -943,17 +1192,20 @@ function assertColumnResidency(table, row, args) {
943
1192
  */
944
1193
  function declarePerRowKey(table, opts) {
945
1194
  if (typeof table !== "string" || table.length === 0) {
946
- throw new Error("declarePerRowKey: table must be a non-empty string");
1195
+ throw new CryptoFieldError("crypto-field/per-row-key-table-empty",
1196
+ "declarePerRowKey: table must be a non-empty string");
947
1197
  }
948
1198
  opts = opts || {};
949
1199
  var keySize = opts.keySize === undefined ? 32 : opts.keySize; // XChaCha20-Poly1305 key length in bytes
950
1200
  if (typeof keySize !== "number" || !isFinite(keySize) ||
951
1201
  keySize < 16 || Math.floor(keySize) !== keySize) { // minimum AES-128 key length in bytes
952
- throw new Error("declarePerRowKey: opts.keySize must be an integer >= 16 (bytes)");
1202
+ throw new CryptoFieldError("crypto-field/per-row-key-bad-size",
1203
+ "declarePerRowKey: opts.keySize must be an integer >= 16 (bytes)");
953
1204
  }
954
1205
  var info = opts.info || ("blamejs-per-row-key:" + table);
955
1206
  if (typeof info !== "string" || info.length === 0) {
956
- throw new Error("declarePerRowKey: opts.info must be a non-empty string");
1207
+ throw new CryptoFieldError("crypto-field/per-row-key-info-empty",
1208
+ "declarePerRowKey: opts.info must be a non-empty string");
957
1209
  }
958
1210
  perRowKeyTables[table] = { keySize: keySize, info: info };
959
1211
  return { table: table, keySize: keySize, info: info };
@@ -1010,7 +1262,8 @@ function materializePerRowKey(table, rowId, dbHandle) {
1010
1262
  var spec = perRowKeyTables[table];
1011
1263
  if (!spec) return null;
1012
1264
  if (!dbHandle || typeof dbHandle.prepare !== "function") {
1013
- throw new Error("materializePerRowKey: dbHandle (b.db) is required");
1265
+ throw new CryptoFieldError("crypto-field/materialize-per-row-key-no-db",
1266
+ "materializePerRowKey: dbHandle (b.db) is required");
1014
1267
  }
1015
1268
  // Existing key? Re-use to support idempotent UPSERTs.
1016
1269
  var existing = dbHandle.prepare(
@@ -1066,7 +1319,8 @@ function materializePerRowKey(table, rowId, dbHandle) {
1066
1319
  function destroyPerRowKey(table, rowId, dbHandle) {
1067
1320
  if (!perRowKeyTables[table]) return { destroyed: 0 };
1068
1321
  if (!dbHandle || typeof dbHandle.prepare !== "function") {
1069
- throw new Error("destroyPerRowKey: dbHandle (b.db) is required");
1322
+ throw new CryptoFieldError("crypto-field/destroy-per-row-key-no-db",
1323
+ "destroyPerRowKey: dbHandle (b.db) is required");
1070
1324
  }
1071
1325
  var result = dbHandle.prepare(
1072
1326
  'DELETE FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
@@ -1105,6 +1359,9 @@ module.exports = {
1105
1359
  getSealedFields: getSealedFields,
1106
1360
  sealRow: sealRow,
1107
1361
  unsealRow: unsealRow,
1362
+ configureUnsealRateCap: configureUnsealRateCap,
1363
+ clearRateCapForTest: clearRateCapForTest,
1364
+ CryptoFieldRateError: CryptoFieldRateError,
1108
1365
  // _aadParts — the column-AAD builder the seal/unseal path uses. Exported
1109
1366
  // (internal) so the vault-key rotation pipeline reconstructs the IDENTICAL
1110
1367
  // AAD tuple a cell was sealed under — one source of truth, no drift