@blamejs/core 0.7.107 → 0.8.4

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/NOTICE +17 -1
  3. package/README.md +4 -3
  4. package/index.js +15 -0
  5. package/lib/asyncapi-bindings.js +160 -0
  6. package/lib/asyncapi-traits.js +143 -0
  7. package/lib/asyncapi.js +531 -0
  8. package/lib/audit-sign.js +1 -1
  9. package/lib/audit.js +68 -2
  10. package/lib/auth/acr-vocabulary.js +265 -0
  11. package/lib/auth/auth-time-tracker.js +111 -0
  12. package/lib/auth/elevation-grant.js +306 -0
  13. package/lib/auth/jwt.js +13 -0
  14. package/lib/auth/lockout.js +16 -3
  15. package/lib/auth/oauth.js +15 -1
  16. package/lib/auth/password.js +22 -2
  17. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  18. package/lib/auth/sd-jwt-vc.js +7 -2
  19. package/lib/auth/step-up-policy.js +335 -0
  20. package/lib/auth/step-up.js +445 -0
  21. package/lib/break-glass.js +53 -14
  22. package/lib/cache-redis.js +1 -1
  23. package/lib/cache.js +6 -1
  24. package/lib/cli.js +3 -3
  25. package/lib/cluster.js +24 -1
  26. package/lib/compliance-ai-act-logging.js +190 -0
  27. package/lib/compliance-ai-act-prohibited.js +205 -0
  28. package/lib/compliance-ai-act-risk.js +189 -0
  29. package/lib/compliance-ai-act-transparency.js +200 -0
  30. package/lib/compliance-ai-act.js +558 -0
  31. package/lib/compliance.js +12 -2
  32. package/lib/config-drift.js +2 -2
  33. package/lib/crypto-field.js +21 -1
  34. package/lib/crypto.js +114 -1
  35. package/lib/db.js +35 -4
  36. package/lib/dev.js +30 -3
  37. package/lib/dual-control.js +19 -1
  38. package/lib/external-db.js +10 -0
  39. package/lib/file-upload.js +30 -3
  40. package/lib/flag-cache.js +136 -0
  41. package/lib/flag-evaluation-context.js +135 -0
  42. package/lib/flag-providers.js +279 -0
  43. package/lib/flag-targeting.js +210 -0
  44. package/lib/flag.js +284 -0
  45. package/lib/guard-all.js +33 -16
  46. package/lib/guard-csv.js +16 -2
  47. package/lib/guard-html.js +35 -0
  48. package/lib/guard-svg.js +20 -0
  49. package/lib/http-client.js +57 -11
  50. package/lib/inbox.js +391 -0
  51. package/lib/log-stream-syslog.js +8 -0
  52. package/lib/log-stream.js +1 -1
  53. package/lib/mail-arc-sign.js +372 -0
  54. package/lib/mail-auth.js +2 -0
  55. package/lib/mail.js +40 -0
  56. package/lib/middleware/ai-act-disclosure.js +166 -0
  57. package/lib/middleware/asyncapi-serve.js +136 -0
  58. package/lib/middleware/attach-user.js +25 -2
  59. package/lib/middleware/bearer-auth.js +71 -6
  60. package/lib/middleware/body-parser.js +13 -0
  61. package/lib/middleware/cors.js +10 -0
  62. package/lib/middleware/csrf-protect.js +34 -3
  63. package/lib/middleware/dpop.js +3 -3
  64. package/lib/middleware/flag-context.js +76 -0
  65. package/lib/middleware/host-allowlist.js +1 -1
  66. package/lib/middleware/index.js +15 -0
  67. package/lib/middleware/openapi-serve.js +143 -0
  68. package/lib/middleware/require-aal.js +2 -2
  69. package/lib/middleware/require-step-up.js +186 -0
  70. package/lib/middleware/trace-propagate.js +1 -1
  71. package/lib/mtls-ca.js +23 -29
  72. package/lib/mtls-engine-default.js +21 -1
  73. package/lib/network-tls.js +21 -6
  74. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  75. package/lib/observability-otlp-exporter.js +35 -2
  76. package/lib/openapi-paths-builder.js +248 -0
  77. package/lib/openapi-schema-walk.js +192 -0
  78. package/lib/openapi-security.js +169 -0
  79. package/lib/openapi-yaml.js +154 -0
  80. package/lib/openapi.js +443 -0
  81. package/lib/outbox.js +3 -3
  82. package/lib/permissions.js +10 -1
  83. package/lib/pqc-agent.js +22 -1
  84. package/lib/pqc-software.js +195 -0
  85. package/lib/pubsub.js +8 -4
  86. package/lib/redact.js +26 -1
  87. package/lib/retention.js +26 -0
  88. package/lib/router.js +1 -0
  89. package/lib/scheduler.js +57 -1
  90. package/lib/session.js +3 -3
  91. package/lib/ssrf-guard.js +19 -4
  92. package/lib/static.js +12 -0
  93. package/lib/totp.js +16 -0
  94. package/lib/vault/index.js +3 -0
  95. package/lib/vault-aad.js +259 -0
  96. package/lib/vendor/MANIFEST.json +29 -0
  97. package/lib/vendor/noble-post-quantum.cjs +18 -0
  98. package/lib/ws-client.js +978 -0
  99. package/package.json +1 -1
  100. package/sbom.cyclonedx.json +6 -6
