@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.
- package/CHANGELOG.md +952 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +78 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/cli.js +13 -0
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-graphql.js +37 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-regex.js +138 -1
- package/lib/guard-smtp-command.js +58 -3
- package/lib/guard-xml.js +39 -1
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/otel-export.js +13 -4
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/auth/jwt-external.js
CHANGED
|
@@ -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
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
1068
|
-
|
|
1069
|
-
|
|
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++)
|
|
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
|
-
|
|
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
|
-
//
|
|
1308
|
-
|
|
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
|
-
|
|
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");
|
package/lib/auth/oid4vci.js
CHANGED
|
@@ -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() /
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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,
|