From d067d1f4d38956958372b86db79d2e33135d65ea Mon Sep 17 00:00:00 2001 From: yuxi-liu-wired <33951560+yuxi-liu-wired@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:59:50 +0000 Subject: [PATCH 1/2] base32ct: reject non-zero trailing bits in the last symbol RFC 4648 3.5 requires the unused low bits of the last symbol of a partial block to be zero. `decode` ignored them, so several strings decoded to the same bytes: "me", "mf" and "mh" all decode to "a" ("me" is the canonical encoding). base64ct rejects the equivalent Base64 inputs since #680 (#679). A remainder of 2/4/5/7 symbols leaves 2/4/1/3 unused bits in its last symbol; reject the input when any of them is set. Tests: a proptest that every accepted input is the canonical encoding of its output (decode then encode gives the input back), and a regression test for "mf" / "mh" / "mf======". --- base32ct/src/encoding.rs | 12 ++++++++++++ base32ct/tests/proptests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/base32ct/src/encoding.rs b/base32ct/src/encoding.rs index 6b50cd3bb..2091aa309 100644 --- a/base32ct/src/encoding.rs +++ b/base32ct/src/encoding.rs @@ -123,6 +123,18 @@ impl Encoding for T { err |= ((c[0] | c[1] | c[2] | c[3] | c[4] | c[5] | c[6]) >> 8) as u8; + // RFC 4648 3.5: the unused low bits of the last symbol of a partial block must be zero + // (otherwise several encodings decode to the same bytes). A remainder of 2/4/5/7 + // symbols leaves 2/4/1/3 unused bits in its last symbol. + let (last, unused_mask) = match src_rem.len() { + 2 => (c[1], 0b11), + 4 => (c[3], 0b1111), + 5 => (c[4], 0b1), + 7 => (c[6], 0b111), + _ => (0, 0), + }; + err |= u8::from(last & unused_mask != 0); + if err == 0 { Ok(dst) } else { diff --git a/base32ct/tests/proptests.rs b/base32ct/tests/proptests.rs index 89f1ebb7f..186bdffee 100644 --- a/base32ct/tests/proptests.rs +++ b/base32ct/tests/proptests.rs @@ -65,4 +65,28 @@ proptest! { assert_eq!(a, b); } } + + /// Every accepted input is the canonical encoding of its output (RFC 4648 3.5: the unused + /// bits of the last symbol are zero), so decode-then-encode gives the input back. + #[test] + fn decode_is_canonical(string in string_regex("[a-z2-7]{0,32}").unwrap()) { + if let Ok(bytes) = Base32UnpaddedCt::decode_vec(&string) { + prop_assert_eq!(Base32UnpaddedCt::encode_string(&bytes), string.clone()); + } + let padded = format!("{string}{}", "=".repeat((8 - string.len() % 8) % 8)); + if let Ok(bytes) = Base32Ct::decode_vec(&padded) { + prop_assert_eq!(Base32Ct::encode_string(&bytes), padded); + } + } +} + +/// "me" is the canonical encoding of "a" (0x61 = 01100 001|00); "mf" and "mh" set the unused +/// low bits of the last symbol and must be rejected. +#[test] +fn reject_non_zero_trailing_bits() { + assert_eq!(Base32UnpaddedCt::decode_vec("me").unwrap(), b"a"); + assert!(Base32UnpaddedCt::decode_vec("mf").is_err()); + assert!(Base32UnpaddedCt::decode_vec("mh").is_err()); + assert_eq!(Base32Ct::decode_vec("me======").unwrap(), b"a"); + assert!(Base32Ct::decode_vec("mf======").is_err()); } From e08117587664776d857957692aec6b3fa8033b72 Mon Sep 17 00:00:00 2001 From: yuxi-liu-wired <33951560+yuxi-liu-wired@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:18:58 +0000 Subject: [PATCH 2/2] base32ct: cast with `as u8` like the rest of decode Review suggestion. --- base32ct/src/encoding.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base32ct/src/encoding.rs b/base32ct/src/encoding.rs index 2091aa309..96c60c54b 100644 --- a/base32ct/src/encoding.rs +++ b/base32ct/src/encoding.rs @@ -133,7 +133,7 @@ impl Encoding for T { 7 => (c[6], 0b111), _ => (0, 0), }; - err |= u8::from(last & unused_mask != 0); + err |= (last & unused_mask != 0) as u8; if err == 0 { Ok(dst)