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