@@ -0,0 +1,445 @@
1
+ "use strict";
2
+ /**
3
+ * RFC 9470 — OAuth 2.0 Step-Up Authentication Challenge.
4
+ *
5
+ * Step-up flows let a resource server demand a stronger or fresher
6
+ * authentication ceremony before serving a particular request. The
7
+ * challenge shape is fixed by RFC 9470:
8
+ *
9
+ * HTTP/1.1 401 Unauthorized
10
+ * WWW-Authenticate: Bearer error="insufficient_user_authentication",
11
+ * error_description="A higher level of authentication is required",
12
+ * acr_values="urn:mace:incommon:iap:silver",
13
+ * max_age="300"
14
+ *
15
+ * The corresponding error code, `insufficient_user_authentication`, is
16
+ * registered in the OAuth Extensions Error Registry; clients MUST
17
+ * recognise it and re-trigger the auth-flow with `acr_values` and/or
18
+ * `max_age` propagated to the IdP.
19
+ *
20
+ * Public surface (b.auth.stepUp.*):
21
+ *
22
+ * .evaluate({ claims, requirement, now? })
23
+ * → { ok: true } | { ok: false, error, requirement }
24
+ *
25
+ * .buildChallenge({ requirement, realm?, error?, errorDescription? })
26
+ * → "Bearer error=\"insufficient_user_authentication\", ..."
27
+ *
28
+ * .acr.register({ value, rank }) (delegates to acr-vocabulary)
29
+ * .acr.meets(presented, required)
30
+ *
31
+ * .grant.create({ subject, scope, acr, amr, evidence?, ttlSec? })
32
+ * → { token, expiresAt, jti }
33
+ * .grant.verify(token, { audience?, scope? })
34
+ * → claims object
35
+ *
36
+ * .parseAuthorizationDetails(value) (RFC 9396 helper)
37
+ *
38
+ * Requirement object shape:
39
+ * {
40
+ * acr: "urn:..." (optional; one acr to require)
41
+ * acrValues: [ "...", "..." ] (optional; ANY satisfies)
42
+ * maxAge: 300 (optional, seconds — RFC 9470)
43
+ * requiredAmr: [ "hwk", "pop" ] (optional; AMR must include all)
44
+ * phishingResistant: true (optional; AMR must include any
45
+ * phishing-resistant method)
46
+ * authorizationDetails: [ {...} ] (optional; RFC 9396 fine-grained)
47
+ * }
48
+ *
49
+ * Per the validation-tier policy: configuration entry-points (.buildChallenge,
50
+ * .grant.create, .acr.register) THROW on bad input — operator catches the
51
+ * typo at boot. The hot-path (.evaluate) never throws — it returns the
52
+ * structured failure so the middleware can emit a 401.
53
+ *
54
+ * Audit emissions on every state transition:
55
+ * - auth.stepUp.required (challenge emitted)
56
+ * - auth.stepUp.satisfied (request passed evaluation)
57
+ * - auth.stepUp.denied (request failed)
58
+ * - auth.stepUp.grant.issued (elevation grant minted)
59
+ * - auth.stepUp.grant.consumed (elevation grant used)
60
+ * - auth.stepUp.grant.revoked (elevation grant revoked)
61
+ */
62
+
63
+ var lazyRequire = require("../lazy-require");
64
+ var validateOpts = require("../validate-opts");
65
+ var safeJson = require("../safe-json");
66
+ var C = require("../constants");
67
+ var { AuthError } = require("../framework-error");
68
+
69
+ var acr = require("./acr-vocabulary");
70
+ var authTime = require("./auth-time-tracker");
71
+ var elevation = lazyRequire(function () { return require("./elevation-grant"); });
72
+ var audit = lazyRequire(function () { return require("../audit"); });
73
+
74
+ var INSUFFICIENT_USER_AUTHENTICATION = "insufficient_user_authentication";
75
+ var DEFAULT_REALM = "api";
76
+
77
+ function _readPresentedClaims(claims) {
78
+ return authTime.readClaims(claims);
79
+ }
80
+
81
+ // Quote a value for inclusion in a WWW-Authenticate parameter per RFC
82
+ // 7235 §2.2 and RFC 9470 §3 (uses `quoted-string` for all values).
83
+ function _quote(value) {
84
+ if (typeof value !== "string") value = String(value);
85
+ // Reject CTLs and quote-injecting characters.
86
+ for (var i = 0; i < value.length; i += 1) {
87
+ var code = value.charCodeAt(i);
88
+ if (code < 32 || code === 127) { // allow:raw-byte-literal — ASCII control codepoints
89
+ throw new AuthError("auth-stepUp/bad-challenge",
90
+ "challenge value contains control character at index " + i);
91
+ }
92
+ if (value.charAt(i) === '"' || value.charAt(i) === "\\") {
93
+ throw new AuthError("auth-stepUp/bad-challenge",
94
+ "challenge value contains illegal character " +
95
+ JSON.stringify(value.charAt(i)) + " at index " + i);
96
+ }
97
+ }
98
+ return '"' + value + '"';
99
+ }
100
+
101
+ function _validateRequirement(requirement, label) {
102
+ if (!requirement || typeof requirement !== "object") {
103
+ throw new AuthError("auth-stepUp/bad-requirement",
104
+ label + ": requirement must be an object — got " +
105
+ JSON.stringify(requirement));
106
+ }
107
+ validateOpts(requirement, [
108
+ "acr", "acrValues", "maxAge", "requiredAmr", "phishingResistant",
109
+ "authorizationDetails",
110
+ ], label);
111
+ if (requirement.acr != null) {
112
+ validateOpts.requireNonEmptyString(requirement.acr,
113
+ label + ": acr", AuthError, "auth-stepUp/bad-acr");
114
+ }
115
+ if (requirement.acrValues != null) {
116
+ if (!Array.isArray(requirement.acrValues) || requirement.acrValues.length === 0) {
117
+ throw new AuthError("auth-stepUp/bad-acr",
118
+ label + ": acrValues must be a non-empty string array");
119
+ }
120
+ for (var i = 0; i < requirement.acrValues.length; i += 1) {
121
+ validateOpts.requireNonEmptyString(requirement.acrValues[i],
122
+ label + ": acrValues[" + i + "]", AuthError, "auth-stepUp/bad-acr");
123
+ }
124
+ }
125
+ if (requirement.maxAge != null) {
126
+ if (typeof requirement.maxAge !== "number" || !isFinite(requirement.maxAge) ||
127
+ requirement.maxAge < 0) {
128
+ throw new AuthError("auth-stepUp/bad-max-age",
129
+ label + ": maxAge must be a finite number >= 0 — got " +
130
+ JSON.stringify(requirement.maxAge));
131
+ }
132
+ }
133
+ if (requirement.requiredAmr != null) {
134
+ if (!Array.isArray(requirement.requiredAmr)) {
135
+ throw new AuthError("auth-stepUp/bad-amr",
136
+ label + ": requiredAmr must be a string array");
137
+ }
138
+ for (var j = 0; j < requirement.requiredAmr.length; j += 1) {
139
+ validateOpts.requireNonEmptyString(requirement.requiredAmr[j],
140
+ label + ": requiredAmr[" + j + "]", AuthError, "auth-stepUp/bad-amr");
141
+ }
142
+ }
143
+ if (requirement.phishingResistant != null &&
144
+ typeof requirement.phishingResistant !== "boolean") {
145
+ throw new AuthError("auth-stepUp/bad-requirement",
146
+ label + ": phishingResistant must be boolean — got " +
147
+ JSON.stringify(requirement.phishingResistant));
148
+ }
149
+ }
150
+
151
+ function evaluate(opts) {
152
+ opts = opts || {};
153
+ var claims = opts.claims;
154
+ var requirement = opts.requirement;
155
+ if (!requirement || typeof requirement !== "object") {
156
+ return { ok: false, error: "no_requirement", reason: "evaluate: requirement object missing" };
157
+ }
158
+ // Hot-path drop-silent: do not throw on typo — return structured
159
+ // failure. But surface unregistered-acr because that's an operator-
160
+ // side typo that should bubble up.
161
+ try { _validateRequirement(requirement, "auth.stepUp.evaluate"); }
162
+ catch (err) { return { ok: false, error: "bad_requirement", reason: err.message }; }
163
+
164
+ var presented = _readPresentedClaims(claims);
165
+ var now = (typeof opts.now === "number") ? opts.now : Math.floor(Date.now() / C.TIME.seconds(1));
166
+
167
+ // 1. ACR check (single)
168
+ if (typeof requirement.acr === "string") {
169
+ if (!acr.isRegistered(requirement.acr)) {
170
+ return {
171
+ ok: false, error: "unknown_acr",
172
+ reason: "evaluate: required acr is not registered: " + requirement.acr,
173
+ requirement: requirement,
174
+ };
175
+ }
176
+ if (!acr.meets(presented.acr, requirement.acr)) {
177
+ return {
178
+ ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
179
+ reason: "presented acr " + JSON.stringify(presented.acr) +
180
+ " does not meet required " + JSON.stringify(requirement.acr),
181
+ requirement: requirement, presented: presented,
182
+ };
183
+ }
184
+ }
185
+ // 2. ACR-values list (any one suffices)
186
+ if (Array.isArray(requirement.acrValues) && requirement.acrValues.length > 0) {
187
+ if (!acr.meetsAny(presented.acr, requirement.acrValues)) {
188
+ return {
189
+ ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
190
+ reason: "presented acr " + JSON.stringify(presented.acr) +
191
+ " does not meet any of " + JSON.stringify(requirement.acrValues),
192
+ requirement: requirement, presented: presented,
193
+ };
194
+ }
195
+ }
196
+ // 3. max_age freshness
197
+ if (typeof requirement.maxAge === "number") {
198
+ if (!authTime.freshEnough(claims, requirement.maxAge, now)) {
199
+ return {
200
+ ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
201
+ reason: "auth_time stale or missing — required max_age=" +
202
+ requirement.maxAge + "s, age=" + authTime.ageSec(claims, now),
203
+ requirement: requirement, presented: presented,
204
+ };
205
+ }
206
+ }
207
+ // 4. AMR — required methods
208
+ if (Array.isArray(requirement.requiredAmr) && requirement.requiredAmr.length > 0) {
209
+ if (!acr.amrSatisfiesRequiredList(presented.amr, requirement.requiredAmr)) {
210
+ return {
211
+ ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
212
+ reason: "presented amr " + JSON.stringify(presented.amr) +
213
+ " does not include all required " + JSON.stringify(requirement.requiredAmr),
214
+ requirement: requirement, presented: presented,
215
+ };
216
+ }
217
+ }
218
+ // 5. AMR — phishing resistance
219
+ if (requirement.phishingResistant === true) {
220
+ if (!acr.amrIncludesPhishingResistant(presented.amr)) {
221
+ return {
222
+ ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
223
+ reason: "presented amr " + JSON.stringify(presented.amr) +
224
+ " does not include any phishing-resistant method",
225
+ requirement: requirement, presented: presented,
226
+ };
227
+ }
228
+ }
229
+ return { ok: true, presented: presented };
230
+ }
231
+
232
+ function buildChallenge(opts) {
233
+ opts = opts || {};
234
+ validateOpts(opts, [
235
+ "requirement", "realm", "error", "errorDescription", "scope",
236
+ ], "auth.stepUp.buildChallenge");
237
+ _validateRequirement(opts.requirement, "auth.stepUp.buildChallenge");
238
+ var realm = (typeof opts.realm === "string" && opts.realm.length > 0) ? opts.realm : DEFAULT_REALM;
239
+ var errCode = (typeof opts.error === "string" && opts.error.length > 0)
240
+ ? opts.error : INSUFFICIENT_USER_AUTHENTICATION;
241
+ var errDesc = (typeof opts.errorDescription === "string" && opts.errorDescription.length > 0)
242
+ ? opts.errorDescription : "A higher level of authentication is required";
243
+
244
+ var parts = [];
245
+ parts.push('realm=' + _quote(realm));
246
+ parts.push('error=' + _quote(errCode));
247
+ parts.push('error_description=' + _quote(errDesc));
248
+ if (typeof opts.scope === "string" && opts.scope.length > 0) {
249
+ parts.push('scope=' + _quote(opts.scope));
250
+ }
251
+
252
+ var req = opts.requirement;
253
+ // Per RFC 9470 §3: emit acr_values as space-separated string per RFC 6749.
254
+ if (typeof req.acr === "string" && req.acr.length > 0) {
255
+ parts.push('acr_values=' + _quote(req.acr));
256
+ } else if (Array.isArray(req.acrValues) && req.acrValues.length > 0) {
257
+ parts.push('acr_values=' + _quote(req.acrValues.join(" ")));
258
+ }
259
+ if (typeof req.maxAge === "number") {
260
+ parts.push('max_age=' + _quote(String(req.maxAge)));
261
+ }
262
+ if (Array.isArray(req.requiredAmr) && req.requiredAmr.length > 0) {
263
+ parts.push('amr_values=' + _quote(req.requiredAmr.join(" ")));
264
+ }
265
+ if (Array.isArray(req.authorizationDetails) && req.authorizationDetails.length > 0) {
266
+ parts.push('authorization_details=' + _quote(JSON.stringify(req.authorizationDetails)));
267
+ }
268
+ return "Bearer " + parts.join(", ");
269
+ }
270
+
271
+ // RFC 9396 helper — parse the JSON-array authorization_details parameter.
272
+ // Throws on malformed payload at config time (operator typo at boot).
273
+ // Hot-path callers wrap this in try/catch.
274
+ function parseAuthorizationDetails(value) {
275
+ if (typeof value !== "string") {
276
+ throw new AuthError("auth-stepUp/bad-rar",
277
+ "parseAuthorizationDetails: value must be a JSON string — got " +
278
+ typeof value);
279
+ }
280
+ var parsed;
281
+ try { parsed = safeJson.parse(value, { maxBytes: C.BYTES.kib(64) }); }
282
+ catch (e) {
283
+ throw new AuthError("auth-stepUp/bad-rar",
284
+ "parseAuthorizationDetails: invalid JSON — " + e.message);
285
+ }
286
+ if (!Array.isArray(parsed)) {
287
+ throw new AuthError("auth-stepUp/bad-rar",
288
+ "parseAuthorizationDetails: value must be a JSON array — got " +
289
+ typeof parsed);
290
+ }
291
+ for (var i = 0; i < parsed.length; i += 1) {
292
+ var entry = parsed[i];
293
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
294
+ throw new AuthError("auth-stepUp/bad-rar",
295
+ "parseAuthorizationDetails[" + i + "]: must be an object");
296
+ }
297
+ if (typeof entry.type !== "string" || entry.type.length === 0) {
298
+ throw new AuthError("auth-stepUp/bad-rar",
299
+ "parseAuthorizationDetails[" + i + "]: missing required 'type' field");
300
+ }
301
+ }
302
+ return parsed;
303
+ }
304
+
305
+ function emitAuditRequired(label, requirement, presented, req) {
306
+ try {
307
+ audit().safeEmit({
308
+ action: "auth.stepup.required",
309
+ outcome: "denied",
310
+ actor: { route: req && (req.url || req.pathname) || null,
311
+ userId: req && req.user && req.user.id || null },
312
+ metadata: {
313
+ label: label || "stepUp",
314
+ requirement: _summarizeRequirement(requirement),
315
+ presented: _summarizePresented(presented),
316
+ },
317
+ });
318
+ } catch (_e) { /* drop-silent */ }
319
+ }
320
+
321
+ function emitAuditSatisfied(label, requirement, presented, req) {
322
+ try {
323
+ audit().safeEmit({
324
+ action: "auth.stepup.satisfied",
325
+ outcome: "success",
326
+ actor: { route: req && (req.url || req.pathname) || null,
327
+ userId: req && req.user && req.user.id || null },
328
+ metadata: {
329
+ label: label || "stepUp",
330
+ requirement: _summarizeRequirement(requirement),
331
+ presented: _summarizePresented(presented),
332
+ },
333
+ });
334
+ } catch (_e) { /* drop-silent */ }
335
+ }
336
+
337
+ function _summarizeRequirement(req) {
338
+ if (!req || typeof req !== "object") return null;
339
+ return {
340
+ acr: req.acr || null,
341
+ acrValues: Array.isArray(req.acrValues) ? req.acrValues.slice() : null,
342
+ maxAge: (typeof req.maxAge === "number") ? req.maxAge : null,
343
+ requiredAmr: Array.isArray(req.requiredAmr) ? req.requiredAmr.slice() : null,
344
+ phishingResistant: req.phishingResistant === true ? true : false,
345
+ };
346
+ }
347
+
348
+ function _summarizePresented(presented) {
349
+ if (!presented || typeof presented !== "object") return null;
350
+ return {
351
+ acr: presented.acr || null,
352
+ amr: Array.isArray(presented.amr) ? presented.amr.slice() : null,
353
+ auth_time: presented.auth_time || null,
354
+ };
355
+ }
356
+
357
+ // ---- Bearer-challenge parser (RFC 7235 §2.1, RFC 9470 §3) ----
358
+ //
359
+ // Operator-side helper to inspect what an upstream RS challenged with.
360
+ // Returns null when the header doesn't carry a Bearer challenge or
361
+ // doesn't carry the insufficient_user_authentication error.
362
+
363
+ function parseChallenge(headerValue) {
364
+ if (typeof headerValue !== "string") return null;
365
+ // Tolerate "Bearer " prefix in any case; reject anything else.
366
+ var idx = headerValue.toLowerCase().indexOf("bearer");
367
+ if (idx === -1) return null;
368
+ var rest = headerValue.slice(idx + "bearer".length).trim();
369
+ if (rest.length === 0) return null;
370
+ var out = { error: null, scope: null, acrValues: null, maxAge: null, raw: {} };
371
+ // Split on commas at top level, but respect quoted strings.
372
+ var tokens = _splitWwwAuth(rest);
373
+ for (var i = 0; i < tokens.length; i += 1) {
374
+ var token = tokens[i].trim();
375
+ var eq = token.indexOf("=");
376
+ if (eq === -1) continue;
377
+ var key = token.slice(0, eq).trim().toLowerCase();
378
+ var val = token.slice(eq + 1).trim();
379
+ if (val.length >= 2 && val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') {
380
+ val = val.slice(1, val.length - 1);
381
+ }
382
+ out.raw[key] = val;
383
+ if (key === "error") out.error = val;
384
+ else if (key === "scope") out.scope = val;
385
+ else if (key === "acr_values") out.acrValues = val.split(/\s+/);
386
+ else if (key === "max_age") out.maxAge = parseInt(val, 10);
387
+ }
388
+ return out;
389
+ }
390
+
391
+ function _splitWwwAuth(raw) {
392
+ var tokens = [];
393
+ var cursor = 0;
394
+ var inQuoted = false;
395
+ var current = "";
396
+ while (cursor < raw.length) {
397
+ var ch = raw.charAt(cursor);
398
+ if (inQuoted) {
399
+ current += ch;
400
+ if (ch === "\\" && cursor + 1 < raw.length) {
401
+ current += raw.charAt(cursor + 1);
402
+ cursor += 2;
403
+ continue;
404
+ }
405
+ if (ch === '"') inQuoted = false;
406
+ cursor += 1;
407
+ continue;
408
+ }
409
+ if (ch === '"') { inQuoted = true; current += ch; cursor += 1; continue; }
410
+ if (ch === ",") {
411
+ tokens.push(current);
412
+ current = "";
413
+ cursor += 1;
414
+ continue;
415
+ }
416
+ current += ch;
417
+ cursor += 1;
418
+ }
419
+ if (current.length > 0) tokens.push(current);
420
+ return tokens;
421
+ }
422
+
423
+ var policy = lazyRequire(function () { return require("./step-up-policy"); });
424
+
425
+ module.exports = {
426
+ evaluate: evaluate,
427
+ buildChallenge: buildChallenge,
428
+ parseChallenge: parseChallenge,
429
+ parseAuthorizationDetails: parseAuthorizationDetails,
430
+ acr: acr,
431
+ authTime: authTime,
432
+ get policy() { return policy(); },
433
+ grant: {
434
+ create: function (opts) { return elevation().create(opts); },
435
+ verify: function (token, opts) { return elevation().verify(token, opts); },
436
+ revoke: function (jti, opts) { return elevation().revoke(jti, opts); },
437
+ isRevoked: function (jti) { return elevation().isRevoked(jti); },
438
+ list: function () { return elevation().list(); },
439
+ setSigningKey: function (key) { return elevation().setSigningKey(key); },
440
+ _resetForTests: function () { return elevation()._resetForTests(); },
441
+ },
442
+ emitAuditRequired: emitAuditRequired,
443
+ emitAuditSatisfied: emitAuditSatisfied,
444
+ INSUFFICIENT_USER_AUTHENTICATION: INSUFFICIENT_USER_AUTHENTICATION,
445
+ };
@@ -693,6 +693,45 @@ async function grant(opts) {
693
693
  "grant: no authenticated actor on request (req.user.id / req.apiKey.id required)", true);
