diff --git a/crates/relicario-cli/src/parse.rs b/crates/relicario-cli/src/parse.rs index cbf8084..06993d8 100644 --- a/crates/relicario-cli/src/parse.rs +++ b/crates/relicario-cli/src/parse.rs @@ -1,47 +1,19 @@ -//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess). -//! -//! Phase 7 of the CLI restructure migrates these to `relicario-core` and -//! turns this file into a thin re-export shim. They live here for now so -//! the Phase 1 relocation stays mechanical. +//! Thin shims over `relicario-core`'s migrated parsers, kept here so existing +//! CLI callsites need no import churn. Plan B Phase 7 moved the bodies into +//! `relicario_core::{time::MonthYear::parse, base32::decode_rfc4648_lenient, +//! mime::guess_for_extension}`. -use anyhow::{Context, Result}; +use anyhow::Result; +use relicario_core::MonthYear; -pub(crate) fn parse_month_year(s: &str) -> Result { - // Accepts MM/YYYY or MM-YYYY or MM/YY. - let (m_str, y_str) = s.split_once(['/', '-']) - .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; - let month: u8 = m_str.parse().context("invalid month")?; - let year: u16 = if y_str.len() == 2 { - 2000 + y_str.parse::().context("invalid 2-digit year")? - } else { - y_str.parse().context("invalid year")? - }; - Ok(relicario_core::MonthYear { month, year }) +pub(crate) fn parse_month_year(s: &str) -> Result { + Ok(MonthYear::parse(s)?) } pub(crate) fn guess_mime(filename: &str) -> String { - let lower = filename.to_ascii_lowercase(); - match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") { - "pdf" => "application/pdf", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "txt" => "text/plain", - "json" => "application/json", - _ => "application/octet-stream", - }.to_string() + relicario_core::mime::guess_for_extension(filename).to_string() } pub(crate) fn base32_decode_lenient(s: &str) -> Result> { - let cleaned: String = s.chars() - .filter(|c| !c.is_whitespace()) - .collect::() - .to_ascii_uppercase() - .trim_end_matches('=') - .to_string(); - let padded = { - let rem = cleaned.len() % 8; - if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) } - }; - data_encoding::BASE32.decode(padded.as_bytes()) - .map_err(|e| anyhow::anyhow!("invalid base32: {e}")) + Ok(relicario_core::base32::decode_rfc4648_lenient(s)?) } 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..8839eaf 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -123,6 +123,17 @@ 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), + + /// Card-expiry month/year string failed to parse. Emitted by + /// [`crate::time::MonthYear::parse`]. + #[error("invalid month/year: {0}")] + InvalidMonthYear(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..74d4d4f 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)] @@ -21,6 +24,14 @@ pub struct TotpCore { pub label: Option, } +impl TotpConfig { + /// Decode a base32-encoded TOTP secret (RFC 4648, lenient input) into the + /// canonical `Zeroizing>` form used in [`Self::secret`]. + pub fn parse_secret(s: &str) -> Result>> { + Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?)) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TotpConfig { /// Raw bytes of the TOTP secret (decoded from base32 when imported). diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 0d397c6..237a2d1 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -14,6 +14,8 @@ //! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and //! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02. //! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`. +//! - [`base32`] — RFC 4648 base32 codec used for TOTP secret encode/decode. +//! - [`mime`] — Filename-extension → MIME-type guess for attachment storage. //! - [`time`] — unix-seconds + `MonthYear` for card expiries. //! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the //! `ItemCore`/`ItemType` enums. @@ -46,6 +48,10 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; +pub mod base32; + +pub mod mime; + pub mod time; pub use time::{now_unix, MonthYear}; diff --git a/crates/relicario-core/src/mime.rs b/crates/relicario-core/src/mime.rs new file mode 100644 index 0000000..16f006e --- /dev/null +++ b/crates/relicario-core/src/mime.rs @@ -0,0 +1,49 @@ +//! Tiny extension → MIME map for the small set of file types Relicario +//! attaches today. Unknown extensions fall back to `application/octet-stream`. + +/// Guess a MIME type from a filename's extension. Case-insensitive. +pub fn guess_for_extension(filename: &str) -> &'static str { + let lower = filename.to_ascii_lowercase(); + match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "txt" => "text/plain", + "json" => "application/json", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_extensions_match() { + assert_eq!(guess_for_extension("doc.pdf"), "application/pdf"); + assert_eq!(guess_for_extension("photo.png"), "image/png"); + assert_eq!(guess_for_extension("photo.jpg"), "image/jpeg"); + assert_eq!(guess_for_extension("photo.jpeg"), "image/jpeg"); + assert_eq!(guess_for_extension("notes.txt"), "text/plain"); + assert_eq!(guess_for_extension("data.json"), "application/json"); + } + + #[test] + fn extension_match_is_case_insensitive() { + assert_eq!(guess_for_extension("doc.PDF"), "application/pdf"); + assert_eq!(guess_for_extension("photo.JPEG"), "image/jpeg"); + } + + #[test] + fn unknown_or_missing_extension_falls_back() { + assert_eq!(guess_for_extension("unknown.xyz"), "application/octet-stream"); + assert_eq!(guess_for_extension("noextension"), "application/octet-stream"); + assert_eq!(guess_for_extension(""), "application/octet-stream"); + } + + #[test] + fn uses_extension_after_last_dot() { + assert_eq!(guess_for_extension("path/to/file.pdf"), "application/pdf"); + assert_eq!(guess_for_extension("archive.tar.gz"), "application/octet-stream"); + } +} diff --git a/crates/relicario-core/src/time.rs b/crates/relicario-core/src/time.rs index 7ca263f..28cc1bf 100644 --- a/crates/relicario-core/src/time.rs +++ b/crates/relicario-core/src/time.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +use crate::error::{RelicarioError, Result}; + /// Current Unix timestamp in seconds. pub fn now_unix() -> i64 { chrono::Utc::now().timestamp() @@ -15,7 +17,7 @@ pub struct MonthYear { } impl MonthYear { - pub fn new(month: u8, year: u16) -> Result { + pub fn new(month: u8, year: u16) -> std::result::Result { if !(1..=12).contains(&month) { return Err("month must be 1..=12"); } @@ -24,6 +26,28 @@ impl MonthYear { } Ok(Self { month, year }) } + + /// Parse a card-expiry string. Accepts `MM/YYYY`, `MM-YYYY`, and `MM/YY` + /// (two-digit year is taken as 20YY). + pub fn parse(s: &str) -> Result { + let invalid = |detail: String| RelicarioError::InvalidMonthYear(detail); + let (m_str, y_str) = s + .split_once(['/', '-']) + .ok_or_else(|| invalid(format!("expected MM/YYYY, got {s:?}")))?; + let month: u8 = m_str + .parse() + .map_err(|_| invalid(format!("bad month {m_str:?}")))?; + let year: u16 = if y_str.len() == 2 { + 2000 + y_str + .parse::() + .map_err(|_| invalid(format!("bad 2-digit year {y_str:?}")))? + } else { + y_str + .parse() + .map_err(|_| invalid(format!("bad year {y_str:?}")))? + }; + Self::new(month, year).map_err(|e| invalid(e.into())) + } } #[cfg(test)] @@ -60,4 +84,30 @@ mod tests { let parsed: MonthYear = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, my); } + + #[test] + fn parse_accepts_mm_slash_yyyy_and_mm_dash_yyyy() { + assert_eq!(MonthYear::parse("01/2026").unwrap(), MonthYear::new(1, 2026).unwrap()); + assert_eq!(MonthYear::parse("12/2099").unwrap(), MonthYear::new(12, 2099).unwrap()); + assert_eq!(MonthYear::parse("07-2030").unwrap(), MonthYear::new(7, 2030).unwrap()); + } + + #[test] + fn parse_accepts_mm_slash_yy() { + assert_eq!(MonthYear::parse("01/26").unwrap(), MonthYear::new(1, 2026).unwrap()); + assert_eq!(MonthYear::parse("12/99").unwrap(), MonthYear::new(12, 2099).unwrap()); + } + + #[test] + fn parse_rejects_malformed() { + assert!(matches!( + MonthYear::parse("garbage"), + Err(RelicarioError::InvalidMonthYear(_)) + )); + assert!(MonthYear::parse("13/2026").is_err()); // bad month + assert!(MonthYear::parse("01/1999").is_err()); // pre-2000 + assert!(MonthYear::parse("01/2100").is_err()); // post-2099 + assert!(MonthYear::parse("/2026").is_err()); // empty month + assert!(MonthYear::parse("01/").is_err()); // empty year + } } diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 66825e7..f6c6b80 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -330,6 +330,32 @@ pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result, JsEr imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string())) } +// ── Pure parsers (no session needed) ──────────────────────────────────────── + +use relicario_core::{base32 as core_base32, mime as core_mime, MonthYear}; + +/// Parse a card-expiry string (`MM/YYYY` / `MM-YYYY` / `MM/YY`). +/// Returns a plain `{ month, year }` object on success. +#[wasm_bindgen] +pub fn parse_month_year(s: &str) -> Result { + let my = MonthYear::parse(s).map_err(|e| JsError::new(&e.to_string()))?; + js_value_for(&my) +} + +/// Decode an RFC 4648 base32 string (case-insensitive, optional padding, +/// whitespace-stripped). Returned as `Uint8Array` on the JS side. +#[wasm_bindgen] +pub fn base32_decode_lenient(s: &str) -> Result, JsError> { + core_base32::decode_rfc4648_lenient(s).map_err(|e| JsError::new(&e.to_string())) +} + +/// Guess a MIME type from a filename's extension. Returns +/// `application/octet-stream` for unknown or missing extensions. +#[wasm_bindgen] +pub fn guess_mime(filename: &str) -> String { + core_mime::guess_for_extension(filename).to_string() +} + use relicario_core::item_types::{TotpConfig, compute_totp_code}; #[wasm_bindgen] @@ -624,4 +650,24 @@ mod session_tests { // Should fail with a header validation error. assert!(err.is_err()); } + + #[test] + fn base32_decode_lenient_round_trips_known_vector() { + let bytes = super::base32_decode_lenient("MZXW6YTBOI").unwrap(); + assert_eq!(bytes, b"foobar"); + } + + #[test] + fn guess_mime_known_and_unknown_extensions() { + assert_eq!(super::guess_mime("doc.pdf"), "application/pdf"); + assert_eq!(super::guess_mime("photo.JPEG"), "image/jpeg"); + assert_eq!(super::guess_mime("file.xyz"), "application/octet-stream"); + } + + // Error paths and JsValue serialization can't be exercised natively — + // JsError::new and serde_wasm_bindgen::Serializer call wasm-bindgen + // imports that panic off-wasm (same constraint as + // `parse_lastpass_csv_json_propagates_header_errors` above). Those + // paths are covered in core: `time::tests::parse_rejects_malformed` + // and `base32::tests::decode_rfc4648_lenient_rejects_non_alphabet_chars`. } diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 3643996..2553999 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -59,6 +59,11 @@ declare module 'relicario-wasm' { export function extract_image_secret(image_bytes: Uint8Array): Uint8Array; export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array; + // Pure parsers (no session needed) + export function parse_month_year(s: string): { month: number; year: number }; + export function base32_decode_lenient(s: string): Uint8Array; + export function guess_mime(filename: string): string; + export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode; export function register_device(name: string): {