@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 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
  };
@@ -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, stored sealed in
19
- * `_blamejs_per_row_keys`. AAD on the K_row binds (table, rowId,
20
- * info-label) copying a wrapped K_row from one row to another
21
- * fails Poly1305 verification, so a DB-write attacker cannot move
22
- * ciphertext between rows to bypass row-scoped erasure. This is the
23
- * crypto-shred substrate for `b.subject.eraseHard`: deleting the
24
- * K_row entry leaves WAL / replica residual ciphertext mathematically
25
- * undecryptable.
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 deletes the wrapped K_row
143
- // from _blamejs_per_row_keys, leaving WAL/replica residual ciphertext
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, residencyTag } }
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. Plain mode: vault.seal (idempotent — already-sealed
713
- // values pass through). AAD mode: vault.aad.seal binds the AEAD tag
714
- // to (table, rowId, column, schemaVersion) — cross-row copy of a
715
- // ciphertext fails Poly1305 on read.
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] !== undefined && out[field] !== null) {
719
- if (s.aad) {
720
- // Idempotent: already-AAD-sealed values pass through unchanged.
721
- if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
722
- continue;
723
- }
724
- out[field] = vaultAad.seal(String(out[field]),
725
- _aadParts(s, table, field, out));
726
- } else {
727
- // allow:seal-without-aad plain-mode legacy table; operator
728
- // opts into AAD via registerTable({aad:true})
729
- out[field] = vault.seal(String(out[field]));
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: s.aad ? "aad" : "plain",
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" && vaultAad.isAadSealed(out[field])) {
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: s.aad ? "aad" : "plain",
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: s.aad ? "aad" : "plain",
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 K_row and stores
1276
- * it sealed in `_blamejs_per_row_keys (table, rowId, wrapped)`. AAD on
1277
- * the K_row binds (table, rowId, info-label) — copy-row attacks fail
1278
- * Poly1305 verification. `b.subject.eraseHard(subjectId)` deletes the
1279
- * per-row key entries for the subject's rows; WAL / replica residual
1280
- * ciphertext becomes mathematically undecryptable because K_row is
1281
- * gone everywhere it ever lived. Throws on bad input (config-time
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. Generates
1344
- * `K_row = SHAKE256(vaultSalt + table + rowId + info, keySize)`, seals
1345
- * it via `vault.seal`, and inserts into `_blamejs_per_row_keys`.
1346
- * Returns the unwrapped K_row Buffer for the caller to use to encrypt
1347
- * sealed columns under the row-scoped key. Idempotent on UPSERT — if
1348
- * a K_row already exists for (table, rowId), returns the unwrapped
1349
- * existing key. The AAD-bound envelope rejects copy-row attacks: a
1350
- * wrapped K_row pasted under a different rowId fails Poly1305
1351
- * verification at unseal time.
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
- // Existing key? Re-use to support idempotent UPSERTs.
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 "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
1374
- ).get(table, rowId);
1576
+ 'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1577
+ ).get(table, ridStr);
1375
1578
  if (existing) {
1376
- return vault.unseal(existing.wrappedKey);
1579
+ return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
1377
1580
  }
1378
- // Derive K_row from the table-level vault key salt + rowId via
1379
- // SHAKE256 expand. This is a one-shot derivation (HKDF-shaped) that
1380
- // matches the framework's PQC-first kdf no HMAC-SHA3 dependency.
1381
- var saltHex = vault.getDerivedHashSalt().toString("hex");
1382
- var ikm = Buffer.from(saltHex + ":" + table + ":" + rowId + ":" + spec.info, "utf8");
1383
- var kRow = kdf(ikm, spec.keySize);
1384
- // allow:seal-without-aad per-row K_row wrap; row identity is the
1385
- // K_row KDF input, not the AEAD AAD on the wrap. Copy-attacks fail
1386
- // because the wrapped K_row only decrypts data sealed under it.
1387
- var sealed = vault.seal(kRow.toString("base64"));
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 "_blamejs_per_row_keys" (tableName, rowId, wrappedKey, createdAt) ' +
1390
- 'VALUES (?, ?, ?, ?)'
1391
- ).run(table, rowId, sealed, Date.now());
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 per-row K_row entry from
1403
- * `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` for each
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 K_row is
1408
- * gone everywhere it ever lived. No-op when the table is not
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 "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
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
- return row ? cryptoField.unsealRow(this._cryptoFieldKey(), row) : null;
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
- var sealed = cryptoField.sealRow(this._cryptoFieldKey(), withId);
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 sealed = cryptoField.sealRow(this._cryptoFieldKey(), changes);
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 a sealed wrapped K_row keyed by (table,
299
- // rowId). b.subject.eraseHard deletes the entry, leaving WAL /
300
- // replica residuals undecryptable.
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
- // The vault root key is the trust root; without it the secret is
314
- // unrecoverable. Lazy: only derived when fpSealOn is enabled AND the
315
- // vault is ready, so test fixtures that haven't initialized the
316
- // vault still construct a dbStore (the fingerprint then falls back
317
- // to bare sha3-256 with a single audit warning).
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
- // Use vault.aad.buildContextAad as a stable derivation input;
322
- // the derivedHashSalt is per-deployment so the same dbStore
323
- // instance across hosts converges on the same HMAC key.
324
- var fpDeriveInput = "idempotency.fingerprint:" + tableNameRaw + ":" +
325
- vault.getDerivedHashSalt().toString("hex");
326
- fpHmacSecret = bCrypto.kdf(Buffer.from(fpDeriveInput, "utf8"), C.BYTES.bytes(32));
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.getDerivedHashSalt() unavailable; fingerprint falls back to plain sha3-256" },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.24",
3
+ "version": "0.14.25",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:0697fb13-faa3-47c7-8ac0-7a29c980f002",
5
+ "serialNumber": "urn:uuid:a667e112-0ef6-4c72-b5b8-839233b6e4a6",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-06T15:36:19.660Z",
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.24",
22
+ "bom-ref": "@blamejs/core@0.14.25",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.24",
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.24",
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.24",
57
+ "ref": "@blamejs/core@0.14.25",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]