// 保險庫 storage: logins, cards and contacts in one encrypted file on this computer // (vault.bin = 12-byte nonce + AES-256-GCM of the JSON). The key is random and lives in the // system keychain (macOS Keychain / Linux Secret Service); without one, a 0600 key file. // // Like the original (whose vault is server-side and never sends secrets back), secrets are // write-only: `list()` returns items with passwords, card numbers and security codes blanked. // Only the fill step (vault.rs, typing into AnyBot's browser after the person allows it) reads // them, through `login_secret` / `card_secret`. use ring::aead; use ring::rand::{SecureRandom, SystemRandom}; use serde::{Deserialize, Serialize}; use std::process::Command; pub const MAX_SITES: usize = 10; pub const MAX_NOTES: usize = 2000; pub const MAX_TITLE: usize = 200; pub const MAX_USERNAME: usize = 512; pub const MAX_PASSWORD_BYTES: usize = 4096; pub const MAX_ROWS: usize = 10; pub const MAX_IMPORT_BYTES: u64 = 5 * 1024 * 1024; #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)] pub enum Kind { #[default] Login, Card, Contact, } #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] pub struct Address { #[serde(default)] pub line1: String, #[serde(default)] pub line2: String, #[serde(default)] pub city: String, #[serde(default)] pub region: String, #[serde(default)] pub postal: String, #[serde(default)] pub country: String, } impl Address { pub fn is_empty(&self) -> bool { [&self.line1, &self.line2, &self.city, &self.region, &self.postal, &self.country].iter().all(|s| s.trim().is_empty()) } pub fn one_line(&self) -> String { [&self.line1, &self.line2, &self.city, &self.region, &self.postal, &self.country].iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::>().join(", ") } } #[derive(Serialize, Deserialize, Clone, Default, Debug)] pub struct Item { pub id: String, pub kind: Kind, /// Login: the site's name; card: 暱稱 or brand; contact: 標籤. #[serde(default)] pub title: String, /// 提示: tells the bot when to use this item. #[serde(default)] pub notes: String, #[serde(default)] pub created_ms: i64, #[serde(default)] pub updated_ms: i64, // Login #[serde(default)] pub sites: Vec, #[serde(default)] pub username: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub password: String, // Card (number and csc are secret; brand/last4 are what anyone sees) #[serde(default, skip_serializing_if = "String::is_empty")] pub number: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub csc: String, #[serde(default)] pub brand: String, #[serde(default)] pub last4: String, #[serde(default)] pub exp_month: u32, #[serde(default)] pub exp_year: u32, #[serde(default)] pub holder: String, #[serde(default)] pub nickname: String, #[serde(default)] pub billing: Address, // Contact #[serde(default)] pub name: String, #[serde(default)] pub address: Address, #[serde(default)] pub phones: Vec, #[serde(default)] pub emails: Vec, } impl Item { /// The same item without its secrets. pub fn public(&self) -> Item { Item { password: String::new(), number: String::new(), csc: String::new(), ..self.clone() } } /// What a row shows as its name. pub fn display_title(&self) -> String { match self.kind { Kind::Login => { if !self.title.trim().is_empty() { self.title.clone() } else { self.sites.first().map(|s| registrable_domain(&host_of(s))).unwrap_or_default() } } Kind::Card => { if !self.nickname.trim().is_empty() { self.nickname.clone() } else if !self.title.trim().is_empty() { self.title.clone() } else { self.brand.clone() } } Kind::Contact => { if !self.title.trim().is_empty() { self.title.clone() } else { self.name.clone() } } } } /// The line under the title. pub fn subtitle(&self) -> String { match self.kind { Kind::Login => self.username.clone(), Kind::Card => format!("{} •••• {}", self.brand, self.last4).trim().to_string(), Kind::Contact => [self.name.clone(), self.address.one_line(), self.phones.first().cloned().unwrap_or_default()].into_iter().filter(|s| !s.is_empty()).collect::>().join(" · "), } } /// Lowercased text the search box matches. pub fn haystack(&self) -> String { let mut v = vec![self.display_title(), self.notes.clone(), self.username.clone(), self.name.clone(), self.nickname.clone(), self.holder.clone(), self.brand.clone(), self.last4.clone(), self.address.one_line()]; v.extend(self.sites.iter().cloned()); v.extend(self.phones.iter().cloned()); v.extend(self.emails.iter().cloned()); v.join("\n").to_lowercase() } /// This login fills on `host` (its sites' registrable domains and their subdomains). pub fn fills_on(&self, host: &str) -> bool { let h = host.trim().to_lowercase(); let h = h.strip_prefix("www.").unwrap_or(&h); self.sites.iter().any(|s| { let d = registrable_domain(&host_of(s)); !d.is_empty() && (h == d || h.ends_with(&format!(".{d}"))) }) } } #[derive(Serialize, Deserialize, Default, Clone)] pub struct Store { #[serde(default)] pub items: Vec, /// AnyBot's old Keychain logins were brought in. #[serde(default)] pub imported_keychain: bool, } // ---------- helpers ---------- pub fn now_ms() -> i64 { chrono::Utc::now().timestamp_millis() } pub fn new_id() -> String { let mut b = [0u8; 8]; let _ = SystemRandom::new().fill(&mut b); b.iter().map(|x| format!("{x:02x}")).collect() } /// "https://Accounts.Example.com/login" or "example.com" → "accounts.example.com". pub fn host_of(s: &str) -> String { let s = s.trim(); let rest = s.split("://").nth(1).unwrap_or(s); let h = rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase(); h.strip_prefix("www.").map(String::from).unwrap_or(h) } /// Second-level suffixes where the registrable domain has three labels. const TWO_PART_SUFFIXES: &[&str] = &[ "com.tw", "org.tw", "net.tw", "edu.tw", "gov.tw", "idv.tw", "co.uk", "org.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.cn", "net.cn", "org.cn", "com.hk", "org.hk", "com.sg", "com.au", "net.au", "org.au", "co.nz", "co.kr", "or.kr", "com.br", "com.mx", "co.in", "co.za", "com.my", "com.ph", "co.id", "co.th", "com.vn", "github.io", "vercel.app", "netlify.app", ]; /// "login.shop.example.co.uk" → "example.co.uk"; IP addresses and single labels stay as they are. pub fn registrable_domain(host: &str) -> String { let h = host.trim().trim_end_matches('.').to_lowercase(); if h.parse::().is_ok() { return h; } let parts: Vec<&str> = h.split('.').filter(|p| !p.is_empty()).collect(); if parts.len() <= 2 { return parts.join("."); } let last2 = parts[parts.len() - 2..].join("."); let n = if TWO_PART_SUFFIXES.contains(&last2.as_str()) { 3 } else { 2 }; parts[parts.len() - n.min(parts.len())..].join(".") } pub fn digits(s: &str) -> String { s.chars().filter(|c| c.is_ascii_digit()).collect() } pub fn luhn_ok(number: &str) -> bool { let d = digits(number); if d.len() < 12 || d.len() > 19 { return false; } let sum: u32 = d.chars().rev().enumerate().map(|(i, c)| { let mut n = c.to_digit(10).unwrap_or(0); if i % 2 == 1 { n *= 2; if n > 9 { n -= 9; } } n }).sum(); sum % 10 == 0 } pub fn card_brand(number: &str) -> &'static str { let d = digits(number); let p2: u32 = d.get(..2).and_then(|x| x.parse().ok()).unwrap_or(0); let p4: u32 = d.get(..4).and_then(|x| x.parse().ok()).unwrap_or(0); if d.starts_with('4') { "Visa" } else if (51..=55).contains(&p2) || (2221..=2720).contains(&p4) { "Mastercard" } else if p2 == 34 || p2 == 37 { "American Express" } else if d.starts_with("6011") || d.starts_with("65") || (644..=649).contains(&(p4 / 10)) { "Discover" } else if (3528..=3589).contains(&p4) { "JCB" } else if d.starts_with("62") { "UnionPay" } else if p2 == 36 || p2 == 38 || (300..=305).contains(&(p4 / 10)) { "Diners Club" } else { "卡片" } } /// "MM/YY", "MM/YYYY", "MM-YY" → (month, 4-digit year). pub fn parse_expiry(s: &str) -> Option<(u32, u32)> { let parts: Vec<&str> = s.split(['/', '-', ' ', '.']).filter(|p| !p.is_empty()).collect(); let (m, y) = match parts.as_slice() { [m, y] => (*m, *y), [one] if one.len() == 4 => (&one[..2], &one[2..]), _ => return None, }; let m: u32 = m.trim().parse().ok()?; let mut y: u32 = y.trim().parse().ok()?; if y < 100 { y += 2000; } ((1..=12).contains(&m) && (2000..=2099).contains(&y)).then_some((m, y)) } pub fn expired(month: u32, year: u32) -> bool { use chrono::Datelike; let now = chrono::Local::now(); (year as i32, month) < (now.year(), now.month()) } pub fn valid_email(s: &str) -> bool { let s = s.trim(); let Some((a, b)) = s.split_once('@') else { return false }; !a.is_empty() && b.contains('.') && !b.starts_with('.') && !b.ends_with('.') && !s.contains(' ') } pub fn valid_phone(s: &str) -> bool { let d = digits(s); (6..=16).contains(&d.len()) && s.chars().all(|c| c.is_ascii_digit() || " +-().#".contains(c)) } // ---------- the key and the file ---------- const KEY_SERVICE: &str = "AnyBot vault key"; fn data_dir() -> std::path::PathBuf { if let Ok(d) = std::env::var("ANYBOT_VAULT_DIR") { return std::path::PathBuf::from(d); } crate::model::State::path().parent().map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir) } pub fn file_path() -> std::path::PathBuf { data_dir().join("vault.bin") } fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } fn unhex(s: &str) -> Option> { let s = s.trim(); (s.len() % 2 == 0).then_some(())?; (0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()).collect() } /// Read the key from wherever it lives (None = no key yet). fn read_key() -> Option> { if let Ok(f) = std::env::var("ANYBOT_VAULT_KEY_FILE") { return unhex(&std::fs::read_to_string(f).ok()?); } #[cfg(target_os = "macos")] { let o = Command::new("security").args(["find-generic-password", "-s", KEY_SERVICE, "-a", "anybot", "-w"]).output().ok()?; if o.status.success() { return unhex(&String::from_utf8_lossy(&o.stdout)); } } #[cfg(target_os = "linux")] { let o = Command::new("secret-tool").env("LC_ALL", "C.UTF-8").args(["lookup", "application", "anybot", "kind", "vault-key"]).output().ok(); if let Some(o) = o.filter(|o| o.status.success() && !o.stdout.is_empty()) { return unhex(&String::from_utf8_lossy(&o.stdout)); } } let f = data_dir().join("vault.key"); unhex(&std::fs::read_to_string(f).ok()?) } fn store_key(key: &[u8]) -> Result<(), String> { let h = hex(key); if let Ok(f) = std::env::var("ANYBOT_VAULT_KEY_FILE") { return std::fs::write(f, h).map_err(|e| e.to_string()); } #[cfg(target_os = "macos")] { let o = Command::new("security").args(["add-generic-password", "-U", "-s", KEY_SERVICE, "-a", "anybot", "-l", KEY_SERVICE, "-w", &h]).output().map_err(|e| e.to_string())?; if o.status.success() { return Ok(()); } } #[cfg(target_os = "linux")] { use std::io::Write; if let Ok(mut c) = Command::new("secret-tool").env("LC_ALL", "C.UTF-8").args(["store", "--label", KEY_SERVICE, "application", "anybot", "kind", "vault-key"]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let ok = c.stdin.take().map(|mut s| s.write_all(h.as_bytes()).is_ok()).unwrap_or(false); if ok && c.wait().map(|s| s.success()).unwrap_or(false) { return Ok(()); } } } // No keychain (Windows for now, or a Linux box without one): a file only this user can read. let f = data_dir().join("vault.key"); let _ = std::fs::create_dir_all(data_dir()); std::fs::write(&f, h).map_err(|e| e.to_string())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)); } Ok(()) } fn key(create: bool) -> Result { let k = match read_key() { Some(k) if k.len() == 32 => k, _ if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()), _ => { let mut k = vec![0u8; 32]; SystemRandom::new().fill(&mut k).map_err(|_| "產生金鑰失敗")?; store_key(&k)?; k } }; let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, &k).map_err(|_| "金鑰格式不對")?; Ok(aead::LessSafeKey::new(unbound)) } static CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); /// The whole store (decrypted, secrets included) — only for this module and the fill step. fn load() -> Result { if let Some(s) = CACHE.lock().unwrap().as_ref() { return Ok(s.clone()); } let path = file_path(); let store = if !path.exists() { Store::default() } else { let mut bytes = std::fs::read(&path).map_err(|e| e.to_string())?; if bytes.len() < 12 { return Err("保險庫檔案損壞".into()); } let k = key(false)?; let nonce = aead::Nonce::try_assume_unique_for_key(&bytes[..12]).map_err(|_| "保險庫檔案損壞")?; let mut body = bytes.split_off(12); let plain = k.open_in_place(nonce, aead::Aad::from(b"anybot-vault-v1"), &mut body).map_err(|_| "保險庫解不開(金鑰不符)")?; serde_json::from_slice(plain).map_err(|e| e.to_string())? }; *CACHE.lock().unwrap() = Some(store.clone()); Ok(store) } fn save(store: &Store) -> Result<(), String> { let k = key(true)?; let mut nonce = [0u8; 12]; SystemRandom::new().fill(&mut nonce).map_err(|_| "產生亂數失敗")?; let mut body = serde_json::to_vec(store).map_err(|e| e.to_string())?; k.seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::from(b"anybot-vault-v1"), &mut body).map_err(|_| "加密失敗")?; let mut out = nonce.to_vec(); out.extend(body); let _ = std::fs::create_dir_all(data_dir()); let tmp = file_path().with_extension("bin.tmp"); std::fs::write(&tmp, &out).map_err(|e| e.to_string())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)); } std::fs::rename(&tmp, file_path()).map_err(|e| e.to_string())?; *CACHE.lock().unwrap() = Some(store.clone()); Ok(()) } fn edit(f: impl FnOnce(&mut Store) -> Result) -> Result { let mut s = load()?; let r = f(&mut s)?; save(&s)?; Ok(r) } /// Bring in logins AnyBot saved in the Keychain before the vault file existed (once). fn import_keychain_once() { let Ok(s) = load() else { return }; if s.imported_keychain { return; } let old = crate::vault::saved_sites(); let mut logins = vec![]; for (site, account) in old { if let Some(l) = crate::vault::keychain_login(&site, &account) { logins.push((site, l.username, l.password)); } } let _ = edit(|s| { for (site, user, pw) in logins { let dup = s.items.iter().any(|i| i.kind == Kind::Login && i.fills_on(&site) && i.username.eq_ignore_ascii_case(&user)); if !dup { let t = now_ms(); s.items.push(Item { id: new_id(), kind: Kind::Login, title: registrable_domain(&site), sites: vec![site], username: user, password: pw, created_ms: t, updated_ms: t, ..Default::default() }); } } s.imported_keychain = true; Ok(()) }); } // ---------- what the window and the bot use ---------- /// Every item without secrets, newest first. pub fn list() -> Result, String> { import_keychain_once(); let mut v: Vec = load()?.items.iter().map(Item::public).collect(); v.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); Ok(v) } pub fn get(id: &str) -> Option { load().ok()?.items.iter().find(|i| i.id == id).map(Item::public) } pub struct LoginInput { pub id: Option, pub sites: Vec, pub username: String, /// Empty when editing = keep the saved password. pub password: String, pub notes: String, } /// Check and save a login; returns its id. pub fn put_login(input: LoginInput) -> Result { let sites: Vec = input.sites.iter().map(|s| host_of(s)).filter(|s| !s.is_empty()).collect(); if sites.is_empty() { return Err("請至少填一個網站".into()); } if sites.len() > MAX_SITES { return Err(format!("網站最多 {MAX_SITES} 個")); } if input.username.chars().count() > MAX_USERNAME || input.notes.chars().count() > MAX_NOTES || input.password.len() > MAX_PASSWORD_BYTES { return Err("某個欄位過長".into()); } edit(|s| { let t = now_ms(); let title = registrable_domain(&sites[0]); match input.id.as_ref().and_then(|id| s.items.iter_mut().find(|i| &i.id == id)) { Some(it) => { it.sites = sites; it.username = input.username.trim().to_string(); if !input.password.is_empty() { it.password = input.password; } it.notes = input.notes; it.title = title; it.updated_ms = t; Ok(it.id.clone()) } None => { if input.password.is_empty() { return Err("請填密碼".into()); } let id = new_id(); s.items.push(Item { id: id.clone(), kind: Kind::Login, title, sites, username: input.username.trim().to_string(), password: input.password, notes: input.notes, created_ms: t, updated_ms: t, ..Default::default() }); Ok(id) } } }) } /// Add another site to a login (the person allowed it on a new domain). pub fn add_site(id: &str, site: &str) -> Result<(), String> { let host = host_of(site); edit(|s| { let it = s.items.iter_mut().find(|i| i.id == id).ok_or("找不到這筆登入資訊")?; if !it.fills_on(&host) && it.sites.len() < MAX_SITES { it.sites.push(registrable_domain(&host)); it.updated_ms = now_ms(); } Ok(()) }) } pub struct CardInput { pub id: Option, /// New cards only (the number is locked once saved). pub number: String, pub expiry: String, pub csc: String, pub holder: String, pub nickname: String, pub billing: Address, pub notes: String, } pub fn put_card(input: CardInput) -> Result { let (m, y) = parse_expiry(&input.expiry).ok_or("有效期限要寫成 月 / 年,例如 08/29")?; if input.id.is_none() && expired(m, y) { return Err("卡片已過期".into()); } if input.holder.chars().count() > MAX_TITLE || input.nickname.chars().count() > MAX_TITLE || input.notes.chars().count() > MAX_NOTES { return Err("某個欄位過長".into()); } edit(|s| { let t = now_ms(); match input.id.as_ref() { Some(id) => { let it = s.items.iter_mut().find(|i| &i.id == id).ok_or("找不到這張卡片")?; it.exp_month = m; it.exp_year = y; it.holder = input.holder.trim().to_string(); it.nickname = input.nickname.trim().to_string(); it.billing = input.billing; it.notes = input.notes; it.updated_ms = t; Ok(it.id.clone()) } None => { let number = digits(&input.number); if !luhn_ok(&number) { return Err("請輸入有效的卡號".into()); } let csc = digits(&input.csc); if !csc.is_empty() && !(3..=4).contains(&csc.len()) { return Err("安全碼是 3 或 4 位數字".into()); } if s.items.iter().any(|i| i.kind == Kind::Card && i.number == number) { return Err("這張卡片已在你的保險庫中".into()); } let id = new_id(); let brand = card_brand(&number).to_string(); s.items.push(Item { id: id.clone(), kind: Kind::Card, title: brand.clone(), last4: number[number.len() - 4..].to_string(), brand, number, csc, exp_month: m, exp_year: y, holder: input.holder.trim().to_string(), nickname: input.nickname.trim().to_string(), billing: input.billing, notes: input.notes, created_ms: t, updated_ms: t, ..Default::default() }); Ok(id) } } }) } pub struct ContactInput { pub id: Option, pub label: String, pub name: String, pub address: Address, pub phones: Vec, pub emails: Vec, pub notes: String, } pub fn put_contact(input: ContactInput) -> Result { let phones: Vec = input.phones.iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect(); let emails: Vec = input.emails.iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect(); if let Some(p) = phones.iter().find(|p| !valid_phone(p)) { return Err(format!("請輸入有效的電話號碼:{p}")); } if let Some(e) = emails.iter().find(|e| !valid_email(e)) { return Err(format!("請輸入有效的電子郵件地址:{e}")); } if phones.len() > MAX_ROWS || emails.len() > MAX_ROWS { return Err(format!("電話和電子郵件各最多 {MAX_ROWS} 個")); } if input.name.trim().is_empty() && input.address.is_empty() && phones.is_empty() && emails.is_empty() { return Err("請至少填姓名、地址、電話或電子郵件其中一項".into()); } edit(|s| { let t = now_ms(); let it = Item { kind: Kind::Contact, title: input.label.trim().to_string(), name: input.name.trim().to_string(), address: input.address, phones, emails, notes: input.notes, updated_ms: t, ..Default::default() }; match input.id.as_ref().and_then(|id| s.items.iter_mut().find(|i| &i.id == id)) { Some(old) => { *old = Item { id: old.id.clone(), created_ms: old.created_ms, ..it }; Ok(old.id.clone()) } None => { let id = new_id(); s.items.push(Item { id: id.clone(), created_ms: t, ..it }); Ok(id) } } }) } pub fn remove(id: &str) -> Result<(), String> { edit(|s| { s.items.retain(|i| i.id != id); Ok(()) }) } /// For the fill step only: (username, password). pub fn login_secret(id: &str) -> Option<(String, String)> { load().ok()?.items.into_iter().find(|i| i.id == id && i.kind == Kind::Login).map(|i| (i.username, i.password)) } /// For the fill step only: the whole card. pub fn card_secret(id: &str) -> Option { load().ok()?.items.into_iter().find(|i| i.id == id && i.kind == Kind::Card) } /// Logins that fill on `host`, newest first (no secrets). pub fn logins_for(host: &str) -> Vec { list().unwrap_or_default().into_iter().filter(|i| i.kind == Kind::Login && i.fills_on(host)).collect() } /// For tests: forget the cached copy. #[cfg(test)] pub fn reset_cache() { *CACHE.lock().unwrap() = None; } // ---------- CSV import ---------- #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum CsvFormat { OnePassword, Chrome, Safari, Firefox, Bitwarden, LastPass, } impl CsvFormat { pub fn banner(self) -> &'static str { match self { CsvFormat::OnePassword => "偵測到 1Password 匯出檔案", CsvFormat::Chrome => "偵測到 Chrome 或 Edge 匯出檔案", CsvFormat::Safari => "偵測到 Safari 或 Apple Passwords 匯出檔案", CsvFormat::Firefox => "偵測到 Firefox 匯出檔案", CsvFormat::Bitwarden => "偵測到 Bitwarden 匯出檔案", CsvFormat::LastPass => "偵測到 LastPass 匯出檔案", } } } /// RFC 4180 CSV: quoted fields, doubled quotes, newlines inside quotes. pub fn parse_csv(text: &str) -> Vec> { let text = text.trim_start_matches('\u{feff}'); let mut rows = vec![]; let mut row = vec![]; let mut field = String::new(); let mut quoted = false; let mut chars = text.chars().peekable(); while let Some(c) = chars.next() { if quoted { if c == '"' { if chars.peek() == Some(&'"') { field.push('"'); chars.next(); } else { quoted = false; } } else { field.push(c); } continue; } match c { '"' => quoted = true, ',' => row.push(std::mem::take(&mut field)), '\r' => {} '\n' => { row.push(std::mem::take(&mut field)); if !(row.len() == 1 && row[0].is_empty()) { rows.push(std::mem::take(&mut row)); } else { row.clear(); } } c => field.push(c), } } if !field.is_empty() || !row.is_empty() { row.push(field); rows.push(row); } rows } pub fn detect_format(header: &[String]) -> Option { let h: Vec = header.iter().map(|s| s.trim().to_lowercase()).collect(); let has = |k: &str| h.iter().any(|x| x == k); if has("login_uri") && has("login_username") && has("login_password") { Some(CsvFormat::Bitwarden) } else if has("url") && has("username") && has("password") && has("extra") && has("name") && has("grouping") { Some(CsvFormat::LastPass) } else if has("url") && has("username") && has("password") && (has("httprealm") || has("formactionorigin") || has("guid")) { Some(CsvFormat::Firefox) } else if has("title") && has("url") && has("username") && has("password") { if has("favorite") || has("archived") || has("tags") || (has("notes") && !has("otpauth")) { Some(CsvFormat::OnePassword) } else { Some(CsvFormat::Safari) } } else if has("name") && has("url") && has("username") && has("password") { Some(CsvFormat::Chrome) } else { None } } #[derive(Clone, Debug, PartialEq)] pub enum RowStatus { Ready, AlreadySaved, Error(&'static str), Skipped(&'static str), } #[derive(Clone, Debug)] pub struct ImportRow { #[allow(dead_code)] pub line: usize, pub kind: Kind, pub label: String, pub secondary: String, pub status: RowStatus, // What would be saved. pub site: String, pub username: String, pub password: String, pub notes: String, pub card: Option, } impl Clone for CardInput { fn clone(&self) -> Self { CardInput { id: self.id.clone(), number: self.number.clone(), expiry: self.expiry.clone(), csc: self.csc.clone(), holder: self.holder.clone(), nickname: self.nickname.clone(), billing: self.billing.clone(), notes: self.notes.clone() } } } impl std::fmt::Debug for CardInput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "CardInput(•••• {})", digits(&self.number).chars().rev().take(4).collect::().chars().rev().collect::()) } } pub struct ImportPlan { pub format: CsvFormat, pub rows: Vec, } impl ImportPlan { pub fn count(&self, f: impl Fn(&RowStatus) -> bool) -> usize { self.rows.iter().filter(|r| f(&r.status)).count() } } /// A LastPass "extra" field: "NoteType:Credit Card\nNumber:4111…\n…" → key → value. fn lastpass_fields(extra: &str) -> std::collections::HashMap { extra.lines().filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect() } /// Read a password-manager export and decide, row by row, what would be imported. pub fn plan_import(text: &str) -> Result { let rows = parse_csv(text); let Some((header, body)) = rows.split_first() else { return Err("這看起來不是密碼匯出檔案。請選擇從支援的應用程式匯出的 CSV 檔案。".into()) }; let format = detect_format(header).ok_or("這看起來不是密碼匯出檔案。請選擇從支援的應用程式匯出的 CSV 檔案。")?; let col = |name: &str| header.iter().position(|h| h.trim().eq_ignore_ascii_case(name)); let (c_url, c_user, c_pw, c_title, c_notes) = match format { CsvFormat::Bitwarden => (col("login_uri"), col("login_username"), col("login_password"), col("name"), col("notes")), CsvFormat::LastPass => (col("url"), col("username"), col("password"), col("name"), col("extra")), CsvFormat::Firefox => (col("url"), col("username"), col("password"), None, None), CsvFormat::OnePassword | CsvFormat::Safari => (col("url"), col("username"), col("password"), col("title"), col("notes")), CsvFormat::Chrome => (col("url"), col("username"), col("password"), col("name"), col("note")), }; let existing = list().unwrap_or_default(); let mut seen: std::collections::HashSet = existing .iter() .flat_map(|i| match i.kind { Kind::Login => i.sites.iter().map(|s| format!("{}\n{}", registrable_domain(&host_of(s)), i.username.to_lowercase())).collect::>(), Kind::Card => vec![format!("{}\n{}\n{}", i.last4, i.exp_month, i.exp_year)], Kind::Contact => vec![], }) .collect(); let get = |r: &Vec, c: Option| c.and_then(|i| r.get(i)).cloned().unwrap_or_default(); let mut out = vec![]; for (n, r) in body.iter().enumerate() { let line = n + 2; let url = get(r, c_url); let title = get(r, c_title); // LastPass cards: url http://sn and an extra that starts with NoteType:Credit Card. if format == CsvFormat::LastPass && url.trim() == "http://sn" { let extra = get(r, c_notes); if !extra.starts_with("NoteType:Credit Card") { out.push(ImportRow { line, kind: Kind::Login, label: if title.is_empty() { format!("第 {line} 列") } else { title }, secondary: String::new(), status: RowStatus::Skipped("不是登入資訊或卡片"), site: String::new(), username: String::new(), password: String::new(), notes: String::new(), card: None }); continue; } let f = lastpass_fields(&extra); let number = digits(f.get("Number").map(String::as_str).unwrap_or("")); let exp_raw = f.get("Expiration Date").cloned().unwrap_or_default(); // LastPass writes "January,2029" or "01/2029". let exp = parse_expiry(&exp_raw).or_else(|| { let (m, y) = exp_raw.split_once(',')?; let months = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; let m = months.iter().position(|x| *x == m.trim().to_lowercase())? as u32 + 1; Some((m, y.trim().parse().ok()?)) }); let csc = digits(f.get("Security Code").map(String::as_str).unwrap_or("")); let last4: String = number.chars().rev().take(4).collect::().chars().rev().collect(); let status = if !luhn_ok(&number) { RowStatus::Error("卡號無效") } else if exp.is_none() { RowStatus::Error("有效期限無效") } else if exp.map(|(m, y)| expired(m, y)).unwrap_or(false) { RowStatus::Error("卡片已過期") } else if !csc.is_empty() && !(3..=4).contains(&csc.len()) { RowStatus::Error("安全碼無效") } else { let (m, y) = exp.unwrap_or_default(); let key = format!("{last4}\n{m}\n{y}"); if seen.contains(&key) { RowStatus::AlreadySaved } else { seen.insert(key); RowStatus::Ready } }; let expiry = exp.map(|(m, y)| format!("{m:02}/{}", y % 100)).unwrap_or_default(); out.push(ImportRow { line, kind: Kind::Card, label: if title.is_empty() { card_brand(&number).to_string() } else { title.clone() }, secondary: format!("•••• {last4}"), status, site: String::new(), username: String::new(), password: String::new(), notes: String::new(), card: Some(CardInput { id: None, number, expiry, csc, holder: f.get("Name on Card").cloned().unwrap_or_default(), nickname: title, billing: Address::default(), notes: String::new() }), }); continue; } let user = get(r, c_user); let pw = get(r, c_pw); let notes = if format == CsvFormat::LastPass { String::new() } else { get(r, c_notes) }; let scheme = url.trim().split("://").next().unwrap_or("").to_lowercase(); let host = host_of(&url); let label = if !title.trim().is_empty() { title.clone() } else if !host.is_empty() { registrable_domain(&host) } else { format!("第 {line} 列") }; let status = if url.trim().is_empty() { RowStatus::Error("缺少網站") } else if url.contains("://") && scheme != "https" && scheme != "http" { // android://, ios-app:// and the like. RowStatus::Skipped("不是登入資訊或卡片") } else if scheme == "http" { RowStatus::Error("網站未使用 https") } else if host.is_empty() || !host.contains('.') { RowStatus::Error("網站無效") } else if pw.is_empty() { RowStatus::Error("缺少密碼") } else if title.chars().count() > MAX_TITLE || user.chars().count() > MAX_USERNAME || pw.len() > MAX_PASSWORD_BYTES || notes.chars().count() > MAX_NOTES { RowStatus::Error("某個欄位過長") } else { let key = format!("{}\n{}", registrable_domain(&host), user.to_lowercase()); if seen.contains(&key) { RowStatus::AlreadySaved } else { seen.insert(key); RowStatus::Ready } }; out.push(ImportRow { line, kind: Kind::Login, label, secondary: if user.is_empty() { "••••••••".into() } else { format!("{user} · ••••••••") }, status, site: host, username: user, password: pw, notes, card: None }); } Ok(ImportPlan { format, rows: out }) } /// Save one ready row. pub fn import_row(row: &ImportRow) -> Result<(), String> { match row.kind { Kind::Card => put_card(row.card.clone().ok_or("卡號無效")?).map(|_| ()), _ => put_login(LoginInput { id: None, sites: vec![row.site.clone()], username: row.username.clone(), password: row.password.clone(), notes: row.notes.clone() }).map(|_| ()), } } #[cfg(test)] mod tests { use super::*; #[test] fn domains_cards_and_csv() { assert_eq!(registrable_domain("login.shop.example.co.uk"), "example.co.uk"); assert_eq!(registrable_domain("www.ctbcbank.com.tw"), "ctbcbank.com.tw"); assert_eq!(registrable_domain("accounts.google.com"), "google.com"); assert_eq!(registrable_domain("127.0.0.1"), "127.0.0.1"); assert_eq!(host_of("https://WWW.Example.com:8443/x?y"), "example.com"); let it = Item { kind: Kind::Login, sites: vec!["https://example.com/login".into()], ..Default::default() }; assert!(it.fills_on("app.example.com") && it.fills_on("example.com") && !it.fills_on("example.com.evil.io") && !it.fills_on("notexample.com")); assert!(luhn_ok("4111 1111 1111 1111") && !luhn_ok("4111 1111 1111 1112")); assert_eq!(card_brand("5555555555554444"), "Mastercard"); assert_eq!(card_brand("378282246310005"), "American Express"); assert_eq!(parse_expiry("08/29"), Some((8, 2029))); assert_eq!(parse_expiry("8/2031"), Some((8, 2031))); assert_eq!(parse_expiry("13/29"), None); assert!(valid_email("a@b.co") && !valid_email("a@b") && valid_phone("+886 912-345-678") && !valid_phone("12ab")); assert_eq!(parse_csv("a,\"b,\"\"c\"\"\nd\",e\r\n\r\nf,g,h\n"), vec![vec!["a".to_string(), "b,\"c\"\nd".into(), "e".into()], vec!["f".into(), "g".into(), "h".into()]]); let h = |s: &str| s.split(',').map(String::from).collect::>(); assert_eq!(detect_format(&h("name,url,username,password,note")), Some(CsvFormat::Chrome)); assert_eq!(detect_format(&h("Title,URL,Username,Password,Notes,OTPAuth")), Some(CsvFormat::Safari)); assert_eq!(detect_format(&h("Title,Url,Username,Password,Notes,Favorite,Archived,Tags")), Some(CsvFormat::OnePassword)); assert_eq!(detect_format(&h("url,username,password,httpRealm,formActionOrigin,guid")), Some(CsvFormat::Firefox)); assert_eq!(detect_format(&h("folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp")), Some(CsvFormat::Bitwarden)); assert_eq!(detect_format(&h("url,username,password,totp,extra,name,grouping,fav")), Some(CsvFormat::LastPass)); assert_eq!(detect_format(&h("a,b,c")), None); } /// The store round trip with a scratch key file and data folder. #[test] fn store_keeps_secrets_inside() { let dir = std::env::temp_dir().join(format!("anybot-vault-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::env::set_var("ANYBOT_VAULT_DIR", &dir); std::env::set_var("ANYBOT_VAULT_KEY_FILE", dir.join("k")); reset_cache(); // Pretend the old Keychain logins were already brought in (no Keychain in this test). save(&Store { imported_keychain: true, ..Default::default() }).unwrap(); let id = put_login(LoginInput { id: None, sites: vec!["https://www.github.com/login".into()], username: "me".into(), password: "pa\"ss 你好".into(), notes: "工作用".into() }).unwrap(); assert!(put_login(LoginInput { id: None, sites: vec![], username: "x".into(), password: "y".into(), notes: String::new() }).is_err()); let card = put_card(CardInput { id: None, number: "4111 1111 1111 1111".into(), expiry: "12/39".into(), csc: "123".into(), holder: "WANG".into(), nickname: "旅行".into(), billing: Address::default(), notes: String::new() }).unwrap(); assert!(put_card(CardInput { id: None, number: "4111 1111 1111 1112".into(), expiry: "12/39".into(), csc: String::new(), holder: String::new(), nickname: String::new(), billing: Address::default(), notes: String::new() }).is_err()); put_contact(ContactInput { id: None, label: "住家".into(), name: "王小明".into(), address: Address { line1: "信義路 1 號".into(), city: "台北市".into(), ..Default::default() }, phones: vec!["0912345678".into()], emails: vec!["a@b.co".into()], notes: String::new() }).unwrap(); // The file is ciphertext. let raw = std::fs::read(file_path()).unwrap(); assert!(!String::from_utf8_lossy(&raw).contains("你好") && !String::from_utf8_lossy(&raw).contains("4111")); // Reading back from disk (not the cache) works, and the list carries no secrets. reset_cache(); let items = list().unwrap(); assert_eq!(items.len(), 3); assert!(items.iter().all(|i| i.password.is_empty() && i.number.is_empty() && i.csc.is_empty())); let c = items.iter().find(|i| i.id == card).unwrap(); assert_eq!((c.brand.as_str(), c.last4.as_str(), c.exp_month, c.exp_year), ("Visa", "1111", 12, 2039)); assert_eq!(login_secret(&id).unwrap().1, "pa\"ss 你好"); assert_eq!(logins_for("gist.github.com").len(), 1); // Editing with an empty password keeps it. put_login(LoginInput { id: Some(id.clone()), sites: vec!["github.com".into(), "githubusercontent.com".into()], username: "me2".into(), password: String::new(), notes: String::new() }).unwrap(); assert_eq!(login_secret(&id).unwrap(), ("me2".to_string(), "pa\"ss 你好".to_string())); add_site(&id, "https://login.example.org").unwrap(); assert!(get(&id).unwrap().fills_on("example.org")); // Import: duplicates, http, cards from LastPass. let csv = "url,username,password,totp,extra,name,grouping,fav\nhttps://github.com,me2,x,,,GitHub,,0\nhttp://old.example.com,u,p,,,Old,,0\nhttps://new.example.net/a,u,p,,,New,,0\nhttp://sn,,,,\"NoteType:Credit Card\nName on Card:WANG\nNumber:5555555555554444\nSecurity Code:321\nExpiration Date:January,2040\",Master,,0\n"; let plan = plan_import(csv).unwrap(); assert_eq!(plan.format, CsvFormat::LastPass); let st: Vec<_> = plan.rows.iter().map(|r| r.status.clone()).collect(); assert_eq!(st, vec![RowStatus::AlreadySaved, RowStatus::Error("網站未使用 https"), RowStatus::Ready, RowStatus::Ready]); for r in plan.rows.iter().filter(|r| r.status == RowStatus::Ready) { import_row(r).unwrap(); } assert_eq!(list().unwrap().len(), 5); remove(&card).unwrap(); assert!(get(&card).is_none()); std::env::remove_var("ANYBOT_VAULT_KEY_FILE"); std::env::remove_var("ANYBOT_VAULT_DIR"); reset_cache(); let _ = std::fs::remove_dir_all(&dir); } /// The real keychain key (macOS Keychain / Secret Service). Creates the key item; the /// caller removes it afterwards. #[test] #[ignore] fn real_keychain_key() { let dir = std::env::temp_dir().join(format!("anybot-vault-kc-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::env::set_var("ANYBOT_VAULT_DIR", &dir); reset_cache(); save(&Store { imported_keychain: true, ..Default::default() }).unwrap(); put_contact(ContactInput { id: None, label: "x".into(), name: "測試".into(), address: Address::default(), phones: vec![], emails: vec![], notes: String::new() }).unwrap(); reset_cache(); assert_eq!(list().unwrap().len(), 1); assert!(!dir.join("vault.key").exists(), "the key should be in the keychain, not a file"); let _ = std::fs::remove_dir_all(&dir); } }