Skip to main content

stygian_browser/
timing_noise.rs

1//! Performance timing noise injection.
2//!
3//! Injects deterministic jitter into `performance.now()`, `performance.timeOrigin`,
4//! `Date.now()`, and `performance.getEntries*()` to break hardware-speed and
5//! headless-detection timing fingerprints.
6//!
7//! Monotonicity of `performance.now()` is preserved — the wrapped function never
8//! returns a value lower than its previous call.
9//!
10//! # Example
11//!
12//! ```
13//! use stygian_browser::timing_noise::{timing_noise_script, TimingNoiseConfig};
14//! use stygian_browser::noise::NoiseSeed;
15//!
16//! let cfg = TimingNoiseConfig { enabled: true, jitter_ms: 0.3, seed: NoiseSeed::from(1_u64) };
17//! let js = timing_noise_script(&cfg);
18//! assert!(js.contains("performance.now"));
19//! assert!(js.contains("__stygian_time_offset"));
20//! ```
21
22use serde::{Deserialize, Serialize};
23
24use crate::noise::{NoiseEngine, NoiseSeed};
25
26// ---------------------------------------------------------------------------
27// TimingNoiseConfig
28// ---------------------------------------------------------------------------
29
30/// Configuration for performance timing noise.
31///
32/// # Example
33///
34/// ```
35/// use stygian_browser::timing_noise::TimingNoiseConfig;
36/// use stygian_browser::noise::NoiseSeed;
37///
38/// let cfg = TimingNoiseConfig::default();
39/// assert!(cfg.enabled);
40/// assert!(cfg.jitter_ms > 0.0 && cfg.jitter_ms <= 1.0);
41/// ```
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct TimingNoiseConfig {
44    /// Whether timing noise is injected at all.
45    pub enabled: bool,
46    /// Maximum timing jitter in milliseconds (recommended: 0.1–0.5).
47    pub jitter_ms: f64,
48    /// Noise seed used to derive timing noise values.
49    pub seed: NoiseSeed,
50}
51
52impl Default for TimingNoiseConfig {
53    /// Enabled with 0.3 ms max jitter and a random seed.
54    fn default() -> Self {
55        Self {
56            enabled: true,
57            jitter_ms: 0.3,
58            seed: NoiseSeed::random(),
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Script generator
65// ---------------------------------------------------------------------------
66
67/// Generate the timing noise injection script for `config`.
68///
69/// Returns an empty string when `config.enabled` is false.
70///
71/// # Example
72///
73/// ```
74/// use stygian_browser::timing_noise::{timing_noise_script, TimingNoiseConfig};
75/// use stygian_browser::noise::NoiseSeed;
76///
77/// let cfg = TimingNoiseConfig { enabled: false, jitter_ms: 0.3, seed: NoiseSeed::from(1_u64) };
78/// assert!(timing_noise_script(&cfg).is_empty());
79///
80/// let cfg2 = TimingNoiseConfig { enabled: true, jitter_ms: 0.3, seed: NoiseSeed::from(1_u64) };
81/// let js = timing_noise_script(&cfg2);
82/// assert!(js.contains("performance.now"));
83/// ```
84#[must_use]
85pub fn timing_noise_script(config: &TimingNoiseConfig) -> String {
86    if !config.enabled {
87        return String::new();
88    }
89
90    let engine = NoiseEngine::new(config.seed);
91    let noise_fn = engine.js_noise_fn();
92    let jitter_ms = config.jitter_ms;
93
94    // A fixed time-origin shift derived from the seed: between ±10 ms.
95    // This prevents cross-tab correlation via performance.timeOrigin.
96    let origin_shift = {
97        let h = engine.float_noise("timing.origin", 0);
98        // float_noise is in [-1e-5, 1e-5]; scale to [-10, 10] ms
99        h * 1_000_000_000.0 // * 1e9 → [-10, 10] ms ballpark (capped below)
100    };
101    // Clamp to [-10, 10]
102    let origin_shift_ms = origin_shift.clamp(-10.0, 10.0);
103
104    format!(
105        r"(function() {{
106  'use strict';
107
108  // ── Noise helpers ──────────────────────────────────────────────────────
109  {noise_fn}
110
111  const _JITTER_MS = {jitter_ms};
112  // Fixed origin shift for this session (±{origin_shift_ms:.4} ms)
113  const _ORIGIN_SHIFT = {origin_shift_ms:.6};
114
115  // ── performance.now() — monotonic jitter accumulator ──────────────────
116  let __stygian_time_offset = 0.0;
117  let __stygian_pnow_counter = 0;
118  let __stygian_pnow_last = 0.0;
119
120  const _origPerfNow = performance.now.bind(performance);
121
122  Object.defineProperty(performance, 'now', {{
123    value: function now() {{
124      const base = _origPerfNow();
125      const noiseFraction = __stygian_float_noise('timing.now', __stygian_pnow_counter++);
126      // noiseFraction is in [-1e-5, 1e-5]; scale to [-jitter_ms/2, jitter_ms/2]
127      const delta = noiseFraction * (_JITTER_MS * 50000.0);
128      // Accumulate only positive deltas to keep monotonicity
129      const positive = Math.max(0.0, delta);
130      __stygian_time_offset += positive;
131      const result = Math.max(__stygian_pnow_last, base + __stygian_time_offset);
132      __stygian_pnow_last = result;
133      return result;
134    }},
135    writable: false,
136    configurable: false,
137    enumerable: true,
138  }});
139
140  // ── performance.timeOrigin — fixed per-session shift ──────────────────
141  const _origTimeOrigin = performance.timeOrigin;
142  Object.defineProperty(performance, 'timeOrigin', {{
143    get: function() {{ return _origTimeOrigin + _ORIGIN_SHIFT; }},
144    configurable: false,
145    enumerable: true,
146  }});
147
148  // ── Date.now() — apply same origin shift ─────────────────────────────
149  const _origDateNow = Date.now.bind(Date);
150  (function() {{
151    const shifted = function now() {{
152      return _origDateNow() + _ORIGIN_SHIFT;
153    }};
154    shifted.toString = function toString() {{ return 'function now() {{ [native code] }}'; }};
155    try {{
156      Date.now = shifted;
157    }} catch(e) {{
158      Object.defineProperty(Date, 'now', {{
159        value: shifted, writable: false, configurable: false, enumerable: false
160      }});
161    }}
162  }})();
163
164  // ── performance.getEntries* — noise on timing fields ─────────────────
165  function _noiseEntry(entry, idx) {{
166    const delta = __stygian_float_noise('timing.entry', idx) * (_JITTER_MS * 50000.0);
167    // Preserve ordering: only add positive deltas
168    const d = Math.abs(delta);
169    // Build a plain-object copy with shifted timings; preserve startTime ordering
170    return {{
171      name: entry.name,
172      entryType: entry.entryType,
173      startTime: entry.startTime + d,
174      duration: entry.duration,
175      // Resource / Navigation fields (may be undefined on other entry types)
176      // We only copy defined fields to avoid breaking typed PerformanceEntry comparisons
177      toJSON: function() {{
178        const j = entry.toJSON ? entry.toJSON() : {{}};
179        j.startTime = entry.startTime + d;
180        return j;
181      }},
182    }};
183  }}
184
185  const _origGetEntries = performance.getEntries.bind(performance);
186  Object.defineProperty(performance, 'getEntries', {{
187    value: function getEntries() {{
188      return _origGetEntries().map(function(e, i) {{ return _noiseEntry(e, i); }});
189    }},
190    writable: false, configurable: false, enumerable: true,
191  }});
192
193  const _origGetEntriesByType = performance.getEntriesByType.bind(performance);
194  Object.defineProperty(performance, 'getEntriesByType', {{
195    value: function getEntriesByType(type) {{
196      return _origGetEntriesByType(type).map(function(e, i) {{ return _noiseEntry(e, i); }});
197    }},
198    writable: false, configurable: false, enumerable: true,
199  }});
200
201  const _origGetEntriesByName = performance.getEntriesByName.bind(performance);
202  Object.defineProperty(performance, 'getEntriesByName', {{
203    value: function getEntriesByName(name, type) {{
204      const args = type !== undefined ? [name, type] : [name];
205      return _origGetEntriesByName.apply(performance, args)
206        .map(function(e, i) {{ return _noiseEntry(e, i); }});
207    }},
208    writable: false, configurable: false, enumerable: true,
209  }});
210
211}})();
212",
213    )
214}
215
216// ---------------------------------------------------------------------------
217// Tests
218// ---------------------------------------------------------------------------
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::noise::NoiseSeed;
224
225    fn cfg(enabled: bool, jitter: f64, seed: u64) -> TimingNoiseConfig {
226        TimingNoiseConfig {
227            enabled,
228            jitter_ms: jitter,
229            seed: NoiseSeed::from(seed),
230        }
231    }
232
233    #[test]
234    fn disabled_returns_empty() {
235        assert!(timing_noise_script(&cfg(false, 0.3, 1)).is_empty());
236    }
237
238    #[test]
239    fn script_overrides_perf_now() {
240        let js = timing_noise_script(&cfg(true, 0.3, 1));
241        assert!(
242            js.contains("performance.now"),
243            "missing performance.now override"
244        );
245    }
246
247    #[test]
248    fn script_overrides_time_origin() {
249        let js = timing_noise_script(&cfg(true, 0.3, 1));
250        assert!(js.contains("timeOrigin"), "missing timeOrigin override");
251    }
252
253    #[test]
254    fn script_overrides_date_now() {
255        let js = timing_noise_script(&cfg(true, 0.3, 1));
256        assert!(js.contains("Date.now"), "missing Date.now override");
257    }
258
259    #[test]
260    fn script_overrides_get_entries() {
261        let js = timing_noise_script(&cfg(true, 0.3, 1));
262        assert!(js.contains("getEntries"), "missing getEntries override");
263        assert!(js.contains("getEntriesByType"), "missing getEntriesByType");
264        assert!(js.contains("getEntriesByName"), "missing getEntriesByName");
265    }
266
267    #[test]
268    fn script_has_monotonicity_accumulator() {
269        let js = timing_noise_script(&cfg(true, 0.3, 1));
270        assert!(
271            js.contains("__stygian_time_offset"),
272            "missing monotonicity accumulator"
273        );
274    }
275
276    #[test]
277    fn default_jitter_in_reasonable_range() {
278        let c = TimingNoiseConfig::default();
279        assert!(
280            c.jitter_ms >= 0.01 && c.jitter_ms <= 1.0,
281            "jitter_ms out of range"
282        );
283    }
284
285    #[test]
286    fn serde_round_trip() {
287        let c = TimingNoiseConfig {
288            enabled: true,
289            jitter_ms: 0.25,
290            seed: NoiseSeed::from(98765_u64),
291        };
292        let json_result = serde_json::to_string(&c);
293        assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
294        let Ok(json) = json_result else {
295            return;
296        };
297        let cfg_result: Result<TimingNoiseConfig, _> = serde_json::from_str(&json);
298        assert!(cfg_result.is_ok(), "deserialize failed: {cfg_result:?}");
299        let Ok(c2) = cfg_result else {
300            return;
301        };
302        assert_eq!(c2.enabled, c.enabled);
303        assert!((c2.jitter_ms - c.jitter_ms).abs() < f64::EPSILON);
304        assert_eq!(c2.seed.as_u64(), c.seed.as_u64());
305    }
306
307    #[test]
308    fn different_seeds_produce_different_scripts() {
309        let js1 = timing_noise_script(&cfg(true, 0.3, 1));
310        let js2 = timing_noise_script(&cfg(true, 0.3, 2));
311        assert_ne!(js1, js2);
312    }
313}