@blamejs/core 0.9.0 → 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/CHANGELOG.md +1 -0
- package/lib/auth/oauth.js +36 -6
- package/lib/auth/openid-federation.js +41 -10
- package/lib/auth/saml.js +74 -19
- package/lib/auth/sd-jwt-vc.js +76 -6
- package/lib/xml-c14n.js +7 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.1 (2026-05-11) — **Audit hardening, slice 1 of N: federation auth + OAuth ID-token verifier**. Audit run after v0.9.0 across OAuth / SAML / OIDC federation / SD-JWT VC / WebAuthn / FIDO MDS3 / constant-time-compare surfaces; this slice closes the highest-severity SAML + OIDC + SD-JWT + OAuth findings. **SAML SP** (`b.auth.saml.sp.create({...}).buildAuthnRequest` + `.metadata`): every operator-supplied URL / ID interpolated into the emitted XML now routes through `b.xmlC14n.escapeAttrValue` / `escapeText`. Pre-v0.9.1 a `"` or `<` in `idpSsoUrl` / `assertionConsumerServiceUrl` / `entityId` / `nameIdFormat` broke out of attribute / element context and produced unsigned-XML breakout into the IdP redirect. Newly exported `b.xmlC14n.escapeAttrValue(s)` and `b.xmlC14n.escapeText(s)` — RFC 3741 §1.3.x compliant; available for any operator emitting XML alongside the framework's own SAML / canonicalization paths. **SAML SP `verifyResponse`**: digest compare on `Reference DigestValue` and `SubjectConfirmation InResponseTo` now use `b.crypto.timingSafeEqual` instead of `Buffer.compare` / `!==`. **SAML XSW defense**: refuses Response payloads that carry duplicate `<Status>`, `<StatusCode>`, `<Assertion>`, `<Subject>`, or `<NameID>` children — XML signature wrapping attacks ferry an unsigned sibling next to a signed element and exploit first-match parsers; the verifier now asserts single-child cardinality on every security-critical element via `_findAllChildren(...).length === 1`. **OIDC federation** (`b.auth.openidFederation.verifyEntityStatement`): JWK key-type cross-check against the JWS `alg` header BEFORE `nodeCrypto.createPublicKey` — an attacker-controlled entity-config declaring `alg: "ES256"` while supplying an RSA JWK would previously load through Node's silent algorithm-vs-key coercion path. Now refuses with `auth-openid-federation/alg-kty-mismatch` for any `alg=ES*` not paired with `kty=EC`, `alg=PS*`/`RS*` not paired with `RSA`, or `alg=EdDSA` not paired with `OKP`. **`b.auth.openidFederation.buildTrustChain` error-masking**: trust-chain ascent previously swallowed every per-authority failure via `catch (_e) {}` and continued to the next `authority_hint`; signature-failure errors from one authority no longer mask, the chain now refuses on cryptographic refusal (`bad-jwk`, `alg-kty-mismatch`, `bad-signature`, `signature-failed`). Network / 404 / iss-sub-mismatch errors still continue to the next hint but are collected and surfaced in the `no-ascent` failure shape. **SD-JWT VC verify** (`b.auth.sdJwtVc.verify`): three correctness fixes. (1) `_sd_alg` default switched from `sha3-512` to `sha-256` per IETF draft §4.1.1 — prior default broke verification against spec-conformant issuers when the issuer omitted `_sd_alg`. (2) Disclosure-replay defense: every disclosure digest tracked in a Set; second occurrence of the same digest refuses with `auth-sd-jwt-vc/disclosure-replay`. (3) Claim-shadowing defense: holder-supplied disclosures whose name collides with an issuer-signed top-level claim (`iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti`, `vct`, `cnf`, `_sd`, `_sd_alg`, `status`) refuse with `auth-sd-jwt-vc/protected-claim-shadow` instead of silently overwriting the signed value. (4) KB-JWT `sd_hash` now uses the credential's declared `_sd_alg` (was hardcoded sha256, breaking against issuers using sha3-512); `sd_hash` compare routed through `b.crypto.timingSafeEqual`. **OAuth `verifyIdToken`** (`b.auth.oauth.verifyIdToken`): three hardening fixes that bring the verifier to parity with the framework's other JWS verifiers. (1) `nodeCrypto.verify` wrapped in try/catch — previously panicked on key/sig shape mismatch (e.g. ES256 sig against an RS256 key returned by a buggy IdP with duplicate kids), bubbling a raw `Error` to the operator's handler instead of an `OAuthError`. (2) RFC 7515 §4.1.11 `crit` header refusal — every sibling verifier (`b.auth.jwt`, `b.auth.jwt.verifyExternal`, `b.auth.dpop`) refuses; verifyIdToken previously silently ignored, letting an attacker-controlled OP ship critical extensions the verifier doesn't understand. (3) `state` and `nonce` claim compares routed through `b.crypto.timingSafeEqual` — these are CSRF / replay tokens compared against attacker-controlled callback / payload data.
|
|
11
12
|
- v0.9.0 (2026-05-11) — **Minor: 3 new RFC primitives + `b.structuredFields` shared substrate + full audit-derived hardening sweep + 5 new bug-class detectors**. **`b.structuredFields`** consolidates the quote-aware top-level splitter (`splitTopLevel(s, sep)`), the raw-value control-byte refusal scan (`refuseControlBytes` / `containsControlBytes`), and the sf-string unquote (`unquoteSfString`) used by every RFC 8941 / RFC 9110 / RFC 9111 / RFC 9213 / RFC 9421 / RFC 6266 / RFC 6265 / RFC 6455 parser in the framework — replaces the per-file open-coded copies that were drifting site-by-site. **8 audit-surfaced bug-class sites fixed in this same patch** (no deferred follow-up): (1) `b.middleware.bodyParser._contentType` + `_parseHeaderParams` — RFC 9110 Content-Type / RFC 6266 Content-Disposition parameters can carry quoted-string values (`boundary="foo;bar"` / `filename="weird;name.txt"`); bare `.split(";")` previously sliced through quoted semicolons and corrupted multipart boundaries. (2) `b.requestHelpers.parseListHeader({ strictToken: true })` — control-byte scan now runs on the RAW value before `.trim()` so a leading `\n<token>` no longer slips past `RFC_9110_TOKEN_RE` (same v0.8.90 bug class; used by webhook signature parsing and WS subprotocol negotiation). (3) `b.middleware.tusUpload._parseChecksumHeader` — same trim-before-validate fix. (4) `b.httpClient.cache._parseCacheControl` — quote-aware `,` splitter (RFC 9111 §5.2 + RFC 9110 §5.6.4 directive values may be quoted-string). (5) `b.httpClient.cookieJar._parseSetCookie` — quote-aware `;` splitter defends RFC 7230 quoted-string attribute values (`SameSite="Strict"` from interop-imperfect upstreams). (6) `b.websocket._parseExtensionHeader` — quote-aware `;` and `,` splitters defend RFC 6455 §9.1 + RFC 7230 token-or-quoted-string parameter values against forward-compat extensions shipping quoted params. (7) `b.aiPref.parseHeader` — control-byte refusal added on the RAW value before split + trim. (8) `b.auth.stepUp.parseChallenge` — same trim-before-validate fix (returns `null` per defensive-reader contract instead of throwing). Also: `b.logStream.init({ minLevel })` now validates the level vocabulary at config time so a typo'd `"infos"` (which previously produced `LEVEL_PRIORITY["infos"] === undefined` and silently dropped every log record) throws at boot. `b.crypto.httpSig`'s RFC 9421 Signature-Input parameter parser uses the quote-aware `;` splitter (RFC 8941 §3.1.2 sf-string parameter values). `b.security.assertProductionPosture({ minTlsVersion })` validates `minTlsVersion` against the canonical TLS vocabulary BEFORE the rank comparison (a typo previously silently passed because `indexOf` returned `-1` — same bug class as v0.8.88 `b.auth.fal.meets`). **3 new RFC primitives**: **`b.cdnCacheControl`** ships an RFC 9213 directive list builder + parser shared across `Cache-Control`, `CDN-Cache-Control`, `Surrogate-Control`, and the operator-specific `Cloudflare-` / `Vercel-` / `Fastly-` / `Akamai-` / `Netlify-CDN-Cache-Control` variants. `build({...})` emits the directive list string (numeric directives non-negative-integer-only; refuses Infinity / NaN / floats / negatives; full RFC 9111 boolean directive set; `extensions` for non-standard directives with RFC 7234 §5.2 token-shape enforcement); refuses `public + private` conflict per RFC 9111 §5.2.2.5/§5.2.2.6. `parse(headerValue)` decodes any targeted header into `{ public, private, noStore, maxAge, sMaxAge, ..., directives, fields }` with **qualified-form support** (`private="Authorization"` flag stays enabled, field-name list under `.fields[camel]` per RFC 9111 §5.2.2.4 / §5.2.2.6 — presence == enabled, the argument narrows scope), **bare `max-stale` parses as `Infinity`** per RFC 9111 §5.2.1.2 (instead of buggy `Number(true) === 1`), and a quote-aware top-level `,` splitter so a `, ` inside a quoted directive value doesn't fake-split. `isTargetedHeader(name)` + curated `TARGETED_HEADERS` allowlist. **`b.clientHints`** ships a Sec-CH-UA-* request-header family parser per W3C UA Client Hints + IETF draft-davidben-http-client-hint-reliability. `parse(req.headers)` returns `{ brands, mobile, platform, platformVersion, arch, bitness, model, fullVersionList, wow64, formFactors, raw }`; quote-aware splitters at brand-list and brand-member-parameter level (RFC 8941 §4.1.1.4 parameter values may be sf-string); refuses control characters in any Sec-CH-* value. `acceptList(hintNames)` builds `Accept-CH` with typo-defense (unknown hint name throws `client-hints/unknown-hint`); dedupes case-variant duplicates; canonicalizes to W3C mixed-case spelling. `KNOWN_HINTS` exports the well-known 22-name list. **`b.network.dns.classifyDnskeyAlgorithm(algorithm)` / `classifyDsDigestType(digestType)`** — RFC 9905 DNSSEC SHA-1 deprecation classifier. Covers every IANA-assigned DNSKEY algorithm (including PRIVATEDNS/PRIVATEOID/INDIRECT/Reserved entries) and RFC 9558 §3 DS digest types 5 (GOST R 34.11-2012) + 6 (SM3); operators auditing inbound DNSSEC chain-of-trust evidence refuse validations where `deprecated === true`. Inline `allow:bare-split-on-quoted-header` markers added across `mail-auth.js` (DKIM / DMARC / ARC tag-list grammar — token-only), `network-smtp-policy.js` (TLS-RPT — token-only), `middleware/scim-server.js` (RFC 7644 §3.9 SCIM attribute paths), `http-client-cache.js` (RFC 9110 §12.5.5 Vary field-names), `http-message-signature.js` (RFC 9421 component-id covered list), `middleware/body-parser.js` (RFC 9112 §6.1 Transfer-Encoding token-only), each citing the controlling RFC clause showing why quoted-string is not a legal value in that grammar. **5 new bug-class detectors** in `test/layer-0-primitives/codebase-patterns.test.js` so the same shapes can't drift back in: (a) `trim-before-validate` — control-byte refusal scans must run on the RAW header value BEFORE `.trim()` strips leading/trailing C0/DEL bytes; detector now catches BOTH the `charCodeAt` codepoint-loop shape AND the `<NAME>_RE.test(<trimmed>)` grammar-regex shape; (b) `enum-rank-without-validation` — `_rankFn(X) >= _rankFn(Y)` arithmetic comparisons must have a preceding `isValid*` / `KNOWN_*` membership check on both inputs (catches `b.auth.fal.meets` bug shape); (c) `bool-string-coerce-shape` — boolean directive parsing must NOT use `val === "" || val === "true"` coercion (catches `b.cdnCacheControl.parse` qualified-form bug shape); (d) `bare-split-on-quoted-header` — RFC structured-fields parsers in files that ALSO handle sf-string unquote regex must use the shared quote-aware `b.structuredFields.splitTopLevel`, not bare `.split(",") / .split(";")`; (e) `scoped-context-binding-unused` — scope-named factory bindings (`forwarderDomain` / `realm` / `origin` / `audience` / `issuer`) captured in the factory must be compared against the inbound value's embedded scope in the `verify` / `reverse` / `decode` path (catches the v0.8.89 SRS forwarder-domain bug shape). Operators upgrade `0.8.90` → `0.9.0`; v0.8.91 was never tagged (its surface is folded into v0.9.0 with the audit-derived hardening).
|
|
12
13
|
- v0.8.90 (2026-05-11) — **RFC 8689 REQUIRETLS support** (`b.mail.requireTls`). Per-message TLS-requirement signaling between sender and receiver MTAs. Complements MTA-STS / DANE (policy-side, domain-scoped) with a per-message knob that overrides policy when the operator wants stricter-than-policy delivery — message bounces instead of falling back to cleartext if no downstream MTA can deliver under TLS. **`peerSupports(ehloLines)`**: walks a parsed EHLO response and returns `true` when the peer advertised the `REQUIRETLS` keyword; case-insensitive per RFC 5321 §2.4; refuses substring matches (`FOO-REQUIRETLS-BAR` does NOT match); empty / non-array input returns `false`. **`mailFromExtension({ requireTls })`**: builds the trailing `" REQUIRETLS"` token to append to a MAIL FROM line; refuses non-boolean flag value (a truthy-but-wrong-shape value like `"yes"` throws instead of silently succeeding). **`parseTlsRequiredHeader(headerValue)`**: parses the RFC 8689 §5 `TLS-Required` header — returns `"no"` only when the value is the literal token `no` (case-insensitive, ignoring whitespace) per spec; any other non-empty value returns `"yes"` (RFC 8689 §5: "any value other than 'No' MUST be treated as if the field had been absent" — conservative strict path); returns `null` for absent / empty / non-string input; refuses control characters on the **raw** header value before `trim()` runs so a leading `\n` / trailing `\r` / NUL / DEL byte can no longer slip past as the literal token `no` (ASCII HT remains permitted as structural folding whitespace).
|
|
13
14
|
- v0.8.89 (2026-05-11) — **Hotfix: `b.earlyHints.send()` case-variant link bypass + new `b.mail.srs` Sender Rewriting Scheme**. **Hotfix (PRIMARY)**: pre-v0.8.89, supplying both `link` (lowercase) AND `Link` (capital, or any other case variant) to `b.earlyHints.send()` bypassed the validator. `opts.link` got the dedicated `_validateLink` pass and was assigned to `headers.link`; the trailing header loop then iterated `Object.keys(opts)`, skipped only the exact-match `"link"` key, and for `"Link"` lowercased the name and wrote `headers.link = opts.Link` — overwriting the validated value with unvalidated content. Malformed Link headers (missing `rel=`, unknown relation, oversized) reached `writeEarlyHints()` despite the API contract. The fix collapses all opt keys to a single canonical lowercase map up front; duplicate case-variants of any header (not just `link`) now refuse with `early-hints/duplicate-header` so operators see the collision instead of getting silent winner-take-all behavior. Capital `Link` alone (no lowercase variant) still works — it goes through the same validator. Tests added: case-variant-collision refuse, capital-Link-alone validates, capital-Link with malformed value still throws `bad-link`. **New**: `b.mail.srs.create({ secret, forwarderDomain, expiryDays? })` — Sender Rewriting Scheme (SRS0) implementation for forwarder envelope-from rewriting so the next-hop SPF check passes and bounces route correctly back to the original sender. Returns `{ rewrite, reverse }`. `rewrite(addr)` produces an SRS-encoded `SRS0=HHHH=TT=domain=local@forwarder.example` form; `reverse(srs)` decodes back to the original sender, verifying the HMAC-SHA-256 short-tag (operator-supplied secret), the day-stamp expiry window (default 30 days), and the canonical 4-field SRS0 grammar. Domain-binding check: `reverse(srs)` refuses with `srs/wrong-forwarder` when the SRS0 address's `@domain` part doesn't match the rewriter's `forwarderDomain` (case-insensitive per RFC 5321 §2.3.5) so a tag signed with the same secret but addressed to a different forwarder domain can no longer be accepted. Refuses tampered tags via `srs/bad-tag`, expired rewrites via `srs/expired`, double-SRS-encoding via `srs/already-rewritten`, and bad address shapes via `srs/bad-address`. HMAC uses `b.crypto.timingSafeEqual` for tag comparison so the verification side stays constant-time against operator-controlled tag inputs.
|
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
|
-
|
|
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)
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
}
|
package/lib/auth/sd-jwt-vc.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/lib/xml-c14n.js
CHANGED
|
@@ -495,5 +495,12 @@ module.exports = {
|
|
|
495
495
|
parse: parse,
|
|
496
496
|
canonicalize: canonicalize,
|
|
497
497
|
canonicalizeElementById: canonicalizeElementById,
|
|
498
|
+
// Exported so SAML metadata / AuthnRequest builders can interpolate
|
|
499
|
+
// operator-supplied URLs and IDs without raw string concatenation.
|
|
500
|
+
// _escapeAttrValue handles double-quoted attribute-value escaping
|
|
501
|
+
// (`"`, `&`, `<`, CR/LF/HT); _escapeText handles element text-node
|
|
502
|
+
// escaping (`&`, `<`, `>`, CR). Both are RFC 3741 §1.3.x compliant.
|
|
503
|
+
escapeAttrValue: _escapeAttrValue,
|
|
504
|
+
escapeText: _escapeText,
|
|
498
505
|
XmlC14nError: XmlC14nError,
|
|
499
506
|
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:bffe9a17-1586-474d-aa8f-75dd58525185",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-11T23:22:03.045Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.9.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.1",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.1",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.1",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.9.
|
|
57
|
+
"ref": "@blamejs/core@0.9.1",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|