@blamejs/core 0.9.49 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +951 -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 +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-jmap.js +321 -0
  29. package/lib/guard-managesieve-command.js +566 -0
  30. package/lib/guard-pop3-command.js +317 -0
  31. package/lib/guard-smtp-command.js +58 -3
  32. package/lib/mail-agent.js +20 -7
  33. package/lib/mail-arc-sign.js +12 -8
  34. package/lib/mail-auth.js +323 -34
  35. package/lib/mail-crypto-pgp.js +934 -0
  36. package/lib/mail-crypto-smime.js +340 -0
  37. package/lib/mail-crypto.js +108 -0
  38. package/lib/mail-dav.js +1224 -0
  39. package/lib/mail-deploy.js +492 -0
  40. package/lib/mail-dkim.js +431 -26
  41. package/lib/mail-journal.js +435 -0
  42. package/lib/mail-scan.js +502 -0
  43. package/lib/mail-server-imap.js +64 -26
  44. package/lib/mail-server-jmap.js +488 -0
  45. package/lib/mail-server-managesieve.js +853 -0
  46. package/lib/mail-server-mx.js +40 -30
  47. package/lib/mail-server-pop3.js +836 -0
  48. package/lib/mail-server-rate-limit.js +13 -0
  49. package/lib/mail-server-submission.js +70 -24
  50. package/lib/mail-server-tls.js +445 -0
  51. package/lib/mail-sieve.js +557 -0
  52. package/lib/mail-spam-score.js +284 -0
  53. package/lib/mail.js +99 -0
  54. package/lib/metrics.js +80 -3
  55. package/lib/middleware/dpop.js +58 -3
  56. package/lib/middleware/idempotency-key.js +255 -42
  57. package/lib/middleware/protected-resource-metadata.js +114 -2
  58. package/lib/network-dns-resolver.js +33 -0
  59. package/lib/network-tls.js +46 -0
  60. package/lib/outbox.js +62 -12
  61. package/lib/pqc-agent.js +13 -5
  62. package/lib/retry.js +23 -9
  63. package/lib/router.js +23 -1
  64. package/lib/safe-ical.js +634 -0
  65. package/lib/safe-icap.js +502 -0
  66. package/lib/safe-mime.js +15 -0
  67. package/lib/safe-sieve.js +684 -0
  68. package/lib/safe-smtp.js +57 -0
  69. package/lib/safe-url.js +37 -0
  70. package/lib/safe-vcard.js +473 -0
  71. package/lib/self-update-standalone-verifier.js +32 -3
  72. package/lib/self-update.js +153 -33
  73. package/lib/vendor/MANIFEST.json +161 -156
  74. package/lib/vendor-data.js +127 -9
  75. package/lib/vex.js +324 -59
  76. package/package.json +1 -1
  77. package/sbom.cdx.json +6 -6
@@ -55,6 +55,7 @@ var safeUrl = require("../safe-url");
55
55
  var lazyRequire = require("../lazy-require");
56
56
  var validateOpts = require("../validate-opts");
57
57
  var C = require("../constants");
58
+ var bCrypto = require("../crypto");
58
59
  var { AuthError } = require("../framework-error");
59
60
 
60
61
  var httpClient = lazyRequire(function () { return require("../http-client"); });
@@ -131,6 +132,77 @@ function _jwkToKey(jwk) {
131
132
  }
132
133
  }
133
134
 
