@blamejs/core 0.5.0 → 0.5.2

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,11 @@ 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.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
12
+ - **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
13
+
9
14
  ## v0.4.x
10
15
 
11
16
  - **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,7 +51,10 @@ 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"); });
57
+ var passkey = lazyRequire(function () { return require("./auth/passkey"); });
55
58
 
56
59
  // Errors — all 14 codes documented in the spec. `permanent: true`
57
60
  // means caller's input is bad (Tier-A); `permanent: false` means
@@ -65,7 +68,7 @@ var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
65
68
  var DEFAULT_REASON_MIN_LEN = 12;
66
69
  var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
67
70
  var DEFAULT_AUDIT_REASON = "cleartext";
68
- var ALLOWED_FACTORS = ["totp"]; // passkey added in v0.5.2
71
+ var ALLOWED_FACTORS = ["totp", "passkey"];
69
72
  var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
70
73
 
71
74
  // In-memory policy cache. Cluster-shared via the policies table; the
@@ -97,6 +100,207 @@ function _ensureFactorLockout() {
97
100
  return _factorLockout;
98
101
  }
99
102
 
103
+ // ---- Cryptographic mode (Model B) — per-cell encryption with context binding ----
104
+ //
105
+ // Each policy in cryptographic mode has a per-policy DEK (data
106
+ // encryption key) generated at first use. The DEK is vault-sealed so
107
+ // it survives restarts. At cell encrypt time, the framework derives a
108
+ // per-cell key K_cell = SHAKE256(DEK || table || rowId || column) so
109
+ // every (table, rowId, column) triple gets a unique key. Encryption
110
+ // uses XChaCha20-Poly1305 with AAD = SHA3-512(table || rowId || column)
111
+ // — the AEAD tag itself is bound to the encryption context, so a
112
+ // ciphertext from row A literally cannot be decrypted as row B even
113
+ // with the same DEK.
114
+ //
115
+ // THREAT MODEL HONESTY: this provides defense-in-depth via per-cell
116
+ // keys + encryption-context binding (cross-cell tampering / accidental
117
+ // row-swap fails closed). It does NOT defend against vault-key
118
+ // compromise alone — the DEK is still vault-recoverable. True
119
+ // second-factor cryptographic gating ships in v0.5.2 with passkey
120
+ // integration (the passkey private key lives on the YubiKey, not in
121
+ // the framework, so a vault leak alone can't unwrap).
122
+
123
+ // In-memory DEK cache. Keyed by table name. Cleared on _resetForTest.
124
+ var dekCache = new Map();
125
+
126
+ function _aadFor(table, rowId, column) {
127
+ return sha3Hash(table + "|" + String(rowId) + "|" + column);
128
+ }
129
+
130
+ function _kCell(dek, table, rowId, column) {
131
+ return kdf(Buffer.concat([
132
+ Buffer.isBuffer(dek) ? dek : Buffer.from(dek, "base64"),
133
+ Buffer.from("breakglass.cell|" + table + "|" + String(rowId) + "|" + column, "utf8"),
134
+ ]), 32);
135
+ }
136
+
137
+ async function _ensureDek(table) {
138
+ if (dekCache.has(table)) return dekCache.get(table);
139
+ // DEK is vault-sealed and stored in the policy row's `dekSealed`
140
+ // column. Generated lazily on first use of cryptographic-mode for
141
+ // the table. Cached in-memory after first read.
142
+ var rows = await clusterStorage.executeAll(
143
+ "SELECT dekSealed 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].dekSealed;
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 dekSealed = ? 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) {
@@ -188,11 +399,38 @@ function _validatePolicySet(table, opts) {
188
399
  throw new BreakGlassError("breakglass/bad-policy",
189
400
  "policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
190
401
  }
402
+ // Service-account bypass: explicit opt-in per table. Operators
403
+ // declare the apiKey ids that may bypass + a required role; the
404
+ // framework requires BOTH to grant the bypass. Without this opt set,
405
+ // there is NO bypass path — every read of a glass-locked column
406
+ // requires a fresh grant.
407
+ var serviceAccountBypass = null;
191
408
  if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
192
- throw new BreakGlassError("breakglass/bad-policy",
193
- "policy.set: serviceAccountBypass ships in v0.5.2 leave unset for v0.5.0");
409
+ var sab = opts.serviceAccountBypass;
410
+ if (!sab || typeof sab !== "object") {
411
+ throw new BreakGlassError("breakglass/bad-policy",
412
+ "policy.set: serviceAccountBypass must be an object { enabled, apiKeyIds, requireRole }");
413
+ }
414
+ if (sab.enabled !== true) {
415
+ throw new BreakGlassError("breakglass/bad-policy",
416
+ "policy.set: serviceAccountBypass.enabled must be true (set serviceAccountBypass: false to disable)");
417
+ }
418
+ if (!Array.isArray(sab.apiKeyIds) || sab.apiKeyIds.length === 0) {
419
+ throw new BreakGlassError("breakglass/bad-policy",
420
+ "policy.set: serviceAccountBypass.apiKeyIds must be a non-empty array of apiKey ids");
421
+ }
422
+ if (typeof sab.requireRole !== "string" || sab.requireRole.length === 0) {
423
+ throw new BreakGlassError("breakglass/bad-policy",
424
+ "policy.set: serviceAccountBypass.requireRole must be a non-empty role / scope string");
425
+ }
426
+ serviceAccountBypass = {
427
+ enabled: true,
428
+ apiKeyIds: sab.apiKeyIds.slice(),
429
+ requireRole: sab.requireRole,
430
+ };
194
431
  }
195
432
  return {
433
+ cryptographic: opts.cryptographic === true,
196
434
  grantTtl: grantTtl,
197
435
  maxRowsPerGrant: maxRows,
198
436
  reasonRequired: opts.reasonRequired !== false,
@@ -202,6 +440,7 @@ function _validatePolicySet(table, opts) {
202
440
  onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
203
441
  requireScope: opts.requireScope != null ? opts.requireScope : null,
204
442
  auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
443
+ serviceAccountBypass: serviceAccountBypass,
205
444
  };
206
445
  }
207
446
 
@@ -212,7 +451,7 @@ async function policySet(table, opts, callerOpts) {
212
451
  tableName: table,
213
452
  columnsJson: JSON.stringify(opts.columns),
214
453
  factorsJson: JSON.stringify(opts.factors),
215
- cryptographic: 0,
454
+ cryptographic: validated.cryptographic ? 1 : 0,
216
455
  grantTtlMs: validated.grantTtl,
217
456
  maxRowsPerGrant: validated.maxRowsPerGrant,
218
457
  reasonRequired: validated.reasonRequired ? 1 : 0,
@@ -221,7 +460,9 @@ async function policySet(table, opts, callerOpts) {
221
460
  sessionPin: validated.sessionPin ? 1 : 0,
222
461
  onLockedAccess: validated.onLockedAccess,
223
462
  requireScope: validated.requireScope,
224
- serviceAccountBypassJson: null,
463
+ serviceAccountBypassJson: validated.serviceAccountBypass
464
+ ? JSON.stringify(validated.serviceAccountBypass)
465
+ : null,
225
466
  auditReasonStorage: validated.auditReasonStorage,
226
467
  updatedAt: Date.now(),
227
468
  };
@@ -280,6 +521,9 @@ async function policyGet(table) {
280
521
  sessionPin: unsealed.sessionPin === 1,
281
522
  onLockedAccess: unsealed.onLockedAccess,
282
523
  requireScope: unsealed.requireScope,
524
+ serviceAccountBypass: unsealed.serviceAccountBypassJson
525
+ ? safeJson.parse(unsealed.serviceAccountBypassJson, { maxBytes: C.BYTES.kib(8) })
526
+ : null,
283
527
  auditReasonStorage: unsealed.auditReasonStorage,
284
528
  updatedAt: Number(unsealed.updatedAt),
285
529
  };
@@ -330,6 +574,34 @@ function _verifyTotpFactor(factor) {
330
574
  return { ok: verified !== false, step: verified };
331
575
  }
332
576
 
577
+ // Passkey factor — operator presents a WebAuthn assertion plus the
578
+ // challenge/origin/RPID + the previously-enrolled credential record.
579
+ // Phishing-resistant; the private key lives on the YubiKey, not in
580
+ // the framework's vault. v0.5.2 uses passkey for identity verification
581
+ // to gate grant issuance; PRF-derived per-policy DEK material (which
582
+ // would give true vault-key-alone-doesn't-decrypt defense) is a
583
+ // follow-up.
584
+ async function _verifyPasskeyFactor(factor) {
585
+ if (!factor || typeof factor !== "object") return { ok: false };
586
+ if (!factor.response || !factor.expectedChallenge ||
587
+ !factor.expectedOrigin || !factor.expectedRPID || !factor.credential) {
588
+ return { ok: false };
589
+ }
590
+ try {
591
+ var result = await passkey().verifyAuthentication({
592
+ response: factor.response,
593
+ expectedChallenge: factor.expectedChallenge,
594
+ expectedOrigin: factor.expectedOrigin,
595
+ expectedRPID: factor.expectedRPID,
596
+ credential: factor.credential,
597
+ requireUserVerification: factor.requireUserVerification !== false,
598
+ });
599
+ return { ok: result && result.verified === true };
600
+ } catch (_e) {
601
+ return { ok: false };
602
+ }
603
+ }
604
+
333
605
  async function grant(opts) {
334
606
  _requireInit();
335
607
  if (!opts || typeof opts !== "object") {
@@ -401,6 +673,8 @@ async function grant(opts) {
401
673
  var factorOk = false;
402
674
  if (factorType === "totp") {
403
675
  factorOk = _verifyTotpFactor(opts.factor).ok;
676
+ } else if (factorType === "passkey") {
677
+ factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
404
678
  }
405
679
 
406
680
  if (!factorOk) {
@@ -592,7 +866,11 @@ async function unsealRow(grantHandle, table, rowId) {
592
866
  }
593
867
  void updateRes;
594
868
 
595
- // Fetch + unseal the target row through cryptoField
869
+ // Fetch + unseal the target row. Model A goes straight through
870
+ // cryptoField; Model B reads the row, lets cryptoField unseal the
871
+ // non-glass-locked columns, and then decryptCell handles the
872
+ // glass-locked columns separately (their ciphertext was written
873
+ // by encryptCell at app-write time, not by cryptoField.sealRow).
596
874
  var rows = await clusterStorage.executeAll(
597
875
  "SELECT * FROM " + table + " WHERE _id = ?",
598
876
  [String(rowId)]
@@ -601,12 +879,43 @@ async function unsealRow(grantHandle, table, rowId) {
601
879
  throw new BreakGlassError("breakglass/row-not-found",
602
880
  "unsealRow: " + table + "[" + rowId + "] not found", true);
603
881
  }
604
- var unsealedRow = cryptoField.unsealRow(table, rows[0]);
882
+ var policy = await policyGet(table);
883
+ var unsealedRow;
884
+ if (policy && policy.cryptographic) {
885
+ // Snapshot the raw glass-locked column ciphertexts BEFORE
886
+ // cryptoField.unsealRow runs — cryptoField doesn't know about the
887
+ // bgcell: format and would no-op (or error) on it. Then unseal the
888
+ // rest of the row, then decrypt the glass-locked columns via
889
+ // decryptCell with encryption-context binding.
890
+ var rawCipher = {};
891
+ for (var c = 0; c < policy.columns.length; c++) {
892
+ rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
893
+ }
894
+ var rowMinusLocked = Object.assign({}, rows[0]);
895
+ for (var c2 = 0; c2 < policy.columns.length; c2++) {
896
+ delete rowMinusLocked[policy.columns[c2]];
897
+ }
898
+ unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
899
+ for (var c3 = 0; c3 < policy.columns.length; c3++) {
900
+ var col = policy.columns[c3];
901
+ if (rawCipher[col] == null) continue;
902
+ try {
903
+ unsealedRow[col] = await decryptCell(rawCipher[col],
904
+ { table: table, rowId: String(rowId), column: col });
905
+ } catch (e) {
906
+ throw new BreakGlassError("breakglass/cell-decrypt-failed",
907
+ "unsealRow: cell decrypt failed for " + table + "[" + rowId +
908
+ "]." + col + " (was the row migrated to Model B?): " +
909
+ ((e && e.message) || String(e)), true);
910
+ }
911
+ }
912
+ } else {
913
+ unsealedRow = cryptoField.unsealRow(table, rows[0]);
914
+ }
605
915
 
606
916
  // Per-row audit. The grant's reasonSealed is already cleartext after
607
917
  // unsealRow on the grant; pass it into the audit row honoring the
608
918
  // policy's auditReasonStorage mode.
609
- var policy = await policyGet(table);
610
919
  var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
611
920
  policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
612
921
  audit.safeEmit({
@@ -690,6 +999,220 @@ async function listActive(opts) {
690
999
  return out;
691
1000
  }
692
1001
 
1002
+ // ---- Service-account bypass ----
1003
+ //
1004
+ // Some legitimate workloads (nightly de-identification, scheduled
1005
+ // compliance reports) need PHI access without a human at the
1006
+ // keyboard. The framework refuses to silently bypass — operators
1007
+ // declare explicit service-account bypasses per-table with an apiKey
1008
+ // allowlist + required role. Both must match (verified apiKey id is
1009
+ // in the allowlist AND the apiKey carries the required role/scope)
1010
+ // before the bypass is granted. Each bypass emits its own distinct
1011
+ // audit row so post-incident review can distinguish operator-initiated
1012
+ // from service-initiated reads.
1013
+
1014
+ async function unsealRowAsService(req, table, rowId, opts) {
1015
+ _requireInit();
1016
+ opts = opts || {};
1017
+ if (!req || typeof req !== "object") {
1018
+ throw new BreakGlassError("breakglass/bad-grant-opts",
1019
+ "unsealRowAsService: req is required (with verified req.apiKey)");
1020
+ }
1021
+ if (typeof table !== "string" || table.length === 0) {
1022
+ throw new BreakGlassError("breakglass/bad-grant-opts",
1023
+ "unsealRowAsService: table must be a non-empty string");
1024
+ }
1025
+ var policy = await policyGet(table);
1026
+ if (!policy) {
1027
+ throw new BreakGlassError("breakglass/policy-not-set",
1028
+ "unsealRowAsService: no break-glass policy for table '" + table + "'", true);
1029
+ }
1030
+ if (!policy.serviceAccountBypass) {
1031
+ throw new BreakGlassError("breakglass/bypass-not-configured",
1032
+ "unsealRowAsService: serviceAccountBypass is not configured for '" + table + "'", true);
1033
+ }
1034
+ var apiKeyOnReq = req.apiKey;
1035
+ if (!apiKeyOnReq || typeof apiKeyOnReq.id !== "string") {
1036
+ throw new BreakGlassError("breakglass/bypass-no-apikey",
1037
+ "unsealRowAsService: req.apiKey.id is required (operator must run b.middleware.requireApiKey before this path)", true);
1038
+ }
1039
+ if (policy.serviceAccountBypass.apiKeyIds.indexOf(apiKeyOnReq.id) === -1) {
1040
+ audit.safeEmit({
1041
+ action: "breakglass.grant.bypass",
1042
+ outcome: "denied",
1043
+ actor: requestHelpers.extractActorContext(req),
1044
+ reason: "apikey-not-in-allowlist",
1045
+ metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id },
1046
+ });
1047
+ throw new BreakGlassError("breakglass/bypass-unauthorized",
1048
+ "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1049
+ "' is not in the bypass allowlist for '" + table + "'", true);
1050
+ }
1051
+ // Role check — actor must carry policy.serviceAccountBypass.requireRole
1052
+ // either as a direct scope or via b.permissions.check resolution.
1053
+ var actorScopes = Array.isArray(apiKeyOnReq.scopes) ? apiKeyOnReq.scopes :
1054
+ Array.isArray(apiKeyOnReq.roles) ? apiKeyOnReq.roles :
1055
+ [];
1056
+ var requiredRole = policy.serviceAccountBypass.requireRole;
1057
+ var hasRole = actorScopes.indexOf(requiredRole) !== -1;
1058
+ if (!hasRole) {
1059
+ audit.safeEmit({
1060
+ action: "breakglass.grant.bypass",
1061
+ outcome: "denied",
1062
+ actor: requestHelpers.extractActorContext(req),
1063
+ reason: "missing-role",
1064
+ metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id, requiredRole: requiredRole },
1065
+ });
1066
+ throw new BreakGlassError("breakglass/bypass-unauthorized",
1067
+ "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1068
+ "' lacks required role '" + requiredRole + "'", true);
1069
+ }
1070
+
1071
+ // Fetch + unseal the row (Model A or Model B path, same as
1072
+ // operator-initiated unsealRow).
1073
+ var rows = await clusterStorage.executeAll(
1074
+ "SELECT * FROM " + table + " WHERE _id = ?",
1075
+ [String(rowId)]
1076
+ );
1077
+ if (!rows || rows.length === 0) {
1078
+ throw new BreakGlassError("breakglass/row-not-found",
1079
+ "unsealRowAsService: " + table + "[" + rowId + "] not found", true);
1080
+ }
1081
+ var unsealedRow;
1082
+ if (policy.cryptographic) {
1083
+ var rawCipher = {};
1084
+ for (var c = 0; c < policy.columns.length; c++) rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
1085
+ var rowMinusLocked = Object.assign({}, rows[0]);
1086
+ for (var c2 = 0; c2 < policy.columns.length; c2++) delete rowMinusLocked[policy.columns[c2]];
1087
+ unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
1088
+ for (var c3 = 0; c3 < policy.columns.length; c3++) {
1089
+ var col = policy.columns[c3];
1090
+ if (rawCipher[col] == null) continue;
1091
+ unsealedRow[col] = await decryptCell(rawCipher[col],
1092
+ { table: table, rowId: String(rowId), column: col });
1093
+ }
1094
+ } else {
1095
+ unsealedRow = cryptoField.unsealRow(table, rows[0]);
1096
+ }
1097
+
1098
+ audit.safeEmit({
1099
+ action: "breakglass.grant.bypass",
1100
+ outcome: "success",
1101
+ actor: requestHelpers.extractActorContext(req),
1102
+ reason: typeof opts.reason === "string" ? opts.reason : null,
1103
+ metadata: {
1104
+ table: table,
1105
+ rowId: String(rowId),
1106
+ apiKeyId: apiKeyOnReq.id,
1107
+ requiredRole: requiredRole,
1108
+ columns: policy.columns.slice(),
1109
+ },
1110
+ });
1111
+ observability.event("breakglass.grant.bypass", { table: table });
1112
+ return unsealedRow;
1113
+ }
1114
+
1115
+ // ---- Admin tools ----
1116
+ //
1117
+ // `listActiveAll` returns every active grant (across all actors) —
1118
+ // for security-team dashboards and offboarding workflows.
1119
+ // `revokeAll` mass-revokes grants matching criteria — used by IR
1120
+ // teams when an account is suspected compromised. Both require
1121
+ // admin scope (operator wires via opts.requireScope or their own gate).
1122
+
1123
+ async function listActiveAll(opts) {
1124
+ _requireInit();
1125
+ opts = opts || {};
1126
+ var nowMs = Date.now();
1127
+ var clauses = ["(revokedAt IS NULL)", "expiresAt > ?", "rowsConsumed < maxRowsPerGrant"];
1128
+ var params = [nowMs];
1129
+ if (opts.table) {
1130
+ clauses.push("scopeTable = ?");
1131
+ params.push(opts.table);
1132
+ }
1133
+ if (opts.since) {
1134
+ clauses.push("issuedAt >= ?");
1135
+ params.push(opts.since);
1136
+ }
1137
+ var rows = await clusterStorage.executeAll(
1138
+ "SELECT * FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND ") +
1139
+ " ORDER BY issuedAt DESC",
1140
+ params
1141
+ );
1142
+ var out = [];
1143
+ for (var i = 0; i < (rows || []).length; i++) {
1144
+ var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
1145
+ out.push({
1146
+ id: u._id,
1147
+ issuedToActorId: u.issuedToActorId,
1148
+ scopeTable: u.scopeTable,
1149
+ scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
1150
+ factorType: u.factorType,
1151
+ issuedAt: Number(u.issuedAt),
1152
+ expiresAt: Number(u.expiresAt),
1153
+ rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
1154
+ });
1155
+ }
1156
+ audit.safeEmit({
1157
+ action: "breakglass.admin.listactiveall",
1158
+ outcome: "success",
1159
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1160
+ metadata: { resultCount: out.length, filterTable: opts.table || null },
1161
+ });
1162
+ return out;
1163
+ }
1164
+
1165
+ async function revokeAll(criteria, opts) {
1166
+ _requireInit();
1167
+ if (!criteria || typeof criteria !== "object") {
1168
+ throw new BreakGlassError("breakglass/bad-revoke-criteria",
1169
+ "revokeAll: criteria is required ({ actorId?, table? })");
1170
+ }
1171
+ if (!criteria.actorId && !criteria.table) {
1172
+ throw new BreakGlassError("breakglass/bad-revoke-criteria",
1173
+ "revokeAll: at least one of { actorId, table } is required (refusing to mass-revoke without scope)");
1174
+ }
1175
+ opts = opts || {};
1176
+ var clauses = ["revokedAt IS NULL"];
1177
+ var params = [];
1178
+ if (criteria.actorId) {
1179
+ var derived = cryptoField.computeDerived(
1180
+ "_blamejs_break_glass_grants", "issuedToActorId", criteria.actorId
1181
+ );
1182
+ if (derived) {
1183
+ clauses.push("issuedToActorHash = ?");
1184
+ params.push(derived.value);
1185
+ }
1186
+ }
1187
+ if (criteria.table) {
1188
+ clauses.push("scopeTable = ?");
1189
+ params.push(criteria.table);
1190
+ }
1191
+ // Snapshot the to-be-revoked grant ids first so audit captures specifics.
1192
+ var ids = await clusterStorage.executeAll(
1193
+ "SELECT _id FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND "),
1194
+ params
1195
+ );
1196
+ var nowMs = Date.now();
1197
+ await clusterStorage.execute(
1198
+ "UPDATE _blamejs_break_glass_grants SET revokedAt = ? WHERE " + clauses.join(" AND "),
1199
+ [nowMs].concat(params)
1200
+ );
1201
+ audit.safeEmit({
1202
+ action: "breakglass.admin.revokeall",
1203
+ outcome: "success",
1204
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1205
+ reason: typeof criteria.reason === "string" ? criteria.reason : null,
1206
+ metadata: {
1207
+ filterActorId: criteria.actorId || null,
1208
+ filterTable: criteria.table || null,
1209
+ revokedCount: (ids || []).length,
1210
+ revokedIds: (ids || []).map(function (r) { return r._id; }),
1211
+ },
1212
+ });
1213
+ return { revokedCount: (ids || []).length };
1214
+ }
1215
+
693
1216
  // ---- Sweep (best-effort cleanup of expired grants) ----
694
1217
 
695
1218
  async function _sweepExpired(opts) {
@@ -731,6 +1254,20 @@ module.exports = {
731
1254
  unsealRow: unsealRow,
732
1255
  revoke: revoke,
733
1256
  listActive: listActive,
1257
+ // Cryptographic mode (Model B) — per-cell encryption with context
1258
+ // binding. Operators in cryptographic mode call encryptCell at write
1259
+ // time; unsealRow auto-routes to decryptCell for glass-locked columns.
1260
+ encryptCell: encryptCell,
1261
+ decryptCell: decryptCell,
1262
+ migrate: migrate,
1263
+ // Service-account bypass — operator-declared per-table, gated by
1264
+ // (apiKey id in allowlist) AND (apiKey carries required role).
1265
+ unsealRowAsService: unsealRowAsService,
1266
+ // Admin tools — for security-team dashboards (listActiveAll) and
1267
+ // incident-response offboarding (revokeAll). Operators wire their
1268
+ // own gate (requireScope or middleware) on the calling routes.
1269
+ listActiveAll: listActiveAll,
1270
+ revokeAll: revokeAll,
734
1271
  BreakGlassError: BreakGlassError,
735
1272
 
736
1273
  // 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/lib/db.js CHANGED
@@ -446,6 +446,7 @@ var FRAMEWORK_SCHEMA = [
446
446
  onLockedAccess: "TEXT NOT NULL DEFAULT 'throw'",
447
447
  requireScope: "TEXT",
448
448
  serviceAccountBypassJson: "TEXT",
449
+ dekSealed: "TEXT",
449
450
  auditReasonStorage: "TEXT NOT NULL DEFAULT 'cleartext'",
450
451
  updatedAt: "INTEGER NOT NULL",
451
452
  },
@@ -584,6 +584,7 @@ function _breakGlassPoliciesDDL(dialect) {
584
584
  " onLockedAccess TEXT NOT NULL DEFAULT 'throw'," +
585
585
  " requireScope TEXT," +
586
586
  " serviceAccountBypassJson TEXT," +
587
+ " dekSealed TEXT," +
587
588
  " auditReasonStorage TEXT NOT NULL DEFAULT 'cleartext'," +
588
589
  " updatedAt " + t.INT + " NOT NULL" +
589
590
  ")",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",