From f76ed1ddf5a57981a2f8e61553d7c7fed2a22fa1 Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Fri, 12 Jun 2026 13:59:37 +0900 Subject: [PATCH] feat(cookies): cross-profile `cookies export` / `cookies transfer` Transferring a logged-in session between Chrome profiles previously needed an ad-hoc external script to decrypt the source profile's cookie store. Make it first-class: - `cookies export --from [--domain [,]]` decrypts another profile's on-disk cookies and prints CDP-shaped JSON for `cookies set --curl`. - `cookies transfer --from [--domain ]` exports + injects into the connected browser in one shot (reuses the cookies_set path). Source profile is resolved by directory name, display name, or "auto". The store is copied to a temp file (immune to a running Chrome's lock/WAL), read via sqlite3, and values are decrypted (macOS v10: AES-128-CBC, key from the shared 'Chrome Safe Storage' Keychain entry). httpOnly/secure/per-domain auth cookies round-trip intact; SameSite=None without Secure is downgraded so CDP accepts it. macOS only for now (clear error elsewhere). --- cli/Cargo.lock | 35 ++++- cli/Cargo.toml | 2 +- cli/src/commands.rs | 45 ++++++ cli/src/cookie_export.rs | 332 +++++++++++++++++++++++++++++++++++++++ cli/src/main.rs | 68 ++++++++ cli/src/output.rs | 4 + 6 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 cli/src/cookie_export.rs diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 1819443..98b572d 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -210,6 +210,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "built" version = "0.8.0" @@ -246,6 +255,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -272,11 +290,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrome-use" -version = "1.0.0" +version = "1.1.0" dependencies = [ + "aes", "aes-gcm", "async-trait", "base64", + "cbc", "chrono", "dirs", "futures-util", @@ -286,11 +306,13 @@ dependencies = [ "image", "include_dir", "libc", + "pbkdf2", "regex-lite", "reqwest", "rust-embed", "serde", "serde_json", + "sha1", "sha2", "similar", "socket2", @@ -1086,6 +1108,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1376,6 +1399,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index fb9d1d9..4450d6e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chrome-use" -version = "1.0.0" +version = "1.1.0" edition = "2021" description = "Fast browser automation CLI for AI agents" license = "Apache-2.0" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 18ecfd7..1845c69 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1289,6 +1289,51 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result { let op = rest.first().unwrap_or(&"get"); match *op { + "transfer" => { + // Copy a logged-in session between Chrome profiles: decrypt + // the SOURCE profile's on-disk cookie store and inject the + // cookies into the active (connected) session — no CDP access + // to the source, no Chrome restart. + // cookies transfer --from [--domain [,]] + // `--from` wins; otherwise the global `--profile` is used. + let from = rest + .iter() + .position(|a| *a == "--from") + .and_then(|i| rest.get(i + 1).copied()) + .or(flags.profile.as_deref()); + let from = from.ok_or_else(|| ParseError::MissingArguments { + context: "cookies transfer".to_string(), + usage: "cookies transfer --from [--domain [,]]", + })?; + let domain = rest + .iter() + .position(|a| *a == "--domain") + .and_then(|i| rest.get(i + 1).copied()); + let cookies = + crate::cookie_export::export_cookies(from, domain).map_err(|e| { + ParseError::InvalidValue { + message: format!("cookies transfer: {}", e), + usage: "cookies transfer --from [--domain ]", + } + })?; + if cookies.is_empty() { + return Err(ParseError::InvalidValue { + message: format!( + "cookies transfer: no cookies found in profile \"{}\"{}", + from, + domain + .map(|d| format!(" for domain {}", d)) + .unwrap_or_default() + ), + usage: "cookies transfer --from [--domain ]", + }); + } + return Ok(json!({ + "id": id, + "action": "cookies_set", + "cookies": cookies, + })); + } "set" => { // --curl mode: import cookies from a JSON array, // raw cURL dump, or bare Cookie header. Scoped to the diff --git a/cli/src/cookie_export.rs b/cli/src/cookie_export.rs new file mode 100644 index 0000000..14bdd52 --- /dev/null +++ b/cli/src/cookie_export.rs @@ -0,0 +1,332 @@ +//! Offline export of a Chrome profile's cookies. +//! +//! Reads a profile's on-disk cookie store, decrypts the values with the OS +//! credential-store key, and returns CDP `Network.setCookie`-shaped objects — +//! the same shape `cookies set --curl` accepts. This is what powers +//! `cookies transfer`: it moves a logged-in session (whose auth cookies are +//! httpOnly + secure and span several hosts) from one profile to another +//! without the source profile being reachable over CDP, and without restarting +//! Chrome. +//! +//! Currently macOS-only. There, value encryption uses the `v10` scheme: +//! AES-128-CBC with a key derived (PBKDF2-HMAC-SHA1, 1003 iterations) from the +//! "Chrome Safe Storage" Keychain entry, shared by every profile of one Chrome +//! install. Other platforms return a clear error. + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +/// Resolve, read, and decrypt a Chrome profile's cookies. +/// +/// `profile` accepts a directory name ("Default", "Profile 14"), a display name +/// ("Davian", case-insensitive), or "auto" (last-used profile). `domain`, when +/// set, is a comma-separated host-suffix filter (e.g. "claude.ai,anthropic.com") +/// matched against `host_key`; pass `None` to export every cookie. +pub fn export_cookies(profile: &str, domain: Option<&str>) -> Result, String> { + let db = resolve_cookie_db(profile)?; + let rows = read_cookie_rows(&db, domain)?; + let key = safe_storage_key()?; + let mut out = Vec::with_capacity(rows.len()); + for r in &rows { + if let Some(value) = decrypt_value(&r.encrypted_value, &key) { + out.push(to_cdp_cookie(r, value)); + } + } + Ok(out) +} + +fn resolve_cookie_db(profile: &str) -> Result { + use crate::native::cdp::chrome::{find_chrome_user_data_dir, resolve_chrome_profile}; + let udd = find_chrome_user_data_dir() + .ok_or_else(|| "No Chrome user data directory found".to_string())?; + let dir = resolve_chrome_profile(&udd, profile)?; + let base = udd.join(&dir); + // Chrome >=96 keeps cookies under Network/; older builds at the profile root. + let net = base.join("Network").join("Cookies"); + if net.is_file() { + return Ok(net); + } + let root = base.join("Cookies"); + if root.is_file() { + return Ok(root); + } + Err(format!( + "no cookie store found for profile \"{}\" (looked in {} and {})", + profile, + net.display(), + root.display() + )) +} + +struct CookieRow { + host_key: String, + name: String, + encrypted_value: Vec, + path: String, + is_secure: bool, + is_httponly: bool, + samesite: i64, + expires_utc: i64, +} + +/// Removes a temp directory when dropped. +struct TempGuard(PathBuf); +impl Drop for TempGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn read_cookie_rows(db: &Path, domain: Option<&str>) -> Result, String> { + // Copy the store (plus any -wal/-shm) to a temp file so a running Chrome's + // lock / hot journal can't block the read or be disturbed by it. + let tmp_dir = std::env::temp_dir().join(format!("chrome-use-cookies-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp_dir).map_err(|e| format!("temp dir: {}", e))?; + let _guard = TempGuard(tmp_dir.clone()); + let tmp_db = tmp_dir.join("Cookies"); + copy_db(db, &tmp_db)?; + + let where_clause = build_where(domain)?; + let sql = format!( + "SELECT json_group_array(json_object(\ + 'h',host_key,'n',name,'e',hex(encrypted_value),'p',path,\ + 'sec',is_secure,'ho',is_httponly,'ss',samesite,'x',expires_utc)) \ + FROM cookies{};", + where_clause + ); + let output = std::process::Command::new("sqlite3") + .arg(tmp_db.to_string_lossy().to_string()) + .arg(&sql) + .output() + .map_err(|e| { + format!( + "could not run sqlite3 (required to read the cookie store): {}", + e + ) + })?; + if !output.status.success() { + return Err(format!( + "sqlite3 failed reading the cookie store: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let trimmed = stdout.trim(); + if trimmed.is_empty() || trimmed == "null" { + return Ok(Vec::new()); + } + let arr: Vec = + serde_json::from_str(trimmed).map_err(|e| format!("parsing cookie rows: {}", e))?; + let mut rows = Vec::with_capacity(arr.len()); + for v in arr { + let enc_hex = v.get("e").and_then(|x| x.as_str()).unwrap_or(""); + let path = v.get("p").and_then(|x| x.as_str()).unwrap_or("/"); + rows.push(CookieRow { + host_key: v + .get("h") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + name: v + .get("n") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + encrypted_value: hex::decode(enc_hex).unwrap_or_default(), + path: if path.is_empty() { + "/".to_string() + } else { + path.to_string() + }, + is_secure: v.get("sec").and_then(|x| x.as_i64()).unwrap_or(0) != 0, + is_httponly: v.get("ho").and_then(|x| x.as_i64()).unwrap_or(0) != 0, + samesite: v.get("ss").and_then(|x| x.as_i64()).unwrap_or(-1), + expires_utc: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0), + }); + } + Ok(rows) +} + +fn copy_db(src: &Path, dst: &Path) -> Result<(), String> { + std::fs::copy(src, dst).map_err(|e| format!("copying cookie store: {}", e))?; + for suffix in ["-wal", "-shm"] { + let s = path_with_suffix(src, suffix); + if s.is_file() { + let _ = std::fs::copy(&s, path_with_suffix(dst, suffix)); + } + } + Ok(()) +} + +fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf { + let mut s = p.as_os_str().to_os_string(); + s.push(suffix); + PathBuf::from(s) +} + +/// Build a `WHERE host_key LIKE '%domain'` clause from a comma-separated filter. +/// Domains are validated (alnum/./-) so they can be inlined without injection. +fn build_where(domain: Option<&str>) -> Result { + let Some(domain) = domain else { + return Ok(String::new()); + }; + let mut clauses = Vec::new(); + for d in domain.split(',') { + let d = d.trim(); + if d.is_empty() { + continue; + } + if !d + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') + { + return Err(format!("invalid domain filter \"{}\"", d)); + } + clauses.push(format!("host_key LIKE '%{}'", d)); + } + if clauses.is_empty() { + Ok(String::new()) + } else { + Ok(format!(" WHERE {}", clauses.join(" OR "))) + } +} + +fn to_cdp_cookie(r: &CookieRow, value: String) -> Value { + let mut o = serde_json::Map::new(); + o.insert("name".into(), json!(r.name)); + o.insert("value".into(), json!(value)); + o.insert("domain".into(), json!(r.host_key)); + o.insert("path".into(), json!(r.path)); + o.insert("secure".into(), json!(r.is_secure)); + o.insert("httpOnly".into(), json!(r.is_httponly)); + // Chrome SameSite: -1 unspecified, 0 None, 1 Lax, 2 Strict. + let same_site = match r.samesite { + 0 => Some("None"), + 1 => Some("Lax"), + 2 => Some("Strict"), + _ => None, + }; + if let Some(ss) = same_site { + // CDP rejects SameSite=None without Secure; downgrade rather than fail. + if ss == "None" && !r.is_secure { + o.insert("sameSite".into(), json!("Lax")); + } else { + o.insert("sameSite".into(), json!(ss)); + } + } + if let Some(unix) = chrome_epoch_to_unix(r.expires_utc) { + o.insert("expires".into(), json!(unix)); + } + Value::Object(o) +} + +/// Chrome stores `expires_utc` as microseconds since 1601-01-01 (0 = session +/// cookie). CDP wants seconds since the Unix epoch. Returns None for session +/// cookies and anything that converts to a non-positive time. +fn chrome_epoch_to_unix(expires_utc: i64) -> Option { + if expires_utc <= 0 { + return None; + } + let unix = expires_utc as f64 / 1_000_000.0 - 11_644_473_600.0; + if unix > 0.0 { + Some(unix) + } else { + None + } +} + +/// Decrypt a Chrome `v10` cookie value (AES-128-CBC, IV = 16 spaces, PKCS7). +/// Returns None for unrecognized schemes or undecryptable values. +fn decrypt_value(enc: &[u8], key: &[u8; 16]) -> Option { + if enc.len() < 3 || &enc[0..3] != b"v10" { + return None; + } + use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; + type Dec = cbc::Decryptor; + let iv = [0x20u8; 16]; + let mut buf = enc[3..].to_vec(); + let pt = Dec::new(key.into(), &iv.into()) + .decrypt_padded_mut::(&mut buf) + .ok()?; + // Chrome >=24 prepends a 32-byte SHA256(host) domain hash to the plaintext. + match std::str::from_utf8(pt) { + Ok(s) => Some(s.to_string()), + Err(_) if pt.len() > 32 => Some(String::from_utf8_lossy(&pt[32..]).into_owned()), + Err(_) => None, + } +} + +#[cfg(target_os = "macos")] +fn safe_storage_key() -> Result<[u8; 16], String> { + use pbkdf2::pbkdf2_hmac; + use sha1::Sha1; + let out = std::process::Command::new("security") + .args(["find-generic-password", "-ws", "Chrome Safe Storage"]) + .output() + .map_err(|e| format!("could not read Keychain (security command): {}", e))?; + if !out.status.success() { + return Err( + "could not read the 'Chrome Safe Storage' key from Keychain \ + (you may be prompted to allow access — approve it and retry)" + .to_string(), + ); + } + let pw = String::from_utf8_lossy(&out.stdout); + let pw = pw.trim_end_matches('\n'); + let mut key = [0u8; 16]; + pbkdf2_hmac::(pw.as_bytes(), b"saltysalt", 1003, &mut key); + Ok(key) +} + +#[cfg(not(target_os = "macos"))] +fn safe_storage_key() -> Result<[u8; 16], String> { + Err("cookies export/transfer is currently supported on macOS only".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn where_clause_filters_and_validates() { + assert_eq!(build_where(None).unwrap(), ""); + assert_eq!( + build_where(Some("claude.ai")).unwrap(), + " WHERE host_key LIKE '%claude.ai'" + ); + assert_eq!( + build_where(Some("claude.ai, anthropic.com")).unwrap(), + " WHERE host_key LIKE '%claude.ai' OR host_key LIKE '%anthropic.com'" + ); + assert!(build_where(Some("evil' OR 1=1 --")).is_err()); + } + + #[test] + fn epoch_conversion() { + assert_eq!(chrome_epoch_to_unix(0), None); + assert_eq!(chrome_epoch_to_unix(-5), None); + // 13380163200000000 us since 1601 == 2025-01-01T00:00:00Z (1735689600 unix) + assert_eq!( + chrome_epoch_to_unix(13_380_163_200_000_000), + Some(1_735_689_600.0) + ); + } + + #[test] + fn to_cdp_downgrades_samesite_none_without_secure() { + let row = CookieRow { + host_key: ".claude.ai".into(), + name: "x".into(), + encrypted_value: vec![], + path: "/".into(), + is_secure: false, + is_httponly: true, + samesite: 0, // None + expires_utc: 0, + }; + let c = to_cdp_cookie(&row, "v".into()); + assert_eq!(c["sameSite"], "Lax"); + assert_eq!(c["httpOnly"], true); + assert_eq!(c.get("expires"), None); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 000c972..605a5bf 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -3,6 +3,7 @@ mod color; mod commands; mod connect; mod connection; +mod cookie_export; mod doctor; mod findurl; mod flags; @@ -200,6 +201,64 @@ fn run_profiles(json_mode: bool) { } } +fn run_cookies_export(args: &[String], flags: &Flags) { + // Source profile comes from `--from `, falling back to the global + // `--profile` (which the flag parser has already moved into flags.profile). + let from = args + .iter() + .position(|a| a == "--from") + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()) + .or(flags.profile.as_deref()); + let profile = match from { + Some(p) => p, + None => { + let msg = "cookies export needs a source profile: cookies export --from [--domain ]"; + if flags.json { + print_json_error(msg); + } else { + eprintln!("{} {}", color::error_indicator(), msg); + } + exit(1); + } + }; + let domain = args + .iter() + .position(|a| a == "--domain") + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()); + + match cookie_export::export_cookies(profile, domain) { + Ok(cookies) => { + if flags.json { + print_json_value(json!({ "success": true, "data": cookies })); + } else { + // A JSON array ready for `cookies set --curl `. + println!( + "{}", + serde_json::to_string(&cookies).unwrap_or_else(|_| "[]".to_string()) + ); + eprintln!( + "{}", + color::dim(&format!( + "{} cookies exported from \"{}\"", + cookies.len(), + profile + )) + ); + } + } + Err(e) => { + if flags.json { + print_json_error(&e); + } else { + eprintln!("{} {}", color::error_indicator(), e); + } + exit(1); + } + } +} + fn run_session(args: &[String], session: &str, json_mode: bool) { let subcommand = args.get(1).map(|s| s.as_str()); @@ -639,6 +698,15 @@ fn main() { return; } + // Handle `cookies export` (doesn't need daemon): decrypt an on-disk Chrome + // profile's cookies and print them as JSON for `cookies set --curl`. + if clean.first().map(|s| s.as_str()) == Some("cookies") + && clean.get(1).map(|s| s.as_str()) == Some("export") + { + run_cookies_export(&clean, &flags); + return; + } + // Handle skills command (doesn't need daemon) if clean.first().map(|s| s.as_str()) == Some("skills") { skills::run_skills(&clean, flags.json); diff --git a/cli/src/output.rs b/cli/src/output.rs index 78bf567..0ef4c7e 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -3101,6 +3101,10 @@ Network: chrome-use network Storage: cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires) Or: cookies set --curl [--domain ] (auto-detects JSON/cURL/Cookie-header files) + cookies export --from [--domain [,]] + Decrypt another Chrome profile's cookies -> JSON for `cookies set --curl` (macOS) + cookies transfer --from [--domain [,]] + Copy a logged-in session from another profile into the connected browser (macOS) storage Manage web storage Tabs: