AnyBot/src/vault_store.rs

1053 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 保險庫 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 {
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 { 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 {
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 { 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);
}
}