@blamejs/core 0.14.22 → 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.
@@ -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 —
@@ -132,15 +141,84 @@ var schemas = Object.create(null);
132
141
  //
133
142
  // { tableName: { columnName: "eu" | "us" | "global" | <tag> } }
134
143
  var columnResidency = Object.create(null);
144
+ // Per-ROW residency registry — table → { residencyColumn, allowedTags }.
145
+ // The row-level sibling of columnResidency: one plaintext column on each
146
+ // row carries that row's residency tag; write gates refuse a tagged row
147
+ // landing on an incompatible backend.
148
+ var perRowResidency = Object.create(null);
135
149
 
136
150
  // Per-row key declaration registry. For tables that opt
137
- // into per-row keying, b.subject.eraseHard deletes the wrapped K_row
138
- // from _blamejs_per_row_keys, leaving WAL/replica residual ciphertext
139
- // 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.
140
154
  //
141
- // { tableName: { keySize, info, residencyTag } }
155
+ // { tableName: { keySize, info } }
142
156
  var perRowKeyTables = Object.create(null);
143
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
+
144
222
  /**
145
223
  * @primitive b.cryptoField.registerTable
146
224
  * @signature b.cryptoField.registerTable(name, opts)
@@ -636,7 +714,7 @@ function clearRateCapForTest() {
636
714
 
637
715
  /**
638
716
  * @primitive b.cryptoField.sealRow
639
- * @signature b.cryptoField.sealRow(table, row)
717
+ * @signature b.cryptoField.sealRow(table, row, opts?)
640
718
  * @since 0.4.0
641
719
  * @compliance hipaa, gdpr, pci-dss
642
720
  * @related b.cryptoField.unsealRow, b.cryptoField.eraseRow, b.vault.seal
@@ -649,6 +727,20 @@ function clearRateCapForTest() {
649
727
  * computed BEFORE sealing the source so the indexed lookup column
650
728
  * captures the plaintext digest.
651
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
+ *
652
744
  * @example
653
745
  * b.cryptoField.registerTable("patients", {
654
746
  * sealedFields: ["ssn"],
@@ -660,11 +752,29 @@ function clearRateCapForTest() {
660
752
  * typeof sealed.ssnHash; // → "string"
661
753
  * row.ssn; // → "123-45-6789" (input untouched)
662
754
  */
663
- function sealRow(table, row) {
755
+ function sealRow(table, row, opts) {
664
756
  if (!row) return row;
665
757
  var s = schemas[table];
666
758
  if (!s) return row;
667
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;
668
778
 
669
779
  // Compute derived hashes from plaintext source values BEFORE sealing those
670
780
  // sources. If a source value arrives already sealed (e.g. from an internal
@@ -704,25 +814,39 @@ function sealRow(table, row) {
704
814
  }
705
815
  }
706
816
 
707
- // Seal fields. Plain mode: vault.seal (idempotent — already-sealed
708
- // values pass through). AAD mode: vault.aad.seal binds the AEAD tag
709
- // to (table, rowId, column, schemaVersion) — cross-row copy of a
710
- // 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).
711
825
  for (var i = 0; i < s.sealedFields.length; i++) {
712
826
  var field = s.sealedFields[i];
713
- if (out[field] !== undefined && out[field] !== null) {
714
- if (s.aad) {
715
- // Idempotent: already-AAD-sealed values pass through unchanged.
716
- if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
717
- continue;
718
- }
719
- out[field] = vaultAad.seal(String(out[field]),
720
- _aadParts(s, table, field, out));
721
- } else {
722
- // allow:seal-without-aad plain-mode legacy table; operator
723
- // opts into AAD via registerTable({aad:true})
724
- 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;
725
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]));
726
850
  }
727
851
  }
728
852
 
