Skip to main content

secure_gate/traits/decoding/
base32.rs

1//! Base32 decoding trait (RFC 4648 §6, uppercase, unpadded).
2//!
3//! > **Import path:** `use secure_gate::FromBase32Str;`
4//!
5//! This trait provides secure, explicit decoding of Base32-encoded strings
6//! (RFC 4648 §6 alphabet, uppercase, no padding) to byte vectors. It is designed for
7//! handling untrusted input in cryptographic contexts, such as decoding `otpauth://`
8//! TOTP/HOTP shared secrets or encoded keys.
9//!
10//! Requires the `encoding-base32` feature.
11//!
12//! # Security Notes
13//!
14//! - **Treat all input as untrusted**: validate Base32 strings upstream before
15//!   wrapping in secrets. Invalid input may indicate tampering or injection attempts.
16//! - **Heap allocation**: Returns `Vec<u8>` — wrap in [`Fixed`](crate::Fixed) or
17//!   [`Dynamic`](crate::Dynamic) to store as a secret.
18//! - **Strict validation**: uppercase RFC 4648 §6 alphabet (`A`–`Z`, `2`–`7`) only,
19//!   no `=` padding, no whitespace, no non-ASCII, and the length must be ≡ 0, 2, 4, 5
20//!   or 7 (mod 8) — the only lengths an unpadded Base32 string can have. Lowercase and
21//!   mixed-case input is rejected; normalise upstream (`to_ascii_uppercase()`, ideally
22//!   inside `zeroize::Zeroizing`, since it copies the encoded secret).
23//! - **Lenient trailing bits**: non-zero bits in the final partial group are ignored
24//!   rather than rejected (`"MZ"` decodes to the same byte as `"MY"`). Decoding is
25//!   therefore not injective: two distinct strings can yield the same bytes. Only
26//!   encoder-produced strings are canonical, so round-tripping
27//!   `encode(decode(s)) == s` holds only for canonical `s`.
28//!
29//! # Example
30//!
31//! ```rust
32//! # #[cfg(feature = "encoding-base32")]
33//! use secure_gate::{FromBase32Str, Fixed};
34//! # #[cfg(feature = "encoding-base32")]
35//! {
36//! // "NBSWY3DP" decodes to b"hello"
37//! let bytes = "NBSWY3DP".try_from_base32().unwrap();
38//! assert_eq!(bytes, b"hello");
39//!
40//! // Wrap result in a secret immediately
41//! let secret: Fixed<[u8; 4]> = Fixed::try_from_base32("32W353Y").unwrap();
42//!
43//! // Error on invalid input
44//! assert!("!!!!".try_from_base32().is_err());
45//! }
46//! ```
47#[cfg(all(feature = "encoding-base32", feature = "alloc"))]
48use crate::error::Base32Error;
49
50/// Rejects encoded lengths that `base32ct` 0.2 cannot decode without panicking.
51///
52/// Unpadded Base32 packs 5 bytes into 8 characters, so the only trailing block
53/// sizes a valid string can have are 0, 2, 4, 5 and 7 characters. `base32ct`
54/// 0.2.2 sizes its output buffer from `decoded_len()` but indexes it from the
55/// *input* remainder, so a remainder of 1, 3 or 6 writes past the end and panics
56/// with "index out of bounds" (`base32ct-0.2.2/src/encoding.rs:105`). Upstream
57/// 0.3 rejects those lengths up front, but 0.3 is edition 2024 / MSRV 1.85 and
58/// cannot be used on the 0.8 line — so this crate performs the check instead.
59///
60/// A panic on attacker-supplied input would be a denial-of-service path in a
61/// secrets crate, so this guard is a correctness requirement, not a nicety.
62/// It branches only on the *encoded length*, which is public metadata already
63/// observable from the input string — never on decoded secret content.
64#[cfg(feature = "encoding-base32")]
65#[must_use]
66pub(crate) const fn encoded_len_is_decodable(len: usize) -> bool {
67    matches!(len % 8, 0 | 2 | 4 | 5 | 7)
68}
69
70/// Extension trait for decoding uppercase, unpadded Base32 strings into byte vectors.
71///
72/// *Requires features `encoding-base32` and `alloc`.*
73///
74/// Blanket-implemented for all `AsRef<str>` types. Returns `Vec<u8>` — requires heap
75/// allocation. For no-alloc targets, use `Fixed::try_from_base32` instead, which
76/// decodes directly into a stack-allocated `[u8; N]` buffer.
77///
78/// Uses the RFC 4648 §6 alphabet (`A`–`Z`, `2`–`7`) without `=` padding.
79///
80/// **The returned `Vec<u8>` is plain heap memory and is not zeroized on drop.** Wrap
81/// the result in [`Fixed`](crate::Fixed) or [`Dynamic`](crate::Dynamic) immediately
82/// (or in [`zeroize::Zeroizing`]) if the decoded bytes are sensitive. Prefer
83/// `Fixed::try_from_base32` / `Dynamic::try_from_base32`, which perform the
84/// wrapping for you and zeroize their internal temporaries.
85///
86/// Treat all input as untrusted; validate lengths and content upstream before wrapping
87/// decoded bytes in secrets.
88#[cfg(all(feature = "encoding-base32", feature = "alloc"))]
89pub trait FromBase32Str {
90    /// Decodes an uppercase, unpadded Base32 string into a byte vector.
91    ///
92    /// # Errors
93    ///
94    /// - [`Base32Error::InvalidBase32`] — wrong alphabet or case, `=` padding,
95    ///   whitespace, or a length that is impossible for unpadded Base32.
96    ///
97    /// # Examples
98    ///
99    /// ```rust
100    /// use secure_gate::FromBase32Str;
101    ///
102    /// // "NBSWY3DP" decodes to b"hello"
103    /// let bytes = "NBSWY3DP".try_from_base32()?;
104    /// assert_eq!(bytes, b"hello");
105    ///
106    /// assert!("nbswy3dp".try_from_base32().is_err()); // lowercase
107    /// assert!("MY======".try_from_base32().is_err()); // padding
108    /// assert!("!!!!".try_from_base32().is_err()); // invalid chars
109    /// # Ok::<(), secure_gate::Base32Error>(())
110    /// ```
111    fn try_from_base32(&self) -> Result<alloc::vec::Vec<u8>, Base32Error>;
112}
113
114// Blanket impl to cover any AsRef<str> (e.g., &str, String, etc.)
115// Returns Vec<u8> — alloc required.
116#[cfg(all(feature = "encoding-base32", feature = "alloc"))]
117impl<T: AsRef<str> + ?Sized> FromBase32Str for T {
118    fn try_from_base32(&self) -> Result<alloc::vec::Vec<u8>, Base32Error> {
119        use base32ct::{Base32UpperUnpadded, Encoding};
120        let src = self.as_ref();
121        if !encoded_len_is_decodable(src.len()) {
122            return Err(Base32Error::InvalidBase32);
123        }
124        Base32UpperUnpadded::decode_vec(src).map_err(|_| Base32Error::InvalidBase32)
125    }
126}