@blamejs/core 0.9.49 → 0.10.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
@@ -58,8 +58,20 @@ var lazyRequire = require("../lazy-require");
58
58
  var validateOpts = require("../validate-opts");
59
59
  var safeJson = require("../safe-json");
60
60
  var nodeCrypto = require("node:crypto");
61
+ var C = require("../constants");
62
+ // Shared JOSE defenses (CVE-2026-22817 alg/kty cross-check +
63
+ // CVE-2026-23552 constant-time iss compare). Top-of-file per project
64
+ // convention §3; no circular load — jwt-external requires nothing from
65
+ // openid-federation.
66
+ var jwtExternal = require("./jwt-external");
61
67
  var { AuthError } = require("../framework-error");
62
68
 
69
+ // Default federation-statement clock-skew tolerance in seconds. OIDC
70
+ // Federation 1.0 doesn't fix a value; 60s matches the framework-wide
71
+ // default applied to ID tokens. Operators tune via
72
+ // verifyEntityStatement(jwt, jwks, { maxClockSkewSec }).
73
+ var DEFAULT_FEDERATION_CLOCK_SKEW_SEC = C.TIME.minutes(1) / C.TIME.seconds(1);
74
+
63
75
  var httpClient = lazyRequire(function () { return require("../http-client"); });
64
76
  var audit = lazyRequire(function () { return require("../audit"); });
65
77
  var observability = lazyRequire(function () { return require("../observability"); });
