docs · docs/30-core/FINGERPRINT_CONFIG_CONTRACT.md · synced 2026-09-10

Fingerprint configuration

the validated identity catalogue and its consistency rules

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.audioContext is now expressible from the product: crates/vflin-fingerprint’s model carries an optional media object and set_audio_noise / audio_noise_on flip it, mirroring the canvas pair. Until then the field was declared here and had no Rust field at all, so patch 0027 — 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.devices is still read by nothing, and §2c now says what closing it costs. A67 typed the model side and left the meaning of a preset to the surface slice; B59 measured 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:

  1. 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.
  2. 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 in enumerateDevices() 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.vendor must be plausible for os.family (no Apple GPU on Windows).
  • fonts.set must match os.family/os.version (no macOS fonts on Windows).
  • locale.timezone must be reachable from network.proxy geolocation (timezone↔IP coherence).
  • browser.uaCh must be derived from os + browser.version (never free-typed).
  • screen.dpr×base resolution must be a real device combo, not arbitrary.
  • screen.available must 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 on availTop 0 beside availHeight = 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.publicIp must equal the proxy’s egress IP; localIps masked.
  • network.tls (JA3/JA4) and network.http2 (SETTINGS/Akamai fp) are derived from os + browser.version and 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.validate returns { valid, issues[] } with a stable code per 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.seedminted 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 source in 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-level provenance.source. Schema v1 should model source: real|from-fp|custom per field (with real flagged 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 risky real case safe at the identity level — the WHOLE config is host-coherent by construction, so real no longer “fails §3 unless the host happens to be coherent” (it is, by construction). What remains v-next is the per-field granularity (mix a real GPU with a custom timezone in one config) and whether host-inversion should carry its own provenance.source (e.g. host-inverted) rather than reusing generated + realDeviceRef.