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)]
378#[allow(clippy::struct_excessive_bools)] // public, stable config type — 4 orthogonal noise surface toggles read more clearly as bools
379pub struct NoiseConfig {
380    /// Fixed seed for reproducibility. If `None`, a random seed is generated
381    /// at [`NoiseEngine`] construction time.
382    pub seed: Option<NoiseSeed>,
383
384    /// Enable canvas API noise (`toDataURL`, `toBlob`, `getImageData`).
385    pub canvas_enabled: bool,
386
387    /// Enable WebGL API noise (`readPixels`, `getParameter`).
388    pub webgl_enabled: bool,
389
390    /// Enable audio API noise (`getChannelData`, analyser nodes).
391    pub audio_enabled: bool,
392
393    /// Enable layout API noise (`getBoundingClientRect`, `TextMetrics`).
394    pub rects_enabled: bool,
395}
396
397impl Default for NoiseConfig {
398    fn default() -> Self {
399        Self {
400            seed: None,
401            canvas_enabled: true,
402            webgl_enabled: true,
403            audio_enabled: true,
404            rects_enabled: true,
405        }
406    }
407}
408
409impl NoiseConfig {
410    /// Build a [`NoiseEngine`] from this config.
411    ///
412    /// If `seed` is `None`, a random seed is generated at call time.
413    ///
414    /// # Example
415    ///
416    /// ```
417    /// use stygian_browser::noise::{NoiseConfig, NoiseSeed};
418    ///
419    /// let cfg = NoiseConfig { seed: Some(NoiseSeed::from(1_u64)), ..Default::default() };
420    /// let engine = cfg.build_engine();
421    /// assert_eq!(engine.seed().as_u64(), 1);
422    /// ```
423    #[must_use]
424    pub fn build_engine(&self) -> NoiseEngine {
425        let seed = self.seed.unwrap_or_else(NoiseSeed::random);
426        NoiseEngine::new(seed)
427    }
428}
429
430// ---------------------------------------------------------------------------
431// Tests
432// ---------------------------------------------------------------------------
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn same_seed_same_args_deterministic() {
440        let e = NoiseEngine::new(NoiseSeed::from(42_u64));
441        assert_eq!(
442            e.pixel_noise("canvas.toDataURL", 10, 20),
443            e.pixel_noise("canvas.toDataURL", 10, 20)
444        );
445    }
446
447    #[test]
448    fn different_seeds_different_outputs() {
449        let e1 = NoiseEngine::new(NoiseSeed::from(1_u64));
450        let e2 = NoiseEngine::new(NoiseSeed::from(2_u64));
451        // With overwhelming probability these differ — if they don't, the hash is broken
452        assert_ne!(
453            e1.pixel_noise("canvas.toDataURL", 10, 20),
454            e2.pixel_noise("canvas.toDataURL", 10, 20)
455        );
456    }
457
458    #[test]
459    fn pixel_noise_bounded() {
460        let e = NoiseEngine::new(NoiseSeed::from(0xDEAD_BEEF_u64));
461        for px in 0u32..16 {
462            for py in 0u32..16 {
463                let (red, green, blue, alpha) = e.pixel_noise("canvas", px, py);
464                assert!((-3..=3).contains(&red), "r={red} out of range");
465                assert!((-3..=3).contains(&green), "g={green} out of range");
466                assert!((-3..=3).contains(&blue), "b={blue} out of range");
467                assert!((-3..=3).contains(&alpha), "a={alpha} out of range");
468            }
469        }
470    }
471
472    #[test]
473    fn webgl_noise_bounded() {
474        let e = NoiseEngine::new(NoiseSeed::from(0xCAFE_u64));
475        for px in 0u32..8 {
476            for py in 0u32..8 {
477                let (red, green, blue, alpha) = e.webgl_noise("readPixels", px, py);
478                assert!((-3..=3).contains(&red));
479                assert!((-3..=3).contains(&green));
480                assert!((-3..=3).contains(&blue));
481                assert!((-3..=3).contains(&alpha));
482            }
483        }
484    }
485
486    #[test]
487    fn float_noise_bounded() {
488        let e = NoiseEngine::new(NoiseSeed::from(7_u64));
489        for i in 0u32..32 {
490            let v = e.float_noise("AudioBuffer", i);
491            assert!(
492                v.abs() <= 1e-5 + f64::EPSILON,
493                "float_noise {v} out of range"
494            );
495        }
496    }
497
498    #[test]
499    fn rect_noise_bounded() {
500        let e = NoiseEngine::new(NoiseSeed::from(99_u64));
501        for i in 0u32..16 {
502            let (dx, dy, dw, dh) = e.rect_noise("getBoundingClientRect", i);
503            assert!(dx.abs() <= 0.5 + f64::EPSILON);
504            assert!(dy.abs() <= 0.5 + f64::EPSILON);
505            assert!(dw.abs() <= 0.5 + f64::EPSILON);
506            assert!(dh.abs() <= 0.5 + f64::EPSILON);
507        }
508    }
509
510    #[test]
511    fn noise_config_serde_round_trip() {
512        let cfg = NoiseConfig {
513            seed: Some(NoiseSeed::from(555_u64)),
514            canvas_enabled: true,
515            webgl_enabled: false,
516            audio_enabled: true,
517            rects_enabled: false,
518        };
519        let json_result = serde_json::to_string(&cfg);
520        assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
521        let Ok(json) = json_result else {
522            return;
523        };
524        let back_result: Result<NoiseConfig, _> = serde_json::from_str(&json);
525        assert!(back_result.is_ok(), "deserialize failed: {back_result:?}");
526        let Ok(back) = back_result else {
527            return;
528        };
529        assert_eq!(back.seed, cfg.seed);
530        assert_eq!(back.canvas_enabled, cfg.canvas_enabled);
531        assert_eq!(back.webgl_enabled, cfg.webgl_enabled);
532        assert_eq!(back.audio_enabled, cfg.audio_enabled);
533        assert_eq!(back.rects_enabled, cfg.rects_enabled);
534    }
535
536    #[test]
537    fn js_noise_fn_contains_seed() {
538        let seed = 98_765_u64;
539        let e = NoiseEngine::new(NoiseSeed::from(seed));
540        let js = e.js_noise_fn();
541        assert!(js.contains(&seed.to_string()), "seed not embedded in JS");
542        assert!(js.contains("__stygian_noise"), "missing __stygian_noise");
543        assert!(
544            js.contains("__stygian_float_noise"),
545            "missing __stygian_float_noise"
546        );
547    }
548
549    #[test]
550    fn noise_seed_random_unique() {
551        // Generate 100 seeds — collision probability is negligible (birthday bound ~2^{-54} for 100 draws)
552        let seeds: Vec<NoiseSeed> = (0..100).map(|_| NoiseSeed::random()).collect();
553        let unique: std::collections::HashSet<u64> = seeds.iter().map(|s| s.as_u64()).collect();
554        assert_eq!(unique.len(), 100, "random seeds collided");
555    }
556
557    #[test]
558    fn noise_config_build_engine_uses_seed() {
559        let cfg = NoiseConfig {
560            seed: Some(NoiseSeed::from(77_u64)),
561            ..Default::default()
562        };
563        let engine = cfg.build_engine();
564        assert_eq!(engine.seed().as_u64(), 77);
565    }
566
567    #[test]
568    fn noise_config_build_engine_random_when_none() {
569        let cfg = NoiseConfig::default();
570        let e1 = cfg.build_engine();
571        let e2 = cfg.build_engine();
572        // Random seeds differ with overwhelming probability
573        assert_ne!(e1.seed().as_u64(), e2.seed().as_u64());
574    }
575}