@blamejs/core 0.5.1 → 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
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.5.x
10
10
 
11
+ - **0.5.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
11
12
  - **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
12
13
 
13
14
  ## v0.4.x
@@ -54,6 +54,7 @@ var { defineClass } = require("./framework-error");
54
54
  var vault = lazyRequire(function () { return require("./vault"); });
55
55
 
56
56
  var lockout = lazyRequire(function () { return require("./auth/lockout"); });
57
+ var passkey = lazyRequire(function () { return require("./auth/passkey"); });
57
58
 
58
59
  // Errors — all 14 codes documented in the spec. `permanent: true`
59
60
  // means caller's input is bad (Tier-A); `permanent: false` means
@@ -67,7 +68,7 @@ var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
67
68
  var DEFAULT_REASON_MIN_LEN = 12;
68
69
  var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
69
70
  var DEFAULT_AUDIT_REASON = "cleartext";
70
- var ALLOWED_FACTORS = ["totp"]; // passkey added in v0.5.2
71
+ var ALLOWED_FACTORS = ["totp", "passkey"];
71
72
  var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
72
73
 
73
74
  // In-memory policy cache. Cluster-shared via the policies table; the
@@ -135,19 +136,18 @@ function _kCell(dek, table, rowId, column) {
135
136
 
136
137
  async function _ensureDek(table) {
137
138
  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.
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
142
  var rows = await clusterStorage.executeAll(
143
- "SELECT serviceAccountBypassJson FROM _blamejs_break_glass_policies WHERE tableName = ?",
143
+ "SELECT dekSealed FROM _blamejs_break_glass_policies WHERE tableName = ?",
144
144
  [table]
145
145
  );
146
146
  if (!rows || rows.length === 0) {
147
147
  throw new BreakGlassError("breakglass/policy-not-set",
148
148
  "_ensureDek: no policy for table '" + table + "'", true);
149
149
  }
150
- var sealed = rows[0].serviceAccountBypassJson;
150
+ var sealed = rows[0].dekSealed;
151
151
  var dek;
152
152
  if (sealed) {
153
153
  dek = Buffer.from(vault().unseal(sealed), "base64");
@@ -155,7 +155,7 @@ async function _ensureDek(table) {
155
155
  dek = generateBytes(32);
156
156
  var sealedDek = vault().seal(dek.toString("base64"));
157
157
  await clusterStorage.execute(
158
- "UPDATE _blamejs_break_glass_policies SET serviceAccountBypassJson = ? WHERE tableName = ?",
158
+ "UPDATE _blamejs_break_glass_policies SET dekSealed = ? WHERE tableName = ?",
159
159
  [sealedDek, table]
160
160
  );
161
161
  }
@@ -399,9 +399,35 @@ function _validatePolicySet(table, opts) {
399
399
  throw new BreakGlassError("breakglass/bad-policy",
400
400
  "policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
401
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;
402
408
  if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
403
- throw new BreakGlassError("breakglass/bad-policy",
404
- "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
+ };
405
431
  }
406
432
  return {
407
433
  cryptographic: opts.cryptographic === true,
@@ -414,6 +440,7 @@ function _validatePolicySet(table, opts) {
414
440
  onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
415
441
  requireScope: opts.requireScope != null ? opts.requireScope : null,
416
442
  auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
443
+ serviceAccountBypass: serviceAccountBypass,
417
444
  };
418
445
  }
419
446
 
@@ -433,7 +460,9 @@ async function policySet(table, opts, callerOpts) {
433
460
  sessionPin: validated.sessionPin ? 1 : 0,
434
461
  onLockedAccess: validated.onLockedAccess,
435
462
  requireScope: validated.requireScope,
436
- serviceAccountBypassJson: null,
463
+ serviceAccountBypassJson: validated.serviceAccountBypass
464
+ ? JSON.stringify(validated.serviceAccountBypass)
465
+ : null,
437
466
  auditReasonStorage: validated.auditReasonStorage,
438
467
  updatedAt: Date.now(),
439
468
  };
@@ -492,6 +521,9 @@ async function policyGet(table) {
492
521
  sessionPin: unsealed.sessionPin === 1,
493
522
  onLockedAccess: unsealed.onLockedAccess,
494
523
  requireScope: unsealed.requireScope,
524
+ serviceAccountBypass: unsealed.serviceAccountBypassJson
525
+ ? safeJson.parse(unsealed.serviceAccountBypassJson, { maxBytes: C.BYTES.kib(8) })
526
+ : null,
495
527
  auditReasonStorage: unsealed.auditReasonStorage,
496
528
  updatedAt: Number(unsealed.updatedAt),
497
529
  };
@@ -542,6 +574,34 @@ function _verifyTotpFactor(factor) {
542
574
  return { ok: verified !== false, step: verified };
543
575
  }
544
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
+
545
605
  async function grant(opts) {
546
606
  _requireInit();
547
607
  if (!opts || typeof opts !== "object") {
@@ -613,6 +673,8 @@ async function grant(opts) {
613
673
  var factorOk = false;
614
674
  if (factorType === "totp") {
615
675
  factorOk = _verifyTotpFactor(opts.factor).ok;
676
+ } else if (factorType === "passkey") {
677
+ factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
616
678
  }
617
679
 
618
680
  if (!factorOk) {
@@ -937,6 +999,220 @@ async function listActive(opts) {
937
999
  return out;
938
1000
  }
939
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
+
940
1216
  // ---- Sweep (best-effort cleanup of expired grants) ----
941
1217
 
942
1218
  async function _sweepExpired(opts) {
@@ -984,6 +1260,14 @@ module.exports = {
984
1260
  encryptCell: encryptCell,
985
1261
  decryptCell: decryptCell,
986
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,
987
1271
  BreakGlassError: BreakGlassError,
988
1272
 
989
1273
  // Test-only / sweep — operators with active grant volume wire this
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.1",
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",