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.
| Reading | Setting it answers |
|---|---|
navigator.userAgent, platform, hardwareConcurrency, deviceMemory, maxTouchPoints, vendor, oscpu, productSub, buildID, appVersion | the settings of the same names |
navigator.language, navigator.languages | locale, languages (and accept_languages, derived: see below) |
Intl.DateTimeFormat().resolvedOptions().timeZone | timezone |
screen.width/height, availWidth/availHeight, colorDepth, devicePixelRatio | screen, avail_screen, color_depth, pixel_ratio |
WebGL UNMASKED_VENDOR_WEBGL, UNMASKED_RENDERER_WEBGL | webgl |
navigator.userAgentData and its high-entropy values | client_hints; no API reads Off |
enumerateDevices() counts | media_devices; no API reads Off |
getBattery(), navigator.connection, speechSynthesis.getVoices(), navigator.bluetooth | battery, 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#
capturestarts a browser assbrs opendoes (--headless,--browser, and the rest apply), opensURL(a blank page by default), and prints the fingerprint JSON, or writes it toFILE. A summary goes to standard error.--readings FILEalso writes the unmapped readings and the probe.exportwrites a preset or a seeded random identity (for--os,windows,macos,linux,androidorios; default this machine’s).checkloads a file and printsvalidate(): the errors, the warnings and the verdict. Exit status 2 when there is an error or the file does not load.diff A Bprints each differing setting and exits 1 when any differs, 0 when none does (Ais the expected side, as above).--fingerprint FILEapplies a file to every command that starts a browser. The same file can be named bySB_FINGERPRINTor by a[fingerprint]table (file = "...") in the settings file; the flag wins over the variable, which wins over the file. A fingerprint without--ucor--cdpwould 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 devtoolsapplies 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
StealthSessiontakes a fingerprint with.fingerprint(..); the core documents how to build one from a capture.