@blamejs/core 0.14.27 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +29 -1
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +218 -100
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +22 -0
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +387 -91
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +497 -255
- package/lib/db-schema.js +209 -44
- package/lib/db.js +176 -95
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/framework-error.js +11 -0
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +596 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +14 -0
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +31 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/outbox.js +136 -82
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/retention.js +82 -39
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +109 -72
- package/lib/sql.js +3885 -0
- package/lib/static.js +45 -7
- package/lib/subject.js +55 -17
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +16 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/crypto-field.js
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
307
|
-
"'
|
|
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
|
-
// - "
|
|
395
|
-
// + value
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
645
|
-
* FAILURES.
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
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
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
* `
|
|
660
|
-
*
|
|
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.
|
|
784
|
-
* in-flight sliding-window + cooldown entry so a fixture
|
|
785
|
-
* the cap between cases
|
|
786
|
-
*
|
|
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
|
|
937
|
+
* // cap is back at the secure default; windows + cooldowns cleared
|
|
792
938
|
*/
|
|
793
939
|
function clearRateCapForTest() {
|
|
794
|
-
|
|
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
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
|
1743
|
-
|
|
1744
|
-
|
|
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,
|