Field-Level Crypto

Per-column field-level encryption with AAD-bound envelopes. Apps declare which columns hold PHI / PCI / personal data via b.db.init({ schema }); the framework then auto-protects those columns on every write (sealRow) and reverses on every read (unsealRow). Sealed values are produced by b.vault.seal, which wraps an XChaCha20-Poly1305 ciphertext under the framework's PQC envelope (ML-KEM + ECDH hybrid) — every encryption uses a fresh random nonce, so two seals of the same plaintext never collide.

Per-row key (K_row) derivation is opt-in via declarePerRowKey. Tables that opt in get a fresh K_row per INSERT: the framework generates a 32-byte CSPRNG row-secret, derives K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":" || info), and stores the SECRET (never K_row) AAD-sealed in _blamejs_per_row_keys.wrappedKey. Because the secret is random — not a function of any on-disk salt — an attacker with full disk access cannot re-derive K_row once the wrapped secret is gone. The AAD on the wrap binds (table, rowId, column, schemaVersion): copying a wrapped secret from one row to another fails Poly1305 verification, so a DB-write attacker cannot move it between rows to bypass row-scoped erasure. Sealed columns on a keyed row carry the vault.row: prefix and are XChaCha20-Poly1305 ciphertext under K_row, AEAD-bound to the same (table, rowId, column) tuple. This is the crypto-shred substrate for b.subject.eraseHard / b.retention: destroying the wrapped secret leaves WAL / replica residual ciphertext mathematically undecryptable — even with the vault root key — because K_row is gone everywhere it ever lived.

Derived hashes (derivedHashes) provide indexed lookup for sealed columns. The default digest is a keyed MAC (hmac-shake256: SHAKE256 under the vault's per-deployment MAC key) + a per-field namespace, so an attacker who recovers the salt alone cannot correlate low-entropy plaintexts across fields or across deployments. Operators keeping byte-compatibility with an existing salted index opt out per-table (derivedHashMode: "salted-sha3") or per-column (derivedHashes..mode). Sealed columns without a derived hash are unindexable — queries on them silently return zero rows.

Per-column residency (declareColumnResidency) declares EU / US / global tags; the storage-write gate (assertColumnResidency) refuses writes to a backend whose tag doesn't satisfy the column under gdpr / dpdp / pipl-cn / uk-gdpr postures.

No mutation of the input row — every operation returns a new object, suitable for direct insertion into the audit chain.

b.cryptoField.applyPosture(posture) #

0.7.27gdprhipaa

Records the active compliance posture so eraseRow can cascade into b.db.vacuumAfterErase({ mode: "full" }) under regimes whose POSTURE_DEFAULTS sets requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br / hipaa). Without the vacuum, freed B-tree index pages keep sealed-column ciphertext readable from a forensic disk image — defeating the "right to erasure" the regime guarantees. Returns null when posture is empty/non-string; otherwise returns { posture, requireVacuumAfterErase }.

var info = b.cryptoField.applyPosture("gdpr");
info.posture;                   // → "gdpr"
info.requireVacuumAfterErase;   // → true

b.cryptoField.applyPosture("");   // → null (no-op)

b.cryptoField.getActivePosture() #

0.7.27

Returns the posture string most recently recorded via applyPosture, or null when no posture has been applied. Read-only — does not mutate state. Used by storage backends to gate cross-border writes.

b.cryptoField.applyPosture("hipaa");
b.cryptoField.getActivePosture();   // → "hipaa"

b.cryptoField.isRowSealed(value) #

0.14.25

Returns true when value is a string carrying the per-row-key sealed-cell prefix (vault.row:), false otherwise. The row-keyed sibling of b.vault.aad.isAadSealed — the read path uses it to route a cell to its K_row decrypt instead of the vault-root unseal.

b.cryptoField.isRowSealed("vault.row:AAAA");   // → true
b.cryptoField.isRowSealed("vault:AAAA");        // → false
b.cryptoField.isRowSealed(null);                // → false

b.cryptoField.registerTable(name, opts) #

