@blamejs/core 0.5.0 → 0.5.1

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 CHANGED
@@ -6,6 +6,10 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.5.x
10
+
11
+ - **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
12
+
9
13
  ## v0.4.x
10
14
 
11
15
  - **0.4.29** (2026-04-30) — primitive-drift sweep: second-pass remediation
package/lib/audit.js CHANGED
@@ -201,7 +201,7 @@ var FRAMEWORK_NAMESPACES = [
201
201
  // Per-primitive namespaces — keep alphabetical
202
202
  "apikey", // b.apiKey
203
203
  "backup", // b.backup
204
- "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth
204
+ "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
205
205
  "cache", // b.cache
206
206
  "dkim", // b.mail.dkim (DKIM-Signature generation events)
207
207
  "mail", // b.mail (b.mail-bounce uses "system.mail.*")
@@ -40,7 +40,7 @@
40
40
  var audit = require("./audit");
41
41
  var C = require("./constants");
42
42
  var clusterStorage = require("./cluster-storage");
43
- var { generateToken, sha3Hash } = require("./crypto");
43
+ var { generateBytes, generateToken, kdf, sha3Hash, encryptPacked, decryptPacked } = require("./crypto");
44
44
  var cryptoField = require("./crypto-field");
45
45
  var lazyRequire = require("./lazy-require");
46
46
  var observability = require("./observability");
@@ -51,6 +51,8 @@ var totp = require("./totp");
51
51
  var validateOpts = require("./validate-opts");
52
52
  var { defineClass } = require("./framework-error");
53
53
 
54
+ var vault = lazyRequire(function () { return require("./vault"); });
55
+
54
56
  var lockout = lazyRequire(function () { return require("./auth/lockout"); });
55
57
 
56
58
  // Errors — all 14 codes documented in the spec. `permanent: true`
@@ -97,6 +99,208 @@ function _ensureFactorLockout() {
97
99
  return _factorLockout;
98
100
  }
99
101
 
102
+ // ---- Cryptographic mode (Model B) — per-cell encryption with context binding ----
103
+ //
104
+ // Each policy in cryptographic mode has a per-policy DEK (data
105
+ // encryption key) generated at first use. The DEK is vault-sealed so
106
+ // it survives restarts. At cell encrypt time, the framework derives a
107
+ // per-cell key K_cell = SHAKE256(DEK || table || rowId || column) so
108
+ // every (table, rowId, column) triple gets a unique key. Encryption
109
+ // uses XChaCha20-Poly1305 with AAD = SHA3-512(table || rowId || column)
110
+ // — the AEAD tag itself is bound to the encryption context, so a
111
+ // ciphertext from row A literally cannot be decrypted as row B even
112
+ // with the same DEK.
113
+ //
114
+ // THREAT MODEL HONESTY: this provides defense-in-depth via per-cell
115
+ // keys + encryption-context binding (cross-cell tampering / accidental
116
+ // row-swap fails closed). It does NOT defend against vault-key
117
+ // compromise alone — the DEK is still vault-recoverable. True
118
+ // second-factor cryptographic gating ships in v0.5.2 with passkey
119
+ // integration (the passkey private key lives on the YubiKey, not in
120
+ // the framework, so a vault leak alone can't unwrap).
121
+
122
+ // In-memory DEK cache. Keyed by table name. Cleared on _resetForTest.
123
+ var dekCache = new Map();
124
+
125
+ function _aadFor(table, rowId, column) {
126
+ return sha3Hash(table + "|" + String(rowId) + "|" + column);
127
+ }
128
+
129
+ function _kCell(dek, table, rowId, column) {
130
+ return kdf(Buffer.concat([
131
+ Buffer.isBuffer(dek) ? dek : Buffer.from(dek, "base64"),
132
+ Buffer.from("breakglass.cell|" + table + "|" + String(rowId) + "|" + column, "utf8"),
133
+ ]), 32);
134
+ }
135
+
136
+ async function _ensureDek(table) {
137
+ if (dekCache.has(table)) return dekCache.get(table);
138
+ // The DEK lives in the policy row's serviceAccountBypassJson slot
139
+ // (repurposed in Model A; v0.5.0 explicitly rejected the slot, so
140
+ // it's unused in Model A rows). For Model B rows, we vault-seal the
141
+ // DEK and store it there. Pre-v1 schema: clean and simple.
142
+ var rows = await clusterStorage.executeAll(
143
+ "SELECT serviceAccountBypassJson FROM _blamejs_break_glass_policies WHERE tableName = ?",
144
+ [table]
145
+ );
146
+ if (!rows || rows.length === 0) {
147
+ throw new BreakGlassError("breakglass/policy-not-set",
148
+ "_ensureDek: no policy for table '" + table + "'", true);
149
+ }
150
+ var sealed = rows[0].serviceAccountBypassJson;
151
+ var dek;
152
+ if (sealed) {
153
+ dek = Buffer.from(vault().unseal(sealed), "base64");
154
+ } else {
155
+ dek = generateBytes(32);
156
+ var sealedDek = vault().seal(dek.toString("base64"));
157
+ await clusterStorage.execute(
158
+ "UPDATE _blamejs_break_glass_policies SET serviceAccountBypassJson = ? WHERE tableName = ?",
159
+ [sealedDek, table]
160
+ );
161
+ }
162
+ dekCache.set(table, dek);
163
+ return dek;
164
+ }
165
+
166
+ // Encrypt a single cell value with encryption context binding. Operators
167
+ // in cryptographic mode use this at write time INSTEAD of letting
168
+ // cryptoField.sealRow seal the column.
169
+ async function encryptCell(plaintext, ctx) {
170
+ _requireInit();
171
+ if (!ctx || typeof ctx !== "object" ||
172
+ typeof ctx.table !== "string" || ctx.table.length === 0 ||
173
+ ctx.rowId === undefined || ctx.rowId === null ||
174
+ typeof ctx.column !== "string" || ctx.column.length === 0) {
175
+ throw new BreakGlassError("breakglass/bad-cell-ctx",
176
+ "encryptCell: ctx must be { table, rowId, column }");
177
+ }
178
+ var policy = await policyGet(ctx.table);
179
+ if (!policy || !policy.cryptographic) {
180
+ throw new BreakGlassError("breakglass/policy-not-set",
181
+ "encryptCell: table '" + ctx.table + "' is not in cryptographic mode " +
182
+ "(set policy.cryptographic = true to opt in)", true);
183
+ }
184
+ if (policy.columns.indexOf(ctx.column) === -1) {
185
+ throw new BreakGlassError("breakglass/grant-column-mismatch",
186
+ "encryptCell: column '" + ctx.column + "' is not glass-locked on '" + ctx.table + "'", true);
187
+ }
188
+ var dek = await _ensureDek(ctx.table);
189
+ var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
190
+ var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
191
+ var pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
192
+ var packed = encryptPacked(pt, kCell, aad);
193
+ return "bgcell:1:" + packed.toString("base64");
194
+ }
195
+
196
+ // Decrypt a cell value. Caller must hold a valid grant covering the
197
+ // (table, column) — caller passes through unsealRow which gates this.
198
+ // The encryption context (table, rowId, column) is passed both to the
199
+ // key derivation AND the AAD; if the caller passes the wrong rowId
200
+ // trying to "swap" ciphertexts between rows, decryption fails closed.
201
+ async function decryptCell(ciphertext, ctx) {
202
+ _requireInit();
203
+ if (typeof ciphertext !== "string" || ciphertext.indexOf("bgcell:1:") !== 0) {
204
+ throw new BreakGlassError("breakglass/bad-ciphertext",
205
+ "decryptCell: expected 'bgcell:1:<base64>' format");
206
+ }
207
+ if (!ctx || typeof ctx !== "object") {
208
+ throw new BreakGlassError("breakglass/bad-cell-ctx",
209
+ "decryptCell: ctx must be { table, rowId, column }");
210
+ }
211
+ var dek = await _ensureDek(ctx.table);
212
+ var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
213
+ var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
214
+ var packed = Buffer.from(ciphertext.slice("bgcell:1:".length), "base64");
215
+ return decryptPacked(packed, kCell, aad).toString("utf8");
216
+ }
217
+
218
+ // ---- Migration support ----
219
+ //
220
+ // Operator runs `b.breakGlass.migrate(table, opts)` (or the CLI
221
+ // equivalent) to convert existing Model-A-sealed rows into Model B
222
+ // per-cell-encrypted form. Each row's glass-locked columns are
223
+ // unsealed via cryptoField, re-encrypted via encryptCell, and written
224
+ // back. The migration is idempotent — a row already in Model B form
225
+ // (column starts with "bgcell:") is skipped.
226
+
227
+ async function migrate(table, opts) {
228
+ _requireInit();
229
+ opts = opts || {};
230
+ validateOpts(opts, ["batchSize", "callerOpts"], "breakGlass.migrate");
231
+ var policy = await policyGet(table);
232
+ if (!policy) {
233
+ throw new BreakGlassError("breakglass/policy-not-set",
234
+ "migrate: no policy for table '" + table + "'", true);
235
+ }
236
+ if (!policy.cryptographic) {
237
+ throw new BreakGlassError("breakglass/bad-policy",
238
+ "migrate: policy must be cryptographic: true to migrate to Model B", true);
239
+ }
240
+ var batchSize = opts.batchSize || 100;
241
+ var totalRows = 0;
242
+ var migratedRows = 0;
243
+ var skippedRows = 0;
244
+ var lastId = "";
245
+ // Iterate via _id-keyset paging so we don't load the whole table into memory.
246
+ while (true) {
247
+ var rows = await clusterStorage.executeAll(
248
+ "SELECT * FROM " + table + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
249
+ [lastId, batchSize]
250
+ );
251
+ if (!rows || rows.length === 0) break;
252
+ for (var i = 0; i < rows.length; i++) {
253
+ totalRows++;
254
+ var row = rows[i];
255
+ var unsealed = cryptoField.unsealRow(table, row);
256
+ var anyChanged = false;
257
+ var update = { _id: row._id };
258
+ for (var c = 0; c < policy.columns.length; c++) {
259
+ var col = policy.columns[c];
260
+ var current = unsealed[col];
261
+ if (current == null) continue;
262
+ if (typeof current === "string" && current.indexOf("bgcell:") === 0) {
263
+ continue; // already migrated
264
+ }
265
+ var encrypted = await encryptCell(current, { table: table, rowId: row._id, column: col });
266
+ update[col] = encrypted;
267
+ anyChanged = true;
268
+ }
269
+ if (anyChanged) {
270
+ // Write Model B ciphertext directly — bypassing cryptoField so
271
+ // the cell ciphertext stays as a literal string, not double-sealed.
272
+ var setCols = Object.keys(update).filter(function (k) { return k !== "_id"; });
273
+ if (setCols.length > 0) {
274
+ var setSql = setCols.map(function (k) { return k + " = ?"; }).join(", ");
275
+ var vals = setCols.map(function (k) { return update[k]; });
276
+ vals.push(row._id);
277
+ await clusterStorage.execute(
278
+ "UPDATE " + table + " SET " + setSql + " WHERE _id = ?",
279
+ vals
280
+ );
281
+ migratedRows++;
282
+ }
283
+ } else {
284
+ skippedRows++;
285
+ }
286
+ lastId = row._id;
287
+ }
288
+ if (rows.length < batchSize) break;
289
+ }
290
+ audit.safeEmit({
291
+ action: "breakglass.migrate",
292
+ outcome: "success",
293
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
294
+ metadata: {
295
+ table: table,
296
+ totalRows: totalRows,
297
+ migratedRows: migratedRows,
298
+ skippedRows: skippedRows,
299
+ },
300
+ });
301
+ return { table: table, totalRows: totalRows, migratedRows: migratedRows, skippedRows: skippedRows };
302
+ }
303
+
100
304
  // ---- init ----
101
305
 
102
306
  function init(opts) {
@@ -110,6 +314,7 @@ function init(opts) {
110
314
  function _resetForTest() {
111
315
  initialized = false;
112
316
  policyCache.clear();
317
+ dekCache.clear();
113
318
  if (_factorLockoutCache && typeof _factorLockoutCache.close === "function") {
114
319
  try { _factorLockoutCache.close(); } catch (_e) { /* best-effort */ }
115
320
  }
@@ -163,10 +368,16 @@ function _validatePolicySet(table, opts) {
163
368
  " (passkey lands in v0.5.2)");
164
369
  }
165
370
  }
166
- if (opts.cryptographic === true) {
371
+ // Model B (cryptographic mode) ships in v0.5.1. When enabled,
372
+ // glass-locked columns must be encrypted with `b.breakGlass.encryptCell`
373
+ // at write time (the framework can't auto-encrypt at write because
374
+ // policy-set may post-date existing data; operators run the migration
375
+ // CLI to convert existing rows). At unseal time, the row's
376
+ // glass-locked columns are decrypted via decryptCell with encryption
377
+ // context binding (table, rowId, column).
378
+ if (opts.cryptographic !== undefined && typeof opts.cryptographic !== "boolean") {
167
379
  throw new BreakGlassError("breakglass/bad-policy",
168
- "policy.set: cryptographic mode (Model B) ships in v0.5.1 — " +
169
- "set cryptographic: false (default) or omit for v0.5.0");
380
+ "policy.set: cryptographic must be a boolean");
170
381
  }
171
382
  var grantTtl = opts.grantTtl != null ? opts.grantTtl : DEFAULT_GRANT_TTL_MS;
172
383
  if (typeof grantTtl !== "number" || !isFinite(grantTtl) || grantTtl <= 0) {
@@ -193,6 +404,7 @@ function _validatePolicySet(table, opts) {
193
404
  "policy.set: serviceAccountBypass ships in v0.5.2 — leave unset for v0.5.0");
194
405
  }
195
406
  return {
407
+ cryptographic: opts.cryptographic === true,
196
408
  grantTtl: grantTtl,
197
409
  maxRowsPerGrant: maxRows,
198
410
  reasonRequired: opts.reasonRequired !== false,
@@ -212,7 +424,7 @@ async function policySet(table, opts, callerOpts) {
212
424
  tableName: table,
213
425
  columnsJson: JSON.stringify(opts.columns),
214
426
  factorsJson: JSON.stringify(opts.factors),
215
- cryptographic: 0,
427
+ cryptographic: validated.cryptographic ? 1 : 0,
216
428
  grantTtlMs: validated.grantTtl,
217
429
  maxRowsPerGrant: validated.maxRowsPerGrant,
218
430
  reasonRequired: validated.reasonRequired ? 1 : 0,
@@ -592,7 +804,11 @@ async function unsealRow(grantHandle, table, rowId) {
592
804
  }
593
805
  void updateRes;
594
806
 
595
- // Fetch + unseal the target row through cryptoField
807
+ // Fetch + unseal the target row. Model A goes straight through
808
+ // cryptoField; Model B reads the row, lets cryptoField unseal the
809
+ // non-glass-locked columns, and then decryptCell handles the
810
+ // glass-locked columns separately (their ciphertext was written
811
+ // by encryptCell at app-write time, not by cryptoField.sealRow).
596
812
  var rows = await clusterStorage.executeAll(
597
813
  "SELECT * FROM " + table + " WHERE _id = ?",
598
814
  [String(rowId)]
@@ -601,12 +817,43 @@ async function unsealRow(grantHandle, table, rowId) {
601
817
  throw new BreakGlassError("breakglass/row-not-found",
602
818
  "unsealRow: " + table + "[" + rowId + "] not found", true);
603
819
  }
604
- var unsealedRow = cryptoField.unsealRow(table, rows[0]);
820
+ var policy = await policyGet(table);
821
+ var unsealedRow;
822
+ if (policy && policy.cryptographic) {
823
+ // Snapshot the raw glass-locked column ciphertexts BEFORE
824
+ // cryptoField.unsealRow runs — cryptoField doesn't know about the
825
+ // bgcell: format and would no-op (or error) on it. Then unseal the
826
+ // rest of the row, then decrypt the glass-locked columns via
827
+ // decryptCell with encryption-context binding.
828
+ var rawCipher = {};
829
+ for (var c = 0; c < policy.columns.length; c++) {
830
+ rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
831
+ }
832
+ var rowMinusLocked = Object.assign({}, rows[0]);
833
+ for (var c2 = 0; c2 < policy.columns.length; c2++) {
834
+ delete rowMinusLocked[policy.columns[c2]];
835
+ }
836
+ unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
837
+ for (var c3 = 0; c3 < policy.columns.length; c3++) {
838
+ var col = policy.columns[c3];
839
+ if (rawCipher[col] == null) continue;
840
+ try {
841
+ unsealedRow[col] = await decryptCell(rawCipher[col],
842
+ { table: table, rowId: String(rowId), column: col });
843
+ } catch (e) {
844
+ throw new BreakGlassError("breakglass/cell-decrypt-failed",
845
+ "unsealRow: cell decrypt failed for " + table + "[" + rowId +
846
+ "]." + col + " (was the row migrated to Model B?): " +
847
+ ((e && e.message) || String(e)), true);
848
+ }
849
+ }
850
+ } else {
851
+ unsealedRow = cryptoField.unsealRow(table, rows[0]);
852
+ }
605
853
 
606
854
  // Per-row audit. The grant's reasonSealed is already cleartext after
607
855
  // unsealRow on the grant; pass it into the audit row honoring the
608
856
  // policy's auditReasonStorage mode.
609
- var policy = await policyGet(table);
610
857
  var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
611
858
  policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
612
859
  audit.safeEmit({
@@ -731,6 +978,12 @@ module.exports = {
731
978
  unsealRow: unsealRow,
732
979
  revoke: revoke,
733
980
  listActive: listActive,
981
+ // Cryptographic mode (Model B) — per-cell encryption with context
982
+ // binding. Operators in cryptographic mode call encryptCell at write
983
+ // time; unsealRow auto-routes to decryptCell for glass-locked columns.
984
+ encryptCell: encryptCell,
985
+ decryptCell: decryptCell,
986
+ migrate: migrate,
734
987
  BreakGlassError: BreakGlassError,
735
988
 
736
989
  // Test-only / sweep — operators with active grant volume wire this
package/lib/crypto.js CHANGED
@@ -206,9 +206,16 @@ function decryptEnvelope(packed, privateKeys) {
206
206
  }
207
207
 
208
208
  // ---- Symmetric buffer encrypt/decrypt (for storage) ----
209
- function encryptPacked(buffer, key) {
209
+ //
210
+ // Optional `aad` (additional authenticated data) is mixed into the
211
+ // Poly1305 tag — encrypt-time and decrypt-time AAD must match exactly
212
+ // or decrypt fails. Used by primitives that want encryption-context
213
+ // binding (b.breakGlass.encryptCell binds (table, rowId, column) so a
214
+ // ciphertext from row A literally cannot decrypt as row B even with
215
+ // the same key).
216
+ function encryptPacked(buffer, key, aad) {
210
217
  var nonce = random(24);
211
- var ct = xchacha20poly1305(key, nonce).encrypt(buffer);
218
+ var ct = xchacha20poly1305(key, nonce, aad ? Buffer.from(aad) : undefined).encrypt(buffer);
212
219
  return Buffer.concat([
213
220
  Buffer.from([C.FORMAT.XCHACHA20_POLY1305]),
214
221
  Buffer.from(nonce),
@@ -216,12 +223,13 @@ function encryptPacked(buffer, key) {
216
223
  ]);
217
224
  }
218
225
 
219
- function decryptPacked(packed, key) {
226
+ function decryptPacked(packed, key, aad) {
220
227
  if (packed[0] !== C.FORMAT.XCHACHA20_POLY1305) {
221
228
  throw new Error("Invalid packed format: unsupported version");
222
229
  }
223
230
  return Buffer.from(
224
- xchacha20poly1305(key, packed.subarray(1, 25)).decrypt(packed.subarray(25))
231
+ xchacha20poly1305(key, packed.subarray(1, 25), aad ? Buffer.from(aad) : undefined)
232
+ .decrypt(packed.subarray(25))
225
233
  );
226
234
  }
227
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",