Skip to main content

rustenium_identity/script/
mod.rs

1use crate::identity::{Browser, Identity, Os};
2
3const PROPERTY_MODIFIER_JS: &str = include_str!("../../js/property_modifier.js");
4const MAIN_WORLD_JS: &str = include_str!("../../js/main_world.js");
5const HARDWARE_JS: &str = include_str!("../../js/hardware.js");
6const WORKER_SCOPE_JS: &str = include_str!("../../js/worker_scope.js");
7const BROWSER_CHROME_JS: &str = include_str!("../../js/browser_chrome.js");
8const BROWSER_SAFARI_JS: &str = include_str!("../../js/browser_safari.js");
9const BROWSER_EDGE_JS: &str = include_str!("../../js/browser_edge.js");
10
11/// Build the full stealth bootstrap JS string with identity values substituted.
12pub fn build_stealth_script(identity: &Identity) -> String {
13    let charging = !identity.has_battery || identity.has_mouse;
14    let charging_time = if identity.has_battery { "Infinity" } else { "0" };
15    let discharging_time = if identity.has_battery { "7200" } else { "Infinity" };
16    let battery_percentage: u8 = rand::random_range(20..=100);
17
18    // The trailing `;` is load-bearing. This block is followed by another IIFE, and
19    // ASI never inserts a semicolon before `(` — without it the two parse as
20    // `(function(){...})()(function(){...})()`, which calls this one's `undefined`
21    // result and throws. That killed every patch after this point, hardware.js
22    // included, and was invisible: an exception inside a CDP init script does not
23    // reach window.onerror.
24    let history_block = match identity.history_count {
25        Some(count) => format!(
26            r#"(function() {{
27  var historyCount = {};
28  for (var n = 0; n < historyCount; ++n) {{
29    if (window.history.length >= historyCount) break;
30    window.history.pushState(null, '');
31  }}
32}})();"#,
33            count
34        ),
35        None => String::new(),
36    };
37
38    // On iOS all browsers use WebKit — use Safari block regardless of browser enum
39    let is_ios = matches!(identity.os, Os::Ios);
40    let browser_block = if is_ios {
41        BROWSER_SAFARI_JS.to_string()
42    } else {
43        match identity.browser {
44            Browser::Chrome => BROWSER_CHROME_JS.to_string(),
45            Browser::Safari => BROWSER_SAFARI_JS.to_string(),
46            Browser::Edge => BROWSER_EDGE_JS.to_string(),
47        }
48    };
49
50    // Substitutions shared by the main-world and worker-scope templates. The two
51    // must agree exactly — a value that differs between the page and a worker is
52    // trivially detectable by comparing them.
53    let shared: [(&str, String); 6] = [
54        ("{{NAVIGATOR_PLATFORM}}", escape_js(identity.platform.navigator_platform.as_str())),
55        ("{{HARDWARE_CONCURRENCY}}", identity.hardware_concurrency.to_string()),
56        ("{{MEMORY}}", identity.memory.to_string()),
57        ("{{WEBGL_VENDOR}}", escape_js(&identity.gpu.webgl_vendor)),
58        ("{{WEBGL_RENDERER}}", escape_js(&identity.gpu.webgl_renderer)),
59        // Empty on failure; worker_scope.js then leaves the native UA in place.
60        ("{{USER_AGENT}}", crate::ua::build_user_agent(identity).map(|s| escape_js(&s)).unwrap_or_default()),
61    ];
62    let apply = |src: &str| {
63        shared.iter().fold(src.to_string(), |acc, (key, val)| acc.replace(key, val))
64    };
65
66    let script = apply(MAIN_WORLD_JS)
67        .replace("{{HISTORY_BLOCK}}", &history_block)
68        .replace("{{CHARGING}}", &charging.to_string())
69        .replace("{{CHARGING_TIME}}", charging_time)
70        .replace("{{DISCHARGING_TIME}}", discharging_time)
71        .replace("{{BATTERY_PERCENTAGE}}", &battery_percentage.to_string())
72        .replace("{{BROWSER_BLOCK}}", &browser_block);
73
74    // Deterministic per-identity seed: keeps the hardware fingerprints
75    // (canvas/audio/WebGL/text) stable across sessions and distinct per persona.
76    let hardware = HARDWARE_JS.replace("{{FP_SEED}}", &fingerprint_seed(identity).to_string());
77
78    // Wrap in an IIFE so PropertyModifier and the browser-block locals never
79    // become global lexical bindings (otherwise page scripts could detect them).
80    //
81    // Fragments are joined with a bare `;` (an empty statement) so that a fragment
82    // ending in an expression can never swallow the IIFE that opens the next one.
83    format!(
84        "(function() {{\n{}\n;\n{}\n;\n{}\n}})();",
85        PROPERTY_MODIFIER_JS, script, hardware
86    )
87}
88
89/// The worker-scope counterpart of `build_stealth_script`.
90///
91/// Workers are separate targets with separate realms and their own intrinsics, so
92/// they need their own copy of PropertyModifier. They also need a *different*
93/// script: worker scope has no `window`, no `document`, and no `Navigator` — the
94/// interface is `WorkerNavigator` — so the main-world script throws on its first
95/// statement there.
96///
97/// Delivered by CDP: auto-attach freezes each worker before it runs anything and
98/// this is evaluated into it. That replaced an earlier approach that patched the
99/// `Worker` constructor to re-host workers from a blob: URL — which could not
100/// reach service workers at all (the platform rejects blob: for `register()`) and
101/// left a trail to cover up (`WorkerLocation`, a `MessageEvent.data` rewrite).
102pub fn build_worker_script(identity: &Identity) -> String {
103    let shared: [(&str, String); 6] = [
104        ("{{NAVIGATOR_PLATFORM}}", escape_js(identity.platform.navigator_platform.as_str())),
105        ("{{HARDWARE_CONCURRENCY}}", identity.hardware_concurrency.to_string()),
106        ("{{MEMORY}}", identity.memory.to_string()),
107        ("{{WEBGL_VENDOR}}", escape_js(&identity.gpu.webgl_vendor)),
108        ("{{WEBGL_RENDERER}}", escape_js(&identity.gpu.webgl_renderer)),
109        ("{{USER_AGENT}}", crate::ua::build_user_agent(identity).map(|s| escape_js(&s)).unwrap_or_default()),
110    ];
111    let worker_scope = shared
112        .iter()
113        .fold(WORKER_SCOPE_JS.to_string(), |acc, (key, val)| acc.replace(key, val));
114
115    format!(
116        "(function() {{\n{}\n;\n{}\n}})();",
117        PROPERTY_MODIFIER_JS, worker_scope
118    )
119}
120
121/// Derive a stable u32 seed from identity fields that define the device. Same
122/// persona → same seed → same hardware fingerprints; different persona → different.
123/// Also reused by `launch` to key a persistent Chrome profile per identity.
124pub(crate) fn fingerprint_seed(identity: &Identity) -> u32 {
125    let material = format!(
126        "{:?}|{}|{}|{}|{}|{}x{}|{}|{}|{}",
127        identity.os,
128        identity.os_version,
129        identity.gpu.webgl_vendor,
130        identity.gpu.webgl_renderer,
131        identity.id.unwrap_or(0),
132        identity.screen.original_width,
133        identity.screen.original_height,
134        identity.hardware_concurrency,
135        identity.memory,
136        identity.platform.navigator_platform.as_str(),
137    );
138    // FNV-1a (32-bit).
139    let mut h: u32 = 0x811c_9dc5;
140    for b in material.as_bytes() {
141        h ^= *b as u32;
142        h = h.wrapping_mul(0x0100_0193);
143    }
144    h
145}
146
147fn escape_js(s: &str) -> String {
148    s.replace('\\', "\\\\")
149        .replace('"', "\\\"")
150        .replace('\n', "\\n")
151}