Skip to main content

Fingerprint capture, export and import#

Status: implemented, listed under ## Unreleased in CHANGELOG.md. It builds on Fingerprint API v2 and adds no setting to it.

Why#

A fingerprint says what a browser should claim. The claim that is hardest to get wrong is one measured on a real browser, and until now the only ways to get a fingerprint were to write the values, to draw them, or to take a preset. This adds the other direction: read what a real browser shows a page, keep it as a Fingerprint, and hand it back to any launch path.

One file format#

There is one file format everywhere: the v2 Fingerprint JSON of Fingerprint API v2. A capture writes it, export writes it, check, diff and --fingerprint read it, and the companion extension validates it with schemas/fingerprint.schema.json. CapturedFingerprint is a Rust value that carries the readings next to the fingerprint; it is not a second file format.

What a capture reads#

One embedded script (src/stealth/js/capture.js) reads, in the page, what a detection script could read. The same script runs on both engines: the pure CDP engine evaluates it as an async function (Page::capture_fingerprint), the WebDriver engine runs it as an asynchronous script (BaseCase::capture_fingerprint). Rust maps the answer in stealth::capture, in one place, so both engines produce the same fingerprint from the same readings.

ReadingSetting it answers
navigator.userAgent, platform, hardwareConcurrency, deviceMemory, maxTouchPoints, vendor, oscpu, productSub, buildID, appVersionthe settings of the same names
navigator.language, navigator.languageslocale, languages (and accept_languages, derived: see below)
Intl.DateTimeFormat().resolvedOptions().timeZonetimezone
screen.width/height, availWidth/availHeight, colorDepth, devicePixelRatioscreen, avail_screen, color_depth, pixel_ratio
WebGL UNMASKED_VENDOR_WEBGL, UNMASKED_RENDERER_WEBGLwebgl
navigator.userAgentData and its high-entropy valuesclient_hints; no API reads Off
enumerateDevices() countsmedia_devices; no API reads Off
getBattery(), navigator.connection, speechSynthesis.getVoices(), navigator.bluetoothbattery, connection, speech_voices, bluetooth; an API that is not there reads Off

An answered setting is a Value (or Off for an API that is not there). A setting the page could not answer stays Natural. The persona comes from the user agent (os_type, browser_type) and core_version from its Chrome major, so the fingerprint claims the Chrome that was measured. The policy is StealthPolicy::balanced(), as in every preset.

accept_languages cannot be read by a page, but Chrome builds the header from navigator.languages by a fixed rule (en-US,en;q=0.9), so a Chromium capture sets it by that rule. Without it the user agent override would send the locale alone, a tell. It is the one derived setting, and compare counts it as unverifiable.

The readings that no setting holds go to CapturedFingerprint::unmapped: window sizes, navigator.webdriver, the plugin and mime-type counts, permission states, a canvas hash, an audio hash and the headless markers. They are for people and for checks, never applied.

Library#

save writes a temporary file in the target folder, flushes it and renames it over the target, so a reader never sees half a file; the file is private to its owner (mode 0600), because a fingerprint may hold proxy credentials. load refuses a file above 4 MiB.

diff and compare#

a.diff(&b) reads a as what is expected and b as what is found. A setting differs when a gives it (anything but Natural) and b holds another. A setting a leaves Natural is never compared: the browser’s own value agrees with any value. Language lists compare without their quality values (en-US,en;q=0.9 equals en-US,en), a WebGL half that a leaves out is not compared, and media devices compare by count.

compare_to is the check “does this live page present this fingerprint”: capture, then diff, with two differences. A setting the fingerprint claims that the capture could not answer (accept_languages, geolocation, fonts, proxy, webrtc, and any reading that failed) is listed as unverifiable, never as a match. And the battery and the connection compare by presence, because their numbers move. FingerprintComparison::presents() is true when nothing differs, and is_verified() when nothing differs and nothing is unverifiable.

The browser extension#

The Page tab of the companion extension (seleniumbase-rs-companion) writes what the open page shows as a fingerprint file and reads one back; it is the same v2 JSON, so a file moves in both directions: sbrs fingerprint capture reads a browser a script starts, the extension reads the browser a person uses, and either file goes to check, diff and --fingerprint. The extension writes only the settings the page answered (a missing reading is left out, not null) and only keys this crate knows, which is why a file with another key is refused here and not ignored. tests/fingerprint_companion.rs loads a file written the way the extension writes it (tests/fixtures/fingerprint/companion_page_tab.json, pinned on the extension’s side by its tests/fingerprint.test.ts), so a change to either format fails a test.

A page that is not a secure context (about:blank, a data: URL, plain http) cannot answer the settings whose APIs need one (client_hints, media_devices, battery, bluetooth); capture lists them as not answered. Give it an https:// page to read them.

JSON Schema#

schemas/fingerprint.schema.json is generated from the types with schemars (a dev-dependency; the derive is on only in the library’s own tests). A test writes the schema in memory and compares it with the file, so a change to a setting that leaves the file stale fails; SB_UPDATE_SCHEMAS=1 cargo test -p seleniumbase-rs --lib schema rewrites it. Every preset and a drawn identity are validated against the schema in the same test.

Command line#

  • capture starts a browser as sbrs open does (--headless, --browser, and the rest apply), opens URL (a blank page by default), and prints the fingerprint JSON, or writes it to FILE. A summary goes to standard error. --readings FILE also writes the unmapped readings and the probe.
  • export writes a preset or a seeded random identity (for --os, windows, macos, linux, android or ios; default this machine’s).
  • check loads a file and prints validate(): the errors, the warnings and the verdict. Exit status 2 when there is an error or the file does not load.
  • diff A B prints each differing setting and exits 1 when any differs, 0 when none does (A is the expected side, as above).
  • --fingerprint FILE applies a file to every command that starts a browser. The same file can be named by SB_FINGERPRINT or by a [fingerprint] table (file = "...") in the settings file; the flag wins over the variable, which wins over the file. A fingerprint without --uc or --cdp would reach a Chromium page through its launch arguments only, so the flag selects the CDP mode for a Chromium browser when no mode was asked for. sbrs devtools applies it through the pure CDP launch options.

To check that a browser presents a file: sbrs --fingerprint f.json capture -o seen.json, then sbrs fingerprint diff f.json seen.json.

Not done#

  • A capture reads one page in one browser at one moment; it is not proof that the fingerprint is undetectable. The canvas and audio hashes are readings, and the claims of the policy (canvas noise, say) change them when the fingerprint is applied.
  • Fonts, WebRTC addresses, the position and the proxy are not read. A permission prompt, a network request to a service and a font-measurement sweep were left out on purpose: each would change what is being measured or show the measurement.
  • The seleniumbase-rs-stealth repository gets no code from this change. Its StealthSession takes a fingerprint with .fingerprint(..); the core documents how to build one from a capture.