rustenium-identity 0.1.14

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

const PROPERTY_MODIFIER_JS: &str = include_str!("../../js/property_modifier.js");
const MAIN_WORLD_JS: &str = include_str!("../../js/main_world.js");
const HARDWARE_JS: &str = include_str!("../../js/hardware.js");
const WORKER_SCOPE_JS: &str = include_str!("../../js/worker_scope.js");
const BROWSER_CHROME_JS: &str = include_str!("../../js/browser_chrome.js");
const BROWSER_SAFARI_JS: &str = include_str!("../../js/browser_safari.js");
const BROWSER_EDGE_JS: &str = include_str!("../../js/browser_edge.js");

/// Build the full stealth bootstrap JS string with identity values substituted.
pub fn build_stealth_script(identity: &Identity) -> String {
    let charging = !identity.has_battery || identity.has_mouse;
    let charging_time = if identity.has_battery { "Infinity" } else { "0" };
    let discharging_time = if identity.has_battery { "7200" } else { "Infinity" };
    let battery_percentage: u8 = rand::random_range(20..=100);

    // The trailing `;` is load-bearing. This block is followed by another IIFE, and
    // ASI never inserts a semicolon before `(` — without it the two parse as
    // `(function(){...})()(function(){...})()`, which calls this one's `undefined`
    // result and throws. That killed every patch after this point, hardware.js
    // included, and was invisible: an exception inside a CDP init script does not
    // reach window.onerror.
    let history_block = match identity.history_count {
        Some(count) => format!(
            r#"(function() {{
  var historyCount = {};
  for (var n = 0; n < historyCount; ++n) {{
    if (window.history.length >= historyCount) break;
    window.history.pushState(null, '');
  }}
}})();"#,
            count
        ),
        None => String::new(),
    };

    // On iOS all browsers use WebKit — use Safari block regardless of browser enum
    let is_ios = matches!(identity.os, Os::Ios);
    let browser_block = if is_ios {
        BROWSER_SAFARI_JS.to_string()
    } else {
        match identity.browser {
            Browser::Chrome => BROWSER_CHROME_JS.to_string(),
            Browser::Safari => BROWSER_SAFARI_JS.to_string(),
            Browser::Edge => BROWSER_EDGE_JS.to_string(),
        }
    };

    // Substitutions shared by the main-world and worker-scope templates. The two
    // must agree exactly — a value that differs between the page and a worker is
    // trivially detectable by comparing them.
    let shared: [(&str, String); 6] = [
        ("{{NAVIGATOR_PLATFORM}}", escape_js(identity.platform.navigator_platform.as_str())),
        ("{{HARDWARE_CONCURRENCY}}", identity.hardware_concurrency.to_string()),
        ("{{MEMORY}}", identity.memory.to_string()),
        ("{{WEBGL_VENDOR}}", escape_js(&identity.gpu.webgl_vendor)),
        ("{{WEBGL_RENDERER}}", escape_js(&identity.gpu.webgl_renderer)),
        // Empty on failure; worker_scope.js then leaves the native UA in place.
        ("{{USER_AGENT}}", crate::ua::build_user_agent(identity).map(|s| escape_js(&s)).unwrap_or_default()),
    ];
    let apply = |src: &str| {
        shared.iter().fold(src.to_string(), |acc, (key, val)| acc.replace(key, val))
    };

    let script = apply(MAIN_WORLD_JS)
        .replace("{{HISTORY_BLOCK}}", &history_block)
        .replace("{{CHARGING}}", &charging.to_string())
        .replace("{{CHARGING_TIME}}", charging_time)
        .replace("{{DISCHARGING_TIME}}", discharging_time)
        .replace("{{BATTERY_PERCENTAGE}}", &battery_percentage.to_string())
        .replace("{{BROWSER_BLOCK}}", &browser_block);

    // Deterministic per-identity seed: keeps the hardware fingerprints
    // (canvas/audio/WebGL/text) stable across sessions and distinct per persona.
    let hardware = HARDWARE_JS.replace("{{FP_SEED}}", &fingerprint_seed(identity).to_string());

    // Wrap in an IIFE so PropertyModifier and the browser-block locals never
    // become global lexical bindings (otherwise page scripts could detect them).
    //
    // Fragments are joined with a bare `;` (an empty statement) so that a fragment
    // ending in an expression can never swallow the IIFE that opens the next one.
    format!(
        "(function() {{\n{}\n;\n{}\n;\n{}\n}})();",
        PROPERTY_MODIFIER_JS, script, hardware
    )
}

/// The worker-scope counterpart of `build_stealth_script`.
///
/// Workers are separate targets with separate realms and their own intrinsics, so
/// they need their own copy of PropertyModifier. They also need a *different*
/// script: worker scope has no `window`, no `document`, and no `Navigator` — the
/// interface is `WorkerNavigator` — so the main-world script throws on its first
/// statement there.
///
/// Delivered by CDP: auto-attach freezes each worker before it runs anything and
/// this is evaluated into it. That replaced an earlier approach that patched the
/// `Worker` constructor to re-host workers from a blob: URL — which could not
/// reach service workers at all (the platform rejects blob: for `register()`) and
/// left a trail to cover up (`WorkerLocation`, a `MessageEvent.data` rewrite).
pub fn build_worker_script(identity: &Identity) -> String {
    let shared: [(&str, String); 6] = [
        ("{{NAVIGATOR_PLATFORM}}", escape_js(identity.platform.navigator_platform.as_str())),
        ("{{HARDWARE_CONCURRENCY}}", identity.hardware_concurrency.to_string()),
        ("{{MEMORY}}", identity.memory.to_string()),
        ("{{WEBGL_VENDOR}}", escape_js(&identity.gpu.webgl_vendor)),
        ("{{WEBGL_RENDERER}}", escape_js(&identity.gpu.webgl_renderer)),
        ("{{USER_AGENT}}", crate::ua::build_user_agent(identity).map(|s| escape_js(&s)).unwrap_or_default()),
    ];
    let worker_scope = shared
        .iter()
        .fold(WORKER_SCOPE_JS.to_string(), |acc, (key, val)| acc.replace(key, val));

    format!(
        "(function() {{\n{}\n;\n{}\n}})();",
        PROPERTY_MODIFIER_JS, worker_scope
    )
}

/// Derive a stable u32 seed from identity fields that define the device. Same
/// persona → same seed → same hardware fingerprints; different persona → different.
/// Also reused by `launch` to key a persistent Chrome profile per identity.
pub(crate) fn fingerprint_seed(identity: &Identity) -> u32 {
    let material = format!(
        "{:?}|{}|{}|{}|{}|{}x{}|{}|{}|{}",
        identity.os,
        identity.os_version,
        identity.gpu.webgl_vendor,
        identity.gpu.webgl_renderer,
        identity.id.unwrap_or(0),
        identity.screen.original_width,
        identity.screen.original_height,
        identity.hardware_concurrency,
        identity.memory,
        identity.platform.navigator_platform.as_str(),
    );
    // FNV-1a (32-bit).
    let mut h: u32 = 0x811c_9dc5;
    for b in material.as_bytes() {
        h ^= *b as u32;
        h = h.wrapping_mul(0x0100_0193);
    }
    h
}

fn escape_js(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
}