diff --git a/crates/relicario-core/src/base32.rs b/crates/relicario-core/src/base32.rs new file mode 100644 index 0000000..a7057b3 --- /dev/null +++ b/crates/relicario-core/src/base32.rs @@ -0,0 +1,132 @@ +//! RFC 4648 base32 codec, no-padding form, lenient on input. +//! +//! The encoder produces canonical no-padding RFC 4648 output (uppercase ASCII). +//! The decoder is lenient: case-insensitive, optional `=` padding, whitespace +//! anywhere is stripped before decoding. +//! +//! Steam Guard's authenticator uses a different (de-ambiguated) alphabet — +//! see `crate::item_types::totp::STEAM_ALPHABET`. That codec is intentionally +//! NOT routed through this module. + +use crate::error::{RelicarioError, Result}; + +const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// RFC 4648 base32 encoder, no-padding form. Output is uppercase ASCII. +pub fn encode_rfc4648(bytes: &[u8]) -> String { + let mut out = String::new(); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for &b in bytes { + buffer = (buffer << 8) | (b as u32); + bits += 8; + while bits >= 5 { + let idx = ((buffer >> (bits - 5)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + bits -= 5; + } + } + if bits > 0 { + let idx = ((buffer << (5 - bits)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + } + out +} + +/// RFC 4648 base32 decoder, lenient on input. +/// +/// Accepts upper- or lower-case letters, optional `=` padding, and whitespace +/// anywhere. Trailing bits less than a full byte are silently discarded +/// (canonical RFC 4648 decode). +pub fn decode_rfc4648_lenient(s: &str) -> Result> { + let cleaned: String = s + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_uppercase(); + let trimmed = cleaned.trim_end_matches('='); + let mut out: Vec = Vec::with_capacity(trimmed.len() * 5 / 8); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for ch in trimmed.bytes() { + let idx = ALPHA.iter().position(|&a| a == ch).ok_or_else(|| { + RelicarioError::InvalidBase32(format!("non-alphabet character {:?}", ch as char)) + })?; + buffer = (buffer << 5) | (idx as u32); + bits += 5; + if bits >= 8 { + bits -= 8; + out.push(((buffer >> bits) & 0xff) as u8); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_rfc4648_matches_rfc_test_vectors() { + // RFC 4648 §10 test vectors, no-padding form. + assert_eq!(encode_rfc4648(b""), ""); + assert_eq!(encode_rfc4648(b"f"), "MY"); + assert_eq!(encode_rfc4648(b"fo"), "MZXQ"); + assert_eq!(encode_rfc4648(b"foo"), "MZXW6"); + assert_eq!(encode_rfc4648(b"foob"), "MZXW6YQ"); + assert_eq!(encode_rfc4648(b"fooba"), "MZXW6YTB"); + assert_eq!(encode_rfc4648(b"foobar"), "MZXW6YTBOI"); + } + + #[test] + fn decode_rfc4648_lenient_inverts_encoder_on_known_vectors() { + let cases: &[(&str, &[u8])] = &[ + ("", b""), + ("MY", b"f"), + ("MZXQ", b"fo"), + ("MZXW6", b"foo"), + ("MZXW6YQ", b"foob"), + ("MZXW6YTB", b"fooba"), + ("MZXW6YTBOI", b"foobar"), + ]; + for (s, want) in cases { + assert_eq!(&decode_rfc4648_lenient(s).unwrap()[..], *want); + } + } + + #[test] + fn decode_rfc4648_lenient_accepts_lowercase_and_mixed_case() { + assert_eq!(decode_rfc4648_lenient("mzxw6").unwrap(), b"foo"); + assert_eq!(decode_rfc4648_lenient("MzXw6yTbOi").unwrap(), b"foobar"); + } + + #[test] + fn decode_rfc4648_lenient_strips_optional_padding() { + assert_eq!(decode_rfc4648_lenient("MY======").unwrap(), b"f"); + assert_eq!(decode_rfc4648_lenient("MZXW6===").unwrap(), b"foo"); + assert_eq!(decode_rfc4648_lenient("MZXW6YTBOI======").unwrap(), b"foobar"); + } + + #[test] + fn decode_rfc4648_lenient_strips_whitespace_anywhere() { + assert_eq!(decode_rfc4648_lenient(" MZXW 6YTB OI ").unwrap(), b"foobar"); + assert_eq!(decode_rfc4648_lenient("MZXW\n6YTB\tOI").unwrap(), b"foobar"); + } + + #[test] + fn decode_rfc4648_lenient_rejects_non_alphabet_chars() { + assert!(matches!( + decode_rfc4648_lenient("MY1"), + Err(RelicarioError::InvalidBase32(_)) + )); + assert!(decode_rfc4648_lenient("???").is_err()); + assert!(decode_rfc4648_lenient("MZ!XW").is_err()); + } + + #[test] + fn encode_decode_round_trips_arbitrary_bytes() { + let bytes: Vec = (0u8..=255).collect(); + let encoded = encode_rfc4648(&bytes); + assert_eq!(decode_rfc4648_lenient(&encoded).unwrap(), bytes); + } +} diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index 076d1f3..059c443 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -123,6 +123,12 @@ pub enum RelicarioError { /// Recovery QR generation or parsing failed. #[error("recovery QR: {0}")] RecoveryQr(String), + + /// Base32 decoding failed (non-alphabet character or other malformed + /// input). Emitted by [`crate::base32::decode_rfc4648_lenient`] and any + /// typed wrappers that delegate to it. + #[error("invalid base32: {0}")] + InvalidBase32(String), } /// Crate-wide result alias, reducing boilerplate in function signatures. diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index 23f2be4..6ad69eb 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -158,8 +158,8 @@ fn map_row( let totp = if totp_raw.is_empty() { None } else { - match decode_base32_totp(totp_raw) { - Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { + match crate::base32::decode_rfc4648_lenient(totp_raw) { + Ok(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { secret: Zeroizing::new(bytes), algorithm: crate::item_types::TotpAlgorithm::Sha1, digits: 6, @@ -196,25 +196,3 @@ fn map_row( (Some(item), warning) } -/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive, -/// padding optional. Returns None if the input contains any non-alphabet -/// character (after upper-casing). Used by the LastPass importer. -fn decode_base32_totp(secret: &str) -> Option> { - const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase(); - if upper.is_empty() { return None; } - - let mut out = Vec::with_capacity(upper.len() * 5 / 8); - let mut buffer: u32 = 0; - let mut bits: u32 = 0; - for ch in upper.bytes() { - let idx = ALPHA.iter().position(|&a| a == ch)?; - buffer = (buffer << 5) | (idx as u32); - bits += 5; - if bits >= 8 { - bits -= 8; - out.push(((buffer >> bits) & 0xFF) as u8); - } - } - Some(out) -} diff --git a/crates/relicario-core/src/item.rs b/crates/relicario-core/src/item.rs index fa65613..ca14c84 100644 --- a/crates/relicario-core/src/item.rs +++ b/crates/relicario-core/src/item.rs @@ -244,7 +244,7 @@ fn serialize_history_value(value: &FieldValue) -> Result> { FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()), FieldValue::Totp(cfg) => { // Store the base32-encoded secret string for human-recognizability. - let s = base32_encode(&cfg.secret); + let s = crate::base32::encode_rfc4648(&cfg.secret); Zeroizing::new(s) } _ => return Err(RelicarioError::Format("not a history-tracked kind".into())), @@ -252,28 +252,6 @@ fn serialize_history_value(value: &FieldValue) -> Result> { Ok(s) } -/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization. -fn base32_encode(bytes: &[u8]) -> String { - const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - let mut out = String::new(); - let mut buffer: u32 = 0; - let mut bits: u32 = 0; - for &b in bytes { - buffer = (buffer << 8) | (b as u32); - bits += 8; - while bits >= 5 { - let idx = ((buffer >> (bits - 5)) & 0x1f) as usize; - out.push(ALPHA[idx] as char); - bits -= 5; - } - } - if bits > 0 { - let idx = ((buffer << (5 - bits)) & 0x1f) as usize; - out.push(ALPHA[idx] as char); - } - out -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 83c1052..0c16e32 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -10,6 +10,9 @@ use crate::error::{RelicarioError, Result}; /// Steam Mobile Authenticator's 5-character output alphabet. /// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z). +// +// Not RFC 4648 — Steam Guard's de-ambiguated alphabet; see `crate::base32` +// for the standard implementation. const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 0d397c6..c4b3030 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -46,6 +46,8 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; +pub(crate) mod base32; + pub mod time; pub use time::{now_unix, MonthYear};