Skip to main content

aitp_core/
base64url.rs

1//! Strict unpadded base64url codec (RFC 4648 §5).
2//!
3//! AITP requires unpadded base64url throughout. RFC-AITP-0001 §5.4 forbids
4//! `=` padding on the wire — these helpers encode without padding and reject
5//! padded input on decode.
6
7use base64ct::{Base64UrlUnpadded, Encoding};
8
9/// Errors from strict base64url decoding.
10#[derive(Debug, thiserror::Error, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Base64UrlError {
13    /// Input contained `=` padding, which is forbidden in AITP.
14    #[error("base64url padding is forbidden in AITP")]
15    PaddingNotAllowed,
16
17    /// Input contained characters outside the base64url alphabet.
18    #[error("invalid base64url character")]
19    InvalidChar,
20
21    /// Decoded length did not match the expected length for the value.
22    #[error("invalid length")]
23    InvalidLength,
24}
25
26/// Encode bytes as unpadded base64url.
27pub fn encode(bytes: &[u8]) -> String {
28    Base64UrlUnpadded::encode_string(bytes)
29}
30
31/// Decode unpadded base64url; rejects `=` padding.
32pub fn decode_strict(s: &str) -> Result<Vec<u8>, Base64UrlError> {
33    if s.contains('=') {
34        return Err(Base64UrlError::PaddingNotAllowed);
35    }
36    Base64UrlUnpadded::decode_vec(s).map_err(|_| Base64UrlError::InvalidChar)
37}
38
39/// Decode and assert the resulting length.
40pub fn decode_strict_exact<const N: usize>(s: &str) -> Result<[u8; N], Base64UrlError> {
41    let bytes = decode_strict(s)?;
42    if bytes.len() != N {
43        return Err(Base64UrlError::InvalidLength);
44    }
45    let mut out = [0u8; N];
46    out.copy_from_slice(&bytes);
47    Ok(out)
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn rejects_padded_input() {
56        assert_eq!(
57            decode_strict("AA=="),
58            Err(Base64UrlError::PaddingNotAllowed)
59        );
60    }
61
62    #[test]
63    fn round_trips_arbitrary_bytes() {
64        let cases: &[&[u8]] = &[&[], b"a", b"hello", &[0xff; 32], &[0x00; 64]];
65        for input in cases {
66            let s = encode(input);
67            assert!(!s.contains('='));
68            assert_eq!(decode_strict(&s).unwrap(), *input);
69        }
70    }
71
72    #[test]
73    fn rejects_invalid_chars() {
74        assert!(matches!(
75            decode_strict("AA!A"),
76            Err(Base64UrlError::InvalidChar)
77        ));
78    }
79
80    #[test]
81    fn decode_strict_exact_enforces_length() {
82        let s = encode(&[0u8; 16]);
83        assert!(decode_strict_exact::<16>(&s).is_ok());
84        assert_eq!(
85            decode_strict_exact::<32>(&s),
86            Err(Base64UrlError::InvalidLength)
87        );
88    }
89}