|
| 1 | +//! Cookie extraction for the Claude desktop app's claude.ai session. |
| 2 | +//! |
| 3 | +//! Reads `~/Library/Application Support/Claude/Cookies` (Chromium-format SQLite, |
| 4 | +//! AES-128-CBC encrypted with a Keychain-stored master key). The DB is copied |
| 5 | +//! to a tempfile first to avoid lock contention while the app is running. |
| 6 | +
|
| 7 | +use std::path::Path; |
| 8 | + |
| 9 | +const COOKIE_NAMES: &[&str] = &["sessionKey", "lastActiveOrg"]; |
| 10 | + |
| 11 | +pub fn read_claude_app_cookies() -> Result<std::collections::HashMap<String, String>, String> { |
| 12 | + let home = dirs::home_dir().ok_or_else(|| "no home dir".to_string())?; |
| 13 | + let cookies_db = home.join("Library/Application Support/Claude/Cookies"); |
| 14 | + if !cookies_db.exists() { |
| 15 | + return Err("Claude desktop app cookies db not found".to_string()); |
| 16 | + } |
| 17 | + |
| 18 | + let tmp = std::env::temp_dir().join(format!("lovcode-cookies-{}.db", std::process::id())); |
| 19 | + std::fs::copy(&cookies_db, &tmp).map_err(|e| format!("copy cookies db: {}", e))?; |
| 20 | + let result = read_cookies_from_db(&tmp); |
| 21 | + let _ = std::fs::remove_file(&tmp); |
| 22 | + result |
| 23 | +} |
| 24 | + |
| 25 | +fn read_cookies_from_db(db_path: &Path) -> Result<std::collections::HashMap<String, String>, String> { |
| 26 | + let conn = rusqlite::Connection::open_with_flags( |
| 27 | + db_path, |
| 28 | + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, |
| 29 | + ).map_err(|e| format!("open cookies db: {}", e))?; |
| 30 | + |
| 31 | + let names_in: String = COOKIE_NAMES.iter().map(|n| format!("'{}'", n)).collect::<Vec<_>>().join(","); |
| 32 | + let q = format!( |
| 33 | + "SELECT name, value, encrypted_value FROM cookies \ |
| 34 | + WHERE host_key LIKE '%claude.ai%' AND name IN ({})", |
| 35 | + names_in |
| 36 | + ); |
| 37 | + |
| 38 | + let mut stmt = conn.prepare(&q).map_err(|e| e.to_string())?; |
| 39 | + let rows = stmt.query_map([], |row| { |
| 40 | + let name: String = row.get(0)?; |
| 41 | + let value: String = row.get(1)?; |
| 42 | + let enc: Vec<u8> = row.get(2)?; |
| 43 | + Ok((name, value, enc)) |
| 44 | + }).map_err(|e| e.to_string())?; |
| 45 | + |
| 46 | + let key = derive_keychain_key()?; |
| 47 | + let mut out = std::collections::HashMap::new(); |
| 48 | + for r in rows { |
| 49 | + let (name, plain, enc) = r.map_err(|e| e.to_string())?; |
| 50 | + let v = if !plain.is_empty() { |
| 51 | + plain |
| 52 | + } else if enc.is_empty() { |
| 53 | + continue |
| 54 | + } else { |
| 55 | + decrypt_cookie(&enc, &key)? |
| 56 | + }; |
| 57 | + out.insert(name, v); |
| 58 | + } |
| 59 | + Ok(out) |
| 60 | +} |
| 61 | + |
| 62 | +#[cfg(target_os = "macos")] |
| 63 | +fn derive_keychain_key() -> Result<[u8; 16], String> { |
| 64 | + use security_framework::passwords::get_generic_password; |
| 65 | + // Chromium-derived apps store an AES master password under a service named |
| 66 | + // `<AppName> Safe Storage`. We don't know the exact string, so try common |
| 67 | + // variants. The account field is typically the same as the app name. |
| 68 | + let candidates: &[(&str, &str)] = &[ |
| 69 | + ("Claude Safe Storage", "Claude Key"), |
| 70 | + ]; |
| 71 | + let mut tried = Vec::new(); |
| 72 | + for (service, account) in candidates { |
| 73 | + match get_generic_password(service, account) { |
| 74 | + Ok(pw) => return derive_aes_key(&pw), |
| 75 | + Err(e) => { |
| 76 | + let s = e.to_string(); |
| 77 | + tried.push(format!("'{}'/'{}'->{}", service, account, s)); |
| 78 | + // ACL denial vs entry-missing have very different fixes. |
| 79 | + if s.contains("not correct") || s.contains("not authorized") || s.contains("user name") { |
| 80 | + return Err(format!( |
| 81 | + "macOS keychain blocked lovcode from reading 'Claude Safe Storage'. \ |
| 82 | + Open Keychain Access, search 'Claude Safe Storage', double-click → \ |
| 83 | + Access Control tab → add this binary or check 'Allow all applications'. \ |
| 84 | + Original error: {}", |
| 85 | + s |
| 86 | + )); |
| 87 | + } |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + Err(format!( |
| 92 | + "Claude Safe Storage keychain entry not found. Tried: {}", |
| 93 | + tried.join("; ") |
| 94 | + )) |
| 95 | +} |
| 96 | + |
| 97 | +#[cfg(not(target_os = "macos"))] |
| 98 | +fn derive_keychain_key() -> Result<[u8; 16], String> { |
| 99 | + Err("cookie decryption only implemented on macOS".to_string()) |
| 100 | +} |
| 101 | + |
| 102 | +fn derive_aes_key(passphrase: &[u8]) -> Result<[u8; 16], String> { |
| 103 | + use pbkdf2::pbkdf2_hmac; |
| 104 | + use sha1::Sha1; |
| 105 | + let salt = b"saltysalt"; |
| 106 | + let mut key = [0u8; 16]; |
| 107 | + pbkdf2_hmac::<Sha1>(passphrase, salt, 1003, &mut key); |
| 108 | + Ok(key) |
| 109 | +} |
| 110 | + |
| 111 | +fn decrypt_cookie(enc: &[u8], key: &[u8; 16]) -> Result<String, String> { |
| 112 | + use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; |
| 113 | + type Aes128CbcDec = cbc::Decryptor<aes::Aes128>; |
| 114 | + |
| 115 | + if enc.len() < 3 || (&enc[0..3] != b"v10" && &enc[0..3] != b"v11") { |
| 116 | + let prefix_hex: String = enc.iter().take(8).map(|b| format!("{:02x}", b)).collect(); |
| 117 | + return Err(format!( |
| 118 | + "unknown cookie encryption prefix: first 8 bytes = {}", |
| 119 | + prefix_hex |
| 120 | + )); |
| 121 | + } |
| 122 | + let body = &enc[3..]; |
| 123 | + let iv = [b' '; 16]; |
| 124 | + let cipher = Aes128CbcDec::new(key.into(), &iv.into()); |
| 125 | + let mut buf = body.to_vec(); |
| 126 | + let plain = cipher.decrypt_padded_mut::<Pkcs7>(&mut buf) |
| 127 | + .map_err(|e| format!("decrypt cookie: {}", e))?; |
| 128 | + |
| 129 | + // Chromium >=v10 prepends a 32-byte SHA256 of the host. Strip if it parses |
| 130 | + // cleaner without it; otherwise return the raw decoded bytes. |
| 131 | + if plain.len() > 32 { |
| 132 | + if let Ok(s) = std::str::from_utf8(&plain[32..]) { |
| 133 | + if !s.is_empty() { |
| 134 | + return Ok(s.to_string()); |
| 135 | + } |
| 136 | + } |
| 137 | + } |
| 138 | + String::from_utf8(plain.to_vec()).map_err(|e| format!("utf8: {}", e)) |
| 139 | +} |
0 commit comments