diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index 2385553..696b692 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -76,56 +76,108 @@ pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec, Vec items.push(item), - Err(w) => warnings.push(w), - } + let (item, warn) = map_row(&record, row_num); + if let Some(it) = item { items.push(it); } + if let Some(w) = warn { warnings.push(w); } } Ok((items, warnings)) } -/// Map a single CSV record to either an `Item` or an `ImportWarning`. -/// Column order is fixed by `EXPECTED_HEADER`. -fn map_row(record: &csv::StringRecord, row: usize) -> std::result::Result { +/// Map a single CSV record. Returns: +/// - `(Some(item), None)` for a fully-imported row. +/// - `(Some(item), Some(warn))` for a partially-imported row (e.g., +/// bad TOTP base32 — login imported without TOTP). +/// - `(None, Some(warn))` for a skipped row (missing required field). +fn map_row( + record: &csv::StringRecord, + row: usize, +) -> (Option, Option) { let url = record.get(0).unwrap_or("").trim(); let username = record.get(1).unwrap_or("").trim(); let password = record.get(2).unwrap_or(""); - let _totp = record.get(3).unwrap_or(""); // populated in Task 4 + let totp_raw = record.get(3).unwrap_or("").trim(); let extra = record.get(4).unwrap_or(""); let name = record.get(5).unwrap_or("").trim(); let group = record.get(6).unwrap_or("").trim(); let fav = record.get(7).unwrap_or("").trim(); if name.is_empty() { - return Err(ImportWarning { + return (None, Some(ImportWarning { row, title: None, message: "missing `name` — skipped".into(), - }); + })); } if password.is_empty() { - return Err(ImportWarning { + return (None, Some(ImportWarning { row, title: Some(name.to_string()), message: "missing `password` — skipped".into(), - }); + })); } let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; + let mut warning: Option = None; + 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 { + secret: Zeroizing::new(bytes), + algorithm: crate::item_types::TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: crate::item_types::TotpKind::Totp, + }), + _ => { + warning = Some(ImportWarning { + row, + title: Some(name.to_string()), + message: "invalid base32 TOTP secret — login imported without TOTP".into(), + }); + None + } + } + }; + let mut item = Item::new( name.to_string(), ItemCore::Login(LoginCore { username: if username.is_empty() { None } else { Some(username.to_string()) }, password: Some(Zeroizing::new(password.to_string())), url: parsed_url, - totp: None, + totp, }), ); item.group = if group.is_empty() { None } else { Some(group.to_string()) }; item.favorite = fav == "1"; item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) }; - Ok(item) + + (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/tests/import_lastpass.rs b/crates/relicario-core/tests/import_lastpass.rs index f79c0be..2d20e74 100644 --- a/crates/relicario-core/tests/import_lastpass.rs +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -1,6 +1,7 @@ //! LastPass CSV importer — parser coverage. use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning}; +use relicario_core::item_types::{TotpAlgorithm, TotpKind}; use relicario_core::ItemCore; const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav"; @@ -98,3 +99,59 @@ fn multiline_extra_round_trips_via_quoting() { assert!(warnings.is_empty(), "multi-line extra should parse cleanly"); assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3")); } + +#[test] +fn login_with_valid_totp_secret_attaches_config() { + // RFC 4648 base32 of b"12345678901234567890" → "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ". + let csv = format!( + "{HEADER}\n\ + https://github.com/login,alice,hunter2,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,,GitHub,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty()); + match &items[0].core { + ItemCore::Login(l) => { + let totp = l.totp.as_ref().expect("expected TOTP config"); + assert_eq!(totp.algorithm, TotpAlgorithm::Sha1); + assert_eq!(totp.digits, 6); + assert_eq!(totp.period_seconds, 30); + assert_eq!(totp.kind, TotpKind::Totp); + assert_eq!(totp.secret.as_slice(), b"12345678901234567890"); + } + other => panic!("expected Login, got {:?}", other), + } +} + +#[test] +fn login_with_bad_totp_secret_imports_without_totp_and_warns() { + let csv = format!( + "{HEADER}\n\ + https://github.com/login,alice,hunter2,!!!!not-base32!!!!,,GitHub,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items.len(), 1, "login should still import"); + match &items[0].core { + ItemCore::Login(l) => assert!(l.totp.is_none(), "TOTP must be dropped"), + other => panic!("expected Login, got {:?}", other), + } + assert_eq!(warnings.len(), 1); + let w = &warnings[0]; + assert_eq!(w.title.as_deref(), Some("GitHub")); + assert!(w.message.contains("TOTP"), "message: {}", w.message); + assert!(w.message.contains("invalid") || w.message.contains("base32")); +} + +#[test] +fn login_with_lowercase_base32_totp_is_accepted() { + // RFC 4648 is case-insensitive; LastPass exports may use either case. + let csv = format!( + "{HEADER}\n\ + https://x,u,p,gezdgnbvgy3tqojqgezdgnbvgy3tqojq,,Acme,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty(), "lowercase base32 must parse"); + match &items[0].core { + ItemCore::Login(l) => assert!(l.totp.is_some()), + _ => unreachable!(), + } +}