@blamejs/core 0.8.90 → 0.9.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.
package/index.js CHANGED
@@ -145,6 +145,9 @@ var compliance = Object.assign({}, require("./lib/compliance"), {
145
145
  var dataAct = require("./lib/data-act");
146
146
  var problemDetails = require("./lib/problem-details");
147
147
  var cacheStatus = require("./lib/cache-status");
148
+ var cdnCacheControl = require("./lib/cdn-cache-control");
149
+ var clientHints = require("./lib/client-hints");
150
+ var structuredFields = require("./lib/structured-fields");
148
151
  var serverTiming = require("./lib/server-timing");
149
152
  var earlyHints = require("./lib/early-hints");
150
153
  var gateContract = require("./lib/gate-contract");
@@ -377,6 +380,9 @@ module.exports = {
377
380
  dataAct: dataAct,
378
381
  problemDetails: problemDetails,
379
382
  cacheStatus: cacheStatus,
383
+ cdnCacheControl: cdnCacheControl,
384
+ clientHints: clientHints,
385
+ structuredFields: structuredFields,
380
386
  serverTiming: serverTiming,
381
387
  earlyHints: earlyHints,
382
388
  gateContract: gateContract,
package/lib/ai-pref.js CHANGED
@@ -26,8 +26,9 @@
26
26
  * AIPREF (RFC draft) signal — operators publish a machine-readable preference about AI training / agent crawling / etc.
27
27
  */
28
28
 
29
- var audit = require("./audit");
30
- var requestHelpers = require("./request-helpers");
29
+ var audit = require("./audit");
30
+ var requestHelpers = require("./request-helpers");
31
+ var structuredFields = require("./structured-fields");
31
32
  var { defineClass } = require("./framework-error");
32
33
  var AiPrefError = defineClass("AiPrefError", { alwaysPermanent: true });
33
34
 
@@ -145,6 +146,11 @@ function parseHeader(value) {
145
146
  throw AiPrefError.factory("HEADER_TOO_LARGE",
146
147
  "aiPref.parseHeader: value exceeds 1024 chars");
147
148
  }
149
+ structuredFields.refuseControlBytes(value, {
150
+ ErrorClass: AiPrefError,
151
+ code: "BAD_HEADER",
152
+ label: "aiPref.parseHeader",
153
+ });
148
154
  var out = { train: null, infer: null, snippet: null, price: null };
149
155
  var pairs = value.split(",");
150
156
  for (var i = 0; i < pairs.length; i += 1) {
package/lib/auth/oauth.js CHANGED
@@ -108,7 +108,7 @@ var nodeCrypto = require("node:crypto");
108
108
  var cache = require("../cache");
109
109
  var C = require("../constants");
110
110
  var safeAsync = require("../safe-async");
111
- var { generateBytes } = require("../crypto");
111
+ var { generateBytes, timingSafeEqual: cryptoTimingSafeEqual } = require("../crypto");
112
112
  var httpClient = require("../http-client");
113
113
  var safeJson = require("../safe-json");
114
114
  var safeUrl = require("../safe-url");
@@ -678,10 +678,15 @@ function create(opts) {
678
678
  "but the callback omitted `iss` — refused (RFC 9207 / FAPI 2.0 §5.4.2)");
679
679
  }
680
680
  if (popts.expectedState !== undefined && popts.expectedState !== null) {
681
- if (query.state !== popts.expectedState) {
681
+ // Constant-time compare on the CSRF state token. Project
682
+ // discipline (auth/dpop.js, mail-srs.js, webhook.js) is
683
+ // timingSafeEqual for any secret-shaped value compared
684
+ // against attacker-controlled input. (Audit 2026-05-11.)
685
+ if (typeof query.state !== "string" ||
686
+ !cryptoTimingSafeEqual(query.state, popts.expectedState)) {
682
687
  throw new OAuthError("auth-oauth/state-mismatch",
683
- "parseCallback: state mismatch (CSRF defense). Expected '" +
684
- popts.expectedState + "', got '" + query.state + "'");
688
+ "parseCallback: state mismatch (CSRF defense) expected and " +
689
+ "supplied state values do not match");
685
690
  }
686
691
  }
687
692
  if (typeof query.code !== "string" || query.code.length === 0) {
@@ -922,6 +927,16 @@ function create(opts) {
922
927
  throw new OAuthError("auth-oauth/alg-not-accepted",
923
928
  "ID token signed with '" + header.alg + "' which is not in the accepted-algorithm list");
924
929
  }
930
+ // RFC 7515 §4.1.11 — refuse JWS with `crit` header. Every other
931
+ // verifier in the framework (jwt.js, jwt-external.js, dpop.js)
932
+ // refuses; verifyIdToken previously silently ignored, letting an
933
+ // attacker-controlled OP ship critical extensions the verifier
934
+ // doesn't understand. (Audit 2026-05-11.)
935
+ if (header.crit !== undefined && header.crit !== null) {
936
+ throw new OAuthError("auth-oauth/crit-not-supported",
937
+ "ID token JWS header carries 'crit' extension list; this verifier does not " +
938
+ "support any critical extensions and refuses per RFC 7515 §4.1.11");
939
+ }
925
940
  var keys = await _getJwks();
926
941
  var match = null;
927
942
  if (header.kid) {
@@ -943,7 +958,19 @@ function create(opts) {
943
958
  if (params.padding !== undefined) verifyOpts.padding = params.padding;
944
959
  if (params.saltLength !== undefined) verifyOpts.saltLength = params.saltLength;
945
960
  if (params.dsaEncoding !== undefined) verifyOpts.dsaEncoding = params.dsaEncoding;
946
- var verified = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
961
+ // nodeCrypto.verify panics on key/sig shape mismatch (e.g. an
962
+ // ES256 signature attempted against an RS256 key returned by a
963
+ // hostile or buggy IdP with duplicate kids). Wrap so the panic
964
+ // becomes a typed AuthError, matching the discipline in
965
+ // jwt-external.js + dpop.js. (Audit 2026-05-11.)
966
+ var verified;
967
+ try {
968
+ verified = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
969
+ } catch (verifyErr) {
970
+ throw new OAuthError("auth-oauth/bad-signature",
971
+ "ID token signature verification raised: " +
972
+ ((verifyErr && verifyErr.message) || String(verifyErr)));
973
+ }
947
974
  if (!verified) {
948
975
  throw new OAuthError("auth-oauth/bad-signature", "ID token signature verification failed");
949
976
  }
@@ -976,7 +1003,10 @@ function create(opts) {
976
1003
  "ID token aud does not contain clientId '" + clientId + "'");
977
1004
  }
978
1005
  if (vopts.nonce && !vopts.skipNonceCheck) {
979
- if (payload.nonce !== vopts.nonce) {
1006
+ // Constant-time nonce compare secret-shaped value matched
1007
+ // against attacker-controlled payload. (Audit 2026-05-11.)
1008
+ if (typeof payload.nonce !== "string" ||
1009
+ !cryptoTimingSafeEqual(payload.nonce, vopts.nonce)) {
980
1010
  throw new OAuthError("auth-oauth/nonce-mismatch",
981
1011
  "ID token nonce mismatch (replay protection)");
982
1012
  }
@@ -155,6 +155,23 @@ function verifyEntityStatement(jwt, jwks) {
155
155
  "verifyEntityStatement: no JWKS key matches kid \"" + parsed.header.kid + "\"");
156
156
  }
157
157
 
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;
161
+ // 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
+ }
174
+
158
175
  var keyObj;
159
176
  try { keyObj = nodeCrypto.createPublicKey({ key: key, format: "jwk" }); }
160
177
  catch (e) {
@@ -431,38 +448,52 @@ async function buildTrustChain(opts) {
431
448
  // OR the first that returns a valid subordinate statement. Real
432
449
  // operators with multiple federations usually have one anchor
433
450
  // active; we walk in order and pick the first success.
451
+ // Track every per-authority failure reason and surface them on
452
+ // `no-ascent` rather than masking. Audit 2026-05-11 — silently
453
+ // swallowing `catch (_e) {}` lets a hostile intermediate that
454
+ // serves a malformed-then-valid pair shape-walk the verifier.
455
+ // We continue past 404 / fetch errors but refuse on
456
+ // signature-verify failure (cryptographic refusal is a hard stop).
434
457
  var ascended = false;
458
+ var ascentErrors = [];
435
459
  for (var ai = 0; ai < parsedEC.claims.authority_hints.length; ai++) {
436
460
  var authority = parsedEC.claims.authority_hints[ai];
437
461
  try {
438
462
  var subordinateJwt = await fetchSubordinate(authority, current);
439
463
  var parsedSub = parseEntityStatement(subordinateJwt);
440
464
  if (parsedSub.claims.iss !== authority || parsedSub.claims.sub !== current) {
465
+ ascentErrors.push({ authority: authority, code: "iss-sub-mismatch" });
441
466
  continue;
442
467
  }
443
- // Need to fetch the authority's JWKS to verify the subordinate
444
- // statement — the authority's entity-config carries it. We
445
- // verify that on the next loop iteration; for now, refuse if
446
- // the subordinate's signature doesn't verify with the keys
447
- // declared in the authority's most recently fetched config.
448
468
  var authorityCfgJwt = await fetcher(authority.replace(/\/$/, "") + "/.well-known/openid-federation");
449
469
  var authorityCfgClaims = parseEntityStatement(authorityCfgJwt).claims;
470
+ // Cryptographic verification — any throw here is a hard
471
+ // refusal, NOT a "try next authority" signal. A malformed-
472
+ // signature subordinate from an authority listed by the
473
+ // entity means that authority is hostile or compromised;
474
+ // moving on lets a chain-shaping attacker bypass the gate.
450
475
  verifyEntityStatement(subordinateJwt, authorityCfgClaims.jwks || {});
451
- // Replace the entity's claimed JWKS with the JWKS the
452
- // authority signs about it — this is the trust-bearing one.
453
476
  chain[chain.length - 1].claims.jwks = parsedSub.claims.jwks || chain[chain.length - 1].claims.jwks;
454
477
  chain[chain.length - 1].subordinateJwt = subordinateJwt;
455
478
  chain[chain.length - 1].subordinate = parsedSub.claims;
456
479
  current = authority;
457
480
  ascended = true;
458
481
  break;
459
- } catch (_e) {
460
- // Try the next authority_hint.
482
+ } catch (err) {
483
+ var errCode = (err && err.code) || "unknown";
484
+ // Network / 404 / parse errors at the AUTHORITY-fetch step
485
+ // are acceptable "try the next hint" signals. Verify-side
486
+ // failures (crypto) are NOT — surface them and abort.
487
+ if (/^auth-openid-federation\/(?:bad-jwk|alg-kty-mismatch|bad-signature|signature-failed)$/.test(errCode)) {
488
+ throw err;
489
+ }
490
+ ascentErrors.push({ authority: authority, code: errCode, message: (err && err.message) || String(err) });
461
491
  }
462
492
  }
463
493
  if (!ascended) {
464
494
  throw new AuthError("auth-openid-federation/no-ascent",
465
- "entity \"" + current + "\" has authority_hints but none yielded a verifiable subordinate statement");
495
+ "entity \"" + current + "\" has authority_hints but none yielded a verifiable subordinate statement: " +
496
+ JSON.stringify(ascentErrors));
466
497
  }
467
498
  depth += 1;
468
499
  }
package/lib/auth/saml.js CHANGED
@@ -49,7 +49,7 @@
49
49
  var lazyRequire = require("../lazy-require");
50
50
  var validateOpts = require("../validate-opts");
51
51
  var nodeCrypto = require("node:crypto");
52
- var { generateToken } = require("../crypto");
52
+ var { generateToken, timingSafeEqual } = require("../crypto");
53
53
  var { AuthError } = require("../framework-error");
54
54
 
55
55
  var xmlC14n = lazyRequire(function () { return require("../xml-c14n"); });
@@ -261,7 +261,11 @@ function _verifyXmldsig(envelope, signatureNode, certPem) {
261
261
  }
262
262
  var canonical = c14n.canonicalize(refTarget, { withComments: refC14nWithComments });
263
263
  var actualDigest = nodeCrypto.createHash(SUPPORTED_DIGEST[digestAlgo]).update(canonical).digest();
264
- if (Buffer.from(expectedDigestB64, "base64").compare(actualDigest) !== 0) {
264
+ // Constant-time compare — Buffer.compare short-circuits per byte and
265
+ // leaks the matching-prefix length when the operator's audit/log
266
+ // captures verify-failure timing. timingSafeEqual returns false for
267
+ // length-mismatched inputs without leaking length.
268
+ if (!timingSafeEqual(Buffer.from(expectedDigestB64, "base64"), actualDigest)) {
265
269
  throw new AuthError("auth-saml/digest-mismatch",
266
270
  "Reference DigestValue does not match canonicalized referenced element (signature-wrapping or tampered content)");
267
271
  }
@@ -360,9 +364,18 @@ function create(opts) {
360
364
  bopts = bopts || {};
361
365
  var id = "_" + generateToken(20);
362
366
  var issueInstant = new Date().toISOString();
367
+ // RFC 3741 §1.3.2 attribute-value + §1.3.1 element-text escaping
368
+ // for every operator-supplied string interpolated into the
369
+ // AuthnRequest XML. Without escaping, a `"` or `<` in any of the
370
+ // four fields (idpSsoUrl, assertionConsumerServiceUrl, entityId,
371
+ // nameIdFormat) produces malformed XML and can break out of the
372
+ // attribute / element context, injecting unsigned content the IdP
373
+ // canonicalizer would never honor but the consumer's signed XML
374
+ // baseline relies on. (Surfaced by the 2026-05-11 SAML audit.)
375
+ var c14n = xmlC14n();
363
376
  var nameIdPolicy = "";
364
377
  if (opts.nameIdFormat) {
365
- nameIdPolicy = "<samlp:NameIDPolicy Format=\"" + opts.nameIdFormat +
378
+ nameIdPolicy = "<samlp:NameIDPolicy Format=\"" + c14n.escapeAttrValue(opts.nameIdFormat) +
366
379
  "\" AllowCreate=\"true\"/>";
367
380
  }
368
381
  var xml =
@@ -371,10 +384,10 @@ function create(opts) {
371
384
  "ID=\"" + id + "\" " +
372
385
  "Version=\"2.0\" " +
373
386
  "IssueInstant=\"" + issueInstant + "\" " +
374
- "Destination=\"" + opts.idpSsoUrl + "\" " +
375
- "AssertionConsumerServiceURL=\"" + opts.assertionConsumerServiceUrl + "\" " +
387
+ "Destination=\"" + c14n.escapeAttrValue(opts.idpSsoUrl) + "\" " +
388
+ "AssertionConsumerServiceURL=\"" + c14n.escapeAttrValue(opts.assertionConsumerServiceUrl) + "\" " +
376
389
  "ProtocolBinding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\">" +
377
- "<saml:Issuer>" + opts.entityId + "</saml:Issuer>" +
390
+ "<saml:Issuer>" + c14n.escapeText(opts.entityId) + "</saml:Issuer>" +
378
391
  nameIdPolicy +
379
392
  "</samlp:AuthnRequest>";
380
393
  var zlib = require("node:zlib");
@@ -436,9 +449,26 @@ function create(opts) {
436
449
  "verifyResponse: root element must be Response, got " + rootLocal);
437
450
  }
438
451
 
439
- // Validate Status
440
- var status = _findChild(root, "Status", SAML_NS.protocol);
441
- var statusCode = status && _findChild(status, "StatusCode", SAML_NS.protocol);
452
+ // XSW defense — refuse duplicate top-level security-critical
453
+ // elements. SAML XML signature wrapping (XSW) attacks shuffle
454
+ // signed elements alongside unsigned siblings; the parser's
455
+ // first-match `_findChild` lookup combined with the signed-
456
+ // element-ID check at L479 was vulnerable to a multi-Assertion
457
+ // payload where the verifier signed one but the consumer read
458
+ // attributes from another. Reject any Response with more than
459
+ // one of these structural children (Audit 2026-05-11).
460
+ var statusChildren = _findAllChildren(root, "Status", SAML_NS.protocol);
461
+ if (statusChildren.length > 1) {
462
+ throw new AuthError("auth-saml/duplicate-status",
463
+ "verifyResponse: Response has multiple <Status> children — XSW shape refused");
464
+ }
465
+ var status = statusChildren[0] || null;
466
+ var statusCodeChildren = status ? _findAllChildren(status, "StatusCode", SAML_NS.protocol) : [];
467
+ if (statusCodeChildren.length > 1) {
468
+ throw new AuthError("auth-saml/duplicate-status-code",
469
+ "verifyResponse: <Status> has multiple <StatusCode> children — XSW shape refused");
470
+ }
471
+ var statusCode = statusCodeChildren[0] || null;
442
472
  var statusValue = statusCode && _attr(statusCode, "Value");
443
473
  if (statusValue !== "urn:oasis:names:tc:SAML:2.0:status:Success") {
444
474
  throw new AuthError("auth-saml/bad-status",
@@ -448,7 +478,12 @@ function create(opts) {
448
478
  // Validate signature: prefer Assertion-level (most secure — the
449
479
  // assertion is the security-critical element). Fall back to
450
480
  // Response-level when the IdP signs the envelope only.
451
- var assertion = _findChild(root, "Assertion", SAML_NS.assertion);
481
+ var assertionChildren = _findAllChildren(root, "Assertion", SAML_NS.assertion);
482
+ if (assertionChildren.length > 1) {
483
+ throw new AuthError("auth-saml/duplicate-assertion",
484
+ "verifyResponse: Response has multiple <Assertion> children — XSW shape refused");
485
+ }
486
+ var assertion = assertionChildren[0] || null;
452
487
  if (!assertion) {
453
488
  throw new AuthError("auth-saml/no-assertion", "verifyResponse: Response has no Assertion");
454
489
  }
@@ -484,10 +519,20 @@ function create(opts) {
484
519
  opts.idpEntityId + "\"");
485
520
  }
486
521
 
487
- // Subject + SubjectConfirmation
488
- var subject = _findChild(assertion, "Subject", SAML_NS.assertion);
522
+ // Subject + SubjectConfirmation — XSW: refuse duplicate <Subject>.
523
+ var subjectChildren = _findAllChildren(assertion, "Subject", SAML_NS.assertion);
524
+ if (subjectChildren.length > 1) {
525
+ throw new AuthError("auth-saml/duplicate-subject",
526
+ "verifyResponse: Assertion has multiple <Subject> children — XSW shape refused");
527
+ }
528
+ var subject = subjectChildren[0] || null;
489
529
  if (!subject) throw new AuthError("auth-saml/no-subject", "verifyResponse: missing Subject");
490
- var nameIdEl = _findChild(subject, "NameID", SAML_NS.assertion);
530
+ var nameIdChildren = _findAllChildren(subject, "NameID", SAML_NS.assertion);
531
+ if (nameIdChildren.length > 1) {
532
+ throw new AuthError("auth-saml/duplicate-nameid",
533
+ "verifyResponse: <Subject> has multiple <NameID> children — XSW shape refused");
534
+ }
535
+ var nameIdEl = nameIdChildren[0] || null;
491
536
  if (!nameIdEl) throw new AuthError("auth-saml/no-nameid", "verifyResponse: missing NameID");
492
537
  var nameId = _textContent(nameIdEl);
493
538
  var nameIdFormat = _attr(nameIdEl, "Format");
@@ -517,10 +562,17 @@ function create(opts) {
517
562
  continue;
518
563
  }
519
564
  var inResponseTo = _attr(scd, "InResponseTo");
520
- if (vopts.expectedInResponseTo && inResponseTo !== vopts.expectedInResponseTo) {
521
- throw new AuthError("auth-saml/bad-in-response-to",
522
- "SubjectConfirmation InResponseTo \"" + inResponseTo +
523
- "\" does not match expected \"" + vopts.expectedInResponseTo + "\" (replay defense)");
565
+ if (vopts.expectedInResponseTo) {
566
+ // Constant-time compare against the AuthnRequest ID the
567
+ // operator stored protects against timing-based InResponseTo
568
+ // probing. timingSafeEqual returns false for missing /
569
+ // length-mismatch without leaking. (Audit 2026-05-11.)
570
+ if (inResponseTo === null || inResponseTo === undefined ||
571
+ !timingSafeEqual(inResponseTo, vopts.expectedInResponseTo)) {
572
+ throw new AuthError("auth-saml/bad-in-response-to",
573
+ "SubjectConfirmation InResponseTo does not match expected " +
574
+ "AuthnRequest ID (replay defense)");
575
+ }
524
576
  }
525
577
  bearerOk = true;
526
578
  break;
@@ -608,13 +660,16 @@ function create(opts) {
608
660
  * });
609
661
  */
610
662
  function metadata() {
663
+ // RFC 3741 attr/text escaping for operator-supplied URLs / IDs —
664
+ // same audit-finding shape as buildAuthnRequest above.
665
+ var c14n = xmlC14n();
611
666
  return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
612
- "<md:EntityDescriptor xmlns:md=\"" + SAML_NS.metadata + "\" entityID=\"" + opts.entityId + "\">" +
667
+ "<md:EntityDescriptor xmlns:md=\"" + SAML_NS.metadata + "\" entityID=\"" + c14n.escapeAttrValue(opts.entityId) + "\">" +
613
668
  "<md:SPSSODescriptor protocolSupportEnumeration=\"" + SAML_NS.protocol + "\" " +
614
669
  "AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"true\">" +
615
670
  "<md:AssertionConsumerService " +
616
671
  "Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" " +
617
- "Location=\"" + opts.assertionConsumerServiceUrl + "\" index=\"0\"/>" +
672
+ "Location=\"" + c14n.escapeAttrValue(opts.assertionConsumerServiceUrl) + "\" index=\"0\"/>" +
618
673
  "</md:SPSSODescriptor>" +
619
674
  "</md:EntityDescriptor>";
620
675
  }
@@ -61,9 +61,15 @@
61
61
  */
62
62
 
63
63
  var nodeCrypto = require("node:crypto");
64
+ var blamejsCrypto = require("../crypto");
64
65
  var safeBuffer = require("../safe-buffer");
65
66
  var safeJson = require("../safe-json");
66
67
  var validateOpts = require("../validate-opts");
68
+
69
+ function _timingSafeEqStr(a, b) {
70
+ if (typeof a !== "string" || typeof b !== "string") return false;
71
+ return blamejsCrypto.timingSafeEqual(a, b);
72
+ }
67
73
  var disclosure = require("./sd-jwt-vc-disclosure");
68
74
  var sdJwtVcIssuer = require("./sd-jwt-vc-issuer");
69
75
  var sdJwtVcHolder = require("./sd-jwt-vc-holder");
@@ -284,7 +290,29 @@ function present(opts) {
284
290
  var jwt = parts[0];
285
291
  var allDisclosures = parts.slice(1).filter(function (p) { return p.length > 0; });
286
292
 
287
- // Decode disclosures + filter by name
293
+ // Decode the issuer JWT payload to read its declared `_sd_alg` —
294
+ // KB-JWT `sd_hash` MUST be computed with the SAME hash algorithm
295
+ // the credential's `_sd` digests use (IETF SD-JWT draft §4.1.1).
296
+ // Hardcoded sha256 here previously diverged from the verifier when
297
+ // an issuer used a non-default hash, producing sd-hash-mismatch on
298
+ // valid presentations.
299
+ var _issuerPayload = null;
300
+ var _jwtParts = jwt.split(".");
301
+ if (_jwtParts.length === 3) {
302
+ try {
303
+ _issuerPayload = safeJson.parse(_b64uDecodeStr(_jwtParts[1]),
304
+ { maxBytes: 64 * 1024 }); // allow:bare-json-parse — payload only read to pull _sd_alg; final auth happens in verify() // allow:raw-byte-literal — JWT payload cap (64 KB)
305
+ } catch (_e) { _issuerPayload = null; }
306
+ }
307
+ var _sdAlg = (_issuerPayload && typeof _issuerPayload._sd_alg === "string")
308
+ ? _issuerPayload._sd_alg : "sha-256";
309
+ var _sdNodeHash = SUPPORTED_HASH_ALGS[_sdAlg];
310
+ if (!_sdNodeHash) {
311
+ throw new AuthError("auth-sd-jwt-vc/bad-hash",
312
+ "present: issuer credential declares _sd_alg \"" + _sdAlg +
313
+ "\" which this framework version does not support");
314
+ }
315
+
288
316
  var disclosedNames = Array.isArray(opts.disclosedClaimNames)
289
317
  ? opts.disclosedClaimNames.slice() : [];
290
318
  var releasedDisclosures = [];
@@ -314,7 +342,11 @@ function present(opts) {
314
342
  ? Math.floor(opts.issuedAt / 1000) : Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s conversion factor
315
343
  // The KB-JWT's hash binds it to the specific SD-JWT + presentation
316
344
  var kbHashInput = presentation; // jwt~d1~d2~ (without KB)
317
- var sdHash = nodeCrypto.createHash("sha256")
345
+ // sd_hash uses the SAME hash algorithm the credential's _sd
346
+ // digests use (computed at top of present() from issuer payload).
347
+ // Matches the verifier's expectation in lib/auth/sd-jwt-vc.js
348
+ // verify() — both ends MUST agree on the algorithm.
349
+ var sdHash = nodeCrypto.createHash(_sdNodeHash)
318
350
  .update(kbHashInput, "ascii")
319
351
  .digest()
320
352
  .toString("base64url");
@@ -429,12 +461,26 @@ async function verify(presentation, opts) {
429
461
  }
430
462
 
431
463
  // 3. Reconstruct disclosed claims from disclosures
432
- var hashAlg = jwtParsed.payload._sd_alg || DEFAULT_HASH_ALG;
464
+ // IETF SD-JWT default `_sd_alg` is `sha-256` (draft-ietf-oauth-
465
+ // selective-disclosure-jwt §4.1.1). Earlier the framework defaulted
466
+ // to its own DEFAULT_HASH_ALG (`sha3-512`) which broke verification
467
+ // against spec-conformant issuers when `_sd_alg` was omitted.
468
+ // (Audit 2026-05-11.)
469
+ var hashAlg = jwtParsed.payload._sd_alg || "sha-256";
433
470
  if (!SUPPORTED_HASH_ALGS[hashAlg]) {
434
471
  throw new AuthError("auth-sd-jwt-vc/bad-hash",
435
472
  "verify: _sd_alg \"" + hashAlg + "\" not supported");
436
473
  }
437
474
  var sdDigests = Array.isArray(jwtParsed.payload._sd) ? jwtParsed.payload._sd : [];
475
+ // Protected-claim refusal: a holder-supplied disclosure with one
476
+ // of these names would shadow the issuer-signed payload claim when
477
+ // merged into the resolved set. Spec-protected per draft §5
478
+ // (the issuer-signed claims are authoritative).
479
+ var PROTECTED_CLAIM_NAMES = {
480
+ iss: 1, sub: 1, aud: 1, iat: 1, nbf: 1, exp: 1, jti: 1,
481
+ vct: 1, cnf: 1, _sd: 1, _sd_alg: 1, status: 1,
482
+ };
483
+ var seenDigests = Object.create(null);
438
484
  var disclosedClaims = {};
439
485
  for (var i = 0; i < disclosureParts.length; i++) {
440
486
  var d = disclosure.decode(disclosureParts[i]);
@@ -444,6 +490,23 @@ async function verify(presentation, opts) {
444
490
  throw new AuthError("auth-sd-jwt-vc/disclosure-mismatch",
445
491
  "verify: disclosure for claim \"" + d.name + "\" does not match any _sd digest");
446
492
  }
493
+ // Disclosure-replay defense — a holder presenting the same _sd
494
+ // digest twice (with the same or different values) is malformed
495
+ // per spec and is the shape of a partial-disclosure smuggling
496
+ // attack. Refuse on duplicate digest. (Audit 2026-05-11.)
497
+ if (seenDigests[digest]) {
498
+ throw new AuthError("auth-sd-jwt-vc/disclosure-replay",
499
+ "verify: disclosure digest \"" + digest.slice(0, 12) +
500
+ "...\" appears twice — refusing replayed disclosure");
501
+ }
502
+ seenDigests[digest] = true;
503
+ // Claim-shadowing defense — refuse holder-supplied disclosures
504
+ // whose name collides with an issuer-signed top-level claim.
505
+ if (PROTECTED_CLAIM_NAMES[d.name]) {
506
+ throw new AuthError("auth-sd-jwt-vc/protected-claim-shadow",
507
+ "verify: disclosure for claim \"" + d.name + "\" would shadow a " +
508
+ "spec-protected issuer-signed claim — refused");
509
+ }
447
510
  disclosedClaims[d.name] = d.value;
448
511
  }
449
512
 
@@ -485,14 +548,21 @@ async function verify(presentation, opts) {
485
548
  throw new AuthError("auth-sd-jwt-vc/wrong-nonce",
486
549
  "verify: KB-JWT nonce mismatch (replay defense)");
487
550
  }
488
- // Validate KB-JWT sd_hash matches the presentation
551
+ // Validate KB-JWT sd_hash matches the presentation, using the
552
+ // credential's declared `_sd_alg` (audit 2026-05-11 — was
553
+ // hardcoded sha256 regardless of issuer's choice, breaking
554
+ // verification when issuer used sha3-512).
489
555
  var kbHashInput = jwt + "~";
490
556
  if (disclosureParts.length > 0) kbHashInput += disclosureParts.join("~") + "~";
491
- var expectedSdHash = nodeCrypto.createHash("sha256")
557
+ var kbNodeHash = SUPPORTED_HASH_ALGS[hashAlg];
558
+ var expectedSdHash = nodeCrypto.createHash(kbNodeHash)
492
559
  .update(kbHashInput, "ascii")
493
560
  .digest()
494
561
  .toString("base64url");
495
- if (kbParsed.payload.sd_hash !== expectedSdHash) {
562
+ // Constant-time compare on the sd_hash (both fixed-width
563
+ // base64url(SHA-*) strings; defense-in-depth even though the
564
+ // hash is itself the integrity binding).
565
+ if (!_timingSafeEqStr(kbParsed.payload.sd_hash, expectedSdHash)) {
496
566
  throw new AuthError("auth-sd-jwt-vc/sd-hash-mismatch",
497
567
  "verify: KB-JWT sd_hash does not match the presentation hash (presentation tampered with?)");
498
568
  }
@@ -60,11 +60,12 @@
60
60
  * - auth.stepUp.grant.revoked (elevation grant revoked)
61
61
  */
62
62
 
63
- var lazyRequire = require("../lazy-require");
64
- var validateOpts = require("../validate-opts");
65
- var safeJson = require("../safe-json");
66
- var C = require("../constants");
67
- var { AuthError } = require("../framework-error");
63
+ var lazyRequire = require("../lazy-require");
64
+ var validateOpts = require("../validate-opts");
65
+ var safeJson = require("../safe-json");
66
+ var structuredFields = require("../structured-fields");
67
+ var C = require("../constants");
68
+ var { AuthError } = require("../framework-error");
68
69
 
69
70
  var acr = require("./acr-vocabulary");
70
71
  var authTime = require("./auth-time-tracker");
@@ -362,6 +363,14 @@ function _summarizePresented(presented) {
362
363
 
363
364
  function parseChallenge(headerValue) {
364
365
  if (typeof headerValue !== "string") return null;
366
+ // Refuse C0 / DEL on the RAW value BEFORE the slice + trim
367
+ // normalisation below. WWW-Authenticate is a token-or-quoted-string
368
+ // grammar per RFC 9110 §11.3; a leading `\nBearer ...` would slip
369
+ // past the slice() with a clean `Bearer` token if we trimmed first
370
+ // (same shape as the v0.8.90 mail-require-tls bug class).
371
+ // parseChallenge is a defensive request-shape reader, so the
372
+ // predicate variant returns null rather than throwing.
373
+ if (structuredFields.containsControlBytes(headerValue)) return null;
365
374
  // Tolerate "Bearer " prefix in any case; reject anything else.
366
375
  var idx = headerValue.toLowerCase().indexOf("bearer");
367
376
  if (idx === -1) return null;