Skip to main content

stygian_browser/
noise.rs

1//! Deterministic noise seed engine for fingerprint perturbation.
2//!
3//! Provides [`NoiseSeed`], [`NoiseEngine`], and [`NoiseConfig`] for injecting
4//! repeatable, per-session noise into canvas, WebGL, audio, and layout APIs.
5//! Given the same [`NoiseSeed`], all noise generators produce identical output
6//! across platforms and across Web Workers / Service Workers.
7//!
8//! # Example
9//!
10//! ```
11//! use stygian_browser::noise::{NoiseEngine, NoiseSeed};
12//!
13//! let engine = NoiseEngine::new(NoiseSeed::from(42_u64));
14//! let (dr, dg, db, da) = engine.pixel_noise("canvas.toDataURL", 10, 20);
15//! assert!((-3..=3).contains(&dr));
16//! ```
17
18use std::fmt;
19
20use serde::{Deserialize, Serialize};
21
22// ---------------------------------------------------------------------------
23// NoiseSeed
24// ---------------------------------------------------------------------------
25
26/// A 64-bit seed that drives all deterministic noise generators.
27///
28/// Construct via [`NoiseSeed::random()`] for per-session uniqueness or
29/// `NoiseSeed::from(<u64>)` for reproducible testing.
30///
31/// # Example
32///
33/// ```
34/// use stygian_browser::noise::NoiseSeed;
35///
36/// let seed = NoiseSeed::from(12345_u64);
37/// let seed2 = NoiseSeed::random();
38/// assert_ne!(seed, seed2); // extremely unlikely to collide
39/// ```
40#[allow(clippy::unsafe_derive_deserialize)]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub struct NoiseSeed(u64);
43
44impl NoiseSeed {
45    /// Generate a process-local pseudo-random [`NoiseSeed`].
46    ///
47    /// # Example
48    ///
49    /// ```
50    /// use stygian_browser::noise::NoiseSeed;
51    /// let a = NoiseSeed::random();
52    /// let b = NoiseSeed::random();
53    /// // successive calls produce different values with overwhelming probability
54    /// assert_ne!(a, b);
55    /// ```
56    #[must_use]
57    pub fn random() -> Self {
58        // Use std::time + thread-local counter for a cheap but sufficiently unique seed.
59        // We avoid pulling in `rand` as a dep here — this is not a CSPRNG use case;
60        // uniqueness across sessions is sufficient.
61        use std::collections::hash_map::DefaultHasher;
62        use std::hash::{Hash, Hasher};
63        use std::time::{Duration, SystemTime};
64
65        std::thread_local! {
66            static COUNTER: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
67        }
68
69        let count = COUNTER.with(|c| {
70            let v = c.get().wrapping_add(1);
71            c.set(v);
72            v
73        });
74
75        let nanos = SystemTime::now()
76            .duration_since(SystemTime::UNIX_EPOCH)
77            .unwrap_or(Duration::ZERO)
78            .subsec_nanos();
79
80        let mut hasher = DefaultHasher::new();
81        // Mix time + counter + stack address for uniqueness across threads/sessions
82        nanos.hash(&mut hasher);
83        count.hash(&mut hasher);
84        Self(hasher.finish())
85    }
86
87    /// Return the raw u64 seed value.
88    ///
89    /// # Example
90    ///
91    /// ```
92    /// use stygian_browser::noise::NoiseSeed;
93    /// let seed = NoiseSeed::from(99_u64);
94    /// assert_eq!(seed.as_u64(), 99);
95    /// ```
96    #[must_use]
97    pub const fn as_u64(self) -> u64 {
98        self.0
99    }
100}
101
102impl From<u64> for NoiseSeed {
103    fn from(v: u64) -> Self {
104        Self(v)
105    }
106}
107
108impl fmt::Display for NoiseSeed {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "{}", self.0)
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Hash mixer — cross-platform deterministic, no stdlib HashMap randomness
116// ---------------------------------------------------------------------------
117
118/// Deterministic keyed mixer: seed + operation string + two u32 coordinates → u64.
119///
120/// Uses a Fibonacci multiplicative hash with byte-by-byte string mixing so that
121/// output is identical on every platform without depending on `DefaultHasher`.
122#[inline]
123fn mix(seed: u64, operation: &str, a: u32, b: u32) -> u64 {
124    const M: u64 = 0x9e37_79b9_7f4a_7c15;
125    let mut h = seed;
126    for byte in operation.bytes() {
127        h = h.wrapping_mul(M).wrapping_add(u64::from(byte));
128        h ^= h >> 33;
129    }
130    h = h.wrapping_mul(M).wrapping_add(u64::from(a));
131    h ^= h >> 33;
132    h = h.wrapping_mul(M).wrapping_add(u64::from(b));
133    h ^= h >> 33;
134    h
135}
136
137/// Extract four independent `i8` values from a `u64`, each bounded to `[-3, 3]`.
138#[inline]
139const fn bounded_bytes(h: u64) -> (i8, i8, i8, i8) {
140    // Split hash into four 16-bit lanes, map each mod 7 → [0,6] → shift by 3 → [-3,3]
141    let red = ((h & 0xFFFF) % 7) as i8 - 3;
142    let green = (((h >> 16) & 0xFFFF) % 7) as i8 - 3;
143    let blue = (((h >> 32) & 0xFFFF) % 7) as i8 - 3;
144    let alpha = (((h >> 48) & 0xFFFF) % 7) as i8 - 3;
145    (red, green, blue, alpha)
146}
147
148// ---------------------------------------------------------------------------
149// NoiseEngine
150// ---------------------------------------------------------------------------
151
152/// Deterministic noise generator seeded with a [`NoiseSeed`].
153///
154/// All methods are pure functions — same seed + same arguments always produce
155/// the same output on every platform.
156///
157/// # Example
158///
159/// ```
160/// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
161///
162/// let engine = NoiseEngine::new(NoiseSeed::from(1_u64));
163/// let n1 = engine.pixel_noise("canvas", 0, 0);
164/// let n2 = engine.pixel_noise("canvas", 0, 0);
165/// assert_eq!(n1, n2); // deterministic
166/// ```
167#[derive(Debug, Clone)]
168pub struct NoiseEngine {
169    seed: NoiseSeed,
170}
171
172impl NoiseEngine {
173    /// Create a new [`NoiseEngine`] with the given seed.
174    ///
175    /// # Example
176    ///
177    /// ```
178    /// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
179    /// let engine = NoiseEngine::new(NoiseSeed::from(42_u64));
180    /// ```
181    #[must_use]
182    pub const fn new(seed: NoiseSeed) -> Self {
183        Self { seed }
184    }
185
186    /// Return the seed this engine was created with.
187    ///
188    /// # Example
189    ///
190    /// ```
191    /// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
192    /// let s = NoiseSeed::from(7_u64);
193    /// assert_eq!(NoiseEngine::new(s).seed(), s);
194    /// ```
195    #[must_use]
196    pub const fn seed(&self) -> NoiseSeed {
197        self.seed
198    }
199
200    /// RGBA pixel delta for canvas operations, each component in `[-3, 3]`.
201    ///
202    /// # Example
203    ///
204    /// ```
205    /// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
206    /// let e = NoiseEngine::new(NoiseSeed::from(1_u64));
207    /// let (r, g, b, a) = e.pixel_noise("toDataURL", 5, 10);
208    /// assert!((-3..=3).contains(&r));
209    /// ```
210    #[must_use]
211    pub fn pixel_noise(&self, operation: &str, x: u32, y: u32) -> (i8, i8, i8, i8) {
212        bounded_bytes(mix(self.seed.0, operation, x, y))
213    }
214
215    /// Small floating-point perturbation for audio / timing values.
216    ///
217    /// Returns a value in `[-0.000_01, 0.000_01]`, imperceptible to human
218    /// listening but sufficient to alter the floating-point fingerprint.
219    ///
220    /// # Example
221    ///
222    /// ```
223    /// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
224    /// let e = NoiseEngine::new(NoiseSeed::from(1_u64));
225    /// let delta = e.float_noise("AudioBuffer", 0);
226    /// assert!(delta.abs() <= 0.000_01);
227    /// ```
228    #[must_use]
229    pub fn float_noise(&self, operation: &str, index: u32) -> f64 {
230        let h = mix(self.seed.0, operation, index, 0);
231        // Keep only 53 bits so conversion to f64 is exact.
232        let upper53 = h >> 11;
233        let high = ((upper53 >> 21) & 0xFFFF_FFFF) as u32;
234        let low = (upper53 & ((1_u64 << 21) - 1)) as u32;
235        let normalized = (f64::from(high) * 2_097_152.0 + f64::from(low)) / 9_007_199_254_740_991.0;
236        (normalized - 0.5) * 2.0e-5 // [-1e-5, 1e-5]
237    }
238
239    /// x/y/width/height delta for `ClientRect` / `TextMetrics` noise.
240    ///
241    /// Each component is a sub-pixel fractional delta in `[-0.5, 0.5]`.
242    ///
243    /// # Example
244    ///
245    /// ```
246    /// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
247    /// let e = NoiseEngine::new(NoiseSeed::from(1_u64));
248    /// let (dx, dy, dw, dh) = e.rect_noise("getBoundingClientRect", 0);
249    /// assert!(dx.abs() <= 0.5);
250    /// ```
251    #[must_use]
252    pub fn rect_noise(&self, operation: &str, index: u32) -> (f64, f64, f64, f64) {
253        let hash = mix(self.seed.0, operation, index, 0xDEAD_BEEF);
254        let (red, green, blue, alpha) = bounded_bytes(hash);
255        // Map [-3, 3] → [-0.5, 0.5] (divide by 6)
256        let scale = 1.0_f64 / 6.0;
257        (
258            f64::from(red) * scale,
259            f64::from(green) * scale,
260            f64::from(blue) * scale,
261            f64::from(alpha) * scale,
262        )
263    }
264
265    /// RGBA pixel delta for WebGL `readPixels`, each component in `[-3, 3]`.
266    ///
267    /// # Example
268    ///
269    /// ```
270    /// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
271    /// let e = NoiseEngine::new(NoiseSeed::from(1_u64));
272    /// let (r, g, b, a) = e.webgl_noise("readPixels", 0, 0);
273    /// assert!((-3..=3).contains(&r));
274    /// ```
275    #[must_use]
276    pub fn webgl_noise(&self, operation: &str, x: u32, y: u32) -> (i8, i8, i8, i8) {
277        bounded_bytes(mix(self.seed.0, operation, x, y ^ 0xCAFE_BABE))
278    }
279
280    /// Generate the JavaScript source for `__stygian_noise(operation, x, y)`.
281    ///
282    /// The returned string embeds the seed value and replicates the hash-based
283    /// noise logic in pure JS with no DOM dependencies — safe to inject into
284    /// Worker / Service Worker contexts.
285    ///
286    /// Returns `(i8, i8, i8, i8)`-equivalent as a JS array `[r, g, b, a]`.
287    ///
288    /// # Example
289    ///
290    /// ```
291    /// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
292    /// let e = NoiseEngine::new(NoiseSeed::from(42_u64));
293    /// let js = e.js_noise_fn();
294    /// assert!(js.contains("42"));
295    /// assert!(js.contains("__stygian_noise"));
296    /// ```
297    #[must_use]
298    pub fn js_noise_fn(&self) -> String {
299        let seed = self.seed.0;
300        // The JS reimplements the Rust mix() function using BigInt arithmetic
301        // so that 64-bit multiply/XOR is exact. Returns [r, g, b, a] each in [-3, 3].
302        format!(
303            r"(function() {{
304  const _SEED = {seed}n;
305  const _M = 0x9e3779b97f4a7c15n;
306  const _MASK = 0xFFFFFFFFFFFFFFFFn;
307
308  function _mix(seed, op, a, b) {{
309    let h = BigInt(seed);
310    for (let i = 0; i < op.length; i++) {{
311      h = ((h * _M) + BigInt(op.charCodeAt(i))) & _MASK;
312      h = (h ^ (h >> 33n)) & _MASK;
313    }}
314    h = ((h * _M) + BigInt(a)) & _MASK;
315    h = (h ^ (h >> 33n)) & _MASK;
316    h = ((h * _M) + BigInt(b)) & _MASK;
317    h = (h ^ (h >> 33n)) & _MASK;
318    return h;
319  }}
320
321  function _bb(h) {{
322    const r = Number((h & 0xFFFFn) % 7n) - 3;
323    const g = Number(((h >> 16n) & 0xFFFFn) % 7n) - 3;
324    const b = Number(((h >> 32n) & 0xFFFFn) % 7n) - 3;
325    const a = Number(((h >> 48n) & 0xFFFFn) % 7n) - 3;
326    return [r, g, b, a];
327  }}
328
329  globalThis.__stygian_noise = function(operation, x, y) {{
330    return _bb(_mix(_SEED, operation, x >>> 0, y >>> 0));
331  }};
332
333  globalThis.__stygian_float_noise = function(operation, index) {{
334    const h = _mix(_SEED, operation, index >>> 0, 0);
335        const upper53 = h >> 11n;
336        const normalized = Number(upper53) / 9007199254740991;
337        return (normalized - 0.5) * 2e-5;
338  }};
339
340  globalThis.__stygian_rect_noise = function(operation, index) {{
341    const h = _mix(_SEED, operation, index >>> 0, 0xDEADBEEF);
342    const [r, g, b, a] = _bb(h);
343    return [r / 6, g / 6, b / 6, a / 6];
344  }};
345
346  globalThis.__stygian_webgl_noise = function(operation, x, y) {{
347    return _bb(_mix(_SEED, operation, x >>> 0, (y >>> 0) ^ 0xCAFEBABE));
348  }};
349}})();"
350        )
351    }
352}
353
354// ---------------------------------------------------------------------------
355// NoiseConfig
356// ---------------------------------------------------------------------------
357
358/// Configuration for the fingerprint noise subsystem.
359///
360/// Added to [`crate::config::BrowserConfig`] when the `stealth` feature is enabled.
361///
362/// # Example
363///
364/// ```
365/// use stygian_browser::noise::{NoiseConfig, NoiseSeed};
366///
367/// let cfg = NoiseConfig::default();
368/// assert!(cfg.canvas_enabled);
369///
370/// let custom = NoiseConfig {
371///     seed: Some(NoiseSeed::from(123_u64)),
372///     ..NoiseConfig::default()
373/// };
374/// assert_eq!(custom.seed.unwrap().as_u64(), 123);
375/// ```
376#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(default)]
378pub struct NoiseConfig {
379    /// Fixed seed for reproducibility. If `None`, a random seed is generated
380    /// at [`NoiseEngine`] construction time.
381    pub seed: Option<NoiseSeed>,
382
383    /// Enable canvas API noise (`toDataURL`, `toBlob`, `getImageData`).
384    pub canvas_enabled: bool,
385
386    /// Enable WebGL API noise (`readPixels`, `getParameter`).
387    pub webgl_enabled: bool,
388
389    /// Enable audio API noise (`getChannelData`, analyser nodes).
390    pub audio_enabled: bool,
391
392    /// Enable layout API noise (`getBoundingClientRect`, `TextMetrics`).
393    pub rects_enabled: bool,
394}
395
396impl Default for NoiseConfig {
397    fn default() -> Self {
398        Self {
399            seed: None,
400            canvas_enabled: true,
401            webgl_enabled: true,
402            audio_enabled: true,
403            rects_enabled: true,
404        }
405    }
406}
407
408impl NoiseConfig {
409    /// Build a [`NoiseEngine`] from this config.
410    ///
411    /// If `seed` is `None`, a random seed is generated at call time.
412    ///
413    /// # Example
414    ///
415    /// ```
416    /// use stygian_browser::noise::{NoiseConfig, NoiseSeed};
417    ///
418    /// let cfg = NoiseConfig { seed: Some(NoiseSeed::from(1_u64)), ..Default::default() };
419    /// let engine = cfg.build_engine();
420    /// assert_eq!(engine.seed().as_u64(), 1);
421    /// ```
422    #[must_use]
423    pub fn build_engine(&self) -> NoiseEngine {
424        let seed = self.seed.unwrap_or_else(NoiseSeed::random);
425        NoiseEngine::new(seed)
426    }
427}
428
429// ---------------------------------------------------------------------------
430// Tests
431// ---------------------------------------------------------------------------
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn same_seed_same_args_deterministic() {
439        let e = NoiseEngine::new(NoiseSeed::from(42_u64));
440        assert_eq!(
441            e.pixel_noise("canvas.toDataURL", 10, 20),
442            e.pixel_noise("canvas.toDataURL", 10, 20)
443        );
444    }
445
446    #[test]
447    fn different_seeds_different_outputs() {
448        let e1 = NoiseEngine::new(NoiseSeed::from(1_u64));
449        let e2 = NoiseEngine::new(NoiseSeed::from(2_u64));
450        // With overwhelming probability these differ — if they don't, the hash is broken
451        assert_ne!(
452            e1.pixel_noise("canvas.toDataURL", 10, 20),
453            e2.pixel_noise("canvas.toDataURL", 10, 20)
454        );
455    }
456
457    #[test]
458    fn pixel_noise_bounded() {
459        let e = NoiseEngine::new(NoiseSeed::from(0xDEAD_BEEF_u64));
460        for px in 0u32..16 {
461            for py in 0u32..16 {
462                let (red, green, blue, alpha) = e.pixel_noise("canvas", px, py);
463                assert!((-3..=3).contains(&red), "r={red} out of range");
464                assert!((-3..=3).contains(&green), "g={green} out of range");
465                assert!((-3..=3).contains(&blue), "b={blue} out of range");
466                assert!((-3..=3).contains(&alpha), "a={alpha} out of range");
467            }
468        }
469    }
470
471    #[test]
472    fn webgl_noise_bounded() {
473        let e = NoiseEngine::new(NoiseSeed::from(0xCAFE_u64));
474        for px in 0u32..8 {
475            for py in 0u32..8 {
476                let (red, green, blue, alpha) = e.webgl_noise("readPixels", px, py);
477                assert!((-3..=3).contains(&red));
478                assert!((-3..=3).contains(&green));
479                assert!((-3..=3).contains(&blue));
480                assert!((-3..=3).contains(&alpha));
481            }
482        }
483    }
484
485    #[test]
486    fn float_noise_bounded() {
487        let e = NoiseEngine::new(NoiseSeed::from(7_u64));
488        for i in 0u32..32 {
489            let v = e.float_noise("AudioBuffer", i);
490            assert!(
491                v.abs() <= 1e-5 + f64::EPSILON,
492                "float_noise {v} out of range"
493            );
494        }
495    }
496
497    #[test]
498    fn rect_noise_bounded() {
499        let e = NoiseEngine::new(NoiseSeed::from(99_u64));
500        for i in 0u32..16 {
501            let (dx, dy, dw, dh) = e.rect_noise("getBoundingClientRect", i);
502            assert!(dx.abs() <= 0.5 + f64::EPSILON);
503            assert!(dy.abs() <= 0.5 + f64::EPSILON);
504            assert!(dw.abs() <= 0.5 + f64::EPSILON);
505            assert!(dh.abs() <= 0.5 + f64::EPSILON);
506        }
507    }
508
509    #[test]
510    fn noise_config_serde_round_trip() {
511        let cfg = NoiseConfig {
512            seed: Some(NoiseSeed::from(555_u64)),
513            canvas_enabled: true,
514            webgl_enabled: false,
515            audio_enabled: true,
516            rects_enabled: false,
517        };
518        let json_result = serde_json::to_string(&cfg);
519        assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
520        let Ok(json) = json_result else {
521            return;
522        };
523        let back_result: Result<NoiseConfig, _> = serde_json::from_str(&json);
524        assert!(back_result.is_ok(), "deserialize failed: {back_result:?}");
525        let Ok(back) = back_result else {
526            return;
527        };
528        assert_eq!(back.seed, cfg.seed);
529        assert_eq!(back.canvas_enabled, cfg.canvas_enabled);
530        assert_eq!(back.webgl_enabled, cfg.webgl_enabled);
531        assert_eq!(back.audio_enabled, cfg.audio_enabled);
532        assert_eq!(back.rects_enabled, cfg.rects_enabled);
533    }
534
535    #[test]
536    fn js_noise_fn_contains_seed() {
537        let seed = 98_765_u64;
538        let e = NoiseEngine::new(NoiseSeed::from(seed));
539        let js = e.js_noise_fn();
540        assert!(js.contains(&seed.to_string()), "seed not embedded in JS");
541        assert!(js.contains("__stygian_noise"), "missing __stygian_noise");
542        assert!(
543            js.contains("__stygian_float_noise"),
544            "missing __stygian_float_noise"
545        );
546    }
547
548    #[test]
549    fn noise_seed_random_unique() {
550        // Generate 100 seeds — collision probability is negligible (birthday bound ~2^{-54} for 100 draws)
551        let seeds: Vec<NoiseSeed> = (0..100).map(|_| NoiseSeed::random()).collect();
552        let unique: std::collections::HashSet<u64> = seeds.iter().map(|s| s.as_u64()).collect();
553        assert_eq!(unique.len(), 100, "random seeds collided");
554    }
555
556    #[test]
557    fn noise_config_build_engine_uses_seed() {
558        let cfg = NoiseConfig {
559            seed: Some(NoiseSeed::from(77_u64)),
560            ..Default::default()
561        };
562        let engine = cfg.build_engine();
563        assert_eq!(engine.seed().as_u64(), 77);
564    }
565
566    #[test]
567    fn noise_config_build_engine_random_when_none() {
568        let cfg = NoiseConfig::default();
569        let e1 = cfg.build_engine();
570        let e2 = cfg.build_engine();
571        // Random seeds differ with overwhelming probability
572        assert_ne!(e1.seed().as_u64(), e2.seed().as_u64());
573    }
574}