Skip to main content

stygian_browser/
audio_noise.rs

1//! Audio fingerprint noise injection.
2//!
3//! Overrides `AudioBuffer`, `AnalyserNode`, and `OfflineAudioContext` APIs to
4//! inject deterministic per-session noise that breaks audio fingerprinting
5//! while remaining inaudible.
6//!
7//! # Example
8//!
9//! ```
10//! use stygian_browser::audio_noise::audio_noise_script;
11//! use stygian_browser::noise::{NoiseEngine, NoiseSeed};
12//!
13//! let engine = NoiseEngine::new(NoiseSeed::from(42_u64));
14//! let js = audio_noise_script(&engine);
15//! assert!(js.contains("getChannelData"));
16//! assert!(js.contains("__stygian_float_noise"));
17//! ```
18
19use crate::noise::NoiseEngine;
20
21/// Generate the audio noise injection script for a given [`NoiseEngine`].
22///
23/// Must be injected via `Page.addScriptToEvaluateOnNewDocument`. Works in
24/// Worker contexts where `OfflineAudioContext` is available.
25///
26/// Returns an empty string if audio noise is not needed (callers should
27/// check [`crate::noise::NoiseConfig::audio_enabled`]).
28///
29/// # Example
30///
31/// ```
32/// use stygian_browser::audio_noise::audio_noise_script;
33/// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
34///
35/// let js = audio_noise_script(&NoiseEngine::new(NoiseSeed::from(1_u64)));
36/// assert!(js.contains("AudioBuffer"));
37/// assert!(js.contains("getChannelData"));
38/// assert!(js.contains("copyFromChannel"));
39/// assert!(js.contains("getFloatFrequencyData"));
40/// assert!(js.contains("startRendering"));
41/// ```
42#[must_use]
43pub fn audio_noise_script(engine: &NoiseEngine) -> String {
44    let noise_fn = engine.js_noise_fn();
45    format!(
46        r"(function() {{
47  'use strict';
48
49  // ── Noise helpers ──────────────────────────────────────────────────────
50  {noise_fn}
51
52  // ── Spoof toString ─────────────────────────────────────────────────────
53  function _nts(name) {{ return function toString() {{ return 'function ' + name + '() {{ [native code] }}'; }}; }}
54  function _def(obj, prop, fn) {{
55    fn.toString = _nts(prop);
56    Object.defineProperty(obj, prop, {{ value: fn, writable: false, configurable: false, enumerable: false }});
57  }}
58
59  // ── AudioBuffer.getChannelData ───────────────────────────────────────
60  if (typeof AudioBuffer !== 'undefined') {{
61    const _origGCD = AudioBuffer.prototype.getChannelData;
62    _def(AudioBuffer.prototype, 'getChannelData', function getChannelData(channel) {{
63      const data = _origGCD.call(this, channel);
64      const key = 'audio.getChannelData.' + channel;
65      for (let i = 0; i < data.length; i++) {{
66        data[i] += __stygian_float_noise(key, i);
67      }}
68      return data;
69    }});
70
71    // AudioBuffer.copyFromChannel
72    const _origCFC = AudioBuffer.prototype.copyFromChannel;
73    _def(AudioBuffer.prototype, 'copyFromChannel', function copyFromChannel(dest, channelNumber, startInChannel) {{
74      const off = startInChannel || 0;
75      _origCFC.call(this, dest, channelNumber, off);
76      const key = 'audio.copyFromChannel.' + channelNumber;
77      for (let i = 0; i < dest.length; i++) {{
78        dest[i] += __stygian_float_noise(key, off + i);
79      }}
80    }});
81  }}
82
83  // ── AnalyserNode frequency/time domain ──────────────────────────────
84  if (typeof AnalyserNode !== 'undefined') {{
85    const _origGFFD = AnalyserNode.prototype.getFloatFrequencyData;
86    _def(AnalyserNode.prototype, 'getFloatFrequencyData', function getFloatFrequencyData(arr) {{
87      _origGFFD.call(this, arr);
88      for (let i = 0; i < arr.length; i++) {{
89        arr[i] += __stygian_float_noise('audio.floatFreq', i);
90      }}
91    }});
92
93    const _origGBFD = AnalyserNode.prototype.getByteFrequencyData;
94    _def(AnalyserNode.prototype, 'getByteFrequencyData', function getByteFrequencyData(arr) {{
95      _origGBFD.call(this, arr);
96      for (let i = 0; i < arr.length; i++) {{
97        const delta = (__stygian_float_noise('audio.byteFreq', i) * 1e5) | 0;
98        arr[i] = Math.max(0, Math.min(255, arr[i] + delta));
99      }}
100    }});
101
102    const _origGFTD = AnalyserNode.prototype.getFloatTimeDomainData;
103    _def(AnalyserNode.prototype, 'getFloatTimeDomainData', function getFloatTimeDomainData(arr) {{
104      _origGFTD.call(this, arr);
105      for (let i = 0; i < arr.length; i++) {{
106        arr[i] += __stygian_float_noise('audio.floatTime', i);
107      }}
108    }});
109
110    const _origGBTD = AnalyserNode.prototype.getByteTimeDomainData;
111    _def(AnalyserNode.prototype, 'getByteTimeDomainData', function getByteTimeDomainData(arr) {{
112      _origGBTD.call(this, arr);
113      for (let i = 0; i < arr.length; i++) {{
114        const delta = (__stygian_float_noise('audio.byteTime', i) * 1e5) | 0;
115        arr[i] = Math.max(0, Math.min(255, arr[i] + delta));
116      }}
117    }});
118  }}
119
120  // ── OfflineAudioContext.startRendering ───────────────────────────────
121  if (typeof OfflineAudioContext !== 'undefined') {{
122    const _origSR = OfflineAudioContext.prototype.startRendering;
123    _def(OfflineAudioContext.prototype, 'startRendering', function startRendering() {{
124      return _origSR.call(this).then(function(buffer) {{
125        const nCh = buffer.numberOfChannels;
126        for (let c = 0; c < nCh; c++) {{
127          const data = buffer.getChannelData(c);
128          const key = 'audio.offline.' + c;
129          for (let i = 0; i < data.length; i++) {{
130            data[i] += __stygian_float_noise(key, i);
131          }}
132        }}
133        return buffer;
134      }});
135    }});
136  }}
137
138}})();
139"
140    )
141}
142
143// ---------------------------------------------------------------------------
144// Tests
145// ---------------------------------------------------------------------------
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::noise::{NoiseEngine, NoiseSeed};
151
152    fn eng(seed: u64) -> NoiseEngine {
153        NoiseEngine::new(NoiseSeed::from(seed))
154    }
155
156    #[test]
157    fn script_overrides_all_methods() {
158        let js = audio_noise_script(&eng(1));
159        assert!(js.contains("getChannelData"), "missing getChannelData");
160        assert!(js.contains("copyFromChannel"), "missing copyFromChannel");
161        assert!(
162            js.contains("getFloatFrequencyData"),
163            "missing getFloatFrequencyData"
164        );
165        assert!(
166            js.contains("getByteFrequencyData"),
167            "missing getByteFrequencyData"
168        );
169        assert!(
170            js.contains("getFloatTimeDomainData"),
171            "missing getFloatTimeDomainData"
172        );
173        assert!(
174            js.contains("getByteTimeDomainData"),
175            "missing getByteTimeDomainData"
176        );
177        assert!(js.contains("startRendering"), "missing startRendering");
178    }
179
180    #[test]
181    fn script_contains_float_noise_fn() {
182        let js = audio_noise_script(&eng(1));
183        assert!(
184            js.contains("__stygian_float_noise"),
185            "missing __stygian_float_noise"
186        );
187    }
188
189    #[test]
190    fn script_contains_native_tostring() {
191        let js = audio_noise_script(&eng(1));
192        assert!(js.contains("[native code]"), "missing toString spoof");
193    }
194
195    #[test]
196    fn script_contains_seed() {
197        let js = audio_noise_script(&eng(54321));
198        assert!(js.contains("54321"), "seed not embedded");
199    }
200
201    #[test]
202    fn different_seeds_differ() {
203        let js1 = audio_noise_script(&eng(1));
204        let js2 = audio_noise_script(&eng(2));
205        assert_ne!(js1, js2);
206    }
207}