135
+ // CVE-2026-22817 — RS256→HS256 alg-confusion + the broader alg/kty
136
+ // confused-deputy class. The header `alg` is attacker-controlled; the
137
+ // JWK's `kty` (and `crv` for EC / OKP) describes what the resolved key
138
+ // actually IS. Without a cross-check, a server that resolved an RSA
139
+ // public key (RS256/PS256 family) can be tricked into accepting a token
140
+ // declaring `alg: "HS256"` — Node's verify() treats the RSA public key
141
+ // bytes as an HMAC secret and the signature verifies. Equivalent
142
+ // confusion lives between EC (ES*) and RSA (RS*/PS*) when the issuer
143
+ // publishes both key types under one kid scheme. Crossing alg → expected
144
+ // kty/crv BEFORE handing the JWK to node:crypto closes the class.
145
+ //
146
+ // Routed through from oauth.verifyIdToken / jwt-external.verifyExternal /
147
+ // oid4vci proof verify / sd-jwt-vc.verify / openid-federation.verifyEntityStatement
148
+ // per the v0.9.x audit (CVE-2026-22817 column).
149
+ //
150
+ // RFC 7518 §3 maps the JWS `alg` to the key shape it requires:
151
+ // RS*/PS* → kty=RSA
152
+ // ES256 → kty=EC, crv=P-256
153
+ // ES384 → kty=EC, crv=P-384
154
+ // ES512 → kty=EC, crv=P-521
155
+ // EdDSA → kty=OKP (crv=Ed25519 or Ed448)
156
+ // ML-DSA-* → kty=AKP, alg=<algId> (draft-ietf-cose-cnsa-pqc)
157
+ function _assertAlgKtyMatch(alg, jwk) {
158
+ if (typeof alg !== "string" || alg.length === 0) {
159
+ throw new AuthError("auth-jwt-external/bad-alg",
160
+ "_assertAlgKtyMatch: alg must be a non-empty string");
161
+ }
162
+ if (!jwk || typeof jwk !== "object" || typeof jwk.kty !== "string") {
163
+ throw new AuthError("auth-jwt-external/bad-jwk",
164
+ "_assertAlgKtyMatch: JWK must declare kty");
165
+ }
166
+ var expectedKty = null;
167
+ var expectedCrv = null;
168
+ if (alg === "RS256" || alg === "RS384" || alg === "RS512" ||
169
+ alg === "PS256" || alg === "PS384" || alg === "PS512") {
170
+ expectedKty = "RSA";
171
+ } else if (alg === "ES256") { expectedKty = "EC"; expectedCrv = "P-256"; }
172
+ else if (alg === "ES384") { expectedKty = "EC"; expectedCrv = "P-384"; }
173
+ else if (alg === "ES512") { expectedKty = "EC"; expectedCrv = "P-521"; }
174
+ else if (alg === "EdDSA") { expectedKty = "OKP"; }
175
+ else if (alg === "ML-DSA-65" || alg === "ML-DSA-87") { expectedKty = "AKP"; }
176
+ else {
177
+ // Unknown alg — caller's alg allowlist should have rejected first;
178
+ // refuse here defensively (CVE-2026-23993 class — unknown-alg paths
179
+ // that skip downstream verification).
180
+ throw new AuthError("auth-jwt-external/unsupported-alg",
181
+ "_assertAlgKtyMatch: alg '" + alg + "' has no defined key-type binding");
182
+ }
183
+ if (jwk.kty !== expectedKty) {
184
+ throw new AuthError("auth-jwt-external/alg-kty-mismatch",
185
+ "JWS alg '" + alg + "' requires JWK kty='" + expectedKty +
186
+ "' but resolved JWK has kty='" + jwk.kty + "' (CVE-2026-22817 — alg confusion)");
187
+ }
188
+ if (expectedCrv && jwk.crv !== expectedCrv) {
189
+ throw new AuthError("auth-jwt-external/alg-crv-mismatch",
190
+ "JWS alg '" + alg + "' requires JWK crv='" + expectedCrv +
191
+ "' but resolved JWK has crv='" + (jwk.crv || "<absent>") + "' (CVE-2026-22817 — curve confusion)");
192
+ }
193
+ }
194
+
195
+ // Constant-time issuer comparison (CVE-2026-23552 — cross-realm/issuer
196
+ // JWT acceptance via weak iss validation). Both sides are
197
+ // operator-supplied strings; a non-CT compare leaks length / prefix
198
+ // timing that lets an attacker narrow which realm prefix the verifier
199
+ // accepts. cryptoTimingSafeEqual handles unequal-length safely and
200
+ // returns false rather than throwing.
201
+ function _issuerMatches(actual, expected) {
202
+ if (typeof actual !== "string" || typeof expected !== "string") return false;
203
+ return bCrypto.timingSafeEqual(actual, expected);
204
+ }
205
+
134
206
  function _toKey(value) {
135
207
  if (!value) {
136
208
  throw new AuthError("auth-jwt-external/no-key",
@@ -299,9 +371,21 @@ async function verifyExternal(token, opts) {
299
371
  throw new AuthError("auth-jwt-external/unknown-crit",
300
372
  "token declares 'crit' header — verifyExternal does not support critical extensions");
301
373
  }
374
+ // CVE-2026-23993 — refuse alg values outside the accepted list BEFORE
375
+ // any key lookup. The early refusal closes the class where an
376
+ // unknown / unsupported alg slips through to a downstream code path
377
+ // that interprets it permissively. The per-listed algorithm check
378
+ // above in the opts-validation loop refuses the OPERATOR'S allowlist
379
+ // shape; this check refuses the TOKEN'S declared alg before any
380
+ // key-resolver / JWKS-fetch side effect.
302
381
  if (opts.algorithms.indexOf(header.alg) === -1) {
303
382
  throw new AuthError("auth-jwt-external/alg-not-allowed",
304
- "token alg='" + header.alg + "' not in allowed list [" + opts.algorithms.join(", ") + "]");
383
+ "token alg='" + header.alg + "' not in allowed list [" + opts.algorithms.join(", ") +
384
+ "] (CVE-2026-23993 — refused before key lookup)");
385
+ }
386
+ if (SUPPORTED_CLASSICAL_ALGS.indexOf(header.alg) === -1) {
387
+ throw new AuthError("auth-jwt-external/unsupported-alg",
388
+ "token alg='" + header.alg + "' is not in the verifier's supported set (CVE-2026-23993)");
305
389
  }
306
390
 
307
391
  // Resolve key.
@@ -313,11 +397,26 @@ async function verifyExternal(token, opts) {
313
397
  throw new AuthError("auth-jwt-external/key-resolver-failed",
314
398
  "keyResolver threw: " + ((e && e.message) || String(e)));
315
399
  }
400
+ // When keyResolver returns a JWK object, cross-check alg/kty BEFORE
401
+ // _toKey hands it to node:crypto (CVE-2026-22817). PEM / KeyObject
402
+ // shapes can't carry a kty surface so the check happens at JWKS
403
+ // resolution only.
404
+ if (resolved && typeof resolved === "object" &&
405
+ !(resolved instanceof nodeCrypto.KeyObject) &&
406
+ !Buffer.isBuffer(resolved) &&
407
+ typeof resolved.kty === "string") {
408
+ _assertAlgKtyMatch(header.alg, resolved);
409
+ }
316
410
  key = _toKey(resolved);
317
411
  } else {
318
412
  var keys = opts.jwks ? opts.jwks
319
413
  : await _fetchJwks(opts.jwksUri, opts.jwksCacheMs);
320
414
  var jwk = _selectKey(keys, header, opts);
415
+ // CVE-2026-22817 — cross-check alg/kty BEFORE importing the JWK as
416
+ // a key object. Without this an attacker-controlled `alg: "HS256"`
417
+ // against an RSA-kty JWK would have node:crypto.verify treat the
418
+ // RSA public key as an HMAC secret.
419
+ _assertAlgKtyMatch(header.alg, jwk);
321
420
  key = _jwkToKey(jwk);
322
421
  }
323
422
 
@@ -376,9 +475,30 @@ async function verifyExternal(token, opts) {
376
475
  JSON.stringify(opts.audience) + "'");
377
476
  }
378
477
  }
