use crate::{Error, Result, SecretBytes};
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const BITS_PER_CHAR: usize = 5;
const fn decode_char(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a'),
b'2'..=b'7' => Some(byte - b'2' + 26),
_ => None,
}
}
const fn output_len_for(count: usize) -> Option<usize> {
match count % 8 {
0 => Some(count / 8 * 5),
2 => Some(count / 8 * 5 + 1),
4 => Some(count / 8 * 5 + 2),
5 => Some(count / 8 * 5 + 3),
7 => Some(count / 8 * 5 + 4),
_ => None,
}
}
pub fn decode(input: &str) -> Result<SecretBytes> {
decode_inner(input, false)
}
pub fn decode_strict(input: &str) -> Result<SecretBytes> {
decode_inner(input, true)
}
fn decode_inner(input: &str, require_canonical: bool) -> Result<SecretBytes> {
let mut valid = 0_usize;
let mut seen_padding = false;
for (index, &byte) in input.as_bytes().iter().enumerate() {
if byte.is_ascii_whitespace() {
continue;
}
if byte == b'=' {
seen_padding = true;
continue;
}
if seen_padding {
return Err(Error::Base32InvalidCharacter { index });
}
if decode_char(byte).is_none() {
return Err(Error::Base32InvalidCharacter { index });
}
valid += 1;
}
let Some(byte_len) = output_len_for(valid) else {
return Err(Error::Base32InvalidLength { length: valid });
};
if byte_len == 0 {
return Err(Error::SecretTooShort {
required: 1,
actual: 0,
});
}
let mut out = Vec::with_capacity(byte_len);
let mut buffer: u64 = 0;
let mut buffered_bits = 0_u32;
for &byte in input.as_bytes() {
if byte.is_ascii_whitespace() || byte == b'=' {
continue;
}
let Some(value) = decode_char(byte) else {
return Err(Error::Base32InvalidCharacter { index: 0 });
};
buffer = (buffer << BITS_PER_CHAR) | u64::from(value);
buffered_bits += BITS_PER_CHAR as u32;
if buffered_bits >= 8 {
buffered_bits -= 8;
out.push((buffer >> buffered_bits) as u8);
}
}
if require_canonical && buffered_bits > 0 {
let residual_mask = (1_u64 << buffered_bits) - 1;
if buffer & residual_mask != 0 {
return Err(Error::Base32InvalidPadding);
}
}
debug_assert_eq!(
out.len(),
byte_len,
"the capacity calculation disagrees with the bytes actually written"
);
Ok(out.into())
}
#[must_use]
pub fn encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(5) * 8);
let mut buffer: u64 = 0;
let mut buffered_bits = 0_u32;
for &byte in data {
buffer = (buffer << 8) | u64::from(byte);
buffered_bits += 8;
while buffered_bits >= BITS_PER_CHAR as u32 {
buffered_bits -= BITS_PER_CHAR as u32;
let index = ((buffer >> buffered_bits) & 0x1f) as usize;
out.push(char::from(ALPHABET[index]));
}
}
if buffered_bits > 0 {
let index = ((buffer << (BITS_PER_CHAR as u32 - buffered_bits)) & 0x1f) as usize;
out.push(char::from(ALPHABET[index]));
}
while !out.len().is_multiple_of(8) {
out.push('=');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ExposeSecret;
#[test]
fn rfc4648_test_vectors() {
let vectors: &[(&str, &[u8])] = &[
("", b""),
("MY======", b"f"),
("MZXQ====", b"fo"),
("MZXW6===", b"foo"),
("MZXW6YQ=", b"foob"),
("MZXW6YTB", b"fooba"),
("MZXW6YTBOI======", b"foobar"),
];
for (encoded, expected) in vectors {
if expected.is_empty() {
assert!(
matches!(decode(encoded), Err(Error::SecretTooShort { .. })),
"empty input must be rejected"
);
continue;
}
let decoded =
decode(encoded).unwrap_or_else(|e| panic!("{encoded:?} failed to decode: {e}"));
assert_eq!(
decoded.expose_secret(),
*expected,
"{encoded:?} decoded to the wrong value"
);
}
}
#[test]
fn encode_matches_rfc4648_vectors() {
assert_eq!(encode(b"f"), "MY======");
assert_eq!(encode(b"fo"), "MZXQ====");
assert_eq!(encode(b"foo"), "MZXW6===");
assert_eq!(encode(b"foob"), "MZXW6YQ=");
assert_eq!(encode(b"fooba"), "MZXW6YTB");
assert_eq!(encode(b"foobar"), "MZXW6YTBOI======");
}
#[test]
fn accepts_lowercase() {
let upper = decode("MZXW6YTBOI======").expect("upper case must decode");
let lower = decode("mzxw6ytboi======").expect("lower case must decode");
assert_eq!(upper.expose_secret(), lower.expose_secret());
}
#[test]
fn tolerates_missing_padding() {
let padded = decode("MZXW6YTBOI======").expect("padded input must decode");
let unpadded = decode("MZXW6YTBOI").expect("unpadded input must decode");
assert_eq!(padded.expose_secret(), unpadded.expose_secret());
}
#[test]
fn tolerates_ascii_whitespace() {
let expected = decode("MZXW6YTBOI").expect("the baseline must decode");
for variant in [
" MZXW6YTBOI ",
"MZXW 6YTBOI",
"MZXW6YTB\nOI",
"MZ\tXW6YTBOI",
"MZ XW 6Y TB OI ",
] {
let got = decode(variant).unwrap_or_else(|e| panic!("{variant:?} must decode: {e}"));
assert_eq!(got.expose_secret(), expected.expose_secret(), "{variant:?}");
}
}
#[test]
fn rejects_characters_outside_alphabet() {
for bad in [
"MZXW6YTBO0======",
"MZXW6YTBO1======",
"MZXW6YTBO8======",
"MZXW6YTBO9======",
"MZXW6YTB-=",
"MZXW6YTB!",
"MZXW6YTBO\u{4e2d}",
] {
let result = decode(bad);
assert!(
matches!(result, Err(Error::Base32InvalidCharacter { .. })),
"{bad:?} must be rejected for an invalid character, got: {result:?}"
);
}
}
#[test]
fn rejects_invalid_lengths() {
for bad in ["M", "MZX", "MZXW6Y"] {
let result = decode(bad);
assert!(
matches!(result, Err(Error::Base32InvalidLength { .. })),
"{bad:?} must be rejected for an invalid length, got: {result:?}"
);
}
}
#[test]
fn rejects_data_after_padding() {
let result = decode("MZXW6YTB=OI");
assert!(
matches!(result, Err(Error::Base32InvalidCharacter { .. })),
"data after padding must be rejected, got: {result:?}"
);
}
#[test]
fn padding_bits_are_accepted_leniently_but_rejected_strictly() {
let lenient = decode("MZ").expect("lenient mode must accept non-canonical padding bits");
assert_eq!(lenient.expose_secret(), &[0x66]);
let strict = decode_strict("MZ");
assert!(
matches!(strict, Err(Error::Base32InvalidPadding)),
"strict mode must reject non-zero padding bits, got: {strict:?}"
);
}
#[test]
fn zero_padding_bits_accepted_in_both_modes() {
for input in ["MA", "ME", "MI", "MM", "MQ", "MU", "MY", "M4"] {
assert!(
decode(input).is_ok(),
"{input:?} must decode in lenient mode"
);
assert!(
decode_strict(input).is_ok(),
"{input:?} must decode in strict mode (zero residual bits)"
);
}
assert!(decode("ML").is_ok(), "lenient mode must still accept it");
assert!(
matches!(decode_strict("ML"), Err(Error::Base32InvalidPadding)),
"strict mode must reject ML"
);
}
#[test]
fn our_own_encoding_is_canonical() {
for len in 1_usize..=70 {
let data: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31)).collect();
let encoded = encode(&data);
let decoded = decode_strict(&encoded).unwrap_or_else(|e| {
panic!("our own encoding of length {len} is not canonical: {e}")
});
assert_eq!(decoded.expose_secret(), data.as_slice(), "length {len}");
}
}
#[test]
fn rejects_empty_and_padding_only() {
for bad in ["", " ", "========", "="] {
let result = decode(bad);
assert!(
matches!(result, Err(Error::SecretTooShort { .. })),
"{bad:?} must be rejected as an empty secret, got: {result:?}"
);
}
}
#[test]
fn roundtrip_realistic_seed_lengths() {
for len in [10_usize, 16, 20, 32, 64] {
let original: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(7)).collect();
let encoded = encode(&original);
let decoded = decode(&encoded)
.unwrap_or_else(|e| panic!("the round trip of length {len} failed: {e}"));
assert_eq!(decoded.expose_secret(), original.as_slice(), "length {len}");
}
}
#[test]
fn errors_do_not_embed_input() {
let secret_like = "JBSWY3DPEHPK3PXP0";
let Err(error) = decode(secret_like) else {
panic!("decoding must fail");
};
let text = format!("{error}");
assert!(
!text.contains("JBSWY3DPEHPK3PXP"),
"the error message leaks the seed content: {text}"
);
}
}