Skip to main content

rustenium_identity/
lib.rs

1pub mod cdp;
2pub mod error;
3pub mod identity;
4pub mod local_proxy_server;
5pub mod preset;
6pub mod script;
7pub mod tz;
8pub mod ua;
9
10use error::IdentityError;
11use local_proxy_server::start_overlay;
12use rustenium::browsers::{
13    BidiBrowser,
14    chrome::browser::{ChromeBrowser, ChromeConfig},
15};
16
17pub use error::IdentityError as Error;
18pub use identity::*;
19
20/// Configuration for launching an identity-spoofed browser session.
21pub struct IdentityConfig {
22    pub identity: Identity,
23    pub chrome: ChromeConfig,
24}
25
26impl From<Identity> for IdentityConfig {
27    fn from(identity: Identity) -> Self {
28        Self {
29            identity,
30            chrome: ChromeConfig {
31                enable_bidi: false,
32                enable_cdp: true,
33                ..Default::default()
34            },
35        }
36    }
37}
38
39impl IdentityConfig {
40    pub fn new(identity: Identity, chrome: ChromeConfig) -> Self {
41        Self { identity, chrome }
42    }
43}
44
45/// Read the running browser's real Chromium version, before any UA override is in
46/// place. Returns `None` if the version cannot be determined, in which case the
47/// caller keeps the configured value rather than guessing.
48async fn read_chromium_version(browser: &mut ChromeBrowser) -> Option<Vec<u16>> {
49    // `uaFullVersion` first: UA reduction freezes the UA string at `MAJOR.0.0.0`,
50    // while the client hint still carries the true build. Taking the reduced form
51    // would make us answer getHighEntropyValues with `146.0.0.0` — a value no real
52    // Chrome returns, which is a worse tell than the version gap we came to fix.
53    const EXPR: &str = r#"(async () => {
54        try {
55            const d = navigator.userAgentData;
56            if (d && d.getHighEntropyValues) {
57                const v = await d.getHighEntropyValues(['uaFullVersion']);
58                if (v && v.uaFullVersion) return v.uaFullVersion;
59            }
60        } catch (e) {}
61        const m = navigator.userAgent.match(/Chrome\/([\d.]+)/);
62        return m ? m[1] : '';
63    })()"#;
64    // ChromeBrowser implements both browser traits; this is the CDP path.
65    let result =
66        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, true)
67            .await
68            .ok()?;
69    let version = result.result.value.as_ref()?.as_str()?.to_string();
70    ua::parse_version_parts(&version)
71}
72
73/// Read the real unmasked WebGL vendor/renderer, before any patch is installed.
74///
75/// The renderer *string* is spoofable but the capability profile behind it is not —
76/// `MAX_TEXTURE_SIZE` and friends come from the actual GPU, and known
77/// (brand → capabilities) pairings are checked. Claiming a card whose capabilities
78/// do not follow is the same shape of lie as claiming a browser version the engine
79/// contradicts, so the persona is pinned to the device instead.
80async fn read_gpu_strings(browser: &mut ChromeBrowser) -> Option<(String, String)> {
81    const EXPR: &str = r#"(() => {
82        try {
83            const gl = document.createElement('canvas').getContext('webgl');
84            const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
85            if (!ext) return '[]';
86            return JSON.stringify([
87                gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
88                gl.getParameter(ext.UNMASKED_RENDERER_WEBGL),
89            ]);
90        } catch (e) { return '[]'; }
91    })()"#;
92    let result =
93        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, false)
94            .await
95            .ok()?;
96    let json = result.result.value.as_ref()?.as_str()?;
97    let pair: Vec<String> = serde_json::from_str(json).ok()?;
98    let [vendor, renderer] = <[String; 2]>::try_from(pair).ok()?;
99    if vendor.is_empty() || renderer.is_empty() {
100        return None;
101    }
102    Some((vendor, renderer))
103}
104
105/// A rustenium browser session with an identity applied.
106pub struct IdentitySession {
107    identity: Identity,
108    browser: ChromeBrowser,
109}
110
111impl IdentitySession {
112    /// Launch a new Chromium instance from the given config.
113    /// Applies all CDP emulation overrides and registers the stealth
114    /// bootstrap script before returning.
115    pub async fn launch(config: impl Into<IdentityConfig>) -> Result<Self, IdentityError> {
116        let mut config = config.into();
117        let geo = tz::resolve_geo(
118            config.identity.timezone.as_deref(),
119            config.identity.proxy.as_deref(),
120        )
121        .await?;
122        let timezone = geo.timezone.clone();
123
124        let mut chrome_config = config.chrome;
125        chrome_config.enable_bidi = false;
126        chrome_config.enable_cdp = true;
127
128        // Remove the `navigator.webdriver` tell at the source instead of patching it in JS.
129        let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
130        flags.push("--disable-blink-features=AutomationControlled".to_string());
131        chrome_config.browser_flags = Some(flags);
132
133        if let Some(ref proxy_url) = config.identity.proxy {
134            if !proxy_url.is_empty() {
135                let local_addr = start_overlay(proxy_url)
136                    .await
137                    .map_err(|e| IdentityError::ProxyError(e.to_string()))?;
138                let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
139                flags.push(format!(
140                    "--proxy-server=http://127.0.0.1:{}",
141                    local_addr.port()
142                ));
143                chrome_config.browser_flags = Some(flags);
144            }
145        }
146
147        let mut browser = ChromeBrowser::new(chrome_config).await;
148
149        // Pin the persona's Chromium version to the binary that is actually running.
150        // The UA is the only part of a version claim we control; the engine leaks the
151        // rest through which CSS properties parse, which `window` members exist and
152        // which JS builtins are present, each of which maps to a release range. A
153        // persona claiming a different major is caught with an exact distance, and a
154        // hardcoded one drifts into that the moment Chrome updates. Only the
155        // Chromium-based personas apply — Safari and iOS carry a WebKit version.
156        if matches!(config.identity.browser, Browser::Chrome | Browser::Edge)
157            && !matches!(config.identity.os, Os::Ios)
158        {
159            match read_chromium_version(&mut browser).await {
160                Some(version) => {if (config.identity.browser_version == version) {tracing::warn!("The chrome version you want to spoof is different from the actual stock chrome version running on")}},
161                None => tracing::warn!(
162                    "could not read the running Chromium version; \
163                     persona keeps its configured browser_version"
164                ),
165            }
166        }
167
168        // Same reasoning for the GPU: the strings are spoofable, the capabilities
169        // behind them are not, so a persona naming a card the capability profile
170        // contradicts is caught by the pairing rather than by the string.
171        match read_gpu_strings(&mut browser).await {
172            Some((vendor, renderer)) => {
173                config.identity.gpu.webgl_vendor = vendor;
174                config.identity.gpu.webgl_renderer = renderer;
175            }
176            None => tracing::warn!(
177                "could not read the real WebGL vendor/renderer; \
178                 persona keeps its configured gpu strings"
179            ),
180        }
181
182        cdp::apply_identity(&mut browser, &config.identity, &timezone).await?;
183
184        Ok(Self {
185            identity: config.identity,
186            browser,
187        })
188    }
189
190    /// Access the underlying rustenium ChromeBrowser.
191    pub fn browser(&self) -> &ChromeBrowser {
192        &self.browser
193    }
194
195    /// Mutable access to the underlying rustenium ChromeBrowser.
196    pub fn browser_mut(&mut self) -> &mut ChromeBrowser {
197        &mut self.browser
198    }
199
200    /// Get the identity.
201    pub fn identity(&self) -> &Identity {
202        &self.identity
203    }
204
205    pub async fn close(self) -> bool {
206        self.browser.close().await.map_err(|_| false).is_ok()
207    }
208}