@blamejs/core 0.14.24 → 0.14.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/constants.js +11 -0
- package/lib/crypto-field.js +307 -78
- package/lib/db-query.js +65 -5
- package/lib/db.js +17 -3
- package/lib/middleware/idempotency-key.js +21 -13
- package/lib/retention.js +11 -1
- package/lib/vault-aad.js +6 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.25 (2026-06-06) — **Per-row crypto-shred is real and AAD-bound, and the idempotency fingerprint key is sealed at rest.** The per-row encryption-key feature that backs `b.subject.eraseHard` crypto-shred was both non-functional and, as documented, unsound: the per-row key (`K_row`) was derived deterministically from a salt that is stored in plaintext in the data directory, so an actor with disk access could re-derive it and decrypt 'shredded' residual ciphertext (WAL / replica / backup) — and the key was never actually materialized on insert, so no table ever used it. Per-row keys are now derived from a fresh 32-byte CSPRNG secret stored only in `_blamejs_per_row_keys`, wrapped under the vault with AEAD additional-data binding `(table, rowId)`, and materialized on insert for tables that declare them; destroying the wrapped secret now genuinely renders the row's residual ciphertext undecryptable. The same plaintext-salt class is fixed for the idempotency request-fingerprint HMAC, which now seeds off the sealed-at-rest `vault.getDerivedHashMacKey()`. There is no migration: the per-row-key table was empty in every deployment, so keyed tables are correct from their first write. **Security:** *Per-row crypto-shred: random secret, AAD-bound wrap, materialized on insert* — `b.cryptoField.declarePerRowKey` tables now get a per-row key derived from a fresh `b.crypto.generateBytes(32)` secret — never from the plaintext per-deployment salt — so the key has no input recomputable from disk. The secret is stored in `_blamejs_per_row_keys` wrapped via `b.vault.aad.seal` with additional-data bound to `(table, rowId)`, so a wrapped key copied onto another row fails its Poly1305 tag. Sealed columns are encrypted under the per-row key and tagged with a `vault.row:` envelope (`b.cryptoField.isRowSealed`); the residency-tag column is never key-sealed so the write-boundary residency gate can still read it. The key is materialized on insert (it previously never was) and re-derived once per read; `b.subject.eraseHard` and the retention sweep destroy the wrapped secret, after which the row's residual ciphertext reads as absent and cannot be recovered even if the vault root is later compromised. Wrapped keys are re-sealed under the new root on vault keypair rotation, so rotation does not strand keyed rows. · *Idempotency request-fingerprint HMAC seeds off the sealed MAC key* — The `fingerprintSeal` HMAC over the request fingerprint was keyed from the plaintext per-deployment salt, so a disk-access actor could recompute the key and brute-force the low-entropy preimage (method + URL + body) offline. It now seeds off `b.vault.getDerivedHashMacKey()`, which is sealed at rest. Fingerprints cached before the upgrade no longer match, so a replayed request re-executes once (the safe direction). **Detectors:** *kdf-key-from-plaintext-derived-hash-salt* — The pattern catalog refuses a key-derivation (`kdf(... getDerivedHashSalt() ...)`) that seeds a per-row shred key or a vault-secret-promising keyed-MAC off the plaintext-on-disk derived-hash salt. Such a key is re-derivable by anyone with the data directory, defeating the shred / secrecy it advertises; the correct sources are a CSPRNG secret or the sealed `getDerivedHashMacKey()`. The deterministic salted-SHA3 equality index is a distinct shape and is unaffected. **Migration:** *No action required* — No data migration: the per-row-key table was empty in every deployment, so tables that declare per-row keys are correct from their first write going forward. The only observable change is that the first request after upgrade matching a pre-upgrade idempotency fingerprint re-executes once.
|
|
12
|
+
|
|
11
13
|
- v0.14.24 (2026-06-05) — **Per-row data residency enforced at the write boundary, and the long-advertised column-residency gate is wired.** Rows can now carry their own residency tag, and the database write paths enforce it: under a cross-border regulated posture (GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA) a row tagged for one region is refused before it lands on a backend in another. `b.cryptoField.declarePerRowResidency` declares which column carries the tag; local SQLite writes check it against the deployment's `dataResidency` region set, and `b.externalDb.query` / `transaction` DML to a residency-tagged backend takes the tag per call via `rowResidencyTag`. The column-scoped gate (`assertColumnResidency`) — exported and documented since v0.7.27 but never composed into any write path — is now enforced on the same boundary. Unregulated deployments are unaffected: every gate passes with an advisory audit instead of a refusal, so operators can observe before adopting a posture. Note: v0.14.23 was tagged but not published to npm (its publish run timed out in validation); its changes — the MX DATA-phase SPF/DKIM/DMARC gate and the inbound mail authentication pipeline — are included in this package, and the publish-validation timeout is fixed here. **Added:** *`b.cryptoField.declarePerRowResidency` / `getPerRowResidency` — row-scoped residency tags* — Declares the plaintext column carrying each row's residency tag plus the whitelist of valid tag values (`{ residencyColumn, allowedTags }`). On INSERT to a declared table the tag is required and must be in `allowedTags`; rows tagged `global` or `unrestricted` pass any backend. The declaration registry mirrors `declareColumnResidency`, and `clearResidencyForTest` now clears both. The tag comes from application logic (the user's declared region) — the framework never infers it from request metadata. · *Local write gate on `b.db.from(...).insertOne` / `update`* — Runs on the plaintext row before sealing, so the tag column stays inspectable when sibling columns seal. Under a cross-border regulated posture, a row whose tag falls outside the deployment's region set (`dataResidency.region` plus `allowedStorageRegions`) is refused with `db-query/row-residency-local-mismatch`; a missing or out-of-whitelist tag refuses with `db-query/row-residency-tag-missing` / `-tag-invalid` regardless of posture. UPDATEs gate when the change set touches the residency column — an update that doesn't move residency is not a transfer. Refusals are typed `DbQueryError`s (new error class) and land in the audit chain (`db.residency.gate.rejected`); unregulated postures emit `db.residency.gate.advisory` and pass. · *External write gate — `rowResidencyTag` on `b.externalDb.query` / `transaction`* — External-db takes raw SQL, not row objects, so the row's tag travels as `opts.rowResidencyTag` (validated at transaction entry too). Under a regulated posture, a write to a residency-tagged backend requires the tag (`RESIDENCY_GATE_REQUIRED`) and refuses a mismatch (`RESIDENCY_TAG_MISMATCH`) before the statement reaches the wire. The gate classifies by what a statement does, not its leading keyword: it resolves the effective verb behind a `WITH` (CTE) or `EXPLAIN ANALYZE` prefix and treats `INSERT`/`UPDATE`/`DELETE`/`MERGE`/`REPLACE`, `CALL`/`EXECUTE`/`DO`, and `COPY ... FROM` as writes — only recognized pure reads (`SELECT`, `SHOW`/`DESCRIBE`/`PRAGMA`, a `COPY ... TO` export, plan-only `EXPLAIN`, and session/transaction statements) pass untagged. A statement whose class can't be resolved, or a multi-statement string that hides a trailing write behind a harmless prefix, is refused (`STATEMENT_UNRESOLVED_REFUSED` / `MULTI_STATEMENT_REFUSED`). Inside `transaction()`, the transaction-level tag applies to every statement and a per-call override on `tx.query(sql, params, opts)` wins for that statement; a refusal rolls the transaction back. Replica reads that carry a tag refuse routing to an incompatible replica (`REPLICA_RESIDENCY_INCOMPATIBLE`) unless the replica is configured `allowCrossBorder: true`, which is audited (`db.residency.replica.cross_border` at read time, `db.residency.replica.cross_border_allowed` at config time). Unrestricted backends are not gated, and the migration runner's own tracking writes are region-neutral so migrations run unaffected on a residency-tagged backend. · *`b.compliance.isCrossBorderRegulated` — shared posture vocabulary* — The cross-border regulated posture set (gdpr, uk-gdpr, dpdp, pipl-cn, lgpd-br, appi-jp, pdpa-sg) now lives on `b.compliance` (`CROSS_BORDER_REGULATED_POSTURES` + the membership helper), one source of truth shared by the local gate, the external gate, and the existing replica-topology boot check. **Fixed:** *`assertColumnResidency` is now actually enforced* — `b.cryptoField.declareColumnResidency` / `assertColumnResidency` shipped in v0.7.27 documenting a write-time gate, but no write path ever called the assertion — column tags were recorded and never checked. The local write paths now run it against the deployment region: a mismatch refuses with `db-query/column-residency-mismatch` under a regulated posture and emits an advisory otherwise. Operators tag columns with the region value their `dataResidency` declares. · *Cross-border-allowed replica audit event is now recorded* — The config-time audit event for a consciously-permitted cross-border replica used a malformed action name that the audit validator silently dropped, so a compliance reviewer saw no record of the `allowCrossBorder` decision in the audit chain. It now emits as `db.residency.replica.cross_border_allowed` and lands like every other audit row. · *Publish-validation timeout that blocked the v0.14.23 npm release* — The v0.14.23 publish run timed out in release validation — the pattern-catalog self-test crossed a per-file watchdog budget as the codebase grew, and the same self-test, which scans the whole library and runs far longer on the slower macOS CI runner, hit the same wall on this package's first CI run. The validation budgets are corrected so a genuinely stuck file still fails fast while the catalog completes. v0.14.23 exists as a signed tag; npm goes 0.14.22 → 0.14.24 carrying both releases' changes. **Detectors:** *db-query-write-without-residency-gate* — Every local write method that seals a row must run the residency gates on the plaintext first — a future write path (upsert, bulk) inherits the requirement automatically. · *Residency-gates-wired catalog check* — The pattern catalog now pins the wiring itself: the local write methods call the gate, the external query/transaction paths call theirs, and `assertColumnResidency` has a real caller — the declared-but-never-enforced class cannot silently reappear. **Migration:** *No action required unless you adopt the gates* — Tables without a residency declaration, deployments without a `dataResidency` region, and unregulated postures behave exactly as before (advisory audit events at most). Adopting: declare per-row residency on mixed-region tables, pass `rowResidencyTag` on external DML, and set a cross-border posture (`b.compliance.set("gdpr")`) to turn refusals on.
|
|
12
14
|
|
|
13
15
|
- v0.14.23 (2026-06-05) — **Inbound mail authentication: a DATA-phase SPF/DKIM/DMARC gate on the MX listener and the one-call receiver pipeline.** The MX listener can now refuse policy-failing mail at the wire instead of asking operators to verify after delivery. `b.mail.inbound.verify` is the receiver pipeline — SPF on the envelope identity, DKIM on the message bytes, DMARC policy + alignment on the From-header domain, and the RFC 8601 Authentication-Results header — and the listener's new `guardEnvelope` opt runs it at DATA completion: when the sender's published DMARC policy says reject, the message is refused with 550 before it reaches the agent handoff; accepted mail carries the verdict to the agent and gains the receiver's Authentication-Results header, with any sender-forged header carrying the receiver's name stripped first. Monitor mode annotates without refusing, so operators can observe verdicts on live traffic before enforcing. **Added:** *`b.mail.inbound.verify` — one-call receiver authentication pipeline* — Runs the inbound authentication set on a full RFC 5322 message (string or Buffer): SPF (RFC 7208) on the MAIL FROM identity with HELO fallback for the null reverse-path, DKIM (RFC 6376) on every signature, From-header extraction, DMARC (RFC 7489 / DMARCbis) policy discovery + alignment, and — when an `authservId` is supplied — the RFC 8601 Authentication-Results header. Returns `{ spf, dkim, from, dmarc, authResults }` with `dmarc.recommendedAction` carrying the policy disposition. From-header discipline per RFC 7489 §6.6.1: a message with zero From fields, several From fields, or several author addresses in one field returns `permerror` with a reject recommendation instead of picking one of the authors — the header-duplication shape behind the CVE-2024-7208 / CVE-2024-7209 hosted-relay spoofing class. The author parser is quote-aware: a literal `<` or comma inside a quoted display-name (`"Doe, John" <j@example.com>`) is one author, not two. A fail verdict computed while SPF or DKIM returned temperror surfaces as temperror (RFC 7489 §6.6.2) — the transiently-failed lookup could have produced the aligned pass, so the caller defers and the sender retries instead of being permanently refused during a DNS blip. · *MX listener `guardEnvelope` — the DATA-phase authentication gate* — `b.mail.server.mx.create({ guardEnvelope: true })` (or a config object: `mode`, `onTemperror`, `authservId`, `dnsLookup`, `maxSignatures`, `clockSkewMs`, `minRsaBits`, `timeoutMs`) runs the pipeline after the SIZE check and before the agent handoff. Enforce mode refuses with 550 5.7.26 (RFC 7372 — multiple authentication checks failed) when DMARC evaluates to fail under a reject policy, 550 5.7.1 on the multi-From shape, and 451 4.7.0 on DNS temperror or pipeline timeout — `onTemperror: "accept"` admits unauthenticated mail instead when availability wins. The whole pipeline runs under a wall-clock ceiling (`timeoutMs`, default 20s) so a message stuffed with signatures pointing at slow resolvers cannot pin the connection slot. Accepted messages reach the agent handoff with the verdict as `auth` (including `quarantine: true` when the sender's policy says quarantine — the agent owns foldering) and gain the receiver's Authentication-Results header; any sender-attached header forging the receiver's authserv-id is stripped first (RFC 8601 §5). Monitor mode (the default under the permissive profile) annotates and audits without refusing. New audit events: `mail.server.mx.envelope_verdict` and `mail.server.mx.envelope_error`. **Detectors:** *ar-header-prepend-without-forged-strip* — Any code path that prepends an emitted Authentication-Results header must first strip sender-attached instances claiming the same authserv-id (RFC 8601 §5) — a forged pre-attached verdict would otherwise shadow the computed one for downstream consumers. **Migration:** *No action required; the gate is opt-in* — `guardEnvelope` is off unless wired — an unwired gate is skipped like the sibling HELO / RBL / greylist gates, and existing listeners behave exactly as before. The agent handoff context gains an `auth` field (null when the gate is off). Start with `guardEnvelope: { mode: "monitor" }` to observe verdicts on live traffic before switching to enforce.
|
package/lib/constants.js
CHANGED
|
@@ -179,6 +179,16 @@ var TLS_GROUP_CURVE_STR = TLS_GROUP_PREFERENCE.join(":");
|
|
|
179
179
|
// ---- Vault sealed-value prefix ----
|
|
180
180
|
var VAULT_PREFIX = "vault:";
|
|
181
181
|
|
|
182
|
+
// ---- Per-row-key sealed-column prefix ----
|
|
183
|
+
// Columns encrypted under a row-scoped key (K_row) — distinct from the
|
|
184
|
+
// vault-root `vault:` / AAD-bound `vault.aad:` prefixes so the read path
|
|
185
|
+
// can route a cell to its decrypt: K_row-sealed cells unwrap the row's
|
|
186
|
+
// secret from `_blamejs_per_row_keys`, derive K_row, then decrypt under
|
|
187
|
+
// it (XChaCha20-Poly1305, AEAD-bound to (table, rowId, column,
|
|
188
|
+
// schemaVersion)). Destroying the row's wrapped secret leaves these
|
|
189
|
+
// cells mathematically undecryptable — the crypto-shred substrate.
|
|
190
|
+
var ROW_PREFIX = "vault.row:";
|
|
191
|
+
|
|
182
192
|
// ---- Default hash namespaces for derived-hash indexed lookups ----
|
|
183
193
|
// Apps add their own via app-config registries. The 'bj-' namespace
|
|
184
194
|
// prevents collision between framework-derived and app-derived hashes.
|
|
@@ -205,5 +215,6 @@ module.exports = {
|
|
|
205
215
|
TLS_GROUP_PREFERENCE: TLS_GROUP_PREFERENCE,
|
|
206
216
|
TLS_GROUP_CURVE_STR: TLS_GROUP_CURVE_STR,
|
|
207
217
|
VAULT_PREFIX: VAULT_PREFIX,
|
|
218
|
+
ROW_PREFIX: ROW_PREFIX,
|
|
208
219
|
HASH_PREFIX: HASH_PREFIX,
|
|
209
220
|
};
|
package/lib/crypto-field.js
CHANGED
|
@@ -15,14 +15,23 @@
|
|
|
15
15
|
* random nonce, so two seals of the same plaintext never collide.
|
|
16
16
|
*
|
|
17
17
|
* Per-row key (K_row) derivation is opt-in via `declarePerRowKey`.
|
|
18
|
-
* Tables that opt in get a fresh K_row per INSERT
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* K_row
|
|
25
|
-
*
|
|
18
|
+
* Tables that opt in get a fresh K_row per INSERT: the framework
|
|
19
|
+
* generates a 32-byte CSPRNG row-secret, derives
|
|
20
|
+
* `K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":"
|
|
21
|
+
* || info)`, and stores the SECRET (never K_row) AAD-sealed in
|
|
22
|
+
* `_blamejs_per_row_keys.wrappedKey`. Because the secret is random —
|
|
23
|
+
* not a function of any on-disk salt — an attacker with full disk
|
|
24
|
+
* access cannot re-derive K_row once the wrapped secret is gone. The
|
|
25
|
+
* AAD on the wrap binds (table, rowId, column, schemaVersion):
|
|
26
|
+
* copying a wrapped secret from one row to another fails Poly1305
|
|
27
|
+
* verification, so a DB-write attacker cannot move it between rows to
|
|
28
|
+
* bypass row-scoped erasure. Sealed columns on a keyed row carry the
|
|
29
|
+
* `vault.row:` prefix and are XChaCha20-Poly1305 ciphertext under
|
|
30
|
+
* K_row, AEAD-bound to the same (table, rowId, column) tuple. This is
|
|
31
|
+
* the crypto-shred substrate for `b.subject.eraseHard` /
|
|
32
|
+
* `b.retention`: destroying the wrapped secret leaves WAL / replica
|
|
33
|
+
* residual ciphertext mathematically undecryptable — even with the
|
|
34
|
+
* vault root key — because K_row is gone everywhere it ever lived.
|
|
26
35
|
*
|
|
27
36
|
* Derived hashes (`derivedHashes`) provide indexed lookup for sealed
|
|
28
37
|
* columns: a normalized SHA3 of the plaintext, salted by the vault's
|
|
@@ -48,8 +57,8 @@ var vaultAad = require("./vault-aad");
|
|
|
48
57
|
var validateOpts = require("./validate-opts");
|
|
49
58
|
var numericBounds = require("./numeric-bounds");
|
|
50
59
|
var { defineClass } = require("./framework-error");
|
|
51
|
-
var { sha3Hash, kdf } = require("./crypto");
|
|
52
|
-
var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
|
|
60
|
+
var { sha3Hash, kdf, generateBytes, encryptPacked, decryptPacked, generateToken } = require("./crypto");
|
|
61
|
+
var { HASH_PREFIX, VAULT_PREFIX, ROW_PREFIX, TIME } = require("./constants");
|
|
53
62
|
|
|
54
63
|
// Typed refusal raised when a (actor, table, column) tuple exceeds the
|
|
55
64
|
// opt-in unseal-failure rate cap and is in cooldown. alwaysPermanent —
|
|
@@ -139,13 +148,77 @@ var columnResidency = Object.create(null);
|
|
|
139
148
|
var perRowResidency = Object.create(null);
|
|
140
149
|
|
|
141
150
|
// Per-row key declaration registry. For tables that opt
|
|
142
|
-
// into per-row keying, b.subject.eraseHard
|
|
143
|
-
// from _blamejs_per_row_keys, leaving WAL/replica
|
|
144
|
-
// undecryptable.
|
|
151
|
+
// into per-row keying, b.subject.eraseHard / b.retention destroy the
|
|
152
|
+
// wrapped row-secret from _blamejs_per_row_keys, leaving WAL/replica
|
|
153
|
+
// residual ciphertext undecryptable.
|
|
145
154
|
//
|
|
146
|
-
// { tableName: { keySize, info
|
|
155
|
+
// { tableName: { keySize, info } }
|
|
147
156
|
var perRowKeyTables = Object.create(null);
|
|
148
157
|
|
|
158
|
+
// The framework registry table that holds each row's AAD-sealed
|
|
159
|
+
// row-secret. Named once so the seal-side AAD (materializePerRowKey),
|
|
160
|
+
// the read-side AAD (unsealRow's K_row fetch), and rotate's reseal all
|
|
161
|
+
// quote the byte-identical (table, rowId, column, schemaVersion) tuple.
|
|
162
|
+
var PER_ROW_KEYS_TABLE = "_blamejs_per_row_keys";
|
|
163
|
+
var PER_ROW_KEYS_COLUMN = "wrappedKey";
|
|
164
|
+
var PER_ROW_KEYS_SCHEMA_VERSION = "1";
|
|
165
|
+
|
|
166
|
+
// Build the canonical AAD parts for a row-secret wrap in
|
|
167
|
+
// _blamejs_per_row_keys. One source of truth so seal / unseal / rotate
|
|
168
|
+
// never drift. `rowId` is the app row's _id (the same value
|
|
169
|
+
// destroyPerRowKey + subject.eraseHard delete on).
|
|
170
|
+
function _wrappedKeyAad(rowId) {
|
|
171
|
+
return vaultAad.buildColumnAad({
|
|
172
|
+
table: PER_ROW_KEYS_TABLE,
|
|
173
|
+
rowId: rowId,
|
|
174
|
+
column: PER_ROW_KEYS_COLUMN,
|
|
175
|
+
schemaVersion: PER_ROW_KEYS_SCHEMA_VERSION,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build the canonical AAD parts for a K_row-sealed data cell. Binds the
|
|
180
|
+
// ciphertext to (table, rowId, column, schemaVersion) under K_row so a
|
|
181
|
+
// cell pasted into a different row / column fails Poly1305 — the same
|
|
182
|
+
// copy-protection the AAD-bound vault.aad: path gives, but keyed by the
|
|
183
|
+
// row-scoped K_row rather than the vault root.
|
|
184
|
+
function _rowCellAad(schema, table, column, rowId) {
|
|
185
|
+
return vaultAad.buildColumnAad({
|
|
186
|
+
table: table,
|
|
187
|
+
rowId: rowId,
|
|
188
|
+
column: column,
|
|
189
|
+
schemaVersion: (schema && schema.schemaVersion) || "1",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Encode a buildColumnAad parts object into the byte form
|
|
194
|
+
// encryptPacked / decryptPacked thread into the AEAD tag. The vault.aad
|
|
195
|
+
// canonicalizer (length-prefixed, sorted-keys) is the one encoder so a
|
|
196
|
+
// K_row cell sealed here and a wrapped-secret sealed via vaultAad.seal
|
|
197
|
+
// agree byte-for-byte on the same logical AAD.
|
|
198
|
+
function _aadBytes(parts) {
|
|
199
|
+
return vaultAad.canonicalizeAad(parts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @primitive b.cryptoField.isRowSealed
|
|
204
|
+
* @signature b.cryptoField.isRowSealed(value)
|
|
205
|
+
* @since 0.14.25
|
|
206
|
+
* @related b.cryptoField.sealRow, b.cryptoField.unsealRow
|
|
207
|
+
*
|
|
208
|
+
* Returns `true` when `value` is a string carrying the per-row-key
|
|
209
|
+
* sealed-cell prefix (`vault.row:`), `false` otherwise. The row-keyed
|
|
210
|
+
* sibling of `b.vault.aad.isAadSealed` — the read path uses it to route
|
|
211
|
+
* a cell to its K_row decrypt instead of the vault-root unseal.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* b.cryptoField.isRowSealed("vault.row:AAAA"); // → true
|
|
215
|
+
* b.cryptoField.isRowSealed("vault:AAAA"); // → false
|
|
216
|
+
* b.cryptoField.isRowSealed(null); // → false
|
|
217
|
+
*/
|
|
218
|
+
function isRowSealed(value) {
|
|
219
|
+
return typeof value === "string" && value.indexOf(ROW_PREFIX) === 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
149
222
|
/**
|
|
150
223
|
* @primitive b.cryptoField.registerTable
|
|
151
224
|
* @signature b.cryptoField.registerTable(name, opts)
|
|
@@ -641,7 +714,7 @@ function clearRateCapForTest() {
|
|
|
641
714
|
|
|
642
715
|
/**
|
|
643
716
|
* @primitive b.cryptoField.sealRow
|
|
644
|
-
* @signature b.cryptoField.sealRow(table, row)
|
|
717
|
+
* @signature b.cryptoField.sealRow(table, row, opts?)
|
|
645
718
|
* @since 0.4.0
|
|
646
719
|
* @compliance hipaa, gdpr, pci-dss
|
|
647
720
|
* @related b.cryptoField.unsealRow, b.cryptoField.eraseRow, b.vault.seal
|
|
@@ -654,6 +727,20 @@ function clearRateCapForTest() {
|
|
|
654
727
|
* computed BEFORE sealing the source so the indexed lookup column
|
|
655
728
|
* captures the plaintext digest.
|
|
656
729
|
*
|
|
730
|
+
* When `opts.kRow` (a row-scoped key Buffer from
|
|
731
|
+
* `materializePerRowKey`) is supplied — wired automatically by the
|
|
732
|
+
* db-query write boundary for `declarePerRowKey` tables — sealed
|
|
733
|
+
* columns are instead XChaCha20-Poly1305-encrypted under K_row and
|
|
734
|
+
* emitted with the `vault.row:` prefix, AEAD-bound to (table, rowId,
|
|
735
|
+
* column, schemaVersion). The residency-tag column (when the table
|
|
736
|
+
* declares per-row residency) is NEVER K_row-sealed: the write gate
|
|
737
|
+
* and reads must see it in plaintext.
|
|
738
|
+
*
|
|
739
|
+
* @opts
|
|
740
|
+
* kRow: Buffer, // row-scoped key from materializePerRowKey; when present,
|
|
741
|
+
* // sealed columns emit vault.row: cells under K_row
|
|
742
|
+
* rowId: string, // the row's _id; required when kRow is present (AAD term)
|
|
743
|
+
*
|
|
657
744
|
* @example
|
|
658
745
|
* b.cryptoField.registerTable("patients", {
|
|
659
746
|
* sealedFields: ["ssn"],
|
|
@@ -665,11 +752,29 @@ function clearRateCapForTest() {
|
|
|
665
752
|
* typeof sealed.ssnHash; // → "string"
|
|
666
753
|
* row.ssn; // → "123-45-6789" (input untouched)
|
|
667
754
|
*/
|
|
668
|
-
function sealRow(table, row) {
|
|
755
|
+
function sealRow(table, row, opts) {
|
|
669
756
|
if (!row) return row;
|
|
670
757
|
var s = schemas[table];
|
|
671
758
|
if (!s) return row;
|
|
672
759
|
var out = Object.assign({}, row);
|
|
760
|
+
opts = opts || {};
|
|
761
|
+
var kRow = Buffer.isBuffer(opts.kRow) ? opts.kRow : null;
|
|
762
|
+
// The per-row-key path needs the row identity for the cell AAD. Prefer
|
|
763
|
+
// the explicit opts.rowId; fall back to the row's _id. A K_row with no
|
|
764
|
+
// rowId can't build a stable AAD, so refuse rather than seal under a
|
|
765
|
+
// placeholder that no later unseal could open.
|
|
766
|
+
var kRowId = kRow
|
|
767
|
+
? String(opts.rowId != null ? opts.rowId : (out._id != null ? out._id : ""))
|
|
768
|
+
: null;
|
|
769
|
+
if (kRow && kRowId.length === 0) {
|
|
770
|
+
throw new CryptoFieldError("crypto-field/seal-row-krow-rowid-missing",
|
|
771
|
+
"cryptoField.sealRow: opts.kRow supplied but no rowId (set opts.rowId " +
|
|
772
|
+
"or row._id) — the K_row cell AAD binds (table, rowId, column)");
|
|
773
|
+
}
|
|
774
|
+
// Residency tag column must stay plaintext even under a K_row seal —
|
|
775
|
+
// the write gate reads it before sealRow and reads surface it verbatim.
|
|
776
|
+
var residencySpec = perRowResidency[table];
|
|
777
|
+
var residencyCol = residencySpec ? residencySpec.residencyColumn : null;
|
|
673
778
|
|
|
674
779
|
// Compute derived hashes from plaintext source values BEFORE sealing those
|
|
675
780
|
// sources. If a source value arrives already sealed (e.g. from an internal
|
|
@@ -709,25 +814,39 @@ function sealRow(table, row) {
|
|
|
709
814
|
}
|
|
710
815
|
}
|
|
711
816
|
|
|
712
|
-
// Seal fields.
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
817
|
+
// Seal fields. Three shapes:
|
|
818
|
+
// - K_row (opts.kRow present): XChaCha20-Poly1305 under the row-
|
|
819
|
+
// scoped key, vault.row: prefix, AEAD-bound (table, rowId, column,
|
|
820
|
+
// schemaVersion). Crypto-shred: destroying the wrapped row-secret
|
|
821
|
+
// leaves these cells undecryptable.
|
|
822
|
+
// - AAD mode (registerTable({aad:true})): vault.aad.seal binds the
|
|
823
|
+
// tag to (table, rowId, column, schemaVersion) under the vault root.
|
|
824
|
+
// - plain mode: vault.seal (idempotent — already-sealed pass through).
|
|
716
825
|
for (var i = 0; i < s.sealedFields.length; i++) {
|
|
717
826
|
var field = s.sealedFields[i];
|
|
718
|
-
if (out[field]
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
827
|
+
if (out[field] === undefined || out[field] === null) continue;
|
|
828
|
+
if (kRow && field === residencyCol) continue; // residency tag stays plaintext
|
|
829
|
+
if (kRow) {
|
|
830
|
+
// Idempotent: an already-K_row-sealed value passes through.
|
|
831
|
+
if (isRowSealed(out[field])) continue;
|
|
832
|
+
var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
|
|
833
|
+
// Coerce to a string the same way the vault.aad path does, then
|
|
834
|
+
// encode as UTF-8 bytes for the AEAD (split out so the byte
|
|
835
|
+
// coercion is Buffer.from(str, "utf8"), not Buffer.from(String(...))).
|
|
836
|
+
var plainStr = String(out[field]);
|
|
837
|
+
out[field] = ROW_PREFIX +
|
|
838
|
+
encryptPacked(Buffer.from(plainStr, "utf8"), kRow, cellAad).toString("base64");
|
|
839
|
+
} else if (s.aad) {
|
|
840
|
+
// Idempotent: already-AAD-sealed values pass through unchanged.
|
|
841
|
+
if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
|
|
842
|
+
continue;
|
|
730
843
|
}
|
|
844
|
+
out[field] = vaultAad.seal(String(out[field]),
|
|
845
|
+
_aadParts(s, table, field, out));
|
|
846
|
+
} else {
|
|
847
|
+
// allow:seal-without-aad — plain-mode legacy table; operator
|
|
848
|
+
// opts into AAD via registerTable({aad:true})
|
|
849
|
+
out[field] = vault.seal(String(out[field]));
|
|
731
850
|
}
|
|
732
851
|
}
|
|
733
852
|
|
|
@@ -749,7 +868,7 @@ function _aadParts(schema, table, column, row) {
|
|
|
749
868
|
|
|
750
869
|
/**
|
|
751
870
|
* @primitive b.cryptoField.unsealRow
|
|
752
|
-
* @signature b.cryptoField.unsealRow(table, row, actor?)
|
|
871
|
+
* @signature b.cryptoField.unsealRow(table, row, actor?, dbHandle?)
|
|
753
872
|
* @since 0.4.0
|
|
754
873
|
* @compliance hipaa, gdpr, pci-dss
|
|
755
874
|
* @related b.cryptoField.sealRow, b.vault.unseal, b.cryptoField.configureUnsealRateCap
|
|
@@ -763,6 +882,18 @@ function _aadParts(schema, table, column, row) {
|
|
|
763
882
|
* so downstream code sees "no value" instead of crashing the request.
|
|
764
883
|
* The input row is never mutated.
|
|
765
884
|
*
|
|
885
|
+
* `vault.row:`-prefixed cells (per-row-key tables, `declarePerRowKey`)
|
|
886
|
+
* are decrypted under the row's K_row: a `dbHandle` (the db-query layer
|
|
887
|
+
* passes `this._db`) is used to fetch the row's wrapped secret from
|
|
888
|
+
* `_blamejs_per_row_keys`, unwrap it, and derive K_row once per call.
|
|
889
|
+
* When a caller passes no `dbHandle` (e.g. `b.breakGlass.unsealRow`,
|
|
890
|
+
* which reads the row via clusterStorage), the framework's local db is
|
|
891
|
+
* resolved automatically — the wrapped secret always lives in the local
|
|
892
|
+
* `_blamejs_per_row_keys`, so keyed reads work on every path.
|
|
893
|
+
* A missing wrapped row (crypto-shredded by `eraseHard` / `retention`)
|
|
894
|
+
* makes the unwrap throw → the field nulls + `system.crypto.unseal_failed`
|
|
895
|
+
* fires, which is correct: shredded data reads as absent.
|
|
896
|
+
*
|
|
766
897
|
* When an unseal-failure rate cap is configured via
|
|
767
898
|
* `configureUnsealRateCap` (default off), repeated forged-ciphertext
|
|
768
899
|
* failures for a single `(actor, table, column)` tuple trip a cooldown:
|
|
@@ -779,7 +910,7 @@ function _aadParts(schema, table, column, row) {
|
|
|
779
910
|
* var clear = b.cryptoField.unsealRow("patients", sealed);
|
|
780
911
|
* clear.ssn; // → "123-45-6789"
|
|
781
912
|
*/
|
|
782
|
-
function unsealRow(table, row, actor) {
|
|
913
|
+
function unsealRow(table, row, actor, dbHandle) {
|
|
783
914
|
if (!row) return row;
|
|
784
915
|
var s = schemas[table];
|
|
785
916
|
if (!s || s.sealedFields.length === 0) return row;
|
|
@@ -787,15 +918,61 @@ function unsealRow(table, row, actor) {
|
|
|
787
918
|
var capActor = (actor === undefined || actor === null || String(actor).length === 0)
|
|
788
919
|
? "_anon" : String(actor);
|
|
789
920
|
|
|
921
|
+
// Lazy K_row: derive at most once per unsealRow call, only if a cell
|
|
922
|
+
// actually carries the vault.row: prefix. Cached across fields (and
|
|
923
|
+
// the failure case is cached too, so a shredded row doesn't re-query
|
|
924
|
+
// _blamejs_per_row_keys for every sealed column). The row identity for
|
|
925
|
+
// both the cell AAD and the wrapped-secret lookup is the row's _id —
|
|
926
|
+
// the same value the seal side (write boundary) passed as rowId and
|
|
927
|
+
// that destroyPerRowKey / eraseHard delete on.
|
|
928
|
+
var kRowId = out._id != null ? String(out._id) : "";
|
|
929
|
+
var keyedTable = hasPerRowKey(table);
|
|
930
|
+
var _kRowCache; // undefined = not yet derived; null = derive failed
|
|
931
|
+
function _kRowOnce() {
|
|
932
|
+
if (_kRowCache !== undefined) return _kRowCache;
|
|
933
|
+
_kRowCache = null;
|
|
934
|
+
if (!keyedTable || kRowId.length === 0) return null;
|
|
935
|
+
// Resolve a prepared-statement source for the wrapped-secret lookup.
|
|
936
|
+
// Prefer the caller's dbHandle (the db-query read layer threads it on
|
|
937
|
+
// first()/all()/stream()); otherwise resolve the framework's local
|
|
938
|
+
// db ourselves. A DIRECT caller — e.g. b.breakGlass.unsealRow, which
|
|
939
|
+
// fetches the target row via clusterStorage and calls unsealRow with
|
|
940
|
+
// no handle — would otherwise null every K_row cell on a keyed table
|
|
941
|
+
// even though the wrapped secret still exists. The secret always
|
|
942
|
+
// lives in the local _blamejs_per_row_keys, so keyed reads must work
|
|
943
|
+
// on every path, not only db-query's. Any failure (db not yet
|
|
944
|
+
// initialized, unusable handle) → null, and the field reads as absent
|
|
945
|
+
// exactly as a shredded row would (the caller audits it).
|
|
946
|
+
var spec = perRowKeyTables[table];
|
|
947
|
+
var wrap;
|
|
948
|
+
try {
|
|
949
|
+
var prep = (dbHandle && typeof dbHandle.prepare === "function")
|
|
950
|
+
? dbHandle.prepare.bind(dbHandle)
|
|
951
|
+
: db().prepare;
|
|
952
|
+
wrap = prep(
|
|
953
|
+
'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
|
|
954
|
+
).get(table, kRowId);
|
|
955
|
+
} catch (_e) {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
if (!wrap || wrap.wrappedKey == null) return null; // shredded / never materialized
|
|
959
|
+
_kRowCache = _deriveKRow(_unwrapRowSecret(wrap.wrappedKey, kRowId), table, kRowId, spec);
|
|
960
|
+
return _kRowCache;
|
|
961
|
+
}
|
|
962
|
+
|
|
790
963
|
for (var i = 0; i < s.sealedFields.length; i++) {
|
|
791
964
|
var field = s.sealedFields[i];
|
|
792
965
|
if (out[field]) {
|
|
966
|
+
// Per-cell envelope shape for audit metadata (operators write alert
|
|
967
|
+
// rules off it): "row" = K_row cell, "aad" = vault.aad: cell on an
|
|
968
|
+
// AAD table, "plain" otherwise.
|
|
969
|
+
var shape = isRowSealed(out[field]) ? "row" : (s.aad ? "aad" : "plain");
|
|
793
970
|
// Opt-in cap: if this (actor, table, column) tuple is in cooldown
|
|
794
971
|
// from prior forged-ciphertext failures, refuse before touching the
|
|
795
972
|
// decryption oracle again (CWE-307). No-op when the cap is disabled.
|
|
796
973
|
if (_rateInCooldown(capActor, table, field)) {
|
|
797
974
|
_emitRateAudit({
|
|
798
|
-
table: table, field: field, actor: capActor, shape:
|
|
975
|
+
table: table, field: field, actor: capActor, shape: shape,
|
|
799
976
|
threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
|
|
800
977
|
});
|
|
801
978
|
throw new CryptoFieldRateError("crypto-field/unseal-rate-exceeded",
|
|
@@ -807,7 +984,22 @@ function unsealRow(table, row, actor) {
|
|
|
807
984
|
// Auto-detect the envelope shape so an AAD-bound table that
|
|
808
985
|
// contains pre-migration plain-vault rows still reads. Read-
|
|
809
986
|
// side migration is lazy; the next sealRow re-emits AAD-bound.
|
|
810
|
-
if (typeof out[field] === "string" &&
|
|
987
|
+
if (typeof out[field] === "string" && isRowSealed(out[field])) {
|
|
988
|
+
// Per-row-key cell: derive K_row (lazy, once), then decrypt
|
|
989
|
+
// under it with the (table, rowId, column, schemaVersion) AAD.
|
|
990
|
+
// A null K_row means the wrapped secret is gone (shredded) or
|
|
991
|
+
// unreadable — throw so the catch nulls the field + audits.
|
|
992
|
+
var kRow = _kRowOnce();
|
|
993
|
+
if (!kRow) {
|
|
994
|
+
throw new CryptoFieldError("crypto-field/row-key-unavailable",
|
|
995
|
+
"unsealRow: per-row key for '" + table + "' row '" + kRowId +
|
|
996
|
+
"' is unavailable (shredded or never materialized)");
|
|
997
|
+
}
|
|
998
|
+
var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
|
|
999
|
+
unsealed = decryptPacked(
|
|
1000
|
+
Buffer.from(out[field].slice(ROW_PREFIX.length), "base64"), kRow, cellAad
|
|
1001
|
+
).toString("utf8");
|
|
1002
|
+
} else if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
|
|
811
1003
|
unsealed = vaultAad.unseal(out[field],
|
|
812
1004
|
_aadParts(s, table, field, out));
|
|
813
1005
|
} else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
|
|
@@ -834,7 +1026,7 @@ function unsealRow(table, row, actor) {
|
|
|
834
1026
|
table: table,
|
|
835
1027
|
field: field,
|
|
836
1028
|
rowId: out[s.rowIdField] || out._id || null,
|
|
837
|
-
shape:
|
|
1029
|
+
shape: shape,
|
|
838
1030
|
reason: (e && e.message) || String(e),
|
|
839
1031
|
},
|
|
840
1032
|
});
|
|
@@ -845,7 +1037,7 @@ function unsealRow(table, row, actor) {
|
|
|
845
1037
|
// transition. No-op when the cap is disabled.
|
|
846
1038
|
if (_rateNoteFailure(capActor, table, field)) {
|
|
847
1039
|
_emitRateAudit({
|
|
848
|
-
table: table, field: field, actor: capActor, shape:
|
|
1040
|
+
table: table, field: field, actor: capActor, shape: shape,
|
|
849
1041
|
threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
|
|
850
1042
|
});
|
|
851
1043
|
}
|
|
@@ -1272,13 +1464,16 @@ function getPerRowResidency(table) {
|
|
|
1272
1464
|
* @related b.cryptoField.materializePerRowKey, b.cryptoField.destroyPerRowKey, b.subject.eraseHard
|
|
1273
1465
|
*
|
|
1274
1466
|
* Opts a table into per-row keying (K_row crypto-shred substrate).
|
|
1275
|
-
* After registration, every INSERT generates a fresh
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1467
|
+
* After registration, every INSERT generates a fresh 32-byte CSPRNG
|
|
1468
|
+
* row-secret, derives K_row from it, and stores the SECRET (never
|
|
1469
|
+
* K_row) AAD-sealed in `_blamejs_per_row_keys (tableName, rowId,
|
|
1470
|
+
* wrappedKey)`. AAD on the wrap binds (table, rowId, column,
|
|
1471
|
+
* schemaVersion) — a wrapped secret copied to a different row fails
|
|
1472
|
+
* Poly1305 verification. `b.subject.eraseHard(subjectId)` /
|
|
1473
|
+
* `b.retention` destroy the per-row entries for the subject's rows; WAL
|
|
1474
|
+
* / replica residual ciphertext becomes mathematically undecryptable
|
|
1475
|
+
* because the random row-secret — the only seed for K_row — is gone
|
|
1476
|
+
* everywhere it ever lived. Throws on bad input (config-time
|
|
1282
1477
|
* fail-loud).
|
|
1283
1478
|
*
|
|
1284
1479
|
* @opts
|
|
@@ -1340,15 +1535,22 @@ function hasPerRowKey(table) {
|
|
|
1340
1535
|
* @compliance gdpr, hipaa
|
|
1341
1536
|
* @related b.cryptoField.declarePerRowKey, b.cryptoField.destroyPerRowKey
|
|
1342
1537
|
*
|
|
1343
|
-
* Derive-and-store: called by the storage backend on INSERT
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1538
|
+
* Derive-and-store: called by the storage backend on INSERT (the
|
|
1539
|
+
* db-query write boundary, gated on `hasPerRowKey`). Generates a fresh
|
|
1540
|
+
* 32-byte CSPRNG row-secret, derives
|
|
1541
|
+
* `K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":"
|
|
1542
|
+
* || info, keySize)`, AAD-seals the SECRET (base64) into
|
|
1543
|
+
* `_blamejs_per_row_keys.wrappedKey` via `b.vault.aad.seal`, and
|
|
1544
|
+
* returns the unwrapped K_row Buffer for the caller to encrypt sealed
|
|
1545
|
+
* columns under the row-scoped key. The secret is random — never a
|
|
1546
|
+
* function of any on-disk salt — so destroying the wrapped secret
|
|
1547
|
+
* makes K_row unrecoverable even with full disk + vault-root access.
|
|
1548
|
+
* Idempotent on UPSERT — if a secret already exists for (table,
|
|
1549
|
+
* rowId), unwraps it and re-derives the same K_row. The AAD-bound wrap
|
|
1550
|
+
* rejects copy-row attacks: a wrapped secret pasted under a different
|
|
1551
|
+
* rowId fails Poly1305 verification at unseal time. `dbHandle` is a
|
|
1552
|
+
* b.db handle (`.prepare`); rowId MUST be the row's `_id` (the value
|
|
1553
|
+
* `destroyPerRowKey` / `b.subject.eraseHard` delete on).
|
|
1352
1554
|
*
|
|
1353
1555
|
* @example
|
|
1354
1556
|
* b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
|
|
@@ -1368,30 +1570,55 @@ function materializePerRowKey(table, rowId, dbHandle) {
|
|
|
1368
1570
|
throw new CryptoFieldError("crypto-field/materialize-per-row-key-no-db",
|
|
1369
1571
|
"materializePerRowKey: dbHandle (b.db) is required");
|
|
1370
1572
|
}
|
|
1371
|
-
|
|
1573
|
+
var ridStr = String(rowId);
|
|
1574
|
+
// Existing secret? Unwrap + re-derive to support idempotent UPSERTs.
|
|
1372
1575
|
var existing = dbHandle.prepare(
|
|
1373
|
-
'SELECT wrappedKey FROM "
|
|
1374
|
-
).get(table,
|
|
1576
|
+
'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
|
|
1577
|
+
).get(table, ridStr);
|
|
1375
1578
|
if (existing) {
|
|
1376
|
-
return
|
|
1579
|
+
return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
|
|
1377
1580
|
}
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
var
|
|
1384
|
-
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
var sealed =
|
|
1581
|
+
// Fresh random row-secret. CRITICAL: this is CSPRNG, not a function
|
|
1582
|
+
// of any on-disk value (the pre-v0.14.25 design derived K_row from
|
|
1583
|
+
// the plaintext-on-disk derivedHash salt, so an attacker with disk
|
|
1584
|
+
// access re-derived it and deleting the wrap shred nothing). With a
|
|
1585
|
+
// random secret, K_row is unrecoverable once the wrap is destroyed.
|
|
1586
|
+
var rowSecret = generateBytes(32);
|
|
1587
|
+
var kRow = _deriveKRow(rowSecret, table, ridStr, spec);
|
|
1588
|
+
// Store the SECRET (never K_row), AAD-sealed under the vault root so a
|
|
1589
|
+
// wrapped secret copied to a different (table, rowId) fails Poly1305.
|
|
1590
|
+
var sealed = vaultAad.seal(rowSecret.toString("base64"), _wrappedKeyAad(ridStr));
|
|
1591
|
+
// _id is the rotation pipeline's pagination/UPDATE key (the natural
|
|
1592
|
+
// identity is the composite (tableName, rowId)). A fresh token keeps
|
|
1593
|
+
// it unique per registry row.
|
|
1388
1594
|
dbHandle.prepare(
|
|
1389
|
-
'INSERT INTO "
|
|
1390
|
-
'VALUES (?, ?, ?, ?)'
|
|
1391
|
-
).run(table,
|
|
1595
|
+
'INSERT INTO "' + PER_ROW_KEYS_TABLE + '" (_id, tableName, rowId, wrappedKey, createdAt) ' +
|
|
1596
|
+
'VALUES (?, ?, ?, ?, ?)'
|
|
1597
|
+
).run(generateToken(16), table, ridStr, sealed, Date.now());
|
|
1392
1598
|
return kRow;
|
|
1393
1599
|
}
|
|
1394
1600
|
|
|
1601
|
+
// Derive the row-scoped key from the random row-secret. SHAKE256 expand
|
|
1602
|
+
// (HKDF-shaped, matches the framework's PQC-first kdf) over
|
|
1603
|
+
// rowSecret || ":" || table || ":" || rowId || ":" || info — the
|
|
1604
|
+
// non-secret context terms domain-separate two rows that (astronomically
|
|
1605
|
+
// improbably) drew the same secret; the secret is the entropy source.
|
|
1606
|
+
function _deriveKRow(rowSecret, table, rowId, spec) {
|
|
1607
|
+
var ikm = Buffer.concat([
|
|
1608
|
+
rowSecret,
|
|
1609
|
+
Buffer.from(":" + table + ":" + rowId + ":" + spec.info, "utf8"),
|
|
1610
|
+
]);
|
|
1611
|
+
return kdf(ikm, spec.keySize);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Unwrap a stored row-secret back to its 32 raw bytes. The wrap is
|
|
1615
|
+
// AAD-bound to (PER_ROW_KEYS_TABLE, rowId, wrappedKey, schemaVersion);
|
|
1616
|
+
// a tampered / copied wrap throws here, which the read path surfaces as
|
|
1617
|
+
// system.crypto.unseal_failed (shredded data reads as absent).
|
|
1618
|
+
function _unwrapRowSecret(wrapped, rowId) {
|
|
1619
|
+
return Buffer.from(vaultAad.unseal(wrapped, _wrappedKeyAad(rowId)), "base64");
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1395
1622
|
/**
|
|
1396
1623
|
* @primitive b.cryptoField.destroyPerRowKey
|
|
1397
1624
|
* @signature b.cryptoField.destroyPerRowKey(table, rowId, dbHandle)
|
|
@@ -1399,13 +1626,14 @@ function materializePerRowKey(table, rowId, dbHandle) {
|
|
|
1399
1626
|
* @compliance gdpr, hipaa
|
|
1400
1627
|
* @related b.cryptoField.materializePerRowKey, b.subject.eraseHard
|
|
1401
1628
|
*
|
|
1402
|
-
* Crypto-shred: drops the
|
|
1403
|
-
* `_blamejs_per_row_keys`. Called by `b.subject.eraseHard`
|
|
1404
|
-
* row mapped to the erased subject. Returns
|
|
1629
|
+
* Crypto-shred: drops the row's wrapped row-secret from
|
|
1630
|
+
* `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` and
|
|
1631
|
+
* `b.retention` for each row mapped to the erased subject. Returns
|
|
1405
1632
|
* `{ destroyed: <rowsAffected> }`. After destruction, any WAL /
|
|
1406
1633
|
* replica residual ciphertext for the row is mathematically
|
|
1407
|
-
* undecryptable — even with the vault root key — because
|
|
1408
|
-
*
|
|
1634
|
+
* undecryptable — even with the vault root key — because the random
|
|
1635
|
+
* row-secret (the only seed for K_row) is gone everywhere it ever
|
|
1636
|
+
* lived. `rowId` MUST be the row's `_id`. No-op when the table is not
|
|
1409
1637
|
* registered for per-row keying.
|
|
1410
1638
|
*
|
|
1411
1639
|
* @example
|
|
@@ -1426,8 +1654,8 @@ function destroyPerRowKey(table, rowId, dbHandle) {
|
|
|
1426
1654
|
"destroyPerRowKey: dbHandle (b.db) is required");
|
|
1427
1655
|
}
|
|
1428
1656
|
var result = dbHandle.prepare(
|
|
1429
|
-
'DELETE FROM "
|
|
1430
|
-
).run(table, rowId);
|
|
1657
|
+
'DELETE FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
|
|
1658
|
+
).run(table, String(rowId));
|
|
1431
1659
|
return { destroyed: (result && result.changes) || 0 };
|
|
1432
1660
|
}
|
|
1433
1661
|
|
|
@@ -1463,6 +1691,7 @@ module.exports = {
|
|
|
1463
1691
|
getSealedFields: getSealedFields,
|
|
1464
1692
|
sealRow: sealRow,
|
|
1465
1693
|
unsealRow: unsealRow,
|
|
1694
|
+
isRowSealed: isRowSealed,
|
|
1466
1695
|
configureUnsealRateCap: configureUnsealRateCap,
|
|
1467
1696
|
clearRateCapForTest: clearRateCapForTest,
|
|
1468
1697
|
CryptoFieldRateError: CryptoFieldRateError,
|
package/lib/db-query.js
CHANGED
|
@@ -509,7 +509,9 @@ class Query {
|
|
|
509
509
|
this._whereClause() + this._orderLimitOffset() + " LIMIT 1";
|
|
510
510
|
var stmt = this._db.prepare(sql);
|
|
511
511
|
var row = stmt.get.apply(stmt, this._whereParams);
|
|
512
|
-
|
|
512
|
+
// 4th arg (dbHandle) lets unsealRow fetch + unwrap the row-scoped
|
|
513
|
+
// K_row for vault.row: cells (declarePerRowKey tables).
|
|
514
|
+
return row ? cryptoField.unsealRow(this._cryptoFieldKey(), row, undefined, this._db) : null;
|
|
513
515
|
}
|
|
514
516
|
|
|
515
517
|
all() {
|
|
@@ -519,8 +521,9 @@ class Query {
|
|
|
519
521
|
var rows = stmt.all.apply(stmt, this._whereParams);
|
|
520
522
|
var out = new Array(rows.length);
|
|
521
523
|
var key = this._cryptoFieldKey();
|
|
524
|
+
var dbHandle = this._db;
|
|
522
525
|
for (var i = 0; i < rows.length; i++) {
|
|
523
|
-
out[i] = cryptoField.unsealRow(key, rows[i]);
|
|
526
|
+
out[i] = cryptoField.unsealRow(key, rows[i], undefined, dbHandle);
|
|
524
527
|
}
|
|
525
528
|
return out;
|
|
526
529
|
}
|
|
@@ -549,6 +552,7 @@ class Query {
|
|
|
549
552
|
}
|
|
550
553
|
var stmt = this._db.prepare(sql);
|
|
551
554
|
var key = this._cryptoFieldKey();
|
|
555
|
+
var dbHandle = this._db;
|
|
552
556
|
var iter;
|
|
553
557
|
try { iter = stmt.iterate.apply(stmt, this._whereParams); }
|
|
554
558
|
catch (e) {
|
|
@@ -570,7 +574,7 @@ class Query {
|
|
|
570
574
|
var step = iter.next();
|
|
571
575
|
if (step.done) { this.push(null); return; }
|
|
572
576
|
emitted += 1;
|
|
573
|
-
this.push(cryptoField.unsealRow(key, step.value));
|
|
577
|
+
this.push(cryptoField.unsealRow(key, step.value, undefined, dbHandle));
|
|
574
578
|
} catch (e) {
|
|
575
579
|
this.destroy(e);
|
|
576
580
|
}
|
|
@@ -596,7 +600,19 @@ class Query {
|
|
|
596
600
|
// Residency gates read the PLAINTEXT row (the tag column must be
|
|
597
601
|
// inspectable even when sibling columns seal below).
|
|
598
602
|
_assertLocalResidency(this._cryptoFieldKey(), withId, "insert");
|
|
599
|
-
|
|
603
|
+
// Per-row-key tables (declarePerRowKey): materialize a fresh K_row
|
|
604
|
+
// BEFORE sealRow so sealed columns encrypt under the row-scoped key
|
|
605
|
+
// (vault.row: cells). rowId MUST be withId._id — the same value
|
|
606
|
+
// b.subject.eraseHard / b.retention destroy on, so a later shred
|
|
607
|
+
// makes these cells undecryptable. Materialize stores the random
|
|
608
|
+
// row-secret AAD-sealed in _blamejs_per_row_keys.
|
|
609
|
+
var sealOpts;
|
|
610
|
+
var cfKey = this._cryptoFieldKey();
|
|
611
|
+
if (cryptoField.hasPerRowKey(cfKey)) {
|
|
612
|
+
var kRow = cryptoField.materializePerRowKey(cfKey, withId._id, this._db);
|
|
613
|
+
sealOpts = { kRow: kRow, rowId: withId._id };
|
|
614
|
+
}
|
|
615
|
+
var sealed = cryptoField.sealRow(cfKey, withId, sealOpts);
|
|
600
616
|
var cols = Object.keys(sealed);
|
|
601
617
|
var placeholders = cols.map(function () { return "?"; }).join(", ");
|
|
602
618
|
var quotedCols = cols.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
@@ -637,7 +653,16 @@ class Query {
|
|
|
637
653
|
// touches the residency tag (or a region-bound column) is a
|
|
638
654
|
// transfer and goes through the same refusal matrix as INSERT.
|
|
639
655
|
_assertLocalResidency(this._cryptoFieldKey(), changes, "update");
|
|
640
|
-
var
|
|
656
|
+
var cfKey = this._cryptoFieldKey();
|
|
657
|
+
// Per-row-key tables: sealed columns must re-encrypt under EACH
|
|
658
|
+
// affected row's own K_row, so a single set-based UPDATE can't seal
|
|
659
|
+
// one value across rows. Resolve the affected _id set, then seal +
|
|
660
|
+
// write each row under its row-scoped key. Idempotent materialize
|
|
661
|
+
// re-derives the existing K_row (created on INSERT).
|
|
662
|
+
if (cryptoField.hasPerRowKey(cfKey)) {
|
|
663
|
+
return this._updatePerRowKey(cfKey, changes, single);
|
|
664
|
+
}
|
|
665
|
+
var sealed = cryptoField.sealRow(cfKey, changes);
|
|
641
666
|
var setKeys = Object.keys(sealed);
|
|
642
667
|
if (setKeys.length === 0) {
|
|
643
668
|
throw new Error("update changes object is empty");
|
|
@@ -666,6 +691,41 @@ class Query {
|
|
|
666
691
|
return info.changes;
|
|
667
692
|
}
|
|
668
693
|
|
|
694
|
+
// Per-row-key UPDATE. Sealed columns on a declarePerRowKey table are
|
|
695
|
+
// K_row cells (vault.row:), so each affected row must be re-sealed
|
|
696
|
+
// under its OWN K_row — a single set-based UPDATE can't carry per-row
|
|
697
|
+
// ciphertext. Resolve the affected _id set via the WHERE, then for
|
|
698
|
+
// each row: materialize (idempotent) its K_row, seal the change set
|
|
699
|
+
// under it (derived hashes computed from plaintext as usual), and
|
|
700
|
+
// UPDATE that single row by _id. `single` stops after the first row.
|
|
701
|
+
_updatePerRowKey(cfKey, changes, single) {
|
|
702
|
+
var whereSql = this._where.join(" AND ");
|
|
703
|
+
var qt = this._quotedTable();
|
|
704
|
+
var idStmt = this._db.prepare(
|
|
705
|
+
"SELECT _id FROM " + qt + " WHERE " + whereSql + (single ? " LIMIT 1" : ""));
|
|
706
|
+
var idRows = idStmt.all.apply(idStmt, this._whereParams);
|
|
707
|
+
var changed = 0;
|
|
708
|
+
for (var r = 0; r < idRows.length; r++) {
|
|
709
|
+
var rowId = idRows[r]._id;
|
|
710
|
+
if (rowId === undefined || rowId === null) continue;
|
|
711
|
+
var kRow = cryptoField.materializePerRowKey(cfKey, rowId, this._db);
|
|
712
|
+
var sealed = cryptoField.sealRow(cfKey, changes, { kRow: kRow, rowId: rowId });
|
|
713
|
+
var setKeys = Object.keys(sealed);
|
|
714
|
+
if (setKeys.length === 0) {
|
|
715
|
+
throw new Error("update changes object is empty");
|
|
716
|
+
}
|
|
717
|
+
setKeys.forEach(_validateField);
|
|
718
|
+
var selfUpd = this;
|
|
719
|
+
setKeys.forEach(function (k) { selfUpd._assertColumnMember(k, "update"); });
|
|
720
|
+
var setClause = setKeys.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
|
|
721
|
+
var setValues = setKeys.map(function (k) { return sealed[k]; });
|
|
722
|
+
var updStmt = this._db.prepare("UPDATE " + qt + " SET " + setClause + " WHERE _id = ?");
|
|
723
|
+
var info = updStmt.run.apply(updStmt, setValues.concat([rowId]));
|
|
724
|
+
changed += (info && info.changes) || 0;
|
|
725
|
+
}
|
|
726
|
+
return changed;
|
|
727
|
+
}
|
|
728
|
+
|
|
669
729
|
deleteOne() {
|
|
670
730
|
return this._delete(true) > 0;
|
|
671
731
|
}
|
package/lib/db.js
CHANGED
|
@@ -295,11 +295,21 @@ var FRAMEWORK_SCHEMA = [
|
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
297
|
// Per-row crypto-erasure key registry — per-row keys.
|
|
298
|
-
// Each entry holds
|
|
299
|
-
// rowId)
|
|
300
|
-
//
|
|
298
|
+
// Each entry holds the AAD-sealed random row-secret keyed by
|
|
299
|
+
// (tableName, rowId); the row-scoped K_row is derived from it.
|
|
300
|
+
// b.subject.eraseHard / b.retention destroy the entry, leaving WAL /
|
|
301
|
+
// replica residuals undecryptable. wrappedKey is registered as an
|
|
302
|
+
// AAD-bound sealed field (aad:true, rowIdField:"rowId") so a vault
|
|
303
|
+
// keypair rotation auto-reseals it old-root -> new-root via
|
|
304
|
+
// rotate._rotateColumn — without this a rotation would orphan every
|
|
305
|
+
// wrapped secret and brick every keyed row.
|
|
301
306
|
name: "_blamejs_per_row_keys",
|
|
302
307
|
columns: {
|
|
308
|
+
// _id is the rotation pipeline's keyset-pagination + UPDATE key
|
|
309
|
+
// (rotate._rotateColumn SELECTs _id and orders by it); the natural
|
|
310
|
+
// identity stays the composite (tableName, rowId). materialize
|
|
311
|
+
// populates _id with a fresh token.
|
|
312
|
+
_id: "TEXT",
|
|
303
313
|
tableName: "TEXT NOT NULL",
|
|
304
314
|
rowId: "TEXT NOT NULL",
|
|
305
315
|
wrappedKey: "BLOB NOT NULL",
|
|
@@ -307,6 +317,10 @@ var FRAMEWORK_SCHEMA = [
|
|
|
307
317
|
},
|
|
308
318
|
primaryKey: ["tableName", "rowId"],
|
|
309
319
|
indexes: [],
|
|
320
|
+
sealedFields: ["wrappedKey"],
|
|
321
|
+
aad: true,
|
|
322
|
+
rowIdField: "rowId",
|
|
323
|
+
schemaVersion: "1",
|
|
310
324
|
},
|
|
311
325
|
{
|
|
312
326
|
// Operator-declared WORM (write-once-read-many) registry. Each
|
|
@@ -309,25 +309,33 @@ function dbStore(opts) {
|
|
|
309
309
|
});
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
// Derive a per-vault HMAC secret for fingerprint sealing.
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
312
|
+
// Derive a per-vault HMAC secret for fingerprint sealing. Seed off the
|
|
313
|
+
// SEALED per-deployment MAC key (vault.getDerivedHashMacKey, sealed at
|
|
314
|
+
// rest under the vault root) — NOT getDerivedHashSalt, which sits in
|
|
315
|
+
// PLAINTEXT on disk. With the salt-derived seed an attacker who read
|
|
316
|
+
// the disk could recompute the HMAC key and forge / correlate request
|
|
317
|
+
// fingerprints; the sealed MAC key closes that (the vault root is the
|
|
318
|
+
// trust root, so the secret is unrecoverable without it). Lazy: only
|
|
319
|
+
// derived when fpSealOn is enabled AND the vault is ready, so test
|
|
320
|
+
// fixtures that haven't initialized the vault still construct a
|
|
321
|
+
// dbStore (the fingerprint then falls back to bare sha3-256 with a
|
|
322
|
+
// single audit warning).
|
|
318
323
|
var fpHmacSecret = null;
|
|
319
324
|
if (fpSealOn) {
|
|
320
325
|
try {
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
326
|
+
// The MAC key is per-deployment + sealed, so the same dbStore
|
|
327
|
+
// instance across hosts converges on the same HMAC key while disk
|
|
328
|
+
// access alone cannot recover it. The table name domain-separates
|
|
329
|
+
// the fingerprint secret from other consumers of the MAC key.
|
|
330
|
+
var fpDeriveInput = Buffer.concat([
|
|
331
|
+
vault.getDerivedHashMacKey(),
|
|
332
|
+
Buffer.from("idempotency.fingerprint:" + tableNameRaw, "utf8"),
|
|
333
|
+
]);
|
|
334
|
+
fpHmacSecret = bCrypto.kdf(fpDeriveInput, C.BYTES.bytes(32));
|
|
327
335
|
} catch (_fpErr) {
|
|
328
336
|
_emitAudit("idempotency.fingerprint_seal_skipped_no_vault",
|
|
329
337
|
{ tableName: tableNameRaw,
|
|
330
|
-
reason: "vault.
|
|
338
|
+
reason: "vault.getDerivedHashMacKey() unavailable; fingerprint falls back to plain sha3-256" },
|
|
331
339
|
"warning");
|
|
332
340
|
fpHmacSecret = null;
|
|
333
341
|
}
|
package/lib/retention.js
CHANGED
|
@@ -274,8 +274,18 @@ function create(opts) {
|
|
|
274
274
|
values.push(row._id);
|
|
275
275
|
var upd2 = db.prepare("UPDATE \"" + table + "\" SET " + setClauses.join(", ") + " WHERE _id = ?");
|
|
276
276
|
upd2.run.apply(upd2, values);
|
|
277
|
+
// Per-row-key tables (declarePerRowKey): NULLing the sealed columns
|
|
278
|
+
// is not enough — WAL / replica residuals keep the old K_row cells.
|
|
279
|
+
// Destroy the row's wrapped secret so K_row is unrecoverable and the
|
|
280
|
+
// residual ciphertext reads as absent (crypto-shred, GDPR Art. 17).
|
|
281
|
+
// rowId is row._id, the same identity materialize / eraseHard use.
|
|
282
|
+
var perRowKeysDestroyed = 0;
|
|
283
|
+
if (cryptoField.hasPerRowKey(table)) {
|
|
284
|
+
var dr = cryptoField.destroyPerRowKey(table, row._id, db);
|
|
285
|
+
perRowKeysDestroyed = (dr && dr.destroyed) || 0;
|
|
286
|
+
}
|
|
277
287
|
void erased;
|
|
278
|
-
return { erased: 1, sealedFieldCount: sealedFields.length };
|
|
288
|
+
return { erased: 1, sealedFieldCount: sealedFields.length, perRowKeysDestroyed: perRowKeysDestroyed };
|
|
279
289
|
}
|
|
280
290
|
|
|
281
291
|
function _cascade(rule, rowId, dryRun) {
|
package/lib/vault-aad.js
CHANGED
|
@@ -304,6 +304,12 @@ module.exports = {
|
|
|
304
304
|
isAadSealed: isAadSealed,
|
|
305
305
|
buildColumnAad: buildColumnAad,
|
|
306
306
|
buildContextAad: buildContextAad,
|
|
307
|
+
// canonicalizeAad — the length-prefixed, sorted-keys AAD-bytes
|
|
308
|
+
// encoder. Exported (internal) so a sibling primitive that runs its
|
|
309
|
+
// own AEAD (crypto-field's per-row K_row cells) threads byte-identical
|
|
310
|
+
// AAD into encryptPacked/decryptPacked as this module does for its
|
|
311
|
+
// own seal/unseal — one canonical encoder, no drift.
|
|
312
|
+
canonicalizeAad: _canonicalize,
|
|
307
313
|
AAD_PREFIX: AAD_PREFIX,
|
|
308
314
|
AAD_VERSION: AAD_VERSION,
|
|
309
315
|
VaultAadError: VaultAadError,
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:a667e112-0ef6-4c72-b5b8-839233b6e4a6",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-06T17:19:53.413Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.14.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.14.25",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.14.
|
|
25
|
+
"version": "0.14.25",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.14.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.14.25",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.14.
|
|
57
|
+
"ref": "@blamejs/core@0.14.25",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|