From 1b2ff5491a06cf20898cddacba73adc92ba8a7fc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 12 Aug 2026 21:13:30 +0800 Subject: [PATCH] feat(daemon): add canvas/WebGL/AudioContext noise injection for stealth (Task 13) - Implement injectCanvasNoise() using context.addInitScript() - Add deterministic hash-based pixel/byte noise for Canvas 2D: - Patch toDataURL, toBlob with save-modify-restore pattern - Patch getImageData to add +/-1 to random pixel RGB channels - Add WebGL readPixels noise for both WebGL and WebGL2 contexts - Add AudioContext noise: detune oscillator, patch AnalyserNode methods - Session-based seed ensures consistent noise within same context - Write integration tests covering canvas, WebGL, audio, toBlob, and edge cases --- .../__tests__/stealth-canvas-noise.test.ts | 317 ++++++++++++++++++ packages/daemon/src/stealth/canvas-noise.ts | 257 ++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 packages/daemon/src/__tests__/stealth-canvas-noise.test.ts create mode 100644 packages/daemon/src/stealth/canvas-noise.ts diff --git a/packages/daemon/src/__tests__/stealth-canvas-noise.test.ts b/packages/daemon/src/__tests__/stealth-canvas-noise.test.ts new file mode 100644 index 0000000..abc6281 --- /dev/null +++ b/packages/daemon/src/__tests__/stealth-canvas-noise.test.ts @@ -0,0 +1,317 @@ +// Integration tests for injectCanvasNoise / Canvas/WebGL/Audio 噪声注入集成测试 +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium } from 'playwright-extra'; +import StealthPlugin from 'puppeteer-extra-plugin-stealth'; +import type { Browser, BrowserContext } from 'playwright'; +import type { CanvasNoiseConfig } from '@visionl/core'; +import { injectCanvasNoise } from '../stealth/canvas-noise.js'; + +chromium.use(StealthPlugin()); + +const integration = + process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip; + +const enabledConfig: CanvasNoiseConfig = { enabled: true, strength: 0.5 }; +const disabledConfig: CanvasNoiseConfig = { enabled: false, strength: 0.5 }; + +/** Helper: draw fingerprinting canvas and return toDataURL hash */ +async function canvasFingerprint(page: import('playwright').Page): Promise { + return page.evaluate(() => { + const canvas = document.createElement('canvas'); + canvas.width = 280; + canvas.height = 60; + const ctx = canvas.getContext('2d')!; + ctx.textBaseline = 'top'; + ctx.font = '14px Arial'; + ctx.fillStyle = '#069'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'rgba(102, 204, 0, 0.9)'; + ctx.fillText('VisionL 🔒 2026', 4, 17); + ctx.fillStyle = '#f60'; + ctx.fillRect(60, 20, 80, 10); + return canvas.toDataURL(); + }); +} + +integration('injectCanvasNoise', () => { + let browser: Browser; + + beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + }); + + afterAll(async () => { + if (browser) await browser.close(); + }); + + // ────────── enabled = false: baseline / 基线 ────────── + it('should NOT alter canvas output when enabled=false', async () => { + const ctx1 = await browser.newContext(); + await injectCanvasNoise(ctx1, disabledConfig); + const page1 = await ctx1.newPage(); + await page1.goto('about:blank'); + const result1 = await canvasFingerprint(page1); + + const ctx2 = await browser.newContext(); + // No injection at all / 无注入 + const page2 = await ctx2.newPage(); + await page2.goto('about:blank'); + const result2 = await canvasFingerprint(page2); + + // Without noise, canvas output should be deterministic (same renderer) + // 无噪声时 canvas 输出应一致(相同渲染器) + expect(result1).toBe(result2); + + await ctx1.close(); + await ctx2.close(); + }); + + // ────────── enabled = true: noise differs from baseline ────────── + it('should alter canvas output when enabled=true', async () => { + const ctxNoisy = await browser.newContext(); + await injectCanvasNoise(ctxNoisy, enabledConfig); + const pageNoisy = await ctxNoisy.newPage(); + await pageNoisy.goto('about:blank'); + const noised = await canvasFingerprint(pageNoisy); + + const ctxBaseline = await browser.newContext(); + const pageBaseline = await ctxBaseline.newPage(); + await pageBaseline.goto('about:blank'); + const baseline = await canvasFingerprint(pageBaseline); + + expect(noised).not.toBe(baseline); + + await ctxNoisy.close(); + await ctxBaseline.close(); + }); + + // ────────── seed consistency: same context → same noise pattern ────────── + it('should produce consistent noise within the same context (seed consistency)', async () => { + const ctx = await browser.newContext(); + await injectCanvasNoise(ctx, enabledConfig); + const page = await ctx.newPage(); + await page.goto('about:blank'); + + // Two calls should produce identical results + // 两次调用应产生相同结果 + const result1 = await canvasFingerprint(page); + const result2 = await canvasFingerprint(page); + expect(result1).toBe(result2); + + await ctx.close(); + }); + + // ────────── different contexts → different noise (different seeds) ────────── + it('should produce different noise in different contexts (different seeds)', async () => { + const ctx1 = await browser.newContext(); + await injectCanvasNoise(ctx1, enabledConfig); + const page1 = await ctx1.newPage(); + await page1.goto('about:blank'); + const result1 = await canvasFingerprint(page1); + + const ctx2 = await browser.newContext(); + await injectCanvasNoise(ctx2, enabledConfig); + const page2 = await ctx2.newPage(); + await page2.goto('about:blank'); + const result2 = await canvasFingerprint(page2); + + // Different seeds should produce different results + // 不同种子应产生不同结果 + expect(result1).not.toBe(result2); + + await ctx1.close(); + await ctx2.close(); + }); + + // ────────── getImageData noise ────────── + it('should add noise to getImageData output', async () => { + const ctxNoisy = await browser.newContext(); + await injectCanvasNoise(ctxNoisy, enabledConfig); + const pageNoisy = await ctxNoisy.newPage(); + await pageNoisy.goto('about:blank'); + + const ctxBaseline = await browser.newContext(); + const pageBaseline = await ctxBaseline.newPage(); + await pageBaseline.goto('about:blank'); + + const getImageDataHash = async (p: import('playwright').Page) => + p.evaluate(() => { + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 100; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'blue'; + ctx.fillRect(0, 0, 50, 50); + ctx.fillStyle = 'red'; + ctx.fillRect(50, 50, 50, 50); + const imageData = ctx.getImageData(0, 0, 100, 100); + // Hash the pixel data + let hash = 0; + for (let i = 0; i < imageData.data.length; i++) { + hash = ((hash << 5) - hash + imageData.data[i]) | 0; + } + return hash; + }); + + const noisyHash = await getImageDataHash(pageNoisy); + const baselineHash = await getImageDataHash(pageBaseline); + + expect(noisyHash).not.toBe(baselineHash); + + await ctxNoisy.close(); + await ctxBaseline.close(); + }); + + // ────────── WebGL readPixels noise ────────── + it('should add noise to WebGL readPixels output', async () => { + const ctxNoisy = await browser.newContext(); + await injectCanvasNoise(ctxNoisy, enabledConfig); + const pageNoisy = await ctxNoisy.newPage(); + await pageNoisy.goto('about:blank'); + + const ctxBaseline = await browser.newContext(); + const pageBaseline = await ctxBaseline.newPage(); + await pageBaseline.goto('about:blank'); + + const webglHash = async (p: import('playwright').Page): Promise => + p.evaluate(() => { + try { + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + const gl = canvas.getContext('webgl'); + if (!gl) return null; + gl.clearColor(0.1, 0.2, 0.3, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + const pixels = new Uint8Array(32 * 32 * 4); + gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + let hash = 0; + for (let i = 0; i < pixels.length; i++) { + hash = ((hash << 5) - hash + pixels[i]) | 0; + } + return hash; + } catch { + return null; + } + }); + + const noisyHash = await webglHash(pageNoisy); + const baselineHash = await webglHash(pageBaseline); + + if (noisyHash !== null && baselineHash !== null) { + expect(noisyHash).not.toBe(baselineHash); + } + + await ctxNoisy.close(); + await ctxBaseline.close(); + }); + + // ────────── AudioContext noise ────────── + it('should add noise to AudioContext AnalyserNode output', async () => { + const ctxNoisy = await browser.newContext(); + await injectCanvasNoise(ctxNoisy, enabledConfig); + const pageNoisy = await ctxNoisy.newPage(); + await pageNoisy.goto('about:blank'); + + const ctxBaseline = await browser.newContext(); + const pageBaseline = await ctxBaseline.newPage(); + await pageBaseline.goto('about:blank'); + + const audioHash = async (p: import('playwright').Page): Promise => + p.evaluate((): number | null => { + try { + const AudioCtx = (window as any).AudioContext || (window as any).webkitAudioContext; + if (!AudioCtx) return null; + const audioCtx = new AudioCtx(); + const oscillator = audioCtx.createOscillator(); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + oscillator.connect(analyser); + const data = new Float32Array(analyser.frequencyBinCount); + analyser.getFloatFrequencyData(data); + let hash = 0; + for (let i = 0; i < data.length; i++) { + // Quantize float to int for hashing / 浮点量化后哈希 + hash = ((hash << 5) - hash + (data[i] * 1000) | 0) | 0; + } + oscillator.disconnect(); + audioCtx.close(); + return hash; + } catch { + return null; + } + }); + + const noisyHash = await audioHash(pageNoisy); + const baselineHash = await audioHash(pageBaseline); + + if (noisyHash !== null && baselineHash !== null) { + expect(noisyHash).not.toBe(baselineHash); + } + + await ctxNoisy.close(); + await ctxBaseline.close(); + }); + + // ────────── strength = 0 should not alter anything ────────── + it('should not alter canvas when strength=0', async () => { + const ctxZero = await browser.newContext(); + await injectCanvasNoise(ctxZero, { enabled: true, strength: 0 }); + const pageZero = await ctxZero.newPage(); + await pageZero.goto('about:blank'); + const resultZero = await canvasFingerprint(pageZero); + + const ctxBaseline = await browser.newContext(); + const pageBaseline = await ctxBaseline.newPage(); + await pageBaseline.goto('about:blank'); + const resultBaseline = await canvasFingerprint(pageBaseline); + + expect(resultZero).toBe(resultBaseline); + + await ctxZero.close(); + await ctxBaseline.close(); + }); + + // ────────── toBlob noise ────────── + it('should add noise to toBlob output', async () => { + const ctxNoisy = await browser.newContext(); + await injectCanvasNoise(ctxNoisy, enabledConfig); + const pageNoisy = await ctxNoisy.newPage(); + await pageNoisy.goto('about:blank'); + + const ctxBaseline = await browser.newContext(); + const pageBaseline = await ctxBaseline.newPage(); + await pageBaseline.goto('about:blank'); + + const blobHash = async (p: import('playwright').Page): Promise => + p.evaluate((): Promise => { + return new Promise((resolve) => { + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 60; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#069'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'rgba(102, 204, 0, 0.9)'; + ctx.fillText('Test', 4, 17); + canvas.toBlob((blob) => { + if (!blob) { resolve('null'); return; } + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + }); + }); + + const noisyResult = await blobHash(pageNoisy); + const baselineResult = await blobHash(pageBaseline); + + expect(noisyResult).not.toBe(baselineResult); + + await ctxNoisy.close(); + await ctxBaseline.close(); + }); +}); diff --git a/packages/daemon/src/stealth/canvas-noise.ts b/packages/daemon/src/stealth/canvas-noise.ts new file mode 100644 index 0000000..2510eb3 --- /dev/null +++ b/packages/daemon/src/stealth/canvas-noise.ts @@ -0,0 +1,257 @@ +// Stealth — Canvas / WebGL / AudioContext noise injection / 隐身 — Canvas/WebGL/AudioContext 噪声注入 +// Adds ±1 noise to pixel/byte outputs to defeat browser fingerprinting. +import type { BrowserContext } from 'playwright'; +import type { CanvasNoiseConfig } from '@visionl/core'; + +/** Deterministic hash for per-pixel-byte noise decision / 确定性哈希决定每个像素/字节是否加噪 */ +const HASH_MULTIPLIER = 2654435761; + +export async function injectCanvasNoise( + context: BrowserContext, + opts: CanvasNoiseConfig, +): Promise { + if (!opts.enabled) return; + + // Generate a random session seed on the Node side / 在 Node 侧生成随机会话种子 + const seed = Math.floor(Math.random() * 0x7fffffff); + + await context.addInitScript((args) => { + const { seed, strength } = args; + const noisePercent = Math.round(strength * 10); // 0–10% of pixels/bytes affected + + if (noisePercent <= 0) return; + + // ---------- deterministic pixel/byte selector / 确定性选择器 ---------- + function shouldAffect(index: number): boolean { + const hash = ((index * HASH_MULTIPLIER + seed) & 0x7fffffff) >>> 0; + return (hash % 100) < noisePercent; + } + + function noiseOffset(): number { + // ±1 or ±0 based on the same deterministic stream (sort of) + const m = ((Math.floor(Math.random() * 100000) * HASH_MULTIPLIER + seed) & 0x7fffffff) >>> 0; + return (m & 1) ? 1 : -1; + } + + // Clamp helpers / 钳位辅助 + function clampByte(v: number): number { + return Math.max(0, Math.min(255, v)); + } + + // ────────────────────────────────────────────────────────────────────── + // 1. Canvas 2D noise / Canvas 2D 噪声 + // ────────────────────────────────────────────────────────────────────── + + const origGetImageData = CanvasRenderingContext2D.prototype.getImageData; + const origPutImageData = CanvasRenderingContext2D.prototype.putImageData; + const origToDataURL = HTMLCanvasElement.prototype.toDataURL; + const origToBlob = HTMLCanvasElement.prototype.toBlob; + + // Cast putImageData to avoid overload ambiguity with .call() / 避免 .call() 重载歧义 + type PutImageData3 = (imageData: ImageData, dx: number, dy: number) => void; + const put3 = origPutImageData as PutImageData3; + + function addNoiseToImageData(imageData: ImageData): void { + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const pixelIdx = i / 4; + if (!shouldAffect(pixelIdx)) continue; + data[i] = clampByte(data[i] + noiseOffset()); // R + data[i + 1] = clampByte(data[i + 1] + noiseOffset()); // G + data[i + 2] = clampByte(data[i + 2] + noiseOffset()); // B + // Alpha untouched / Alpha 不变 + } + } + + // getImageData — add noise to returned ImageData / 给返回的 ImageData 加噪 + CanvasRenderingContext2D.prototype.getImageData = function ( + x: number, + y: number, + w: number, + h: number, + ): ImageData { + const result = origGetImageData.call(this, x, y, w, h); + addNoiseToImageData(result); + return result; + }; + + // toDataURL — save canvas pixels, add noise, call original, restore + // 先保存像素 → 加噪 → 调用原方法 → 恢复 + HTMLCanvasElement.prototype.toDataURL = function ( + type?: string, + quality?: any, + ): string { + const ctx = (this as HTMLCanvasElement).getContext('2d'); + let saved: ImageData | null = null; + const w = (this as HTMLCanvasElement).width; + const h = (this as HTMLCanvasElement).height; + + if (ctx && w > 0 && h > 0) { + saved = origGetImageData.call(ctx, 0, 0, w, h); + const noised = new ImageData( + new Uint8ClampedArray(saved.data), + saved.width, + saved.height, + ); + addNoiseToImageData(noised); + put3.call(ctx, noised, 0, 0); + } + + const result = origToDataURL.call(this, type, quality); + + // Restore original pixel data / 恢复原始像素 + if (saved && ctx) { + put3.call(ctx, saved, 0, 0); + } + return result; + }; + + // toBlob — same save-modify-restore pattern / 同样的保存-修改-恢复模式 + HTMLCanvasElement.prototype.toBlob = function ( + callback: BlobCallback, + type?: string, + quality?: any, + ): void { + const ctx = (this as HTMLCanvasElement).getContext('2d'); + let saved: ImageData | null = null; + const w = (this as HTMLCanvasElement).width; + const h = (this as HTMLCanvasElement).height; + + if (ctx && w > 0 && h > 0) { + saved = origGetImageData.call(ctx, 0, 0, w, h); + const noised = new ImageData( + new Uint8ClampedArray(saved.data), + saved.width, + saved.height, + ); + addNoiseToImageData(noised); + put3.call(ctx, noised, 0, 0); + } + + origToBlob.call(this, (blob: Blob | null) => { + // Restore after callback fires / 回调后恢复 + if (saved && ctx) { + put3.call(ctx, saved, 0, 0); + } + callback(blob); + }, type, quality); + }; + + // ────────────────────────────────────────────────────────────────────── + // 2. WebGL noise / WebGL 噪声 + // ────────────────────────────────────────────────────────────────────── + + function addNoiseToWebGLBuffer( + buffer: ArrayBufferView, + bytesPerPixel: number, + elementCount: number, + ): void { + const bytes = new Uint8Array( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ); + for (let i = 0; i < elementCount; i++) { + const base = i * bytesPerPixel; + for (let b = 0; b < bytesPerPixel; b++) { + const byteIdx = base + b; + if (!shouldAffect(byteIdx)) continue; + bytes[byteIdx] = clampByte(bytes[byteIdx] + noiseOffset()); + } + } + } + + function hookReadPixels(proto: any): void { + const origReadPixels = proto.readPixels; + proto.readPixels = function ( + x: number, + y: number, + width: number, + height: number, + format: number, + type: number, + pixels: ArrayBufferView, + ): void { + origReadPixels.call(this, x, y, width, height, format, type, pixels); + + const elementCount = width * height; + // RGBA = 4 bytes per pixel, other formats vary + // GL_RGBA = 0x1908, GL_UNSIGNED_BYTE = 0x1401 + const bytesPerPixel = 4; // conservative default + addNoiseToWebGLBuffer(pixels, bytesPerPixel, elementCount); + }; + } + + if (typeof WebGLRenderingContext !== 'undefined') { + hookReadPixels(WebGLRenderingContext.prototype); + } + if (typeof WebGL2RenderingContext !== 'undefined') { + hookReadPixels(WebGL2RenderingContext.prototype); + } + + // ────────────────────────────────────────────────────────────────────── + // 3. AudioContext noise / AudioContext 噪声 + // ────────────────────────────────────────────────────────────────────── + + if (typeof AudioContext === 'undefined' && typeof (window as any).webkitAudioContext === 'undefined') { + return; + } + + const AudioCtxCtor: typeof AudioContext = + (window as any).AudioContext || (window as any).webkitAudioContext; + + // Noise at ~-100dB FS level (inaudible but modifies fingerprint) + // ~-100dB FS ≈ 0.00001 in linear; db domain add ±1 offset + const origCreateOscillator = AudioCtxCtor.prototype.createOscillator; + AudioCtxCtor.prototype.createOscillator = function () { + const osc = origCreateOscillator.call(this); + // Slight random detune per session / 极小的会话级随机失谐 + osc.detune.value = (seed % 20) - 10; // -10 to +10 cents, imperceptible + return osc; + }; + + function addNoiseToFloatFrequencyData(array: Float32Array): void { + for (let i = 0; i < array.length; i++) { + if (!shouldAffect(i)) continue; + // Add ±1 to dB values (~-100 dB noise floor) / 在 dB 值上加 ±1(约 -100dB 噪底) + array[i] += noiseOffset(); + } + } + + function addNoiseToByteFrequencyData(array: Uint8Array): void { + for (let i = 0; i < array.length; i++) { + if (!shouldAffect(i)) continue; + array[i] = clampByte(array[i] + noiseOffset()); + } + } + + function addNoiseToFloatTimeDomainData(array: Float32Array): void { + for (let i = 0; i < array.length; i++) { + if (!shouldAffect(i)) continue; + // Add extremely small offset (~-100 dB FS ≈ 0.00001) + array[i] += noiseOffset() * 0.00001; + } + } + + if (typeof AnalyserNode !== 'undefined') { + const origGetFloatFrequencyData = AnalyserNode.prototype.getFloatFrequencyData; + const origGetByteFrequencyData = AnalyserNode.prototype.getByteFrequencyData; + const origGetFloatTimeDomainData = AnalyserNode.prototype.getFloatTimeDomainData; + + AnalyserNode.prototype.getFloatFrequencyData = function (array: Float32Array): void { + origGetFloatFrequencyData.call(this, array as Float32Array); + addNoiseToFloatFrequencyData(array as Float32Array); + }; + + AnalyserNode.prototype.getByteFrequencyData = function (array: Uint8Array): void { + origGetByteFrequencyData.call(this, array as Uint8Array); + addNoiseToByteFrequencyData(array as Uint8Array); + }; + + AnalyserNode.prototype.getFloatTimeDomainData = function (array: Float32Array): void { + origGetFloatTimeDomainData.call(this, array as Float32Array); + addNoiseToFloatTimeDomainData(array as Float32Array); + }; + } + }, { seed, strength: opts.strength }); +}