AnyBot/src/vault_store.rs

1056 lines
44 KiB
Rust
Raw Normal View History

// 保險庫 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::<Vec<_>>().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<String>,
#[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<String>,
#[serde(default)]
pub emails: Vec<String>,
}
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::<Vec<_>>().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<Item>,
/// 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::<std::net::IpAddr>().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<Vec<u8>> {
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<Vec<u8>> {
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<aead::LessSafeKey, String> {
let k = match read_key() {
Some(k) if k.len() == 32 => k,
// An entry exists but is not a key: generating a fresh one (and -U overwriting the
// entry) would destroy the only copy of the old key — every saved password would be
// undecryptable forever. Stop and tell the person instead.
Some(_) => return Err("保險庫金鑰損毀:鑰匙圈裡的「AnyBot vault key」內容不正確。已停止動作,避免覆蓋後舊密碼永遠解不開;若你確定不需要舊密碼,可手動刪除該鑰匙圈項目再試。".into()),
None if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()),
None => {
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<Option<Store>> = std::sync::Mutex::new(None);
/// The whole store (decrypted, secrets included) — only for this module and the fill step.
fn load() -> Result<Store, String> {
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<R>(f: impl FnOnce(&mut Store) -> Result<R, String>) -> Result<R, String> {
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<Vec<Item>, String> {
import_keychain_once();
let mut v: Vec<Item> = 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<Item> {
load().ok()?.items.iter().find(|i| i.id == id).map(Item::public)
}
pub struct LoginInput {
pub id: Option<String>,
pub sites: Vec<String>,
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<String, String> {
let sites: Vec<String> = 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<String>,
/// 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<String, String> {
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<String>,
pub label: String,
pub name: String,
pub address: Address,
pub phones: Vec<String>,
pub emails: Vec<String>,
pub notes: String,
}
pub fn put_contact(input: ContactInput) -> Result<String, String> {
let phones: Vec<String> = input.phones.iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect();
let emails: Vec<String> = 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<Item> {
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<Item> {
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<Vec<String>> {
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<CsvFormat> {
let h: Vec<String> = 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<CardInput>,
}
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::<String>().chars().rev().collect::<String>())
}
}
pub struct ImportPlan {
pub format: CsvFormat,
pub rows: Vec<ImportRow>,
}
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<String, String> {
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<ImportPlan, String> {
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<String> = 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::<Vec<_>>(),
Kind::Card => vec![format!("{}\n{}\n{}", i.last4, i.exp_month, i.exp_year)],
Kind::Contact => vec![],
})
.collect();
let get = |r: &Vec<String>, c: Option<usize>| 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::<String>().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::<Vec<_>>();
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);
}
}