1use serde::{Deserialize, Serialize};
31
32use crate::noise::{NoiseEngine, NoiseSeed};
33use crate::profile::{FingerprintProfile, Os};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct PeripheralStealthConfig {
58 pub iframe_inner_width: bool,
60 pub always_visible: bool,
62 pub fake_media_devices: bool,
64 pub block_port_scan: bool,
66 pub history_length: bool,
68 pub raf_timing: bool,
70 pub pdf_viewer: bool,
72 pub seed: NoiseSeed,
74}
75
76impl PeripheralStealthConfig {
77 #[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#[must_use]
129pub fn peripheral_stealth_script(config: &PeripheralStealthConfig) -> String {
130 peripheral_stealth_script_with_profile(config, None)
131}
132
133#[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 if config.iframe_inner_width {
161 sections.push(IFRAME_INNER_WIDTH_SECTION.to_string());
162 }
163
164 if config.always_visible {
166 sections.push(VISIBILITY_SECTION.to_string());
167 }
168
169 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 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 if config.block_port_scan {
223 sections.push(PORT_SCAN_SECTION.to_string());
224 }
225
226 if config.history_length {
228 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 if config.raf_timing {
243 sections.push(RAF_TIMING_SECTION.to_string());
244 }
245
246 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
261const 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
283const 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
411fn 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 fn hex_id(&self, key: &str) -> String {
427 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 fn u64_noise(&self, key: &str) -> u64 {
443 self.float_noise(key, 0).to_bits()
444 }
445}
446
447#[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 #[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 #[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 #[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 #[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 #[test]
541 fn history_length_in_range_3_to_8() {
542 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 #[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 #[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 #[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}