@@ -744,7 +868,7 @@ function _aadParts(schema, table, column, row) {
744
868
 
745
869
  /**
746
870
  * @primitive b.cryptoField.unsealRow
747
- * @signature b.cryptoField.unsealRow(table, row, actor?)
871
+ * @signature b.cryptoField.unsealRow(table, row, actor?, dbHandle?)
748
872
  * @since 0.4.0
749
873
  * @compliance hipaa, gdpr, pci-dss
750
874
  * @related b.cryptoField.sealRow, b.vault.unseal, b.cryptoField.configureUnsealRateCap
@@ -758,6 +882,18 @@ function _aadParts(schema, table, column, row) {
758
882
  * so downstream code sees "no value" instead of crashing the request.
759
883
  * The input row is never mutated.
760
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
+ *
761
897
  * When an unseal-failure rate cap is configured via
762
898
  * `configureUnsealRateCap` (default off), repeated forged-ciphertext
763
899
  * failures for a single `(actor, table, column)` tuple trip a cooldown:
@@ -774,7 +910,7 @@ function _aadParts(schema, table, column, row) {
774
910
  * var clear = b.cryptoField.unsealRow("patients", sealed);
775
911
  * clear.ssn; // → "123-45-6789"
776
912
  */
777
- function unsealRow(table, row, actor) {
913
+ function unsealRow(table, row, actor, dbHandle) {
778
914
  if (!row) return row;
779
915
  var s = schemas[table];
780
916
  if (!s || s.sealedFields.length === 0) return row;
@@ -782,15 +918,61 @@ function unsealRow(table, row, actor) {
782
918
  var capActor = (actor === undefined || actor === null || String(actor).length === 0)
783
919
  ? "_anon" : String(actor);
784
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
+
785
963
  for (var i = 0; i < s.sealedFields.length; i++) {
786
964
  var field = s.sealedFields[i];
787
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");
788
970
  // Opt-in cap: if this (actor, table, column) tuple is in cooldown
789
971
  // from prior forged-ciphertext failures, refuse before touching the
790
972
  // decryption oracle again (CWE-307). No-op when the cap is disabled.
791
973
  if (_rateInCooldown(capActor, table, field)) {
792
974
  _emitRateAudit({
793
- table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
975
+ table: table, field: field, actor: capActor, shape: shape,
794
976
  threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
795
977
  });
796
978
  throw new CryptoFieldRateError("crypto-field/unseal-rate-exceeded",
@@ -802,7 +984,22 @@ function unsealRow(table, row, actor) {
802
984
  // Auto-detect the envelope shape so an AAD-bound table that
803
985
  // contains pre-migration plain-vault rows still reads. Read-
804
986
  // side migration is lazy; the next sealRow re-emits AAD-bound.
805
- 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])) {
806
1003
  unsealed = vaultAad.unseal(out[field],
807
1004
  _aadParts(s, table, field, out));
808
1005
  } else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
@@ -829,7 +1026,7 @@ function unsealRow(table, row, actor) {
829
1026
  table: table,
830
1027
  field: field,
831
1028
  rowId: out[s.rowIdField] || out._id || null,
832
- shape: s.aad ? "aad" : "plain",
1029
+ shape: shape,
833
1030
  reason: (e && e.message) || String(e),
834
1031
  },
835
1032
  });
@@ -840,7 +1037,7 @@ function unsealRow(table, row, actor) {
840
1037
  // transition. No-op when the cap is disabled.
841
1038
  if (_rateNoteFailure(capActor, table, field)) {
842
1039
  _emitRateAudit({
843
- table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
1040
+ table: table, field: field, actor: capActor, shape: shape,
844
1041
  threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
845
1042
  });
846
1043
  }
@@ -1105,7 +1302,7 @@ function getColumnResidency(table) {
1105
1302
  * @signature b.cryptoField.assertColumnResidency(table, row, args)
1106
1303
  * @since 0.7.27
1107
1304
  * @compliance gdpr
1108
- * @related b.cryptoField.declareColumnResidency
1305
+ * @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowResidency
1109
1306
  *
1110
1307
  * Storage-write gate. Storage backends call this with the proposed
1111
1308
  * row before the SQL hits the wire; refusal under regulated postures
@@ -1161,6 +1358,104 @@ function assertColumnResidency(table, row, args) {
1161
1358
  return null;
1162
1359
  }
1163
1360
 
1361
+ /**
1362
+ * @primitive b.cryptoField.declarePerRowResidency
1363
+ * @signature b.cryptoField.declarePerRowResidency(table, opts)
1364
+ * @since 0.14.24
1365
+ * @compliance gdpr
1366
+ * @related b.cryptoField.getPerRowResidency, b.cryptoField.declareColumnResidency
1367
+ *
1368
+ * Declares per-ROW data residency for `table`: one plaintext column on
1369
+ * each row carries that row's residency tag, and the write gates
1370
+ * refuse a tagged row landing on an incompatible backend. The sibling
1371
+ * of `declareColumnResidency` — columns answer "which fields are
1372
+ * region-bound", rows answer "which region does THIS record belong
1373
+ * to" (an EU user's row next to a US user's row in the same table).
1374
+ * Local writes (`b.db.from(...).insertOne` / `.update`) enforce the
1375
+ * tag against the deployment's `dataResidency` region set under
1376
+ * cross-border regulated postures; external writes
1377
+ * (`b.externalDb.query`) take the tag per call via
1378
+ * `opts.rowResidencyTag` because raw SQL carries no row object. Rows
1379
+ * tagged "global" or "unrestricted" pass any backend. Throws on bad
1380
+ * input (config-time fail-loud).
1381
+ *
1382
+ * @opts
1383
+ * residencyColumn: string, // plaintext column carrying the row's tag
1384
+ * allowedTags: string[], // whitelist of valid tag values ("eu", "us", "global", region names)
1385
+ *
1386
+ * @example
1387
+ * b.cryptoField.declarePerRowResidency("users", {
1388
+ * residencyColumn: "dataRegion",
1389
+ * allowedTags: ["eu-west-1", "us-east-1", "global"],
1390
+ * });
1391
+ * var spec = b.cryptoField.getPerRowResidency("users");
1392
+ * spec.residencyColumn; // → "dataRegion"
1393
+ */
1394
+ function declarePerRowResidency(table, opts) {
1395
+ validateOpts.requireNonEmptyString(table, "declarePerRowResidency: table",
1396
+ CryptoFieldError, "crypto-field/per-row-residency-table-empty");
1397
+ validateOpts.requireObject(opts, "declarePerRowResidency",
1398
+ CryptoFieldError, "crypto-field/per-row-residency-opts-not-object");
1399
+ validateOpts(opts, ["residencyColumn", "allowedTags"], "cryptoField.declarePerRowResidency");
1400
+ validateOpts.requireNonEmptyString(opts.residencyColumn,
1401
+ "declarePerRowResidency: opts.residencyColumn",
1402
+ CryptoFieldError, "crypto-field/per-row-residency-column-invalid");
1403
+ if (!Array.isArray(opts.allowedTags) || opts.allowedTags.length === 0) {
1404
+ throw new CryptoFieldError("crypto-field/per-row-residency-tags-invalid",
1405
+ "declarePerRowResidency: opts.allowedTags must be a non-empty array of tag strings");
1406
+ }
1407
+ validateOpts.optionalNonEmptyStringArray(opts.allowedTags,
1408
+ "declarePerRowResidency: opts.allowedTags",
1409
+ CryptoFieldError, "crypto-field/per-row-residency-tag-empty");
1410
+ // The residency tag column MUST stay plaintext — the write gate reads
1411
+ // it on every INSERT / UPDATE before sealRow, and reads return it
1412
+ // verbatim. A sealed residency column would be ciphertext the gate
1413
+ // can't compare and reads can't surface. Refuse the misconfiguration
1414
+ // at declaration time when the table's sealed-field set is already
1415
+ // known (registration order permitting).
1416
+ var sealed = getSealedFields(table);
1417
+ if (Array.isArray(sealed) && sealed.indexOf(opts.residencyColumn) !== -1) {
1418
+ throw new CryptoFieldError("crypto-field/per-row-residency-sealed-conflict",
1419
+ "declarePerRowResidency: residencyColumn '" + opts.residencyColumn +
1420
+ "' is a sealed field on table '" + table + "' — the residency tag must " +
1421
+ "stay plaintext so the write gate can read it. Choose a non-sealed column");
1422
+ }
1423
+ perRowResidency[table] = {
1424
+ residencyColumn: opts.residencyColumn,
1425
+ allowedTags: opts.allowedTags.slice(),
1426
+ };
1427
+ return {
1428
+ table: table,
1429
+ residencyColumn: opts.residencyColumn,
1430
+ allowedTags: opts.allowedTags.slice(),
1431
+ };
1432
+ }
1433
+
1434
+ /**
1435
+ * @primitive b.cryptoField.getPerRowResidency
1436
+ * @signature b.cryptoField.getPerRowResidency(table)
1437
+ * @since 0.14.24
1438
+ * @related b.cryptoField.declarePerRowResidency
1439
+ *
1440
+ * Returns the per-row residency spec declared for `table`
1441
+ * (`{ residencyColumn, allowedTags }`), or null when the table has no
1442
+ * declaration. Read-only — storage backends call this at the write
1443
+ * boundary to decide whether the row-residency gate applies.
1444
+ *
1445
+ * @example
1446
+ * b.cryptoField.declarePerRowResidency("users", {
1447
+ * residencyColumn: "dataRegion",
1448
+ * allowedTags: ["eu-west-1", "global"],
1449
+ * });
1450
+ * b.cryptoField.getPerRowResidency("users").allowedTags; // → ["eu-west-1", "global"]
1451
+ * b.cryptoField.getPerRowResidency("unknown"); // → null
1452
+ */
1453
+ function getPerRowResidency(table) {
1454
+ var spec = perRowResidency[table];
1455
+ if (!spec) return null;
1456
+ return { residencyColumn: spec.residencyColumn, allowedTags: spec.allowedTags.slice() };
1457
+ }
1458
+
1164
1459
  /**
1165
1460
  * @primitive b.cryptoField.declarePerRowKey
1166
1461
  * @signature b.cryptoField.declarePerRowKey(table, opts)
@@ -1169,13 +1464,16 @@ function assertColumnResidency(table, row, args) {
1169
1464
  * @related b.cryptoField.materializePerRowKey, b.cryptoField.destroyPerRowKey, b.subject.eraseHard
1170
1465
  *
1171
1466
  * Opts a table into per-row keying (K_row crypto-shred substrate).
1172
- * After registration, every INSERT generates a fresh K_row and stores
1173
- * it sealed in `_blamejs_per_row_keys (table, rowId, wrapped)`. AAD on
1174
- * the K_row binds (table, rowId, info-label) — copy-row attacks fail
1175
- * Poly1305 verification. `b.subject.eraseHard(subjectId)` deletes the
1176
- * per-row key entries for the subject's rows; WAL / replica residual
1177
- * ciphertext becomes mathematically undecryptable because K_row is
1178
- * 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
1179
1477
  * fail-loud).
1180
1478
  *
1181
1479
  * @opts
@@ -1237,15 +1535,22 @@ function hasPerRowKey(table) {
1237
1535
  * @compliance gdpr, hipaa
1238
1536
  * @related b.cryptoField.declarePerRowKey, b.cryptoField.destroyPerRowKey
1239
1537
  *
1240
- * Derive-and-store: called by the storage backend on INSERT. Generates
1241
- * `K_row = SHAKE256(vaultSalt + table + rowId + info, keySize)`, seals
1242
- * it via `vault.seal`, and inserts into `_blamejs_per_row_keys`.
1243
- * Returns the unwrapped K_row Buffer for the caller to use to encrypt
1244
- * sealed columns under the row-scoped key. Idempotent on UPSERT — if
1245
- * a K_row already exists for (table, rowId), returns the unwrapped
1246
- * existing key. The AAD-bound envelope rejects copy-row attacks: a
1247
- * wrapped K_row pasted under a different rowId fails Poly1305
1248
- * 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).
1249
1554
  *
1250
1555
  * @example
1251
1556
  * b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
@@ -1265,30 +1570,55 @@ function materializePerRowKey(table, rowId, dbHandle) {
1265
1570
  throw new CryptoFieldError("crypto-field/materialize-per-row-key-no-db",
1266
1571
  "materializePerRowKey: dbHandle (b.db) is required");
1267
1572
  }
1268
- // Existing key? Re-use to support idempotent UPSERTs.
1573
+ var ridStr = String(rowId);
1574
+ // Existing secret? Unwrap + re-derive to support idempotent UPSERTs.
1269
1575
  var existing = dbHandle.prepare(
1270
- 'SELECT wrappedKey FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
1271
- ).get(table, rowId);
1576
+ 'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1577
+ ).get(table, ridStr);
1272
1578
  if (existing) {
1273
- return vault.unseal(existing.wrappedKey);
1579
+ return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
1274
1580
  }
1275
- // Derive K_row from the table-level vault key salt + rowId via
1276
- // SHAKE256 expand. This is a one-shot derivation (HKDF-shaped) that
1277
- // matches the framework's PQC-first kdf no HMAC-SHA3 dependency.
1278
- var saltHex = vault.getDerivedHashSalt().toString("hex");
1279
- var ikm = Buffer.from(saltHex + ":" + table + ":" + rowId + ":" + spec.info, "utf8");
1280
- var kRow = kdf(ikm, spec.keySize);
1281
- // allow:seal-without-aad per-row K_row wrap; row identity is the
1282
- // K_row KDF input, not the AEAD AAD on the wrap. Copy-attacks fail
1283
- // because the wrapped K_row only decrypts data sealed under it.
1284
- 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.
1285
1594
  dbHandle.prepare(
1286
- 'INSERT INTO "_blamejs_per_row_keys" (tableName, rowId, wrappedKey, createdAt) ' +
1287
- 'VALUES (?, ?, ?, ?)'
1288
- ).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());
1289
1598
  return kRow;
1290
1599
  }
1291
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
+
1292
1622
  /**
1293
1623
  * @primitive b.cryptoField.destroyPerRowKey
1294
1624
  * @signature b.cryptoField.destroyPerRowKey(table, rowId, dbHandle)
@@ -1296,13 +1626,14 @@ function materializePerRowKey(table, rowId, dbHandle) {
1296
1626
  * @compliance gdpr, hipaa
1297
1627
  * @related b.cryptoField.materializePerRowKey, b.subject.eraseHard
1298
1628
  *
1299
- * Crypto-shred: drops the per-row K_row entry from
1300
- * `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` for each
1301
- * 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
1302
1632
  * `{ destroyed: <rowsAffected> }`. After destruction, any WAL /
1303
1633
  * replica residual ciphertext for the row is mathematically
1304
- * undecryptable — even with the vault root key — because K_row is
1305
- * 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
1306
1637
  * registered for per-row keying.
1307
1638
  *
1308
1639
  * @example
@@ -1323,8 +1654,8 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1323
1654
  "destroyPerRowKey: dbHandle (b.db) is required");
1324
1655
  }
1325
1656
  var result = dbHandle.prepare(
1326
- 'DELETE FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
1327
- ).run(table, rowId);
1657
+ 'DELETE FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
1658
+ ).run(table, String(rowId));
1328
1659
  return { destroyed: (result && result.changes) || 0 };
1329
1660
  }
1330
1661
 
@@ -1336,10 +1667,10 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1336
1667
  * @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowKey
1337
1668
  *
1338
1669
  * Test-only helper. Drops every entry from the per-column residency
1339
- * registry AND the per-row-key registry so a test fixture can
1340
- * re-declare both between cases. Operator code never calls this —
1341
- * production declarations come from `b.db.init({ schema })` once at
1342
- * boot.
1670
+ * registry, the per-row residency registry, and the per-row-key
1671
+ * registry so a test fixture can re-declare them between cases.
1672
+ * Operator code never calls this production declarations come from
1673
+ * `b.db.init({ schema })` once at boot.
1343
1674
  *
1344
1675
  * @example
1345
1676
  * b.cryptoField.declareColumnResidency("users", {
@@ -1351,6 +1682,7 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1351
1682
  function clearResidencyForTest() {
1352
1683
  for (var t in columnResidency) delete columnResidency[t];
1353
1684
  for (var u in perRowKeyTables) delete perRowKeyTables[u];
1685
+ for (var v in perRowResidency) delete perRowResidency[v];
1354
1686
  }
1355
1687
 
1356
1688
  module.exports = {
@@ -1359,6 +1691,7 @@ module.exports = {
1359
1691
  getSealedFields: getSealedFields,
1360
1692
  sealRow: sealRow,
1361
1693
  unsealRow: unsealRow,
1694
+ isRowSealed: isRowSealed,
1362
1695
  configureUnsealRateCap: configureUnsealRateCap,
1363
1696
  clearRateCapForTest: clearRateCapForTest,
1364
1697
  CryptoFieldRateError: CryptoFieldRateError,
@@ -1382,6 +1715,8 @@ module.exports = {
1382
1715
  declareColumnResidency: declareColumnResidency,
1383
1716
  getColumnResidency: getColumnResidency,
1384
1717
  assertColumnResidency: assertColumnResidency,
1718
+ declarePerRowResidency: declarePerRowResidency,
1719
+ getPerRowResidency: getPerRowResidency,
1385
1720
  declarePerRowKey: declarePerRowKey,
1386
1721
  hasPerRowKey: hasPerRowKey,
1387
1722
  materializePerRowKey: materializePerRowKey,