@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.
- package/CHANGELOG.md +2 -0
- package/README.md +1 -1
- package/lib/auth/oauth.js +736 -1
- package/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/crypto-field.js +274 -17
- package/lib/mail-auth.js +333 -0
- package/lib/middleware/fetch-metadata.js +115 -14
- package/lib/middleware/security-headers.js +47 -0
- package/lib/observability.js +39 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
|
144
|
+
var algorithm = _resolveHolderAlg(opts.holderKey, opts.algorithm);
|
|
100
145
|
var auditOn = opts.auditOn !== false;
|
|
101
146
|
|
|
102
147
|
function _emitAudit(action, outcome, metadata) {
|
package/lib/crypto-field.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|