feat(core): import_lastpass — TOTP base32 → TotpConfig
Successful base32 decode attaches a SHA1/6/30s Totp config to LoginCore.totp. Bad base32 emits a warning and imports the login without TOTP rather than skipping the row entirely. Refactors map_row to return (Option<Item>, Option<ImportWarning>) so a single row can produce both an item and a warning. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -76,56 +76,108 @@ pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec<Item>, Vec<ImportWarn
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match map_row(&record, row_num) {
|
let (item, warn) = map_row(&record, row_num);
|
||||||
Ok(item) => items.push(item),
|
if let Some(it) = item { items.push(it); }
|
||||||
Err(w) => warnings.push(w),
|
if let Some(w) = warn { warnings.push(w); }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((items, warnings))
|
Ok((items, warnings))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a single CSV record to either an `Item` or an `ImportWarning`.
|
/// Map a single CSV record. Returns:
|
||||||
/// Column order is fixed by `EXPECTED_HEADER`.
|
/// - `(Some(item), None)` for a fully-imported row.
|
||||||
fn map_row(record: &csv::StringRecord, row: usize) -> std::result::Result<Item, ImportWarning> {
|
/// - `(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<Item>, Option<ImportWarning>) {
|
||||||
let url = record.get(0).unwrap_or("").trim();
|
let url = record.get(0).unwrap_or("").trim();
|
||||||
let username = record.get(1).unwrap_or("").trim();
|
let username = record.get(1).unwrap_or("").trim();
|
||||||
let password = record.get(2).unwrap_or("");
|
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 extra = record.get(4).unwrap_or("");
|
||||||
let name = record.get(5).unwrap_or("").trim();
|
let name = record.get(5).unwrap_or("").trim();
|
||||||
let group = record.get(6).unwrap_or("").trim();
|
let group = record.get(6).unwrap_or("").trim();
|
||||||
let fav = record.get(7).unwrap_or("").trim();
|
let fav = record.get(7).unwrap_or("").trim();
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(ImportWarning {
|
return (None, Some(ImportWarning {
|
||||||
row,
|
row,
|
||||||
title: None,
|
title: None,
|
||||||
message: "missing `name` — skipped".into(),
|
message: "missing `name` — skipped".into(),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if password.is_empty() {
|
if password.is_empty() {
|
||||||
return Err(ImportWarning {
|
return (None, Some(ImportWarning {
|
||||||
row,
|
row,
|
||||||
title: Some(name.to_string()),
|
title: Some(name.to_string()),
|
||||||
message: "missing `password` — skipped".into(),
|
message: "missing `password` — skipped".into(),
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() };
|
let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() };
|
||||||
|
|
||||||
|
let mut warning: Option<ImportWarning> = 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(
|
let mut item = Item::new(
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
ItemCore::Login(LoginCore {
|
ItemCore::Login(LoginCore {
|
||||||
username: if username.is_empty() { None } else { Some(username.to_string()) },
|
username: if username.is_empty() { None } else { Some(username.to_string()) },
|
||||||
password: Some(Zeroizing::new(password.to_string())),
|
password: Some(Zeroizing::new(password.to_string())),
|
||||||
url: parsed_url,
|
url: parsed_url,
|
||||||
totp: None,
|
totp,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
|
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
|
||||||
item.favorite = fav == "1";
|
item.favorite = fav == "1";
|
||||||
item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) };
|
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<Vec<u8>> {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! LastPass CSV importer — parser coverage.
|
//! LastPass CSV importer — parser coverage.
|
||||||
|
|
||||||
use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning};
|
use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
use relicario_core::item_types::{TotpAlgorithm, TotpKind};
|
||||||
use relicario_core::ItemCore;
|
use relicario_core::ItemCore;
|
||||||
|
|
||||||
const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav";
|
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!(warnings.is_empty(), "multi-line extra should parse cleanly");
|
||||||
assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3"));
|
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!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user