@@ -124,7 +136,7 @@ function parseEntityStatement(jwt) {
124
136
 
125
137
  /**
126
138
  * @primitive b.auth.openidFederation.verifyEntityStatement
127
- * @signature b.auth.openidFederation.verifyEntityStatement(jwt, jwks)
139
+ * @signature b.auth.openidFederation.verifyEntityStatement(jwt, jwks, vopts)
128
140
  * @since 0.8.62
129
141
  *
130
142
  * Verify a single entity statement's JWS signature using the
@@ -132,11 +144,16 @@ function parseEntityStatement(jwt) {
132
144
  * any failure (malformed / wrong typ / unsupported alg / no
133
145
  * matching kid / bad signature / iat-future / expired).
134
146
  *
147
+ * @opts
148
+ * maxClockSkewSec: number // tolerance for iat / exp; default: 60
149
+ * now: number // override Date.now() for tests
150
+ *
135
151
  * @example
136
152
  * var claims = b.auth.openidFederation.verifyEntityStatement(jwt, anchorJwks);
137
153
  * // → { iss, sub, iat, exp, jwks, metadata, authority_hints, ... }
138
154
  */
139
- function verifyEntityStatement(jwt, jwks) {
155
+ function verifyEntityStatement(jwt, jwks, vopts) {
156
+ vopts = vopts || {};
140
157
  var parsed = parseEntityStatement(jwt);
141
158
  if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
142
159
  throw new AuthError("auth-openid-federation/no-keys",
@@ -147,30 +164,36 @@ function verifyEntityStatement(jwt, jwks) {
147
164
  for (var i = 0; i < jwks.keys.length; i++) {
148
165
  if (jwks.keys[i].kid === parsed.header.kid) { key = jwks.keys[i]; break; }
149
166
  }
150
- } else if (jwks.keys.length === 1) {
151
- key = jwks.keys[0];
152
- }
153
- if (!key) {
154
- throw new AuthError("auth-openid-federation/no-matching-kid",
155
- "verifyEntityStatement: no JWKS key matches kid \"" + parsed.header.kid + "\"");
167
+ if (!key) {
168
+ throw new AuthError("auth-openid-federation/no-matching-kid",
169
+ "verifyEntityStatement: no JWKS key matches kid \"" + parsed.header.kid + "\"");
170
+ }
171
+ } else {
172
+ // AUTH-10 refuse kid-less entity statements unless the operator
173
+ // explicitly opts in. JWKS rotation creates a window where the
174
+ // rotated-out key is still cached but the rotated-in key is already
175
+ // published; a kid-less statement during that window gets the
176
+ // lone-key path silently. Modern federations always emit kid; the
177
+ // gap that oauth + jwt-external closed in v0.9.4 applies here too.
178
+ if (jwks.keys.length === 1 && vopts.allowKidlessJwks === true) {
179
+ key = jwks.keys[0];
180
+ } else {
181
+ throw new AuthError("auth-openid-federation/kid-required",
182
+ "verifyEntityStatement: header.kid absent — framework refuses kid-less " +
183
+ "entity statements to defend against JWKS-rotation key-pick attacks " +
184
+ "(pass vopts.allowKidlessJwks: true to opt out)");
185
+ }
156
186
  }
157
187
 
158
- // Cross-check the JWK key type against the JWS `alg` header BEFORE
159
- // verifying. Without this an attacker-controlled entity-config can
160
- // declare `alg: "ES256"` while supplying an RSA `kty: "RSA"` JWK;
188
+ // CVE-2026-22817 — cross-check the JWK key type against the JWS alg
189
+ // BEFORE verifying. Without this an attacker-controlled entity-config
190
+ // can declare `alg: "ES256"` while supplying an RSA `kty: "RSA"` JWK;
161
191
  // Node will silently use the RSA key with SHA-256 and the signature
162
- // verify either always-fails (if PSS) or succeeds against a payload
163
- // the attacker crafted to match the wrong primitive (algorithm/key-
164
- // type confusion). (Audit 2026-05-11.)
165
- var expectedKty = null;
166
- if (parsed.header.alg.indexOf("ES") === 0) expectedKty = "EC";
167
- else if (parsed.header.alg.indexOf("PS") === 0 || parsed.header.alg.indexOf("RS") === 0) expectedKty = "RSA";
168
- else if (parsed.header.alg === "EdDSA") expectedKty = "OKP";
169
- if (expectedKty && key.kty !== expectedKty) {
170
- throw new AuthError("auth-openid-federation/alg-kty-mismatch",
171
- "verifyEntityStatement: JWS header alg=\"" + parsed.header.alg + "\" requires " +
172
- "JWK kty=\"" + expectedKty + "\" but the resolved JWK has kty=\"" + key.kty + "\"");
173
- }
192
+ // verify either always-fails (PSS) or succeeds against a payload the
193
+ // attacker crafted (alg/key-type confusion). Routed through the
194
+ // shared helper so every JWT verifier in the framework enforces the
195
+ // same check.
196
+ jwtExternal._assertAlgKtyMatch(parsed.header.alg, key);
174
197
 
175
198
  var keyObj;
176
199
  try { keyObj = nodeCrypto.createPublicKey({ key: key, format: "jwk" }); }
@@ -194,8 +217,12 @@ function verifyEntityStatement(jwt, jwks) {
194
217
  "verifyEntityStatement: signature verification failed");
195
218
  }
196
219
 
197
- var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s
198
- var skew = 60; // allow:raw-time-literal — clock-skew tolerance 60s
220
+ var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
221
+ // AUTH-30operator-tunable clock skew (sibling primitives accept
222
+ // tunable). Default matches the prior fixed 60s.
223
+ var skew = (typeof vopts.maxClockSkewSec === "number" && isFinite(vopts.maxClockSkewSec) && vopts.maxClockSkewSec >= 0)
224
+ ? vopts.maxClockSkewSec
225
+ : DEFAULT_FEDERATION_CLOCK_SKEW_SEC;
199
226
  if (typeof parsed.claims.iat !== "number" || parsed.claims.iat > nowSec + skew) {
200
227
  throw new AuthError("auth-openid-federation/iat-future",
201
228
  "verifyEntityStatement: iat is in the future or missing");
@@ -409,6 +436,16 @@ async function buildTrustChain(opts) {
409
436
  var chain = [];
410
437
  var current = opts.leafEntityId;
411
438
  var depth = 0;
439
+ // AUTH-9 — visited-set cycle guard. The maxDepth cap alone caps the
440
+ // loop count but doesn't distinguish "long chain" from "cyclic
441
+ // chain"; a hostile authority that lists itself in authority_hints
442
+ // walks the verifier until depth runs out and then surfaces as
443
+ // chain-too-deep, masking the actual misuse pattern. Tracking
444
+ // visited entities + refusing on revisit surfaces the cycle as a
445
+ // distinct fault class so operators alerting on chain-cycle see
446
+ // hostile-federation probes immediately.
447
+ var visited = Object.create(null);
448
+ visited[current] = true;
412
449
  while (depth < maxDepth) {
413
450
  var entityConfigUrl = current.replace(/\/$/, "") + "/.well-known/openid-federation";
414
451
  var entityConfigJwt = await fetcher(entityConfigUrl);
@@ -476,6 +513,15 @@ async function buildTrustChain(opts) {
476
513
  chain[chain.length - 1].claims.jwks = parsedSub.claims.jwks || chain[chain.length - 1].claims.jwks;
477
514
  chain[chain.length - 1].subordinateJwt = subordinateJwt;
478
515
  chain[chain.length - 1].subordinate = parsedSub.claims;
516
+ // AUTH-9 — refuse revisit. A trust anchor terminates the loop
517
+ // before re-entry, so a revisit here ALWAYS means a cyclic
518
+ // authority_hints graph.
519
+ if (visited[authority]) {
520
+ throw new AuthError("auth-openid-federation/chain-cycle",
521
+ "buildTrustChain: authority \"" + authority + "\" already visited — " +
522
+ "cyclic authority_hints graph refused");
523
+ }
524
+ visited[authority] = true;
479
525
  current = authority;
480
526
  ascended = true;
481
527
  break;
@@ -77,6 +77,50 @@ function _requireString(v, name) {
77
77
  }
78
78
  }
79
79
 
80
+ // AUTH-28 — WebAuthn extensions allowlist. Pre-v0.9.x `opts.extensions`
81
+ // was forwarded verbatim to the vendor, letting an operator (or a
82
+ // caller threading user-input through opts) ship arbitrary extension
83
+ // keys to the authenticator. Restrict to the framework-supported
84
+ // extension surface (`prf` / `largeBlob` / `credBlob`) and route every
85
+ // value through the matching `extensions.<name>(args)` builder so the
86
+ // shape is validated. Operators with custom extensions opt in via
87
+ // { allowUnknownExtensions: true } with a documented reason.
88
+ var ALLOWED_EXTENSION_KEYS = Object.freeze({
89
+ prf: 1,
90
+ largeBlob: 1,
91
+ credBlob: 1,
92
+ });
93
+ function _validateExtensions(extensions, allowUnknown) {
94
+ if (extensions === undefined || extensions === null) return undefined;
95
+ if (typeof extensions !== "object" || Array.isArray(extensions)) {
96
+ throw new AuthError("auth-passkey/bad-extensions",
97
+ "opts.extensions must be a plain object");
98
+ }
99
+ var out = {};
100
+ var keys = Object.keys(extensions);
101
+ for (var i = 0; i < keys.length; i++) {
102
+ var k = keys[i];
103
+ if (!Object.prototype.hasOwnProperty.call(ALLOWED_EXTENSION_KEYS, k)) {
104
+ if (allowUnknown === true) {
105
+ out[k] = extensions[k];
106
+ continue;
107
+ }
108
+ throw new AuthError("auth-passkey/unknown-extension",
109
+ "opts.extensions['" + k + "'] not in the framework-supported set " +
110
+ "(allowed: " + Object.keys(ALLOWED_EXTENSION_KEYS).join(", ") +
111
+ "). Pass `allowUnknownExtensions: true` to opt out.");
112
+ }
113
+ // Route every recognised extension through its builder so the
114
+ // shape is validated (PRF eval salt length, largeBlob support
115
+ // values, credBlob ≤ 32 bytes). Builder output replaces the raw
116
+ // input so the wire shape is always the spec-correct one.
117
+ if (k === "prf") Object.assign(out, _prfExt(extensions.prf));
118
+ if (k === "largeBlob") Object.assign(out, _largeBlobExt(extensions.largeBlob));
119
+ if (k === "credBlob") Object.assign(out, _credBlobExt(extensions.credBlob));
120
+ }
121
+ return out;
122
+ }
123
+
80
124
  // ---- Registration ----
81
125
 
82
126
  async function startRegistration(opts) {
@@ -86,6 +130,7 @@ async function startRegistration(opts) {
86
130
  _requireString(opts.userName, "userName");
87
131
 
88
132
  var sel = opts.authenticatorSelection || {};
133
+ var safeExtensions = _validateExtensions(opts.extensions, opts.allowUnknownExtensions === true);
89
134
  var options = await _vendor().generateRegistrationOptions({
90
135
  rpName: opts.rpName,
91
136
  rpID: opts.rpId,
@@ -100,7 +145,7 @@ async function startRegistration(opts) {
100
145
  requireResidentKey: sel.requireResidentKey,
101
146
  },
102
147
  timeout: opts.timeout,
103
- extensions: opts.extensions,
148
+ extensions: safeExtensions,
104
149
  });
105
150
  // Hint the browser to surface platform + cross-device authenticators
106
151
  // (Touch ID / Windows Hello AND 1Password / Bitwarden / phone-as-key).
@@ -202,12 +247,13 @@ async function startAuthentication(opts) {
202
247
  "mediation must be one of silent/optional/required/conditional");
203
248
  }
204
249
 
250
+ var safeAuthExtensions = _validateExtensions(opts.extensions, opts.allowUnknownExtensions === true);
205
251
  var options = await _vendor().generateAuthenticationOptions({
206
252
  rpID: opts.rpId,
207
253
  userVerification: opts.userVerification || "preferred",
208
254
  allowCredentials: opts.allowCredentials || [],
209
255
  timeout: opts.timeout,
210
- extensions: opts.extensions,
256
+ extensions: safeAuthExtensions,
211
257
  });
212
258
  if (!opts.hints) {
213
259
  options.hints = ["client-device", "hybrid"];
@@ -230,6 +276,7 @@ async function conditionalAuthOptions(opts) {
230
276
  if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
231
277
  _requireString(opts.rpId, "rpId");
232
278
 
279
+ var safeCondExtensions = _validateExtensions(opts.extensions, opts.allowUnknownExtensions === true);
233
280
  var options = await _vendor().generateAuthenticationOptions({
234
281
  rpID: opts.rpId,
235
282
  // For conditional UI the spec mandates an empty allowCredentials
@@ -238,7 +285,7 @@ async function conditionalAuthOptions(opts) {
238
285
  allowCredentials: [],
239
286
  userVerification: opts.userVerification || "preferred",
240
287
  timeout: opts.timeout,
241
- extensions: opts.extensions,
288
+ extensions: safeCondExtensions,
242
289
  });
243
290
  options.mediation = "conditional";
244
291
  if (!opts.hints) {
@@ -257,22 +304,46 @@ async function conditionalAuthOptions(opts) {
257
304
  // contract. Validation tier: throw at config-time. Misuse here is a
258
305
  // coding bug, not a request-shape thing.
259
306
 
260
- function _b64urlExtInput(value, name) {
307
+ // CTAP2.1 §6.5 — PRF eval inputs are 32-byte salts. Caps every
308
+ // extension input that ships through the binary normalizer.
309
+ var MAX_EXT_INPUT_BYTES = 32; // allow:raw-byte-literal — CTAP2.1 §6.5 PRF salt length
310
+
311
+ function _b64urlExtInput(value, name, maxBytes) {
261
312
  // Accept a base64url string OR a Buffer / Uint8Array. Normalize the
262
313
  // wire shape to base64url (the JSON descriptor ships base64url; the
263
314
  // browser turns it into an ArrayBuffer before passing to the
264
315
  // authenticator).
316
+ //
317
+ // AUTH-29 — when `maxBytes` is set, refuse decoded inputs longer than
318
+ // the cap. Per CTAP2.1 §6.5 PRF salts are 32 bytes; pre-v0.9.x the
319
+ // framework accepted arbitrary length, which is undefined behavior on
320
+ // authenticators that may truncate / reject / behave inconsistently.
265
321
  if (typeof value === "string") {
266
322
  if (value.length === 0 || !safeBuffer.BASE64URL_RE.test(value)) {
267
323
  throw new AuthError("auth-passkey/bad-extension-input",
268
324
  name + " must be base64url (no padding) when string");
269
325
  }
326
+ if (typeof maxBytes === "number") {
327
+ var decoded = Buffer.from(value, "base64url");
328
+ if (decoded.length > maxBytes) {
329
+ throw new AuthError("auth-passkey/extension-input-too-large",
330
+ name + " decoded length " + decoded.length + " exceeds " + maxBytes + " bytes");
331
+ }
332
+ }
270
333
  return value;
271
334
  }
272
335
  if (Buffer.isBuffer(value)) {
336
+ if (typeof maxBytes === "number" && value.length > maxBytes) {
337
+ throw new AuthError("auth-passkey/extension-input-too-large",
338
+ name + " length " + value.length + " exceeds " + maxBytes + " bytes");
339
+ }
273
340
  return value.toString("base64url");
274
341
  }
275
342
  if (value instanceof Uint8Array) {
343
+ if (typeof maxBytes === "number" && value.length > maxBytes) {
344
+ throw new AuthError("auth-passkey/extension-input-too-large",
345
+ name + " length " + value.length + " exceeds " + maxBytes + " bytes");
346
+ }
276
347
  return Buffer.from(value).toString("base64url");
277
348
  }
278
349
  throw new AuthError("auth-passkey/bad-extension-input",
@@ -293,9 +364,10 @@ function _prfExt(args) {
293
364
  throw new AuthError("auth-passkey/missing-prf-first",
294
365
  "extensions.prf eval.first is required");
295
366
  }
296
- var out = { prf: { eval: { first: _b64urlExtInput(args.eval.first, "eval.first") } } };
367
+ // AUTH-29 CTAP2.1 §6.5 caps PRF salts at 32 bytes.
368
+ var out = { prf: { eval: { first: _b64urlExtInput(args.eval.first, "eval.first", MAX_EXT_INPUT_BYTES) } } };
297
369
  if (args.eval.second !== undefined && args.eval.second !== null) {
298
- out.prf.eval.second = _b64urlExtInput(args.eval.second, "eval.second");
370
+ out.prf.eval.second = _b64urlExtInput(args.eval.second, "eval.second", MAX_EXT_INPUT_BYTES);
299
371
  }
300
372
  return out;
301
373
  }
@@ -448,6 +520,66 @@ async function verifyAuthentication(opts) {
448
520
  return rv;
449
521
  }
450
522
 
523
+ /**
524
+ * @primitive b.auth.passkey.compareBackupState
525
+ * @signature b.auth.passkey.compareBackupState(prev, current)
526
+ * @since 0.9.57
527
+ *
528
+ * AUTH-27 — WebAuthn L3 §6.1.3. Inspect the credential's persisted BE
529
+ * (backupEligible) + BS (backupState) flags against the values
530
+ * surfaced on a fresh assertion. Returns a normalized verdict the
531
+ * operator routes into audit / step-up decisions:
532
+ *
533
+ * - `ok` — flags unchanged
534
+ * - `be-flipped-on` — credential newly backup-eligible (the
535
+ * authenticator manufacturer enabled cloud-backup on a previously
536
+ * single-device credential; suspicious — operator surfaces
537
+ * step-up)
538
+ * - `be-flipped-off` — credential lost backup eligibility (rare;
539
+ * authenticator firmware downgrade or vendor policy change)
540
+ * - `bs-flipped-on` — credential is now backed up (user enrolled
541
+ * in cloud-sync after initial registration; legitimate but
542
+ * audit-worthy)
543
+ * - `bs-flipped-off` — credential no longer backed up (user
544
+ * disabled cloud-sync; legitimate but audit-worthy)
545
+ *
546
+ * Operators wire this against the credential row's persisted
547
+ * `backupEligible` / `backupState` fields and the corresponding
548
+ * fields on `verifyAuthentication`'s return value.
549
+ *
550
+ * @example
551
+ * var rv = await b.auth.passkey.verifyAuthentication(opts);
552
+ * var diff = b.auth.passkey.compareBackupState(stored, rv);
553
+ * if (diff.verdict !== "ok") {
554
+ * await audit.emit({ event: "passkey.backup-state-changed", metadata: diff });
555
+ * if (diff.verdict === "be-flipped-on") { requireStepUp(); }
556
+ * }
557
+ */
558
+ function compareBackupState(prev, current) {
559
+ if (!prev || typeof prev !== "object") {
560
+ throw new AuthError("auth-passkey/bad-compare-backup",
561
+ "compareBackupState: prev must be an object with { backupEligible, backupState }");
562
+ }
563
+ if (!current || typeof current !== "object") {
564
+ throw new AuthError("auth-passkey/bad-compare-backup",
565
+ "compareBackupState: current must be an object with { backupEligible, backupState }");
566
+ }
567
+ var pBE = prev.backupEligible === true;
568
+ var pBS = prev.backupState === true;
569
+ var cBE = current.backupEligible === true;
570
+ var cBS = current.backupState === true;
571
+ var verdict = "ok";
572
+ if (pBE !== cBE) verdict = cBE ? "be-flipped-on" : "be-flipped-off";
573
+ else if (pBS !== cBS) verdict = cBS ? "bs-flipped-on" : "bs-flipped-off";
574
+ return {
575
+ verdict: verdict,
576
+ prevBackupEligible: pBE,
577
+ prevBackupState: pBS,
578
+ currentBackupEligible: cBE,
579
+ currentBackupState: cBS,
580
+ };
581
+ }
582
+
451
583
  // ---- WebAuthn Signal API (W3C draft, 2024) ----
452
584
  //
453
585
  // The signal* methods build the JSON descriptor that the operator
@@ -539,4 +671,6 @@ module.exports = {
539
671
  signalUnknownCredential: signalUnknownCredential,
540
672
  signalAllAcceptedCredentials: signalAllAcceptedCredentials,
541
673
  signalCurrentUserDetails: signalCurrentUserDetails,
674
+ compareBackupState: compareBackupState,
675
+ ALLOWED_EXTENSION_KEYS: ALLOWED_EXTENSION_KEYS,
542
676
  };
@@ -73,6 +73,11 @@ function _timingSafeEqStr(a, b) {
73
73
  var disclosure = require("./sd-jwt-vc-disclosure");
74
74
  var sdJwtVcIssuer = require("./sd-jwt-vc-issuer");
75
75
  var sdJwtVcHolder = require("./sd-jwt-vc-holder");
76
+ // Shared JOSE defenses (CVE-2026-22817 alg/kty cross-check +
77
+ // CVE-2026-23552 constant-time iss compare). Top-of-file per project
78
+ // convention §3; no circular load — jwt-external requires nothing from
79
+ // sd-jwt-vc.
80
+ var jwtExternal = require("./jwt-external");
76
81
  var { AuthError } = require("../framework-error");
77
82
 
78
83
  var SUPPORTED_ALGS = Object.freeze([
@@ -296,6 +301,17 @@ function present(opts) {
296
301
  // Hardcoded sha256 here previously diverged from the verifier when
297
302
  // an issuer used a non-default hash, producing sd-hash-mismatch on
298
303
  // valid presentations.
304
+ //
305
+ // Defense-in-depth: this pre-parse runs on the holder side
306
+ // (presentation builder) and reads `_sd_alg` from UNSIGNED bytes,
307
+ // because the holder needs to know which hash to use BEFORE the
308
+ // verifier sees the presentation. The presentation itself carries
309
+ // the JWS-signed issuer JWT verbatim; verify() re-parses the
310
+ // payload from the cryptographically-verified signing input. That
311
+ // post-verify decode is the source of truth — a holder who tampers
312
+ // with `_sd_alg` here only breaks their own KB-JWT digest, since
313
+ // the verifier recomputes from the signed bytes. No security
314
+ // boundary is crossed by reading the value here.
299
315
  var _issuerPayload = null;
300
316
  var _jwtParts = jwt.split(".");
301
317
  if (_jwtParts.length === 3) {
@@ -425,14 +441,39 @@ async function verify(presentation, opts) {
425
441
  "verify: malformed JWT header: " + e.message);
426
442
  }
427
443
  var alg = headerObj.alg;
428
- if (SUPPORTED_ALGS.indexOf(alg) === -1) {
444
+ // CVE-2026-23993 — refuse unknown / unsupported alg BEFORE any key
445
+ // resolution. The shared `_assertAlgKtyMatch` helper repeats this
446
+ // check after the issuer key is resolved; doing it here too closes
447
+ // the gap where an issuerKeyResolver with side effects (network
448
+ // fetch, audit emit) would run even when the alg is unsupported.
449
+ if (typeof alg !== "string" || SUPPORTED_ALGS.indexOf(alg) === -1) {
429
450
  throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
430
- "verify: header alg \"" + alg + "\" not in supported set");
431
- }
451
+ "verify: header alg \"" + alg + "\" not in supported set " +
452
+ "(CVE-2026-23993 — refused before key lookup)");
453
+ }
454
+ // draft-ietf-oauth-sd-jwt-vc §3.1 — typ MUST be `vc+sd-jwt` (or
455
+ // `dc+sd-jwt` for digital-credential profile). Pre-v0.9.x the absent-
456
+ // typ short-circuit accepted any token without typ, contradicting
457
+ // the spec MUST. Refuse absent typ; drop the legacy JWT allowance —
458
+ // verifyExternal handles generic JWT, sd-jwt-vc handles only the
459
+ // typ'd shape.
432
460
  var typ = headerObj.typ;
433
- if (typ && typ !== "vc+sd-jwt" && typ !== "JWT") {
461
+ if (typeof typ !== "string" || (typ !== "vc+sd-jwt" && typ !== "dc+sd-jwt")) {
434
462
  throw new AuthError("auth-sd-jwt-vc/bad-typ",
435
- "verify: header typ must be \"vc+sd-jwt\" (got \"" + typ + "\")");
463
+ "verify: header typ must be \"vc+sd-jwt\" or \"dc+sd-jwt\" (got " +
464
+ (typ === undefined ? "<absent>" : "\"" + typ + "\"") +
465
+ ") — draft-ietf-oauth-sd-jwt-vc §3.1 MUST");
466
+ }
467
+ // RFC 7515 §4.1.11 — refuse non-empty `crit` header. Every other
468
+ // verifier in the framework refuses critical extensions; sd-jwt-vc
469
+ // previously silently ignored, letting an attacker-controlled issuer
470
+ // declare critical extensions the verifier doesn't understand.
471
+ if (headerObj.crit !== undefined && headerObj.crit !== null) {
472
+ if (!Array.isArray(headerObj.crit) || headerObj.crit.length > 0) {
473
+ throw new AuthError("auth-sd-jwt-vc/unknown-crit",
474
+ "verify: header carries 'crit' extension list — sd-jwt-vc does not " +
475
+ "support any critical extensions and refuses per RFC 7515 §4.1.11");
476
+ }
436
477
  }
437
478
 
438
479
  var issuerKey = await opts.issuerKeyResolver(headerObj);
@@ -440,7 +481,27 @@ async function verify(presentation, opts) {
440
481
  throw new AuthError("auth-sd-jwt-vc/key-not-found",
441
482
  "verify: issuerKeyResolver returned no key");
442
483
  }
484
+ // CVE-2026-22817 — when issuerKeyResolver returns a JWK object,
485
+ // cross-check alg/kty BEFORE handing it to node:crypto.verify.
486
+ // KeyObject / PEM shapes can't surface kty so the check happens at
487
+ // JWK resolution only.
488
+ if (typeof issuerKey === "object" &&
489
+ !(issuerKey instanceof nodeCrypto.KeyObject) &&
490
+ !Buffer.isBuffer(issuerKey) &&
491
+ typeof issuerKey.kty === "string") {
492
+ jwtExternal._assertAlgKtyMatch(alg, issuerKey);
493
+ }
443
494
  var jwtParsed = _verifyJwt(jwt, issuerKey, alg);
495
+ // AUTH-25 — post-verify header compare. Pre-verify we parsed the
496
+ // header bytes to look up the key; _verifyJwt parses again from the
497
+ // cryptographically-verified signing input. Both decodes MUST yield
498
+ // the same JSON; a mismatch indicates a JWS-canonicalization or
499
+ // duplicate-key issue and refuses defense-in-depth.
500
+ if (safeJson.stringify(headerObj) !== safeJson.stringify(jwtParsed.header)) {
501
+ throw new AuthError("auth-sd-jwt-vc/header-roundtrip-mismatch",
502
+ "verify: pre-verify header bytes do not round-trip equal to post-verify " +
503
+ "header bytes — refusing potential JWS canonicalization smuggle");
504
+ }
444
505
 
445
506
  // 2. Validate iss / iat / exp / vct
446
507
  var nowSec = (typeof opts.now === "number" && isFinite(opts.now))
@@ -459,6 +520,18 @@ async function verify(presentation, opts) {
459
520
  "verify: vct mismatch (got \"" + jwtParsed.payload.vct +
460
521
  "\", expected \"" + opts.expectedVct + "\")");
461
522
  }
523
+ // CVE-2026-23552 — optional explicit iss check with constant-time
524
+ // compare. Operators with a known-issuer trust scope pass
525
+ // `opts.expectedIssuer`; absence preserves the existing
526
+ // issuerKeyResolver-only trust model.
527
+ if (opts.expectedIssuer) {
528
+ if (typeof jwtParsed.payload.iss !== "string" ||
529
+ !jwtExternal._issuerMatches(jwtParsed.payload.iss, opts.expectedIssuer)) {
530
+ throw new AuthError("auth-sd-jwt-vc/iss-mismatch",
531
+ "verify: iss '" + jwtParsed.payload.iss + "' does not match expected '" +
532
+ opts.expectedIssuer + "' (CVE-2026-23552 — cross-realm refused)");
533
+ }
534
+ }
462
535
 
463
536
  // 3. Reconstruct disclosed claims from disclosures
464
537
  // IETF SD-JWT default `_sd_alg` is `sha-256` (draft-ietf-oauth-
@@ -78,9 +78,17 @@ function create(opts) {
78
78
  // The factory's documented shape is `create({ name, ...opts })`;
79
79
  // split the name out of opts before invoking the constructor.
80
80
  // Caught by hermitstash-sync operator review against v0.9.12.
81
+ //
82
+ // CRYPTO-19 — the previous empty-string fallback was unreachable
83
+ // (retryHelper.CircuitBreaker validator throws on "" first) AND
84
+ // produced a confusing error message ("name must be a non-empty
85
+ // string, got string \"\"") that obscured the real opt-shape
86
+ // problem. Pass opts.name through directly so the validator's
87
+ // error message reports the exact opt-shape error (missing name,
88
+ // non-string name, etc.) — the operator can act on it without
89
+ // tracing through the factory.
81
90
  opts = opts || {};
82
- var name = (opts && typeof opts.name === "string") ? opts.name : "";
83
- return new retryHelper.CircuitBreaker(name, opts);
91
+ return new retryHelper.CircuitBreaker(opts.name, opts);
84
92
  }
85
93
 
86
94
  module.exports = {
package/lib/cli.js CHANGED
@@ -48,6 +48,7 @@ var C = require("./constants");
48
48
  var bCrypto = require("./crypto");
49
49
  var dev = require("./dev");
50
50
  var fileType = require("./file-type");
51
+ var guardRegex = require("./guard-regex");
51
52
  var migrations = require("./migrations");
52
53
  var passwordModule = require("./auth/password");
53
54
  var requestHelpers = require("./request-helpers");
@@ -357,6 +358,18 @@ async function _runDev(args, ctx) {
357
358
  "blamejs dev: --ignore pattern exceeds max length " +
358
359
  MAX_IGNORE_PATTERN_LENGTH + " (got " + str.length + ")");
359
360
  }
361
+ // ReDoS / catastrophic-backtracking defense — refuses nested-quant
362
+ // (CVE-2024-21538 class), consecutive-* (CVE-2026-26996), nested
363
+ // extglob (CVE-2026-33671), and lookaround-quant shapes before the
364
+ // pattern reaches RegExp(). Operator typo / hostile-input identical
365
+ // shape from here on — both want the same refusal.
366
+ try {
367
+ guardRegex.sanitize(str, { profile: "strict" });
368
+ } catch (e) {
369
+ throw new CliError("cli/bad-ignore-pattern",
370
+ "blamejs dev: --ignore pattern refused by guardRegex: " +
371
+ ((e && e.message) || String(e)));
372
+ }
360
373
  return RegExp(str);
361
374
  });
362
375
  var graceMs = args.flags["grace-ms"] !== undefined ? Number(args.flags["grace-ms"]) : undefined;