0.4.0
{
  sealedFields:   string[],              // column names sealed via vault.seal
  derivedHashes:  { [hashCol]: { from: string, normalize?: fn } },
  hashNamespaces: { [field]: string },   // override default rainbow-defense ns
  aad:            boolean,               // when true, route seal/unseal through
                                         // b.vault.aad — AEAD-binds the ciphertext
                                         // to (table, rowIdField=primary key, column)
                                         // so a DB-write attacker can't copy a
                                         // sealed value between rows.
  rowIdField:     string,                // when aad=true, the column name carrying
                                         // the row identity. Default "id". The row
                                         // passed to sealRow MUST already have this
                                         // column populated; sealRow refuses when
                                         // missing (an AAD bound to a placeholder
                                         // would silently fail every unseal).
  schemaVersion:  string|number,         // when aad=true, the schema version
                                         // threaded into AAD. Default "1". Bump
                                         // when the column layout changes to
                                         // invalidate all prior ciphertext.
  allowPlainMigration: boolean,          // default false. On an aad / per-row-key
                                         // table the read path refuses a PLAIN
                                         // (unbound) vault: cell — a relocatable
                                         // envelope an attacker could copy in from
                                         // another row defeats the AAD copy-
                                         // protection, so it is nulled, not surfaced.
                                         // Set true ONLY for the bounded window while
                                         // migrating pre-AAD rows up to AAD-bound
                                         // ciphertext; clear it once migration ends.
}

Registers a table's sealed-column declaration. Called from b.db.init({ schema }) at boot — operators rarely call directly. Stores the per-table list of sealed fields, the derived-hash specs (mapping derivedField -> { from, normalize }), and any per-field hash namespaces. Subsequent sealRow / unsealRow / eraseRow calls dispatch through this registry.

Seal-envelope floor: when a compliance posture that declares a sealEnvelopeFloor is globally pinned (b.compliance.set — today hipaa / pci-dss require at least an AAD-bound envelope), a table that seals columns under a weaker envelope throws crypto-field/seal-envelope-below-floor here at registration so the operator catches the under-protected schema at boot. Unpinned and non-regulated deployments register unchanged.

b.cryptoField.registerTable("patients", {
  sealedFields: ["ssn", "diagnosis"],
  derivedHashes: {
    ssnHash: { from: "ssn", normalize: function (s) { return String(s).replace(/-/g, ""); } }
  }
});
b.cryptoField.getSealedFields("patients");   // → ["ssn", "diagnosis"]

// AAD-bound table (recommended for new schemas).
b.cryptoField.registerTable("idempotency_keys", {
  sealedFields: ["headers", "body"],
  aad:          true,
  rowIdField:   "k",       // primary key column
});

b.cryptoField.computeNamespacedHash(ns, value, opts?) #

0.14.10gdprhipaa
{
  mode:          string,   // "salted-sha3" (default) | "hmac-shake256"
  truncateBytes: number,   // optional; positive integer byte width to slice to
}

Computes a namespaced indexed-lookup digest of value for a pseudo-field that is NOT backed by a registered derived-hash column (e.g. the sealed-token FTS index in b.mailStore.fts). The caller supplies the full namespace string directly — there is no schema lookup — so the same keyed/salted hash machinery that protects registered derived hashes also covers ad-hoc indexed tokens. This is the canonical entry point: hand-rolling sha3Hash(vault.getDerivedHashSalt() + ns + value) at a call site bypasses the keyed-MAC mode (hmac-shake256 off vault.getDerivedHashMacKey) and the per-deployment salt policy.

opts.mode selects the digest: - "salted-sha3" (default): SHA3-512 over + ns + value (deterministic per deployment; byte-identical to the legacy hand-rolled scheme). - "hmac-shake256": SHAKE256( || ns + value) — a keyed MAC so an attacker who recovers the salt alone cannot correlate two low-entropy plaintexts.

opts.truncateBytes truncates the hex digest to that many BYTES (the hex string is sliced to truncateBytes * 2 characters). Throws (config-time / entry-point tier) on an unknown mode or a non-positive-integer truncateBytes so an operator catches the typo at boot rather than silently indexing under a malformed digest.

var ns = "bj-mail_messages-body:fts:";
var h = b.cryptoField.computeNamespacedHash(ns, "kubernetes", {
  mode: "hmac-shake256", truncateBytes: 8
});
/^[0-9a-f]{16}$/.test(h);   // → true

// Default mode is byte-identical to the legacy salted-sha3 hash.
b.cryptoField.computeNamespacedHash(ns, "kubernetes").length;   // → 128

b.cryptoField.getSchema(table) #

0.4.0

