Skip to content

Decode base64url strictly in Base64.urlSafeDecode (JWS signature malleability) - #82

Open
mohass1927 wants to merge 2 commits into
tink-crypto:mainfrom
mohass1927:fix/strict-base64-canonical
Open

mohass1927 wants to merge 2 commits into
tink-crypto:mainfrom
mohass1927:fix/strict-base64-canonical

Conversation

@mohass1927

Copy link
Copy Markdown

Summary

Base64.urlSafeDecode accepts non-canonical encodings: the final quantum's unused bits are discarded without being checked, so a string whose last character carries non-zero unused bits decodes to the same bytes as its canonical equivalent. RFC 4648 section 3.5 says a decoder must reject such a string.

For JWS/JWT material this is signature malleability. JwtFormat decodes the signature segment with Base64.urlSafeDecode, so two distinct compact serialisations verify against a single signature. Token identity is therefore not unique, which breaks any control keyed on the token string (revocation/denylist entries, one-time-use enforced by string comparison, dedup or cache keys keyed on the compact form).

Reproducer

With com.google.crypto.tink:tink:1.23.0 on the classpath, this pair differs only in the unused trailing bits of the last character:

String canonical    = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
String nonCanonical = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXl";

Before this change, both urlSafeDecode calls return the same 32 signature bytes. End to end, a token built from the canonical string verifies, and the same token with only that final character changed (...Xk -> ...Xl) also verifies through JwtMac.verifyMacAndDecode — two distinct strings, one signature.

Why this shape

This mirrors what landed in tink-go on 2026-09-11: commit fe4b23e7 ("Use strict base64url decoding in jwt to prevent signature malleability") switched jwt/jwt_encoding.go and internal/jwk/jwk.go to the strict decoder, with tests for a non-canonical JWS signature and a non-canonical Ed25519 JWK x. Java still accepts the non-canonical form on both paths, since JwtFormat and JwkSetConverter share this decoder, and a check here covers both.

The change

Two guards in the decoder's finish switch, where the trailing bits are currently thrown away:

  • case 2 — twelve bits carried, eight used, so the low four must be zero.
  • case 3 — eighteen bits carried, sixteen used, so the low two must be zero.

Canonical input is unaffected, including the short forms (QQ, QUI, QUJD), which the new test asserts alongside the rejected non-canonical signature.

Validation

I could not run this repository's Maven test suite from my environment. I validated the behaviour directly by compiling the unmodified and patched Base64.java standalone and exercising the three cases above: canonical decodes to 32 bytes, the non-canonical twin used to decode to the identical bytes and now throws, and the short forms still decode to 1/2/3 bytes. The JUnit test follows the existing Base64Test conventions (JUnit4, Truth).

If you would rather limit the blast radius, the alternative is a strict variant used only by JwtFormat/JwkSetConverter rather than tightening the shared decoder; I am happy to restructure either way. Flagging rather than assuming because I do not know whether any other caller depends on the lenient behaviour.

The decoder discarded the unused bits of the final quantum without checking
them, so a non-canonical string decoded to the same bytes as its canonical
equivalent. For JWS/JWT inputs that is signature malleability: two distinct
compact serialisations verify against one signature. This mirrors the fix
landed in tink-go (jwt/jwt_encoding.go and internal/jwk/jwk.go now decode
strictly).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant