Skip to main content

stygian_browser/
peripheral_stealth.rs

1//! Peripheral detection surface hardening.
2//!
3//! Covers secondary browser detection vectors that build bot-confidence scores:
4//!
5//! 1. **iframe `innerWidth` mismatch** — ensures iframe inner dimensions differ
6//!    slightly from the parent window (Kasada check).
7//! 2. **Document visibility** — forces `document.hidden = false` and
8//!    `visibilityState = "visible"`.
9//! 3. **Camera / microphone device names** — returns platform-appropriate fake
10//!    device names with seeded random `deviceId`/`groupId`.
11//! 4. **Port scan protection** — blocks `fetch` and `XMLHttpRequest` to
12//!    localhost on commonly-probed ports.
13//! 5. **History length** — overrides `history.length` to a plausible 3-8.
14//! 6. **`requestAnimationFrame` timing** — adds light per-frame jitter to
15//!    prevent headless rAF timing detection.
16//! 7. **PDF viewer** — ensures `navigator.pdfViewerEnabled` reads `true`.
17//!
18//! # Example
19//!
20//! ```
21//! use stygian_browser::peripheral_stealth::{peripheral_stealth_script, PeripheralStealthConfig};
22//! use stygian_browser::noise::NoiseSeed;
23//!
24//! let cfg = PeripheralStealthConfig::default_with_seed(NoiseSeed::from(1_u64));
25//! let js = peripheral_stealth_script(&cfg);
26//! assert!(js.contains("document.hidden"));
27//! assert!(js.contains("History.prototype"));
28//! ```
29
30use serde::{Deserialize, Serialize};
31
32use crate::noise::{NoiseEngine, NoiseSeed};
33use crate::profile::{FingerprintProfile, Os};
34
35// ---------------------------------------------------------------------------
36// Config
37// ---------------------------------------------------------------------------
38
39/// Per-subsystem toggles for peripheral stealth injection.
40///
41/// # Example
42///
43/// ```
44/// use stygian_browser::peripheral_stealth::PeripheralStealthConfig;
45/// use stygian_browser::noise::NoiseSeed;
46///
47/// let cfg = PeripheralStealthConfig::default_with_seed(NoiseSeed::from(1_u64));
48/// assert!(cfg.iframe_inner_width);
49/// assert!(cfg.always_visible);
50/// assert!(cfg.fake_media_devices);
51/// assert!(cfg.block_port_scan);
52/// assert!(cfg.history_length);
53/// assert!(cfg.raf_timing);
54/// assert!(cfg.pdf_viewer);
55/// ```
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[allow(clippy::struct_excessive_bools)] // public config struct — 7 orthogonal peripheral stealth toggles read more clearly as bools than a bitmask
58pub struct PeripheralStealthConfig {
59    /// Adjust iframe `contentWindow.innerWidth` to differ from parent.
60    pub iframe_inner_width: bool,
61    /// Force `document.hidden = false` / `visibilityState = "visible"`.
62    pub always_visible: bool,
63    /// Return platform-appropriate fake camera/microphone devices.
64    pub fake_media_devices: bool,
65    /// Block fetch/XHR to localhost probe ports silently.
66    pub block_port_scan: bool,
67    /// Override `history.length` to a plausible value (3–8).
68    pub history_length: bool,
69    /// Add per-frame jitter to `requestAnimationFrame`.
70    pub raf_timing: bool,
71    /// Ensure `navigator.pdfViewerEnabled` returns `true`.
72    pub pdf_viewer: bool,
73    /// Noise seed for deterministic device IDs and history length.
74    pub seed: NoiseSeed,
75}
76
77impl PeripheralStealthConfig {
78    /// All subsystems enabled, with a given seed.
79    ///
80    /// # Example
81    ///
82    /// ```
83    /// use stygian_browser::peripheral_stealth::PeripheralStealthConfig;
84    /// use stygian_browser::noise::NoiseSeed;
85    ///
86    /// let cfg = PeripheralStealthConfig::default_with_seed(NoiseSeed::from(42_u64));
87    /// assert!(cfg.iframe_inner_width);
88    /// ```
89    #[must_use]
90    pub const fn default_with_seed(seed: NoiseSeed) -> Self {
91        Self {
92            iframe_inner_width: true,
93            always_visible: true,
94            fake_media_devices: true,
95            block_port_scan: true,
96            history_length: true,
97            raf_timing: true,
98            pdf_viewer: true,
99            seed,
100        }
101    }
102}
103
104impl Default for PeripheralStealthConfig {
105    fn default() -> Self {
106        Self::default_with_seed(NoiseSeed::random())
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Script generator
112// ---------------------------------------------------------------------------
113
114/// Generate the peripheral stealth injection script.
115///
116/// Pass `fingerprint_profile` when you want platform-appropriate device names;
117/// if `None`, defaults to Windows-like device names.
118///
119/// # Example
120///
121/// ```
122/// use stygian_browser::peripheral_stealth::{peripheral_stealth_script, PeripheralStealthConfig};
123/// use stygian_browser::noise::NoiseSeed;
124///
125/// let cfg = PeripheralStealthConfig::default_with_seed(NoiseSeed::from(1_u64));
126/// let js = peripheral_stealth_script(&cfg);
127/// assert!(js.contains("visibilityState"));
128/// ```
129#[must_use]
130pub fn peripheral_stealth_script(config: &PeripheralStealthConfig) -> String {
131    peripheral_stealth_script_with_profile(config, None)
132}
133
134/// Generate peripheral stealth script with optional [`FingerprintProfile`] for
135/// platform-aware device names.
136///
137/// # Example
138///
139/// ```
140/// use stygian_browser::peripheral_stealth::{
141///     peripheral_stealth_script_with_profile, PeripheralStealthConfig,
142/// };
143/// use stygian_browser::profile::FingerprintProfile;
144/// use stygian_browser::noise::NoiseSeed;
145///
146/// let cfg = PeripheralStealthConfig::default_with_seed(NoiseSeed::from(1_u64));
147/// let profile = FingerprintProfile::macos_chrome_136_m1();
148/// let js = peripheral_stealth_script_with_profile(&cfg, Some(&profile));
149/// assert!(js.contains("FaceTime"));
150/// ```
151#[must_use]
152pub fn peripheral_stealth_script_with_profile(
153    config: &PeripheralStealthConfig,
154    fingerprint_profile: Option<&FingerprintProfile>,
155) -> String {
156    let engine = NoiseEngine::new(config.seed);
157
158    let mut sections: Vec<String> = Vec::new();
159
160    // ── 1. iframe innerWidth ─────────────────────────────────────────────────
161    if config.iframe_inner_width {
162        sections.push(IFRAME_INNER_WIDTH_SECTION.to_string());
163    }
164
165    // ── 2. Document visibility ────────────────────────────────────────────────
166    if config.always_visible {
167        sections.push(VISIBILITY_SECTION.to_string());
168    }
169
170    // ── 3. Camera / microphone devices ────────────────────────────────────────
171    if config.fake_media_devices {
172        let os = fingerprint_profile.map(|p| &p.platform.os);
173        let video_device = platform_video_device(os);
174        let audio_device = platform_audio_device(os);
175
176        // Derive deterministic hex hashes for device/group IDs
177        let video_device_id = engine.hex_id("media.video.device_id");
178        let video_group_id = engine.hex_id("media.video.group_id");
179        let audio_device_id = engine.hex_id("media.audio.device_id");
180        let audio_group_id = engine.hex_id("media.audio.group_id");
181
182        sections.push(format!(
183            r"  // ── 3. Fake media devices (camera / microphone) ───────────────────────
184  if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {{
185    const _fakeDevices = [
186      {{
187        deviceId: '{video_device_id}',
188        groupId: '{video_group_id}',
189        kind: 'videoinput',
190        label: '{video_device}',
191        toJSON: function() {{
192          return {{ deviceId: '{video_device_id}', groupId: '{video_group_id}', kind: 'videoinput', label: '{video_device}' }};
193        }},
194      }},
195      {{
196        deviceId: '{audio_device_id}',
197        groupId: '{audio_group_id}',
198        kind: 'audioinput',
199        label: '{audio_device}',
200        toJSON: function() {{
201          return {{ deviceId: '{audio_device_id}', groupId: '{audio_group_id}', kind: 'audioinput', label: '{audio_device}' }};
202        }},
203      }},
204    ];
205    const _origEnum = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices);
206    Object.defineProperty(navigator.mediaDevices, 'enumerateDevices', {{
207      value: function enumerateDevices() {{
208        return _origEnum().then(function(real) {{
209          // If real devices are present (permissions granted), return them;
210          // otherwise return our fake list to avoid empty-list detection.
211          return real.length > 0 && real.some(function(d) {{ return d.label !== ''; }})
212            ? real
213            : _fakeDevices;
214        }});
215      }},
216      writable: false, configurable: false, enumerable: true,
217    }});
218  }}",
219        ));
220    }
221
222    // ── 4. Port scan protection ────────────────────────────────────────────────
223    if config.block_port_scan {
224        sections.push(PORT_SCAN_SECTION.to_string());
225    }
226
227    // ── 5. History length ─────────────────────────────────────────────────────
228    if config.history_length {
229        // Derive a stable value in [3, 8] from the seed
230        let history_len = 3u64 + (engine.u64_noise("history.length") % 6);
231        sections.push(format!(
232            r"  // ── 5. History length ──────────────────────────────────────────────────
233  try {{
234    Object.defineProperty(History.prototype, 'length', {{
235      get: function() {{ return {history_len}; }},
236      configurable: true, enumerable: true,
237    }});
238  }} catch(e) {{}}",
239        ));
240    }
241
242    // ── 6. requestAnimationFrame timing ──────────────────────────────────────
243    if config.raf_timing {
244        sections.push(RAF_TIMING_SECTION.to_string());
245    }
246
247    // ── 7. pdfViewerEnabled ───────────────────────────────────────────────────
248    if config.pdf_viewer {
249        sections.push(PDF_VIEWER_SECTION.to_string());
250    }
251
252    if sections.is_empty() {
253        return String::new();
254    }
255
256    format!(
257        "(function() {{\n  'use strict';\n\n{body}\n\n}})();\n",
258        body = sections.join("\n\n"),
259    )
260}
261
262// ---------------------------------------------------------------------------
263// Platform-aware device names
264// ---------------------------------------------------------------------------
265
266const fn platform_video_device(os: Option<&Os>) -> &'static str {
267    match os {
268        Some(Os::MacOs | Os::Ios) => "FaceTime HD Camera",
269        Some(Os::Linux) => "USB2.0 PC Camera",
270        Some(Os::Android) => "Camera 0",
271        _ => "Integrated Webcam",
272    }
273}
274
275const fn platform_audio_device(os: Option<&Os>) -> &'static str {
276    match os {
277        Some(Os::MacOs | Os::Ios) => "MacBook Pro Microphone",
278        Some(Os::Linux) => "Built-in Audio Analog Stereo",
279        Some(Os::Android) => "Default",
280        _ => "Microphone (Realtek Audio)",
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Static script sections
286// ---------------------------------------------------------------------------
287
288const IFRAME_INNER_WIDTH_SECTION: &str = r"  // ── 1. iframe innerWidth mismatch ─────────────────────────────────────
289  try {
290    const _origContentWindow = Object.getOwnPropertyDescriptor(
291      HTMLIFrameElement.prototype, 'contentWindow'
292    );
293    if (_origContentWindow && _origContentWindow.get) {
294      const _origGetter = _origContentWindow.get;
295      Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
296        get: function() {
297          const cw = _origGetter.call(this);
298          if (!cw) { return cw; }
299          // Expose a border-adjusted innerWidth to prevent Kasada's
300          // iframe-vs-window width equality check
301          const _iframeWidth = cw.innerWidth;
302          if (typeof _iframeWidth === 'number' && _iframeWidth === window.innerWidth) {
303            try {
304              Object.defineProperty(cw, 'innerWidth', {
305                get: function() { return _iframeWidth - 17; }, // scrollbar offset
306                configurable: true,
307              });
308            } catch(e) {}
309          }
310          return cw;
311        },
312        configurable: true,
313        enumerable: true,
314      });
315    }
316  } catch(e) {}";
317
318const VISIBILITY_SECTION: &str = r"  // ── 2. Document visibility ─────────────────────────────────────────────
319  // Ensures document.hidden and document.visibilityState are properly spoofed.
320  try {
321    Object.defineProperty(Document.prototype, 'hidden', {
322      get: function() { return false; }, configurable: false, enumerable: true,
323    });
324    Object.defineProperty(Document.prototype, 'visibilityState', {
325      get: function() { return 'visible'; }, configurable: false, enumerable: true,
326    });
327    // Filter visibilitychange events from propagating
328    const _origDocAEL = Document.prototype.addEventListener;
329    Document.prototype.addEventListener = function addEventListener(type, listener, opts) {
330      if (type === 'visibilitychange') { return; }
331      return _origDocAEL.call(this, type, listener, opts);
332    };
333    Document.prototype.addEventListener.toString = function toString() {
334      return 'function addEventListener() { [native code] }';
335    };
336  } catch(e) {}";
337
338const PORT_SCAN_SECTION: &str = r"  // ── 4. Port scan protection ──────────────────────────────────────────────
339  (function() {
340    // Ports commonly probed by anti-bot scripts during port scanning
341    const _probePorts = new Set([
342      22, 23, 25, 80, 443, 3000, 3001, 3002, 3389, 3999,
343      5000, 5432, 5500, 5900, 6379, 8080, 8081, 8082, 8083,
344      8084, 8085, 8086, 8087, 8088, 8089, 8090, 8091, 8092,
345      8093, 8094, 8095, 8096, 8097, 8098, 8099, 9229,
346    ]);
347    const _localHosts = ['127.0.0.1', 'localhost', '[::1]', '0.0.0.0', '::1'];
348    function _isLocalProbe(url) {
349      try {
350        const u = new URL(url);
351        const port = parseInt(u.port || (u.protocol === 'https:' ? '443' : '80'), 10);
352        return _localHosts.some(function(h) { return u.hostname === h; }) &&
353               _probePorts.has(port);
354      } catch(e) { return false; }
355    }
356    // Wrap fetch
357    const _origFetch = window.fetch;
358    window.fetch = function fetch(resource, init) {
359      const url = typeof resource === 'string' ? resource
360        : resource instanceof Request ? resource.url : String(resource);
361      if (_isLocalProbe(url)) {
362        return Promise.reject(new TypeError('Failed to fetch'));
363      }
364      return _origFetch.apply(window, arguments);
365    };
366    window.fetch.toString = function toString() {
367      return 'function fetch() { [native code] }';
368    };
369    // Wrap XMLHttpRequest.open
370    const _origXhrOpen = XMLHttpRequest.prototype.open;
371    XMLHttpRequest.prototype.open = function open(method, url) {
372      if (_isLocalProbe(String(url))) {
373        // Silently make this request go nowhere
374        return _origXhrOpen.call(this, method, 'about:blank');
375      }
376      return _origXhrOpen.apply(this, arguments);
377    };
378    XMLHttpRequest.prototype.open.toString = function toString() {
379      return 'function open() { [native code] }';
380    };
381  })();";
382
383const RAF_TIMING_SECTION: &str = r"  // ── 6. requestAnimationFrame timing jitter ─────────────────────────────
384  try {
385    const _origRAF = window.requestAnimationFrame;
386    let __raf_counter = 0;
387    window.requestAnimationFrame = function requestAnimationFrame(callback) {
388      const _frame = __raf_counter++;
389      return _origRAF.call(window, function(timestamp) {
390        // Add sub-millisecond jitter to the rAF timestamp to simulate real
391        // vsync timing variation (±0.1 ms max)
392        const jitter = ((_frame * 2654435761) % 1000) / 10000000.0;
393        return callback(timestamp + jitter);
394      });
395    };
396    window.requestAnimationFrame.toString = function toString() {
397      return 'function requestAnimationFrame() { [native code] }';
398    };
399  } catch(e) {}";
400
401const PDF_VIEWER_SECTION: &str = r"  // ── 7. pdfViewerEnabled ─────────────────────────────────────────────────
402  try {
403    const _pdfDesc = Object.getOwnPropertyDescriptor(Navigator.prototype, 'pdfViewerEnabled');
404    if (!_pdfDesc || (_pdfDesc.get && navigator.pdfViewerEnabled !== true)) {
405      Object.defineProperty(Navigator.prototype, 'pdfViewerEnabled', {
406        get: function() { return true; },
407        configurable: false, enumerable: true,
408      });
409    }
410  } catch(e) {}";
411
412// ---------------------------------------------------------------------------
413// NoiseEngine hex_id helper (private extension)
414// ---------------------------------------------------------------------------
415
416fn f64_bits_to_hex(value: f64) -> String {
417    format!("{:016x}", value.to_bits())
418}
419
420trait NoiseEngineExt {
421    fn hex_id(&self, key: &str) -> String;
422    fn u64_noise(&self, key: &str) -> u64;
423}
424
425impl NoiseEngineExt for NoiseEngine {
426    /// Derive a 64-char hex string (256-bit-equivalent ID) from `key`.
427    fn hex_id(&self, key: &str) -> String {
428        // Build 4 × u64 and format as hex to approximate a UUID-length device ID
429        let a = self.float_noise(key, 0);
430        let b = self.float_noise(key, 1);
431        let c = self.float_noise(key, 2);
432        let d = self.float_noise(key, 3);
433        format!(
434            "{}{}{}{}",
435            f64_bits_to_hex(a),
436            f64_bits_to_hex(b),
437            f64_bits_to_hex(c),
438            f64_bits_to_hex(d)
439        )
440    }
441
442    /// Derive a deterministic `u64` from `key`.
443    fn u64_noise(&self, key: &str) -> u64 {
444        self.float_noise(key, 0).to_bits()
445    }
446}
447
448// ---------------------------------------------------------------------------
449// Tests
450// ---------------------------------------------------------------------------
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::noise::NoiseSeed;
456
457    fn cfg(seed: u64) -> PeripheralStealthConfig {
458        PeripheralStealthConfig::default_with_seed(NoiseSeed::from(seed))
459    }
460
461    fn script(seed: u64) -> String {
462        peripheral_stealth_script(&cfg(seed))
463    }
464
465    // ── iframe innerWidth ─────────────────────────────────────────────────────
466
467    #[test]
468    fn iframe_script_adjusts_inner_width() {
469        let js = script(1);
470        assert!(js.contains("innerWidth"), "missing innerWidth override");
471        assert!(
472            js.contains("scrollbar offset") || js.contains("17"),
473            "missing offset adjustment"
474        );
475    }
476
477    // ── Document visibility ────────────────────────────────────────────────────
478
479    #[test]
480    fn visibility_forces_hidden_false_and_visible() {
481        let js = script(1);
482        assert!(
483            js.contains("document.hidden") || js.contains("'hidden'"),
484            "missing hidden"
485        );
486        assert!(js.contains("visibilityState"), "missing visibilityState");
487        assert!(js.contains("'visible'"), "must set visible");
488    }
489
490    // ── Camera device names ────────────────────────────────────────────────────
491
492    #[test]
493    fn camera_names_windows_default() {
494        let cfg = cfg(1);
495        let js = peripheral_stealth_script_with_profile(&cfg, None);
496        assert!(
497            js.contains("Integrated Webcam"),
498            "missing Windows video device"
499        );
500        assert!(js.contains("Realtek"), "missing Windows audio device");
501    }
502
503    #[test]
504    fn camera_names_macos() {
505        use crate::profile::FingerprintProfile;
506        let cfg = cfg(1);
507        let profile = FingerprintProfile::macos_chrome_136_m1();
508        let js = peripheral_stealth_script_with_profile(&cfg, Some(&profile));
509        assert!(js.contains("FaceTime"), "missing macOS video device");
510        assert!(
511            js.contains("MacBook Pro Microphone"),
512            "missing macOS audio device"
513        );
514    }
515
516    #[test]
517    fn camera_names_linux() {
518        use crate::profile::FingerprintProfile;
519        let cfg = cfg(1);
520        let profile = FingerprintProfile::linux_chrome_136_intel();
521        let js = peripheral_stealth_script_with_profile(&cfg, Some(&profile));
522        assert!(
523            js.contains("USB2.0 PC Camera"),
524            "missing Linux video device"
525        );
526        assert!(js.contains("Built-in Audio"), "missing Linux audio device");
527    }
528
529    // ── Port scanning protection ───────────────────────────────────────────────
530
531    #[test]
532    fn port_scan_blocks_localhost_fetch() {
533        let js = script(1);
534        assert!(js.contains("127.0.0.1"), "missing localhost check");
535        assert!(js.contains("Failed to fetch"), "missing fetch rejection");
536        assert!(js.contains("_probePorts"), "missing probe ports set");
537    }
538
539    // ── History length ─────────────────────────────────────────────────────────
540
541    #[test]
542    fn history_length_in_range_3_to_8() {
543        // Run many seeds and ensure the derived length is always in [3, 8]
544        for seed in 0u64..50 {
545            let cfg = cfg(seed);
546            let engine = NoiseEngine::new(cfg.seed);
547            let len = 3u64 + (engine.u64_noise("history.length") % 6);
548            assert!(
549                (3..=8).contains(&len),
550                "history length {len} out of range for seed {seed}"
551            );
552        }
553    }
554
555    #[test]
556    fn history_length_script_in_output() {
557        let js = script(1);
558        assert!(
559            js.contains("history.length") || js.contains("History.prototype"),
560            "missing history override"
561        );
562    }
563
564    // ── rAF timing ────────────────────────────────────────────────────────────
565
566    #[test]
567    fn raf_timing_script_references_noise() {
568        let js = script(1);
569        assert!(js.contains("requestAnimationFrame"), "missing rAF override");
570        assert!(js.contains("jitter"), "missing jitter variable in rAF");
571    }
572
573    // ── Individual toggles ────────────────────────────────────────────────────
574
575    #[test]
576    fn each_subsystem_can_be_disabled() {
577        let disabled = PeripheralStealthConfig {
578            iframe_inner_width: false,
579            always_visible: false,
580            fake_media_devices: false,
581            block_port_scan: false,
582            history_length: false,
583            raf_timing: false,
584            pdf_viewer: false,
585            seed: NoiseSeed::from(1_u64),
586        };
587        assert!(peripheral_stealth_script(&disabled).is_empty());
588    }
589
590    #[test]
591    fn only_visibility_enabled() {
592        let cfg = PeripheralStealthConfig {
593            iframe_inner_width: false,
594            always_visible: true,
595            fake_media_devices: false,
596            block_port_scan: false,
597            history_length: false,
598            raf_timing: false,
599            pdf_viewer: false,
600            seed: NoiseSeed::from(1_u64),
601        };
602        let js = peripheral_stealth_script(&cfg);
603        assert!(
604            js.contains("visibilityState"),
605            "visibility should be present"
606        );
607        assert!(!js.contains("innerWidth"), "iframe should be absent");
608        assert!(!js.contains("history.length"), "history should be absent");
609    }
610
611    // ── Integration (always ignored) ──────────────────────────────────────────
612
613    #[test]
614    #[ignore = "requires launched browser"]
615    fn live_document_hidden_returns_false() {}
616
617    #[test]
618    #[ignore = "requires launched browser"]
619    fn live_history_length_greater_than_one() {}
620
621    #[test]
622    #[ignore = "requires launched browser with media permissions"]
623    fn live_enumerate_devices_returns_platform_appropriate_names() {}
624}