rustenium-identity 0.1.14

A versatile stealth overlay for rustenium
Documentation
use crate::error::IdentityError;
use crate::identity::{Browser, Identity, Os};

use crate::script;
use crate::ua;

use rustenium::browsers::chrome::browser::ChromeBrowser;
use rustenium::browsers::cdp_browser::CdpBrowser;
use rustenium::cdp::target_manager::InitScript;
use rustenium_cdp_definitions::browser_protocol::emulation::commands::{
    SetDeviceMetricsOverride, SetEmitTouchEventsForMouse, SetEmitTouchEventsForMouseConfiguration,
    SetHardwareConcurrencyOverride, SetLocaleOverride, SetTimezoneOverride,
    SetTouchEmulationEnabled, SetUserAgentOverride,
};
use rustenium_cdp_definitions::browser_protocol::emulation::types::{
    UserAgentBrandVersion, UserAgentMetadata,
};

/// The GREASE brand a given Chromium major sends, and the version it carries.
///
/// Both rotate with the major, so any fixed pair is wrong for all but one
/// release — `Not;A=Brand`/`24`, the value this used to hardcode, is Chrome
/// 150's brand paired with Chrome 152's version, which is a combination no
/// browser has ever sent. The point of a greasey brand is that a parser must
/// tolerate junk; a *stale* one is a version claim that contradicts the version
/// claimed everywhere else.
///
/// Chromium builds it by indexing two tables with the major version. Verified
/// against Chrome 151 (`Not=A?Brand`/`99`) and Chrome for Testing 152
/// (`Not?A_Brand`/`24`), both read off a running browser.
fn grease(major: u16) -> (String, &'static str) {
    const CHARS: [&str; 11] = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
    const VERSIONS: [&str; 3] = ["8", "99", "24"];
    let seed = major as usize;
    (
        format!(
            "Not{}A{}Brand",
            CHARS[seed % CHARS.len()],
            CHARS[(seed + 1) % CHARS.len()]
        ),
        VERSIONS[seed % VERSIONS.len()],
    )
}

/// Build UserAgentMetadata (Client Hints) for Chrome/Edge identities.
fn build_client_hints(identity: &Identity) -> UserAgentMetadata {
    let major_number = identity.browser_version.first().copied().unwrap_or(0);
    let major = major_number.to_string();
    let full_version = identity
        .browser_version
        .iter()
        .map(|v| v.to_string())
        .collect::<Vec<_>>()
        .join(".");
    let is_mobile = identity.device_model.is_some();

    let (brand_name, brand_name_full) = match identity.browser {
        Browser::Edge => ("Microsoft Edge", "Microsoft Edge"),
        _ => ("Google Chrome", "Google Chrome"),
    };

    let (grease_brand, grease_version) = grease(major_number);

    // Greasey brand first, then the branded entry, then Chromium — the order a
    // real Chrome was measured to send. Chromium permutes the list by the same
    // seed; that table is not replicated here, so this is one observed layout
    // rather than a derivation.
    let brands = vec![
        UserAgentBrandVersion::new(&grease_brand, grease_version),
        UserAgentBrandVersion::new(brand_name, &major),
        UserAgentBrandVersion::new("Chromium", &major),
    ];

    let full_version_list = vec![
        UserAgentBrandVersion::new(&grease_brand, &format!("{grease_version}.0.0.0")),
        UserAgentBrandVersion::new(brand_name_full, &full_version),
        UserAgentBrandVersion::new("Chromium", &full_version),
    ];

    let platform = match identity.os {
        Os::Windows => "Windows",
        Os::Macintosh => "macOS",
        Os::Linux => "Linux",
        Os::Android => "Android",
        Os::Ios => "iOS",
    };

    // On mobile, architecture and bitness should be empty
    let (architecture, bitness) = if is_mobile {
        (String::new(), String::new())
    } else {
        (
            identity.platform.architecture.clone().unwrap_or_else(|| "x86".into()),
            identity.platform.bitness.clone().unwrap_or_else(|| "64".into()),
        )
    };
    let model = identity.device_model.clone().unwrap_or_default();

    UserAgentMetadata::builder()
        .brands(brands)
        .full_version_lists(full_version_list)
        .platform(platform)
        .platform_version(&identity.platform.version)
        .architecture(&architecture)
        .model(&model)
        .mobile(is_mobile)
        .bitness(bitness)
        .build()
        .unwrap()
}