694
694
  }
695
695
 
696
+ // Scope-gate enforcement — when the policy declares requireScope,
697
+ // the actor must carry the named scope (or matching wildcard via
698
+ // b.permissions.match) before the framework will mint a grant.
699
+ // Without this, every TOTP-passing actor could glass-unseal PHI
700
+ // even when the operator explicitly declared `requireScope:
701
+ // "phi:admin"`.
702
+ if (policy.requireScope) {
703
+ var actorScopes = (opts.req && opts.req.user && Array.isArray(opts.req.user.scopes))
704
+ ? opts.req.user.scopes
705
+ : ((opts.req && opts.req.apiKey && Array.isArray(opts.req.apiKey.scopes))
706
+ ? opts.req.apiKey.scopes
707
+ : []);
708
+ var scopeOk = false;
709
+ for (var sci = 0; sci < actorScopes.length; sci += 1) {
710
+ if (actorScopes[sci] === policy.requireScope) { scopeOk = true; break; }
711
+ // Wildcard support: "phi:*" matches "phi:admin" and "phi:read".
712
+ if (typeof actorScopes[sci] === "string" &&
713
+ actorScopes[sci].length > 0 &&
714
+ actorScopes[sci].charAt(actorScopes[sci].length - 1) === "*") {
715
+ var prefix = actorScopes[sci].slice(0, -1);
716
+ if (typeof policy.requireScope === "string" &&
717
+ policy.requireScope.indexOf(prefix) === 0) {
718
+ scopeOk = true; break;
719
+ }
720
+ }
721
+ }
722
+ if (!scopeOk) {
723
+ audit.safeEmit({
724
+ action: "breakglass.grant.requested",
725
+ outcome: "denied",
726
+ actor: actor,
727
+ reason: "missing-scope",
728
+ metadata: { table: table, requireScope: policy.requireScope },
729
+ });
730
+ throw new BreakGlassError("breakglass/missing-scope",
731
+ "grant: actor does not carry required scope '" + policy.requireScope + "'", true);
732
+ }
733
+ }
734
+
696
735
  // Factor verification + lockout
