
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: <= 0.19.0
Vulnerable: <= 0.19.0
Patched in 0.20.0
Details
When 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.
This 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.
Two distinct tampering inputs converge on the same silent-drop sink:
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._sd): same end state — the unmatched Disclosure is silently skipped.Two RFC 9901 MUSTs compound:
Confirmed 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).
Silent 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/*.
A 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.
The 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.
Observed on the byte-identical credential (three independent oracles):
| Oracle | Version | Verdict | Detail |
|---|---|---|---|
| RFC 9901 reference (pure Python) | reference impl | REJECT | R_DISCLOSURE_DECODE |
sd-jwt (Daniel Fett's reference impl) | pip 0.10.4 | REJECT | UnicodeDecodeError: 0xf2 ... invalid continuation byte |
@sd-jwt/decode (this package) | 0.19.0 | ACCEPT | reduced set {given_name, email, vct, iss} — family_name silently dropped, no error |
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.
In the disclosure-decode loop of @sd-jwt/decode:
_sd array (directly or recursively), reject the entire presentation — do not silently skip it.[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).verify() / decodeSdJwt() entry points, not only from an internal helper._sd accepted (§7.1 step 3). MEDIUM (conformance, issuer-side). Fix: reject if the claim name is a reserved key (_sd, _sd_alg, ...)._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.The maintainer may prefer to address all three in one advisory/patch.
Found 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.
EPSS, exploit probability
Low0.23%
estimated chance of real-world exploitation in the next 30 days, higher than 13.3% of every CVE FIRST.org scores
Refreshed 11/2/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.