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}