{"id":"GHSA-f9j6-8p6x-r9j6","summary":"SD JWT JS: Tampered disclosures silently dropped instead of rejected","details":"## Summary\n\nWhen one Disclosure in an SD-JWT presentation is tampered so its digest no longer matches the Issuer-signed `_sd` array, `@sd-jwt/decode` 0.19.0 does **not** reject the presentation. It silently drops the unmatched Disclosure and returns a **reduced claim set**, reporting success. The Issuer signature is untouched and valid.\n\nThis is a downgrade/omission primitive. A malicious or curious **holder** can present a tampered Disclosure and have the relying party receive *fewer* claims than the credential carries, with no error surfaced. Where the relying party does **not** enforce key binding, an **in-transit (on-path) attacker** can achieve the same by mangling one Disclosure's bytes; where key binding **is** enforced, on-path tampering instead breaks the holder's KB-JWT `sd_hash` binding (so it is rejected), and the downgrade is then exercisable by the key-bound holder — confirmed end-to-end with KB required.\n\nTwo distinct tampering inputs converge on the same silent-drop sink:\n\n1. **Genuinely non-decodable Disclosure** (broken base64url -\u003e bytes that are not valid UTF-8, e.g. an injected `0xF2` lead byte). RFC 9901 §7.1 step 3 / §4.2.1 require rejection. `@sd-jwt/decode` instead **lossy-decodes** it: the `TextDecoder`/`Buffer` path substitutes U+FFFD (REPLACEMENT CHARACTER) for the invalid byte and recovers a syntactically valid disclosure (the `salt` gains a U+FFFD, claim `family_name` = `\"Smith\"`). The recovered Disclosure then hashes to a digest **absent** from the signed `_sd` array, so it is silently skipped.\n2. **Tampered-but-still-valid-JSON Disclosure** (digest no longer matches `_sd`): same end state — the unmatched Disclosure is silently skipped.\n\nTwo RFC 9901 MUSTs compound:\n- **§7.1 step 5 (primary):** \"If any Disclosure was not referenced by digest value in the Issuer-signed JWT (directly or recursively via other Disclosures), the SD-JWT MUST be rejected.\" — instead silently dropped.\n- **§7.1 step 3 / §4.2.1 (secondary):** a non-base64url-JSON Disclosure MUST be rejected — the lenient lossy decode skips this check.\n\nConfirmed end-to-end through `SDJwtInstance.verify()` with a real ES256 issuer key, and again with **key binding REQUIRED** (a valid holder ES256 KB-JWT over `aud`+`nonce`+`sd_hash`).\n\n## Impact\n\nSilent claim-set reduction is a downgrade/omission primitive. If a relying party's authorization logic is \"default-allow when a constraining claim is absent\" (an age / role / restriction attribute), dropping that one Disclosure flips the decision, with no error surfaced. The decoder reporting **success** on a tampered presentation is the core problem; tampering should be a hard reject. Because SD-JWT VC and the EU EUDIW ARF profiles reuse this disclosure layer, the exposure is to wallet/verifier deployments built on `@sd-jwt/*`.\n\n## Proof of concept (minimized — NOT a weaponized exploit)\n\nA clean single-mutation credential: lowercase `_sd_alg: \"sha-256\"`, an honest Issuer body whose `_sd` digests are computed over the valid wire Disclosures, and exactly one wire byte-segment tampered — the `family_name` Disclosure made genuinely non-decodable (an invalid-UTF-8 `0xF2` byte injected into its base64url). Everything else is byte-for-byte valid.\n\nThe byte-exact credential cannot be faithfully pasted as text (it deliberately contains the invalid `0xF2` byte that triggers the lossy decode). It is provided as `bug3_clean_tampered.cred` with an untampered twin `bug3_clean_control.cred`, plus the minting script `mint_bug3_clean.py` and a three-oracle harness `run_three_oracles.py` — shared privately with the maintainer on first reply. Structure: 5 Disclosures, only index 1 (`family_name`) tampered; all other segments byte-for-byte valid.\n\nObserved on the byte-identical credential (three independent oracles):\n\n| Oracle | Version | Verdict | Detail |\n|---|---|---|---|\n| RFC 9901 reference (pure Python) | reference impl | **REJECT** | `R_DISCLOSURE_DECODE` |\n| `sd-jwt` (Daniel Fett's reference impl) | pip 0.10.4 | **REJECT** | `UnicodeDecodeError: 0xf2 ... invalid continuation byte` |\n| `@sd-jwt/decode` (this package) | 0.19.0 | **ACCEPT** | reduced set `{given_name, email, vct, iss}` — `family_name` silently dropped, no error |\n\n**Control (untampered, same body):** all three oracles ACCEPT the full 5-claim set including `family_name: \"Smith\"` — confirming the single tampered byte is the only difference. **Liveness:** the `@sd-jwt/decode` harness REJECTs pure garbage (`\"not-a-jwt\"`, `\"~~~~\"`, `\"aaa.bbb.ccc~ZZZZ~\"`, `\"\"`) and ACCEPTs the clean control, so the ACCEPT above is a genuine behavioral divergence, not an accept-everything stub. No ready-to-run exploit against any deployed verifier is included.\n\n## Suggested fix\n\nIn the disclosure-decode loop of `@sd-jwt/decode`:\n1. **Enforce §7.1 step 5:** if any provided Disclosure is not referenced by a digest in the signed `_sd` array (directly or recursively), reject the entire presentation — do not silently skip it.\n2. **Enforce §7.1 step 3 / §4.2.1:** treat a Disclosure that fails base64url/JSON decode, or that is not a 3-element `[salt, claimName, claimValue]` array, as a fatal error. Replace the lenient `TextDecoder`/`Buffer` decode with a strict UTF-8 decode that throws on invalid bytes (do not substitute U+FFFD).\n3. Surface both rejections from the public `verify()` / `decodeSdJwt()` entry points, not only from an internal helper.\n\n## Related findings in the same library (MEDIUM — context for one combined patch)\n\n- **Reserved claim name `_sd` accepted** (§7.1 step 3). MEDIUM (conformance, issuer-side). Fix: reject if the claim name is a reserved key (`_sd`, `_sd_alg`, ...).\n- **`_sd_alg` not validated case-sensitively / against the registry** (§4.1.1, §7.1 step 2). MEDIUM (defense-in-depth). Accepts `sha-1` and `SHA-256` (wrong case, silently normalized to `sha-256`). Fix: exact, case-sensitive membership test against the supported registered set; no case normalization, no silent default.\n\nThe maintainer may prefer to address all three in one advisory/patch.\n\n## Credit\n\nFound and reported by **Anton Sokolov, Tyche Institute** (Tallinn, Estonia), ORCID 0000-0003-2452-7096. Discovered via differential fuzzing of the SD-JWT disclosure layer against an RFC 9901 reference oracle and two reference libraries.","aliases":["CVE-2026-62374"],"modified":"2026-11-02T18:00:04.175458008Z","published":"2026-11-02T17:54:39Z","database_specific":{"cwe_ids":["CWE-176"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-11-02T17:54:39Z","nvd_published_at":"2026-10-29T17:18:10Z"},"references":[{"type":"WEB","url":"https://github.com/openwallet-foundation/sd-jwt-js/security/advisories/GHSA-f9j6-8p6x-r9j6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-62374"},{"type":"WEB","url":"https://github.com/openwallet-foundation/sd-jwt-js/pull/380"},{"type":"WEB","url":"https://github.com/openwallet-foundation/sd-jwt-js/commit/3d8a72a0a279d67439db038c9c2b52621568b866"},{"type":"PACKAGE","url":"https://github.com/openwallet-foundation/sd-jwt-js"}],"affected":[{"package":{"name":"@sd-jwt/decode","ecosystem":"npm","purl":"pkg:npm/%40sd-jwt/decode"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"last_affected":"0.19.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-f9j6-8p6x-r9j6/GHSA-f9j6-8p6x-r9j6.json"}},{"package":{"name":"@sd-jwt/core","ecosystem":"npm","purl":"pkg:npm/%40sd-jwt/core"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.20.0"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 0.19.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-f9j6-8p6x-r9j6/GHSA-f9j6-8p6x-r9j6.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N"}]}