Returns the registered schema record for table{ sealedFields, derivedHashes, hashNamespaces } — or null when the table was never registered. Read-only; mutations to the returned object do not affect future calls (the inner arrays/objects are shared, so operators should treat the result as read-only).

b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
var schema = b.cryptoField.getSchema("patients");
schema.sealedFields;   // → ["ssn"]

b.cryptoField.getSchema("unknown");   // → null

b.cryptoField.getSealedFields(table) #

0.4.0

Returns the array of sealed column names for table, or an empty array when the table is unregistered. Convenience accessor used by storage backends to know which columns to wrap in vault.seal on write and vault.unseal on read.

b.cryptoField.registerTable("patients", { sealedFields: ["ssn", "diagnosis"] });
b.cryptoField.getSealedFields("patients");   // → ["ssn", "diagnosis"]
b.cryptoField.getSealedFields("public");     // → []

b.cryptoField.clearForTest() #

experimental0.4.0

Test-only helper. Drops every entry from the per-table schema registry so a test fixture can re-register tables under different sealed-field declarations between cases. Operator code never calls this — production schemas come from b.db.init({ schema }) once at boot.

b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
b.cryptoField.clearForTest();
b.cryptoField.getSchema("patients");   // → null

b.cryptoField.computeDerived(table, sourceField, sourceValue) #

0.4.0

Computes the derived hash for a (table, sourceField) pair when the schema declares a derived-hash mirror of that source. Returns { field, value } naming the derived column and its hash, or null when no derived hash is declared. Hashes are SHA3 of vaultSalt + namespace + normalizedValue, where the per-deployment vault salt prevents cross-deployment correlation and the per-field namespace prevents cross-field rainbow attacks.

b.cryptoField.registerTable("users", {
  sealedFields: ["email"],
  derivedHashes: { emailHash: { from: "email" } }
});
var d = b.cryptoField.computeDerived("users", "email", "alice@example.com");
d.field;          // → "emailHash"
typeof d.value;   // → "string"

b.cryptoField.computeDerived("users", "email", null);   // → null

b.cryptoField.configureUnsealRateCap(opts) #

0.14.20hipaagdprpci-dss
{
  threshold: number,    // failures within the window before refusal kicks in (positive int)
  windowMs:  number,    // sliding-window width in ms (positive int; default 60000)
  cooldownMs: number,   // refusal duration once tripped (positive int; default windowMs)
  disabled:  boolean,   // pass true to turn the cap off (same as configureUnsealRateCap(null))
  now:       function,  // injected clock returning epoch ms; default Date.now (test seam)
  onAudit:   function,  // optional sink({ action, outcome, metadata }) for the rate audit (test seam)
}

Tune the per-(actor, table, column) cap on sealed-column unseal FAILURES. The cap is ON BY DEFAULT (default-on, v0.15.0): the framework arms it at module load (threshold 10 / 1-minute window / 5-minute cooldown) so a forged-ciphertext oracle is bounded with no operator action. Once a single tuple accrues threshold failures inside windowMs, every subsequent unsealRow touching that tuple is REFUSED for cooldownMs with a CryptoFieldRateError and a distinct system.crypto.unseal_rate_exceeded audit row, bounding the oracle. Without the cap, an attacker who can write vault: payloads can hammer the KEM-decapsulation / AEAD-verify oracle indefinitely and only an off-band operator alert rule catches the burst.

Pass an opts object to RAISE/lower the thresholds. Pass null (or { disabled: true }) to turn the cap off entirely and fall back to audit-only (the pre-v0.15.0 behaviour) — the documented opt-out for the rare deployment that needs an unbounded read path. Validation is config-time / entry-point tier — bad threshold / windowMs / cooldownMs THROW so an operator catches the typo at boot rather than silently mis-configuring the cap.

CWE-307 (excessive-attempt restriction); OWASP ASVS v5 §2.2.1; NIST SP 800-63B §5.2.2.

b.cryptoField.configureUnsealRateCap({ threshold: 5, windowMs: 60000, cooldownMs: 300000 });
// ...after 5 forged-ciphertext unseal failures for one (actor, table, column):
try { b.cryptoField.unsealRow("patients", forgedRow, "actor-42"); }
catch (e) { e.code; }   // → "crypto-field/unseal-rate-exceeded"

b.cryptoField.configureUnsealRateCap(null);   // → disable again

