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
694 format!("(function() {{\n{}\n}})();", parts.join("\n\n"))
695 }
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct FingerprintProfile {
710 pub name: String,
712
713 pub fingerprint: Fingerprint,
715}
716
717impl FingerprintProfile {
718 pub fn new(name: String) -> Self {
729 Self {
730 name,
731 fingerprint: Fingerprint::random(),
732 }
733 }
734
735 pub fn random_weighted(name: String) -> Self {
752 let seed = std::time::SystemTime::now()
753 .duration_since(std::time::UNIX_EPOCH)
754 .map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
755 .unwrap_or(0x5a5a_5a5a_5a5a_5a5a);
756
757 let device = DeviceProfile::random_weighted(seed);
758 Self {
759 name,
760 fingerprint: Fingerprint::from_device_profile(device, seed),
761 }
762 }
763}
764
765pub fn inject_fingerprint(fingerprint: &Fingerprint) -> String {
784 fingerprint.injection_script()
785}
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
803pub enum DeviceProfile {
804 #[default]
806 DesktopWindows,
807 DesktopMac,
809 DesktopLinux,
811 MobileAndroid,
813 MobileIOS,
815}
816
817impl DeviceProfile {
818 pub const fn random_weighted(seed: u64) -> Self {
832 let v = rng(seed, 97) % 100;
833 match v {
834 0..=69 => Self::DesktopWindows,
835 70..=89 => Self::DesktopMac,
836 _ => Self::DesktopLinux,
837 }
838 }
839
840 pub const fn is_mobile(self) -> bool {
851 matches!(self, Self::MobileAndroid | Self::MobileIOS)
852 }
853}
854
855#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
869pub enum BrowserKind {
870 #[default]
872 Chrome,
873 Edge,
875 Safari,
877 Firefox,
879}
880
881impl BrowserKind {
882 pub const fn for_device(device: DeviceProfile, seed: u64) -> Self {
898 match device {
899 DeviceProfile::MobileIOS => Self::Safari,
900 DeviceProfile::MobileAndroid => {
901 let v = rng(seed, 201) % 100;
902 if v < 90 { Self::Chrome } else { Self::Firefox }
903 }
904 DeviceProfile::DesktopMac => {
905 let v = rng(seed, 201) % 100;
906 match v {
907 0..=55 => Self::Chrome,
908 56..=91 => Self::Safari,
909 _ => Self::Firefox,
910 }
911 }
912 _ => {
913 let v = rng(seed, 201) % 100;
915 match v {
916 0..=64 => Self::Chrome,
917 65..=80 => Self::Edge,
918 _ => Self::Firefox,
919 }
920 }
921 }
922 }
923}
924
925fn screen_script((width, height): (u32, u32)) -> String {
928 let avail_height = height.saturating_sub(40);
930 format!(
931 r" // Screen dimensions
932 const _defineScreen = (prop, val) =>
933 Object.defineProperty(screen, prop, {{ get: () => val, configurable: false }});
934 _defineScreen('width', {width});
935 _defineScreen('height', {height});
936 _defineScreen('availWidth', {width});
937 _defineScreen('availHeight', {avail_height});
938 _defineScreen('colorDepth', 24);
939 _defineScreen('pixelDepth', 24);"
940 )
941}
942
943fn timezone_script(timezone: &str) -> String {
944 format!(
945 r" // Timezone via Intl.DateTimeFormat
946 const _origResolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
947 Intl.DateTimeFormat.prototype.resolvedOptions = function() {{
948 const opts = _origResolvedOptions.apply(this, arguments);
949 opts.timeZone = {timezone:?};
950 return opts;
951 }};"
952 )
953}
954
955fn language_script(language: &str, user_agent: &str) -> String {
956 let primary = language.split('-').next().unwrap_or("en");
958 format!(
959 r" // Language + userAgent
960 Object.defineProperty(navigator, 'language', {{ get: () => {language:?}, configurable: false }});
961 Object.defineProperty(navigator, 'languages', {{ get: () => Object.freeze([{language:?}, {primary:?}]), configurable: false }});
962 Object.defineProperty(navigator, 'userAgent', {{ get: () => {user_agent:?}, configurable: false }});"
963 )
964}
965
966fn hardware_script(concurrency: u32, memory: u32) -> String {
967 format!(
968 r" // Hardware concurrency + device memory
969 Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {concurrency}, configurable: false }});
970 Object.defineProperty(navigator, 'deviceMemory', {{ get: () => {memory}, configurable: false }});"
971 )
972}
973
974fn webgl_script(vendor: &str, renderer: &str) -> String {
975 format!(
976 r" // WebGL vendor + renderer
977 (function() {{
978 const _getContext = HTMLCanvasElement.prototype.getContext;
979 HTMLCanvasElement.prototype.getContext = function(type, attrs) {{
980 const ctx = _getContext.call(this, type, attrs);
981 if (!ctx) return ctx;
982 if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {{
983 const _getParam = ctx.getParameter.bind(ctx);
984 ctx.getParameter = function(param) {{
985 if (param === 0x1F00) return {vendor:?}; // GL_VENDOR
986 if (param === 0x1F01) return {renderer:?}; // GL_RENDERER
987 return _getParam(param);
988 }};
989 }}
990 return ctx;
991 }};
992 }})();"
993 )
994}
995
996fn canvas_noise_script() -> String {
997 r" // Canvas noise: flip lowest bit of R/G/B channels to defeat pixel readback
998 (function() {
999 const _getImageData = CanvasRenderingContext2D.prototype.getImageData;
1000 CanvasRenderingContext2D.prototype.getImageData = function() {
1001 const id = _getImageData.apply(this, arguments);
1002 const d = id.data;
1003 for (let i = 0; i < d.length; i += 4) {
1004 d[i] ^= 1;
1005 d[i + 1] ^= 1;
1006 d[i + 2] ^= 1;
1007 }
1008 return id;
1009 };
1010 })();"
1011 .to_string()
1012}
1013
1014fn audio_fingerprint_script() -> String {
1015 r" // Audio fingerprint defence: add sub-epsilon noise to frequency data
1016 (function() {
1017 if (typeof AnalyserNode === 'undefined') return;
1018 const _getFloatFreq = AnalyserNode.prototype.getFloatFrequencyData;
1019 AnalyserNode.prototype.getFloatFrequencyData = function(arr) {
1020 _getFloatFreq.apply(this, arguments);
1021 for (let i = 0; i < arr.length; i++) {
1022 arr[i] += (Math.random() - 0.5) * 1e-7;
1023 }
1024 };
1025 })();"
1026 .to_string()
1027}
1028
1029#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 #[test]
1036 fn random_fingerprint_has_valid_ranges() {
1037 let fp = Fingerprint::random();
1038 let (w, h) = fp.screen_resolution;
1039 assert!(
1040 (1280..=3840).contains(&w),
1041 "width {w} out of expected range"
1042 );
1043 assert!(
1044 (768..=2160).contains(&h),
1045 "height {h} out of expected range"
1046 );
1047 assert!(
1048 HARDWARE_CONCURRENCY.contains(&fp.hardware_concurrency),
1049 "hardware_concurrency {} not in pool",
1050 fp.hardware_concurrency
1051 );
1052 assert!(
1053 DEVICE_MEMORY.contains(&fp.device_memory),
1054 "device_memory {} not in pool",
1055 fp.device_memory
1056 );
1057 assert!(
1058 TIMEZONES.contains(&fp.timezone.as_str()),
1059 "timezone {} not in pool",
1060 fp.timezone
1061 );
1062 assert!(
1063 LANGUAGES.contains(&fp.language.as_str()),
1064 "language {} not in pool",
1065 fp.language
1066 );
1067 }
1068
1069 #[test]
1070 fn random_generates_different_values_over_time() {
1071 let fp1 = Fingerprint::random();
1074 let fp2 = Fingerprint::random();
1075 assert!(!fp1.user_agent.is_empty());
1077 assert!(!fp2.user_agent.is_empty());
1078 }
1079
1080 #[test]
1081 fn injection_script_contains_screen_dimensions() {
1082 let fp = Fingerprint {
1083 screen_resolution: (2560, 1440),
1084 ..Fingerprint::default()
1085 };
1086 let script = fp.injection_script();
1087 assert!(script.contains("2560"), "missing width in script");
1088 assert!(script.contains("1440"), "missing height in script");
1089 }
1090
1091 #[test]
1092 fn injection_script_contains_timezone() {
1093 let fp = Fingerprint {
1094 timezone: "Europe/Berlin".to_string(),
1095 ..Fingerprint::default()
1096 };
1097 let script = fp.injection_script();
1098 assert!(script.contains("Europe/Berlin"), "timezone missing");
1099 }
1100
1101 #[test]
1102 fn injection_script_contains_canvas_noise_when_enabled() {
1103 let fp = Fingerprint {
1104 canvas_noise: true,
1105 ..Fingerprint::default()
1106 };
1107 let script = fp.injection_script();
1108 assert!(
1109 script.contains("getImageData"),
1110 "canvas noise block missing"
1111 );
1112 }
1113
1114 #[test]
1115 fn injection_script_omits_canvas_noise_when_disabled() {
1116 let fp = Fingerprint {
1117 canvas_noise: false,
1118 ..Fingerprint::default()
1119 };
1120 let script = fp.injection_script();
1121 assert!(
1122 !script.contains("getImageData"),
1123 "canvas noise should be absent"
1124 );
1125 }
1126
1127 #[test]
1128 fn injection_script_contains_webgl_vendor() {
1129 let fp = Fingerprint {
1130 webgl_vendor: Some("TestVendor".to_string()),
1131 webgl_renderer: Some("TestRenderer".to_string()),
1132 canvas_noise: false,
1133 ..Fingerprint::default()
1134 };
1135 let script = fp.injection_script();
1136 assert!(script.contains("TestVendor"), "WebGL vendor missing");
1137 assert!(script.contains("TestRenderer"), "WebGL renderer missing");
1138 }
1139
1140 #[test]
1141 fn inject_fingerprint_fn_equals_method() {
1142 let fp = Fingerprint::default();
1143 assert_eq!(inject_fingerprint(&fp), fp.injection_script());
1144 }
1145
1146 #[test]
1147 fn from_profile_returns_profile_fingerprint() {
1148 let profile = FingerprintProfile::new("test".to_string());
1149 let fp = Fingerprint::from_profile(&profile);
1150 assert_eq!(fp.user_agent, profile.fingerprint.user_agent);
1151 }
1152
1153 #[test]
1154 fn script_is_wrapped_in_iife() {
1155 let script = Fingerprint::default().injection_script();
1156 assert!(script.starts_with("(function()"), "must start with IIFE");
1157 assert!(script.ends_with("})();"), "must end with IIFE call");
1158 }
1159
1160 #[test]
1161 fn rng_produces_distinct_values_for_different_steps() {
1162 let seed = 0xdead_beef_cafe_babe_u64;
1163 let v1 = rng(seed, 1);
1164 let v2 = rng(seed, 2);
1165 let v3 = rng(seed, 3);
1166 assert_ne!(v1, v2);
1167 assert_ne!(v2, v3);
1168 }
1169
1170 #[test]
1173 fn device_profile_windows_is_consistent() {
1174 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 42);
1175 assert_eq!(fp.platform, "Win32");
1176 assert!(fp.user_agent.contains("Windows NT"), "UA must be Windows");
1177 assert!(!fp.fonts.is_empty(), "Windows profile must have fonts");
1178 assert!(
1179 fp.validate_consistency().is_empty(),
1180 "must pass consistency"
1181 );
1182 }
1183
1184 #[test]
1185 fn device_profile_mac_is_consistent() {
1186 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopMac, 42);
1187 assert_eq!(fp.platform, "MacIntel");
1188 assert!(
1189 fp.user_agent.contains("Mac OS X"),
1190 "UA must be macOS: {}",
1191 fp.user_agent
1192 );
1193 assert!(!fp.fonts.is_empty(), "Mac profile must have fonts");
1194 assert!(
1195 fp.validate_consistency().is_empty(),
1196 "must pass consistency"
1197 );
1198 }
1199
1200 #[test]
1201 fn device_profile_linux_is_consistent() {
1202 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopLinux, 42);
1203 assert_eq!(fp.platform, "Linux x86_64");
1204 assert!(fp.user_agent.contains("Linux"), "UA must be Linux");
1205 assert!(!fp.fonts.is_empty(), "Linux profile must have fonts");
1206 assert!(
1207 fp.validate_consistency().is_empty(),
1208 "must pass consistency"
1209 );
1210 }
1211
1212 #[test]
1213 fn device_profile_android_is_mobile() {
1214 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileAndroid, 42);
1215 assert!(
1216 fp.platform.starts_with("Linux"),
1217 "Android platform should be Linux-based"
1218 );
1219 assert!(
1220 fp.user_agent.contains("Android") || fp.user_agent.contains("Firefox"),
1221 "Android UA mismatch: {}",
1222 fp.user_agent
1223 );
1224 assert!(!fp.fonts.is_empty());
1225 assert!(DeviceProfile::MobileAndroid.is_mobile());
1226 }
1227
1228 #[test]
1229 fn device_profile_ios_is_mobile() {
1230 let fp = Fingerprint::from_device_profile(DeviceProfile::MobileIOS, 42);
1231 assert_eq!(fp.platform, "iPhone");
1232 assert!(
1233 fp.user_agent.contains("iPhone"),
1234 "iOS UA must contain iPhone"
1235 );
1236 assert!(!fp.fonts.is_empty());
1237 assert!(DeviceProfile::MobileIOS.is_mobile());
1238 }
1239
1240 #[test]
1241 fn desktop_profiles_are_not_mobile() {
1242 assert!(!DeviceProfile::DesktopWindows.is_mobile());
1243 assert!(!DeviceProfile::DesktopMac.is_mobile());
1244 assert!(!DeviceProfile::DesktopLinux.is_mobile());
1245 }
1246
1247 #[test]
1248 fn browser_kind_ios_always_safari() {
1249 for seed in [0u64, 1, 42, 999, u64::MAX] {
1250 assert_eq!(
1251 BrowserKind::for_device(DeviceProfile::MobileIOS, seed),
1252 BrowserKind::Safari,
1253 "iOS must always return Safari (seed={seed})"
1254 );
1255 }
1256 }
1257
1258 #[test]
1259 fn device_profile_random_weighted_distribution() {
1260 let windows_count = (0u64..1000)
1262 .filter(|&i| {
1263 DeviceProfile::random_weighted(i * 13 + 7) == DeviceProfile::DesktopWindows
1264 })
1265 .count();
1266 assert!(
1267 windows_count >= 500,
1268 "Expected ≥50% Windows, got {windows_count}/1000"
1269 );
1270 }
1271
1272 #[test]
1273 fn validate_consistency_catches_platform_ua_mismatch() {
1274 let fp = Fingerprint {
1275 platform: "Win32".to_string(),
1276 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
1277 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
1278 .to_string(),
1279 ..Fingerprint::default()
1280 };
1281 let issues = fp.validate_consistency();
1282 assert!(!issues.is_empty(), "should detect Win32+Mac UA mismatch");
1283 }
1284
1285 #[test]
1286 fn validate_consistency_catches_platform_font_mismatch() {
1287 let fp = Fingerprint {
1288 platform: "MacIntel".to_string(),
1289 fonts: vec!["Segoe UI".to_string(), "Calibri".to_string()],
1290 ..Fingerprint::default()
1291 };
1292 let issues = fp.validate_consistency();
1293 assert!(
1294 !issues.is_empty(),
1295 "should detect MacIntel + Windows fonts mismatch"
1296 );
1297 }
1298
1299 #[test]
1300 fn validate_consistency_passes_for_default() {
1301 let fp = Fingerprint::default();
1302 assert!(fp.validate_consistency().is_empty());
1303 }
1304
1305 #[test]
1306 fn fingerprint_profile_random_weighted_has_fonts() {
1307 let profile = FingerprintProfile::random_weighted("sess-1".to_string());
1308 assert_eq!(profile.name, "sess-1");
1309 assert!(!profile.fingerprint.fonts.is_empty());
1310 assert!(profile.fingerprint.validate_consistency().is_empty());
1311 }
1312
1313 #[test]
1314 fn from_device_profile_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
1315 let fp = Fingerprint::from_device_profile(DeviceProfile::DesktopWindows, 123);
1316 let json = serde_json::to_string(&fp)?;
1317 let back: Fingerprint = serde_json::from_str(&json)?;
1318 assert_eq!(back.platform, fp.platform);
1319 assert_eq!(back.fonts, fp.fonts);
1320 Ok(())
1321 }
1322
1323 proptest::proptest! {
1326 #[test]
1328 fn prop_seeded_fingerprint_always_consistent(seed in 0u64..10_000) {
1329 let profile = DeviceProfile::random_weighted(seed);
1330 let fp = Fingerprint::from_device_profile(profile, seed);
1331 let issues = fp.validate_consistency();
1332 proptest::prop_assert!(
1333 issues.is_empty(),
1334 "validate_consistency() failed for seed {seed}: {issues:?}"
1335 );
1336 }
1337
1338 #[test]
1340 fn prop_hardware_concurrency_is_sensible(_seed in 0u64..10_000) {
1341 let fp = Fingerprint::random();
1342 proptest::prop_assert!(
1343 fp.hardware_concurrency >= 1 && fp.hardware_concurrency <= 32,
1344 "hardware_concurrency {} out of [1,32]", fp.hardware_concurrency
1345 );
1346 }
1347
1348 #[test]
1350 fn prop_device_memory_is_valid_value(_seed in 0u64..10_000) {
1351 let fp = Fingerprint::random();
1352 let valid: &[u32] = &[4, 8, 16];
1353 proptest::prop_assert!(
1354 valid.contains(&fp.device_memory),
1355 "device_memory {} is not a valid value", fp.device_memory
1356 );
1357 }
1358
1359 #[test]
1361 fn prop_screen_dimensions_are_plausible(_seed in 0u64..10_000) {
1362 let fp = Fingerprint::random();
1363 let (w, h) = fp.screen_resolution;
1364 proptest::prop_assert!((320..=7680).contains(&w));
1365 proptest::prop_assert!((240..=4320).contains(&h));
1366 }
1367
1368 #[test]
1370 fn prop_fingerprint_profile_passes_consistency(name in "[a-z][a-z0-9]{0,31}") {
1371 let profile = FingerprintProfile::random_weighted(name.clone());
1372 let issues = profile.fingerprint.validate_consistency();
1373 proptest::prop_assert!(
1374 issues.is_empty(),
1375 "FingerprintProfile for '{name}' has issues: {issues:?}"
1376 );
1377 }
1378
1379 #[test]
1381 fn prop_injection_script_non_empty(_seed in 0u64..10_000) {
1382 let fp = Fingerprint::random();
1383 let script = inject_fingerprint(&fp);
1384 proptest::prop_assert!(!script.is_empty());
1385 proptest::prop_assert!(script.contains("navigator"));
1386 }
1387 }
1388}