@blamejs/core 0.14.24 → 0.14.26

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 —
@@ -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,