b.cryptoField.clearRateCapForTest() #

experimental0.14.20

Test-only helper. Restores the secure DEFAULT cap (default-on baseline) and drops every in-flight sliding-window + cooldown entry so a fixture can re-configure the cap between cases from a known-good starting point. Operator code never calls this — production deployments inherit the default cap at boot and tune or disable it via configureUnsealRateCap.

b.cryptoField.configureUnsealRateCap({ threshold: 3 });
b.cryptoField.clearRateCapForTest();
// cap is back at the secure default; windows + cooldowns cleared

b.cryptoField.sealRow(table, row, opts?) #

0.4.0hipaagdprpci-dss
{
  kRow:  Buffer,   // row-scoped key from materializePerRowKey; when present,
                   // sealed columns emit vault.row: cells under K_row
  rowId: string,   // the row's _id; required when kRow is present (AAD term)
}

Returns a copy of row with every sealed column wrapped in vault.seal() and every derived-hash mirror computed from the pre-seal plaintext. The input row is never mutated. vault.seal is idempotent — already-sealed values pass through unchanged so round-trips through the storage layer are safe. Derived hashes are computed BEFORE sealing the source so the indexed lookup column captures the plaintext digest.

When opts.kRow (a row-scoped key Buffer from materializePerRowKey) is supplied — wired automatically by the db-query write boundary for declarePerRowKey tables — sealed columns are instead XChaCha20-Poly1305-encrypted under K_row and emitted with the vault.row: prefix, AEAD-bound to (table, rowId, column, schemaVersion). The residency-tag column (when the table declares per-row residency) is NEVER K_row-sealed: the write gate and reads must see it in plaintext.

b.cryptoField.registerTable("patients", {
  sealedFields: ["ssn"],
  derivedHashes: { ssnHash: { from: "ssn" } }
});
var row = { id: 1, name: "Alice", ssn: "123-45-6789" };
var sealed = b.cryptoField.sealRow("patients", row);
String(sealed.ssn).startsWith("vault:");   // → true
typeof sealed.ssnHash;                     // → "string"
row.ssn;                                   // → "123-45-6789" (input untouched)

b.cryptoField.unsealRow(table, row, actor?, dbHandle?) #

0.4.0hipaagdprpci-dss

Returns a copy of row with every sealed column unwrapped via vault.unseal(). Round-trips with sealRow. When vault.unseal throws (DB-write attacker forging a vault: payload to force ML-KEM decapsulation on attacker-controlled bytes), the failure is recorded on the audit chain as system.crypto.unseal_failed and the field is replaced with null so downstream code sees "no value" instead of crashing the request. The input row is never mutated.

vault.row:-prefixed cells (per-row-key tables, declarePerRowKey) are decrypted under the row's K_row: a dbHandle (the db-query layer passes this._db) is used to fetch the row's wrapped secret from _blamejs_per_row_keys, unwrap it, and derive K_row once per call. When a caller passes no dbHandle (e.g. b.breakGlass.unsealRow, which reads the row via clusterStorage), the framework's local db is resolved automatically — the wrapped secret always lives in the local _blamejs_per_row_keys, so keyed reads work on every path. A missing wrapped row (crypto-shredded by eraseHard / retention) makes the unwrap throw → the field nulls + system.crypto.unseal_failed fires, which is correct: shredded data reads as absent.

The unseal-failure rate cap is ON BY DEFAULT (default-on, v0.15.0): repeated forged-ciphertext failures for a single (actor, table, column) tuple trip a cooldown (threshold 10 / 1-minute window / 5-minute cooldown out of the box; tune or disable via configureUnsealRateCap). Once tripped, this call THROWS CryptoFieldRateError and emits a distinct system.crypto.unseal_rate_exceeded audit instead of exercising the decryption oracle again (CWE-307). actor identifies the caller for that tuple (e.g. session subject / API key id); it defaults to an anonymous bucket when omitted, and is ignored entirely when the cap is disabled (full back-compat for the 2-arg call).

b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
var clear  = b.cryptoField.unsealRow("patients", sealed);
clear.ssn;   // → "123-45-6789"

b.cryptoField.eraseRow(table, row) #

0.7.10gdprhipaa

