From f59c1c4b1f92165475cccd4cd52636bc4243bf01 Mon Sep 17 00:00:00 2001 From: mohass1927 <76394919+mohass1927@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:25:43 +0200 Subject: [PATCH 1/2] Enforce canonical base64url in Base64.urlSafeDecode (RFC 4648 3.5) 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). --- .../com/google/crypto/tink/subtle/Base64.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/com/google/crypto/tink/subtle/Base64.java b/src/main/java/com/google/crypto/tink/subtle/Base64.java index 5c6563ead..9f06b1de3 100644 --- a/src/main/java/com/google/crypto/tink/subtle/Base64.java +++ b/src/main/java/com/google/crypto/tink/subtle/Base64.java @@ -395,11 +395,26 @@ public boolean process(byte[] input, int offset, int len, boolean finish) { this.state = 6; return false; case 2: + // The final quantum carries twelve bits but only eight are used, so + // the low four bits must be zero: RFC 4648 section 3.5 requires the + // canonical encoding. Without this check a non-canonical encoding of + // the same bytes is accepted, which makes two distinct strings decode + // to one value (signature malleability for JWS inputs). + if ((value & 0xF) != 0) { + this.state = 6; + return false; + } // Read two extra input bytes, enough to emit 1 more // output byte. Fine. output[op++] = (byte) (value >> 4); break; case 3: + // The final quantum carries eighteen bits but only sixteen are used, + // so the low two bits must be zero (RFC 4648 section 3.5). + if ((value & 0x3) != 0) { + this.state = 6; + return false; + } // Read three extra input bytes, enough to emit 2 more // output bytes. Fine. output[op++] = (byte) (value >> 10); From 48a9a963fe26b7ba0cefa0632ffb5449d6fb2eda Mon Sep 17 00:00:00 2001 From: mohass1927 <76394919+mohass1927@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:25:51 +0200 Subject: [PATCH 2/2] Add regression test for canonical base64url decoding --- .../tink/subtle/Base64StrictDecodingTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/test/java/com/google/crypto/tink/subtle/Base64StrictDecodingTest.java diff --git a/src/test/java/com/google/crypto/tink/subtle/Base64StrictDecodingTest.java b/src/test/java/com/google/crypto/tink/subtle/Base64StrictDecodingTest.java new file mode 100644 index 000000000..0663aacdd --- /dev/null +++ b/src/test/java/com/google/crypto/tink/subtle/Base64StrictDecodingTest.java @@ -0,0 +1,56 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// + +package com.google.crypto.tink.subtle; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * RFC 4648 section 3.5 requires decoders to reject a final quantum whose unused bits are not zero. + * Without that check a non-canonical string decodes to the same bytes as its canonical + * equivalent, which for JWS/JWT inputs means two distinct compact serialisations verify against one + * signature (signature malleability). + */ +@RunWith(JUnit4.class) +public final class Base64StrictDecodingTest { + + // RFC 7515 Appendix A.1.1 signature. The two strings differ only in the unused + // trailing bits of the final character ('k' has zero, 'l' does not). + private static final String CANONICAL = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String NON_CANONICAL = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXl"; + + @Test + public void urlSafeDecode_canonicalSignature_isAccepted() { + assertThat(Base64.urlSafeDecode(CANONICAL)).hasLength(32); + } + + @Test + public void urlSafeDecode_nonCanonicalTrailingBits_isRejected() { + assertThrows(IllegalArgumentException.class, () -> Base64.urlSafeDecode(NON_CANONICAL)); + } + + @Test + public void urlSafeDecode_validShortForms_stillDecode() { + assertThat(Base64.urlSafeDecode("QQ")).hasLength(1); + assertThat(Base64.urlSafeDecode("QUI")).hasLength(2); + assertThat(Base64.urlSafeDecode("QUJD")).hasLength(3); + } +}