1use serde::{Deserialize, Serialize};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21const SCREEN_RESOLUTIONS: &[(u32, u32)] = &[
24 (1920, 1080),
25 (2560, 1440),
26 (1440, 900),
27 (1366, 768),
28 (1536, 864),
29 (1280, 800),
30 (2560, 1600),
31 (1680, 1050),
32];
33
34const TIMEZONES: &[&str] = &[
35 "America/New_York",
36 "America/Chicago",
37 "America/Denver",
38 "America/Los_Angeles",
39 "Europe/London",
40 "Europe/Paris",
41 "Europe/Berlin",
42 "Asia/Tokyo",
43 "Asia/Shanghai",
44 "Australia/Sydney",
45];
46
47const LANGUAGES: &[&str] = &[
48 "en-US", "en-GB", "en-AU", "en-CA", "fr-FR", "de-DE", "es-ES", "it-IT", "pt-BR", "ja-JP",
49 "zh-CN",
50];
51
52const HARDWARE_CONCURRENCY: &[u32] = &[4, 8, 12, 16];
53const DEVICE_MEMORY: &[u32] = &[4, 8, 16];
54
55const WEBGL_PROFILES: &[(&str, &str, &str)] = &[
57 ("Intel Inc.", "Intel Iris OpenGL Engine", "MacIntel"),
58 ("Intel Inc.", "Intel UHD Graphics 630", "MacIntel"),
59 (
60 "Google Inc. (Apple)",
61 "ANGLE (Apple, Apple M2, OpenGL 4.1)",
62 "MacIntel",
63 ),
64 (
65 "Google Inc. (NVIDIA)",
66 "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080, OpenGL 4.1)",
67 "Win32",
68 ),
69 (
70 "Google Inc. (Intel)",
71 "ANGLE (Intel, Intel(R) UHD Graphics 770, OpenGL 4.6)",
72 "Win32",
73 ),
74 (
75 "Google Inc. (AMD)",
76 "ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0)",
77 "Win32",
78 ),
79];
80
81const WINDOWS_WEBGL_PROFILES: &[(&str, &str)] = &[
83 (
84 "Google Inc. (NVIDIA)",
85 "ANGLE (NVIDIA, NVIDIA GeForce RTX 3080, OpenGL 4.1)",
86 ),
87 (
88 "Google Inc. (Intel)",
89 "ANGLE (Intel, Intel(R) UHD Graphics 770, OpenGL 4.6)",
90 ),
91 (
92 "Google Inc. (AMD)",
93 "ANGLE (AMD, AMD Radeon RX 6800 XT Direct3D11 vs_5_0 ps_5_0)",
94 ),
95];
96
97const MACOS_WEBGL_PROFILES: &[(&str, &str)] = &[
99 ("Intel Inc.", "Intel Iris OpenGL Engine"),
100 ("Intel Inc.", "Intel UHD Graphics 630"),
101 ("Google Inc. (Apple)", "ANGLE (Apple, Apple M2, OpenGL 4.1)"),
102];
103
104const MOBILE_ANDROID_RESOLUTIONS: &[(u32, u32)] =
106 &[(393, 851), (390, 844), (412, 915), (414, 896), (360, 780)];
107
108const MOBILE_IOS_RESOLUTIONS: &[(u32, u32)] =
109 &[(390, 844), (393, 852), (375, 667), (414, 896), (428, 926)];
110
111const ANDROID_WEBGL_PROFILES: &[(&str, &str)] = &[
113 ("Qualcomm", "Adreno (TM) 730"),
114 ("ARM", "Mali-G710 MC10"),
115 (
116 "Google Inc. (Qualcomm)",
117 "ANGLE (Qualcomm, Adreno (TM) 730, OpenGL ES 3.2)",
118 ),
119 ("Google Inc. (ARM)", "ANGLE (ARM, Mali-G610, OpenGL ES 3.2)"),
120];
121
122const IOS_WEBGL_PROFILES: &[(&str, &str)] = &[
123 ("Apple Inc.", "Apple A16 GPU"),
124 ("Apple Inc.", "Apple A15 GPU"),
125 ("Apple Inc.", "Apple A14 GPU"),
126 ("Apple Inc.", "Apple M1"),
127];
128
129const WINDOWS_FONTS: &[&str] = &[
131 "Arial",
132 "Calibri",
133 "Cambria",
134 "Comic Sans MS",
135 "Consolas",
136 "Courier New",
137 "Georgia",
138 "Impact",
139 "Segoe UI",
140 "Tahoma",
141 "Times New Roman",
142 "Trebuchet MS",
143 "Verdana",
144];
145
146const MACOS_FONTS: &[&str] = &[
147 "Arial",
148 "Avenir",
149 "Baskerville",
150 "Courier New",
151 "Futura",
152 "Georgia",
153 "Helvetica Neue",
154 "Lucida Grande",
155 "Optima",
156 "Palatino",
157 "Times New Roman",
158 "Verdana",
159];
160
161const LINUX_FONTS: &[&str] = &[
162 "Arial",
163 "DejaVu Sans",
164 "DejaVu Serif",
165 "FreeMono",
166 "Liberation Mono",
167 "Liberation Sans",
168 "Liberation Serif",
169 "Times New Roman",
170 "Ubuntu",
171];
172
173const MOBILE_ANDROID_FONTS: &[&str] = &[
174 "Roboto",
175 "Noto Sans",
176 "Droid Sans",
177 "sans-serif",
178 "serif",
179 "monospace",
180];
181
182const MOBILE_IOS_FONTS: &[&str] = &[
183 "Helvetica Neue",
184 "Arial",
185 "Georgia",
186 "Times New Roman",
187 "Courier New",
188];
189
190const CHROME_VERSIONS: &[u32] = &[120, 121, 122, 123, 124, 125];
192const EDGE_VERSIONS: &[u32] = &[120, 121, 122, 123, 124];
193const FIREFOX_VERSIONS: &[u32] = &[121, 122, 123, 124, 125, 126];
194const SAFARI_VERSIONS: &[&str] = &["17.0", "17.1", "17.2", "17.3", "17.4"];
195const IOS_OS_VERSIONS: &[&str] = &["16_6", "17_0", "17_1", "17_2", "17_3"];
196
197const fn rng(seed: u64, step: u64) -> u64 {
202 let x = seed.wrapping_add(step.wrapping_mul(0x9e37_79b9_7f4a_7c15));
203 let x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
204 let x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
205 x ^ (x >> 31)
206}
207
208fn pick<T: Copy + Default>(items: &[T], entropy: u64) -> T {
209 let idx = usize::try_from(entropy).unwrap_or(usize::MAX) % items.len().max(1);
210 items.get(idx).copied().unwrap_or_default()
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Fingerprint {
228 pub user_agent: String,
230
231 pub screen_resolution: (u32, u32),
233
234 pub timezone: String,
236
237 pub language: String,
239
240 pub platform: String,
242
243 pub hardware_concurrency: u32,
245
246 pub device_memory: u32,
248
249 pub webgl_vendor: Option<String>,
251
252 pub webgl_renderer: Option<String>,
254
255 pub canvas_noise: bool,
257
258 pub fonts: Vec<String>,
263}
264
265impl Default for Fingerprint {
266 fn default() -> Self {
267 Self {
268 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
269 AppleWebKit/537.36 (KHTML, like Gecko) \
270 Chrome/120.0.0.0 Safari/537.36"
271 .to_string(),
272 screen_resolution: (1920, 1080),
273 timezone: "America/New_York".to_string(),
274 language: "en-US".to_string(),
275 platform: "MacIntel".to_string(),
276 hardware_concurrency: 8,
277 device_memory: 8,
278 webgl_vendor: Some("Intel Inc.".to_string()),
279 webgl_renderer: Some("Intel Iris OpenGL Engine".to_string()),
280 canvas_noise: true,
281 fonts: vec![],
282 }
283 }
284}
285
286impl Fingerprint {
287 pub fn random() -> Self {
303 let seed = SystemTime::now()
304 .duration_since(UNIX_EPOCH)
305 .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
306 .unwrap_or(0x5a5a_5a5a_5a5a_5a5a);
307
308 let res = pick(SCREEN_RESOLUTIONS, rng(seed, 1));
309 let tz = pick(TIMEZONES, rng(seed, 2));
310 let lang = pick(LANGUAGES, rng(seed, 3));
311 let hw = pick(HARDWARE_CONCURRENCY, rng(seed, 4));
312 let dm = pick(DEVICE_MEMORY, rng(seed, 5));
313 let (wv, wr, platform) = pick(WEBGL_PROFILES, rng(seed, 6));
314
315 let user_agent = if platform == "Win32" {
316 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
317 AppleWebKit/537.36 (KHTML, like Gecko) \
318 Chrome/120.0.0.0 Safari/537.36"
319 .to_string()
320 } else {
321 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
322 AppleWebKit/537.36 (KHTML, like Gecko) \
323 Chrome/120.0.0.0 Safari/537.36"
324 .to_string()
325 };
326
327 let fonts: Vec<String> = if platform == "Win32" {
328 WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect()
329 } else {
330 MACOS_FONTS.iter().map(|s| (*s).to_string()).collect()
331 };
332
333 Self {
334 user_agent,
335 screen_resolution: res,
336 timezone: tz.to_string(),
337 language: lang.to_string(),
338 platform: platform.to_string(),
339 hardware_concurrency: hw,
340 device_memory: dm,
341 webgl_vendor: Some(wv.to_string()),
342 webgl_renderer: Some(wr.to_string()),
343 canvas_noise: true,
344 fonts,
345 }
346 }
347
348 pub fn from_profile(profile: &FingerprintProfile) -> Self {
360 profile.fingerprint.clone()
361 }
362
363 pub fn from_device_profile(device: DeviceProfile, seed: u64) -> Self {
378 match device {
379 DeviceProfile::DesktopWindows => Self::for_windows(seed),
380 DeviceProfile::DesktopMac => Self::for_mac(seed),
381 DeviceProfile::DesktopLinux => Self::for_linux(seed),
382 DeviceProfile::MobileAndroid => Self::for_android(seed),
383 DeviceProfile::MobileIOS => Self::for_ios(seed),
384 }
385 }
386
387 pub fn validate_consistency(&self) -> Vec<String> {
401 let mut issues = Vec::new();
402
403 if self.platform == "Win32" && self.user_agent.contains("Mac OS X") {
405 issues.push("Win32 platform but user-agent says Mac OS X".to_string());
406 }
407 if self.platform == "MacIntel" && self.user_agent.contains("Windows NT") {
408 issues.push("MacIntel platform but user-agent says Windows NT".to_string());
409 }
410 if self.platform.starts_with("Linux") && self.user_agent.contains("Windows NT") {
411 issues.push("Linux platform but user-agent says Windows NT".to_string());
412 }
413
414 if let Some(vendor) = &self.webgl_vendor {
416 if (self.platform == "Win32" || self.platform == "MacIntel")
417 && (vendor.contains("Qualcomm")
418 || vendor.contains("Adreno")
419 || vendor.contains("Mali"))
420 {
421 issues.push(format!(
422 "Desktop platform '{}' has mobile GPU vendor '{vendor}'",
423 self.platform
424 ));
425 }
426 if self.platform == "Win32" && vendor.starts_with("Apple") {
427 issues.push(format!("Win32 platform has Apple GPU vendor '{vendor}'"));
428 }
429 }
430
431 if !self.fonts.is_empty() {
433 let has_win_exclusive = self
434 .fonts
435 .iter()
436 .any(|f| matches!(f.as_str(), "Segoe UI" | "Calibri" | "Consolas" | "Tahoma"));
437 let has_mac_exclusive = self.fonts.iter().any(|f| {
438 matches!(
439 f.as_str(),
440 "Lucida Grande" | "Avenir" | "Optima" | "Futura" | "Baskerville"
441 )
442 });
443 let has_linux_exclusive = self.fonts.iter().any(|f| {
444 matches!(
445 f.as_str(),
446 "DejaVu Sans" | "Liberation Sans" | "Ubuntu" | "FreeMono"
447 )
448 });
449
450 if self.platform == "MacIntel" && has_win_exclusive {
451 issues.push("MacIntel platform has Windows-exclusive fonts".to_string());
452 }
453 if self.platform == "Win32" && has_mac_exclusive {
454 issues.push("Win32 platform has macOS-exclusive fonts".to_string());
455 }
456 if self.platform == "Win32" && has_linux_exclusive {
457 issues.push("Win32 platform has Linux-exclusive fonts".to_string());
458 }
459 }
460
461 issues
462 }
463
464 fn for_windows(seed: u64) -> Self {
467 let browser = BrowserKind::for_device(DeviceProfile::DesktopWindows, seed);
468 let user_agent = match browser {
469 BrowserKind::Chrome | BrowserKind::Safari => {
470 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
471 format!(
472 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
473 AppleWebKit/537.36 (KHTML, like Gecko) \
474 Chrome/{ver}.0.0.0 Safari/537.36"
475 )
476 }
477 BrowserKind::Edge => {
478 let ver = pick(EDGE_VERSIONS, rng(seed, 10));
479 format!(
480 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
481 AppleWebKit/537.36 (KHTML, like Gecko) \
482 Chrome/{ver}.0.0.0 Safari/537.36 Edg/{ver}.0.0.0"
483 )
484 }
485 BrowserKind::Firefox => {
486 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
487 format!(
488 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ver}.0) \
489 Gecko/20100101 Firefox/{ver}.0"
490 )
491 }
492 };
493
494 let (webgl_vendor, webgl_renderer) = pick(WINDOWS_WEBGL_PROFILES, rng(seed, 7));
495 let fonts = WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect();
496
497 Self {
498 user_agent,
499 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
500 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
501 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
502 platform: "Win32".to_string(),
503 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
504 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
505 webgl_vendor: Some(webgl_vendor.to_string()),
506 webgl_renderer: Some(webgl_renderer.to_string()),
507 canvas_noise: true,
508 fonts,
509 }
510 }
511
512 fn for_mac(seed: u64) -> Self {
513 let browser = BrowserKind::for_device(DeviceProfile::DesktopMac, seed);
514 let user_agent = match browser {
515 BrowserKind::Chrome | BrowserKind::Edge => {
516 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
517 format!(
518 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
519 AppleWebKit/537.36 (KHTML, like Gecko) \
520 Chrome/{ver}.0.0.0 Safari/537.36"
521 )
522 }
523 BrowserKind::Safari => {
524 let ver = pick(SAFARI_VERSIONS, rng(seed, 10));
525 format!(
526 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
527 AppleWebKit/605.1.15 (KHTML, like Gecko) \
528 Version/{ver} Safari/605.1.15"
529 )
530 }
531 BrowserKind::Firefox => {
532 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
533 format!(
534 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:{ver}.0) \
535 Gecko/20100101 Firefox/{ver}.0"
536 )
537 }
538 };
539
540 let (webgl_vendor, webgl_renderer) = pick(MACOS_WEBGL_PROFILES, rng(seed, 7));
541 let fonts = MACOS_FONTS.iter().map(|s| (*s).to_string()).collect();
542
543 Self {
544 user_agent,
545 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
546 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
547 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
548 platform: "MacIntel".to_string(),
549 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
550 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
551 webgl_vendor: Some(webgl_vendor.to_string()),
552 webgl_renderer: Some(webgl_renderer.to_string()),
553 canvas_noise: true,
554 fonts,
555 }
556 }
557
558 fn for_linux(seed: u64) -> Self {
559 let browser = BrowserKind::for_device(DeviceProfile::DesktopLinux, seed);
560 let user_agent = if browser == BrowserKind::Firefox {
561 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
562 format!(
563 "Mozilla/5.0 (X11; Linux x86_64; rv:{ver}.0) \
564 Gecko/20100101 Firefox/{ver}.0"
565 )
566 } else {
567 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
568 format!(
569 "Mozilla/5.0 (X11; Linux x86_64) \
570 AppleWebKit/537.36 (KHTML, like Gecko) \
571 Chrome/{ver}.0.0.0 Safari/537.36"
572 )
573 };
574
575 let fonts = LINUX_FONTS.iter().map(|s| (*s).to_string()).collect();
576
577 Self {
578 user_agent,
579 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
580 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
581 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
582 platform: "Linux x86_64".to_string(),
583 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
584 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
585 webgl_vendor: Some("Mesa/X.org".to_string()),
586 webgl_renderer: Some("llvmpipe (LLVM 15.0.7, 256 bits)".to_string()),
587 canvas_noise: true,
588 fonts,
589 }
590 }
591
592 fn for_android(seed: u64) -> Self {
593 let browser = BrowserKind::for_device(DeviceProfile::MobileAndroid, seed);
594 let user_agent = if browser == BrowserKind::Firefox {
595 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
596 format!(
597 "Mozilla/5.0 (Android 14; Mobile; rv:{ver}.0) \
598 Gecko/20100101 Firefox/{ver}.0"
599 )
600 } else {
601 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
602 format!(
603 "Mozilla/5.0 (Linux; Android 14; Pixel 7) \
604 AppleWebKit/537.36 (KHTML, like Gecko) \
605 Chrome/{ver}.0.6099.144 Mobile Safari/537.36"
606 )
607 };
608
609 let (webgl_vendor, webgl_renderer) = pick(ANDROID_WEBGL_PROFILES, rng(seed, 6));
610 let fonts = MOBILE_ANDROID_FONTS
611 .iter()
612 .map(|s| (*s).to_string())
613 .collect();
614
615 Self {
616 user_agent,
617 screen_resolution: pick(MOBILE_ANDROID_RESOLUTIONS, rng(seed, 1)),
618 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
619 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
620 platform: "Linux armv8l".to_string(),
621 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
622 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
623 webgl_vendor: Some(webgl_vendor.to_string()),
624 webgl_renderer: Some(webgl_renderer.to_string()),
625 canvas_noise: true,
626 fonts,
627 }
628 }
629
630 fn for_ios(seed: u64) -> Self {
631 let safari_ver = pick(SAFARI_VERSIONS, rng(seed, 10));
632 let ios_ver = pick(IOS_OS_VERSIONS, rng(seed, 11));
633 let user_agent = format!(
634 "Mozilla/5.0 (iPhone; CPU iPhone OS {ios_ver} like Mac OS X) \
635 AppleWebKit/605.1.15 (KHTML, like Gecko) \
636 Version/{safari_ver} Mobile/15E148 Safari/604.1"
637 );
638
639 let (webgl_vendor, webgl_renderer) = pick(IOS_WEBGL_PROFILES, rng(seed, 6));
640 let fonts = MOBILE_IOS_FONTS.iter().map(|s| (*s).to_string()).collect();
641
642 Self {
643 user_agent,
644 screen_resolution: pick(MOBILE_IOS_RESOLUTIONS, rng(seed, 1)),
645 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
646 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
647 platform: "iPhone".to_string(),
648 hardware_concurrency: 6,
649 device_memory: 4,
650 webgl_vendor: Some(webgl_vendor.to_string()),
651 webgl_renderer: Some(webgl_renderer.to_string()),
652 canvas_noise: true,
653 fonts,
654 }
655 }
656
657 pub fn injection_script(&self) -> String {
677 let mut parts = vec![
678 screen_script(self.screen_resolution),
679 timezone_script(&self.timezone),
680 language_script(&self.language, &self.user_agent),
681 hardware_script(self.hardware_concurrency, self.device_memory),
682 ];
683
684 if let (Some(vendor), Some(renderer)) = (&self.webgl_vendor, &self.webgl_renderer) {
685 parts.push(webgl_script(vendor, renderer));
686 }
687
688 if self.canvas_noise {
689 parts.push(canvas_noise_script());
690 }
691
692 parts.push(audio_fingerprint_script());
693 parts.push(connection_spoof_script());
694 parts.push(font_measurement_intercept_script());
695 parts.push(storage_estimate_spoof_script());
696 parts.push(battery_spoof_script());
697 parts.push(plugins_spoof_script());
698
699 format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
700 }
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct FingerprintProfile {
715 pub name: String,
717
718 pub fingerprint: Fingerprint,
720}
721
722impl FingerprintProfile {
723 pub fn new(name: String) -> Self {
734 Self {
735 name,
736 fingerprint: Fingerprint::random(),
737 }
738 }
739
740 pub fn random_weighted(name: String) -> Self {
757 let seed = std::time::SystemTime::now()
758 .duration_since(std::time::UNIX_EPOCH)
759 .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
760 .unwrap_or(0x5a5a_5a5a_5a5a_5a5a);
761
762 let device = DeviceProfile::random_weighted(seed);
763 Self {
764 name,
765 fingerprint: Fingerprint::from_device_profile(device, seed),
766 }
767 }
768}
769
770pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
789 fingerprint.injection_script()
790}
791
792#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
808pub enum DeviceProfile {
809 #[default]
811 DesktopWindows,
812 DesktopMac,
814 DesktopLinux,
816 MobileAndroid,
818 MobileIOS,
820}
821
822impl DeviceProfile {
823 pub const fn random_weighted(seed: u64) -> Self {
837 let v = rng(seed, 97) % 100;
838 match v {
839 0..=69 => Self::DesktopWindows,
840 70..=89 => Self::DesktopMac,
841 _ => Self::DesktopLinux,
842 }
843 }
844
845 pub const fn is_mobile(self) -> bool {
856 matches!(self, Self::MobileAndroid | Self::MobileIOS)
857 }
858}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
874pub enum BrowserKind {
875 #[default]
877 Chrome,
878 Edge,
880 Safari,
882 Firefox,
884}
885
886impl BrowserKind {
887 pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
903 match device {
904 DeviceProfile::MobileIOS => Self::Safari,
905 DeviceProfile::MobileAndroid => {
906 let v = rng(seed, 201) % 100;
907 if v < 90 { Self::Chrome } else { Self::Firefox }
908 }
909 DeviceProfile::DesktopMac => {
910 let v = rng(seed, 201) % 100;
911 match v {
912 0..=55 => Self::Chrome,
913 56..=91 => Self::Safari,
914 _ => Self::Firefox,
915 }
916 }
917 _ => {
918 let v = rng(seed, 201) % 100;
920 match v {
921 0..=64 => Self::Chrome,
922 65..=80 => Self::Edge,
923 _ => Self::Firefox,
924 }
925 }
926 }
927 }
928}
929
930fn screen_script((width, height): (u32, u32)) -> String {
933 let avail_height = height.saturating_sub(40);
935 format!(
939 r" // Screen dimensions
940 const _defineScreen = (prop, val) =>
941 Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
942 _defineScreen('width', {width});
943 _defineScreen('height', {height});
944 _defineScreen('availWidth', {width});
945 _defineScreen('availHeight', {avail_height});
946 _defineScreen('availLeft', 0);
947 _defineScreen('availTop', 0);
948 _defineScreen('colorDepth', 24);
949 _defineScreen('pixelDepth', 24);
950 // outerWidth/outerHeight: headless Chrome may return 0; spoof to viewport size.
951 try {{
952 Object.defineProperty(window, 'outerWidth', {{ get: () => {width}, configurable: true }});
953 Object.defineProperty(window, 'outerHeight', {{ get: () => {height}, configurable: true }});
954 }} catch(_) {{}}"
955 )
956}
957
958fn timezone_script(timezone: &str) -> String {
959 format!(
960 r" // Timezone via Intl.DateTimeFormat
961 const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
962 Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
963 const opts = _origResolvedOptions.apply(this, arguments);
964 opts.timeZone = {timezone:?};
965 return opts;
966 }};"
967 )
968}
969
970fn language_script(language: &str, user_agent: &str) -> String {
971 let primary = language.split('-').next().unwrap_or("en");
973 format!(
974 r" // Language + userAgent
975 Object.defineProperty(navigator, 'language', {{ get: () => {language:?}, configurable: false }});
976 Object.defineProperty(navigator, 'languages', {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
977 Object.defineProperty(navigator, 'userAgent', {{ get: () => {user_agent:?}, configurable: false }});"
978 )
979}
980
981fn hardware_script(concurrency: u32, memory: u32) -> String {
982 format!(
983 r" // Hardware concurrency + device memory
984 Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
985 Object.defineProperty(navigator, 'deviceMemory', {{ get: () => {memory}, configurable: false }});"
986 )
987}
988
989fn webgl_script(vendor: &str, renderer: &str) -> String {
990 format!(
991 r" // WebGL vendor + renderer
992 (function() {{
993 const _getContext = HTMLCanvasElement.prototype.getContext;
994 HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
995 const ctx = _getContext.call(this, type, attrs);
996 if (!ctx) return ctx;
997 if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
998 const _getParam = ctx.getParameter.bind(ctx);
999 ctx.getParameter = function(param) {{
1000 if (param === 0x1F00) return {vendor:?}; // GL_VENDOR
1001 if (param === 0x1F01) return {renderer:?}; // GL_RENDERER
1002 return _getParam(param);
1003 }};
1004 }}
1005 return ctx;
1006 }};
1007 }})();"
1008 )
1009}
1010
1011fn canvas_noise_script() -> String {
1012 r" // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
1013 (function() {
1014 const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1015 CanvasRenderingContext2D.prototype.getImageData = function() {
1016 const id = _getImageData.apply(this, arguments);
1017 const d = id.data;
1018 for (let i = 0; i < d.length; i += 4) {
1019 d[i] ^= 1;
1020 d[i + 1] ^= 1;
1021 d[i + 2] ^= 1;
1022 }
1023 return id;
1024 };
1025 })();"
1026 .to_string()
1027}
1028
1029fn audio_fingerprint_script() -> String {
1030 r" // Audio fingerprint defence: add sub-epsilon noise to frequency data
1031 (function() {
1032 if (typeof AnalyserNode === 'undefined') return;
1033 const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1034 AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1035 _getFloatFreq.apply(this, arguments);
1036 for (let i = 0; i < arr.length; i++) {
1037 arr[i] += (Math.random() - 0.5) * 1e-7;
1038 }
1039 };
1040 })();"
1041 .to_string()
1042}
1043
1044fn connection_spoof_script() -> String {
1048 r" // NetworkInformation API spoof (navigator.connection)
1051 (function() {
1052 const _seed = Math.floor(performance.timeOrigin % 997);
1053 const conn = {
1054 rtt: 50 + _seed % 100,
1055 downlink: 5 + _seed % 15,
1056 effectiveType: '4g',
1057 type: 'wifi',
1058 saveData: false,
1059 onchange: null,
1060 ontypechange: null,
1061 addEventListener: function() {},
1062 removeEventListener: function() {},
1063 dispatchEvent: function() { return true; },
1064 };
1065 try {
1066 Object.defineProperty(navigator, 'connection', {
1067 get: () => conn,
1068 enumerable: true,
1069 configurable: false,
1070 });
1071 } catch (_) {}
1072 })();"
1073 .to_string()
1074}
1075
1076fn font_measurement_intercept_script() -> String {
1087 r" // getBoundingClientRect font-probe intercept (Turnstile Layer 1)
1088 (function() {
1089 const _origGBCR = Element.prototype.getBoundingClientRect;
1090 const _seed = Math.floor(performance.timeOrigin % 9973);
1091 function _jitter(base, range) {
1092 return base + ((_seed * 1103515245 + 12345) & 0x7fffffff) % range;
1093 }
1094 Element.prototype.getBoundingClientRect = function() {
1095 const rect = _origGBCR.call(this);
1096 // Only intercept zero-size rects on hidden probe elements (the font-
1097 // measurement pattern: position absolute/fixed, visibility hidden).
1098 if (rect.width === 0 && rect.height === 0) {
1099 const st = window.getComputedStyle(this);
1100 const vis = st.getPropertyValue('visibility');
1101 const pos = st.getPropertyValue('position');
1102 const ariaHidden = this.getAttribute('aria-hidden');
1103 if ((vis === 'hidden' || ariaHidden === 'true') &&
1104 (pos === 'absolute' || pos === 'fixed')) {
1105 const w = _jitter(10, 8);
1106 const h = _jitter(14, 4);
1107 return new DOMRect(0, 0, w, h);
1108 }
1109 }
1110 return rect;
1111 };
1112 })();"
1113 .to_string()
1114}
1115
1116fn storage_estimate_spoof_script() -> String {
1122 r" // navigator.storage.estimate() spoof (Turnstile Layer 1 — storage)
1123 (function() {
1124 if (!navigator.storage || typeof navigator.storage.estimate !== 'function') return;
1125 const _origEstimate = navigator.storage.estimate.bind(navigator.storage);
1126 const _seed = Math.floor(performance.timeOrigin % 9973);
1127 // Realistic Chrome profile: ~250 GB quota, small stable usage.
1128 const quota = (240 + _seed % 20) * 1073741824;
1129 const usage = (5 + _seed % 10) * 1048576;
1130 navigator.storage.estimate = function() {
1131 return _origEstimate().then(function(result) {
1132 return Object.assign({}, result, {
1133 quota: quota,
1134 usage: usage
1135 });
1136 });
1137 };
1138 })();"
1139 .to_string()
1140}
1141
1142fn plugins_spoof_script() -> String {
1151 r" // navigator.plugins / mimeTypes — empty array = instant headless flag
1152 (function() {
1153 // Build minimal objects that survive instanceof checks.
1154 var mime0 = { type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1155 var mime1 = { type: 'text/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1156 var pdfPlugin = {
1157 name: 'PDF Viewer',
1158 description: 'Portable Document Format',
1159 filename: 'internal-pdf-viewer',
1160 length: 2,
1161 0: mime0, 1: mime1,
1162 item: function(i) { return [mime0, mime1][i] || null; },
1163 namedItem: function(n) {
1164 if (n === 'application/pdf') return mime0;
1165 if (n === 'text/pdf') return mime1;
1166 return null;
1167 },
1168 };
1169 mime0.enabledPlugin = pdfPlugin;
1170 mime1.enabledPlugin = pdfPlugin;
1171
1172 var fakePlugins = {
1173 length: 1,
1174 0: pdfPlugin,
1175 item: function(i) { return i === 0 ? pdfPlugin : null; },
1176 namedItem: function(n) { return n === 'PDF Viewer' ? pdfPlugin : null; },
1177 refresh: function() {},
1178 };
1179 var fakeMimes = {
1180 length: 2,
1181 0: mime0, 1: mime1,
1182 item: function(i) { return [mime0, mime1][i] || null; },
1183 namedItem: function(n) {
1184 if (n === 'application/pdf') return mime0;
1185 if (n === 'text/pdf') return mime1;
1186 return null;
1187 },
1188 };
1189
1190 try {
1191 Object.defineProperty(navigator, 'plugins', { get: function() { return fakePlugins; }, configurable: false });
1192 Object.defineProperty(navigator, 'mimeTypes', { get: function() { return fakeMimes; }, configurable: false });
1193 } catch(_) {}
1194 })();"
1195 .to_string()
1196}
1197
1198fn battery_spoof_script() -> String {
1199 r" // Battery API normalization (navigator.getBattery)
1200 (function() {
1201 if (typeof navigator.getBattery !== 'function') return;
1202 const _seed = Math.floor(performance.timeOrigin % 997);
1203 const battery = {
1204 charging: false,
1205 chargingTime: Infinity,
1206 dischargingTime: 3600 + _seed * 7,
1207 level: 0.65 + (_seed % 30) / 100,
1208 onchargingchange: null,
1209 onchargingtimechange: null,
1210 ondischargingtimechange: null,
1211 onlevelchange: null,
1212 addEventListener: function() {},
1213 removeEventListener: function() {},
1214 dispatchEvent: function() { return true; },
1215 };
1216 navigator.getBattery = function() {
1217 return Promise.resolve(battery);
1218 };
1219 })();"
1220 .to_string()
1221}
1222
1223#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 #[test]
1230 fn random_fingerprint_has_valid_ranges() {
1231 let fp = Fingerprint::random();
1232 let (w, h) = fp.screen_resolution;
1233 assert!(
1234 (1280..=3840).contains(&w),
1235 "width {w} out of expected range"
1236 );
1237 assert!(
1238 (768..=2160).contains(&h),
1239 "height {h} out of expected range"
1240 );
1241 assert!(
1242 HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1243 "hardware_concurrency {} not in pool",
1244 fp.hardware_concurrency
1245 );
1246 assert!(
1247 DEVICE_MEMORY.contains(&fp.device_memory),
1248 "device_memory {} not in pool",
1249 fp.device_memory
1250 );
1251 assert!(
1252 TIMEZONES.contains(&fp.timezone.as_str()),
1253 "timezone {} not in pool",
1254 fp.timezone
1255 );
1256 assert!(
1257 LANGUAGES.contains(&fp.language.as_str()),
1258 "language {} not in pool",
1259 fp.language
1260 );
1261 }
1262
1263 #[test]
1264 fn random_generates_different_values_over_time() {
1265 let fp1 = Fingerprint::random();
1268 let fp2 = Fingerprint::random();
1269 assert!(!fp1.user_agent.is_empty());
1271 assert!(!fp2.user_agent.is_empty());
1272 }
1273
1274 #[test]
1275 fn injection_script_contains_screen_dimensions() {
1276 let fp = Fingerprint {
1277 screen_resolution: (2560, 1440),
1278 ..Fingerprint::default()
1279 };
1280 let script = fp.injection_script();
1281 assert!(script.contains("2560"), "missing width in script");
1282 assert!(script.contains("1440"), "missing height in script");
1283 }
1284
1285 #[test]
1286 fn injection_script_contains_timezone() {
1287 let fp = Fingerprint {
1288 timezone: "Europe/Berlin".to_string(),
1289 ..Fingerprint::default()
1290 };
1291 let script = fp.injection_script();
1292 assert!(script.contains("Europe/Berlin"), "timezone missing");
1293 }
1294
1295 #[test]
1296 fn injection_script_contains_canvas_noise_when_enabled() {
1297 let fp = Fingerprint {
1298 canvas_noise: true,
1299 ..Fingerprint::default()
1300 };
1301 let script = fp.injection_script();
1302 assert!(
1303 script.contains("getImageData"),
1304 "canvas noise block missing"
1305 );
1306 }
1307
1308 #[test]
1309 fn injection_script_omits_canvas_noise_when_disabled() {
1310 let fp = Fingerprint {
1311 canvas_noise: false,
1312 ..Fingerprint::default()
1313 };
1314 let script = fp.injection_script();
1315 assert!(
1316 !script.contains("getImageData"),
1317 "canvas noise should be absent"
1318 );
1319 }
1320
1321 #[test]
1322 fn injection_script_contains_webgl_vendor() {
1323 let fp = Fingerprint {
1324 webgl_vendor: Some("TestVendor".to_string()),
1325 webgl_renderer: Some("TestRenderer".to_string()),
1326 canvas_noise: false,
1327 ..Fingerprint::default()
1328 };
1329 let script = fp.injection_script();
1330 assert!(script.contains("TestVendor"), "WebGL vendor missing");
1331 assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1332 }
1333
1334 #[test]
1335 fn inject_fingerprint_fn_equals_method() {
1336 let fp = Fingerprint::default();
1337 assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1338 }
1339
1340 #[test]
1341 fn from_profile_returns_profile_fingerprint() {
1342 let profile = FingerprintProfile::new("test".to_string());
1343 let fp = Fingerprint::from_profile(&profile);
1344 assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1345 }
1346
1347 #[test]
1348 fn script_is_wrapped_in_iife() {
1349 let script = Fingerprint::default().injection_script();
1350 assert!(script.starts_with("(function()"), "must start with IIFE");
1351 assert!(script.ends_with("})();"), "must end with IIFE call");
1352 }
1353
1354 #[test]
1355 fn rng_produces_distinct_values_for_different_steps() {
1356 let seed = 0xdead_beef_cafe_babe_u64;
1357 let v1 = rng(seed, 1);
1358 let v2 = rng(seed, 2);
1359 let v3 = rng(seed, 3);
1360 assert_ne!(v1, v2);
1361 assert_ne!(v2, v3);
1362 }
1363
1364 #[test]
1367 fn device_profile_windows_is_consistent() {
1368 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1369 assert_eq!(fp.platform, "Win32");
1370 assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1371 assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1372 assert!(
1373 fp.validate_consistency().is_empty(),
1374 "must pass consistency"
1375 );
1376 }
1377
1378 #[test]
1379 fn device_profile_mac_is_consistent() {
1380 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1381 assert_eq!(fp.platform, "MacIntel");
1382 assert!(
1383 fp.user_agent.contains("Mac OS X"),
1384 "UA must be macOS: {}",
1385 fp.user_agent
1386 );
1387 assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1388 assert!(
1389 fp.validate_consistency().is_empty(),
1390 "must pass consistency"
1391 );
1392 }
1393
1394 #[test]
1395 fn device_profile_linux_is_consistent() {
1396 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1397 assert_eq!(fp.platform, "Linux x86_64");
1398 assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1399 assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1400 assert!(
1401 fp.validate_consistency().is_empty(),
1402 "must pass consistency"
1403 );
1404 }
1405
1406 #[test]
1407 fn device_profile_android_is_mobile() {
1408 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1409 assert!(
1410 fp.platform.starts_with("Linux"),
1411 "Android platform should be Linux-based"
1412 );
1413 assert!(
1414 fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1415 "Android UA mismatch: {}",
1416 fp.user_agent
1417 );
1418 assert!(!fp.fonts.is_empty());
1419 assert!(DeviceProfile::MobileAndroid.is_mobile());
1420 }
1421
1422 #[test]
1423 fn device_profile_ios_is_mobile() {
1424 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1425 assert_eq!(fp.platform, "iPhone");
1426 assert!(
1427 fp.user_agent.contains("iPhone"),
1428 "iOS UA must contain iPhone"
1429 );
1430 assert!(!fp.fonts.is_empty());
1431 assert!(DeviceProfile::MobileIOS.is_mobile());
1432 }
1433
1434 #[test]
1435 fn desktop_profiles_are_not_mobile() {
1436 assert!(!DeviceProfile::DesktopWindows.is_mobile());
1437 assert!(!DeviceProfile::DesktopMac.is_mobile());
1438 assert!(!DeviceProfile::DesktopLinux.is_mobile());
1439 }
1440
1441 #[test]
1442 fn browser_kind_ios_always_safari() {
1443 for seed in [0u64, 1, 42, 999, u64::MAX] {
1444 assert_eq!(
1445 BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1446 BrowserKind::Safari,
1447 "iOS must always return Safari (seed={seed})"
1448 );
1449 }
1450 }
1451
1452 #[test]
1453 fn device_profile_random_weighted_distribution() {
1454 let windows_count = (0u64..1000)
1456 .filter(|&i| {
1457 DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1458 })
1459 .count();
1460 assert!(
1461 windows_count >= 500,
1462 "Expected ≥50% Windows, got {windows_count}/1000"
1463 );
1464 }
1465
1466 #[test]
1467 fn validate_consistency_catches_platform_ua_mismatch() {
1468 let fp = Fingerprint {
1469 platform: "Win32".to_string(),
1470 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1471 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1472 .to_string(),
1473 ..Fingerprint::default()
1474 };
1475 let issues = fp.validate_consistency();
1476 assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1477 }
1478
1479 #[test]
1480 fn validate_consistency_catches_platform_font_mismatch() {
1481 let fp = Fingerprint {
1482 platform: "MacIntel".to_string(),
1483 fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1484 ..Fingerprint::default()
1485 };
1486 let issues = fp.validate_consistency();
1487 assert!(
1488 !issues.is_empty(),
1489 "should detect MacIntel + Windows fonts mismatch"
1490 );
1491 }
1492
1493 #[test]
1494 fn validate_consistency_passes_for_default() {
1495 let fp = Fingerprint::default();
1496 assert!(fp.validate_consistency().is_empty());
1497 }
1498
1499 #[test]
1500 fn fingerprint_profile_random_weighted_has_fonts() {
1501 let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1502 assert_eq!(profile.name, "sess-1");
1503 assert!(!profile.fingerprint.fonts.is_empty());
1504 assert!(profile.fingerprint.validate_consistency().is_empty());
1505 }
1506
1507 #[test]
1508 fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1509 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1510 let json = serde_json::to_string(&fp)?;
1511 let back: Fingerprint = serde_json::from_str(&json)?;
1512 assert_eq!(back.platform, fp.platform);
1513 assert_eq!(back.fonts, fp.fonts);
1514 Ok(())
1515 }
1516
1517 proptest::proptest! {
1520 #[test]
1522 fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1523 let profile = DeviceProfile::random_weighted(seed);
1524 let fp = Fingerprint::from_device_profile(profile, seed);
1525 let issues = fp.validate_consistency();
1526 proptest::prop_assert!(
1527 issues.is_empty(),
1528 "validate_consistency() failed for seed {seed}: {issues:?}"
1529 );
1530 }
1531
1532 #[test]
1534 fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1535 let fp = Fingerprint::random();
1536 proptest::prop_assert!(
1537 fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1538 "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1539 );
1540 }
1541
1542 #[test]
1544 fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1545 let fp = Fingerprint::random();
1546 let valid: &[u32] = &[4, 8, 16];
1547 proptest::prop_assert!(
1548 valid.contains(&fp.device_memory),
1549 "device_memory {} is not a valid value", fp.device_memory
1550 );
1551 }
1552
1553 #[test]
1555 fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1556 let fp = Fingerprint::random();
1557 let (w, h) = fp.screen_resolution;
1558 proptest::prop_assert!((320..=7680).contains(&w));
1559 proptest::prop_assert!((240..=4320).contains(&h));
1560 }
1561
1562 #[test]
1564 fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1565 let profile = FingerprintProfile::random_weighted(name.clone());
1566 let issues = profile.fingerprint.validate_consistency();
1567 proptest::prop_assert!(
1568 issues.is_empty(),
1569 "FingerprintProfile for '{name}' has issues: {issues:?}"
1570 );
1571 }
1572
1573 #[test]
1575 fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1576 let fp = Fingerprint::random();
1577 let script = inject_fingerprint(&fp);
1578 proptest::prop_assert!(!script.is_empty());
1579 proptest::prop_assert!(script.contains("navigator"));
1580 }
1581 }
1582}