Skip to main content

browser_commander/browser/migration/
cookies.rs

1//! Cookie migration, mirroring `js/src/browser/migration/cookies.js`.
2//!
3//! Cookies are read from the source profile with the existing
4//! [`read_browser_cookies`] (which handles the per-platform keystore and the
5//! v10/v11 decryption), optionally filtered by domain, and returned so the
6//! launcher can seed them into the dedicated profile over CDP with the existing
7//! `seed_cookies` path. This module does not write anything into the target
8//! profile itself; the CDP seed is what a real, running browser accepts.
9//!
10//! ## Device Bound Session Credentials (DBSC)
11//!
12//! Google has shipped Device Bound Session Credentials: the session is bound to
13//! a private key held in the device's TPM or Secure Enclave, and the short
14//! "session token" cookies are rotated by the browser using that key. The key
15//! cannot leave the profile, so a copied Google session cannot be refreshed
16//! from another profile and expires quickly.
17//!
18//! References:
19//! - <https://developer.chrome.com/docs/web-platform/device-bound-session-credentials>
20//! - <https://github.com/WICG/dbsc> (the DBSC explainer)
21//!
22//! Detection is twofold:
23//!
24//! 1. By name+domain: the rotating bound cookies Chrome refreshes for a Google
25//!    sign-in are `__Secure-1PSIDTS`, `__Secure-3PSIDTS` and `SIDTS` on
26//!    `google.*` hosts. These are copied (so the rest of the session's cookies
27//!    stay coherent) but reported in `skipped` with the reason `dbsc-bound`.
28//! 2. By registration: if the source profile stores a DBSC registration
29//!    database (`DeviceBoundSessions`, under the profile's `Network`
30//!    directory), a warning is added. The registration itself is not copied
31//!    because it is bound to the source device's key.
32
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, LazyLock};
36
37use anyhow::Result;
38use regex::Regex;
39
40use super::fs_utils::path_exists;
41use super::{ClassOutcome, MigrationEntry};
42use crate::browser::browser_cookies::{
43    read_browser_cookies, BrowserCookie, BrowserCookieReadOptions,
44};
45
46/// Rotating Google session-token cookies that DBSC binds to the device key.
47pub const DBSC_BOUND_COOKIE_NAMES: [&str; 3] = ["__Secure-1PSIDTS", "__Secure-3PSIDTS", "SIDTS"];
48
49/// DBSC registration databases Chrome may write in the profile.
50const DBSC_REGISTRATION_FILES: [&str; 2] = ["Network/DeviceBoundSessions", "DeviceBoundSessions"];
51
52const DBSC_REGISTRATION_DETAIL: &str = "The source profile has a Device Bound Session registration; cookies covered by it are bound to the source device key and will expire in the migrated profile.";
53
54/// Reads installed-browser cookies; the default is [`read_browser_cookies`].
55pub type CookieReader =
56    Arc<dyn Fn(BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> + Send + Sync>;
57
58/// The default cookie reader.
59pub(crate) fn default_cookie_reader() -> CookieReader {
60    Arc::new(read_browser_cookies)
61}
62
63static GOOGLE_COUNTRY_HOST: LazyLock<Regex> = LazyLock::new(|| {
64    Regex::new(r"(^|\.)google\.[a-z.]+$").expect("the Google host pattern is valid")
65});
66
67fn is_google_host(domain: &str) -> bool {
68    let host = domain.strip_prefix('.').unwrap_or(domain).to_lowercase();
69    host == "google.com" || host.ends_with(".google.com") || GOOGLE_COUNTRY_HOST.is_match(&host)
70}
71
72/// Decide whether a cookie is a DBSC-bound Google session cookie.
73pub(crate) fn is_dbsc_bound_cookie(cookie: &BrowserCookie) -> bool {
74    is_google_host(&cookie.domain) && DBSC_BOUND_COOKIE_NAMES.contains(&cookie.name.as_str())
75}
76
77fn find_dbsc_registration(profile_dir: &Path) -> Option<PathBuf> {
78    DBSC_REGISTRATION_FILES
79        .iter()
80        .map(|relative| profile_dir.join(relative))
81        .find(|candidate| path_exists(candidate))
82}
83
84/// Where the source cookies come from.
85pub(crate) struct CookieSource<'a> {
86    pub browser: &'a str,
87    pub profile: Option<&'a str>,
88    pub source_profile_dir: Option<&'a Path>,
89    pub domains: &'a [String],
90    pub platform: &'a str,
91    pub home_dir: &'a Path,
92}
93
94/// Read cookies from the source browser, tag the DBSC-bound ones, and return
95/// the cookies to seed plus the migration report fragment.
96pub(crate) fn migrate_cookies(
97    source: &CookieSource<'_>,
98    read_cookies: &CookieReader,
99) -> Result<(Vec<BrowserCookie>, ClassOutcome)> {
100    let domain_filters: Vec<Option<&str>> = if source.domains.is_empty() {
101        vec![None]
102    } else {
103        source
104            .domains
105            .iter()
106            .map(|domain| Some(domain.as_str()))
107            .collect()
108    };
109
110    // A JavaScript Map keeps the first insertion position and the last value.
111    let mut order: Vec<String> = Vec::new();
112    let mut seen: HashMap<String, BrowserCookie> = HashMap::new();
113    for domain_filter in domain_filters {
114        let mut options = BrowserCookieReadOptions::new(source.browser)
115            .ignore_decryption_errors(true)
116            .platform(source.platform)
117            .home_dir(source.home_dir);
118        if let Some(profile) = source.profile {
119            options = options.profile(profile);
120        }
121        // Pass the already-resolved profile directory (honouring a custom
122        // userDataDir) so the reader does not re-resolve the default location.
123        if let Some(profile_dir) = source.source_profile_dir {
124            options = options.profile_dir(profile_dir);
125        }
126        if let Some(domain) = domain_filter {
127            options = options.domain_filter(domain);
128        }
129        for cookie in read_cookies(options)? {
130            if !super::domains::matches_domains(&cookie.domain, source.domains) {
131                continue;
132            }
133            let key = format!("{}\0{}\0{}", cookie.domain, cookie.name, cookie.path);
134            if !seen.contains_key(&key) {
135                order.push(key.clone());
136            }
137            seen.insert(key, cookie);
138        }
139    }
140    let cookies: Vec<BrowserCookie> = order.iter().filter_map(|key| seen.remove(key)).collect();
141
142    let skipped = cookies
143        .iter()
144        .filter(|cookie| is_dbsc_bound_cookie(cookie))
145        .map(|cookie| {
146            MigrationEntry::new(
147                "cookies",
148                format!("{} {}", cookie.domain, cookie.name),
149                "dbsc-bound",
150            )
151        })
152        .collect();
153
154    let mut warnings = Vec::new();
155    if source
156        .source_profile_dir
157        .and_then(find_dbsc_registration)
158        .is_some()
159    {
160        warnings.push(
161            MigrationEntry::new(
162                "cookies",
163                "DeviceBoundSessions",
164                "dbsc-registration-present",
165            )
166            .with_detail(DBSC_REGISTRATION_DETAIL),
167        );
168    }
169
170    let outcome = ClassOutcome {
171        migrated: cookies.len() as u64,
172        skipped,
173        warnings,
174    };
175    Ok((cookies, outcome))
176}