@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.
- package/CHANGELOG.md +4 -0
- package/lib/auth/oauth.js +25 -5
- package/lib/auth/sd-jwt-vc.js +16 -3
- package/lib/break-glass.js +153 -3
- package/lib/constants.js +11 -0
- package/lib/crypto-field.js +307 -78
- package/lib/db-query.js +65 -5
- package/lib/db.js +17 -3
- package/lib/dsr.js +378 -52
- package/lib/middleware/idempotency-key.js +21 -13
- package/lib/queue-local.js +23 -1
- package/lib/queue.js +7 -0
- package/lib/request-helpers.js +7 -0
- package/lib/retention.js +11 -1
- package/lib/vault/rotate.js +64 -44
- 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 —
|
|
@@ -139,13 +148,77 @@ var columnResidency = Object.create(null);
|
|
|
139
148
|
var perRowResidency = Object.create(null);
|
|
140
149
|
|
|
141
150
|
// Per-row key declaration registry. For tables that opt
|
|
142
|
-
// into per-row keying, b.subject.eraseHard
|
|
143
|
-
// from _blamejs_per_row_keys, leaving WAL/replica
|
|
144
|
-
// undecryptable.
|
|
151
|
+
// into per-row keying, b.subject.eraseHard / b.retention destroy the
|
|
152
|
+
// wrapped row-secret from _blamejs_per_row_keys, leaving WAL/replica
|
|
153
|
+
// residual ciphertext undecryptable.
|
|
145
154
|
//
|
|
146
|
-
// { tableName: { keySize, info
|
|
155
|
+
// { tableName: { keySize, info } }
|
|
147
156
|
var perRowKeyTables = Object.create(null);
|
|
148
157
|
|
|
158
|
+
// The framework registry table that holds each row's AAD-sealed
|
|
159
|
+
// row-secret. Named once so the seal-side AAD (materializePerRowKey),
|
|
160
|
+
// the read-side AAD (unsealRow's K_row fetch), and rotate's reseal all
|
|
161
|
+
// quote the byte-identical (table, rowId, column, schemaVersion) tuple.
|
|
162
|
+
var PER_ROW_KEYS_TABLE = "_blamejs_per_row_keys";
|
|
163
|
+
var PER_ROW_KEYS_COLUMN = "wrappedKey";
|
|
164
|
+
var PER_ROW_KEYS_SCHEMA_VERSION = "1";
|
|
165
|
+
|
|
166
|
+
// Build the canonical AAD parts for a row-secret wrap in
|
|
167
|
+
// _blamejs_per_row_keys. One source of truth so seal / unseal / rotate
|
|
168
|
+
// never drift. `rowId` is the app row's _id (the same value
|
|
169
|
+
// destroyPerRowKey + subject.eraseHard delete on).
|
|
170
|
+
function _wrappedKeyAad(rowId) {
|
|
171
|
+
return vaultAad.buildColumnAad({
|
|
172
|
+
table: PER_ROW_KEYS_TABLE,
|
|
173
|
+
rowId: rowId,
|
|
174
|
+
column: PER_ROW_KEYS_COLUMN,
|
|
175
|
+
schemaVersion: PER_ROW_KEYS_SCHEMA_VERSION,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build the canonical AAD parts for a K_row-sealed data cell. Binds the
|
|
180
|
+
// ciphertext to (table, rowId, column, schemaVersion) under K_row so a
|
|
181
|
+
// cell pasted into a different row / column fails Poly1305 — the same
|
|
182
|
+
// copy-protection the AAD-bound vault.aad: path gives, but keyed by the
|
|
183
|
+
// row-scoped K_row rather than the vault root.
|
|
184
|
+
function _rowCellAad(schema, table, column, rowId) {
|
|
185
|
+
return vaultAad.buildColumnAad({
|
|
186
|
+
table: table,
|
|
187
|
+
rowId: rowId,
|
|
188
|
+
column: column,
|
|
189
|
+
schemaVersion: (schema && schema.schemaVersion) || "1",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Encode a buildColumnAad parts object into the byte form
|
|
194
|
+
// encryptPacked / decryptPacked thread into the AEAD tag. The vault.aad
|
|
195
|
+
// canonicalizer (length-prefixed, sorted-keys) is the one encoder so a
|
|
196
|
+
// K_row cell sealed here and a wrapped-secret sealed via vaultAad.seal
|
|
197
|
+
// agree byte-for-byte on the same logical AAD.
|
|
198
|
+
function _aadBytes(parts) {
|
|
199
|
+
return vaultAad.canonicalizeAad(parts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @primitive b.cryptoField.isRowSealed
|
|
204
|
+
* @signature b.cryptoField.isRowSealed(value)
|
|
205
|
+
* @since 0.14.25
|
|
206
|
+
* @related b.cryptoField.sealRow, b.cryptoField.unsealRow
|
|
207
|
+
*
|
|
208
|
+
* Returns `true` when `value` is a string carrying the per-row-key
|
|
209
|
+
* sealed-cell prefix (`vault.row:`), `false` otherwise. The row-keyed
|
|
210
|
+
* sibling of `b.vault.aad.isAadSealed` — the read path uses it to route
|
|
211
|
+
* a cell to its K_row decrypt instead of the vault-root unseal.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* b.cryptoField.isRowSealed("vault.row:AAAA"); // → true
|
|
215
|
+
* b.cryptoField.isRowSealed("vault:AAAA"); // → false
|
|
216
|
+
* b.cryptoField.isRowSealed(null); // → false
|
|
217
|
+
*/
|
|
218
|
+
function isRowSealed(value) {
|
|
219
|
+
return typeof value === "string" && value.indexOf(ROW_PREFIX) === 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
149
222
|
/**
|
|
150
223
|
* @primitive b.cryptoField.registerTable
|
|
151
224
|
* @signature b.cryptoField.registerTable(name, opts)
|
|
@@ -641,7 +714,7 @@ function clearRateCapForTest() {
|
|
|
641
714
|
|
|
642
715
|
/**
|
|
643
716
|
* @primitive b.cryptoField.sealRow
|
|
644
|
-
* @signature b.cryptoField.sealRow(table, row)
|
|
717
|
+
* @signature b.cryptoField.sealRow(table, row, opts?)
|
|
645
718
|
* @since 0.4.0
|
|
646
719
|
* @compliance hipaa, gdpr, pci-dss
|
|
647
720
|
* @related b.cryptoField.unsealRow, b.cryptoField.eraseRow, b.vault.seal
|
|
@@ -654,6 +727,20 @@ function clearRateCapForTest() {
|
|
|
654
727
|
* computed BEFORE sealing the source so the indexed lookup column
|
|
655
728
|
* captures the plaintext digest.
|
|
656
729
|
*
|
|
730
|
+
* When `opts.kRow` (a row-scoped key Buffer from
|
|
731
|
+
* `materializePerRowKey`) is supplied — wired automatically by the
|
|
732
|
+
* db-query write boundary for `declarePerRowKey` tables — sealed
|
|
733
|
+
* columns are instead XChaCha20-Poly1305-encrypted under K_row and
|
|
734
|
+
* emitted with the `vault.row:` prefix, AEAD-bound to (table, rowId,
|
|
735
|
+
* column, schemaVersion). The residency-tag column (when the table
|
|
736
|
+
* declares per-row residency) is NEVER K_row-sealed: the write gate
|
|
737
|
+
* and reads must see it in plaintext.
|
|
738
|
+
*
|
|
739
|
+
* @opts
|
|
740
|
+
* kRow: Buffer, // row-scoped key from materializePerRowKey; when present,
|
|
741
|
+
* // sealed columns emit vault.row: cells under K_row
|
|
742
|
+
* rowId: string, // the row's _id; required when kRow is present (AAD term)
|
|
743
|
+
*
|
|
657
744
|
* @example
|
|
658
745
|
* b.cryptoField.registerTable("patients", {
|
|
659
746
|
* sealedFields: ["ssn"],
|
|
@@ -665,11 +752,29 @@ function clearRateCapForTest() {
|
|
|
665
752
|
* typeof sealed.ssnHash; // → "string"
|
|
666
753
|
* row.ssn; // → "123-45-6789" (input untouched)
|
|
667
754
|
*/
|
|
668
|
-
function sealRow(table, row) {
|
|
755
|
+
function sealRow(table, row, opts) {
|
|
669
756
|
if (!row) return row;
|
|
670
757
|
var s = schemas[table];
|
|
671
758
|
if (!s) return row;
|
|
672
759
|
var out = Object.assign({}, row);
|
|
760
|
+
opts = opts || {};
|
|
761
|
+
var kRow = Buffer.isBuffer(opts.kRow) ? opts.kRow : null;
|
|
762
|
+
// The per-row-key path needs the row identity for the cell AAD. Prefer
|
|
763
|
+
// the explicit opts.rowId; fall back to the row's _id. A K_row with no
|
|
764
|
+
// rowId can't build a stable AAD, so refuse rather than seal under a
|
|
765
|
+
// placeholder that no later unseal could open.
|
|
766
|
+
var kRowId = kRow
|
|
767
|
+
? String(opts.rowId != null ? opts.rowId : (out._id != null ? out._id : ""))
|
|
768
|
+
: null;
|
|
769
|
+
if (kRow && kRowId.length === 0) {
|
|
770
|
+
throw new CryptoFieldError("crypto-field/seal-row-krow-rowid-missing",
|
|
771
|
+
"cryptoField.sealRow: opts.kRow supplied but no rowId (set opts.rowId " +
|
|
772
|
+
"or row._id) — the K_row cell AAD binds (table, rowId, column)");
|
|
773
|
+
}
|
|
774
|
+
// Residency tag column must stay plaintext even under a K_row seal —
|
|
775
|
+
// the write gate reads it before sealRow and reads surface it verbatim.
|
|
776
|
+
var residencySpec = perRowResidency[table];
|
|
777
|
+
var residencyCol = residencySpec ? residencySpec.residencyColumn : null;
|
|
673
778
|
|
|
674
779
|
// Compute derived hashes from plaintext source values BEFORE sealing those
|
|
675
780
|
// sources. If a source value arrives already sealed (e.g. from an internal
|
|
@@ -709,25 +814,39 @@ function sealRow(table, row) {
|
|
|
709
814
|
}
|
|
710
815
|
}
|
|
711
816
|
|
|
712
|
-
// Seal fields.
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
817
|
+
// Seal fields. Three shapes:
|
|
818
|
+
// - K_row (opts.kRow present): XChaCha20-Poly1305 under the row-
|
|
819
|
+
// scoped key, vault.row: prefix, AEAD-bound (table, rowId, column,
|
|
820
|
+
// schemaVersion). Crypto-shred: destroying the wrapped row-secret
|
|
821
|
+
// leaves these cells undecryptable.
|
|
822
|
+
// - AAD mode (registerTable({aad:true})): vault.aad.seal binds the
|
|
823
|
+
// tag to (table, rowId, column, schemaVersion) under the vault root.
|
|
824
|
+
// - plain mode: vault.seal (idempotent — already-sealed pass through).
|
|
716
825
|
for (var i = 0; i < s.sealedFields.length; i++) {
|
|
717
826
|
var field = s.sealedFields[i];
|
|
718
|
-
if (out[field]
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
827
|
+
if (out[field] === undefined || out[field] === null) continue;
|
|
828
|
+
if (kRow && field === residencyCol) continue; // residency tag stays plaintext
|
|
829
|
+
if (kRow) {
|
|
830
|
+
// Idempotent: an already-K_row-sealed value passes through.
|
|
831
|
+
if (isRowSealed(out[field])) continue;
|
|
832
|
+
var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
|
|
833
|
+
// Coerce to a string the same way the vault.aad path does, then
|
|
834
|
+
// encode as UTF-8 bytes for the AEAD (split out so the byte
|
|
835
|
+
// coercion is Buffer.from(str, "utf8"), not Buffer.from(String(...))).
|
|
836
|
+
var plainStr = String(out[field]);
|
|
837
|
+
out[field] = ROW_PREFIX +
|
|
838
|
+
encryptPacked(Buffer.from(plainStr, "utf8"), kRow, cellAad).toString("base64");
|
|
839
|
+
} else if (s.aad) {
|
|
840
|
+
// Idempotent: already-AAD-sealed values pass through unchanged.
|
|
841
|
+
if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
|
|
842
|
+
continue;
|
|
730
843
|
}
|
|
844
|
+
out[field] = vaultAad.seal(String(out[field]),
|
|
845
|
+
_aadParts(s, table, field, out));
|
|
846
|
+
} else {
|
|
847
|
+
// allow:seal-without-aad — plain-mode legacy table; operator
|
|
848
|
+
// opts into AAD via registerTable({aad:true})
|
|
849
|
+
out[field] = vault.seal(String(out[field]));
|
|
731
850
|
}
|
|
732
851
|
}
|
|
733
852
|
|
|
@@ -749,7 +868,7 @@ function _aadParts(schema, table, column, row) {
|
|
|
749
868
|
|
|
750
869
|
/**
|
|
751
870
|
* @primitive b.cryptoField.unsealRow
|
|
752
|
-
* @signature b.cryptoField.unsealRow(table, row, actor?)
|
|
871
|
+
* @signature b.cryptoField.unsealRow(table, row, actor?, dbHandle?)
|
|
753
872
|
* @since 0.4.0
|
|
754
873
|
* @compliance hipaa, gdpr, pci-dss
|
|
755
874
|
* @related b.cryptoField.sealRow, b.vault.unseal, b.cryptoField.configureUnsealRateCap
|
|
@@ -763,6 +882,18 @@ function _aadParts(schema, table, column, row) {
|
|
|
763
882
|
* so downstream code sees "no value" instead of crashing the request.
|
|
764
883
|
* The input row is never mutated.
|
|
765
884
|
*
|
|
885
|
+
* `vault.row:`-prefixed cells (per-row-key tables, `declarePerRowKey`)
|
|
886
|
+
* are decrypted under the row's K_row: a `dbHandle` (the db-query layer
|
|
887
|
+
* passes `this._db`) is used to fetch the row's wrapped secret from
|
|
888
|
+
* `_blamejs_per_row_keys`, unwrap it, and derive K_row once per call.
|
|
889
|
+
* When a caller passes no `dbHandle` (e.g. `b.breakGlass.unsealRow`,
|
|
890
|
+
* which reads the row via clusterStorage), the framework's local db is
|
|
891
|
+
* resolved automatically — the wrapped secret always lives in the local
|
|
892
|
+
* `_blamejs_per_row_keys`, so keyed reads work on every path.
|
|
893
|
+
* A missing wrapped row (crypto-shredded by `eraseHard` / `retention`)
|
|
894
|
+
* makes the unwrap throw → the field nulls + `system.crypto.unseal_failed`
|
|
895
|
+
* fires, which is correct: shredded data reads as absent.
|
|
896
|
+
*
|
|
766
897
|
* When an unseal-failure rate cap is configured via
|
|
767
898
|
* `configureUnsealRateCap` (default off), repeated forged-ciphertext
|
|
768
899
|
* failures for a single `(actor, table, column)` tuple trip a cooldown:
|
|
@@ -779,7 +910,7 @@ function _aadParts(schema, table, column, row) {
|
|
|
779
910
|
* var clear = b.cryptoField.unsealRow("patients", sealed);
|
|
780
911
|
* clear.ssn; // → "123-45-6789"
|
|
781
912
|
*/
|
|
782
|
-
function unsealRow(table, row, actor) {
|
|
913
|
+
function unsealRow(table, row, actor, dbHandle) {
|
|
783
914
|
if (!row) return row;
|
|
784
915
|
var s = schemas[table];
|
|
785
916
|
if (!s || s.sealedFields.length === 0) return row;
|
|
@@ -787,15 +918,61 @@ function unsealRow(table, row, actor) {
|
|
|
787
918
|
var capActor = (actor === undefined || actor === null || String(actor).length === 0)
|
|
788
919
|
? "_anon" : String(actor);
|
|
789
920
|
|
|
921
|
+
// Lazy K_row: derive at most once per unsealRow call, only if a cell
|
|
922
|
+
// actually carries the vault.row: prefix. Cached across fields (and
|
|
923
|
+
// the failure case is cached too, so a shredded row doesn't re-query
|
|
924
|
+
// _blamejs_per_row_keys for every sealed column). The row identity for
|
|
925
|
+
// both the cell AAD and the wrapped-secret lookup is the row's _id —
|
|
926
|
+
// the same value the seal side (write boundary) passed as rowId and
|
|
927
|
+
// that destroyPerRowKey / eraseHard delete on.
|
|
928
|
+
var kRowId = out._id != null ? String(out._id) : "";
|
|
929
|
+
var keyedTable = hasPerRowKey(table);
|
|
930
|
+
var _kRowCache; // undefined = not yet derived; null = derive failed
|
|
931
|
+
function _kRowOnce() {
|
|
932
|
+
if (_kRowCache !== undefined) return _kRowCache;
|
|
933
|
+
_kRowCache = null;
|
|
934
|
+
if (!keyedTable || kRowId.length === 0) return null;
|
|
935
|
+
// Resolve a prepared-statement source for the wrapped-secret lookup.
|
|
936
|
+
// Prefer the caller's dbHandle (the db-query read layer threads it on
|
|
937
|
+
// first()/all()/stream()); otherwise resolve the framework's local
|
|
938
|
+
// db ourselves. A DIRECT caller — e.g. b.breakGlass.unsealRow, which
|
|
939
|
+
// fetches the target row via clusterStorage and calls unsealRow with
|
|
940
|
+
// no handle — would otherwise null every K_row cell on a keyed table
|
|
941
|
+
// even though the wrapped secret still exists. The secret always
|
|
942
|
+
// lives in the local _blamejs_per_row_keys, so keyed reads must work
|
|
943
|
+
// on every path, not only db-query's. Any failure (db not yet
|
|
944
|
+
// initialized, unusable handle) → null, and the field reads as absent
|
|
945
|
+
// exactly as a shredded row would (the caller audits it).
|
|
946
|
+
var spec = perRowKeyTables[table];
|
|
947
|
+
var wrap;
|
|
948
|
+
try {
|
|
949
|
+
var prep = (dbHandle && typeof dbHandle.prepare === "function")
|
|
950
|
+
? dbHandle.prepare.bind(dbHandle)
|
|
951
|
+
: db().prepare;
|
|
952
|
+
wrap = prep(
|
|
953
|
+
'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
|
|
954
|
+
).get(table, kRowId);
|
|
955
|
+
} catch (_e) {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
if (!wrap || wrap.wrappedKey == null) return null; // shredded / never materialized
|
|
959
|
+
_kRowCache = _deriveKRow(_unwrapRowSecret(wrap.wrappedKey, kRowId), table, kRowId, spec);
|
|
960
|
+
return _kRowCache;
|
|
961
|
+
}
|
|
962
|
+
|
|
790
963
|
for (var i = 0; i < s.sealedFields.length; i++) {
|
|
791
964
|
var field = s.sealedFields[i];
|
|
792
965
|
if (out[field]) {
|
|
966
|
+
// Per-cell envelope shape for audit metadata (operators write alert
|
|
967
|
+
// rules off it): "row" = K_row cell, "aad" = vault.aad: cell on an
|
|
968
|
+
// AAD table, "plain" otherwise.
|
|
969
|
+
var shape = isRowSealed(out[field]) ? "row" : (s.aad ? "aad" : "plain");
|
|
793
970
|
// Opt-in cap: if this (actor, table, column) tuple is in cooldown
|
|
794
971
|
// from prior forged-ciphertext failures, refuse before touching the
|
|
795
972
|
// decryption oracle again (CWE-307). No-op when the cap is disabled.
|
|
796
973
|
if (_rateInCooldown(capActor, table, field)) {
|
|
797
974
|
_emitRateAudit({
|
|
798
|
-
table: table, field: field, actor: capActor, shape:
|
|
975
|
+
table: table, field: field, actor: capActor, shape: shape,
|
|
799
976
|
threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
|
|
800
977
|
});
|
|
801
978
|
throw new CryptoFieldRateError("crypto-field/unseal-rate-exceeded",
|
|
@@ -807,7 +984,22 @@ function unsealRow(table, row, actor) {
|
|
|
807
984
|
// Auto-detect the envelope shape so an AAD-bound table that
|
|
808
985
|
// contains pre-migration plain-vault rows still reads. Read-
|
|
809
986
|
// side migration is lazy; the next sealRow re-emits AAD-bound.
|
|
810
|
-
if (typeof out[field] === "string" &&
|
|
987
|
+
if (typeof out[field] === "string" && isRowSealed(out[field])) {
|
|
988
|
+
// Per-row-key cell: derive K_row (lazy, once), then decrypt
|
|
989
|
+
// under it with the (table, rowId, column, schemaVersion) AAD.
|
|
990
|
+
// A null K_row means the wrapped secret is gone (shredded) or
|
|
991
|
+
// unreadable — throw so the catch nulls the field + audits.
|
|
992
|
+
var kRow = _kRowOnce();
|
|
993
|
+
if (!kRow) {
|
|
994
|
+
throw new CryptoFieldError("crypto-field/row-key-unavailable",
|
|
995
|
+
"unsealRow: per-row key for '" + table + "' row '" + kRowId +
|
|
996
|
+
"' is unavailable (shredded or never materialized)");
|
|
997
|
+
}
|
|
998
|
+
var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
|
|
999
|
+
unsealed = decryptPacked(
|
|
1000
|
+
Buffer.from(out[field].slice(ROW_PREFIX.length), "base64"), kRow, cellAad
|
|
1001
|
+
).toString("utf8");
|
|
1002
|
+
} else if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
|
|
811
1003
|
unsealed = vaultAad.unseal(out[field],
|
|
812
1004
|
_aadParts(s, table, field, out));
|
|
813
1005
|
} else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
|
|
@@ -834,7 +1026,7 @@ function unsealRow(table, row, actor) {
|
|
|
834
1026
|
table: table,
|
|
835
1027
|
field: field,
|
|
836
1028
|
rowId: out[s.rowIdField] || out._id || null,
|
|
837
|
-
shape:
|
|
1029
|
+
shape: shape,
|
|
838
1030
|
reason: (e && e.message) || String(e),
|
|
839
1031
|
},
|
|
840
1032
|
});
|
|
@@ -845,7 +1037,7 @@ function unsealRow(table, row, actor) {
|
|
|
845
1037
|
// transition. No-op when the cap is disabled.
|
|
846
1038
|
if (_rateNoteFailure(capActor, table, field)) {
|
|
847
1039
|
_emitRateAudit({
|
|
848
|
-
table: table, field: field, actor: capActor, shape:
|
|
1040
|
+
table: table, field: field, actor: capActor, shape: shape,
|
|
849
1041
|
threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
|
|
850
1042
|
});
|
|
851
1043
|
}
|
|
@@ -1272,13 +1464,16 @@ function getPerRowResidency(table) {
|
|
|
1272
1464
|
* @related b.cryptoField.materializePerRowKey, b.cryptoField.destroyPerRowKey, b.subject.eraseHard
|
|
1273
1465
|
*
|
|
1274
1466
|
* Opts a table into per-row keying (K_row crypto-shred substrate).
|
|
1275
|
-
* After registration, every INSERT generates a fresh
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1467
|
+
* After registration, every INSERT generates a fresh 32-byte CSPRNG
|
|
1468
|
+
* row-secret, derives K_row from it, and stores the SECRET (never
|
|
1469
|
+
* K_row) AAD-sealed in `_blamejs_per_row_keys (tableName, rowId,
|
|
1470
|
+
* wrappedKey)`. AAD on the wrap binds (table, rowId, column,
|
|
1471
|
+
* schemaVersion) — a wrapped secret copied to a different row fails
|
|
1472
|
+
* Poly1305 verification. `b.subject.eraseHard(subjectId)` /
|
|
1473
|
+
* `b.retention` destroy the per-row entries for the subject's rows; WAL
|
|
1474
|
+
* / replica residual ciphertext becomes mathematically undecryptable
|
|
1475
|
+
* because the random row-secret — the only seed for K_row — is gone
|
|
1476
|
+
* everywhere it ever lived. Throws on bad input (config-time
|
|
1282
1477
|
* fail-loud).
|
|
1283
1478
|
*
|
|
1284
1479
|
* @opts
|
|
@@ -1340,15 +1535,22 @@ function hasPerRowKey(table) {
|
|
|
1340
1535
|
* @compliance gdpr, hipaa
|
|
1341
1536
|
* @related b.cryptoField.declarePerRowKey, b.cryptoField.destroyPerRowKey
|
|
1342
1537
|
*
|
|
1343
|
-
* Derive-and-store: called by the storage backend on INSERT
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1538
|
+
* Derive-and-store: called by the storage backend on INSERT (the
|
|
1539
|
+
* db-query write boundary, gated on `hasPerRowKey`). Generates a fresh
|
|
1540
|
+
* 32-byte CSPRNG row-secret, derives
|
|
1541
|
+
* `K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":"
|
|
1542
|
+
* || info, keySize)`, AAD-seals the SECRET (base64) into
|
|
1543
|
+
* `_blamejs_per_row_keys.wrappedKey` via `b.vault.aad.seal`, and
|
|
1544
|
+
* returns the unwrapped K_row Buffer for the caller to encrypt sealed
|
|
1545
|
+
* columns under the row-scoped key. The secret is random — never a
|
|
1546
|
+
* function of any on-disk salt — so destroying the wrapped secret
|
|
1547
|
+
* makes K_row unrecoverable even with full disk + vault-root access.
|
|
1548
|
+
* Idempotent on UPSERT — if a secret already exists for (table,
|
|
1549
|
+
* rowId), unwraps it and re-derives the same K_row. The AAD-bound wrap
|
|
1550
|
+
* rejects copy-row attacks: a wrapped secret pasted under a different
|
|
1551
|
+
* rowId fails Poly1305 verification at unseal time. `dbHandle` is a
|
|
1552
|
+
* b.db handle (`.prepare`); rowId MUST be the row's `_id` (the value
|
|
1553
|
+
* `destroyPerRowKey` / `b.subject.eraseHard` delete on).
|
|
1352
1554
|
*
|
|
1353
1555
|
* @example
|
|
1354
1556
|
* b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
|
|
@@ -1368,30 +1570,55 @@ function materializePerRowKey(table, rowId, dbHandle) {
|
|
|
1368
1570
|
throw new CryptoFieldError("crypto-field/materialize-per-row-key-no-db",
|
|
1369
1571
|
"materializePerRowKey: dbHandle (b.db) is required");
|
|
1370
1572
|
}
|
|
1371
|
-
|
|
1573
|
+
var ridStr = String(rowId);
|
|
1574
|
+
// Existing secret? Unwrap + re-derive to support idempotent UPSERTs.
|
|
1372
1575
|
var existing = dbHandle.prepare(
|
|
1373
|
-
'SELECT wrappedKey FROM "
|
|
1374
|
-
).get(table,
|
|
1576
|
+
'SELECT wrappedKey FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
|
|
1577
|
+
).get(table, ridStr);
|
|
1375
1578
|
if (existing) {
|
|
1376
|
-
return
|
|
1579
|
+
return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
|
|
1377
1580
|
}
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
var
|
|
1384
|
-
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
var sealed =
|
|
1581
|
+
// Fresh random row-secret. CRITICAL: this is CSPRNG, not a function
|
|
1582
|
+
// of any on-disk value (the pre-v0.14.25 design derived K_row from
|
|
1583
|
+
// the plaintext-on-disk derivedHash salt, so an attacker with disk
|
|
1584
|
+
// access re-derived it and deleting the wrap shred nothing). With a
|
|
1585
|
+
// random secret, K_row is unrecoverable once the wrap is destroyed.
|
|
1586
|
+
var rowSecret = generateBytes(32);
|
|
1587
|
+
var kRow = _deriveKRow(rowSecret, table, ridStr, spec);
|
|
1588
|
+
// Store the SECRET (never K_row), AAD-sealed under the vault root so a
|
|
1589
|
+
// wrapped secret copied to a different (table, rowId) fails Poly1305.
|
|
1590
|
+
var sealed = vaultAad.seal(rowSecret.toString("base64"), _wrappedKeyAad(ridStr));
|
|
1591
|
+
// _id is the rotation pipeline's pagination/UPDATE key (the natural
|
|
1592
|
+
// identity is the composite (tableName, rowId)). A fresh token keeps
|
|
1593
|
+
// it unique per registry row.
|
|
1388
1594
|
dbHandle.prepare(
|
|
1389
|
-
'INSERT INTO "
|
|
1390
|
-
'VALUES (?, ?, ?, ?)'
|
|
1391
|
-
).run(table,
|
|
1595
|
+
'INSERT INTO "' + PER_ROW_KEYS_TABLE + '" (_id, tableName, rowId, wrappedKey, createdAt) ' +
|
|
1596
|
+
'VALUES (?, ?, ?, ?, ?)'
|
|
1597
|
+
).run(generateToken(16), table, ridStr, sealed, Date.now());
|
|
1392
1598
|
return kRow;
|
|
1393
1599
|
}
|
|
1394
1600
|
|
|
1601
|
+
// Derive the row-scoped key from the random row-secret. SHAKE256 expand
|
|
1602
|
+
// (HKDF-shaped, matches the framework's PQC-first kdf) over
|
|
1603
|
+
// rowSecret || ":" || table || ":" || rowId || ":" || info — the
|
|
1604
|
+
// non-secret context terms domain-separate two rows that (astronomically
|
|
1605
|
+
// improbably) drew the same secret; the secret is the entropy source.
|
|
1606
|
+
function _deriveKRow(rowSecret, table, rowId, spec) {
|
|
1607
|
+
var ikm = Buffer.concat([
|
|
1608
|
+
rowSecret,
|
|
1609
|
+
Buffer.from(":" + table + ":" + rowId + ":" + spec.info, "utf8"),
|
|
1610
|
+
]);
|
|
1611
|
+
return kdf(ikm, spec.keySize);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Unwrap a stored row-secret back to its 32 raw bytes. The wrap is
|
|
1615
|
+
// AAD-bound to (PER_ROW_KEYS_TABLE, rowId, wrappedKey, schemaVersion);
|
|
1616
|
+
// a tampered / copied wrap throws here, which the read path surfaces as
|
|
1617
|
+
// system.crypto.unseal_failed (shredded data reads as absent).
|
|
1618
|
+
function _unwrapRowSecret(wrapped, rowId) {
|
|
1619
|
+
return Buffer.from(vaultAad.unseal(wrapped, _wrappedKeyAad(rowId)), "base64");
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1395
1622
|
/**
|
|
1396
1623
|
* @primitive b.cryptoField.destroyPerRowKey
|
|
1397
1624
|
* @signature b.cryptoField.destroyPerRowKey(table, rowId, dbHandle)
|
|
@@ -1399,13 +1626,14 @@ function materializePerRowKey(table, rowId, dbHandle) {
|
|
|
1399
1626
|
* @compliance gdpr, hipaa
|
|
1400
1627
|
* @related b.cryptoField.materializePerRowKey, b.subject.eraseHard
|
|
1401
1628
|
*
|
|
1402
|
-
* Crypto-shred: drops the
|
|
1403
|
-
* `_blamejs_per_row_keys`. Called by `b.subject.eraseHard`
|
|
1404
|
-
* row mapped to the erased subject. Returns
|
|
1629
|
+
* Crypto-shred: drops the row's wrapped row-secret from
|
|
1630
|
+
* `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` and
|
|
1631
|
+
* `b.retention` for each row mapped to the erased subject. Returns
|
|
1405
1632
|
* `{ destroyed: <rowsAffected> }`. After destruction, any WAL /
|
|
1406
1633
|
* replica residual ciphertext for the row is mathematically
|
|
1407
|
-
* undecryptable — even with the vault root key — because
|
|
1408
|
-
*
|
|
1634
|
+
* undecryptable — even with the vault root key — because the random
|
|
1635
|
+
* row-secret (the only seed for K_row) is gone everywhere it ever
|
|
1636
|
+
* lived. `rowId` MUST be the row's `_id`. No-op when the table is not
|
|
1409
1637
|
* registered for per-row keying.
|
|
1410
1638
|
*
|
|
1411
1639
|
* @example
|
|
@@ -1426,8 +1654,8 @@ function destroyPerRowKey(table, rowId, dbHandle) {
|
|
|
1426
1654
|
"destroyPerRowKey: dbHandle (b.db) is required");
|
|
1427
1655
|
}
|
|
1428
1656
|
var result = dbHandle.prepare(
|
|
1429
|
-
'DELETE FROM "
|
|
1430
|
-
).run(table, rowId);
|
|
1657
|
+
'DELETE FROM "' + PER_ROW_KEYS_TABLE + '" WHERE tableName = ? AND rowId = ?'
|
|
1658
|
+
).run(table, String(rowId));
|
|
1431
1659
|
return { destroyed: (result && result.changes) || 0 };
|
|
1432
1660
|
}
|
|
1433
1661
|
|
|
@@ -1463,6 +1691,7 @@ module.exports = {
|
|
|
1463
1691
|
getSealedFields: getSealedFields,
|
|
1464
1692
|
sealRow: sealRow,
|
|
1465
1693
|
unsealRow: unsealRow,
|
|
1694
|
+
isRowSealed: isRowSealed,
|
|
1466
1695
|
configureUnsealRateCap: configureUnsealRateCap,
|
|
1467
1696
|
clearRateCapForTest: clearRateCapForTest,
|
|
1468
1697
|
CryptoFieldRateError: CryptoFieldRateError,
|