Purpose: define fingerprints as a structured, validated catalog with consistency rules, not random JSON. This contract is implementable now (schema + validation) even though deep engine enforcement waits for the patched core. The same config drives every provider.
1. Why a contract first
Detection is driven by incoherence between signals, not by individual “bad” values. So the config’s job is less “pick random values” and more “guarantee a coherent device identity”. The catalog is the brain (what to present); the Core Provider is the hands (how).
2. Config schema (v0)
{
"id": "uuid",
"label": "win11-chrome-148-desktop",
"os": { "family": "windows", "version": "11", "arch": "x86_64" },
"browser": { "engine": "chromium", "version": "148.0.x", "uaCh": { /* derived, see §4 */ } },
"hardware": { "cores": 8, "memoryGb": 16, "gpu": { "vendor": "...", "renderer": "..." } },
"screen": { "width": 1920, "height": 1080, "dpr": 1.0, "colorDepth": 24,
"available": { "left": 0, "top": 0, "width": 1920, "height": 1032 } },
"locale": { "language": "en-US", "languages": ["en-US","en"], "timezone": "Europe/Berlin" },
"fonts": { "set": "win11-default", "additions": [], "removals": [] },
"media": { "audioContext": "coherent-noise", // read by patch 0027 (A67: expressible since 2026-08-31)
"devices": "win11-typical" }, // DECLARED, READ BY NOTHING — see §2c for why
"webrtc": { "policy": "proxy-only", "publicIp": "from-proxy", "localIps": "masked" },
"canvas": { "mode": "stable-noise", "seed": "per-profile" },
"webgl": { "vendorMatchesGpu": true, "noise": "stable" },
"network": { "proxyRef": "uuid", "quic": true,
"tls": { "ja3": "derived", "ja4": "derived" }, // match claimed browser
"http2": { "settings": "derived", "akamaiFp": "derived" } },
"provenance":{ "source": "catalog|generated|imported", "realDeviceRef?": "..." }
}
2a. screen.available — the available rectangle
screen.avail{Left,Top,Width,Height}, resolved by the catalog and carried whole. Dotted paths, for a
consumer reading the JSON: screen.available.left · .top · .width · .height.
All four or none. The field is one object rather than four keys so that a half-filled rectangle cannot be written; absent means “not carried” — a config minted before this field existed — and a consumer must fall back to its own derivation rather than reading absent as zero.
Where the numbers come from. vflin-fingerprint’s validate::SCREEN_INSETS holds the OS chrome as
per-edge insets with provenance per row (Measured / Assumed, same discipline as SCREEN_MODES),
and validate::available_rect resolves them against the display. Windows reserves at the BOTTOM (48 CSS
px on 11 — measured on a real host, 40 on 10); macOS reserves 25 at the TOP (the menu bar, Dock modelled
as auto-hidden); Linux 27 at the top. Under host-inversion (ADR-0031) the SIZE comes from the host’s own
availWidth/availHeight when the display being claimed is the host’s; the ORIGIN still comes from the
catalog, because a capture does not read availLeft/availTop.
Why it is a rectangle and not a delta. A single “reserve N px” cannot say which edge the N px are
on. Patch 0011 held exactly that — four literals in chrome/browser/vflin/fp_config.cc
(AvailHeightDeltaForOsFamily) subtracted from screen.height with the origin forced to (0, 0) — so a
modelled Mac reserved its menu bar and reported it along the BOTTOM of the screen. Hardcoded identity
values are what invariant 5 forbids; this is where they now live.
2b. media.audioContext and canvas.mode — one field, one surface
State of play, corrected by
A67(2026-08-31).media.audioContextis now expressible from the product:crates/vflin-fingerprint’s model carries an optionalmediaobject andset_audio_noise/audio_noise_onflip it, mirroring the canvas pair. Until then the field was declared here and had no Rust field at all, so patch0027— shipped and working — could only be reached by hand-written JSON, which is how two sessions proved the patch while the gap stayed invisible. Absent means off, and the product default is off (the owner’s instruction: available, not default-on).
media.devicesis still read by nothing, and §2c now says what closing it costs.A67typed the model side and left the meaning of a preset to the surface slice;B59measured that surface and found the mechanism cannot be built at the enumeration layer alone. The field stays declared and unread, which is honest, rather than half-implemented, which would not be.
Each of these two fields enables exactly one surface, and neither decides for the other. That
sentence was false until patch 0027 (2026-08-30) and is worth stating because of how it failed.
0022 (audio-noise) shipped gated on canvas.mode == "stable-noise", so the switch named
canvas also turned on the Web Audio perturbation — while media.audioContext, declared in the
schema above and named as 0022’s config key in the series plan, was read by no parser arm at
all. The documented control did nothing; an undocumented one did the work. MEASURED by K10
outside CreepJS with a bare OfflineAudioContext: sample 4500 read -0.14573481678962708 with the
canvas switch off — stock to the last digit — and -0.14574481546878815 with it on, from a config
carrying no audio field. CreepJS billed it separately as AudioBuffer: "audio is fake".
Since 0027, audio noise is enabled by media.audioContext: "coherent-noise" and by nothing
else. Proven live (B55): with canvas.mode: "stable-noise" and no audio field the sample is
-0.14573481678962708, identical to stock; with media.audioContext set and canvas.mode: off
it moves to -0.14572481811046600.
The seed is still shared, and that is deliberate. The audio perturbation is keyed on
canvas.seed folded under a disjoint "audio" domain, so the two features never share a key — two
features on one key would be a cross-feature correlation a detector could exploit. Residual:
audio therefore cannot be enabled without a canvas.seed present to fold. Giving audio a seed of
its own is a schema change and has not been made.
Still advertised and still unread: media.devices. B59 attempted it and withdrew the patch; §2c
records what it measured and what closing the surface actually requires.
2c. media.devices — measured, attempted, and why it is still unread
B59 built the mechanism, measured it working, and withdrew it. This section exists so the next
attempt starts from what that cost rather than from the schema line.
What the surface actually is. Before permission Chrome exposes at most one entry per kind,
with deviceId, groupId and label blanked — media_devices_manager.cc:1183 breaks out of the
per-kind loop when permission is absent. So the only thing a page can read without asking is which
kinds exist; a count cannot be read back, and emitting several entries for one kind would be a tell
no real browser produces.
What was measured on this host (B59, M149, and each leg twice):
| leg | result |
|---|---|
| our core, no identity | 2 devices — {videoinput, audiooutput}, no microphone |
| our core, with an identity | identical — the config moved nothing |
| stock Chrome 152 | identical |
So this was never a divergence from stock. It is that every profile on one machine enumerates that machine, which links them to each other exactly as the voice list (ISSUE-063) and the WebGL capabilities did.
Why the patch was withdrawn. Two forms were built and both were incoherent, and the sixth gate found each:
- Substituting the list, pre-permission only. The moment a grant revealed a real device id the projection switched off and the same origin saw a different machine than a moment earlier. It also repopulated a list Chromium had already filtered by Permissions-Policy, so a document denied the microphone could be handed one.
- Filtering only — removing kinds, applied always. Coherent across permission and invents
nothing, and it measured correctly (a narrowing claim removed the camera; the control kept the
host set; a claimed-but-absent microphone was correctly not invented). But
getUserMedia()never consults it: a site sees no camera inenumerateDevices()and then captures video from the host’s camera. Hiding a kind from enumeration while capture still yields it is a single-session self-contradiction, and hiding is the only thing that form can do.
What closing it requires. Enumeration and capture have to move together — enumerateDevices(),
getUserMedia() and selectAudioOutput() filtered by one decision, which means the projection
belongs in the browser process beside MediaDevicesManager, not in Blink’s result callback. Adding a
kind the host lacks additionally requires synthesising a device with an id and, after permission, a
label, so it needs provenanced device names — catalog data under ADR-0035, not an engine guess.
Until then the field stays declared and unread, which is the honest state; the alternative on offer was a mechanism whose only function created a contradiction sharper than the linkability it removed.
3. Consistency rules (the validator)
Validation FAILS (not warns) when signals contradict. Examples:
webgl.gpu.vendormust be plausible foros.family(no Apple GPU on Windows).fonts.setmust matchos.family/os.version(no macOS fonts on Windows).locale.timezonemust be reachable fromnetwork.proxygeolocation (timezone↔IP coherence).browser.uaChmust be derived fromos+browser.version(never free-typed).screen.dpr×base resolution must be a real device combo, not arbitrary.screen.availablemust fit the display (C6_AVAIL_BOUNDS,C6_AVAIL_EMPTY) and put its reserve on the edge the claimed OS reserves from (C6_AVAIL_RESERVE_SIDE). The second is the load-bearing one: an ordering rule passes onavailTop 0besideavailHeight = height − 25, because two correctly ordered numbers can still describe a device that does not exist. The MAGNITUDE is deliberately not pinned — a real host’s Dock, taller taskbar or auto-hidden menu bar is realistic by construction under ADR-0031, and refusing one would be a false negative.webrtc.publicIpmust equal the proxy’s egress IP;localIpsmasked.network.tls(JA3/JA4) andnetwork.http2(SETTINGS/Akamai fp) are derived fromos+browser.versionand must match the claimed browser — a Chrome-148 identity emits a Chrome-148 handshake, not a Chromium-default or proxy-rewritten one. (Suite C10/C11.)fingerprint.validatereturns{ valid, issues[] }with a stablecodeper rule.
4. Derivation, not free-entry
Several fields are computed to stay coherent: UA string + UA Client Hints from os+version;
languages[] from primary language; colorDepth/dpr from a device profile. The UI exposes
high-level choices (device class, region); the catalog derives the rest.
5. Sources of values
- Catalog presets: curated, coherent device profiles (win11-chrome-desktop, macos-safari-…).
- Generated: constrained generation (e.g. Apify Fingerprint Suite as a value source), then run through our validator. (We use it for “what”; we apply via the core for “how”.)
- Imported / real-device-derived: highest trust; coherence by construction. (Vision/Afina’s “real device fingerprints, not generated” maps here.) Now built for the local tier by host-inversion — see §5a: a node’s device-hardware dims are taken from its OWN real host, not a synthesized guess.
Sourcing strategy (the realism differentiator — blocker before Phase 2). Generated combos are
plausible but not real; Vision/Afina win on real-device truth, so the catalog’s data source is a
load-bearing decision, not a footnote. Options, to be settled in an ADR before Phase 2:
- Real-device collection — an opt-in agent / small device fleet emits coherent signal bundles (no PII): real GPU+OS+screen+font+net combos → highest realism; ongoing cost; must cover the OS / GPU / screen / locale matrix and refresh as devices update. (This central-pool variant is now the deferred B-fleet tier; B-local host-inversion — §5a, ADR-0031 — ships the real-device tier WITHOUT a pool, by taking each node’s own host, so the dataset-coverage cost dissolves for local use.)
- License a dataset — fastest start; recurring cost; coverage/quality varies; ToS risk.
- Generate + validate (Apify) — cheap breadth; lower trust; fine for low-stakes profiles. Decided (ADR-0012, Accepted 2026-06-18): foundation = curated catalog + constrained Rust generator now (browserforge as a later breadth upgrade behind this schema); a real-device collection pipeline for the high-trust tier grows later, gated by its own privacy/consent ADR. Every identity is trust-scored by provenance (§6); preset refresh must track Chrome stable (presets age fast). → ADR-0012 + CATALOG_SOURCING_COMPARISON.md.
Realism prior — how the constrained generator draws (ADR-0012 realism prior, Proposed 2026-07-01). The
foundation generator no longer draws uniform-random over the flat tables; it draws a frequency-weighted
JOINT over a hand-encoded coherence graph (os → chrome_major → gpu → {cores, ram, screen};
region → tz/lang), deterministically from the per-profile root seed (same seed ⇒ same tuple; different
seed ⇒ usually different). The GPU carries its own plausible hardware/screen sub-tables, so
jointly-impossible combos (a laptop iGPU on a desktop {16-core, 32 GB, 4K} tuple) are unrepresentable — the
edges are encoded, not reconstructed from marginals. This refines §4 (the generator picks coherent and
plausible-frequency combinations) without changing the §2 schema or the §3 validator: weights are
generation-time const metadata, not serialized fields, and every draw still passes the validator by
construction. See ADR-0012 realism prior (internal: docs/10-architecture/adr/0012-realism-prior-weighted-joint.md).
5a. Host-inversion — the real-device tier, by construction (ADR-0031, B-local)
The “real-device-derived” source (§5) is built for the local tier, but by inversion, not
collection: instead of a central dataset/fleet emitting signal bundles, each node characterizes its OWN
host (the capture-profile harness writes a HostFingerprint cache) and a profile’s device-hardware
dims are taken from that real host — coherent by construction, nothing synthesized. Because the values are
real (not plausible-guesses), the realism-prior tell and the WebGPU↔WebGL cross-API GPU leak (suite A8)
both dissolve. Only the free dims vary per profile. The split (as host_coherent_config builds it):
Host-real (from the captured HostFingerprint) |
Free (per-profile — from the request / minted) |
|---|---|
os.family/arch (navigator.platform + UA-CH arch)² |
screen.width/height/dpr/colorDepth — a headed/free dim¹ |
browser.version + derived ua/uaCh (host UA’s Chrome/…) |
locale.timezone · language · derived languages[] |
hardware.cores (hardwareConcurrency) · memoryGb (deviceMemory) |
network.proxyRef |
hardware.gpu.vendor/renderer (WebGL UNMASKED — the real GPU) |
canvas.seed — minted fresh per profile (see below) |
fonts.set — the real OS’s default set (not a capture) |
id — minted per profile |
network TLS/HTTP2/QUIC are the running core’s by construction (ADR-0027 — measured, not spoofed), so
they match the host browser automatically. provenance.source = generated with
realDeviceRef = "hostinv:<platform>:<renderer>" linking the config back to the host.
Per-profile uniqueness is a coherence rule, not a nicety. host_coherent_config emits a fixed
placeholder canvas.seed; every create path snapshot()s the result → a fresh per-profile canvas/WebGL
seed (and id). Without it, two profiles derived from the SAME host would share one seed — a cross-profile
linkage tell. So the snapshot is mandatory on the host path (as it already is for catalog/inline).
Three create-time identity sources. profile.create (and fingerprint.generate*) take at most one:
a catalog fingerprintRef, an inline validated fingerprint, or fromHost (host-inversion —
the free dims only; device dims come from the host cache). More than one ⇒ 400. All three are snapshot()ed
and pass §3 by construction. An explicit fromHost on an un-characterized device hard-fails
(NOT_FOUND) — it must not silently degrade to an identity-less profile — whereas the implicit path (no
identity requested) is simply None.
¹ Honest residuals: the bootstrap capture is headless, so it can’t read the real monitor (its 800×600
fails §3 C6) → screen is supplied per-profile / by a headed capture (pending). B-fleet (a central
real-hardware pool) and a per-field source: real|from-fp|custom toggle (host-inversion is
identity-level today — the whole config is host-derived, not signal-by-signal) remain v-next.
² os.version is host-real (since 2026-07-10, ISSUE-031 c): HostFingerprint carries the captured
ua_platform_version (the OS-version carrier — navigator.platform gives only the family), and
host_coherent_config derives os.version from it (Win10 platform-version major 10 → "10", Win11 major ≥13 →
"11"; an empty/old cache falls back to the family default). So a Windows 10 host now emits "10", not "11".
The UA-CH platformVersion is likewise host-real (ISSUE-031 c2, since 2026-07-10): the config emits the host’s
EXACT value (e.g. Win11 "19.0.0"), NOT a fleet-shared forward-map constant — every Win11 profile sharing one
Sec-CH-UA-Platform-Version was itself a linkage tell. The C1 validator checks it for STRUCTURED per-OS
plausibility (platform_version_plausible: Windows "<major>.0.0" with Win11 major ∈ 13..=40 / Win10 == 10; macOS
"<major>.<minor>.<patch>" major == os.version; Linux empty) instead of exact-equality, so a real host value
passes while a macOS-shaped / out-of-range / malformed value is still rejected; an anomalous capture falls back to
the forward map. browser.version is likewise host-real — parsed from the host UA’s Chrome/….
See ADR-0031 host-inversion (internal: docs/10-architecture/adr/0031-fingerprint-by-host-inversion.md) (device dims by
host-inversion) and ADR-0027 network-by-inversion (internal: docs/10-architecture/adr/0027-network-fingerprint-by-inversion.md)
(TLS/HTTP2/QUIC by construction).
6. Trust scoring
Each materialized identity gets a trust score from: provenance, validator result, and the
running core’s capabilities (how much it could actually enforce). Low-trust launches are
flagged, not silently shipped.
UI note (design): this is not a vanity per-profile number — validated profiles on a capable core are coherent (they’d all read ~max). It surfaces in the grid only as a coherence warning flag when a profile can’t be made coherent (weak core, proxy/timezone mismatch). The score itself (driven mostly by provenance × core capability) lives in the fingerprint detail, not as a column.
7. Relationship to patches
Patch authors (Phase 7) implement enforcement for fields in this schema. The schema is the stable interface between “config” and “C++ patch” — patches read config, never invent values. (CHROMIUM_PATCHING_POLICY.md §1.4, §7.)
8. Editing, one-click generation & templates (UX bar)
- 50+ granular params, each adjustable — the editor exposes the full schema (§2) down to individual signals; each field supports Real ↔ From-fingerprint ↔ Custom (Vision pattern). Depth matches Vision/Afina (50+ tunable parameters), but every value still passes the §3 validator.
- One-click smart generate — a single button produces a coherent, validated identity from a device class + region (uses §5 generation + the §3 consistency rules). “Smart” = it picks plausible combinations (GPU↔OS, fonts↔OS, timezone↔IP, TLS↔browser), never random; the result passes the consistency suite by construction, so an operator gets a clean profile in one click.
- Templates — save any config (fingerprint + proxy policy + extensions + home pages + bookmarks) as a reusable template; bulk-create N profiles from a template + a dataset (AUTOMATION_ENGINE §4). Catalog presets (§5) are read-only templates; user templates are editable copies.
Open questions
Where the curated catalog comes from (build vs license vs collect), how often presets refresh with Chrome releases, trust-score formula → OPEN_QUESTIONS.md.
- Per-field
sourcein the schema (UI-surfaced, 2026-06-21). §8 + DESIGN_DIRECTION give each signal a Real ↔ From-fp ↔ Custom toggle, but the §2 schema v0 only carries identity-levelprovenance.source. Schema v1 should modelsource: real|from-fp|customper field (withrealflagged risky — it leaks the host device, fails §3 unless the host happens to be coherent) so the toggle is contract-backed and the core knows, per signal, whether to read the host value, the catalog value, or a validated custom override. Surfaced by the fingerprint slide-over mockup. Update (ADR-0031, 2026-07-04): host-inversion (§5a) makes the riskyrealcase safe at the identity level — the WHOLE config is host-coherent by construction, sorealno longer “fails §3 unless the host happens to be coherent” (it is, by construction). What remains v-next is the per-field granularity (mix arealGPU with acustomtimezone in one config) and whether host-inversion should carry its ownprovenance.source(e.g.host-inverted) rather than reusinggenerated+realDeviceRef.