rustenium-identity 0.1.10

A versatile stealth overlay for rustenium
Documentation
pub mod cdp;
pub mod error;
pub mod identity;
pub mod local_proxy_server;
pub mod preset;
pub mod script;
pub mod tz;
pub mod ua;

use error::IdentityError;
use local_proxy_server::start_overlay;
use rustenium::browsers::{
    BidiBrowser,
    chrome::browser::{ChromeBrowser, ChromeConfig},
};

pub use error::IdentityError as Error;
pub use identity::*;

/// Configuration for launching an identity-spoofed browser session.
pub struct IdentityConfig {
    pub identity: Identity,
    pub chrome: ChromeConfig,
}

impl From<Identity> for IdentityConfig {
    fn from(identity: Identity) -> Self {
        Self {
            identity,
            chrome: ChromeConfig {
                enable_bidi: false,
                enable_cdp: true,
                ..Default::default()
            },
        }
    }
}

impl IdentityConfig {
    pub fn new(identity: Identity, chrome: ChromeConfig) -> Self {
        Self { identity, chrome }
    }
}

/// Read the running browser's real Chromium version, before any UA override is in
/// place. Returns `None` if the version cannot be determined, in which case the
/// caller keeps the configured value rather than guessing.
async fn read_chromium_version(browser: &mut ChromeBrowser) -> Option<Vec<u16>> {
    // `uaFullVersion` first: UA reduction freezes the UA string at `MAJOR.0.0.0`,
    // while the client hint still carries the true build. Taking the reduced form
    // would make us answer getHighEntropyValues with `146.0.0.0` — a value no real
    // Chrome returns, which is a worse tell than the version gap we came to fix.
    const EXPR: &str = r#"(async () => {
        try {
            const d = navigator.userAgentData;
            if (d && d.getHighEntropyValues) {
                const v = await d.getHighEntropyValues(['uaFullVersion']);
                if (v && v.uaFullVersion) return v.uaFullVersion;
            }
        } catch (e) {}
        const m = navigator.userAgent.match(/Chrome\/([\d.]+)/);
        return m ? m[1] : '';
    })()"#;
    // ChromeBrowser implements both browser traits; this is the CDP path.
    let result =
        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, true)
            .await
            .ok()?;
    let version = result.result.value.as_ref()?.as_str()?.to_string();
    ua::parse_version_parts(&version)
}

/// Read the real unmasked WebGL vendor/renderer, before any patch is installed.
///
/// The renderer *string* is spoofable but the capability profile behind it is not —
/// `MAX_TEXTURE_SIZE` and friends come from the actual GPU, and known
/// (brand → capabilities) pairings are checked. Claiming a card whose capabilities
/// do not follow is the same shape of lie as claiming a browser version the engine
/// contradicts, so the persona is pinned to the device instead.
async fn read_gpu_strings(browser: &mut ChromeBrowser) -> Option<(String, String)> {
    const EXPR: &str = r#"(() => {
        try {
            const gl = document.createElement('canvas').getContext('webgl');
            const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
            if (!ext) return '[]';
            return JSON.stringify([
                gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
                gl.getParameter(ext.UNMASKED_RENDERER_WEBGL),
            ]);
        } catch (e) { return '[]'; }
    })()"#;
    let result =
        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, false)
            .await
            .ok()?;
    let json = result.result.value.as_ref()?.as_str()?;
    let pair: Vec<String> = serde_json::from_str(json).ok()?;
    let [vendor, renderer] = <[String; 2]>::try_from(pair).ok()?;
    if vendor.is_empty() || renderer.is_empty() {
        return None;
    }
    Some((vendor, renderer))
}

/// A rustenium browser session with an identity applied.
pub struct IdentitySession {
    identity: Identity,
    browser: ChromeBrowser,
}

