Skip to main content

browser_commander/browser/
browser_cookies.rs

1//! Import cookies from installed Chrome-family and Firefox profiles.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use anyhow::{anyhow, Context, Result};
7use rusqlite::{params, Connection, OpenFlags};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10
11use super::browser_cookie_cache::{
12    get_cached_credential, normalize_cookie_cache, read_cookie_result_cache,
13    write_cookie_result_cache, NormalizedCookieCache,
14};
15use super::browser_cookie_credentials::{
16    decrypt_windows_dpapi, read_safe_storage_password, read_windows_encryption_key,
17};
18use super::browser_cookie_crypto::{
19    chromium_same_site, decode_chromium_plaintext, decrypt_chromium_cookie,
20    derive_chromium_cookie_key, firefox_same_site,
21};
22use super::browser_profiles::{
23    find_cookie_database, normalize_cookie_browser, resolve_browser_profile, BrowserProfile,
24    BrowserProfileOptions,
25};
26
27const CHROME_EPOCH_OFFSET_SECONDS: i64 = 11_644_473_600;
28
29/// A browser cookie in the shape accepted by Playwright/Puppeteer contexts.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct BrowserCookie {
33    /// Cookie name.
34    pub name: String,
35    /// Decrypted cookie value.
36    pub value: String,
37    /// Cookie domain, including any leading dot.
38    pub domain: String,
39    /// Cookie path.
40    pub path: String,
41    /// Unix expiry seconds, or `-1` for a session cookie.
42    pub expires: i64,
43    /// Whether JavaScript is prevented from reading the cookie.
44    pub http_only: bool,
45    /// Whether the cookie is restricted to secure transports.
46    pub secure: bool,
47    /// `Strict`, `Lax`, or `None`.
48    pub same_site: String,
49}
50
51/// Options for [`read_browser_cookies`].
52#[derive(Debug, Clone)]
53pub struct BrowserCookieReadOptions {
54    /// Installed browser name.
55    pub browser: String,
56    /// Optional on-disk or display profile name.
57    pub profile: Option<String>,
58    /// Optional domain substring used by the SQLite query.
59    pub domain_filter: Option<String>,
60    /// Enable the owner-only decrypted-result and derived-key cache.
61    pub cache: bool,
62    /// Override the cache directory.
63    pub cache_dir: Option<PathBuf>,
64    /// Cache lifetime in minutes.
65    pub ttl_minutes: Option<f64>,
66    /// Bypass cached values and coordinate one refreshed credential read.
67    pub refresh: bool,
68    /// Skip individual cookies that cannot be decrypted.
69    pub ignore_decryption_errors: bool,
70    /// Home directory used for profile discovery and the default cache.
71    pub home_dir: PathBuf,
72    /// Platform convention (`linux`, `darwin`, or `win32`).
73    pub platform: String,
74}
75
76impl BrowserCookieReadOptions {
77    /// Create options for one installed browser.
78    pub fn new(browser: impl Into<String>) -> Self {
79        Self {
80            browser: browser.into(),
81            profile: None,
82            domain_filter: None,
83            cache: true,
84            cache_dir: None,
85            ttl_minutes: None,
86            refresh: false,
87            ignore_decryption_errors: false,
88            home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
89            platform: super::browser_profiles::current_platform().to_string(),
90        }
91    }
92
93    /// Select a named installed-browser profile.
94    pub fn profile(mut self, profile: impl Into<String>) -> Self {
95        self.profile = Some(profile.into());
96        self
97    }
98
99    /// Restrict the SQLite query to domains containing this value.
100    pub fn domain_filter(mut self, domain: impl Into<String>) -> Self {
101        self.domain_filter = Some(domain.into());
102        self
103    }
104
105    /// Enable or disable disk caching.
106    pub fn cache(mut self, enabled: bool) -> Self {
107        self.cache = enabled;
108        self
109    }
110
111    /// Override the owner-only cache directory.
112    pub fn cache_dir(mut self, directory: impl Into<PathBuf>) -> Self {
113        self.cache_dir = Some(directory.into());
114        self
115    }
116
117    /// Set the decrypted-result and derived-key cache TTL.
118    pub fn ttl_minutes(mut self, minutes: f64) -> Self {
119        self.ttl_minutes = Some(minutes);
120        self
121    }
122
123    /// Force a coordinated refresh of cached results and credentials.
124    pub fn refresh(mut self, refresh: bool) -> Self {
125        self.refresh = refresh;
126        self
127    }
128
129    /// Skip cookies whose platform decryption fails.
130    pub fn ignore_decryption_errors(mut self, ignore: bool) -> Self {
131        self.ignore_decryption_errors = ignore;
132        self
133    }
134
135    /// Override the home directory used for discovery.
136    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
137        self.home_dir = home_dir.into();
138        self
139    }
140
141    /// Override the platform convention, primarily for portable tooling/tests.
142    pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
143        self.platform = super::browser_profiles::normalize_platform(platform.as_ref()).to_string();
144        self
145    }
146}
147
148#[derive(Debug)]
149struct ChromiumRow {
150    host: String,
151    name: String,
152    value: String,
153    encrypted_value: Vec<u8>,
154    path: String,
155    expires: i64,
156    secure: bool,
157    http_only: bool,
158    same_site: i64,
159}
160
161#[derive(Default)]
162struct OperationKeyCache {
163    attempts: HashMap<String, std::result::Result<Vec<u8>, String>>,
164}
165
166struct CookieDecryptionState<'a> {
167    cache: &'a NormalizedCookieCache,
168    operation_keys: OperationKeyCache,
169}
170
171impl OperationKeyCache {
172    fn get_or_try_create<F>(&mut self, identity: &str, create: F) -> Result<Vec<u8>>
173    where
174        F: FnOnce() -> Result<Vec<u8>>,
175    {
176        if !self.attempts.contains_key(identity) {
177            self.attempts.insert(
178                identity.to_string(),
179                create().map_err(|error| error.to_string()),
180            );
181        }
182        self.attempts[identity]
183            .clone()
184            .map_err(|error| anyhow!(error))
185    }
186}
187
188fn open_cookie_database(path: &std::path::Path) -> Result<Connection> {
189    Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
190        .with_context(|| format!("Could not open browser cookie database: {}", path.display()))
191}
192
193fn read_database_version(database: &Connection) -> i64 {
194    database
195        .query_row("SELECT value FROM meta WHERE key = 'version'", [], |row| {
196            row.get::<_, String>(0)
197        })
198        .ok()
199        .and_then(|value| value.parse().ok())
200        .unwrap_or_default()
201}
202
203fn domain_pattern(domain_filter: Option<&str>) -> Option<String> {
204    domain_filter.map(|domain| format!("%{domain}%"))
205}
206
207fn read_chromium_rows(
208    database: &Connection,
209    domain_filter: Option<&str>,
210) -> Result<Vec<ChromiumRow>> {
211    let where_clause = domain_filter
212        .map(|_| " WHERE host_key LIKE ?1")
213        .unwrap_or("");
214    let query = format!(
215        "SELECT host_key, name, value, encrypted_value, path, expires_utc, \
216         is_secure, is_httponly, samesite FROM cookies{where_clause} \
217         ORDER BY host_key, name, path"
218    );
219    let mut statement = database.prepare(&query)?;
220    let pattern = domain_pattern(domain_filter);
221    let mapper = |row: &rusqlite::Row<'_>| {
222        Ok(ChromiumRow {
223            host: row.get(0)?,
224            name: row.get(1)?,
225            value: row.get(2)?,
226            encrypted_value: row.get(3)?,
227            path: row.get(4)?,
228            expires: row.get(5)?,
229            secure: row.get::<_, i64>(6)? != 0,
230            http_only: row.get::<_, i64>(7)? != 0,
231            same_site: row.get(8)?,
232        })
233    };
234    let rows = if let Some(pattern) = pattern.as_deref() {
235        statement.query_map(params![pattern], mapper)?
236    } else {
237        statement.query_map([], mapper)?
238    };
239    rows.collect::<rusqlite::Result<Vec<_>>>()
240        .map_err(Into::into)
241}
242
243fn read_firefox_cookies(
244    database: &Connection,
245    domain_filter: Option<&str>,
246) -> Result<Vec<BrowserCookie>> {
247    let where_clause = domain_filter.map(|_| " WHERE host LIKE ?1").unwrap_or("");
248    let query = format!(
249        "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite \
250         FROM moz_cookies{where_clause} ORDER BY host, name, path"
251    );
252    let mut statement = database.prepare(&query)?;
253    let pattern = domain_pattern(domain_filter);
254    let mapper = |row: &rusqlite::Row<'_>| {
255        let expires = row.get::<_, i64>(4)?;
256        Ok(BrowserCookie {
257            name: row.get(0)?,
258            value: row.get(1)?,
259            domain: row.get(2)?,
260            path: row.get::<_, String>(3).map(|path| {
261                if path.is_empty() {
262                    "/".to_string()
263                } else {
264                    path
265                }
266            })?,
267            expires: if expires > 0 { expires } else { -1 },
268            secure: row.get::<_, i64>(5)? != 0,
269            http_only: row.get::<_, i64>(6)? != 0,
270            same_site: firefox_same_site(row.get(7)?).to_string(),
271        })
272    };
273    let rows = if let Some(pattern) = pattern.as_deref() {
274        statement.query_map(params![pattern], mapper)?
275    } else {
276        statement.query_map([], mapper)?
277    };
278    rows.collect::<rusqlite::Result<Vec<_>>>()
279        .map_err(Into::into)
280}
281
282fn credential_metadata(browser: &str, platform: &str, source: &str) -> Map<String, Value> {
283    let mut metadata = Map::new();
284    metadata.insert("browser".into(), json!(browser));
285    metadata.insert("platform".into(), json!(platform));
286    metadata.insert("source".into(), json!(source));
287    metadata
288}
289
290fn chromium_key_for_prefix(
291    prefix: &[u8],
292    browser: &str,
293    platform: &str,
294    profile: &BrowserProfile,
295    refresh: bool,
296    state: &mut CookieDecryptionState<'_>,
297) -> Result<Vec<u8>> {
298    if platform == "linux" && prefix == b"v10" {
299        return derive_chromium_cookie_key("peanuts", "linux");
300    }
301    if platform == "linux" || platform == "darwin" {
302        let identity = format!("{browser}:{platform}:safe-storage");
303        return state.operation_keys.get_or_try_create(&identity, || {
304            get_cached_credential(
305                state.cache,
306                &identity,
307                refresh,
308                credential_metadata(browser, platform, "safe-storage"),
309                || {
310                    derive_chromium_cookie_key(
311                        &read_safe_storage_password(browser, platform)?,
312                        platform,
313                    )
314                },
315            )
316        });
317    }
318    if platform == "win32" {
319        let identity = format!("{browser}:win32:legacy-aes-key");
320        return state.operation_keys.get_or_try_create(&identity, || {
321            get_cached_credential(
322                state.cache,
323                &identity,
324                refresh,
325                credential_metadata(browser, platform, "dpapi"),
326                || {
327                    read_windows_encryption_key(
328                        &profile
329                            .path
330                            .parent()
331                            .unwrap_or(&profile.path)
332                            .join("Local State"),
333                    )
334                },
335            )
336        });
337    }
338    Err(anyhow!(
339        "Chromium cookie decryption is unsupported on {platform}"
340    ))
341}
342
343fn chromium_expires(value: i64) -> i64 {
344    if value == 0 {
345        -1
346    } else {
347        value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS
348    }
349}
350
351fn decrypt_chromium_row(
352    row: ChromiumRow,
353    database_version: i64,
354    browser: &str,
355    platform: &str,
356    profile: &BrowserProfile,
357    refresh: bool,
358    state: &mut CookieDecryptionState<'_>,
359) -> Result<BrowserCookie> {
360    let value = if !row.value.is_empty() {
361        row.value
362    } else if row.encrypted_value.is_empty() {
363        String::new()
364    } else {
365        let prefix = row.encrypted_value.get(..3).unwrap_or_default();
366        if platform == "win32" && prefix != b"v10" && prefix != b"v11" {
367            if prefix == b"v20" {
368                decrypt_chromium_cookie(
369                    &row.encrypted_value,
370                    &row.host,
371                    database_version,
372                    platform,
373                    &[0_u8; 32],
374                )?
375            } else {
376                decode_chromium_plaintext(
377                    &decrypt_windows_dpapi(&row.encrypted_value)?,
378                    &row.host,
379                    database_version,
380                )?
381            }
382        } else {
383            let key = chromium_key_for_prefix(prefix, browser, platform, profile, refresh, state)?;
384            decrypt_chromium_cookie(
385                &row.encrypted_value,
386                &row.host,
387                database_version,
388                platform,
389                &key,
390            )?
391        }
392    };
393    Ok(BrowserCookie {
394        name: row.name,
395        value,
396        domain: row.host,
397        path: if row.path.is_empty() {
398            "/".into()
399        } else {
400            row.path
401        },
402        expires: chromium_expires(row.expires),
403        http_only: row.http_only,
404        secure: row.secure,
405        same_site: chromium_same_site(row.same_site).to_string(),
406    })
407}
408
409fn read_chromium_cookies(
410    database: &Connection,
411    profile: &BrowserProfile,
412    options: &BrowserCookieReadOptions,
413    cache: &NormalizedCookieCache,
414) -> Result<Vec<BrowserCookie>> {
415    let version = read_database_version(database);
416    let mut cookies = Vec::new();
417    let mut state = CookieDecryptionState {
418        cache,
419        operation_keys: OperationKeyCache::default(),
420    };
421    for row in read_chromium_rows(database, options.domain_filter.as_deref())? {
422        let name = row.name.clone();
423        let host = row.host.clone();
424        match decrypt_chromium_row(
425            row,
426            version,
427            &options.browser,
428            &options.platform,
429            profile,
430            options.refresh,
431            &mut state,
432        ) {
433            Ok(cookie) => cookies.push(cookie),
434            Err(_) if options.ignore_decryption_errors => {}
435            Err(error) => {
436                return Err(anyhow!(
437                    "Could not decrypt cookie {name} for {host}: {error}"
438                ))
439            }
440        }
441    }
442    Ok(cookies)
443}
444
445/// Read cookies from an installed Chrome, Edge, Brave, Chromium, or Firefox profile.
446pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> {
447    options.browser = normalize_cookie_browser(&options.browser)?.to_string();
448    let discovery = BrowserProfileOptions::default()
449        .browser(&options.browser)
450        .home_dir(&options.home_dir)
451        .platform(&options.platform);
452    let profile =
453        resolve_browser_profile(&options.browser, options.profile.as_deref(), &discovery)?;
454    let cookie_path = find_cookie_database(&options.browser, &profile.path)
455        .ok_or_else(|| anyhow!("No cookie database exists in {}", profile.path.display()))?;
456    let cache = normalize_cookie_cache(
457        options.cache,
458        options.cache_dir.as_deref(),
459        &options.home_dir,
460        options.ttl_minutes,
461    )?;
462    let identity = serde_json::to_string(&json!({
463        "browser": options.browser,
464        "profile": profile.path,
465        "domainFilter": options.domain_filter,
466        "ignoreDecryptionErrors": options.ignore_decryption_errors,
467    }))?;
468    if let Some(values) = read_cookie_result_cache(&cache, &identity, options.refresh) {
469        return values
470            .into_iter()
471            .map(serde_json::from_value)
472            .collect::<serde_json::Result<Vec<_>>>()
473            .context("cached cookies have an invalid shape");
474    }
475
476    let database = open_cookie_database(&cookie_path)?;
477    let cookies = if options.browser == "firefox" {
478        read_firefox_cookies(&database, options.domain_filter.as_deref())?
479    } else {
480        read_chromium_cookies(&database, &profile, &options, &cache)?
481    };
482    let serialized = cookies
483        .iter()
484        .map(serde_json::to_value)
485        .collect::<serde_json::Result<Vec<_>>>()?;
486    write_cookie_result_cache(&cache, &identity, &serialized)?;
487    Ok(cookies)
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn operation_key_cache_reads_a_refreshed_credential_once() -> Result<()> {
496        let mut keys = OperationKeyCache::default();
497        let mut calls = 0;
498        assert_eq!(
499            keys.get_or_try_create("safe-storage", || {
500                calls += 1;
501                Ok(vec![7_u8; 16])
502            })?,
503            vec![7_u8; 16]
504        );
505        assert_eq!(
506            keys.get_or_try_create("safe-storage", || {
507                calls += 1;
508                Ok(vec![8_u8; 16])
509            })?,
510            vec![7_u8; 16]
511        );
512        assert_eq!(calls, 1);
513        Ok(())
514    }
515}