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