Skip to main content

stygian_browser/
canvas_noise.rs

1//! Canvas fingerprint noise injection.
2//!
3//! Generates a CDP `Page.addScriptToEvaluateOnNewDocument` script that overrides
4//! `CanvasRenderingContext2D` and `OffscreenCanvasRenderingContext2D` APIs to
5//! inject deterministic per-session noise into all pixel-readback operations.
6//!
7//! The noise is driven by [`crate::noise::NoiseEngine`] (T37). Given the same
8//! seed, every canvas read produces the same perturbation — enabling
9//! cross-context consistency (main thread vs. `OffscreenCanvas` in a Worker).
10//!
11//! # Example
12//!
13//! ```
14//! use stygian_browser::canvas_noise::canvas_noise_script;
15//! use stygian_browser::noise::{NoiseEngine, NoiseSeed};
16//!
17//! let engine = NoiseEngine::new(NoiseSeed::from(42_u64));
18//! let js = canvas_noise_script(&engine);
19//! assert!(js.contains("__stygian_noise"));
20//! assert!(js.contains("toDataURL"));
21//! ```
22
23use crate::noise::NoiseEngine;
24
25/// Generate the canvas noise injection script for a given [`NoiseEngine`].
26///
27/// The script must be injected via `Page.addScriptToEvaluateOnNewDocument`
28/// so it runs before any page JavaScript. It works in both the main thread
29/// and Web Worker / `OffscreenCanvas` contexts.
30///
31/// Returns an empty string if canvas noise is not needed (callers should
32/// check [`crate::noise::NoiseConfig::canvas_enabled`] before calling).
33///
34/// # Example
35///
36/// ```
37/// use stygian_browser::canvas_noise::canvas_noise_script;
38/// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
39///
40/// let engine = NoiseEngine::new(NoiseSeed::from(1_u64));
41/// let js = canvas_noise_script(&engine);
42/// assert!(js.contains("toDataURL"));
43/// assert!(js.contains("getImageData"));
44/// assert!(js.contains("convertToBlob"));
45/// ```
46#[must_use]
47#[allow(clippy::too_many_lines)]
48pub fn canvas_noise_script(engine: &NoiseEngine) -> String {
49    // First emit the seed-embedded noise helper (from T37), then the overrides.
50    let noise_fn = engine.js_noise_fn();
51    format!(
52        r"(function() {{
53  'use strict';
54
55  // ── Noise helpers (injected from T37 NoiseEngine) ──────────────────────
56  {noise_fn}
57
58  // ── Utility: apply pixel noise to an ImageData in-place ────────────────
59  function _applyCanvasNoise(imageData, offsetX, offsetY, operation) {{
60    const data = imageData.data;
61    const w = imageData.width;
62    for (let i = 0; i < data.length; i += 4) {{
63      const pixelIdx = (i / 4) | 0;
64      const px = (offsetX + (pixelIdx % w)) >>> 0;
65      const py = (offsetY + ((pixelIdx / w) | 0)) >>> 0;
66      // Only noise non-transparent, non-zero pixels to avoid noising blank canvases
67      if (data[i] === 0 && data[i | 1] === 0 && data[i | 2] === 0 && data[i | 3] === 0) {{
68        continue;
69      }}
70      const [dr, dg, db, da] = __stygian_noise(operation, px, py);
71      data[i]     = Math.max(0, Math.min(255, data[i]     + dr));
72      data[i | 1] = Math.max(0, Math.min(255, data[i | 1] + dg));
73      data[i | 2] = Math.max(0, Math.min(255, data[i | 2] + db));
74      data[i | 3] = Math.max(0, Math.min(255, data[i | 3] + da));
75    }}
76    return imageData;
77  }}
78
79  // ── Helper: copy canvas content to a temp canvas, noise it, return it ──
80  function _noisedCanvas(src) {{
81    const tmp = (typeof OffscreenCanvas !== 'undefined' && src instanceof OffscreenCanvas)
82      ? new OffscreenCanvas(src.width, src.height)
83      : document.createElement('canvas');
84    tmp.width  = src.width;
85    tmp.height = src.height;
86    const ctx = tmp.getContext('2d');
87    ctx.drawImage(src, 0, 0);
88    const id = ctx.getImageData(0, 0, tmp.width, tmp.height);
89    _applyCanvasNoise(id, 0, 0, 'canvas.toDataURL');
90    ctx.putImageData(id, 0, 0);
91    return tmp;
92  }}
93
94  // ── Spoof toString so overrides look like native functions ───────────────
95  function _nativeToString(name) {{
96    return function toString() {{ return 'function ' + name + '() {{ [native code] }}'; }};
97  }}
98
99  function _defineNative(obj, prop, fn) {{
100    fn.toString = _nativeToString(prop);
101    Object.defineProperty(obj, prop, {{
102      value: fn,
103      writable: false,
104      configurable: false,
105      enumerable: false,
106    }});
107  }}
108
109  // ── CanvasRenderingContext2D.getImageData ────────────────────────────────
110  (function() {{
111    const ctx2d = CanvasRenderingContext2D.prototype;
112    const _origGetImageData = ctx2d.getImageData;
113    _defineNative(ctx2d, 'getImageData', function getImageData(sx, sy, sw, sh, settings) {{
114      const id = settings !== undefined
115        ? _origGetImageData.call(this, sx, sy, sw, sh, settings)
116        : _origGetImageData.call(this, sx, sy, sw, sh);
117      return _applyCanvasNoise(id, sx >>> 0, sy >>> 0, 'canvas.getImageData');
118    }});
119  }})();
120
121  // ── HTMLCanvasElement.toDataURL ──────────────────────────────────────────
122  (function() {{
123    if (typeof HTMLCanvasElement === 'undefined') return;
124    const _origToDataURL = HTMLCanvasElement.prototype.toDataURL;
125    _defineNative(HTMLCanvasElement.prototype, 'toDataURL', function toDataURL(type, quality) {{
126      const tmp = _noisedCanvas(this);
127      return quality !== undefined
128        ? _origToDataURL.call(tmp, type, quality)
129        : type !== undefined
130          ? _origToDataURL.call(tmp, type)
131          : _origToDataURL.call(tmp);
132    }});
133  }})();
134
135  // ── HTMLCanvasElement.toBlob ─────────────────────────────────────────────
136  (function() {{
137    if (typeof HTMLCanvasElement === 'undefined') return;
138    const _origToBlob = HTMLCanvasElement.prototype.toBlob;
139    _defineNative(HTMLCanvasElement.prototype, 'toBlob', function toBlob(callback, type, quality) {{
140      const tmp = _noisedCanvas(this);
141      if (quality !== undefined) {{
142        _origToBlob.call(tmp, callback, type, quality);
143      }} else if (type !== undefined) {{
144        _origToBlob.call(tmp, callback, type);
145      }} else {{
146        _origToBlob.call(tmp, callback);
147      }}
148    }});
149  }})();
150
151  // ── OffscreenCanvasRenderingContext2D.getImageData ───────────────────────
152  (function() {{
153    if (typeof OffscreenCanvasRenderingContext2D === 'undefined') return;
154    const octx2d = OffscreenCanvasRenderingContext2D.prototype;
155    const _origOGID = octx2d.getImageData;
156    _defineNative(octx2d, 'getImageData', function getImageData(sx, sy, sw, sh, settings) {{
157      const id = settings !== undefined
158        ? _origOGID.call(this, sx, sy, sw, sh, settings)
159        : _origOGID.call(this, sx, sy, sw, sh);
160      return _applyCanvasNoise(id, sx >>> 0, sy >>> 0, 'offscreencanvas.getImageData');
161    }});
162  }})();
163
164  // ── OffscreenCanvas.convertToBlob ────────────────────────────────────────
165  (function() {{
166    if (typeof OffscreenCanvas === 'undefined') return;
167    const _origCTB = OffscreenCanvas.prototype.convertToBlob;
168    _defineNative(OffscreenCanvas.prototype, 'convertToBlob', async function convertToBlob(options) {{
169      const tmp = new OffscreenCanvas(this.width, this.height);
170      const ctx = tmp.getContext('2d');
171      ctx.drawImage(this, 0, 0);
172      const id = ctx.getImageData(0, 0, tmp.width, tmp.height);
173      _applyCanvasNoise(id, 0, 0, 'offscreencanvas.convertToBlob');
174      ctx.putImageData(id, 0, 0);
175      return options !== undefined
176        ? _origCTB.call(tmp, options)
177        : _origCTB.call(tmp);
178    }});
179  }})();
180
181}})();
182"
183    )
184}
185
186// ---------------------------------------------------------------------------
187// Tests
188// ---------------------------------------------------------------------------
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::noise::{NoiseEngine, NoiseSeed};
194
195    fn engine(seed: u64) -> NoiseEngine {
196        NoiseEngine::new(NoiseSeed::from(seed))
197    }
198
199    #[test]
200    fn script_contains_seed() {
201        let js = canvas_noise_script(&engine(12345));
202        assert!(js.contains("12345"), "seed not embedded");
203    }
204
205    #[test]
206    fn script_contains_all_five_overrides() {
207        let js = canvas_noise_script(&engine(1));
208        assert!(js.contains("getImageData"), "missing getImageData");
209        assert!(js.contains("toDataURL"), "missing toDataURL");
210        assert!(js.contains("toBlob"), "missing toBlob");
211        // OffscreenCanvas variants
212        assert!(
213            js.contains("OffscreenCanvasRenderingContext2D"),
214            "missing OffscreenCanvas getImageData"
215        );
216        assert!(js.contains("convertToBlob"), "missing convertToBlob");
217    }
218
219    #[test]
220    fn script_contains_native_tostring_spoofing() {
221        let js = canvas_noise_script(&engine(1));
222        assert!(
223            js.contains("[native code]"),
224            "missing native code toString spoof"
225        );
226    }
227
228    #[test]
229    fn script_contains_noise_helper() {
230        let js = canvas_noise_script(&engine(1));
231        assert!(js.contains("__stygian_noise"), "missing __stygian_noise");
232    }
233
234    #[test]
235    fn different_seeds_produce_different_seeds_in_script() {
236        let js1 = canvas_noise_script(&engine(111));
237        let js2 = canvas_noise_script(&engine(222));
238        // The embedded seed values differ
239        assert_ne!(js1, js2, "scripts should differ for different seeds");
240    }
241}