Returns a tombstoned copy of row: every sealed column NULLed, every derived-hash mirror NULLed, and __erasedAt set to a 1-day-bucketed UTC ms timestamp (sub-day timing is intentionally fuzzed to defeat audit-log exfiltration + cross-tenant correlation attacks like "this row was erased 2.3s before that one"). Under regulatory postures whose POSTURE_DEFAULTS sets requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br / hipaa), automatically schedules b.db.vacuumAfterErase({ mode: "full" }) so freed B-tree pages don't linger with sealed-column ciphertext readable from a forensic disk image. The row stays in the table for referential integrity; outright DELETE remains the caller's choice when FKs allow.

b.cryptoField.registerTable("patients", {
  sealedFields: ["ssn"],
  derivedHashes: { ssnHash: { from: "ssn" } }
});
var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
var erased = b.cryptoField.eraseRow("patients", sealed);
erased.ssn;        // → null
erased.ssnHash;    // → null
typeof erased.__erasedAt;   // → "number"

b.cryptoField.lookupHash(table, field, value) #

0.4.0

Translates a plaintext-keyed lookup (e.g. where({ email: "..." })) into the derived-hash form (where({ emailHash: hash(...) })). Returns { field, value } naming the derived column and its hash, or null when no derived hash is declared for that source field. Sealed columns without a declared derived hash are unindexable — every encryption uses a fresh random nonce, so the ciphertext alone cannot anchor a query.

value is the digest under the column's ACTIVE mode (keyed-MAC by default since v0.15.0; salted-sha3 when opted out), so existing callers that emit where(result.field, result.value) are unchanged. When the active mode is the keyed MAC, the result ALSO carries legacyValue — the byte-form a row written under the pre-v0.15.0 salted-sha3 default would hold. Callers that can issue a match-EITHER query (or that prefer the ready-made candidate list) use b.cryptoField.lookupHashCandidates; the upgrade-on-read auto-migrate in unsealRow re-hashes any row found via the legacy digest to the keyed-MAC form.

b.cryptoField.registerTable("users", {
  sealedFields: ["email"],
  derivedHashes: { emailHash: { from: "email" } }
});
var lookup = b.cryptoField.lookupHash("users", "email", "alice@example.com");
lookup.field;          // → "emailHash"
typeof lookup.value;   // → "string"

b.cryptoField.lookupHash("users", "name", "Alice");   // → null (no derived hash)

b.cryptoField.lookupHashCandidates(table, field, value) #

0.15.0gdprhipaa

Dual-read sibling of lookupHash. Returns { field, values } where values is the list of derived-hash digests that should ALL be treated as a match for value — the digest under the column's active mode FIRST, plus (when the active mode is the keyed MAC) the pre-v0.15.0 salted-sha3 digest a row written under the old default would carry. A caller that can issue an IN (…) / OR equality over field finds both the new keyed-indexed rows and the legacy salted-indexed rows in one query, so the keyed-MAC default flip never silently drops pre-flip rows. Returns null when no derived hash is declared for field.

Pair it with the upgrade-on-read auto-migrate: unsealRow re-hashes any row whose stored derived-hash matches the legacy digest to the keyed-MAC form, so the candidate list shrinks back to a single value as rows are read over time.

b.cryptoField.registerTable("users", {
  sealedFields:  ["email"],
  derivedHashes: { emailHash: { from: "email" } },
});
var c = b.cryptoField.lookupHashCandidates("users", "email", "alice@example.com");
c.field;            // → "emailHash"
c.values.length;    // → 2  (keyed-MAC + legacy salted-sha3)
// → b.db.from("users").where(c.field, "IN", c.values)

b.cryptoField.declareColumnResidency(table, opts) #

0.7.27gdpr
{
  columnResidency: { [columnName]: "eu" | "us" | "global" |  },
}

Declares per-column data residency for table. Real GDPR / DPDP / pipl-cn deployments have row-level mixed residency: a users.name column may be globally replicable, but users.addressLine1 must stay in EU storage. At write time (b.db.set / b.db.from(...).insert / .update), the framework consults this registry; if the storage backend's tag doesn't satisfy the column's tag, the write is refused under gdpr / dpdp / pipl-cn / uk-gdpr postures. Throws on bad input (config-time fail-loud).

b.cryptoField.declareColumnResidency("users", {
  columnResidency: {
    name:         "global",
    addressLine1: "eu",
    addressLine2: "eu"
  }
});
var got = b.cryptoField.getColumnResidency("users");
got.addressLine1;   // → "eu"

