@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.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/lib/compliance.js +37 -0
- package/lib/constants.js +11 -0
- package/lib/crypto-field.js +418 -83
- package/lib/db-query.js +188 -5
- package/lib/db.js +17 -3
- package/lib/external-db-migrate.js +19 -7
- package/lib/external-db.js +508 -20
- package/lib/framework-error.js +6 -0
- package/lib/mail-auth.js +236 -0
- package/lib/mail-dkim.js +1 -0
- package/lib/mail-server-mx.js +276 -7
- package/lib/mail.js +8 -4
- package/lib/middleware/idempotency-key.js +21 -13
- package/lib/retention.js +11 -1
- package/lib/vault-aad.js +6 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/crypto-field.js
CHANGED
|
@@ -15,14 +15,23 @@
|
|
|
15
15
|
* random nonce, so two seals of the same plaintext never collide.
|
|
16
16
|
*
|
|
17
17
|
* Per-row key (K_row) derivation is opt-in via `declarePerRowKey`.
|
|
18
|
-
* Tables that opt in get a fresh K_row per INSERT
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* K_row
|
|
25
|
-
*
|
|
18
|
+
* Tables that opt in get a fresh K_row per INSERT: the framework
|
|
19
|
+
* generates a 32-byte CSPRNG row-secret, derives
|
|
20
|
+
* `K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":"
|
|
21
|
+
* || info)`, and stores the SECRET (never K_row) AAD-sealed in
|
|
22
|
+
* `_blamejs_per_row_keys.wrappedKey`. Because the secret is random —
|
|
23
|
+
* not a function of any on-disk salt — an attacker with full disk
|
|
24
|
+
* access cannot re-derive K_row once the wrapped secret is gone. The
|
|
25
|
+
* AAD on the wrap binds (table, rowId, column, schemaVersion):
|
|
26
|
+
* copying a wrapped secret from one row to another fails Poly1305
|
|
27
|
+
* verification, so a DB-write attacker cannot move it between rows to
|
|
28
|
+
* bypass row-scoped erasure. Sealed columns on a keyed row carry the
|
|
29
|
+
* `vault.row:` prefix and are XChaCha20-Poly1305 ciphertext under
|
|
30
|
+
* K_row, AEAD-bound to the same (table, rowId, column) tuple. This is
|
|
31
|
+
* the crypto-shred substrate for `b.subject.eraseHard` /
|
|
32
|
+
* `b.retention`: destroying the wrapped secret leaves WAL / replica
|
|
33
|
+
* residual ciphertext mathematically undecryptable — even with the
|
|
34
|
+
* vault root key — because K_row is gone everywhere it ever lived.
|
|
26
35
|
*
|
|
27
36
|
* Derived hashes (`derivedHashes`) provide indexed lookup for sealed
|
|
28
37
|
* columns: a normalized SHA3 of the plaintext, salted by the vault's
|
|
@@ -48,8 +57,8 @@ var vaultAad = require("./vault-aad");
|
|
|
48
57
|
var validateOpts = require("./validate-opts");
|
|
49
58
|
var numericBounds = require("./numeric-bounds");
|
|
50
59
|
var { defineClass } = require("./framework-error");
|
|
51
|
-
var { sha3Hash, kdf } = require("./crypto");
|
|
52
|
-
var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
|
|
60
|
+
var { sha3Hash, kdf, generateBytes, encryptPacked, decryptPacked, generateToken } = require("./crypto");
|
|
61
|
+
var { HASH_PREFIX, VAULT_PREFIX, ROW_PREFIX, TIME } = require("./constants");
|
|
53
62
|
|
|
54
63
|
// Typed refusal raised when a (actor, table, column) tuple exceeds the
|
|
55
64
|
// opt-in unseal-failure rate cap and is in cooldown. alwaysPermanent —
|
|
@@ -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
|
|
138
|
-
// from _blamejs_per_row_keys, leaving WAL/replica
|
|
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
|
|
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.
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
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]
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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:
|
|
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" &&
|
|
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:
|
|
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:
|
|
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
|
|
1173
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
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
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
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
|
-
|
|
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 "
|
|
1271
|
-
).get(table,
|
|
1576
|
+
'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
|
|
1577
|
+
).get(table, ridStr);
|
|
1272
1578
|
if (existing) {
|
|
1273
|
-
return
|
|
1579
|
+
return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
|
|
1274
1580
|
}
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
var
|
|
1281
|
-
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
var sealed =
|
|
1581
|
+
// Fresh random row-secret. CRITICAL: this is CSPRNG, not a function
|
|
1582
|
+
// of any on-disk value (the pre-v0.14.25 design derived K_row from
|
|
1583
|
+
// the plaintext-on-disk derivedHash salt, so an attacker with disk
|
|
1584
|
+
// access re-derived it and deleting the wrap shred nothing). With a
|
|
1585
|
+
// random secret, K_row is unrecoverable once the wrap is destroyed.
|
|
1586
|
+
var rowSecret = generateBytes(32);
|
|
1587
|
+
var kRow = _deriveKRow(rowSecret, table, ridStr, spec);
|
|
1588
|
+
// Store the SECRET (never K_row), AAD-sealed under the vault root so a
|
|
1589
|
+
// wrapped secret copied to a different (table, rowId) fails Poly1305.
|
|
1590
|
+
var sealed = vaultAad.seal(rowSecret.toString("base64"), _wrappedKeyAad(ridStr));
|
|
1591
|
+
// _id is the rotation pipeline's pagination/UPDATE key (the natural
|
|
1592
|
+
// identity is the composite (tableName, rowId)). A fresh token keeps
|
|
1593
|
+
// it unique per registry row.
|
|
1285
1594
|
dbHandle.prepare(
|
|
1286
|
-
'INSERT INTO "
|
|
1287
|
-
'VALUES (?, ?, ?, ?)'
|
|
1288
|
-
).run(table,
|
|
1595
|
+
'INSERT INTO "' + PER_ROW_KEYS_TABLE + '" (_id, tableName, rowId, wrappedKey, createdAt) ' +
|
|
1596
|
+
'VALUES (?, ?, ?, ?, ?)'
|
|
1597
|
+
).run(generateToken(16), table, ridStr, sealed, Date.now());
|
|
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
|
|
1300
|
-
* `_blamejs_per_row_keys`. Called by `b.subject.eraseHard`
|
|
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
|
|
1305
|
-
*
|
|
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 "
|
|
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
|
|
1340
|
-
* re-declare
|
|
1341
|
-
*
|
|
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,
|