Skip to main content

rustenium_identity/
cdp.rs

1use crate::error::IdentityError;
2use crate::identity::{Browser, Identity, Os};
3
4use crate::script;
5use crate::ua;
6
7use rustenium::browsers::chrome::browser::ChromeBrowser;
8use rustenium::browsers::cdp_browser::CdpBrowser;
9use rustenium::cdp::target_manager::InitScript;
10use rustenium_cdp_definitions::browser_protocol::emulation::commands::{
11    SetDeviceMetricsOverride, SetEmitTouchEventsForMouse, SetEmitTouchEventsForMouseConfiguration,
12    SetHardwareConcurrencyOverride, SetLocaleOverride, SetTimezoneOverride,
13    SetTouchEmulationEnabled, SetUserAgentOverride,
14};
15use rustenium_cdp_definitions::browser_protocol::emulation::types::{
16    UserAgentBrandVersion, UserAgentMetadata,
17};
18
19/// The GREASE brand a given Chromium major sends, and the version it carries.
20///
21/// Both rotate with the major, so any fixed pair is wrong for all but one
22/// release — `Not;A=Brand`/`24`, the value this used to hardcode, is Chrome
23/// 150's brand paired with Chrome 152's version, which is a combination no
24/// browser has ever sent. The point of a greasey brand is that a parser must
25/// tolerate junk; a *stale* one is a version claim that contradicts the version
26/// claimed everywhere else.
27///
28/// Chromium builds it by indexing two tables with the major version. Verified
29/// against Chrome 151 (`Not=A?Brand`/`99`) and Chrome for Testing 152
30/// (`Not?A_Brand`/`24`), both read off a running browser.
31fn grease(major: u16) -> (String, &'static str) {
32    const CHARS: [&str; 11] = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
33    const VERSIONS: [&str; 3] = ["8", "99", "24"];
34    let seed = major as usize;
35    (
36        format!(
37            "Not{}A{}Brand",
38            CHARS[seed % CHARS.len()],
39            CHARS[(seed + 1) % CHARS.len()]
40        ),
41        VERSIONS[seed % VERSIONS.len()],
42    )
43}
44
45/// Build UserAgentMetadata (Client Hints) for Chrome/Edge identities.
46fn build_client_hints(identity: &Identity) -> UserAgentMetadata {
47    let major_number = identity.browser_version.first().copied().unwrap_or(0);
48    let major = major_number.to_string();
49    let full_version = identity
50        .browser_version
51        .iter()
52        .map(|v| v.to_string())
53        .collect::<Vec<_>>()
54        .join(".");
55    let is_mobile = identity.device_model.is_some();
56
57    let (brand_name, brand_name_full) = match identity.browser {
58        Browser::Edge => ("Microsoft Edge", "Microsoft Edge"),
59        _ => ("Google Chrome", "Google Chrome"),
60    };
61
62    let (grease_brand, grease_version) = grease(major_number);
63
64    // Greasey brand first, then the branded entry, then Chromium — the order a
65    // real Chrome was measured to send. Chromium permutes the list by the same
66    // seed; that table is not replicated here, so this is one observed layout
67    // rather than a derivation.
68    let brands = vec![
69        UserAgentBrandVersion::new(&grease_brand, grease_version),
70        UserAgentBrandVersion::new(brand_name, &major),
71        UserAgentBrandVersion::new("Chromium", &major),
72    ];
73
74    let full_version_list = vec![
75        UserAgentBrandVersion::new(&grease_brand, &format!("{grease_version}.0.0.0")),
76        UserAgentBrandVersion::new(brand_name_full, &full_version),
77        UserAgentBrandVersion::new("Chromium", &full_version),
78    ];
79
80    let platform = match identity.os {
81        Os::Windows => "Windows",
82        Os::Macintosh => "macOS",
83        Os::Linux => "Linux",
84        Os::Android => "Android",
85        Os::Ios => "iOS",
86    };
87
88    // On mobile, architecture and bitness should be empty
89    let (architecture, bitness) = if is_mobile {
90        (String::new(), String::new())
91    } else {
92        (
93            identity.platform.architecture.clone().unwrap_or_else(|| "x86".into()),
94            identity.platform.bitness.clone().unwrap_or_else(|| "64".into()),
95        )
96    };
97    let model = identity.device_model.clone().unwrap_or_default();
98
99    UserAgentMetadata::builder()
100        .brands(brands)
101        .full_version_lists(full_version_list)
102        .platform(platform)
103        .platform_version(&identity.platform.version)
104        .architecture(&architecture)
105        .model(&model)
106        .mobile(is_mobile)
107        .bitness(bitness)
108        .build()
109        .unwrap()
110}
111
112/// Apply all CDP emulation commands and register the stealth script.
113pub async fn apply_identity(
114    browser: &mut ChromeBrowser,
115    identity: &Identity,
116    timezone: &str,
117) -> Result<(), IdentityError> {
118    let user_agent = ua::build_user_agent(identity)?;
119    let is_mobile = identity.device_model.is_some();
120
121    // The primary tag drives both `navigator.language` and the engine's own
122    // locale; the whole list drives `navigator.languages` and Accept-Language.
123    let lang0 = identity.language.first().map(String::as_str).unwrap_or_else(|| {
124        tracing::warn!("identity has no language; falling back to en-US");
125        "en-US"
126    });
127
128    // Build client hints for Chrome/Edge (skip iOS — all iOS browsers are WebKit-based)
129    let client_hints = if matches!(identity.browser, Browser::Chrome | Browser::Edge)
130        && !matches!(identity.os, Os::Ios)
131    {
132        Some(build_client_hints(identity))
133    } else {
134        None
135    };
136
137    // Note the emulation overrides below are session-scoped: rustenium replicates
138    // them to every target automatically, so a worker's timezone, locale and UA-CH
139    // stay consistent with the page's rather than leaking the host's.
140
141    // Emulation.setUserAgentOverride
142    //
143    // `acceptLanguage` takes the whole list: Chrome splits it on commas into
144    // `navigator.languages` (first entry becomes `navigator.language`) and
145    // q-weights it into the Accept-Language header. Sending only the primary tag
146    // leaves `navigator.languages` one entry long, which no real profile is.
147    let mut ua_builder = SetUserAgentOverride::builder()
148        .user_agent(&user_agent)
149        .accept_language(identity.language.join(","))
150        .platform(identity.platform.navigator_platform.as_str());
151    if let Some(ref metadata) = client_hints {
152        ua_builder = ua_builder.user_agent_metadata(metadata.clone());
153    }
154    let ua_cmd = ua_builder.build().unwrap();
155
156    // `uaFullVersion` has no non-deprecated way to be set. Chrome derives it from
157    // the legacy scalar `fullVersion`, not from `fullVersionList`, and CDP dropped
158    // that field from the published protocol — so the generated UserAgentMetadata
159    // cannot express it and Chrome falls back to the *real* browser version. Left
160    // alone, getHighEntropyValues(['uaFullVersion']) reports the host's Chrome next
161    // to the persona's everywhere else.
162    //
163    // Measured: the browser still honours the field, so send it alongside.
164    let full_version = identity
165        .browser_version
166        .iter()
167        .map(|v| v.to_string())
168        .collect::<Vec<_>>()
169        .join(".");
170    let ua_extensions = serde_json::json!({
171        "userAgentMetadata": { "fullVersion": full_version }
172    });
173    browser
174        .send_command_extended(ua_cmd, ua_extensions)
175        .await
176        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
177
178    // Emulation.setDeviceMetricsOverride — only spoof display dimensions for mobile
179    if is_mobile {
180        let screen_width = (identity.screen.logical_width as f64 * identity.screen.density_pixel_ratio as f64) as i64;
181        let screen_height = (identity.screen.logical_height as f64 * identity.screen.density_pixel_ratio as f64) as i64;
182        let device_cmd = SetDeviceMetricsOverride::builder()
183            .width(identity.screen.logical_width as i64)
184            .height(identity.screen.logical_height as i64)
185            .device_scale_factor(identity.screen.density_pixel_ratio as f64)
186            .mobile(true)
187            .screen_width(screen_width)
188            .screen_height(screen_height)
189            .build()
190            .unwrap();
191        browser
192            .send_command(device_cmd)
193            .await
194            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
195    }
196
197    // Emulation.setTouchEmulationEnabled
198    if identity.has_touch {
199        let touch_cmd = SetTouchEmulationEnabled::builder()
200            .enabled(true)
201            .max_touch_points(5i64)
202            .build()
203            .unwrap();
204        browser
205            .send_command(touch_cmd)
206            .await
207            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
208
209        // Emulation.setEmitTouchEventsForMouse — translate the automation's mouse
210        // input into synthetic touch events so a touch device receives touchstart/
211        // move/end (as a real finger would) instead of mouse events.
212        let configuration = if is_mobile {
213            SetEmitTouchEventsForMouseConfiguration::Mobile
214        } else {
215            SetEmitTouchEventsForMouseConfiguration::Desktop
216        };
217        let emit_touch_cmd = SetEmitTouchEventsForMouse::builder()
218            .enabled(true)
219            .configuration(configuration)
220            .build()
221            .unwrap();
222        browser
223            .send_command(emit_touch_cmd)
224            .await
225            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
226    }
227
228    // Emulation.setLocaleOverride — the engine's ICU default, which is a
229    // different thing from `acceptLanguage` above.
230    //
231    // Without it `navigator.language` is the persona's while every
232    // `Intl.*.resolvedOptions().locale` is the host's, and the two are compared
233    // directly: CreepJS derives `localeEntropyIsTrusty` from
234    // `(1).toLocaleString(navigator.language, {currency}) ==
235    // (1).toLocaleString(undefined, {currency})` and `localeIntlEntropyIsTrusty`
236    // from whether the deduped Intl locale set is a member of
237    // `navigator.language.split(',')`. Both fail on the mismatch, which is
238    // rendered as a red diff and also drops screen and timezone out of the
239    // trusted fingerprint.
240    //
241    // Chrome answers a second override on an already-configured session with
242    // "Another locale override is already in effect"; the target manager logs
243    // that at debug and carries on, so replication to workers is harmless.
244    let locale_cmd = SetLocaleOverride::builder().locale(lang0).build();
245    browser
246        .send_command(locale_cmd)
247        .await
248        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
249
250    // Emulation.setTimezoneOverride
251    let tz_cmd = SetTimezoneOverride::builder()
252        .timezone_id(timezone)
253        .build()
254        .unwrap();
255    browser
256        .send_command(tz_cmd)
257        .await
258        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
259
260    // Emulation.setHardwareConcurrencyOverride
261    let hc_cmd = SetHardwareConcurrencyOverride::builder()
262        .hardware_concurrency(identity.hardware_concurrency as i64)
263        .build()
264        .unwrap();
265    browser
266        .send_command(hc_cmd)
267        .await
268        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
269
270    // One registration covers every realm. The target manager applies it to each
271    // target already attached and to every one that appears later, installing it
272    // while the target is still frozen — before it has run a statement.
273    //
274    // This is what reaches workers and cross-origin iframes. They are separate
275    // targets with their own realms, so without it they report the host's real
276    // values while the page reports the persona, and comparing the two is a
277    // one-line detection. Service workers especially: they belong to no tab, so
278    // only a browser-scoped registration can reach them at all.
279    //
280    // Two sources because a worker realm has no window or document and uses
281    // WorkerNavigator, so the document script throws on its first statement there.
282    browser
283        .add_init_script(InitScript {
284            page: Some(script::build_stealth_script(identity)),
285            worker: Some(script::build_worker_script(identity)),
286        })
287        .await;
288    Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293    use super::grease;
294
295    /// Both pairs were read off a running browser. They are the evidence that
296    /// the brand is derived from the major rather than fixed, and they pin the
297    /// two tables: change either and these stop matching what Chrome sends.
298    #[test]
299    fn grease_matches_the_browsers_it_was_measured_against() {
300        assert_eq!(grease(151), ("Not=A?Brand".to_string(), "99"));
301        assert_eq!(grease(152), ("Not?A_Brand".to_string(), "24"));
302    }
303
304    /// The value this used to hardcode belongs to 150, paired with 152's
305    /// version — a combination no release has sent.
306    #[test]
307    fn the_old_hardcoded_pair_was_never_a_real_release() {
308        let (brand, version) = grease(150);
309        assert_eq!(brand, "Not;A=Brand");
310        assert_ne!(version, "24");
311    }
312}