379
- if (opts.issuer && payload.iss !== opts.issuer) {
380
- throw new AuthError("auth-jwt-external/iss-mismatch",
381
- "token iss '" + payload.iss + "' does not match expected '" + opts.issuer + "'");
478
+ if (opts.issuer) {
479
+ // CVE-2026-23552 — cross-realm / cross-issuer JWT acceptance via
480
+ // weak iss validation. Constant-time compare defeats prefix-timing
481
+ // narrowing; emit a DISTINCT audit event (separate from sig-verify-
482
+ // fail) so detection signals lights up on the cross-realm shape
483
+ // independently of generic verification failures.
484
+ if (typeof payload.iss !== "string" ||
485
+ !_issuerMatches(payload.iss, opts.issuer)) {
486
+ try { audit().safeEmit({
487
+ action: "jwt.iss.mismatch",
488
+ outcome: "denied",
489
+ metadata: {
490
+ expectedIssuer: opts.issuer,
491
+ // payload.iss is attacker-controlled, but logging it for
492
+ // detection is the point — operators correlate against
493
+ // their tenant table to identify cross-realm probes.
494
+ presentedIssuer: typeof payload.iss === "string" ? payload.iss : null,
495
+ reason: "cross-realm-jwt-refused",
496
+ },
497
+ }); } catch (_e) { /* drop-silent — observability sink */ }
498
+ throw new AuthError("auth-jwt-external/iss-mismatch",
499
+ "token iss '" + payload.iss + "' does not match expected '" + opts.issuer +
500
+ "' (CVE-2026-23552 — cross-realm refused)");
501
+ }
382
502
  }
383
503
  if (opts.subject && payload.sub !== opts.subject) {
384
504
  throw new AuthError("auth-jwt-external/sub-mismatch",
@@ -392,4 +512,8 @@ module.exports = {
392
512
  verifyExternal: verifyExternal,
393
513
  SUPPORTED_CLASSICAL_ALGS: SUPPORTED_CLASSICAL_ALGS,
394
514
  REFUSED_ALGS: REFUSED_ALGS,
515
+ // Shared JOSE defenses — routed from oauth.verifyIdToken /
516
+ // oid4vci proof verify / sd-jwt-vc.verify / openid-federation.
517
+ _assertAlgKtyMatch: _assertAlgKtyMatch,
518
+ _issuerMatches: _issuerMatches,
395
519
  };
package/lib/auth/oauth.js CHANGED
@@ -115,6 +115,13 @@ var safeJson = require("../safe-json");
115
115
  var safeUrl = require("../safe-url");
116
116
  var { URL } = require("node:url");
117
117
  var { defineClass } = require("../framework-error");
118
+ var lazyRequire = require("../lazy-require");
119
+ // Shared JOSE defenses (CVE-2026-22817 alg/kty cross-check +
120
+ // CVE-2026-23552 constant-time iss compare). Top-of-file per project
121
+ // convention §3; no circular load — jwt-external requires nothing from
122
+ // oauth.
123
+ var jwtExternal = require("./jwt-external");
124
+ var audit = lazyRequire(function () { return require("../audit"); });
118
125
 
119
126
  // Cap on responses parsed from upstream OAuth providers. Token /
120
127
  // userinfo / discovery responses are tiny in spec; 256 KiB leaves
@@ -208,6 +215,30 @@ var PSS_SALT_BYTES_SHA256 = C.BYTES.bytes(32);
208
215
  var PSS_SALT_BYTES_SHA384 = C.BYTES.bytes(48);
209
216
  var PSS_SALT_BYTES_SHA512 = C.BYTES.bytes(64);
210
217
 
218
+ // RFC 8628 §3.4 — device_code length cap. The spec doesn't fix a max
219
+ // length but 8 KiB comfortably accommodates any legitimate base64url
220
+ // CSPRNG output and refuses pathological payloads.
221
+ var MAX_DEVICE_CODE_BYTES = C.BYTES.kib(8);
222
+ // RFC 8628 §3.4 — 5s is the spec-documented MINIMUM polling interval.
223
+ var MIN_DEVICE_POLL_INTERVAL_SEC = 5; // allow:raw-time-literal — RFC 8628 §3.4 spec floor
224
+ // OIDC Back-Channel Logout §2.6 — replay defense via jti store catches
225
+ // duplicate-jti reuse, but pre-v0.9.x an old captured logout-token
226
+ // with a fresh jti could still pass. Enforce iat freshness against
227
+ // this floor (operator-tunable).
228
+ var DEFAULT_LOGOUT_TOKEN_MAX_AGE_SEC = C.TIME.minutes(5) / C.TIME.seconds(1);
229
+
230
+ // RFC 8693 §3 — registered token-type URNs for token exchange.
231
+ // Operators with custom URNs pass allowCustomTokenType:true with a
232
+ // documented downstream contract.
233
+ var RFC_8693_TOKEN_TYPES = Object.freeze([
234
+ "urn:ietf:params:oauth:token-type:access_token",
235
+ "urn:ietf:params:oauth:token-type:refresh_token",
236
+ "urn:ietf:params:oauth:token-type:id_token",
237
+ "urn:ietf:params:oauth:token-type:saml1",
238
+ "urn:ietf:params:oauth:token-type:saml2",
239
+ "urn:ietf:params:oauth:token-type:jwt",
240
+ ]);
241
+
211
242
  // ---- helpers ----
212
243
 
213
244
  function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
@@ -363,6 +394,14 @@ function create(opts) {
363
394
  || (preset && typeof preset.issuerTemplate === "function" && preset.issuerTemplate(opts))
364
395
  || (preset && preset.issuer)
365
396
  || null;
397
+ // OIDC Core §15.5 — issuer is a URL the framework subsequently uses
398
+ // as the OP identity in discovery + JWT iss comparisons. An operator
399
+ // typo in opts.auth0Domain / opts.keycloakUrl flows into the preset's
400
+ // issuerTemplate output verbatim; without validation that mistake
401
+ // reaches discovery + the iss compare. Re-route through _validateUrl
402
+ // so the issuer the framework will trust later is well-formed before
403
+ // any network round-trip.
404
+ if (issuer) _validateUrl(issuer, allowHttp, "issuer");
366
405
  var scope = Array.isArray(opts.scope) && opts.scope.length > 0
367
406
  ? opts.scope.slice()
368
407
  : (preset && preset.defaultScope ? preset.defaultScope.slice() : ["openid"]);
@@ -953,6 +992,23 @@ function create(opts) {
953
992
  throw new OAuthError("auth-oauth/no-id-token", "verifyIdToken: idToken must be a string");
954
993
  }
955
994
  var parts = idToken.split(".");
995
+ // CVE-2026-29000 / CVE-2026-22817 / CVE-2026-23993 — mirror
996
+ // jwt-external's 5-segment JWE refusal. A 5-segment compact
997
+ // serialization is a JWE (RFC 7516); verifyIdToken is a JWS verifier
998
+ // and a JWE shape reaching here is the confused-deputy class an OP
999
+ // shipping JWE id_tokens would exercise. Operators with JWE
1000
+ // id_tokens wire a separate JWE handler at their KMS — never on
1001
+ // this verifier path.
1002
+ if (parts.length === 5) {
1003
+ try { audit().safeEmit({
1004
+ action: "jwt.jwe.refused",
1005
+ outcome: "denied",
1006
+ metadata: { reason: "jwe-on-jws-verifier", primitive: "oauth.verifyIdToken" },
1007
+ }); } catch (_e) { /* drop-silent — observability sink */ }
1008
+ throw new OAuthError("auth-oauth/jwe-refused",
1009
+ "5-segment JWE id_token refused — verifyIdToken only handles JWS " +
1010
+ "(CVE-2026-29000 / CVE-2026-23993 / CVE-2026-22817 / CVE-2026-34950 JWE-bypass class)");
1011
+ }
956
1012
  if (parts.length !== 3) {
957
1013
  throw new OAuthError("auth-oauth/malformed-jwt", "ID token does not have 3 parts");
958
1014
  }
@@ -967,9 +1023,13 @@ function create(opts) {
967
1023
  if (!header || typeof header.alg !== "string") {
968
1024
  throw new OAuthError("auth-oauth/malformed-jwt", "ID token header missing 'alg'");
969
1025
  }
1026
+ // CVE-2026-23993 — refuse unknown alg BEFORE any key resolution.
1027
+ // The acceptedAlgorithms list is the operator's posture; an alg
1028
+ // outside it never reaches the JWKS lookup or node:crypto.verify.
970
1029
  if (acceptedAlgorithms.indexOf(header.alg) === -1) {
971
1030
  throw new OAuthError("auth-oauth/alg-not-accepted",
972
- "ID token signed with '" + header.alg + "' which is not in the accepted-algorithm list");
1031
+ "ID token signed with '" + header.alg + "' which is not in the accepted-algorithm list " +
1032
+ "(CVE-2026-23993 — refused before key lookup)");
973
1033
  }
974
1034
  // RFC 7515 §4.1.11 — refuse JWS with `crit` header. Every other
975
1035
  // verifier in the framework (jwt.js, jwt-external.js, dpop.js)
@@ -1021,6 +1081,13 @@ function create(opts) {
1021
1081
  "call)");
1022
1082
  }
1023
1083
  }
1084
+ // CVE-2026-22817 — cross-check JWS alg against the resolved JWK's
1085
+ // kty (and crv for EC). Without this an attacker-controlled
1086
+ // `alg: "HS256"` against an RSA-kty JWK would hand the public-key
1087
+ // bytes to node:crypto.verify as an HMAC secret. Routed through the
1088
+ // shared helper so every JWT verifier (oauth / jwt-external /
1089
+ // oid4vci / sd-jwt-vc / openid-federation) enforces the same check.
1090
+ jwtExternal._assertAlgKtyMatch(header.alg, match);
1024
1091
  var keyObject = _jwkToKey(match);
1025
1092
  var params = _verifyParamsForAlg(header.alg);
1026
1093
  var signingInput = parts[0] + "." + parts[1];
@@ -1064,9 +1131,29 @@ function create(opts) {
1064
1131
  if (typeof payload.nbf === "number" && payload.nbf - skewSec > now) {
1065
1132
  throw new OAuthError("auth-oauth/nbf-future", "ID token nbf is in the future");
1066
1133
  }
1067
- if (issuer && payload.iss !== issuer) {
1068
- throw new OAuthError("auth-oauth/iss-mismatch",
1069
- "ID token iss '" + payload.iss + "' does not match expected '" + issuer + "'");
1134
+ if (issuer) {
1135
+ // CVE-2026-23552 — cross-realm / cross-issuer JWT acceptance. The
1136
+ // expected issuer is operator-supplied; payload.iss is attacker-
1137
+ // controlled bytes. Constant-time compare defeats prefix-timing
1138
+ // narrowing. Emit a DISTINCT audit event (separate from the
1139
+ // bad-signature failure) so detection signals on cross-realm
1140
+ // probes independently of generic verification failures.
1141
+ if (typeof payload.iss !== "string" ||
1142
+ !jwtExternal._issuerMatches(payload.iss, issuer)) {
1143
+ try { audit().safeEmit({
1144
+ action: "jwt.iss.mismatch",
1145
+ outcome: "denied",
1146
+ metadata: {
1147
+ expectedIssuer: issuer,
1148
+ presentedIssuer: typeof payload.iss === "string" ? payload.iss : null,
1149
+ reason: "cross-realm-jwt-refused",
1150
+ primitive: "oauth.verifyIdToken",
1151
+ },
1152
+ }); } catch (_e) { /* drop-silent — observability sink */ }
1153
+ throw new OAuthError("auth-oauth/iss-mismatch",
1154
+ "ID token iss '" + payload.iss + "' does not match expected '" + issuer +
1155
+ "' (CVE-2026-23552 — cross-realm refused)");
1156
+ }
1070
1157
  }
1071
1158
  var aud = Array.isArray(payload.aud) ? payload.aud : (payload.aud ? [payload.aud] : []);
1072
1159
  if (aud.indexOf(clientId) === -1) {
@@ -1104,6 +1191,12 @@ function create(opts) {
1104
1191
  var params = new URLSearchParams();
1105
1192
  if (uopts.idTokenHint) params.set("id_token_hint", uopts.idTokenHint);
1106
1193
  if (uopts.postLogoutRedirectUri) {
1194
+ // OIDC RP-Init Logout §3.1 — postLogoutRedirectUri is operator-
1195
+ // supplied; an operator typo could ship `http://` or
1196
+ // `javascript:`. Route through the framework's URL gate before
1197
+ // emitting so the URL is validated the same way as every other
1198
+ // operator-supplied OAuth URL (audit 2026-05-15).
1199
+ _validateUrl(uopts.postLogoutRedirectUri, allowHttp, "postLogoutRedirectUri");
1107
1200
  params.set("post_logout_redirect_uri", uopts.postLogoutRedirectUri);
1108
1201
  }
1109
1202
  if (uopts.state) params.set("state", uopts.state);
@@ -1111,8 +1204,30 @@ function create(opts) {
1111
1204
  if (uopts.uiLocales) params.set("ui_locales", uopts.uiLocales);
1112
1205
  if (uopts.clientId !== false) params.set("client_id", clientId);
1113
1206
  if (uopts.extraParams && typeof uopts.extraParams === "object") {
1207
+ // OIDC RP-Init Logout §3.1 — extraParams carries operator-
1208
+ // controlled key/value pairs. Refuse keys that collide with
1209
+ // first-class params so an operator typo / library-merge can't
1210
+ // smuggle a second `post_logout_redirect_uri` past the
1211
+ // _validateUrl gate above. Defense-in-depth — the operator
1212
+ // controls extraParams, so this is a config-time invariant, not
1213
+ // an attacker-input filter.
1214
+ var RESERVED_END_SESSION_PARAMS = {
1215
+ "id_token_hint": 1,
1216
+ "post_logout_redirect_uri": 1,
1217
+ "state": 1,
1218
+ "logout_hint": 1,
1219
+ "ui_locales": 1,
1220
+ "client_id": 1,
1221
+ };
1114
1222
  var ek = Object.keys(uopts.extraParams);
1115
- for (var i = 0; i < ek.length; i++) params.set(ek[i], String(uopts.extraParams[ek[i]]));
1223
+ for (var i = 0; i < ek.length; i++) {
1224
+ if (RESERVED_END_SESSION_PARAMS[ek[i]]) {
1225
+ throw new OAuthError("auth-oauth/end-session-reserved-extra-param",
1226
+ "endSessionUrl: extraParams key '" + ek[i] + "' collides with a first-class " +
1227
+ "RP-Init Logout parameter — pass it through the named field instead");
1228
+ }
1229
+ params.set(ek[i], String(uopts.extraParams[ek[i]]));
1230
+ }
1116
1231
  }
1117
1232
  var qs = params.toString();
1118
1233
  if (qs.length === 0) return endpoint;
@@ -1218,10 +1333,24 @@ function create(opts) {
1218
1333
  // logout for a session at a different IdP). `sid` is required
1219
1334
  // when the RP registered with frontchannel_logout_session_required=true;
1220
1335
  // we surface it either way and let the operator decide.
1221
- if (iss && iss !== issuer) {
1336
+ // CVE-2026-23552 constant-time issuer compare. Defeats prefix-
1337
+ // timing narrowing against the configured issuer string; iss is
1338
+ // attacker-controlled query-param input.
1339
+ if (iss && (typeof issuer !== "string" || !jwtExternal._issuerMatches(iss, issuer))) {
1340
+ try { audit().safeEmit({
1341
+ action: "jwt.iss.mismatch",
1342
+ outcome: "denied",
1343
+ metadata: {
1344
+ expectedIssuer: issuer,
1345
+ presentedIssuer: iss,
1346
+ reason: "frontchannel-logout-cross-realm",
1347
+ primitive: "oauth.parseFrontchannelLogoutRequest",
1348
+ },
1349
+ }); } catch (_e) { /* drop-silent — observability sink */ }
1222
1350
  throw new OAuthError("auth-oauth/frontchannel-logout-iss-mismatch",
1223
1351
  "parseFrontchannelLogoutRequest: iss \"" + iss +
1224
- "\" does not match configured issuer \"" + issuer + "\"");
1352
+ "\" does not match configured issuer \"" + issuer +
1353
+ "\" (CVE-2026-23552 — cross-realm refused)");
1225
1354
  }
1226
1355
  return { iss: iss || issuer, sid: sid || null };
1227
1356
  }
@@ -1304,8 +1433,54 @@ function create(opts) {
1304
1433
  throw new OAuthError("auth-oauth/no-sub-or-sid",
1305
1434
  "verifyBackchannelLogoutToken: payload must include sub or sid");
1306
1435
  }
1307
- // Replay defenseoperator-supplied jti store
1308
- if (typeof vopts.seen === "function") {
1436
+ // OIDC Back-Channel Logout §2.6 iat freshness gate. Logout tokens
1437
+ // have no exp claim; freshness rests entirely on iat plus a
1438
+ // replay-cache window. A captured old logout-token with a fresh jti
1439
+ // (never seen by THIS RP's replay store, e.g. cleared across a
1440
+ // restart) would otherwise pass. Refuse iat older than
1441
+ // opts.maxAgeSec (default 5 minutes) — matches the standard 5-min
1442
+ // jti-replay-cache window operators ship.
1443
+ var logoutMaxAgeSec = typeof vopts.maxAgeSec === "number"
1444
+ ? vopts.maxAgeSec
1445
+ : DEFAULT_LOGOUT_TOKEN_MAX_AGE_SEC;
1446
+ var nowSecLogout = Math.floor(Date.now() / C.TIME.seconds(1));
1447
+ if (typeof claims.iat !== "number") {
1448
+ throw new OAuthError("auth-oauth/logout-token-no-iat",
1449
+ "verifyBackchannelLogoutToken: payload.iat required (OIDC BCL §2.4)");
1450
+ }
1451
+ if (claims.iat + logoutMaxAgeSec < nowSecLogout) {
1452
+ throw new OAuthError("auth-oauth/logout-token-too-old",
1453
+ "verifyBackchannelLogoutToken: payload.iat=" + claims.iat +
1454
+ " is older than maxAgeSec=" + logoutMaxAgeSec +
1455
+ " (OIDC BCL §2.6 — old logout-token refused)");
1456
+ }
1457
+ // Replay defense — atomic checkAndInsert when the operator supplies
1458
+ // a b.nonceStore-shaped backend, fallback to the legacy
1459
+ // seen()-callback when supplied. The atomic shape closes the
1460
+ // race-class first surfaced for refresh-token rotation in v0.9.3:
1461
+ // two simultaneous deliveries of the same logout_token both pass
1462
+ // the seen() check and both run the operator's session-destroy
1463
+ // handler. atomicReplayStore.checkAndInsert(jti, expireAtMs)
1464
+ // returns true if it WAS the first insert, false on duplicate.
1465
+ if (vopts.atomicReplayStore && typeof vopts.atomicReplayStore.checkAndInsert === "function") {
1466
+ if (typeof claims.jti !== "string" || claims.jti.length === 0) {
1467
+ throw new OAuthError("auth-oauth/no-jti",
1468
+ "verifyBackchannelLogoutToken: jti required when atomicReplayStore is configured");
1469
+ }
1470
+ var expireAtMs = (nowSecLogout + logoutMaxAgeSec * 2) * C.TIME.seconds(1);
1471
+ var inserted;
1472
+ try { inserted = await vopts.atomicReplayStore.checkAndInsert(claims.jti, expireAtMs); }
1473
+ catch (e) {
1474
+ throw new OAuthError("auth-oauth/replay-store-failed",
1475
+ "verifyBackchannelLogoutToken: atomicReplayStore.checkAndInsert threw: " +
1476
+ ((e && e.message) || String(e)));
1477
+ }
1478
+ if (inserted === false) {
1479
+ throw new OAuthError("auth-oauth/logout-token-replay",
1480
+ "verifyBackchannelLogoutToken: jti '" + claims.jti +
1481
+ "' already seen — replay refused (atomic)");
1482
+ }
1483
+ } else if (typeof vopts.seen === "function") {
1309
1484
  if (typeof claims.jti !== "string" || claims.jti.length === 0) {
1310
1485
  throw new OAuthError("auth-oauth/no-jti",
1311
1486
  "verifyBackchannelLogoutToken: jti required when a seen() callback is configured");
@@ -1451,6 +1626,17 @@ function create(opts) {
1451
1626
  "(RFC 7591 §2 makes it optional, but registering without explicit URIs " +
1452
1627
  "creates an open-redirect surface)");
1453
1628
  }
1629
+ // RFC 7591 §2 / RFC 9700 §4.1.1 — every redirect_uri MUST be a
1630
+ // valid https:// URL (or http://localhost for dev). Pre-v0.9.x the
1631
+ // gate only enforced presence; an operator copying a config with
1632
+ // `http://app.example` or `javascript:` would ship that string to
1633
+ // the AS, which then permanently associates the open-redirect
1634
+ // surface with the registered client_id. Validate at registration
1635
+ // time so the bad URL never reaches the AS.
1636
+ for (var ri = 0; ri < metadata.redirect_uris.length; ri++) {
1637
+ _validateUrl(metadata.redirect_uris[ri], allowHttp,
1638
+ "metadata.redirect_uris[" + ri + "]");
1639
+ }
1454
1640
  var endpoint;
1455
1641
  try { endpoint = await _resolveEndpoint("registrationEndpoint"); }
1456
1642
  catch (_e) {
@@ -1566,8 +1752,24 @@ function create(opts) {
1566
1752
  throw new OAuthError("auth-oauth/bad-device-code",
1567
1753
  "pollDeviceCode: deviceCode must be a non-empty string");
1568
1754
  }
1755
+ // RFC 8628 §3.4 — device_code is server-generated and opaque to the
1756
+ // client, but the polling loop POSTs it on every iteration. Without
1757
+ // a length cap an attacker who controls the device_code source
1758
+ // (e.g. a hostile AS in a CIBA-style misconfig) can amplify the
1759
+ // outbound HTTP body across N polls. The 8 KiB cap matches RFC 8628
1760
+ // §6.1's "alphanumeric with sufficient entropy" — even base64url
1761
+ // 512-bit codes fit comfortably.
1762
+ if (deviceCode.length > MAX_DEVICE_CODE_BYTES) {
1763
+ throw new OAuthError("auth-oauth/device-code-too-large",
1764
+ "pollDeviceCode: deviceCode exceeds " + MAX_DEVICE_CODE_BYTES + " bytes " +
1765
+ "(RFC 8628 §3.4 — opaque server-generated code, no legitimate need for length above the cap)");
1766
+ }
1569
1767
  var endpoint = await _resolveEndpoint("tokenEndpoint");
1570
- var interval = Math.max(1, popts.interval || 5);
1768
+ // RFC 8628 §3.4 — "If no value is provided, clients MUST use 5 as
1769
+ // the default" and §3.5 directs clients to use slow_down responses
1770
+ // to extend the interval. A 1s floor violates the spec's "5
1771
+ // RECOMMENDED" and amplifies AS load. Enforce 5s minimum.
1772
+ var interval = Math.max(MIN_DEVICE_POLL_INTERVAL_SEC, popts.interval || MIN_DEVICE_POLL_INTERVAL_SEC);
1571
1773
  var deadline = Date.now() + (popts.maxWaitMs || C.TIME.minutes(10));
1572
1774
  while (Date.now() < deadline) {
1573
1775
  var body = new URLSearchParams();
@@ -1656,6 +1858,26 @@ function create(opts) {
1656
1858
  throw new OAuthError("auth-oauth/bad-exchange",
1657
1859
  "exchangeToken: opts.subjectTokenType required (RFC 8693 §3 URN)");
1658
1860
  }
1861
+ // RFC 8693 §3 — the token-type URN identifies the requested format
1862
+ // (access_token / refresh_token / id_token / saml2 / saml1 / jwt).
1863
+ // Pre-v0.9.x accepted any string, which let an attacker-controlled
1864
+ // service or operator-mistyped value reach the AS verbatim. Refuse
1865
+ // anything outside the RFC 8693 §3 list unless the operator
1866
+ // explicitly opts in via { allowCustomTokenType: true } with a
1867
+ // documented downstream contract.
1868
+ if (RFC_8693_TOKEN_TYPES.indexOf(xopts.subjectTokenType) === -1 &&
1869
+ xopts.allowCustomTokenType !== true) {
1870
+ throw new OAuthError("auth-oauth/bad-subject-token-type",
1871
+ "exchangeToken: subjectTokenType '" + xopts.subjectTokenType + "' not in RFC 8693 §3 " +
1872
+ "(allowed: " + RFC_8693_TOKEN_TYPES.join(", ") + "); pass `allowCustomTokenType: true` " +
1873
+ "to accept operator-defined URNs");
1874
+ }
1875
+ if (xopts.actorTokenType &&
1876
+ RFC_8693_TOKEN_TYPES.indexOf(xopts.actorTokenType) === -1 &&
1877
+ xopts.allowCustomTokenType !== true) {
1878
+ throw new OAuthError("auth-oauth/bad-actor-token-type",
1879
+ "exchangeToken: actorTokenType '" + xopts.actorTokenType + "' not in RFC 8693 §3");
1880
+ }
1659
1881
  var endpoint = await _resolveEndpoint("tokenEndpoint");
1660
1882
  var body = new URLSearchParams();
1661
1883
  body.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
@@ -55,6 +55,10 @@ var validateOpts = require("../validate-opts");
55
55
  var safeJson = require("../safe-json");
56
56
  var nodeCrypto = require("node:crypto");
57
57
  var { generateToken, sha3Hash, timingSafeEqual } = require("../crypto");
58
+ // Shared JOSE defenses (CVE-2026-22817 alg/kty cross-check). Top-of-
59
+ // file per project convention §3; no circular load — jwt-external
60
+ // requires nothing from oid4vci.
61
+ var jwtExternal = require("./jwt-external");
58
62
  var { AuthError } = require("../framework-error");
59
63
 
60
64
  var cache = lazyRequire(function () { return require("../cache"); });
@@ -75,7 +79,7 @@ function _b64uDecodeStr(s) {
75
79
  return Buffer.from(s, "base64url").toString("utf8");
76
80
  }
77
81
 
78
- function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId, supportedAlgs) {
82
+ function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId, supportedAlgs, proofMaxAgeMs) {
79
83
  // OID4VCI §7.2.1.1: the proof JWT MUST:
80
84
  // - typ = "openid4vci-proof+jwt"
81
85
  // - alg in supported list (issuer publishes these)
@@ -104,9 +108,23 @@ function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId
104
108
  throw new AuthError("auth-oid4vci/wrong-proof-typ",
105
109
  "credential issuance: proof JWT typ must be \"openid4vci-proof+jwt\" (got \"" + header.typ + "\")");
106
110
  }
111
+ // CVE-2026-23993 — refuse unknown / unsupported alg BEFORE any
112
+ // verify-side work. The supportedAlgs list is the issuer's posture;
113
+ // refusing here mirrors the discipline in oauth.verifyIdToken /
114
+ // jwt-external.verifyExternal.
107
115
  if (!header.alg || supportedAlgs.indexOf(header.alg) === -1) {
108
116
  throw new AuthError("auth-oid4vci/unsupported-proof-alg",
109
- "credential issuance: proof JWT alg \"" + header.alg + "\" not in issuer-supported set");
117
+ "credential issuance: proof JWT alg \"" + header.alg + "\" not in issuer-supported set " +
118
+ "(CVE-2026-23993 — refused before key lookup)");
119
+ }
120
+ // AUTH-5 / RFC 7515 §4.1.11 — refuse non-empty `crit`. Pre-v0.9.x
121
+ // silently ignored, letting an attacker-controlled wallet declare
122
+ // critical extensions the verifier doesn't understand.
123
+ if (header.crit !== undefined && header.crit !== null) {
124
+ if (!Array.isArray(header.crit) || header.crit.length > 0) {
125
+ throw new AuthError("auth-oid4vci/unknown-crit",
126
+ "credential issuance: proof JWT carries non-empty 'crit' header — refused per RFC 7515 §4.1.11");
127
+ }
110
128
  }
111
129
  if (!header.jwk && !header.kid && !header.x5c) {
112
130
  throw new AuthError("auth-oid4vci/no-key-in-proof",
@@ -129,14 +147,24 @@ function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId
129
147
  throw new AuthError("auth-oid4vci/no-proof-iat",
130
148
  "credential issuance: proof JWT must include iat");
131
149
  }
132
- var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s
133
- if (payload.iat > nowSec + 60) { // allow:raw-time-literal — 60s skew tolerance
150
+ var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
151
+ // AUTH-34 use C.TIME for the 60s skew tolerance rather than a bare
152
+ // 60 literal; matches the framework's constants discipline.
153
+ var iatSkewSec = C.TIME.seconds(60) / C.TIME.seconds(1);
154
+ if (payload.iat > nowSec + iatSkewSec) {
134
155
  throw new AuthError("auth-oid4vci/proof-iat-future",
135
156
  "credential issuance: proof JWT iat is in the future");
136
157
  }
137
- if (payload.iat < nowSec - Math.floor(C.TIME.minutes(10) / 1000)) { // allow:raw-byte-literal — ms→s
158
+ // AUTH-26 operator-tunable proof max-age. Default 10 minutes per
159
+ // OID4VCI §7.2.1.1; operators with longer-lived wallet flows raise.
160
+ var effectiveMaxAgeMs = (typeof proofMaxAgeMs === "number" && isFinite(proofMaxAgeMs) && proofMaxAgeMs > 0)
161
+ ? proofMaxAgeMs
162
+ : C.TIME.minutes(10);
163
+ if (payload.iat < nowSec - Math.floor(effectiveMaxAgeMs / C.TIME.seconds(1))) {
138
164
  throw new AuthError("auth-oid4vci/proof-iat-too-old",
139
- "credential issuance: proof JWT iat older than 10 minutes — wallet must mint a fresh proof");
165
+ "credential issuance: proof JWT iat older than " +
166
+ Math.floor(effectiveMaxAgeMs / C.TIME.seconds(1)) +
167
+ " seconds — wallet must mint a fresh proof");
140
168
  }
141
169
  if (expectedClientId && payload.iss && payload.iss !== expectedClientId) {
142
170
  throw new AuthError("auth-oid4vci/wrong-proof-iss",
@@ -155,6 +183,12 @@ function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId
155
183
  throw new AuthError("auth-oid4vci/no-jwk-in-header",
156
184
  "credential issuance: proof JWT must carry `jwk` for inline holder-key binding");
157
185
  }
186
+ // CVE-2026-22817 — cross-check alg/kty before importing the holder
187
+ // JWK. Without this an attacker-controlled `alg: "HS256"` against an
188
+ // RSA holder JWK would have node:crypto.verify treat the RSA public
189
+ // key as an HMAC secret. Routed through the shared helper so every
190
+ // JWT verifier in the framework enforces the same check.
191
+ jwtExternal._assertAlgKtyMatch(header.alg, holderKeyJwk);
158
192
  var keyObj;
159
193
  try { keyObj = nodeCrypto.createPublicKey({ key: holderKeyJwk, format: "jwk" }); }
160
194
  catch (e) {
@@ -270,6 +304,18 @@ function create(opts) {
270
304
  var preAuthTtl = opts.preAuthCodeTtlMs || DEFAULT_PRE_AUTH_TTL_MS;
271
305
  var accessTokenTtl = opts.accessTokenTtlMs || DEFAULT_ACCESS_TOKEN_TTL;
272
306
  var cNonceTtl = opts.cNonceTtlMs || DEFAULT_C_NONCE_TTL_MS;
307
+ // AUTH-26 — operator-tunable proof iat-too-old window. Default 10
308
+ // minutes per OID4VCI §7.2.1.1.
309
+ var proofMaxAgeMs = (typeof opts.proofMaxAgeMs === "number" && isFinite(opts.proofMaxAgeMs) && opts.proofMaxAgeMs > 0)
310
+ ? opts.proofMaxAgeMs
311
+ : C.TIME.minutes(10);
312
+ // AUTH-6 — access-token single-use. OID4VCI §7's credential endpoint
313
+ // does NOT inherently make the access token single-use; pre-v0.9.x
314
+ // c_nonce rotation alone defended against proof replay, but a stolen
315
+ // access token combined with a fresh proof could re-mint
316
+ // credentials. Default true; operators with batch_credential flows
317
+ // that need access-token reuse opt out with an audited reason.
318
+ var accessTokenSingleUse = opts.accessTokenSingleUse !== false;
273
319
 
274
320
  var codeStore = opts.codeStore || cache().create({
275
321
  namespace: "auth.oid4vci.preauth", ttlMs: preAuthTtl,
@@ -508,7 +554,7 @@ function create(opts) {
508
554
  }
509
555
 
510
556
  var expectedCNonce = await cNonceStore.get(iopts.accessToken);
511
- var verified = _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs);
557
+ var verified = _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs, proofMaxAgeMs);
512
558
 
513
559
  if (!iopts.claims || typeof iopts.claims !== "object") {
514
560
  throw new AuthError("auth-oid4vci/no-claims",
@@ -528,6 +574,20 @@ function create(opts) {
528
574
  var newCNonce = generateToken(16); // allow:raw-byte-literal — 128-bit c_nonce
529
575
  await cNonceStore.set(iopts.accessToken, newCNonce);
530
576
 
577
+ // AUTH-6 — when single-use is on (default), DELETE the access token
578
+ // after successful credential mint. A stolen access token paired
579
+ // with a fresh proof would otherwise re-mint credentials; the
580
+ // c_nonce rotation alone defends against proof replay but not
581
+ // against an attacker who exfiltrated the access token. The
582
+ // accompanying c_nonce entry expires with its TTL; deleting it
583
+ // explicitly tightens cleanup.
584
+ if (accessTokenSingleUse) {
585
+ try {
586
+ await atStore.delete(iopts.accessToken);
587
+ await cNonceStore.delete(iopts.accessToken);
588
+ } catch (_e) { /* drop-silent — cleanup is best-effort */ }
589
+ }
590
+
531
591
  _emitAudit("credential_issued", "success", {
532
592
  subject: record.subject,
533
593
  credentialIdentifier: iopts.credentialIdentifier,