/// Apply all CDP emulation commands and register the stealth script.
pub async fn apply_identity(
    browser: &mut ChromeBrowser,
    identity: &Identity,
    timezone: &str,
) -> Result<(), IdentityError> {
    let user_agent = ua::build_user_agent(identity)?;
    let is_mobile = identity.device_model.is_some();

    // The primary tag drives both `navigator.language` and the engine's own
    // locale; the whole list drives `navigator.languages` and Accept-Language.
    let lang0 = identity.language.first().map(String::as_str).unwrap_or_else(|| {
        tracing::warn!("identity has no language; falling back to en-US");
        "en-US"
    });

    // Build client hints for Chrome/Edge (skip iOS — all iOS browsers are WebKit-based)
    let client_hints = if matches!(identity.browser, Browser::Chrome | Browser::Edge)
        && !matches!(identity.os, Os::Ios)
    {
        Some(build_client_hints(identity))
    } else {
        None
    };

    // Note the emulation overrides below are session-scoped: rustenium replicates
    // them to every target automatically, so a worker's timezone, locale and UA-CH
    // stay consistent with the page's rather than leaking the host's.

    // Emulation.setUserAgentOverride
    //
    // `acceptLanguage` takes the whole list: Chrome splits it on commas into
    // `navigator.languages` (first entry becomes `navigator.language`) and
    // q-weights it into the Accept-Language header. Sending only the primary tag
    // leaves `navigator.languages` one entry long, which no real profile is.
    let mut ua_builder = SetUserAgentOverride::builder()
        .user_agent(&user_agent)
        .accept_language(identity.language.join(","))
        .platform(identity.platform.navigator_platform.as_str());
    if let Some(ref metadata) = client_hints {
        ua_builder = ua_builder.user_agent_metadata(metadata.clone());
    }
    let ua_cmd = ua_builder.build().unwrap();

    // `uaFullVersion` has no non-deprecated way to be set. Chrome derives it from
    // the legacy scalar `fullVersion`, not from `fullVersionList`, and CDP dropped
    // that field from the published protocol — so the generated UserAgentMetadata
    // cannot express it and Chrome falls back to the *real* browser version. Left
    // alone, getHighEntropyValues(['uaFullVersion']) reports the host's Chrome next
    // to the persona's everywhere else.
    //
    // Measured: the browser still honours the field, so send it alongside.
    let full_version = identity
        .browser_version
        .iter()
        .map(|v| v.to_string())
        .collect::<Vec<_>>()
        .join(".");
    let ua_extensions = serde_json::json!({
        "userAgentMetadata": { "fullVersion": full_version }
    });
    browser
        .send_command_extended(ua_cmd, ua_extensions)
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // Emulation.setDeviceMetricsOverride — only spoof display dimensions for mobile
    if is_mobile {
        let screen_width = (identity.screen.logical_width as f64 * identity.screen.density_pixel_ratio as f64) as i64;
        let screen_height = (identity.screen.logical_height as f64 * identity.screen.density_pixel_ratio as f64) as i64;
        let device_cmd = SetDeviceMetricsOverride::builder()
            .width(identity.screen.logical_width as i64)
            .height(identity.screen.logical_height as i64)
            .device_scale_factor(identity.screen.density_pixel_ratio as f64)
            .mobile(true)
            .screen_width(screen_width)
            .screen_height(screen_height)
            .build()
            .unwrap();
        browser
            .send_command(device_cmd)
            .await
            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
    }

    // Emulation.setTouchEmulationEnabled
    if identity.has_touch {
        let touch_cmd = SetTouchEmulationEnabled::builder()
            .enabled(true)
            .max_touch_points(5i64)
            .build()
            .unwrap();
        browser
            .send_command(touch_cmd)
            .await
            .map_err(|e| IdentityError::CdpError(e.to_string()))?;

        // Emulation.setEmitTouchEventsForMouse — translate the automation's mouse
        // input into synthetic touch events so a touch device receives touchstart/
        // move/end (as a real finger would) instead of mouse events.
        let configuration = if is_mobile {
            SetEmitTouchEventsForMouseConfiguration::Mobile
        } else {
            SetEmitTouchEventsForMouseConfiguration::Desktop
        };
        let emit_touch_cmd = SetEmitTouchEventsForMouse::builder()
            .enabled(true)
            .configuration(configuration)
            .build()
            .unwrap();
        browser
            .send_command(emit_touch_cmd)
            .await
            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
    }

    // Emulation.setLocaleOverride — the engine's ICU default, which is a
    // different thing from `acceptLanguage` above.
    //
    // Without it `navigator.language` is the persona's while every
    // `Intl.*.resolvedOptions().locale` is the host's, and the two are compared
    // directly: CreepJS derives `localeEntropyIsTrusty` from
    // `(1).toLocaleString(navigator.language, {currency}) ==
    // (1).toLocaleString(undefined, {currency})` and `localeIntlEntropyIsTrusty`
    // from whether the deduped Intl locale set is a member of
    // `navigator.language.split(',')`. Both fail on the mismatch, which is
    // rendered as a red diff and also drops screen and timezone out of the
    // trusted fingerprint.
    //
    // Chrome answers a second override on an already-configured session with
    // "Another locale override is already in effect"; the target manager logs
    // that at debug and carries on, so replication to workers is harmless.
    let locale_cmd = SetLocaleOverride::builder().locale(lang0).build();
    browser
        .send_command(locale_cmd)
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // Emulation.setTimezoneOverride
    let tz_cmd = SetTimezoneOverride::builder()
        .timezone_id(timezone)
        .build()
        .unwrap();
    browser
        .send_command(tz_cmd)
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // Emulation.setHardwareConcurrencyOverride
    let hc_cmd = SetHardwareConcurrencyOverride::builder()
        .hardware_concurrency(identity.hardware_concurrency as i64)
        .build()
        .unwrap();
    browser
        .send_command(hc_cmd)
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // One registration covers every realm. The target manager applies it to each
    // target already attached and to every one that appears later, installing it
    // while the target is still frozen — before it has run a statement.
    //
    // This is what reaches workers and cross-origin iframes. They are separate
    // targets with their own realms, so without it they report the host's real
    // values while the page reports the persona, and comparing the two is a
    // one-line detection. Service workers especially: they belong to no tab, so
    // only a browser-scoped registration can reach them at all.
    //
    // Two sources because a worker realm has no window or document and uses
    // WorkerNavigator, so the document script throws on its first statement there.
    browser
        .add_init_script(InitScript {
            page: Some(script::build_stealth_script(identity)),
            worker: Some(script::build_worker_script(identity)),
        })
        .await;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::grease;

    /// Both pairs were read off a running browser. They are the evidence that
    /// the brand is derived from the major rather than fixed, and they pin the
    /// two tables: change either and these stop matching what Chrome sends.
    #[test]
    fn grease_matches_the_browsers_it_was_measured_against() {
        assert_eq!(grease(151), ("Not=A?Brand".to_string(), "99"));
        assert_eq!(grease(152), ("Not?A_Brand".to_string(), "24"));
    }

    /// The value this used to hardcode belongs to 150, paired with 152's
    /// version — a combination no release has sent.
    #[test]
    fn the_old_hardcoded_pair_was_never_a_real_release() {
        let (brand, version) = grease(150);
        assert_eq!(brand, "Not;A=Brand");
        assert_ne!(version, "24");
    }
}