b.cryptoField.getColumnResidency(table) #

0.7.27

Returns the residency map declared for table, or null when the table has no residency declaration. Read-only — does not mutate state. Storage backends use this to inspect residency at the write boundary.

b.cryptoField.declareColumnResidency("users", {
  columnResidency: { addressLine1: "eu" }
});
b.cryptoField.getColumnResidency("users");      // → { addressLine1: "eu" }
b.cryptoField.getColumnResidency("unknown");    // → null

b.cryptoField.assertColumnResidency(table, row, args) #

0.7.27gdpr
{
  backendTag: string,   // tag of the storage backend ("eu" | "us" | "unrestricted")
}

Storage-write gate. Storage backends call this with the proposed row before the SQL hits the wire; refusal under regulated postures surfaces a config-time error rather than a silent cross-border leak. Returns null on pass; returns { error, table, column, want, got } on refusal so the storage backend can wrap it in its own error class. Columns tagged "global" or "unrestricted" pass any backend; columns tagged with a region (e.g. "eu") refuse mismatched backends.

b.cryptoField.declareColumnResidency("users", {
  columnResidency: { addressLine1: "eu" }
});
var refusal = b.cryptoField.assertColumnResidency(
  "users",
  { id: 1, addressLine1: "10 Rue de Rivoli" },
  { backendTag: "us" }
);
refusal.error;    // → "column-residency-mismatch"
refusal.column;   // → "addressLine1"
refusal.want;     // → "eu"
refusal.got;      // → "us"

b.cryptoField.assertColumnResidency(
  "users",
  { id: 1, addressLine1: "10 Rue de Rivoli" },
  { backendTag: "eu" }
);   // → null (pass)

b.cryptoField.declarePerRowResidency(table, opts) #

0.14.24gdpr
{
  residencyColumn: string,    // plaintext column carrying the row's tag
  allowedTags:     string[],  // whitelist of valid tag values ("eu", "us", "global", region names)
}

Declares per-ROW data residency for table: one plaintext column on each row carries that row's residency tag, and the write gates refuse a tagged row landing on an incompatible backend. The sibling of declareColumnResidency — columns answer "which fields are region-bound", rows answer "which region does THIS record belong to" (an EU user's row next to a US user's row in the same table). Local writes (b.db.from(...).insertOne / .update) enforce the tag against the deployment's dataResidency region set under cross-border regulated postures; external writes (b.externalDb.query) take the tag per call via opts.rowResidencyTag because raw SQL carries no row object. Rows tagged "global" or "unrestricted" pass any backend. Throws on bad input (config-time fail-loud).

b.cryptoField.declarePerRowResidency("users", {
  residencyColumn: "dataRegion",
  allowedTags:     ["eu-west-1", "us-east-1", "global"],
});
var spec = b.cryptoField.getPerRowResidency("users");
spec.residencyColumn;   // → "dataRegion"

b.cryptoField.getPerRowResidency(table) #

0.14.24

Returns the per-row residency spec declared for table ({ residencyColumn, allowedTags }), or null when the table has no declaration. Read-only — storage backends call this at the write boundary to decide whether the row-residency gate applies.

b.cryptoField.declarePerRowResidency("users", {
  residencyColumn: "dataRegion",
  allowedTags:     ["eu-west-1", "global"],
});
b.cryptoField.getPerRowResidency("users").allowedTags;   // → ["eu-west-1", "global"]
b.cryptoField.getPerRowResidency("unknown");             // → null

b.cryptoField.listPerRowResidency() #

0.15.4

Enumerate every table opted into per-row residency. Returns one entry per declared table — { table, residencyColumn, allowedTags } — where allowedTags lists the regions that table's rows may be tagged to. Read-only. Consumers that must reason about residency across the whole deployment rather than one table use this: b.backup.create enumerates it to surface the per-row cross-border regions a deployment-level region compare is blind to.

b.cryptoField.declarePerRowResidency("residents", {
  residencyColumn: "region",
  allowedTags:     ["eu-west-1", "us-east-1"],
});
b.cryptoField.listPerRowResidency();
// → [ { table: "residents", residencyColumn: "region",
//       allowedTags: ["eu-west-1", "us-east-1"] } ]

b.cryptoField.declarePerRowKey(table, opts) #

