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_or(0x5a5a_5a5a_5a5a_5a5a, |d| {
306 d.as_secs() ^ u64::from(d.subsec_nanos())
307 });
308
309 let res = pick(SCREEN_RESOLUTIONS, rng(seed, 1));
310 let tz = pick(TIMEZONES, rng(seed, 2));
311 let lang = pick(LANGUAGES, rng(seed, 3));
312 let hw = pick(HARDWARE_CONCURRENCY, rng(seed, 4));
313 let dm = pick(DEVICE_MEMORY, rng(seed, 5));
314 let (wv, wr, platform) = pick(WEBGL_PROFILES, rng(seed, 6));
315
316 let user_agent = if platform == "Win32" {
317 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
318 AppleWebKit/537.36 (KHTML, like Gecko) \
319 Chrome/120.0.0.0 Safari/537.36"
320 .to_string()
321 } else {
322 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
323 AppleWebKit/537.36 (KHTML, like Gecko) \
324 Chrome/120.0.0.0 Safari/537.36"
325 .to_string()
326 };
327
328 let fonts: Vec<String> = if platform == "Win32" {
329 WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect()
330 } else {
331 MACOS_FONTS.iter().map(|s| (*s).to_string()).collect()
332 };
333
334 Self {
335 user_agent,
336 screen_resolution: res,
337 timezone: tz.to_string(),
338 language: lang.to_string(),
339 platform: platform.to_string(),
340 hardware_concurrency: hw,
341 device_memory: dm,
342 webgl_vendor: Some(wv.to_string()),
343 webgl_renderer: Some(wr.to_string()),
344 canvas_noise: true,
345 fonts,
346 }
347 }
348
349 pub fn from_profile(profile: &FingerprintProfile) -> Self {
361 profile.fingerprint.clone()
362 }
363
364 pub fn from_device_profile(device: DeviceProfile, seed: u64) -> Self {
379 match device {
380 DeviceProfile::DesktopWindows => Self::for_windows(seed),
381 DeviceProfile::DesktopMac => Self::for_mac(seed),
382 DeviceProfile::DesktopLinux => Self::for_linux(seed),
383 DeviceProfile::MobileAndroid => Self::for_android(seed),
384 DeviceProfile::MobileIOS => Self::for_ios(seed),
385 }
386 }
387
388 pub fn validate_consistency(&self) -> Vec<String> {
402 let mut issues = Vec::new();
403
404 if self.platform == "Win32" && self.user_agent.contains("Mac OS X") {
406 issues.push("Win32 platform but user-agent says Mac OS X".to_string());
407 }
408 if self.platform == "MacIntel" && self.user_agent.contains("Windows NT") {
409 issues.push("MacIntel platform but user-agent says Windows NT".to_string());
410 }
411 if self.platform.starts_with("Linux") && self.user_agent.contains("Windows NT") {
412 issues.push("Linux platform but user-agent says Windows NT".to_string());
413 }
414
415 if let Some(vendor) = &self.webgl_vendor {
417 if (self.platform == "Win32" || self.platform == "MacIntel")
418 && (vendor.contains("Qualcomm")
419 || vendor.contains("Adreno")
420 || vendor.contains("Mali"))
421 {
422 issues.push(format!(
423 "Desktop platform '{}' has mobile GPU vendor '{vendor}'",
424 self.platform
425 ));
426 }
427 if self.platform == "Win32" && vendor.starts_with("Apple") {
428 issues.push(format!("Win32 platform has Apple GPU vendor '{vendor}'"));
429 }
430 }
431
432 if !self.fonts.is_empty() {
434 let has_win_exclusive = self
435 .fonts
436 .iter()
437 .any(|f| matches!(f.as_str(), "Segoe UI" | "Calibri" | "Consolas" | "Tahoma"));
438 let has_mac_exclusive = self.fonts.iter().any(|f| {
439 matches!(
440 f.as_str(),
441 "Lucida Grande" | "Avenir" | "Optima" | "Futura" | "Baskerville"
442 )
443 });
444 let has_linux_exclusive = self.fonts.iter().any(|f| {
445 matches!(
446 f.as_str(),
447 "DejaVu Sans" | "Liberation Sans" | "Ubuntu" | "FreeMono"
448 )
449 });
450
451 if self.platform == "MacIntel" && has_win_exclusive {
452 issues.push("MacIntel platform has Windows-exclusive fonts".to_string());
453 }
454 if self.platform == "Win32" && has_mac_exclusive {
455 issues.push("Win32 platform has macOS-exclusive fonts".to_string());
456 }
457 if self.platform == "Win32" && has_linux_exclusive {
458 issues.push("Win32 platform has Linux-exclusive fonts".to_string());
459 }
460 }
461
462 issues
463 }
464
465 fn for_windows(seed: u64) -> Self {
468 let browser = BrowserKind::for_device(DeviceProfile::DesktopWindows, seed);
469 let user_agent = match browser {
470 BrowserKind::Chrome | BrowserKind::Safari => {
471 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
472 format!(
473 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
474 AppleWebKit/537.36 (KHTML, like Gecko) \
475 Chrome/{ver}.0.0.0 Safari/537.36"
476 )
477 }
478 BrowserKind::Edge => {
479 let ver = pick(EDGE_VERSIONS, rng(seed, 10));
480 format!(
481 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
482 AppleWebKit/537.36 (KHTML, like Gecko) \
483 Chrome/{ver}.0.0.0 Safari/537.36 Edg/{ver}.0.0.0"
484 )
485 }
486 BrowserKind::Firefox => {
487 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
488 format!(
489 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ver}.0) \
490 Gecko/20100101 Firefox/{ver}.0"
491 )
492 }
493 };
494
495 let (webgl_vendor, webgl_renderer) = pick(WINDOWS_WEBGL_PROFILES, rng(seed, 7));
496 let fonts = WINDOWS_FONTS.iter().map(|s| (*s).to_string()).collect();
497
498 Self {
499 user_agent,
500 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
501 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
502 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
503 platform: "Win32".to_string(),
504 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
505 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
506 webgl_vendor: Some(webgl_vendor.to_string()),
507 webgl_renderer: Some(webgl_renderer.to_string()),
508 canvas_noise: true,
509 fonts,
510 }
511 }
512
513 fn for_mac(seed: u64) -> Self {
514 let browser = BrowserKind::for_device(DeviceProfile::DesktopMac, seed);
515 let user_agent = match browser {
516 BrowserKind::Chrome | BrowserKind::Edge => {
517 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
518 format!(
519 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
520 AppleWebKit/537.36 (KHTML, like Gecko) \
521 Chrome/{ver}.0.0.0 Safari/537.36"
522 )
523 }
524 BrowserKind::Safari => {
525 let ver = pick(SAFARI_VERSIONS, rng(seed, 10));
526 format!(
527 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
528 AppleWebKit/605.1.15 (KHTML, like Gecko) \
529 Version/{ver} Safari/605.1.15"
530 )
531 }
532 BrowserKind::Firefox => {
533 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
534 format!(
535 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:{ver}.0) \
536 Gecko/20100101 Firefox/{ver}.0"
537 )
538 }
539 };
540
541 let (webgl_vendor, webgl_renderer) = pick(MACOS_WEBGL_PROFILES, rng(seed, 7));
542 let fonts = MACOS_FONTS.iter().map(|s| (*s).to_string()).collect();
543
544 Self {
545 user_agent,
546 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
547 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
548 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
549 platform: "MacIntel".to_string(),
550 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
551 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
552 webgl_vendor: Some(webgl_vendor.to_string()),
553 webgl_renderer: Some(webgl_renderer.to_string()),
554 canvas_noise: true,
555 fonts,
556 }
557 }
558
559 fn for_linux(seed: u64) -> Self {
560 let browser = BrowserKind::for_device(DeviceProfile::DesktopLinux, seed);
561 let user_agent = if browser == BrowserKind::Firefox {
562 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
563 format!(
564 "Mozilla/5.0 (X11; Linux x86_64; rv:{ver}.0) \
565 Gecko/20100101 Firefox/{ver}.0"
566 )
567 } else {
568 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
569 format!(
570 "Mozilla/5.0 (X11; Linux x86_64) \
571 AppleWebKit/537.36 (KHTML, like Gecko) \
572 Chrome/{ver}.0.0.0 Safari/537.36"
573 )
574 };
575
576 let fonts = LINUX_FONTS.iter().map(|s| (*s).to_string()).collect();
577
578 Self {
579 user_agent,
580 screen_resolution: pick(SCREEN_RESOLUTIONS, rng(seed, 1)),
581 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
582 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
583 platform: "Linux x86_64".to_string(),
584 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
585 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
586 webgl_vendor: Some("Mesa/X.org".to_string()),
587 webgl_renderer: Some("llvmpipe (LLVM 15.0.7, 256 bits)".to_string()),
588 canvas_noise: true,
589 fonts,
590 }
591 }
592
593 fn for_android(seed: u64) -> Self {
594 let browser = BrowserKind::for_device(DeviceProfile::MobileAndroid, seed);
595 let user_agent = if browser == BrowserKind::Firefox {
596 let ver = pick(FIREFOX_VERSIONS, rng(seed, 10));
597 format!(
598 "Mozilla/5.0 (Android 14; Mobile; rv:{ver}.0) \
599 Gecko/20100101 Firefox/{ver}.0"
600 )
601 } else {
602 let ver = pick(CHROME_VERSIONS, rng(seed, 10));
603 format!(
604 "Mozilla/5.0 (Linux; Android 14; Pixel 7) \
605 AppleWebKit/537.36 (KHTML, like Gecko) \
606 Chrome/{ver}.0.6099.144 Mobile Safari/537.36"
607 )
608 };
609
610 let (webgl_vendor, webgl_renderer) = pick(ANDROID_WEBGL_PROFILES, rng(seed, 6));
611 let fonts = MOBILE_ANDROID_FONTS
612 .iter()
613 .map(|s| (*s).to_string())
614 .collect();
615
616 Self {
617 user_agent,
618 screen_resolution: pick(MOBILE_ANDROID_RESOLUTIONS, rng(seed, 1)),
619 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
620 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
621 platform: "Linux armv8l".to_string(),
622 hardware_concurrency: pick(HARDWARE_CONCURRENCY, rng(seed, 4)),
623 device_memory: pick(DEVICE_MEMORY, rng(seed, 5)),
624 webgl_vendor: Some(webgl_vendor.to_string()),
625 webgl_renderer: Some(webgl_renderer.to_string()),
626 canvas_noise: true,
627 fonts,
628 }
629 }
630
631 fn for_ios(seed: u64) -> Self {
632 let safari_ver = pick(SAFARI_VERSIONS, rng(seed, 10));
633 let ios_ver = pick(IOS_OS_VERSIONS, rng(seed, 11));
634 let user_agent = format!(
635 "Mozilla/5.0 (iPhone; CPU iPhone OS {ios_ver} like Mac OS X) \
636 AppleWebKit/605.1.15 (KHTML, like Gecko) \
637 Version/{safari_ver} Mobile/15E148 Safari/604.1"
638 );
639
640 let (webgl_vendor, webgl_renderer) = pick(IOS_WEBGL_PROFILES, rng(seed, 6));
641 let fonts = MOBILE_IOS_FONTS.iter().map(|s| (*s).to_string()).collect();
642
643 Self {
644 user_agent,
645 screen_resolution: pick(MOBILE_IOS_RESOLUTIONS, rng(seed, 1)),
646 timezone: pick(TIMEZONES, rng(seed, 2)).to_string(),
647 language: pick(LANGUAGES, rng(seed, 3)).to_string(),
648 platform: "iPhone".to_string(),
649 hardware_concurrency: 6,
650 device_memory: 4,
651 webgl_vendor: Some(webgl_vendor.to_string()),
652 webgl_renderer: Some(webgl_renderer.to_string()),
653 canvas_noise: true,
654 fonts,
655 }
656 }
657
658 pub fn injection_script(&self) -> String {
678 let mut parts = vec![
679 screen_script(self.screen_resolution),
680 timezone_script(&self.timezone),
681 language_script(&self.language, &self.user_agent),
682 hardware_script(self.hardware_concurrency, self.device_memory),
683 ];
684
685 if let (Some(vendor), Some(renderer)) = (&self.webgl_vendor, &self.webgl_renderer) {
686 parts.push(webgl_script(vendor, renderer));
687 }
688
689 if self.canvas_noise {
690 parts.push(canvas_noise_script());
691 }
692
693 parts.push(audio_fingerprint_script());
694 parts.push(connection_spoof_script());
695 parts.push(font_measurement_intercept_script());
696 parts.push(storage_estimate_spoof_script());
697 parts.push(battery_spoof_script());
698 parts.push(plugins_spoof_script());
699
700 format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
701 }
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct FingerprintProfile {
716 pub name: String,
718
719 pub fingerprint: Fingerprint,
721}
722
723impl FingerprintProfile {
724 pub fn new(name: String) -> Self {
735 Self {
736 name,
737 fingerprint: Fingerprint::random(),
738 }
739 }
740
741 pub fn random_weighted(name: String) -> Self {
758 let seed = std::time::SystemTime::now()
759 .duration_since(std::time::UNIX_EPOCH)
760 .map_or(0x5a5a_5a5a_5a5a_5a5a, |d| {
761 d.as_secs() ^ u64::from(d.subsec_nanos())
762 });
763
764 let device = DeviceProfile::random_weighted(seed);
765 Self {
766 name,
767 fingerprint: Fingerprint::from_device_profile(device, seed),
768 }
769 }
770}
771
772pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
791 fingerprint.injection_script()
792}
793
794#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
810pub enum DeviceProfile {
811 #[default]
813 DesktopWindows,
814 DesktopMac,
816 DesktopLinux,
818 MobileAndroid,
820 MobileIOS,
822}
823
824impl DeviceProfile {
825 pub const fn random_weighted(seed: u64) -> Self {
839 let v = rng(seed, 97) % 100;
840 match v {
841 0..=69 => Self::DesktopWindows,
842 70..=89 => Self::DesktopMac,
843 _ => Self::DesktopLinux,
844 }
845 }
846
847 pub const fn is_mobile(self) -> bool {
858 matches!(self, Self::MobileAndroid | Self::MobileIOS)
859 }
860}
861
862#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
876pub enum BrowserKind {
877 #[default]
879 Chrome,
880 Edge,
882 Safari,
884 Firefox,
886}
887
888impl BrowserKind {
889 pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
905 match device {
906 DeviceProfile::MobileIOS => Self::Safari,
907 DeviceProfile::MobileAndroid => {
908 let v = rng(seed, 201) % 100;
909 if v < 90 { Self::Chrome } else { Self::Firefox }
910 }
911 DeviceProfile::DesktopMac => {
912 let v = rng(seed, 201) % 100;
913 match v {
914 0..=55 => Self::Chrome,
915 56..=91 => Self::Safari,
916 _ => Self::Firefox,
917 }
918 }
919 _ => {
920 let v = rng(seed, 201) % 100;
922 match v {
923 0..=64 => Self::Chrome,
924 65..=80 => Self::Edge,
925 _ => Self::Firefox,
926 }
927 }
928 }
929 }
930}
931
932fn screen_script((width, height): (u32, u32)) -> String {
935 let avail_height = height.saturating_sub(40);
937 format!(
941 r" // Screen dimensions
942 const _defineScreen = (prop, val) =>
943 Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
944 _defineScreen('width', {width});
945 _defineScreen('height', {height});
946 _defineScreen('availWidth', {width});
947 _defineScreen('availHeight', {avail_height});
948 _defineScreen('availLeft', 0);
949 _defineScreen('availTop', 0);
950 _defineScreen('colorDepth', 24);
951 _defineScreen('pixelDepth', 24);
952 // outerWidth/outerHeight: headless Chrome may return 0; spoof to viewport size.
953 try {{
954 Object.defineProperty(window, 'outerWidth', {{ get: () => {width}, configurable: true }});
955 Object.defineProperty(window, 'outerHeight', {{ get: () => {height}, configurable: true }});
956 }} catch(_) {{}}"
957 )
958}
959
960fn timezone_script(timezone: &str) -> String {
961 format!(
962 r" // Timezone via Intl.DateTimeFormat
963 const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
964 Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
965 const opts = _origResolvedOptions.apply(this, arguments);
966 opts.timeZone = {timezone:?};
967 return opts;
968 }};"
969 )
970}
971
972fn language_script(language: &str, user_agent: &str) -> String {
973 let primary = language.split('-').next().unwrap_or("en");
975 format!(
976 r" // Language + userAgent
977 Object.defineProperty(navigator, 'language', {{ get: () => {language:?}, configurable: false }});
978 Object.defineProperty(navigator, 'languages', {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
979 Object.defineProperty(navigator, 'userAgent', {{ get: () => {user_agent:?}, configurable: false }});"
980 )
981}
982
983fn hardware_script(concurrency: u32, memory: u32) -> String {
984 format!(
985 r" // Hardware concurrency + device memory
986 Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
987 Object.defineProperty(navigator, 'deviceMemory', {{ get: () => {memory}, configurable: false }});"
988 )
989}
990
991fn webgl_script(vendor: &str, renderer: &str) -> String {
992 format!(
993 r" // WebGL vendor + renderer
994 (function() {{
995 const _getContext = HTMLCanvasElement.prototype.getContext;
996 HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
997 const ctx = _getContext.call(this, type, attrs);
998 if (!ctx) return ctx;
999 if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
1000 const _getParam = ctx.getParameter.bind(ctx);
1001 ctx.getParameter = function(param) {{
1002 if (param === 0x1F00) return {vendor:?}; // GL_VENDOR
1003 if (param === 0x1F01) return {renderer:?}; // GL_RENDERER
1004 return _getParam(param);
1005 }};
1006 }}
1007 return ctx;
1008 }};
1009 }})();"
1010 )
1011}
1012
1013fn canvas_noise_script() -> String {
1014 r" // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
1015 (function() {
1016 const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1017 CanvasRenderingContext2D.prototype.getImageData = function() {
1018 const id = _getImageData.apply(this, arguments);
1019 const d = id.data;
1020 for (let i = 0; i < d.length; i += 4) {
1021 d[i] ^= 1;
1022 d[i + 1] ^= 1;
1023 d[i + 2] ^= 1;
1024 }
1025 return id;
1026 };
1027 })();"
1028 .to_string()
1029}
1030
1031fn audio_fingerprint_script() -> String {
1032 r" // Audio fingerprint defence: add sub-epsilon noise to frequency data
1033 (function() {
1034 if (typeof AnalyserNode === 'undefined') return;
1035 const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1036 AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1037 _getFloatFreq.apply(this, arguments);
1038 for (let i = 0; i < arr.length; i++) {
1039 arr[i] += (Math.random() - 0.5) * 1e-7;
1040 }
1041 };
1042 })();"
1043 .to_string()
1044}
1045
1046fn connection_spoof_script() -> String {
1050 r" // NetworkInformation API spoof (navigator.connection)
1053 (function() {
1054 const _seed = Math.floor(performance.timeOrigin % 997);
1055 const conn = {
1056 rtt: 50 + _seed % 100,
1057 downlink: 5 + _seed % 15,
1058 effectiveType: '4g',
1059 type: 'wifi',
1060 saveData: false,
1061 onchange: null,
1062 ontypechange: null,
1063 addEventListener: function() {},
1064 removeEventListener: function() {},
1065 dispatchEvent: function() { return true; },
1066 };
1067 try {
1068 Object.defineProperty(navigator, 'connection', {
1069 get: () => conn,
1070 enumerable: true,
1071 configurable: false,
1072 });
1073 } catch (_) {}
1074 })();"
1075 .to_string()
1076}
1077
1078fn font_measurement_intercept_script() -> String {
1089 r" // getBoundingClientRect font-probe intercept (Turnstile Layer 1)
1090 (function() {
1091 const _origGBCR = Element.prototype.getBoundingClientRect;
1092 const _seed = Math.floor(performance.timeOrigin % 9973);
1093 function _jitter(base, range) {
1094 return base + ((_seed * 1103515245 + 12345) & 0x7fffffff) % range;
1095 }
1096 Element.prototype.getBoundingClientRect = function() {
1097 const rect = _origGBCR.call(this);
1098 // Only intercept zero-size rects on hidden probe elements (the font-
1099 // measurement pattern: position absolute/fixed, visibility hidden).
1100 if (rect.width === 0 && rect.height === 0) {
1101 const st = window.getComputedStyle(this);
1102 const vis = st.getPropertyValue('visibility');
1103 const pos = st.getPropertyValue('position');
1104 const ariaHidden = this.getAttribute('aria-hidden');
1105 if ((vis === 'hidden' || ariaHidden === 'true') &&
1106 (pos === 'absolute' || pos === 'fixed')) {
1107 const w = _jitter(10, 8);
1108 const h = _jitter(14, 4);
1109 return new DOMRect(0, 0, w, h);
1110 }
1111 }
1112 return rect;
1113 };
1114 })();"
1115 .to_string()
1116}
1117
1118fn storage_estimate_spoof_script() -> String {
1124 r" // navigator.storage.estimate() spoof (Turnstile Layer 1 — storage)
1125 (function() {
1126 if (!navigator.storage || typeof navigator.storage.estimate !== 'function') return;
1127 const _origEstimate = navigator.storage.estimate.bind(navigator.storage);
1128 const _seed = Math.floor(performance.timeOrigin % 9973);
1129 // Realistic Chrome profile: ~250 GB quota, small stable usage.
1130 const quota = (240 + _seed % 20) * 1073741824;
1131 const usage = (5 + _seed % 10) * 1048576;
1132 navigator.storage.estimate = function() {
1133 return _origEstimate().then(function(result) {
1134 return Object.assign({}, result, {
1135 quota: quota,
1136 usage: usage
1137 });
1138 });
1139 };
1140 })();"
1141 .to_string()
1142}
1143
1144fn plugins_spoof_script() -> String {
1153 r" // navigator.plugins / mimeTypes — empty array = instant headless flag
1154 (function() {
1155 // Build minimal objects that survive instanceof checks.
1156 var mime0 = { type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1157 var mime1 = { type: 'text/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1158 var pdfPlugin = {
1159 name: 'PDF Viewer',
1160 description: 'Portable Document Format',
1161 filename: 'internal-pdf-viewer',
1162 length: 2,
1163 0: mime0, 1: mime1,
1164 item: function(i) { return [mime0, mime1][i] || null; },
1165 namedItem: function(n) {
1166 if (n === 'application/pdf') return mime0;
1167 if (n === 'text/pdf') return mime1;
1168 return null;
1169 },
1170 };
1171 mime0.enabledPlugin = pdfPlugin;
1172 mime1.enabledPlugin = pdfPlugin;
1173
1174 var fakePlugins = {
1175 length: 1,
1176 0: pdfPlugin,
1177 item: function(i) { return i === 0 ? pdfPlugin : null; },
1178 namedItem: function(n) { return n === 'PDF Viewer' ? pdfPlugin : null; },
1179 refresh: function() {},
1180 };
1181 var fakeMimes = {
1182 length: 2,
1183 0: mime0, 1: mime1,
1184 item: function(i) { return [mime0, mime1][i] || null; },
1185 namedItem: function(n) {
1186 if (n === 'application/pdf') return mime0;
1187 if (n === 'text/pdf') return mime1;
1188 return null;
1189 },
1190 };
1191
1192 try {
1193 Object.defineProperty(navigator, 'plugins', { get: function() { return fakePlugins; }, configurable: false });
1194 Object.defineProperty(navigator, 'mimeTypes', { get: function() { return fakeMimes; }, configurable: false });
1195 } catch(_) {}
1196 })();"
1197 .to_string()
1198}
1199
1200fn battery_spoof_script() -> String {
1201 r" // Battery API normalization (navigator.getBattery)
1202 (function() {
1203 if (typeof navigator.getBattery !== 'function') return;
1204 const _seed = Math.floor(performance.timeOrigin % 997);
1205 const battery = {
1206 charging: false,
1207 chargingTime: Infinity,
1208 dischargingTime: 3600 + _seed * 7,
1209 level: 0.65 + (_seed % 30) / 100,
1210 onchargingchange: null,
1211 onchargingtimechange: null,
1212 ondischargingtimechange: null,
1213 onlevelchange: null,
1214 addEventListener: function() {},
1215 removeEventListener: function() {},
1216 dispatchEvent: function() { return true; },
1217 };
1218 navigator.getBattery = function() {
1219 return Promise.resolve(battery);
1220 };
1221 })();"
1222 .to_string()
1223}
1224
1225#[cfg(test)]
1228mod tests {
1229 use super::*;
1230
1231 #[test]
1232 fn random_fingerprint_has_valid_ranges() {
1233 let fp = Fingerprint::random();
1234 let (w, h) = fp.screen_resolution;
1235 assert!(
1236 (1280..=3840).contains(&w),
1237 "width {w} out of expected range"
1238 );
1239 assert!(
1240 (768..=2160).contains(&h),
1241 "height {h} out of expected range"
1242 );
1243 assert!(
1244 HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1245 "hardware_concurrency {} not in pool",
1246 fp.hardware_concurrency
1247 );
1248 assert!(
1249 DEVICE_MEMORY.contains(&fp.device_memory),
1250 "device_memory {} not in pool",
1251 fp.device_memory
1252 );
1253 assert!(
1254 TIMEZONES.contains(&fp.timezone.as_str()),
1255 "timezone {} not in pool",
1256 fp.timezone
1257 );
1258 assert!(
1259 LANGUAGES.contains(&fp.language.as_str()),
1260 "language {} not in pool",
1261 fp.language
1262 );
1263 }
1264
1265 #[test]
1266 fn random_generates_different_values_over_time() {
1267 let fp1 = Fingerprint::random();
1270 let fp2 = Fingerprint::random();
1271 assert!(!fp1.user_agent.is_empty());
1273 assert!(!fp2.user_agent.is_empty());
1274 }
1275
1276 #[test]
1277 fn injection_script_contains_screen_dimensions() {
1278 let fp = Fingerprint {
1279 screen_resolution: (2560, 1440),
1280 ..Fingerprint::default()
1281 };
1282 let script = fp.injection_script();
1283 assert!(script.contains("2560"), "missing width in script");
1284 assert!(script.contains("1440"), "missing height in script");
1285 }
1286
1287 #[test]
1288 fn injection_script_contains_timezone() {
1289 let fp = Fingerprint {
1290 timezone: "Europe/Berlin".to_string(),
1291 ..Fingerprint::default()
1292 };
1293 let script = fp.injection_script();
1294 assert!(script.contains("Europe/Berlin"), "timezone missing");
1295 }
1296
1297 #[test]
1298 fn injection_script_contains_canvas_noise_when_enabled() {
1299 let fp = Fingerprint {
1300 canvas_noise: true,
1301 ..Fingerprint::default()
1302 };
1303 let script = fp.injection_script();
1304 assert!(
1305 script.contains("getImageData"),
1306 "canvas noise block missing"
1307 );
1308 }
1309
1310 #[test]
1311 fn injection_script_omits_canvas_noise_when_disabled() {
1312 let fp = Fingerprint {
1313 canvas_noise: false,
1314 ..Fingerprint::default()
1315 };
1316 let script = fp.injection_script();
1317 assert!(
1318 !script.contains("getImageData"),
1319 "canvas noise should be absent"
1320 );
1321 }
1322
1323 #[test]
1324 fn injection_script_contains_webgl_vendor() {
1325 let fp = Fingerprint {
1326 webgl_vendor: Some("TestVendor".to_string()),
1327 webgl_renderer: Some("TestRenderer".to_string()),
1328 canvas_noise: false,
1329 ..Fingerprint::default()
1330 };
1331 let script = fp.injection_script();
1332 assert!(script.contains("TestVendor"), "WebGL vendor missing");
1333 assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1334 }
1335
1336 #[test]
1337 fn inject_fingerprint_fn_equals_method() {
1338 let fp = Fingerprint::default();
1339 assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1340 }
1341
1342 #[test]
1343 fn from_profile_returns_profile_fingerprint() {
1344 let profile = FingerprintProfile::new("test".to_string());
1345 let fp = Fingerprint::from_profile(&profile);
1346 assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1347 }
1348
1349 #[test]
1350 fn script_is_wrapped_in_iife() {
1351 let script = Fingerprint::default().injection_script();
1352 assert!(script.starts_with("(function()"), "must start with IIFE");
1353 assert!(script.ends_with("})();"), "must end with IIFE call");
1354 }
1355
1356 #[test]
1357 fn rng_produces_distinct_values_for_different_steps() {
1358 let seed = 0xdead_beef_cafe_babe_u64;
1359 let v1 = rng(seed, 1);
1360 let v2 = rng(seed, 2);
1361 let v3 = rng(seed, 3);
1362 assert_ne!(v1, v2);
1363 assert_ne!(v2, v3);
1364 }
1365
1366 #[test]
1369 fn device_profile_windows_is_consistent() {
1370 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1371 assert_eq!(fp.platform, "Win32");
1372 assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1373 assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1374 assert!(
1375 fp.validate_consistency().is_empty(),
1376 "must pass consistency"
1377 );
1378 }
1379
1380 #[test]
1381 fn device_profile_mac_is_consistent() {
1382 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1383 assert_eq!(fp.platform, "MacIntel");
1384 assert!(
1385 fp.user_agent.contains("Mac OS X"),
1386 "UA must be macOS: {}",
1387 fp.user_agent
1388 );
1389 assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1390 assert!(
1391 fp.validate_consistency().is_empty(),
1392 "must pass consistency"
1393 );
1394 }
1395
1396 #[test]
1397 fn device_profile_linux_is_consistent() {
1398 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1399 assert_eq!(fp.platform, "Linux x86_64");
1400 assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1401 assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1402 assert!(
1403 fp.validate_consistency().is_empty(),
1404 "must pass consistency"
1405 );
1406 }
1407
1408 #[test]
1409 fn device_profile_android_is_mobile() {
1410 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1411 assert!(
1412 fp.platform.starts_with("Linux"),
1413 "Android platform should be Linux-based"
1414 );
1415 assert!(
1416 fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1417 "Android UA mismatch: {}",
1418 fp.user_agent
1419 );
1420 assert!(!fp.fonts.is_empty());
1421 assert!(DeviceProfile::MobileAndroid.is_mobile());
1422 }
1423
1424 #[test]
1425 fn device_profile_ios_is_mobile() {
1426 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1427 assert_eq!(fp.platform, "iPhone");
1428 assert!(
1429 fp.user_agent.contains("iPhone"),
1430 "iOS UA must contain iPhone"
1431 );
1432 assert!(!fp.fonts.is_empty());
1433 assert!(DeviceProfile::MobileIOS.is_mobile());
1434 }
1435
1436 #[test]
1437 fn desktop_profiles_are_not_mobile() {
1438 assert!(!DeviceProfile::DesktopWindows.is_mobile());
1439 assert!(!DeviceProfile::DesktopMac.is_mobile());
1440 assert!(!DeviceProfile::DesktopLinux.is_mobile());
1441 }
1442
1443 #[test]
1444 fn browser_kind_ios_always_safari() {
1445 for seed in [0u64, 1, 42, 999, u64::MAX] {
1446 assert_eq!(
1447 BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1448 BrowserKind::Safari,
1449 "iOS must always return Safari (seed={seed})"
1450 );
1451 }
1452 }
1453
1454 #[test]
1455 fn device_profile_random_weighted_distribution() {
1456 let windows_count = (0u64..1000)
1458 .filter(|&i| {
1459 DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1460 })
1461 .count();
1462 assert!(
1463 windows_count >= 500,
1464 "Expected ≥50% Windows, got {windows_count}/1000"
1465 );
1466 }
1467
1468 #[test]
1469 fn validate_consistency_catches_platform_ua_mismatch() {
1470 let fp = Fingerprint {
1471 platform: "Win32".to_string(),
1472 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1473 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1474 .to_string(),
1475 ..Fingerprint::default()
1476 };
1477 let issues = fp.validate_consistency();
1478 assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1479 }
1480
1481 #[test]
1482 fn validate_consistency_catches_platform_font_mismatch() {
1483 let fp = Fingerprint {
1484 platform: "MacIntel".to_string(),
1485 fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1486 ..Fingerprint::default()
1487 };
1488 let issues = fp.validate_consistency();
1489 assert!(
1490 !issues.is_empty(),
1491 "should detect MacIntel + Windows fonts mismatch"
1492 );
1493 }
1494
1495 #[test]
1496 fn validate_consistency_passes_for_default() {
1497 let fp = Fingerprint::default();
1498 assert!(fp.validate_consistency().is_empty());
1499 }
1500
1501 #[test]
1502 fn fingerprint_profile_random_weighted_has_fonts() {
1503 let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1504 assert_eq!(profile.name, "sess-1");
1505 assert!(!profile.fingerprint.fonts.is_empty());
1506 assert!(profile.fingerprint.validate_consistency().is_empty());
1507 }
1508
1509 #[test]
1510 fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1511 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1512 let json = serde_json::to_string(&fp)?;
1513 let back: Fingerprint = serde_json::from_str(&json)?;
1514 assert_eq!(back.platform, fp.platform);
1515 assert_eq!(back.fonts, fp.fonts);
1516 Ok(())
1517 }
1518
1519 proptest::proptest! {
1522 #[test]
1524 fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1525 let profile = DeviceProfile::random_weighted(seed);
1526 let fp = Fingerprint::from_device_profile(profile, seed);
1527 let issues = fp.validate_consistency();
1528 proptest::prop_assert!(
1529 issues.is_empty(),
1530 "validate_consistency() failed for seed {seed}: {issues:?}"
1531 );
1532 }
1533
1534 #[test]
1536 fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1537 let fp = Fingerprint::random();
1538 proptest::prop_assert!(
1539 fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1540 "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1541 );
1542 }
1543
1544 #[test]
1546 fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1547 let fp = Fingerprint::random();
1548 let valid: &[u32] = &[4, 8, 16];
1549 proptest::prop_assert!(
1550 valid.contains(&fp.device_memory),
1551 "device_memory {} is not a valid value", fp.device_memory
1552 );
1553 }
1554
1555 #[test]
1557 fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1558 let fp = Fingerprint::random();
1559 let (w, h) = fp.screen_resolution;
1560 proptest::prop_assert!((320..=7680).contains(&w));
1561 proptest::prop_assert!((240..=4320).contains(&h));
1562 }
1563
1564 #[test]
1566 fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1567 let profile = FingerprintProfile::random_weighted(name.clone());
1568 let issues = profile.fingerprint.validate_consistency();
1569 proptest::prop_assert!(
1570 issues.is_empty(),
1571 "FingerprintProfile for '{name}' has issues: {issues:?}"
1572 );
1573 }
1574
1575 #[test]
1577 fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1578 let fp = Fingerprint::random();
1579 let script = inject_fingerprint(&fp);
1580 proptest::prop_assert!(!script.is_empty());
1581 proptest::prop_assert!(script.contains("navigator"));
1582 }
1583 }
1584}