impl IdentitySession {
    /// Launch a new Chromium instance from the given config.
    /// Applies all CDP emulation overrides and registers the stealth
    /// bootstrap script before returning.
    pub async fn launch(config: impl Into<IdentityConfig>) -> Result<Self, IdentityError> {
        let mut config = config.into();
        let geo = tz::resolve_geo(
            config.identity.timezone.as_deref(),
            config.identity.proxy.as_deref(),
        )
        .await?;
        let timezone = geo.timezone.clone();

        let mut chrome_config = config.chrome;
        chrome_config.enable_bidi = false;
        chrome_config.enable_cdp = true;

        // Remove the `navigator.webdriver` tell at the source instead of patching it in JS.
        let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
        flags.push("--disable-blink-features=AutomationControlled".to_string());
        // Keep WebGL alive on a machine with no GPU.
        //
        // ANGLE defaults to its bundled SwiftShader over Vulkan, which fails to
        // initialize on a headless host, and Chrome then blocklists the software
        // renderer it falls back to — leaving `getContext("webgl")` returning null.
        // A desktop browser with no WebGL at all is a louder signal than any
        // renderer string: the vector every detector reads first simply is not
        // there, and the vendor/renderer substitution below has no context to
        // apply to. Pointing ANGLE at the system GL and accepting the blocklisted
        // renderer gives a real context back.
        //
        // These say nothing about which card the page sees. That is the identity's
        // GPU, spoofed on getParameter.
        flags.push("--use-gl=angle".to_string());
        flags.push("--use-angle=gl".to_string());
        flags.push("--ignore-gpu-blocklist".to_string());
        chrome_config.browser_flags = Some(flags);

        if let Some(ref proxy_url) = config.identity.proxy {
            if !proxy_url.is_empty() {
                let local_addr = start_overlay(proxy_url)
                    .await
                    .map_err(|e| IdentityError::ProxyError(e.to_string()))?;
                let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
                flags.push(format!(
                    "--proxy-server=http://127.0.0.1:{}",
                    local_addr.port()
                ));
                chrome_config.browser_flags = Some(flags);
            }
        }

        let mut browser = ChromeBrowser::new(chrome_config).await;

        // Pin the persona's Chromium version to the binary that is actually running.
        // The UA is the only part of a version claim we control; the engine leaks the
        // rest through which CSS properties parse, which `window` members exist and
        // which JS builtins are present, each of which maps to a release range. A
        // persona claiming a different major is caught with an exact distance, and a
        // hardcoded one drifts into that the moment Chrome updates. Only the
        // Chromium-based personas apply — Safari and iOS carry a WebKit version.
        if matches!(config.identity.browser, Browser::Chrome | Browser::Edge)
            && !matches!(config.identity.os, Os::Ios)
        {
            match read_chromium_version(&mut browser).await {
                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")}},
                None => tracing::warn!(
                    "could not read the running Chromium version; \
                     persona keeps its configured browser_version"
                ),
            }
        }

        // Same reasoning for the GPU: the strings are spoofable, the capabilities
        // behind them are not, so a persona naming a card the capability profile
        // contradicts is caught by the pairing rather than by the string.
        match read_gpu_strings(&mut browser).await {
            Some((vendor, renderer)) => {
                config.identity.gpu.webgl_vendor = vendor;
                config.identity.gpu.webgl_renderer = renderer;
            }
            None => tracing::warn!(
                "could not read the real WebGL vendor/renderer; \
                 persona keeps its configured gpu strings"
            ),
        }

        cdp::apply_identity(&mut browser, &config.identity, &timezone).await?;

        Ok(Self {
            identity: config.identity,
            browser,
        })
    }

    /// Access the underlying rustenium ChromeBrowser.
    pub fn browser(&self) -> &ChromeBrowser {
        &self.browser
    }

    /// Mutable access to the underlying rustenium ChromeBrowser.
    pub fn browser_mut(&mut self) -> &mut ChromeBrowser {
        &mut self.browser
    }

    /// Get the identity.
    pub fn identity(&self) -> &Identity {
        &self.identity
    }

    pub async fn close(self) -> bool {
        self.browser.close().await.map_err(|_| false).is_ok()
    }
}