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(battery_spoof_script());
695 parts.push(plugins_spoof_script());
696
697 format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
698 }
699}
700
701#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct FingerprintProfile {
713 pub name: String,
715
716 pub fingerprint: Fingerprint,
718}
719
720impl FingerprintProfile {
721 pub fn new(name: String) -> Self {
732 Self {
733 name,
734 fingerprint: Fingerprint::random(),
735 }
736 }
737
738 pub fn random_weighted(name: String) -> Self {
755 let seed = std::time::SystemTime::now()
756 .duration_since(std::time::UNIX_EPOCH)
757 .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
758 .unwrap_or(0x5a5a_5a5a_5a5a_5a5a);
759
760 let device = DeviceProfile::random_weighted(seed);
761 Self {
762 name,
763 fingerprint: Fingerprint::from_device_profile(device, seed),
764 }
765 }
766}
767
768pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
787 fingerprint.injection_script()
788}
789
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
806pub enum DeviceProfile {
807 #[default]
809 DesktopWindows,
810 DesktopMac,
812 DesktopLinux,
814 MobileAndroid,
816 MobileIOS,
818}
819
820impl DeviceProfile {
821 pub const fn random_weighted(seed: u64) -> Self {
835 let v = rng(seed, 97) % 100;
836 match v {
837 0..=69 => Self::DesktopWindows,
838 70..=89 => Self::DesktopMac,
839 _ => Self::DesktopLinux,
840 }
841 }
842
843 pub const fn is_mobile(self) -> bool {
854 matches!(self, Self::MobileAndroid | Self::MobileIOS)
855 }
856}
857
858#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
872pub enum BrowserKind {
873 #[default]
875 Chrome,
876 Edge,
878 Safari,
880 Firefox,
882}
883
884impl BrowserKind {
885 pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
901 match device {
902 DeviceProfile::MobileIOS => Self::Safari,
903 DeviceProfile::MobileAndroid => {
904 let v = rng(seed, 201) % 100;
905 if v < 90 { Self::Chrome } else { Self::Firefox }
906 }
907 DeviceProfile::DesktopMac => {
908 let v = rng(seed, 201) % 100;
909 match v {
910 0..=55 => Self::Chrome,
911 56..=91 => Self::Safari,
912 _ => Self::Firefox,
913 }
914 }
915 _ => {
916 let v = rng(seed, 201) % 100;
918 match v {
919 0..=64 => Self::Chrome,
920 65..=80 => Self::Edge,
921 _ => Self::Firefox,
922 }
923 }
924 }
925 }
926}
927
928fn screen_script((width, height): (u32, u32)) -> String {
931 let avail_height = height.saturating_sub(40);
933 format!(
934 r" // Screen dimensions
935 const _defineScreen = (prop, val) =>
936 Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
937 _defineScreen('width', {width});
938 _defineScreen('height', {height});
939 _defineScreen('availWidth', {width});
940 _defineScreen('availHeight', {avail_height});
941 _defineScreen('colorDepth', 24);
942 _defineScreen('pixelDepth', 24);
943 // outerWidth/outerHeight: headless Chrome may return 0; spoof to viewport size.
944 try {{
945 Object.defineProperty(window, 'outerWidth', {{ get: () => {width}, configurable: true }});
946 Object.defineProperty(window, 'outerHeight', {{ get: () => {height}, configurable: true }});
947 }} catch(_) {{}}"
948 )
949}
950
951fn timezone_script(timezone: &str) -> String {
952 format!(
953 r" // Timezone via Intl.DateTimeFormat
954 const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
955 Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
956 const opts = _origResolvedOptions.apply(this, arguments);
957 opts.timeZone = {timezone:?};
958 return opts;
959 }};"
960 )
961}
962
963fn language_script(language: &str, user_agent: &str) -> String {
964 let primary = language.split('-').next().unwrap_or("en");
966 format!(
967 r" // Language + userAgent
968 Object.defineProperty(navigator, 'language', {{ get: () => {language:?}, configurable: false }});
969 Object.defineProperty(navigator, 'languages', {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
970 Object.defineProperty(navigator, 'userAgent', {{ get: () => {user_agent:?}, configurable: false }});"
971 )
972}
973
974fn hardware_script(concurrency: u32, memory: u32) -> String {
975 format!(
976 r" // Hardware concurrency + device memory
977 Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
978 Object.defineProperty(navigator, 'deviceMemory', {{ get: () => {memory}, configurable: false }});"
979 )
980}
981
982fn webgl_script(vendor: &str, renderer: &str) -> String {
983 format!(
984 r" // WebGL vendor + renderer
985 (function() {{
986 const _getContext = HTMLCanvasElement.prototype.getContext;
987 HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
988 const ctx = _getContext.call(this, type, attrs);
989 if (!ctx) return ctx;
990 if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
991 const _getParam = ctx.getParameter.bind(ctx);
992 ctx.getParameter = function(param) {{
993 if (param === 0x1F00) return {vendor:?}; // GL_VENDOR
994 if (param === 0x1F01) return {renderer:?}; // GL_RENDERER
995 return _getParam(param);
996 }};
997 }}
998 return ctx;
999 }};
1000 }})();"
1001 )
1002}
1003
1004fn canvas_noise_script() -> String {
1005 r" // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
1006 (function() {
1007 const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1008 CanvasRenderingContext2D.prototype.getImageData = function() {
1009 const id = _getImageData.apply(this, arguments);
1010 const d = id.data;
1011 for (let i = 0; i < d.length; i += 4) {
1012 d[i] ^= 1;
1013 d[i + 1] ^= 1;
1014 d[i + 2] ^= 1;
1015 }
1016 return id;
1017 };
1018 })();"
1019 .to_string()
1020}
1021
1022fn audio_fingerprint_script() -> String {
1023 r" // Audio fingerprint defence: add sub-epsilon noise to frequency data
1024 (function() {
1025 if (typeof AnalyserNode === 'undefined') return;
1026 const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1027 AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1028 _getFloatFreq.apply(this, arguments);
1029 for (let i = 0; i < arr.length; i++) {
1030 arr[i] += (Math.random() - 0.5) * 1e-7;
1031 }
1032 };
1033 })();"
1034 .to_string()
1035}
1036
1037fn connection_spoof_script() -> String {
1041 r" // NetworkInformation API spoof (navigator.connection)
1044 (function() {
1045 const _seed = Math.floor(performance.timeOrigin % 997);
1046 const conn = {
1047 rtt: 50 + _seed % 100,
1048 downlink: 5 + _seed % 15,
1049 effectiveType: '4g',
1050 type: 'wifi',
1051 saveData: false,
1052 onchange: null,
1053 ontypechange: null,
1054 addEventListener: function() {},
1055 removeEventListener: function() {},
1056 dispatchEvent: function() { return true; },
1057 };
1058 try {
1059 Object.defineProperty(navigator, 'connection', {
1060 get: () => conn,
1061 enumerable: true,
1062 configurable: false,
1063 });
1064 } catch (_) {}
1065 })();"
1066 .to_string()
1067}
1068
1069fn plugins_spoof_script() -> String {
1078 r" // navigator.plugins / mimeTypes — empty array = instant headless flag
1079 (function() {
1080 // Build minimal objects that survive instanceof checks.
1081 var mime0 = { type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1082 var mime1 = { type: 'text/pdf', description: 'Portable Document Format', suffixes: 'pdf', enabledPlugin: null };
1083 var pdfPlugin = {
1084 name: 'PDF Viewer',
1085 description: 'Portable Document Format',
1086 filename: 'internal-pdf-viewer',
1087 length: 2,
1088 0: mime0, 1: mime1,
1089 item: function(i) { return [mime0, mime1][i] || null; },
1090 namedItem: function(n) {
1091 if (n === 'application/pdf') return mime0;
1092 if (n === 'text/pdf') return mime1;
1093 return null;
1094 },
1095 };
1096 mime0.enabledPlugin = pdfPlugin;
1097 mime1.enabledPlugin = pdfPlugin;
1098
1099 var fakePlugins = {
1100 length: 1,
1101 0: pdfPlugin,
1102 item: function(i) { return i === 0 ? pdfPlugin : null; },
1103 namedItem: function(n) { return n === 'PDF Viewer' ? pdfPlugin : null; },
1104 refresh: function() {},
1105 };
1106 var fakeMimes = {
1107 length: 2,
1108 0: mime0, 1: mime1,
1109 item: function(i) { return [mime0, mime1][i] || null; },
1110 namedItem: function(n) {
1111 if (n === 'application/pdf') return mime0;
1112 if (n === 'text/pdf') return mime1;
1113 return null;
1114 },
1115 };
1116
1117 try {
1118 Object.defineProperty(navigator, 'plugins', { get: function() { return fakePlugins; }, configurable: false });
1119 Object.defineProperty(navigator, 'mimeTypes', { get: function() { return fakeMimes; }, configurable: false });
1120 } catch(_) {}
1121 })();"
1122 .to_string()
1123}
1124
1125fn battery_spoof_script() -> String {
1126 r" // Battery API normalization (navigator.getBattery)
1127 (function() {
1128 if (typeof navigator.getBattery !== 'function') return;
1129 const _seed = Math.floor(performance.timeOrigin % 997);
1130 const battery = {
1131 charging: false,
1132 chargingTime: Infinity,
1133 dischargingTime: 3600 + _seed * 7,
1134 level: 0.65 + (_seed % 30) / 100,
1135 onchargingchange: null,
1136 onchargingtimechange: null,
1137 ondischargingtimechange: null,
1138 onlevelchange: null,
1139 addEventListener: function() {},
1140 removeEventListener: function() {},
1141 dispatchEvent: function() { return true; },
1142 };
1143 navigator.getBattery = function() {
1144 return Promise.resolve(battery);
1145 };
1146 })();"
1147 .to_string()
1148}
1149
1150#[cfg(test)]
1153mod tests {
1154 use super::*;
1155
1156 #[test]
1157 fn random_fingerprint_has_valid_ranges() {
1158 let fp = Fingerprint::random();
1159 let (w, h) = fp.screen_resolution;
1160 assert!(
1161 (1280..=3840).contains(&w),
1162 "width {w} out of expected range"
1163 );
1164 assert!(
1165 (768..=2160).contains(&h),
1166 "height {h} out of expected range"
1167 );
1168 assert!(
1169 HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1170 "hardware_concurrency {} not in pool",
1171 fp.hardware_concurrency
1172 );
1173 assert!(
1174 DEVICE_MEMORY.contains(&fp.device_memory),
1175 "device_memory {} not in pool",
1176 fp.device_memory
1177 );
1178 assert!(
1179 TIMEZONES.contains(&fp.timezone.as_str()),
1180 "timezone {} not in pool",
1181 fp.timezone
1182 );
1183 assert!(
1184 LANGUAGES.contains(&fp.language.as_str()),
1185 "language {} not in pool",
1186 fp.language
1187 );
1188 }
1189
1190 #[test]
1191 fn random_generates_different_values_over_time() {
1192 let fp1 = Fingerprint::random();
1195 let fp2 = Fingerprint::random();
1196 assert!(!fp1.user_agent.is_empty());
1198 assert!(!fp2.user_agent.is_empty());
1199 }
1200
1201 #[test]
1202 fn injection_script_contains_screen_dimensions() {
1203 let fp = Fingerprint {
1204 screen_resolution: (2560, 1440),
1205 ..Fingerprint::default()
1206 };
1207 let script = fp.injection_script();
1208 assert!(script.contains("2560"), "missing width in script");
1209 assert!(script.contains("1440"), "missing height in script");
1210 }
1211
1212 #[test]
1213 fn injection_script_contains_timezone() {
1214 let fp = Fingerprint {
1215 timezone: "Europe/Berlin".to_string(),
1216 ..Fingerprint::default()
1217 };
1218 let script = fp.injection_script();
1219 assert!(script.contains("Europe/Berlin"), "timezone missing");
1220 }
1221
1222 #[test]
1223 fn injection_script_contains_canvas_noise_when_enabled() {
1224 let fp = Fingerprint {
1225 canvas_noise: true,
1226 ..Fingerprint::default()
1227 };
1228 let script = fp.injection_script();
1229 assert!(
1230 script.contains("getImageData"),
1231 "canvas noise block missing"
1232 );
1233 }
1234
1235 #[test]
1236 fn injection_script_omits_canvas_noise_when_disabled() {
1237 let fp = Fingerprint {
1238 canvas_noise: false,
1239 ..Fingerprint::default()
1240 };
1241 let script = fp.injection_script();
1242 assert!(
1243 !script.contains("getImageData"),
1244 "canvas noise should be absent"
1245 );
1246 }
1247
1248 #[test]
1249 fn injection_script_contains_webgl_vendor() {
1250 let fp = Fingerprint {
1251 webgl_vendor: Some("TestVendor".to_string()),
1252 webgl_renderer: Some("TestRenderer".to_string()),
1253 canvas_noise: false,
1254 ..Fingerprint::default()
1255 };
1256 let script = fp.injection_script();
1257 assert!(script.contains("TestVendor"), "WebGL vendor missing");
1258 assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1259 }
1260
1261 #[test]
1262 fn inject_fingerprint_fn_equals_method() {
1263 let fp = Fingerprint::default();
1264 assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1265 }
1266
1267 #[test]
1268 fn from_profile_returns_profile_fingerprint() {
1269 let profile = FingerprintProfile::new("test".to_string());
1270 let fp = Fingerprint::from_profile(&profile);
1271 assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1272 }
1273
1274 #[test]
1275 fn script_is_wrapped_in_iife() {
1276 let script = Fingerprint::default().injection_script();
1277 assert!(script.starts_with("(function()"), "must start with IIFE");
1278 assert!(script.ends_with("})();"), "must end with IIFE call");
1279 }
1280
1281 #[test]
1282 fn rng_produces_distinct_values_for_different_steps() {
1283 let seed = 0xdead_beef_cafe_babe_u64;
1284 let v1 = rng(seed, 1);
1285 let v2 = rng(seed, 2);
1286 let v3 = rng(seed, 3);
1287 assert_ne!(v1, v2);
1288 assert_ne!(v2, v3);
1289 }
1290
1291 #[test]
1294 fn device_profile_windows_is_consistent() {
1295 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1296 assert_eq!(fp.platform, "Win32");
1297 assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1298 assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1299 assert!(
1300 fp.validate_consistency().is_empty(),
1301 "must pass consistency"
1302 );
1303 }
1304
1305 #[test]
1306 fn device_profile_mac_is_consistent() {
1307 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1308 assert_eq!(fp.platform, "MacIntel");
1309 assert!(
1310 fp.user_agent.contains("Mac OS X"),
1311 "UA must be macOS: {}",
1312 fp.user_agent
1313 );
1314 assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1315 assert!(
1316 fp.validate_consistency().is_empty(),
1317 "must pass consistency"
1318 );
1319 }
1320
1321 #[test]
1322 fn device_profile_linux_is_consistent() {
1323 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1324 assert_eq!(fp.platform, "Linux x86_64");
1325 assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1326 assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1327 assert!(
1328 fp.validate_consistency().is_empty(),
1329 "must pass consistency"
1330 );
1331 }
1332
1333 #[test]
1334 fn device_profile_android_is_mobile() {
1335 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1336 assert!(
1337 fp.platform.starts_with("Linux"),
1338 "Android platform should be Linux-based"
1339 );
1340 assert!(
1341 fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1342 "Android UA mismatch: {}",
1343 fp.user_agent
1344 );
1345 assert!(!fp.fonts.is_empty());
1346 assert!(DeviceProfile::MobileAndroid.is_mobile());
1347 }
1348
1349 #[test]
1350 fn device_profile_ios_is_mobile() {
1351 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1352 assert_eq!(fp.platform, "iPhone");
1353 assert!(
1354 fp.user_agent.contains("iPhone"),
1355 "iOS UA must contain iPhone"
1356 );
1357 assert!(!fp.fonts.is_empty());
1358 assert!(DeviceProfile::MobileIOS.is_mobile());
1359 }
1360
1361 #[test]
1362 fn desktop_profiles_are_not_mobile() {
1363 assert!(!DeviceProfile::DesktopWindows.is_mobile());
1364 assert!(!DeviceProfile::DesktopMac.is_mobile());
1365 assert!(!DeviceProfile::DesktopLinux.is_mobile());
1366 }
1367
1368 #[test]
1369 fn browser_kind_ios_always_safari() {
1370 for seed in [0u64, 1, 42, 999, u64::MAX] {
1371 assert_eq!(
1372 BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1373 BrowserKind::Safari,
1374 "iOS must always return Safari (seed={seed})"
1375 );
1376 }
1377 }
1378
1379 #[test]
1380 fn device_profile_random_weighted_distribution() {
1381 let windows_count = (0u64..1000)
1383 .filter(|&i| {
1384 DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1385 })
1386 .count();
1387 assert!(
1388 windows_count >= 500,
1389 "Expected ≥50% Windows, got {windows_count}/1000"
1390 );
1391 }
1392
1393 #[test]
1394 fn validate_consistency_catches_platform_ua_mismatch() {
1395 let fp = Fingerprint {
1396 platform: "Win32".to_string(),
1397 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1398 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1399 .to_string(),
1400 ..Fingerprint::default()
1401 };
1402 let issues = fp.validate_consistency();
1403 assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1404 }
1405
1406 #[test]
1407 fn validate_consistency_catches_platform_font_mismatch() {
1408 let fp = Fingerprint {
1409 platform: "MacIntel".to_string(),
1410 fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1411 ..Fingerprint::default()
1412 };
1413 let issues = fp.validate_consistency();
1414 assert!(
1415 !issues.is_empty(),
1416 "should detect MacIntel + Windows fonts mismatch"
1417 );
1418 }
1419
1420 #[test]
1421 fn validate_consistency_passes_for_default() {
1422 let fp = Fingerprint::default();
1423 assert!(fp.validate_consistency().is_empty());
1424 }
1425
1426 #[test]
1427 fn fingerprint_profile_random_weighted_has_fonts() {
1428 let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1429 assert_eq!(profile.name, "sess-1");
1430 assert!(!profile.fingerprint.fonts.is_empty());
1431 assert!(profile.fingerprint.validate_consistency().is_empty());
1432 }
1433
1434 #[test]
1435 fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1436 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1437 let json = serde_json::to_string(&fp)?;
1438 let back: Fingerprint = serde_json::from_str(&json)?;
1439 assert_eq!(back.platform, fp.platform);
1440 assert_eq!(back.fonts, fp.fonts);
1441 Ok(())
1442 }
1443
1444 proptest::proptest! {
1447 #[test]
1449 fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1450 let profile = DeviceProfile::random_weighted(seed);
1451 let fp = Fingerprint::from_device_profile(profile, seed);
1452 let issues = fp.validate_consistency();
1453 proptest::prop_assert!(
1454 issues.is_empty(),
1455 "validate_consistency() failed for seed {seed}: {issues:?}"
1456 );
1457 }
1458
1459 #[test]
1461 fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1462 let fp = Fingerprint::random();
1463 proptest::prop_assert!(
1464 fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1465 "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1466 );
1467 }
1468
1469 #[test]
1471 fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1472 let fp = Fingerprint::random();
1473 let valid: &[u32] = &[4, 8, 16];
1474 proptest::prop_assert!(
1475 valid.contains(&fp.device_memory),
1476 "device_memory {} is not a valid value", fp.device_memory
1477 );
1478 }
1479
1480 #[test]
1482 fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1483 let fp = Fingerprint::random();
1484 let (w, h) = fp.screen_resolution;
1485 proptest::prop_assert!((320..=7680).contains(&w));
1486 proptest::prop_assert!((240..=4320).contains(&h));
1487 }
1488
1489 #[test]
1491 fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1492 let profile = FingerprintProfile::random_weighted(name.clone());
1493 let issues = profile.fingerprint.validate_consistency();
1494 proptest::prop_assert!(
1495 issues.is_empty(),
1496 "FingerprintProfile for '{name}' has issues: {issues:?}"
1497 );
1498 }
1499
1500 #[test]
1502 fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1503 let fp = Fingerprint::random();
1504 let script = inject_fingerprint(&fp);
1505 proptest::prop_assert!(!script.is_empty());
1506 proptest::prop_assert!(script.contains("navigator"));
1507 }
1508 }
1509}