1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
// ---- Hardware fingerprint protection (deterministic, identity-seeded) ----
// Every value below is a pure function of (FP_SEED, sample index), so a persona's
// canvas and text metrics are stable across sessions AND identical when a page
// renders the same thing twice (FP libs compare two passes) — while differing
// from the host machine's real output and across identities.
//
// Every hook goes through PropertyModifier.spoofMethod so the replacement keeps a
// native shape; these exact methods (getImageData, toDataURL, toBlob, measureText)
// are the ones prototype auditors hash to attribute a fingerprint to a specific
// tool.
//
// Three surfaces are deliberately left alone, each documented at its former site:
// audio, WebGL render output, and element geometry. All three are checked against
// exact known-good values or exact inter-value relationships, so perturbing them
// is visible without buying meaningful unlinkability.
(function () {
var FP_SEED = ({{FP_SEED}}) >>> 0;
// Integer avalanche hash -> u32, deterministic for (seed, n).
function fpHash(n) {
var h = (FP_SEED ^ (n >>> 0)) >>> 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
return (h ^ (h >>> 16)) >>> 0;
}
function fpRand(n) { return fpHash(n) / 4294967296; } // [0,1)
function strHash(s) {
var h = 0x811c9dc5 >>> 0;
for (var i = 0; i < s.length; i++) { h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0; }
return h >>> 0;
}
var hook = PropertyModifier.spoofMethod;
// Perturb by ±1 on RGB, but ONLY on pixels that already differ from their left
// neighbour and are not fully transparent.
//
// This restriction is the whole ballgame. Noising unconditionally breaks two
// invariants that hold on every real GPU, and both are one-line probes:
// * a solid fillRect must read back perfectly uniform
// * an untouched canvas must be entirely zero
// Speckling either one is a far louder signal than the true canvas hash ever was.
// Antialiased glyph edges — where essentially all the fingerprint entropy lives —
// do vary from their neighbours, so the hash still moves off the host's value.
//
// The comparison uses the ORIGINAL neighbour value (tracked in prev*), not the
// possibly-already-noised buffer, so the result is independent of scan order.
// Canvases too small to carry hardware entropy are left untouched.
//
// Detectors use a tiny fixed render (a 2x2 with an antialiased arc) precisely
// BECAUSE it is low entropy — the result is identical on every machine of a
// given engine, which is what makes a known-good lookup table possible. Noising
// it therefore cannot help unlinkability, and can only move the value off the
// table, which is itself the signal. The real fingerprint lives in large text
// and emoji renders, which are well above this threshold.
var MIN_ENTROPY_PIXELS = 16 * 16;
function perturbImage(data, width, height) {
if (!width || !height) return;
if (width * height < MIN_ENTROPY_PIXELS) return;
for (var y = 0; y < height; y++) {
var pr = -1, pg = -1, pb = -1, pa = -1;
for (var x = 0; x < width; x++) {
var i = (y * width + x) * 4;
var r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
// Fully-opaque only. Canvas stores pixels premultiplied; getImageData
// un-premultiplies and putImageData re-premultiplies, and for alpha<255
// that round-trip is lossy — a forced bit would not survive it, so an
// encode/decode cycle would disagree with a direct read. At alpha 255 the
// conversion is exact. Fingerprint canvases draw text over a filled
// background, so the antialiased glyph pixels that carry the entropy are
// opaque blends and remain eligible.
// Compare with bit 0 masked out — the only bit this function writes.
// Eligibility must not depend on anything the transform itself changes,
// or re-application is not a no-op: with a raw compare, forcing pixel P
// can make its right-hand neighbour (previously equal to P, hence
// skipped) suddenly differ, so that neighbour becomes eligible on the
// SECOND pass only. A canvas is read back through two paths — direct
// getImageData, and encode/decode/draw/getImageData — and the second
// applies this twice, so the two disagreed by 1 on every such pixel.
// Real hardware round-trips PNG losslessly, which is a one-line probe.
var varies = x > 0 && (
(r & 0xFE) !== (pr & 0xFE) ||
(g & 0xFE) !== (pg & 0xFE) ||
(b & 0xFE) !== (pb & 0xFE) ||
a !== pa
);
if (varies && a === 255) {
// Force the low bit to a deterministic function of position instead of
// adding ±1. Both change the pixel by at most one level, but forcing is
// IDEMPOTENT: re-applying it is a no-op. That matters because a canvas
// can be read back twice through different paths — e.g. encode with
// toDataURL, decode, draw, then getImageData — and additive noise would
// accumulate, making the round-trip differ from the direct read. On real
// hardware a PNG round-trip is lossless and those two agree exactly.
//
// Idempotence needs BOTH halves: the write must be a no-op the second
// time (forcing, not adding) and the eligibility test must be blind to
// what the write changed (the mask above). Forcing alone is not enough.
data[i] = (r & 0xFE) | (fpHash(i) & 1);
data[i + 1] = (g & 0xFE) | (fpHash(i + 1) & 1);
data[i + 2] = (b & 0xFE) | (fpHash(i + 2) & 1);
}
pr = r; pg = g; pb = b; pa = a;
}
}
}
// ---- Canvas 2D ----
var Ctx2D = typeof CanvasRenderingContext2D !== 'undefined' ? CanvasRenderingContext2D : null;
var OffCtx2D = typeof OffscreenCanvasRenderingContext2D !== 'undefined' ? OffscreenCanvasRenderingContext2D : null;
// Capture the un-hooked readers so noisyClone() noises exactly once. Both are
// needed: getImageData brand-checks its receiver, so the HTMLCanvas one throws
// "Illegal invocation" on an OffscreenCanvasRenderingContext2D. That threw
// inside noisyClone, the catch below swallowed it, and convertToBlob then
// returned UNNOISED bytes while toDataURL returned noised ones — leaving the
// two encode paths disagreeing, which is exactly what a detector compares.
var origGetImageData = Ctx2D ? Ctx2D.prototype.getImageData : null;
var origOffGetImageData = OffCtx2D ? OffCtx2D.prototype.getImageData : null;
function readRaw(ctx, w, h) {
var reader = (OffCtx2D && ctx instanceof OffCtx2D) ? origOffGetImageData : origGetImageData;
return reader.call(ctx, 0, 0, w, h);
}
function hookGetImageData(proto) {
if (!proto) return;
hook(proto, 'getImageData', function (orig) {
return function getImageData() {
var img = orig.apply(this, arguments);
try { perturbImage(img.data, img.width, img.height); } catch (e) {}
return img;
};
});
}
hookGetImageData(Ctx2D && Ctx2D.prototype);
hookGetImageData(OffCtx2D && OffCtx2D.prototype);
// Encode a noised copy so the source canvas is never mutated. drawImage works for
// both 2D and WebGL source canvases, so this also covers WebGL toDataURL.
function noisyClone(canvas) {
var w = canvas.width, h = canvas.height;
var tmp = (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas)
? new OffscreenCanvas(w, h)
: document.createElement('canvas');
tmp.width = w; tmp.height = h;
var tctx = tmp.getContext('2d');
tctx.drawImage(canvas, 0, 0);
var img = readRaw(tctx, w, h);
perturbImage(img.data, w, h);
tctx.putImageData(img, 0, 0);
return tmp;
}
if (typeof HTMLCanvasElement !== 'undefined' && origGetImageData) {
hook(HTMLCanvasElement.prototype, 'toDataURL', function (orig) {
return function toDataURL() {
try { return orig.apply(noisyClone(this), arguments); }
catch (e) { return orig.apply(this, arguments); }
};
});
hook(HTMLCanvasElement.prototype, 'toBlob', function (orig) {
return function toBlob() {
try { return orig.apply(noisyClone(this), arguments); }
catch (e) { return orig.apply(this, arguments); }
};
});
}
// OffscreenCanvas has its own encode path; without this it reads back unnoised
// while the 2D context reads back noised, which is its own inconsistency.
if (typeof OffscreenCanvas !== 'undefined' && origOffGetImageData) {
hook(OffscreenCanvas.prototype, 'convertToBlob', function (orig) {
return function convertToBlob() {
try { return orig.apply(noisyClone(this), arguments); }
catch (e) { return orig.apply(this, arguments); }
};
});
}
// ---- WebGL render output (readPixels): deliberately NOT spoofed ----
// Removed after measurement. A shader writes whole colours, so the channels of
// a rendered pixel are locked to each other, and antialiasing scales them
// together. The standard probe renders `gl_FragColor = vec4(1,0,0,1)` into a
// 41x41 buffer and asserts `red === alpha` for every pixel — which holds on all
// real hardware, coverage included. Per-channel noise cannot survive that:
// moving red's low bit breaks the equality, and moving alpha to match means an
// opaque pixel reads back at 254, which is its own signal. Measured 25
// mismatches on the eligible pixels — the ones just inboard of an antialiased
// edge, where the pixel is fully opaque but differs from its left neighbour.
//
// Nor is much lost. That render is flat-shaded, so it carries almost no
// hardware entropy — the same reason the 2D path skips low-entropy canvases —
// and against a detector holding ground truth for the claimed GPU the render
// is already wrong, since the persona names one card while the pixels come off
// another. Noising makes the output match neither, which is worse than matching
// the real one: it is unique rather than merely inconsistent.
//
// The renderer/vendor STRINGS are still spoofed, in main_world.js.
// ---- AudioContext: deliberately NOT spoofed ----
// Removed after measurement. Detectors hold a table of known-good channel sums
// per compressor gain, and this machine's natural sum is already in it
// (124.04347527516074, one of 14 for its gain). Any perturbation lands between
// known values, and the value is then rendered as a character-level diff against
// the nearest one — the divergence is displayed digit by digit.
//
// The table is small precisely because audio carries little entropy: a handful of
// legitimate sums covers every machine on an engine. So noising it buys almost no
// unlinkability while making the value visibly non-standard, which is the same
// trade the low-entropy canvas skip refuses.
//
// Snapping to a *different* known sum would in principle give both — differing
// from the host while staying in the table — but `includes()` compares floats
// exactly, and scaling a buffer cannot reliably land a reduce() on a specific
// float. Not worth the fragility.
//
// Removing the hooks also removes what they dragged in: the rendered-buffer
// WeakSet, the startRendering hook, and the copyFromChannel hook that existed
// only to stay consistent with the noise.
// ---- Text metrics (font fingerprint) ----
// Perturbs absolute text metrics so metric-hash fingerprints differ from the host.
// The noise is applied on TextMetrics.prototype.width rather than as an own property
// on the returned object: natively `width` lives on the prototype, so a shadowing
// own property would make Object.getOwnPropertyNames(metrics) non-empty — a one-line
// tell. NOTE: this does not hide the installed-font *set* — equal-width fallbacks
// stay equal — which is not solvable from JS without breaking real rendering.
if (Ctx2D && typeof TextMetrics !== 'undefined') {
var metricNoise = new WeakMap();
hook(Ctx2D.prototype, 'measureText', function (orig) {
return function measureText(text) {
var m = orig.apply(this, arguments);
try { metricNoise.set(m, strHash('' + text + '|' + this.font)); } catch (e) {}
return m;
};
});
PropertyModifier.spoofAccessor(TextMetrics.prototype, 'width', function (real, self) {
var key = metricNoise.get(self);
if (key === undefined) return real;
return real + (fpRand(key) - 0.5) * 0.0004 * (real || 1);
});
}
// ---- Element geometry: deliberately NOT spoofed ----
// Removed after testing: DOMRect noise is reliably detected, and the checks use
// exact equality so there is no safe margin.
//
// * `right - left == width` (and bottom/top, right/x, bottom/y) is asserted on
// every rect. Noising width and right but not left means the two sides are
// computed by different float paths and disagree in the last ULP — flagged as
// "failed math calculation". Deriving all eight fields from one noised value
// does not fix it either, because float subtraction is not associative.
// * Two identically-sized elements must yield identical left/right. Any noise
// keyed on position breaks that ("equal elements mismatch"); noise not keyed
// on position is trivially averaged out.
//
// Real rect entropy comes from font metrics, zoom and devicePixelRatio, which are
// browser-level settings (Emulation.setDeviceMetricsOverride, font config). Set
// there, every rect stays internally consistent because the browser computed it.
})();