683 lines
38 KiB
Rust
683 lines
38 KiB
Rust
// 保險庫 / 1Password / 安全表單: the bot can sign in to a site in AnyBot's browser without ever
|
||
// seeing the password (the original keeps logins in a vault and fills them through a secure
|
||
// form; it also connects to 1Password). The login comes from the macOS Keychain (AnyBot's own
|
||
// saved logins or any internet password there) or the 1Password CLI (`op`), and is typed into
|
||
// the page by AnyBot; the model only hears that it was filled.
|
||
|
||
use serde_json::Value;
|
||
use std::process::Command;
|
||
|
||
pub const TOOL: &str = "browser_fill_login";
|
||
/// Keychain entries AnyBot saves are marked with this label.
|
||
const LABEL: &str = "AnyBot 登入";
|
||
|
||
pub const LIST_TOOL: &str = "vault_list";
|
||
pub const CARD_TOOL: &str = "browser_fill_card";
|
||
pub const CONTACT_TOOL: &str = "browser_fill_contact";
|
||
|
||
/// The vault tools the bot gets (with AnyBot's browser).
|
||
pub fn specs() -> Vec<Value> {
|
||
vec![
|
||
serde_json::json!({"name": LIST_TOOL, "description": "List what the person saved in AnyBot's 保險庫 (vault): logins (site, username, notes — never the password), payment cards (nickname, brand, last 4 digits, expiry — never the number or security code) and contact info (name, address, phones, emails). Notes say when to use an item. Use the ids with browser_fill_login / browser_fill_card / browser_fill_contact.",
|
||
"parameters": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["all", "login", "card", "contact"]}, "query": {"type": "string", "description": "Words to search for"}}}}),
|
||
serde_json::json!({"name": TOOL, "description": "Sign in on the current tab of AnyBot's browser with a login from the person's vault (or the macOS Keychain / 1Password). You never see the password; the person approves each use. Open the login page with browser_open first; after filling, press the page's sign-in button (computer_use or ask the person) and check with browser_read.",
|
||
"parameters": {"type": "object", "properties": {"login_id": {"type": "string", "description": "From vault_list (default: the saved login for the current site)"}, "site": {"type": "string", "description": "Domain to use (default: the current tab's)"}, "submit": {"type": "boolean", "description": "Also submit the form"}}}}),
|
||
serde_json::json!({"name": CARD_TOOL, "description": "Fill a saved payment card into the checkout form on the current tab of AnyBot's browser (card number, expiry, security code, name). You never see the number or security code; the person approves every use. Card fields inside a payment provider's frame may need the person's help. Never submit a payment without the person saying so.",
|
||
"parameters": {"type": "object", "properties": {"card_id": {"type": "string", "description": "From vault_list"}}, "required": ["card_id"]}}),
|
||
serde_json::json!({"name": CONTACT_TOOL, "description": "Fill saved contact info (name, address, phone, email) into the form on the current tab of AnyBot's browser, e.g. a shipping or billing address.",
|
||
"parameters": {"type": "object", "properties": {"contact_id": {"type": "string", "description": "From vault_list"}}, "required": ["contact_id"]}}),
|
||
]
|
||
}
|
||
|
||
/// vault_list: what the bot may know (no passwords, card numbers or security codes).
|
||
pub fn list_for_bot(args: &str) -> String {
|
||
use crate::vault_store::Kind;
|
||
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default();
|
||
let kind = v["kind"].as_str().unwrap_or("all");
|
||
let q = v["query"].as_str().unwrap_or("").trim().to_lowercase();
|
||
let items = match crate::vault_store::list() {
|
||
Ok(i) => i,
|
||
Err(e) => return format!("讀不到保險庫:{e}"),
|
||
};
|
||
let rows: Vec<Value> = items
|
||
.iter()
|
||
.filter(|i| match kind {
|
||
"login" => i.kind == Kind::Login,
|
||
"card" => i.kind == Kind::Card,
|
||
"contact" => i.kind == Kind::Contact,
|
||
_ => true,
|
||
})
|
||
.filter(|i| q.is_empty() || i.haystack().contains(&q))
|
||
.map(|i| match i.kind {
|
||
Kind::Login => serde_json::json!({"id": i.id, "kind": "login", "title": i.display_title(), "sites": i.sites, "username": i.username, "notes": i.notes}),
|
||
Kind::Card => serde_json::json!({"id": i.id, "kind": "card", "title": i.display_title(), "brand": i.brand, "last4": i.last4, "expiry": format!("{:02}/{}", i.exp_month, i.exp_year % 100), "holder": i.holder, "billing_address": i.billing.one_line(), "notes": i.notes}),
|
||
Kind::Contact => serde_json::json!({"id": i.id, "kind": "contact", "label": i.title, "name": i.name, "address": {"line1": i.address.line1, "line2": i.address.line2, "city": i.address.city, "region": i.address.region, "postal_code": i.address.postal, "country": i.address.country}, "phones": i.phones, "emails": i.emails, "notes": i.notes}),
|
||
})
|
||
.collect();
|
||
if rows.is_empty() {
|
||
return "保險庫裡沒有符合的項目。請使用者到「設定 → 保險庫」新增,或改問使用者。".into();
|
||
}
|
||
serde_json::to_string_pretty(&rows).unwrap_or_default()
|
||
}
|
||
|
||
/// "https://accounts.example.com/login?x" → "accounts.example.com"
|
||
pub fn host_of(url: &str) -> String {
|
||
let rest = url.split("://").nth(1).unwrap_or(url);
|
||
rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase()
|
||
}
|
||
|
||
/// accounts.google.com → [accounts.google.com, google.com]
|
||
pub fn candidates(host: &str) -> Vec<String> {
|
||
let parts: Vec<&str> = host.split('.').collect();
|
||
let mut v = vec![host.to_string()];
|
||
for i in 1..parts.len().saturating_sub(1) {
|
||
v.push(parts[i..].join("."));
|
||
}
|
||
v
|
||
}
|
||
|
||
pub struct Login {
|
||
pub username: String,
|
||
pub password: String,
|
||
pub source: &'static str,
|
||
}
|
||
|
||
// ---------- Linux: the desktop keyring (GNOME Keyring / KWallet) through Secret Service ----------
|
||
// Entries are stored with attributes application=anybot, server=<host>, user=<name>.
|
||
|
||
fn secret_tool(args: &[&str], input: Option<&str>) -> Option<std::process::Output> {
|
||
use std::io::Write;
|
||
let mut c = Command::new("secret-tool");
|
||
// The label has Chinese in it: make sure secret-tool reads its arguments as UTF-8 (C.UTF-8
|
||
// exists everywhere; the person's own locale might not be installed).
|
||
c.env("LC_ALL", "C.UTF-8");
|
||
c.args(args).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).stdin(if input.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() });
|
||
let mut child = c.spawn().ok()?;
|
||
if let (Some(text), Some(mut w)) = (input, child.stdin.take()) {
|
||
let _ = w.write_all(text.as_bytes());
|
||
}
|
||
child.wait_with_output().ok()
|
||
}
|
||
|
||
/// `secret-tool search --all` blocks → (attributes, secret).
|
||
pub fn parse_secret_search(out: &str) -> Vec<(std::collections::HashMap<String, String>, String)> {
|
||
let mut v = vec![];
|
||
for block in out.split("\n[").map(|b| b.trim_start_matches('[')) {
|
||
let mut attrs = std::collections::HashMap::new();
|
||
let mut secret = String::new();
|
||
for l in block.lines() {
|
||
if let Some((k, val)) = l.split_once(" = ") {
|
||
match k.trim() {
|
||
"secret" => secret = val.to_string(),
|
||
k if k.starts_with("attribute.") => {
|
||
attrs.insert(k.trim_start_matches("attribute.").to_string(), val.to_string());
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
if !attrs.is_empty() {
|
||
v.push((attrs, secret));
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
fn linux_find(host: &str) -> Option<Login> {
|
||
let o = secret_tool(&["search", "--all", "--unlock", "application", "anybot", "server", host], None)?;
|
||
// secret-tool prints the matches on stdout, or (older versions) on stderr.
|
||
let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||
let (attrs, secret) = parse_secret_search(&text).into_iter().find(|(_, s)| !s.is_empty())?;
|
||
Some(Login { username: attrs.get("user").cloned().unwrap_or_default(), password: secret, source: "系統鑰匙圈" })
|
||
}
|
||
|
||
/// One Keychain login by site and account (bringing old AnyBot logins into the vault file).
|
||
pub fn keychain_login(site: &str, account: &str) -> Option<Login> {
|
||
if !cfg!(target_os = "macos") {
|
||
return linux_find(site).filter(|l| l.username == account);
|
||
}
|
||
let out = Command::new("security").args(["find-internet-password", "-s", site, "-a", account, "-g"]).output().ok()?;
|
||
if !out.status.success() {
|
||
return None;
|
||
}
|
||
Some(Login { username: account.to_string(), password: parse_g_password(&String::from_utf8_lossy(&out.stderr))?, source: "鑰匙圈" })
|
||
}
|
||
|
||
fn keychain(host: &str) -> Option<Login> {
|
||
if !cfg!(target_os = "macos") {
|
||
return linux_find(host);
|
||
}
|
||
// `-g` prints the attributes on stdout and the password on stderr, either as `"text"` or,
|
||
// when it isn't plain ASCII, as `0x<hex> "escaped"` (`-w` gives bare hex then, which
|
||
// can't be told apart from a password that looks like hex).
|
||
let out = Command::new("security").args(["find-internet-password", "-s", host, "-g"]).output().ok()?;
|
||
if !out.status.success() {
|
||
return None;
|
||
}
|
||
let meta = String::from_utf8_lossy(&out.stdout).to_string();
|
||
let account = meta.lines().find(|l| l.contains("\"acct\"")).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default();
|
||
let password = parse_g_password(&String::from_utf8_lossy(&out.stderr))?;
|
||
Some(Login { username: account, password, source: "鑰匙圈" })
|
||
}
|
||
|
||
/// The password line of `security find-…-password -g`.
|
||
pub fn parse_g_password(stderr: &str) -> Option<String> {
|
||
let line = stderr.lines().find_map(|l| l.strip_prefix("password: ").or_else(|| (l == "password:").then_some("")))?;
|
||
if let Some(hex) = line.strip_prefix("0x") {
|
||
let hex: String = hex.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
|
||
let bytes: Option<Vec<u8>> = (0..hex.len() / 2).map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()).collect();
|
||
return String::from_utf8(bytes?).ok();
|
||
}
|
||
Some(line.strip_prefix('"').and_then(|l| l.strip_suffix('"')).unwrap_or(line).to_string())
|
||
}
|
||
|
||
fn onepassword(host: &str) -> Option<Login> {
|
||
let path = crate::cloudbox::tool_path();
|
||
let list = Command::new("op").env("PATH", &path).args(["item", "list", "--categories", "Login", "--format", "json"]).output().ok()?;
|
||
if !list.status.success() {
|
||
return None;
|
||
}
|
||
let items: Value = serde_json::from_slice(&list.stdout).ok()?;
|
||
let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_of(u["href"].as_str().unwrap_or("")).ends_with(host)))?["id"].as_str()?.to_string();
|
||
let got = Command::new("op").env("PATH", &path).args(["item", "get", &id, "--fields", "label=username,label=password", "--reveal", "--format", "json"]).output().ok()?;
|
||
let fields: Value = serde_json::from_slice(&got.stdout).ok()?;
|
||
let arr = fields.as_array()?;
|
||
let get = |l: &str| arr.iter().find(|f| f["label"].as_str() == Some(l)).and_then(|f| f["value"].as_str()).unwrap_or("").to_string();
|
||
Some(Login { username: get("username"), password: get("password"), source: "1Password" })
|
||
}
|
||
|
||
pub fn find(host: &str) -> Option<Login> {
|
||
for h in candidates(host) {
|
||
if let Some(l) = keychain(&h) {
|
||
return Some(l);
|
||
}
|
||
}
|
||
for h in candidates(host) {
|
||
if let Some(l) = onepassword(&h) {
|
||
return Some(l);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Save a login in the macOS Keychain (the person types it in AnyBot's settings).
|
||
/// (Kept for the live tests and older callers.)
|
||
#[cfg_attr(not(test), allow(dead_code))]
|
||
pub fn save(site: &str, username: &str, password: &str) -> Result<(), String> {
|
||
let host = host_of(site);
|
||
let host = if host.is_empty() { site.trim().to_lowercase() } else { host };
|
||
if host.is_empty() || password.is_empty() {
|
||
return Err("請填網站和密碼".into());
|
||
}
|
||
if !cfg!(target_os = "macos") {
|
||
let label = format!("{LABEL}:{host}");
|
||
let o = secret_tool(&["store", "--label", &label, "application", "anybot", "server", &host, "user", username], Some(password))
|
||
.ok_or("找不到 secret-tool:請安裝 libsecret-tools(系統鑰匙圈的指令)。")?;
|
||
return if o.status.success() { Ok(()) } else { Err(format!("存不進系統鑰匙圈:{}", String::from_utf8_lossy(&o.stderr).trim())) };
|
||
}
|
||
let out = Command::new("security").args(["add-internet-password", "-U", "-s", &host, "-a", username, "-l", LABEL, "-w", password]).output().map_err(|e| e.to_string())?;
|
||
if out.status.success() {
|
||
index_add(&host, username);
|
||
Ok(())
|
||
} else {
|
||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||
}
|
||
}
|
||
|
||
/// AnyBot's list of the logins it saved: site and account only (the passwords stay in the
|
||
/// keychain). macOS prints a non-ASCII label like 「AnyBot 登入」 as hex bytes, so the keychain
|
||
/// can't be searched by label reliably; this list is the source of truth for what to show.
|
||
fn index_path() -> std::path::PathBuf {
|
||
crate::model::State::path().parent().map(|d| d.join("vault-index.json")).unwrap_or_default()
|
||
}
|
||
|
||
fn read_index() -> Option<Vec<(String, String)>> {
|
||
let mut list: Vec<(String, String)> = std::fs::read_to_string(index_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
|
||
// Once: bring in logins AnyBot saved before the list existed.
|
||
let marker = index_path().with_file_name("vault-index-imported");
|
||
if cfg!(target_os = "macos") && !marker.exists() {
|
||
for l in legacy_keychain_logins() {
|
||
if !list.contains(&l) {
|
||
list.push(l);
|
||
}
|
||
}
|
||
write_index(&list);
|
||
let _ = std::fs::write(&marker, "1");
|
||
}
|
||
Some(list)
|
||
}
|
||
|
||
fn write_index(list: &[(String, String)]) {
|
||
let _ = std::fs::write(index_path(), serde_json::to_string_pretty(list).unwrap_or_default());
|
||
}
|
||
|
||
/// (Kept for the live tests and older callers.)
|
||
#[cfg_attr(not(test), allow(dead_code))]
|
||
fn index_add(site: &str, account: &str) {
|
||
let mut list = read_index().unwrap_or_default();
|
||
if !list.iter().any(|(s, a)| s == site && a == account) {
|
||
list.push((site.to_string(), account.to_string()));
|
||
write_index(&list);
|
||
}
|
||
}
|
||
|
||
/// The keychain has this login (no password is read, so no prompt).
|
||
fn keychain_has(site: &str, account: &str) -> bool {
|
||
Command::new("security").args(["find-internet-password", "-s", site, "-a", account]).output().map(|o| o.status.success()).unwrap_or(false)
|
||
}
|
||
|
||
/// Logins saved before the index existed: keychain items with AnyBot's label, which
|
||
/// `security dump-keychain` prints either as text or as hex bytes.
|
||
fn legacy_keychain_logins() -> Vec<(String, String)> {
|
||
let Ok(out) = Command::new("security").args(["dump-keychain"]).output() else { return vec![] };
|
||
let text = String::from_utf8_lossy(&out.stdout);
|
||
let hex: String = LABEL.bytes().map(|b| format!("{b:02X}")).collect();
|
||
let mut v = vec![];
|
||
for block in text.split("keychain: ") {
|
||
if !block.contains(&format!("=\"{LABEL}\"")) && !block.to_uppercase().contains(&format!("0X{hex}")) {
|
||
continue;
|
||
}
|
||
let get = |k: &str| block.lines().find(|l| l.contains(&format!("\"{k}\""))).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default();
|
||
let (s, a) = (get("srvr"), get("acct"));
|
||
if !s.is_empty() {
|
||
v.push((s, a));
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// Sites AnyBot saved (names only).
|
||
pub fn saved_sites() -> Vec<(String, String)> {
|
||
if !cfg!(target_os = "macos") {
|
||
let Some(o) = secret_tool(&["search", "--all", "application", "anybot"], None) else { return vec![] };
|
||
let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||
return parse_secret_search(&text).into_iter().filter_map(|(a, _)| Some((a.get("server")?.clone(), a.get("user").cloned().unwrap_or_default()))).collect();
|
||
}
|
||
let list = read_index().unwrap_or_default();
|
||
// Drop entries someone deleted in 鑰匙圈存取 meanwhile.
|
||
let kept: Vec<_> = list.iter().filter(|(s, a)| keychain_has(s, a)).cloned().collect();
|
||
if kept.len() != list.len() {
|
||
write_index(&kept);
|
||
}
|
||
kept
|
||
}
|
||
|
||
/// (Kept for the live tests and older callers.)
|
||
#[cfg_attr(not(test), allow(dead_code))]
|
||
pub fn remove(site: &str, account: &str) {
|
||
if !cfg!(target_os = "macos") {
|
||
let _ = secret_tool(&["clear", "application", "anybot", "server", site, "user", account], None);
|
||
return;
|
||
}
|
||
let _ = Command::new("security").args(["delete-internet-password", "-s", site, "-a", account]).output();
|
||
if let Some(mut list) = read_index() {
|
||
list.retain(|(s, a)| !(s == site && a == account));
|
||
write_index(&list);
|
||
}
|
||
}
|
||
|
||
/// The JavaScript that types the login into the page (as a JSON-encoded string literal).
|
||
pub fn fill_script(username: &str, password: &str, submit: bool) -> String {
|
||
let u = serde_json::to_string(username).unwrap_or_default();
|
||
let p = serde_json::to_string(password).unwrap_or_default();
|
||
format!(
|
||
r#"(() => {{
|
||
const set = (el, v) => {{ const d = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); d.set.call(el, v); el.dispatchEvent(new Event('input', {{bubbles: true}})); el.dispatchEvent(new Event('change', {{bubbles: true}})); }};
|
||
const vis = el => el.offsetParent !== null && !el.disabled;
|
||
const pw = [...document.querySelectorAll('input[type=password]')].find(vis);
|
||
const users = [...document.querySelectorAll('input[type=email],input[type=text],input[autocomplete=username],input[name*=user i],input[name*=mail i],input[id*=user i],input[id*=mail i]')].filter(vis);
|
||
let user = null;
|
||
if (pw) {{ for (const el of users) {{ if (el.compareDocumentPosition(pw) & Node.DOCUMENT_POSITION_FOLLOWING) user = el; }} }} else {{ user = users[0] || null; }}
|
||
if (user && {u}) set(user, {u});
|
||
if (pw) set(pw, {p});
|
||
let submitted = false;
|
||
if ({submit} && (pw || user)) {{
|
||
const field = pw || user;
|
||
const f = field.form;
|
||
// The form's own button; else (pages without a <form>, like many shops) the nearest visible
|
||
// button that says 登入 / Log in / Sign in / 繼續 / 下一步.
|
||
let b = f ? (f.querySelector('button[type=submit],input[type=submit]') || f.querySelector('button')) : null;
|
||
if (!b) {{
|
||
const words = /登入|登录|log ?in|sign ?in|繼續|继续|下一步|next|continue/i;
|
||
const cands = [...document.querySelectorAll('button,input[type=submit],[role=button],a')].filter(vis).filter(el => words.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim()));
|
||
const fr = field.getBoundingClientRect();
|
||
cands.sort((a, c) => {{ const ra = a.getBoundingClientRect(), rc = c.getBoundingClientRect(); return (Math.abs(ra.top - fr.top) + Math.abs(ra.left - fr.left)) - (Math.abs(rc.top - fr.top) + Math.abs(rc.left - fr.left)); }});
|
||
b = cands[0] || null;
|
||
}}
|
||
if (b && !b.disabled) {{ b.click(); submitted = true; }}
|
||
else if (f) {{ f.requestSubmit ? f.requestSubmit() : f.submit(); submitted = true; }}
|
||
}}
|
||
return JSON.stringify({{user: !!user, password: !!pw, submitted}});
|
||
}})()"#
|
||
)
|
||
}
|
||
|
||
fn args_of(args: &str) -> Value {
|
||
serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default()
|
||
}
|
||
|
||
/// The current tab's host in AnyBot's browser.
|
||
fn current_host() -> Result<String, String> {
|
||
let (url, _) = crate::botbrowser::eval_current(None, "1")?;
|
||
Ok(host_of(&url))
|
||
}
|
||
|
||
/// What browser_fill_login will use, worked out before asking the person.
|
||
pub struct LoginPlan {
|
||
pub host: String,
|
||
/// A vault login (public fields only).
|
||
pub item: Option<crate::vault_store::Item>,
|
||
/// The vault login is saved for this site (else the person is warned before allowing it).
|
||
pub matched: bool,
|
||
/// A Keychain / 1Password login (when the vault has none).
|
||
other: Option<Login>,
|
||
}
|
||
|
||
impl LoginPlan {
|
||
/// The approval question.
|
||
pub fn question(&self) -> String {
|
||
match &self.item {
|
||
Some(i) if self.matched => format!("用保險庫的登入資訊({}{})登入 {}(Bot 看不到密碼)", i.display_title(), if i.username.is_empty() { String::new() } else { format!(" · {}", i.username) }, self.host),
|
||
Some(i) => format!(
|
||
"也要在 {} 上使用 {} 嗎?(已為 {} 儲存)\n只有在它與已儲存的網站屬於同一服務時才允許。仿冒網站可能會竊取你的密碼。",
|
||
self.host,
|
||
i.display_title(),
|
||
i.sites.join("、")
|
||
),
|
||
None => format!("用{}的登入資料({})登入 {}(Bot 看不到密碼)", self.other.as_ref().map(|l| l.source).unwrap_or("已儲存"), self.other.as_ref().map(|l| l.username.as_str()).unwrap_or(""), self.host),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn plan_login(args: &str) -> Result<LoginPlan, String> {
|
||
let v = args_of(args);
|
||
let tab = current_host()?;
|
||
let host = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty()).unwrap_or(tab);
|
||
if let Some(id) = v["login_id"].as_str().filter(|s| !s.is_empty()) {
|
||
let item = crate::vault_store::get(id).filter(|i| i.kind == crate::vault_store::Kind::Login).ok_or("保險庫裡沒有這筆登入資訊(先用 vault_list 看 id)")?;
|
||
let matched = item.fills_on(&host);
|
||
return Ok(LoginPlan { host, item: Some(item), matched, other: None });
|
||
}
|
||
if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() {
|
||
return Ok(LoginPlan { host, item: Some(item), matched: true, other: None });
|
||
}
|
||
match find(&host) {
|
||
Some(l) => Ok(LoginPlan { host, item: None, matched: true, other: Some(l) }),
|
||
None => Err(format!("找不到「{host}」的登入資料。請使用者在「設定 → 保險庫」新增一筆登入資訊(或存在 macOS 鑰匙圈/1Password)。")),
|
||
}
|
||
}
|
||
|
||
/// Type the planned login into the page (after the person allowed it).
|
||
pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result<String, String> {
|
||
let (username, password, source) = match (&plan.item, &plan.other) {
|
||
(Some(i), _) => {
|
||
let (u, p) = crate::vault_store::login_secret(&i.id).ok_or("保險庫裡沒有這筆登入資訊")?;
|
||
if !plan.matched {
|
||
crate::vault_store::add_site(&i.id, &plan.host)?;
|
||
}
|
||
(u, p, "保險庫")
|
||
}
|
||
(None, Some(l)) => (l.username.clone(), l.password.clone(), l.source),
|
||
_ => return Err("沒有可用的登入資料".into()),
|
||
};
|
||
let (_, r) = crate::botbrowser::eval_current(None, &fill_script(&username, &password, submit))?;
|
||
let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
|
||
if got["password"].as_bool() != Some(true) && got["user"].as_bool() != Some(true) {
|
||
return Err("這一頁找不到帳號或密碼欄位。先打開登入頁(有些網站要先按「登入」或先填帳號、按下一步才出現密碼欄)。".into());
|
||
}
|
||
Ok(format!(
|
||
"已在 {} 填入{}{}(來自{source},你看不到密碼)。{}",
|
||
plan.host,
|
||
if got["user"].as_bool() == Some(true) { format!("帳號 {username}") } else { String::new() },
|
||
if got["password"].as_bool() == Some(true) { "和密碼" } else { "(這頁沒有密碼欄,可能要先按下一步)" },
|
||
if submit && got["submitted"].as_bool() == Some(true) {
|
||
"也已按下登入,請用 browser_read 確認是否登入成功。如果還在登入頁(常見原因:需要驗證碼、簡訊碼或人機驗證),請使用者在 AnyBot 瀏覽器視窗裡完成,之後會記住登入。"
|
||
} else if submit {
|
||
"但頁面上找不到登入按鈕,沒有送出。請用 computer_use 按登入按鈕,或請使用者按。"
|
||
} else {
|
||
"接著按頁面上的登入按鈕。"
|
||
}
|
||
))
|
||
}
|
||
|
||
|
||
/// JavaScript that fills a form's fields by their autocomplete hints and common names.
|
||
/// `values` maps a field role to its value; the page gets them as one JSON string literal.
|
||
pub fn form_fill_script(values: &Value) -> String {
|
||
let data = serde_json::to_string(&values.to_string()).unwrap_or_default();
|
||
format!(
|
||
r#"(() => {{
|
||
const V = JSON.parse({data});
|
||
const vis = el => el.offsetParent !== null && !el.disabled && !el.readOnly;
|
||
const set = (el, v) => {{
|
||
if (el.tagName === 'SELECT') {{
|
||
const want = String(v).toLowerCase();
|
||
const opt = [...el.options].find(o => o.value.toLowerCase() === want || o.text.trim().toLowerCase() === want || (want.length > 1 && o.text.trim().toLowerCase().startsWith(want)));
|
||
if (!opt) return false;
|
||
el.value = opt.value;
|
||
}} else {{
|
||
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, String(v));
|
||
}}
|
||
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||
return true;
|
||
}};
|
||
const rules = {{
|
||
'cc-number': [/card.?num|cc.?num|cardnumber|ccnumber|卡號/i],
|
||
'cc-exp': [/^(cc-?)?exp(iry|iration)?(-?date)?$|mm.?\/?.?yy|有效期/i],
|
||
'cc-exp-month': [/exp.*month|cc-?month|card.?month|月/i],
|
||
'cc-exp-year': [/exp.*year|cc-?year|card.?year|年/i],
|
||
'cc-csc': [/cvc|cvv|csc|security.?code|card.?code|安全碼|驗證碼/i],
|
||
'cc-name': [/name.?on.?card|card.?holder|holder.?name|cc-?name|持卡人/i],
|
||
'name': [/^(full.?)?name$|姓名|收件人/i],
|
||
'address-line1': [/address.?(line)?.?1|street|^address$|地址/i],
|
||
'address-line2': [/address.?(line)?.?2|apt|suite|unit/i],
|
||
'address-level2': [/city|town|城市|縣市/i],
|
||
'address-level1': [/state|province|region|county|州|省/i],
|
||
'postal-code': [/zip|postal|postcode|郵遞區號|郵遞/i],
|
||
'country': [/country|國家/i],
|
||
'tel': [/phone|mobile|tel|手機|電話/i],
|
||
'email': [/e-?mail|電子郵件/i],
|
||
}};
|
||
const fields = [...document.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=password]),select,textarea')].filter(vis);
|
||
const used = new Set();
|
||
const filled = [];
|
||
for (const role of Object.keys(V)) {{
|
||
if (V[role] === '' || V[role] == null) continue;
|
||
let el = fields.find(f => !used.has(f) && (f.autocomplete || '').toLowerCase().split(/\s+/).includes(role));
|
||
if (!el) {{
|
||
const pats = rules[role] || [];
|
||
el = fields.find(f => {{ const ac = (f.autocomplete || '').toLowerCase(); return !used.has(f) && (ac === '' || ac === 'on' || ac === 'off' || ac.startsWith('cc-') === role.startsWith('cc-')) && pats.some(p => p.test([f.type === 'text' ? '' : f.type, f.name, f.id, f.placeholder, f.getAttribute('aria-label') || '', (f.labels && f.labels[0] ? f.labels[0].innerText : '')].join(' '))); }});
|
||
}}
|
||
if (el && set(el, V[role])) {{ used.add(el); filled.push(role); }}
|
||
}}
|
||
const frames = document.querySelectorAll('iframe').length;
|
||
return JSON.stringify({{filled, frames}});
|
||
}})()"#
|
||
)
|
||
}
|
||
|
||
/// Names for the roles, as the bot is told what was filled.
|
||
fn role_name(r: &str) -> &'static str {
|
||
match r {
|
||
"cc-number" => "卡號",
|
||
"cc-exp" | "cc-exp-month" | "cc-exp-year" => "有效期限",
|
||
"cc-csc" => "安全碼",
|
||
"cc-name" => "持卡人姓名",
|
||
"name" => "姓名",
|
||
"address-line1" | "address-line2" => "地址",
|
||
"address-level2" => "城市",
|
||
"address-level1" => "州/省/地區",
|
||
"postal-code" => "郵遞區號",
|
||
"country" => "國家/地區",
|
||
"tel" => "電話",
|
||
"email" => "電子郵件",
|
||
_ => "欄位",
|
||
}
|
||
}
|
||
|
||
fn filled_summary(r: &Value) -> (Vec<&'static str>, u64) {
|
||
let mut names: Vec<&'static str> = r["filled"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).map(role_name).collect();
|
||
names.dedup();
|
||
(names, r["frames"].as_u64().unwrap_or(0))
|
||
}
|
||
|
||
/// For the approval question: (current host, card label) without secrets.
|
||
pub fn plan_card(args: &str) -> Result<(String, crate::vault_store::Item), String> {
|
||
let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string();
|
||
let card = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Card).ok_or("保險庫裡沒有這張卡片(先用 vault_list 看 id)")?;
|
||
Ok((current_host()?, card))
|
||
}
|
||
|
||
pub fn fill_card(args: &str) -> Result<String, String> {
|
||
let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string();
|
||
let c = crate::vault_store::card_secret(&id).ok_or("保險庫裡沒有這張卡片")?;
|
||
let values = serde_json::json!({
|
||
"cc-number": c.number,
|
||
"cc-exp": format!("{:02}/{:02}", c.exp_month, c.exp_year % 100),
|
||
"cc-exp-month": format!("{:02}", c.exp_month),
|
||
"cc-exp-year": c.exp_year.to_string(),
|
||
"cc-csc": c.csc,
|
||
"cc-name": c.holder,
|
||
});
|
||
let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?;
|
||
let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
|
||
let (names, frames) = filled_summary(&got);
|
||
if names.is_empty() {
|
||
return Err(if frames > 0 {
|
||
"這一頁的付款欄位在付款服務的框架裡,AnyBot 填不進去。請使用者自己填卡號,或改用網站提供的其他付款方式。".into()
|
||
} else {
|
||
"這一頁找不到付款欄位。先打開結帳頁的付款步驟。".into()
|
||
});
|
||
}
|
||
Ok(format!("已填入 {} •••• {} 的{}(你看不到卡號和安全碼)。送出付款前一定要先問使用者。", c.display_title(), c.last4, names.join("、")))
|
||
}
|
||
|
||
pub fn plan_contact(args: &str) -> Result<(String, crate::vault_store::Item), String> {
|
||
let id = args_of(args)["contact_id"].as_str().unwrap_or("").to_string();
|
||
let c = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Contact).ok_or("保險庫裡沒有這筆聯絡資訊(先用 vault_list 看 id)")?;
|
||
Ok((current_host()?, c))
|
||
}
|
||
|
||
pub fn fill_contact(args: &str) -> Result<String, String> {
|
||
let (_, c) = plan_contact(args)?;
|
||
let values = serde_json::json!({
|
||
"name": c.name, "address-line1": c.address.line1, "address-line2": c.address.line2, "address-level2": c.address.city,
|
||
"address-level1": c.address.region, "postal-code": c.address.postal, "country": c.address.country,
|
||
"tel": c.phones.first().cloned().unwrap_or_default(), "email": c.emails.first().cloned().unwrap_or_default(),
|
||
});
|
||
let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?;
|
||
let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
|
||
let (names, _) = filled_summary(&got);
|
||
if names.is_empty() {
|
||
return Err("這一頁找不到地址或聯絡欄位。".into());
|
||
}
|
||
Ok(format!("已填入「{}」的{}。請用 browser_read 確認。", c.display_title(), names.join("、")))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn hosts_and_candidates() {
|
||
assert_eq!(host_of("https://accounts.Google.com/signin?x=1"), "accounts.google.com");
|
||
assert_eq!(host_of("https://me:pw@example.com:8443/a"), "example.com");
|
||
assert_eq!(candidates("a.b.example.com"), vec!["a.b.example.com", "b.example.com", "example.com"]);
|
||
let found = parse_secret_search("[/org/freedesktop/secrets/collection/login/7]\nlabel = AnyBot 登入:x.com\nsecret = hunter2\ncreated = 2026-11-04\nattribute.application = anybot\nattribute.server = x.com\nattribute.user = me\n[/org/freedesktop/secrets/collection/login/8]\nsecret = pw2\nattribute.server = y.com\n");
|
||
assert_eq!(found.len(), 2);
|
||
assert_eq!(found[0].1, "hunter2");
|
||
assert_eq!(found[0].0["user"], "me");
|
||
assert_eq!(found[1].0["server"], "y.com");
|
||
}
|
||
|
||
#[test]
|
||
fn keychain_password_lines() {
|
||
assert_eq!(parse_g_password("password: \"hunter2\"\n").unwrap(), "hunter2");
|
||
assert_eq!(parse_g_password("password: \"a\"b\"\n").unwrap(), "a\"b");
|
||
assert_eq!(parse_g_password("password: 0x706122737320E4BDA0E5A5BD \"pa\\\"ss \\344\\275\\240\\345\\245\\275\"\n").unwrap(), "pa\"ss 你好");
|
||
assert_eq!(parse_g_password("password: \n").unwrap(), "");
|
||
assert!(parse_g_password("nothing").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn the_script_keeps_the_password_in_a_string() {
|
||
let js = fill_script("me@x.com", "p\"w'</script>", true);
|
||
assert!(js.contains(r#""p\"w'</script>""#));
|
||
assert!(js.contains("input[type=password]") && js.contains("if (true && (pw || user))"));
|
||
}
|
||
|
||
/// Needs a keyring: on Linux run under `dbus-run-session` with gnome-keyring unlocked.
|
||
#[test]
|
||
#[ignore]
|
||
fn keyring_live() {
|
||
save("https://login.example.org/x", "me@example.org", "s3cret pass").unwrap();
|
||
let l = find("login.example.org").expect("found");
|
||
assert_eq!((l.username.as_str(), l.password.as_str()), ("me@example.org", "s3cret pass"));
|
||
assert!(saved_sites().iter().any(|(s, a)| s == "login.example.org" && a == "me@example.org"));
|
||
// Logins from before the list existed (label printed as hex by `security`) show up too.
|
||
if cfg!(target_os = "macos") {
|
||
assert!(saved_sites().iter().any(|(s, _)| s == "legacy.example.org"), "legacy entry: {:?}", saved_sites());
|
||
}
|
||
remove("login.example.org", "me@example.org");
|
||
assert!(find("login.example.org").is_none());
|
||
}
|
||
|
||
/// The whole bot-side flow on this computer: local pages in AnyBot's browser (a throwaway
|
||
/// profile), items in a scratch vault, and the fill tools. Run with ANYBOT_DATA_DIR set to a
|
||
/// scratch folder; opens a browser window for a moment.
|
||
#[test]
|
||
#[ignore]
|
||
fn fill_live() {
|
||
use crate::vault_store as store;
|
||
use std::io::{Read, Write};
|
||
let dir = crate::model::State::path().parent().unwrap().to_path_buf();
|
||
std::env::set_var("ANYBOT_VAULT_DIR", &dir);
|
||
std::env::set_var("ANYBOT_VAULT_KEY_FILE", dir.join("vault-test.key"));
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||
let port = listener.local_addr().unwrap().port();
|
||
std::thread::spawn(move || {
|
||
for mut c in listener.incoming().flatten() {
|
||
let mut buf = [0u8; 2048];
|
||
let n = c.read(&mut buf).unwrap_or(0);
|
||
let req = String::from_utf8_lossy(&buf[..n]);
|
||
let page = if req.starts_with("GET /checkout") {
|
||
"<html><body><form><input autocomplete=name id=n><input name=street id=a1><input placeholder=City id=c><input name=zip id=z><input type=tel id=t><input id=cc name=cardnumber><input id=ex placeholder='MM / YY'><input id=cv name=cvc><input id=hn placeholder='Name on card'></form></body></html>"
|
||
} else {
|
||
"<html><body><form><input type=email name=email id=e><input type=password id=p><button type=submit>Sign in</button></form></body></html>"
|
||
};
|
||
let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len());
|
||
}
|
||
});
|
||
crate::botbrowser::add("vault-test").unwrap();
|
||
let run = || -> Result<(), String> {
|
||
let login = store::put_login(store::LoginInput { id: None, sites: vec!["127.0.0.1".into()], username: "tester@example.org".into(), password: "pa\"ss 你好".into(), notes: String::new() })?;
|
||
let card = store::put_card(store::CardInput { id: None, number: "4111111111111111".into(), expiry: "08/39".into(), csc: "123".into(), holder: "WANG XIAOMING".into(), nickname: "測試".into(), billing: store::Address::default(), notes: String::new() })?;
|
||
let contact = store::put_contact(store::ContactInput { id: None, label: "住家".into(), name: "王小明".into(), address: store::Address { line1: "信義路五段 7 號".into(), city: "台北市".into(), postal: "110".into(), ..Default::default() }, phones: vec!["0912345678".into()], emails: vec![], notes: String::new() })?;
|
||
// What the bot sees has no secrets.
|
||
let seen = list_for_bot("{}");
|
||
assert!(seen.contains("tester@example.org") && seen.contains("1111") && !seen.contains("4111111111111111") && !seen.contains("pa\\\"ss") && !seen.contains("123\""), "{seen}");
|
||
crate::botbrowser::open(&format!("http://127.0.0.1:{port}/login"), Some("vault-test"))?;
|
||
let plan = plan_login("{}")?;
|
||
assert!(plan.matched && plan.item.as_ref().map(|i| i.id == login).unwrap_or(false));
|
||
let said = fill_login(&plan, false)?;
|
||
assert!(!said.contains("pa\"ss"), "{said}");
|
||
let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([e.value, p.value])")?;
|
||
assert_eq!(got.as_str().unwrap(), r#"["tester@example.org","pa\"ss 你好"]"#);
|
||
crate::botbrowser::open(&format!("http://127.0.0.1:{port}/checkout"), Some("vault-test"))?;
|
||
let said = fill_card(&format!("{{\"card_id\":\"{card}\"}}"))?;
|
||
assert!(!said.contains("4111111111111111") && !said.contains("123"), "{said}");
|
||
fill_contact(&format!("{{\"contact_id\":\"{contact}\"}}"))?;
|
||
let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([n.value,a1.value,c.value,z.value,t.value,cc.value,ex.value,cv.value,hn.value])")?;
|
||
assert_eq!(got.as_str().unwrap(), r#"["王小明","信義路五段 7 號","台北市","110","0912345678","4111111111111111","08/39","123","WANG XIAOMING"]"#);
|
||
Ok(())
|
||
};
|
||
let r = run();
|
||
let _ = crate::botbrowser::remove("vault-test");
|
||
let _ = std::fs::remove_file(dir.join("vault-test.key"));
|
||
let _ = std::fs::remove_file(store::file_path());
|
||
r.unwrap();
|
||
}
|
||
}
|