697
736
  var factorType = opts.factor && opts.factor.type;
698
737
  if (!factorType || policy.factors.indexOf(factorType) === -1) {
@@ -897,6 +936,20 @@ async function unsealRow(grantHandle, table, rowId, opts) {
897
936
  grantRow.maxRowsPerGrant + " allowed rows", true);
898
937
  }
899
938
 
939
+ // SELECT-before-increment — fetch the target row FIRST. If the row
940
+ // doesn't exist (operator typo, race with row-deletion, etc.), the
941
+ // grant should not be consumed. Without this ordering, a single
942
+ // typo against `maxRowsPerGrant: 1` (the default) exhausts the
943
+ // grant and forces the operator to re-do the step-up ceremony.
944
+ var rows = await clusterStorage.executeAll(
945
+ "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
946
+ [String(rowId)]
947
+ );
948
+ if (!rows || rows.length === 0) {
949
+ throw new BreakGlassError("breakglass/row-not-found",
950
+ "unsealRow: " + table + "[" + rowId + "] not found", true);
951
+ }
952
+
900
953
  // Increment rowsConsumed (atomic UPDATE with WHERE rowsConsumed < cap
901
954
  // so concurrent unseals can't both pass the runtime check above).
902
955
  var updateRes = await clusterStorage.execute(
@@ -925,20 +978,6 @@ async function unsealRow(grantHandle, table, rowId, opts) {
925
978
  "unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
926
979
  }
927
980
  void updateRes;
928
-
929
- // Fetch + unseal the target row. Model A goes straight through
930
- // cryptoField; Model B reads the row, lets cryptoField unseal the
931
- // non-glass-locked columns, and then decryptCell handles the
932
- // glass-locked columns separately (their ciphertext was written
933
- // by encryptCell at app-write time, not by cryptoField.sealRow).
934
- var rows = await clusterStorage.executeAll(
935
- "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
936
- [String(rowId)]
937
- );
938
- if (!rows || rows.length === 0) {
939
- throw new BreakGlassError("breakglass/row-not-found",
940
- "unsealRow: " + table + "[" + rowId + "] not found", true);
941
- }
942
981
  var policy = await policyGet(table);
943
982
  var unsealedRow;
944
983
  if (policy && policy.cryptographic) {
@@ -96,7 +96,7 @@ function create(cfg) {
96
96
 
97
97
  async function set(key, value, expiresAt, meta) {
98
98
  await _ensureConnected();
99
- var json = JSON.stringify(value);
99
+ var json = safeJson.stringify(value);
100
100
 
101
101
  // Drop any prior tag membership for this key (tags may have changed
102
102
  // across sets). The reverse-tag set tells us which tag SETs need
package/lib/cache.js CHANGED
@@ -491,7 +491,12 @@ function _clusterBackend(cfg) {
491
491
  }
492
492
 
493
493
  async function set(key, value, expiresAt, meta) {
494
- var json = JSON.stringify(value);
494
+ // safeJson.stringify refuses Buffer / circular / Date round-trip
495
+ // ambiguity that vanilla JSON.stringify silently flattens. The
496
+ // failure mode without this is "cache returns a structurally-
497
+ // changed value, app code treats it as the original" — a subtle
498
+ // freshness bug that's hard to debug.
499
+ var json = safeJson.stringify(value);
495
500
  var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
496
501
  var now = clock();
497
502
  var ck = _composedKey(key);
package/lib/cli.js CHANGED
@@ -1370,9 +1370,9 @@ async function _runMtls(args, ctx) {
1370
1370
  if (vaultMode !== "wrapped" && vaultMode !== "plaintext") {
1371
1371
  return report.error("--vault-mode must be 'wrapped' or 'plaintext'", 2);
1372
1372
  }
1373
- var sealedMode = args.flags["sealed-mode"] || "auto";
1374
- if (["auto", "required", "disabled"].indexOf(sealedMode) === -1) {
1375
- return report.error("--sealed-mode must be 'auto', 'required', or 'disabled'", 2);
1373
+ var sealedMode = args.flags["sealed-mode"] || "required";
1374
+ if (["required", "disabled"].indexOf(sealedMode) === -1) {
1375
+ return report.error("--sealed-mode must be 'required' or 'disabled'", 2);
1376
1376
  }
1377
1377
 
1378
1378
  var booted;
package/lib/cluster.js CHANGED
@@ -69,7 +69,17 @@ var vault = lazyRequire(function () { return require("./vault"); });
69
69
 
70
70
  var DEFAULT_LEASE_TTL = C.TIME.seconds(30);
71
71
  var DEFAULT_HEARTBEAT = C.TIME.seconds(10);
72
- var MIN_LEASE_TTL = C.TIME.seconds(5);
72
+ // MIN_LEASE_TTL bumped from 5s → 10s. With 5s leases + 1s heartbeats,
73
+ // a network glitch + GC pause can leave the old leader believing it
74
+ // still holds the lease (4s remaining on its clock) while a new
75
+ // leader has already acquired. Old-leader writes during that window
76
+ // only land on framework state with a fencingToken WHERE clause
77
+ // (audit-tip CHECK catches it); operator-supplied writes through
78
+ // b.externalDb.transaction outside the audit chain DON'T carry the
79
+ // clause and can be accepted by both leaders. 10s leaves more room
80
+ // for the framework's audit-tip fencing to catch the split-brain
81
+ // before consequential writes reach durable state.
82
+ var MIN_LEASE_TTL = C.TIME.seconds(10);
73
83
  var MIN_HEARTBEAT = C.TIME.seconds(1);
74
84
 
75
85
  var initialized = false;
@@ -476,7 +486,20 @@ async function _tryAcquire() {
476
486
 
477
487
  async function _heartbeat() {
478
488
  if (!initialized) return;
489
+ // ±20% per-tick jitter on followers — without it, N followers
490
+ // polling on a deterministic cadence all fire _tryAcquire at the
491
+ // same wall-clock instant on lease expiry, producing thundering-
492
+ // herd INSERT/UPDATE pressure on the leader-election row at
493
+ // exactly the worst time. Leader-renewal path doesn't jitter
494
+ // (a missed renewal hands the lease to a follower; the timing
495
+ // budget is in `leaseTtl - heartbeatMs`, not in the jitter
496
+ // window).
479
497
  if (!lease) {
498
+ var jitterMs = Math.floor(Math.random() * (heartbeatMs * 0.4)); // allow:math-random-noncrypto — heartbeat jitter, not security-bearing
499
+ if (jitterMs > 0) {
500
+ await safeAsync.sleep(jitterMs);
501
+ }
502
+ if (!initialized) return;
480
503
  // Not currently leader — try to acquire (lease may have expired
481
504
  // on the previous holder).
482
505
  await _tryAcquire();