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