0.7.27gdprhipaa
{
  keySize: number,   // bytes; default 32 (XChaCha20-Poly1305 key length); minimum 16
  info:    string,   // HKDF info label; default "blamejs-per-row-key:"
}

Opts a table into per-row keying (K_row crypto-shred substrate). After registration, every INSERT generates a fresh 32-byte CSPRNG row-secret, derives K_row from it, and stores the SECRET (never K_row) AAD-sealed in _blamejs_per_row_keys (tableName, rowId, wrappedKey). AAD on the wrap binds (table, rowId, column, schemaVersion) — a wrapped secret copied to a different row fails Poly1305 verification. b.subject.eraseHard(subjectId) / b.retention destroy the per-row entries for the subject's rows; WAL / replica residual ciphertext becomes mathematically undecryptable because the random row-secret — the only seed for K_row — is gone everywhere it ever lived. Throws on bad input (config-time fail-loud).

var spec = b.cryptoField.declarePerRowKey("orders", {
  keySize: 32,
  info:    "blamejs-per-row-key:orders"
});
spec.keySize;                          // → 32
b.cryptoField.hasPerRowKey("orders");  // → true

b.cryptoField.hasPerRowKey(table) #

0.7.27

Returns true when table has been registered for per-row keying via declarePerRowKey, false otherwise. Storage backends gate the K_row materialize/destroy paths through this check.

b.cryptoField.hasPerRowKey("orders");   // → false
b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
b.cryptoField.hasPerRowKey("orders");   // → true

b.cryptoField.materializePerRowKey(table, rowId, dbHandle) #

0.7.27gdprhipaa

Derive-and-store: called by the storage backend on INSERT (the db-query write boundary, gated on hasPerRowKey). Generates a fresh 32-byte CSPRNG row-secret, derives K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":" || info, keySize), AAD-seals the SECRET (base64) into _blamejs_per_row_keys.wrappedKey via b.vault.aad.seal, and returns the unwrapped K_row Buffer for the caller to encrypt sealed columns under the row-scoped key. The secret is random — never a function of any on-disk salt — so destroying the wrapped secret makes K_row unrecoverable even with full disk + vault-root access. Idempotent on UPSERT — if a secret already exists for (table, rowId), unwraps it and re-derives the same K_row. The AAD-bound wrap rejects copy-row attacks: a wrapped secret pasted under a different rowId fails Poly1305 verification at unseal time. dbHandle is a b.db handle (.prepare); rowId MUST be the row's _id (the value destroyPerRowKey / b.subject.eraseHard delete on).

b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
var dbHandle = b.db.handle();
var kRow = b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
Buffer.isBuffer(kRow);   // → true
kRow.length;             // → 32

// Idempotent — second call returns the same key.
var kRowAgain = b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);
kRow.equals(kRowAgain);  // → true

b.cryptoField.destroyPerRowKey(table, rowId, dbHandle) #

0.7.27gdprhipaa

Crypto-shred: drops the row's wrapped row-secret from _blamejs_per_row_keys. Called by b.subject.eraseHard and b.retention for each row mapped to the erased subject. Returns { destroyed: }. After destruction, any WAL / replica residual ciphertext for the row is mathematically undecryptable — even with the vault root key — because the random row-secret (the only seed for K_row) is gone everywhere it ever lived. rowId MUST be the row's _id. No-op when the table is not registered for per-row keying.

b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
var dbHandle = b.db.handle();
b.cryptoField.materializePerRowKey("orders", "ord-42", dbHandle);

var result = b.cryptoField.destroyPerRowKey("orders", "ord-42", dbHandle);
result.destroyed;   // → 1

// Subsequent destroy is a no-op.
b.cryptoField.destroyPerRowKey("orders", "ord-42", dbHandle).destroyed;   // → 0

b.cryptoField.clearResidencyForTest() #

experimental0.7.27

Test-only helper. Drops every entry from the per-column residency registry, the per-row residency registry, and the per-row-key registry so a test fixture can re-declare them between cases. Operator code never calls this — production declarations come from b.db.init({ schema }) once at boot.

b.cryptoField.declareColumnResidency("users", {
  columnResidency: { addressLine1: "eu" }
});
b.cryptoField.clearResidencyForTest();
b.cryptoField.getColumnResidency("users");   // → null

Last updated 2026-08-08T16:39:49.652Z by seeder.