1use serde::{Deserialize, Serialize};
31
32use crate::noise::{NoiseEngine, NoiseSeed};
33use crate::profile::{FingerprintProfile, Os};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
57#[allow(clippy::struct_excessive_bools)] pub struct PeripheralStealthConfig {
59 pub iframe_inner_width: bool,
61 pub always_visible: bool,
63 pub fake_media_devices: bool,
65 pub block_port_scan: bool,
67 pub history_length: bool,
69 pub raf_timing: bool,
71 pub pdf_viewer: bool,
73 pub seed: NoiseSeed,
75}
76
77impl PeripheralStealthConfig {
78 #[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#[must_use]
130pub fn peripheral_stealth_script(config: &PeripheralStealthConfig) -> String {
131 peripheral_stealth_script_with_profile(config, None)
132}
133
134#[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 if config.iframe_inner_width {
162 sections.push(IFRAME_INNER_WIDTH_SECTION.to_string());
163 }
164
165 if config.always_visible {
167 sections.push(VISIBILITY_SECTION.to_string());
168 }
169
170 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 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 if config.block_port_scan {
224 sections.push(PORT_SCAN_SECTION.to_string());
225 }
226
227 if config.history_length {
229 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 if config.raf_timing {
244 sections.push(RAF_TIMING_SECTION.to_string());
245 }
246
247 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
262const 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
284const 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
412fn 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 fn hex_id(&self, key: &str) -> String {
428 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 fn u64_noise(&self, key: &str) -> u64 {
444 self.float_noise(key, 0).to_bits()
445 }
446}
447
448#[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 #[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 #[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 #[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 #[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 #[test]
542 fn history_length_in_range_3_to_8() {
543 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 #[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 #[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 #[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}