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
This commit is contained in:
@@ -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<string> {
|
||||||
|
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<number | null> =>
|
||||||
|
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<number | null> =>
|
||||||
|
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<string> =>
|
||||||
|
p.evaluate((): Promise<string> => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<ArrayBuffer>): 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<ArrayBuffer>): void {
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
if (!shouldAffect(i)) continue;
|
||||||
|
array[i] = clampByte(array[i] + noiseOffset());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNoiseToFloatTimeDomainData(array: Float32Array<ArrayBuffer>): 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<ArrayBuffer>);
|
||||||
|
addNoiseToFloatFrequencyData(array as Float32Array<ArrayBuffer>);
|
||||||
|
};
|
||||||
|
|
||||||
|
AnalyserNode.prototype.getByteFrequencyData = function (array: Uint8Array): void {
|
||||||
|
origGetByteFrequencyData.call(this, array as Uint8Array<ArrayBuffer>);
|
||||||
|
addNoiseToByteFrequencyData(array as Uint8Array<ArrayBuffer>);
|
||||||
|
};
|
||||||
|
|
||||||
|
AnalyserNode.prototype.getFloatTimeDomainData = function (array: Float32Array): void {
|
||||||
|
origGetFloatTimeDomainData.call(this, array as Float32Array<ArrayBuffer>);
|
||||||
|
addNoiseToFloatTimeDomainData(array as Float32Array<ArrayBuffer>);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, { seed, strength: opts.strength });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user