One coherent invented machine — the same one in the window, in every frame and in every worker.
English · Русский
Coherence is the whole point. A site does not identify you by any single value; it
identifies you by values that agree. Claiming a 1366×768 screen while the layout proves the
window is wider, or answering matchMedia from a profile while the CSS engine answers from
the real display, does not hide a machine — it produces a machine that cannot exist, and
that is rarer than the one you started with.
Every fix here carries a measurement, and every suite compares the result against a clean browser rather than against an assumption.
Who Am I — the machine as a site reads it. Every value on this page is the claim, not the host, and the fingerprint hash at the top is what a tracker would key on.
Not in the Chrome Web Store — load it unpacked.
- Download the zip from Releases and extract it. CI builds it from the commit the tag points at, and it contains the extension only: no dev pages, no suites, no generators.
- Open
chrome://extensions/and turn on Developer mode. - Click Load unpacked and point it at the extracted folder.
To run from source instead, point Load unpacked at a clone of this repository.
The extension's interface follows your browser's language, in English or Russian. The audit page, this README and the code comments are in English only.
The popup: country, per-site switches, device profile. |
Thirteen modules, switched one by one. |
The interface is in English and Russian and follows the browser's own language — there is no
switch to set. A key missing from a catalogue leaves the text that is already there and says
so on the console, because chrome.i18n.getMessage answers an unknown key with an empty
string and writing that into the page would blank the control rather than fail.
test/i18n.mjs opens the browser twice, --lang=en-US and --lang=ru, and requires the two
renderings to differ — without that, a build ignoring the locale passes every other check by
showing one language twice.
npm ci
npm test # the 9 Node suites — seconds, no browser
npm run test:all # adds the 48 Playwright suites — six to eight minutes57 suites in total. The Node half runs on every push and every pull request; it includes
test/parity-static.mjs, which re-runs both generators in memory and fails if
mw-bundle.js or dyn/ on disk are stale. The Playwright half loads the extension for real
in Chromium and is triggered manually, because its assertions are Windows facts — the ANGLE
renderer strings, the font list, outerHeight - innerHeight.
Two are worth naming. clean vs ours diffs a patched browser against an unpatched one and
fails on any difference nobody has judged. timezones vs ICU checks 941 timezone assertions
against the ICU that Node bundles, which is why the Node version is pinned.
The population reference in tools/crowd-reference.json is read off named public sources on
a named date — StatCounter for screen resolution, the Steam hardware survey for GPUs — with
each source's sampling bias written down beside it. Nothing there is interpolated or rounded
to taste. Where a number has no source, the tool prints a dash instead of guessing.
What this extension knowingly does not close. The rule for being on this list: a site can read it, and we know we cannot stop it — either the platform does not allow it, or closing it would cost more than the leak. The item numbers are referenced from the code and from the audit page, so they are not renumbered.
-
The machine is 2.6 bits. There are six table profiles. A site reading only hardware — screen, cores, memory, GPU — sees one of six machines.
-
Audio and text metrics are the host's.
OfflineAudioContextcomputes on this machine's real audio stack. Text metrics are noised; audio is not. -
WebGL readback is noised, not controlled. Noise is not substitution: a site holding a reference for this GPU sees "not that one", not "this other one".
-
The GPU is spoofed at the string level.
UNMASKED_RENDERER_WEBGLandGPUAdapterInfoanswer from the profile; anything actually computed on the card does not. -
A site's own service worker reads the real machine. Measured on one page: 16 cores against 18,
Europe/BerlinagainstEurope/Moscow. -
On a
trusted-typesorigin the machine becomes the host's — coherently. Creating a policy raises a violation that the browser builds in C++ from the real stack and names the extension in; JS cannot reach that. -
The screen becomes the host's when the window is wider than the claim. A screen cannot be smaller than the window, and one
100vwproves the lower bound. -
@mediaandmatchMediadisagree on several features.matchMediaanswers from the profile; the CSS engine answers from the real window, and the CSS engine is out of reach. The pixel-ratio half is closed since 2.5.27 — a claimed dpr the engine disproves is refutable in two lines, so the ratio yields to the host as the screen does above. -
The install window. For the first seconds after install or reload, dynamic DNR rules are not registered yet. The headers that leaked there —
accept-language, which is a country — have been moved into the static ruleset and are now closed. -
A canvas read by an inline script during parse gets the defaults, because the feature decision freezes on first use.
-
Window geometry before the browser knows it. In the first inline script
outerWidth/outerHeightare0. We pass that0through, exactly as a clean browser does. -
Exit country is the one axis internal coherence cannot see. Every suite asks whether the build contradicts itself. None can ask whether the claimed country matches the network the traffic actually leaves from.
-
First visit to an origin that refuses blob workers. Every worker this extension patches is built from a blob; an origin whose CSP bars
blob:rejects that construction. -
TLS and HTTP/2 are out of reach. Cipher order, TLS extensions, ALPN, curves, the HTTP/2 SETTINGS frame — JA3/JA4 and the h2 fingerprint are formed before the page gets a byte, and Cloudflare and Akamai read them as a matter of course.
-
The WebRTC decoder and codec list are the graphics card, not a table.
decodingInfo()answerspowerEfficientfrom whether the real card has a hardware decoder;getCapabilities('video')listsvideo/H265only where the hardware does HEVC. -
A WebGL warning about an unknown constant names us. Chrome attributes
INVALID_ENUMto the nearest script frame, which is our wrapper. -
The marker names are fixed.
'__t0' in windowanswers yes for this build and no for a clean browser, in the top document and in frames, and__AFP_PATCH_URLis the worker’s equivalent. They are non-enumerable, so a name diff against a fresh iframe does not show them — but a constant anyone can guess once needs no diff. Deriving them per site is blocked by the markers being set before the seed exists and by the in-browser checks that read them. There were two names on the window until 2.5.27; the second is a field of the first now. Hiding either was measured as worse than owning one fewer — a clean window has zero own symbols, so a symbol key makes the count anomalous andSymbol.keyForhands the name back, while hiding from enumeration alone makes reachable, listed andindisagree, which no browser does for any name. -
The Intl locale and
navigator.languageanswer to different switches. The language claim belongs to the navigator module; the window's Intl locale is installed under the timezone one. With navigator off the locale follows it back to the host, but the mirrored case is open: with the timezone module off no Intl wrapper is installed at all, so the window answers with the machine's locale while a worker answers with the profile's. -
The claimed core count is a number; parallelism is behaviour.
navigator.hardwareConcurrencyanswers with the profile while the machine still runs as many workers at once as it really has. An extension cannot refuse the ninth the way an eight-core processor would — the scheduler belongs to the engine. -
The claimed memory size is not backed either.
navigator.deviceMemoryreports the profile's bucket while a page can allocate and watch for the bend that never comes. Same reason: the allocator belongs to the engine. Both of these are silent, need no permission, and are readable by any page that thinks to look. -
One
sessionStoragekey is visible. The per-document Trusted Types verdict lives atv.ui.tte. It claims nothing about the machine — it decides who is named in a refusal — but on a fresh origin a clean browser has no keys at all, sosessionStorage.lengthreading 1 instead of 0 finds it without guessing the name. It stays because it has to survive navigation: a previous document's verdict for the same route is what keeps the proxies from installing on a repeat load.
The extension's own audit page carries the same list beside its verdict, because a green verdict is only ever as broad as the questions asked.
node tools/probe-diff.mjs # what the build CHANGES against a clean browser
node tools/probe-time.mjs # what changes over TIME and does not for a clean browser
node tools/probe-crowd.mjs # the size of the crowd you land in
node tools/diff-metrics.mjs # two machines: what differs between them
node test/hostleak.mjs # host values that make it throughnpm run build # regenerates dyn/ and mw-bundle.js
node tools/pack.mjs # builds dist/ — the extension only, 243 files
node tools/shots.mjs # retakes the screenshots above from the running extensionmw-bundle.js is generated from the eleven modules in mw/. Edit the modules, not the
bundle; npm test fails if the bundle on disk is stale.
Bug reports and pull requests are welcome — see CONTRIBUTING.md. Found a security problem? Report it privately rather than in a public issue.
MIT.

