Минимальный init-script (src/api/js/canvas_noise.js): младший бит у ~1/64 непрозрачных пикселей при toDataURL/toBlob/getImageData, детерминирован от seed профиля. Пустой canvas не меняется, подменённые функции неотличимы по toString и length. Старые профили получают seed, экран сохраняется. Проверено: 4 профиля — 4 разных хеша canvas; Cloudflare 6/6 на новых профилях. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
2.9 KiB
JavaScript
72 lines
2.9 KiB
JavaScript
// Шум canvas профиля (#89): младший бит одного канала у ~1/64 непрозрачных пикселей.
|
|
// Детерминирован от seed профиля и номера пикселя: внутри профиля хеш canvas стабилен,
|
|
// между профилями — разный. Пустые (прозрачные) пиксели не меняются, иначе пустой canvas
|
|
// перестал бы быть пустым. Подменённые функции выглядят нативными для toString.
|
|
(() => {
|
|
const SEED = __SEED__ | 0;
|
|
const MAX_PIXELS = 4096 * 4096;
|
|
const fnToString = Function.prototype.toString;
|
|
const nativeSource = new WeakMap();
|
|
|
|
const noise = (data) => {
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (data[i + 3] === 0) continue;
|
|
let x = Math.imul((i >>> 2) ^ SEED, 0x9e3779b1);
|
|
x ^= x >>> 15;
|
|
x = Math.imul(x, 0x85ebca77);
|
|
x ^= x >>> 13;
|
|
if ((x & 63) === 0) {
|
|
const c = i + ((x >>> 6) % 3);
|
|
data[c] ^= 1;
|
|
}
|
|
}
|
|
};
|
|
|
|
const replace = (proto, name, make) => {
|
|
const desc = Object.getOwnPropertyDescriptor(proto, name);
|
|
if (!desc || typeof desc.value !== 'function') return null;
|
|
const orig = desc.value;
|
|
const impl = make(orig);
|
|
const fn = { [name](...args) { return impl.call(this, args); } }[name];
|
|
Object.defineProperty(fn, 'length', { value: orig.length });
|
|
nativeSource.set(fn, fnToString.call(orig));
|
|
Object.defineProperty(proto, name, { ...desc, value: fn });
|
|
return orig;
|
|
};
|
|
|
|
const toStringDesc = Object.getOwnPropertyDescriptor(Function.prototype, 'toString');
|
|
const toString = { toString() { return nativeSource.has(this) ? nativeSource.get(this) : fnToString.call(this); } }.toString;
|
|
nativeSource.set(toString, fnToString.call(fnToString));
|
|
Object.defineProperty(Function.prototype, 'toString', { ...toStringDesc, value: toString });
|
|
|
|
const ctxProto = CanvasRenderingContext2D.prototype;
|
|
const origGetImageData = replace(ctxProto, 'getImageData', (orig) => function (args) {
|
|
const img = orig.apply(this, args);
|
|
noise(img.data);
|
|
return img;
|
|
});
|
|
|
|
// toDataURL/toBlob: шумим копию, исходный canvas страницы не трогаем
|
|
const noisyCopy = (canvas) => {
|
|
const w = canvas.width, h = canvas.height;
|
|
if (!w || !h || w * h > MAX_PIXELS) return canvas;
|
|
const copy = document.createElement('canvas');
|
|
copy.width = w;
|
|
copy.height = h;
|
|
const ctx = copy.getContext('2d');
|
|
ctx.drawImage(canvas, 0, 0);
|
|
const img = origGetImageData.call(ctx, 0, 0, w, h);
|
|
noise(img.data);
|
|
ctx.putImageData(img, 0, 0);
|
|
return copy;
|
|
};
|
|
|
|
const canvasProto = HTMLCanvasElement.prototype;
|
|
replace(canvasProto, 'toDataURL', (orig) => function (args) {
|
|
return orig.apply(noisyCopy(this), args);
|
|
});
|
|
replace(canvasProto, 'toBlob', (orig) => function (args) {
|
|
return orig.apply(noisyCopy(this), args);
|
|
});
|
|
})();
|