feat: initial public release

Stealthfox — a patched Firefox 150.0.1 for browser-fingerprint stealth,
shipped as a Playwright-compatible Python wrapper.

  * Sync + async Stealthfox launcher (firefox_user_prefs, virtual desktop
    on Windows, SOCKS5 auth via patched nsProtocolProxyService)
  * fpforge: Bayesian fingerprint sampler over GPU / audio / fonts /
    screen / TCP options / ~400 other navigator fields
  * WebRTC stealth: srflx address swap, synthetic srflx fallback,
    private-LAN host candidates — no real public IP leak via STUN
  * GPU sandbox fix for FF150 alt-desktop regression
  * Bezier-curve mouse motion baked into Juggler

Targets Windows x86_64 + Linux x86_64. Binary fetched on first run from
GitHub Release "firefox-1".
This commit is contained in:
feder-cr 2026-05-12 21:34:14 -07:00
commit 60e55491ea
51 changed files with 10967 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.pytest_cache/
.venv/
firefox-source/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 stealthfox contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

226
README.md Normal file
View file

@ -0,0 +1,226 @@
# stealthfox
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Firefox 150.0.1](https://img.shields.io/badge/firefox-150.0.1-orange.svg)](https://www.mozilla.org/firefox/)
[![GitHub release](https://img.shields.io/github/v/release/feder-cr/stealthfox.svg)](https://github.com/feder-cr/stealthfox/releases)
[![GitHub stars](https://img.shields.io/github/stars/feder-cr/stealthfox.svg?style=social)](https://github.com/feder-cr/stealthfox/stargazers)
A patched Firefox **100% Playwright-compatible** that passes the hardest browser-fingerprint detectors in the wild.
## Results
These are the "best" outcomes observed across independent runs on residential proxies.
### Google reCAPTCHA v3 - **0.90 / 1.0**
Top-tier score. Google classifies the session as "very likely a human". Most anti-detect stacks plateau around 0.3-0.7.
![reCAPTCHA score 0.90](docs/screenshots/recaptcha_score.png)
### Fingerprint Pro - **bot: not detected, VPN: false, tampering: false, dev tools: not detected**
FingerprintJS Pro's full Smart Signals battery flips every flag to "Not detected". Browser correctly identified as Firefox 150 on Windows 10. Confidence score 0.9.
![FingerprintPro not detected](docs/screenshots/fingerprintpro.png)
### CreepJS - **0 lies**, fingerprint is internally coherent
No contradictions between headless hints, spoofed values, and real rendering output. That "0 lies" is what kills most anti-detect browsers: one inconsistency (e.g. Chrome UA + Firefox WebGL) and the trust score collapses.
![CreepJS 0 lies](docs/screenshots/creepjs.png)
### BrowserLeaks WebRTC - **no public IP leak**
WebRTC srflx address is the proxy egress IP; host candidates are private LAN. The real public IP never leaks via STUN, even on pages that configure their own ICE servers. Stock Firefox leaks the real local IP via WebRTC mDNS - stealthfox doesn't.
![WebRTC no leaks](docs/screenshots/webrtc.png)
### bot.sannysoft.com - **all checks pass**
Every row green: WebDriver not present, Chrome-only properties absent, plugin/mime/languages arrays coherent, permissions API correct, iframe/source window checks pass.
![Sannysoft all green](docs/screenshots/sannysoft.png)
---
## Why it's powerful
**Most anti-detect browsers patch Chromium at the JavaScript level** - they override `navigator`, `WebGLRenderingContext.getParameter`, canvas APIs, and so on via injected scripts. This has two fatal problems:
1. **JS patches are detectable.** Anti-bots enumerate native function `.toString()`, check descriptor configurability, compare property enumeration order, watch for prototype mutations. Every patch leaves a fingerprint of its own. CreepJS has an entire battery of "lies detectors" built around this.
2. **Chromium itself is now suspect.** Residential-proxy bot traffic is overwhelmingly Chromium-based, so detectors weight anything Chromium-shaped as risky by default. And the parts that matter (TLS stack, renderer process) are not fully open-source in Chrome proper - forks either inherit all Chromium tells or drift in visible ways.
**stealthfox patches Firefox at the C++ level.** The spoofed values come back out through the normal Gecko paths - there is no JS shim, no override, no `Object.defineProperty`. **From the page's point of view, the browser is just telling the truth.** Anti-bot lie-detectors have nothing to latch onto.
stealthfox spoofs **all the layers that matter, together, coherently**:
| Layer | What we do | Why it matters |
|-------|-----------|-----------------|
| Navigator / hardware | C++ overrides: UA, oscpu, languages, hardwareConcurrency, deviceMemory, storage quota | Self-description coherent across every API |
| Screen / window / pointer | C++ patch: screen WxH, outerSize bound, media-query device-size, pointer/hover/touch capabilities | `screen.*`, `window.outer*`, CSS `@media (pointer: fine)` all coherent |
| CSS system colors | 40 `ui.*` Win32 palette overrides | `getComputedStyle()` on system colors matches real Windows |
| GPU / WebGL | C++ patch: vendor, renderer, extensions whitelist, integer/float params, shader precisions, readPixels noise | Matches real Windows ANGLE down to enum values |
| Canvas 2D | C++ patch: per-pixel substitution + geometry skip-mask noise + TextMetrics variance | Defeats canvas hashing and text-metrics fingerprinting |
| Fonts / DirectWrite | C++ patch: family whitelist + fabricated authoritative list + per-family width scale + DWrite settings | Font enumeration matches real Win10; canvas text hash stable |
| Audio | C++ patch: sampleRate + output latency + max channels + AnalyserNode/DynamicsCompressor noise | AudioContext fingerprints bucket users very tightly |
| Speech synthesis | C++ patch: fabricated voices list | `navigator.speechSynthesis.getVoices()` matches the spoofed OS |
| WebRTC | C++ patch (nICEr): srflx address swap + synthetic srflx fallback + private-LAN host candidates | Real public IP never leaks via STUN |
| Timezone | C++ patch: per-Realm TZ via BrowsingContext (no IPC pref races) | `Date.getTimezoneOffset()`, `Intl.DateTimeFormat` match the spoofed location |
| DevTools detection | C++ patch: `Debugger.stealthMode` + Juggler `Runtime.js` + thread actor | FP Pro `developer_tools` = Not detected even with debugger attached |
| SOCKS5 auth | C++ patch | Stock Playwright+Firefox cannot negotiate it at all |
| DNS | Routed through SOCKS proxy by default | No DNS leak when using a residential gateway |
| Mouse motion | Bezier curves inside Juggler `PageHandler.js`, ~10 ms per waypoint | Even `page.click(selector)` moves like a human |
| GPU on virtual desktop | Pref-driven workaround for FF150 alt-desktop sandbox regression | WebGL renderer populated even in headless / multi-worker mass tests |
| Fission navigation | C++ patch: `nsDocShell` + `CanonicalBrowsingContext` Juggler navigation fix | `page.goto()` reliable on FF150 across proxy edge cases |
| about:newtab race | Async wrapper sleep around `new_page()` | No "Navigation interrupted by about:newtab" on FF150 |
| Proxy reliability | Juggler `PageHandler.equalsExceptRef` split try/catch | No spurious "Invalid url" with proxies like Evomi |
Everything is driven by preferences - no hardcoded values in the binary. You change one pref, you change the spoofed value.
---
## How it compares
Commercial anti-detect browsers (Multilogin, GoLogin, AdsPower, Kameleo, Dolphin Anty, Browserbase) ship a patched Chromium and override fingerprints at the JavaScript layer. That's the ceiling - and it's a low one.
| | stealthfox | Multilogin / GoLogin | AdsPower / Dolphin | Browserbase |
|---|---|---|---|---|
| Engine | Firefox (open source) | Chromium fork | Chromium fork | Chromium |
| Patch depth | C++ source | JS overrides | JS overrides | JS overrides |
| `.toString()` clean | ✅ Native Gecko path | ❌ Detectable shims | ❌ Detectable shims | ❌ Detectable shims |
| Canvas / WebGL | ✅ C++ level | ⚠️ JS override | ⚠️ JS override | ⚠️ JS override |
| SOCKS5 auth | ✅ Patched | ⚠️ Varies | ⚠️ Varies | ❌ |
| Self-hosted | ✅ | ❌ SaaS | ❌ SaaS | ❌ Cloud |
| reCAPTCHA v3 score | **0.90** | ~0.3-0.6 | ~0.3-0.5 | ~0.3-0.5 |
| FP Pro - bot detected | ✅ Not detected | ❌ Detected | ❌ Detected | ❌ Detected |
| FP Pro - tampering | ✅ Not detected | ❌ Detected | ❌ Detected | ❌ Detected |
| FP Pro - VPN flag | ✅ false | ❌ true | ❌ true | ❌ true |
| CreepJS lies | ✅ 0 | ❌ multiple | ❌ multiple | ❌ multiple |
---
## Install
```bash
pip install stealthfox
python -m stealthfox fetch # one-time ~100 MB download, SHA256-verified
```
Supported platforms: **Windows x86_64**, **Linux x86_64**.
---
## Usage
### Random fingerprint per session
**100% Playwright-compatible** - sync and async, all methods, zero API changes. If you already use Playwright, switching is two lines:
```diff
- from playwright.sync_api import sync_playwright
- with sync_playwright() as p:
- browser = p.firefox.launch()
+ from stealthfox import Stealthfox
+ with Stealthfox() as browser:
```
Every session gets a unique, coherent fingerprint drawn from real-world Firefox telemetry (GPU / audio / fonts / ~400 other fields) and Bezier-curve mouse motion baked into the browser itself.
**Sync**
```python
from stealthfox import Stealthfox
with Stealthfox(proxy={"server": "socks5://...", "username": "u", "password": "p"}) as browser:
page = browser.new_page()
page.goto("https://example.com")
page.click("#submit") # mouse arcs to the button on a Bezier curve
```
**Async**
```python
from stealthfox.async_api import Stealthfox
async with Stealthfox(proxy={"server": "socks5://...", "username": "u", "password": "p"}) as browser:
page = await browser.new_page()
await page.goto("https://example.com")
await page.click("#submit")
```
The `browser` object is a `playwright.sync_api.Browser` / `playwright.async_api.Browser` - every Playwright method works as-is.
---
### Random fingerprint per session
```python
from stealthfox import Stealthfox
with Stealthfox() as browser:
page = browser.new_page()
page.goto("https://creepjs-api.web.app")
```
Every call samples a new coherent profile. Log the seed to reproduce interesting runs:
```python
sf = Stealthfox()
with sf as browser:
print("seed =", sf.seed)
# ...
```
### Reproducible fingerprint
```python
with Stealthfox(seed=42) as browser:
... # same GPU, same canvas hash, same audio context, every run
```
### Proxies
```python
proxy = {
"server": "socks5://gate.example.com:1080",
"username": "user",
"password": "pass",
}
with Stealthfox(proxy=proxy) as browser:
...
```
Schemes supported: `socks5`, `socks4`, `http`, `https`. Auth works on all of them (SOCKS5 via patched `nsProtocolProxyService.cpp`, HTTP/HTTPS via Playwright). DNS is routed through the proxy by default, no local leak.
### Pinning specific fingerprint fields
By default everything comes from `seed`. To force specific values while the rest stays seed-derived:
```python
with Stealthfox(
seed=42,
pin={
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)",
"gpu.vendor": "Google Inc. (NVIDIA)",
"screen.width": 2560,
"screen.height": 1440,
"hardware.concurrency": 16,
},
) as browser:
...
```
Full list of pinnable keys, how pinning interacts with the Bayesian sampler, and common patterns are in **[docs/pinning.md](docs/pinning.md)**.
---
## CLI
```bash
stealthfox fetch # download the binary if missing
stealthfox path # print the absolute path to the cached binary
stealthfox version # wrapper and binary versions
stealthfox clear-cache # remove all cached binaries
```
## License
MIT - see [LICENSE](LICENSE). The patched Firefox binary is distributed under the MPL-2.0 (Firefox upstream license). The C++ patches against mozilla-central that produce that binary are at [feder-cr/firefox-stealth](https://github.com/feder-cr/firefox-stealth).

143
docs/pinning.md Normal file
View file

@ -0,0 +1,143 @@
# Pinning fingerprint fields
By default, every field of the fingerprint is sampled from a Bayesian network of real-world Firefox telemetry, seeded by an integer. Pass the same `seed` and you get the same fingerprint; omit it and each session is fresh.
`pin` lets you **force specific fields** while letting the rest stay seed-derived. Useful when you need to replicate a known device (e.g. an NVIDIA 1080p laptop), test a specific GPU/screen combo, or pin just one noisy signal that a target site weighs heavily.
```python
from stealthfox import Stealthfox
with Stealthfox(
seed=42,
pin={
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)",
"gpu.vendor": "Google Inc. (NVIDIA)",
"screen.width": 2560,
"screen.height": 1440,
"hardware.concurrency": 16,
},
) as browser:
...
```
## How sampling + pinning interact
The generator is a Bayesian network: every field has a probability distribution **conditioned on its parents**. For example `gpu_class_tier` conditions `screen.tier`, `hardware.concurrency` and `webgl.msaa_samples`. A high-end GPU will tend to pair with a 2560x1440+ screen and 16+ cores.
When you pin a field:
1. The pinned value is written directly, bypassing the sampler.
2. **Unpinned children are still sampled from their conditionals** — using the parent's original posterior, not the pinned value.
That last point is the subtle one: pinning breaks the conditional chain. If you pin `gpu.renderer` to an RTX 4090 string but leave `screen` unpinned, the sampler will pick `screen` from the seed-derived tier (which might be `low_end`), producing a physically implausible "RTX 4090 + 1366x768" pairing.
**Rule of thumb:** pin correlated fields together, or just trust the sampler.
## Full list of pinnable keys
Keys are dotted paths. All values are optional — omitted keys fall back to the sampler.
### `gpu.*`
| Key | Type | Example | Notes |
|-----|------|---------|-------|
| `gpu.class_tier` | str | `"high_end"` | The **root** of the Bayesian network. One of `"low_end"`, `"mid_range"`, `"high_end"`, `"integrated_old"`, `"integrated_modern"`. Pin this alone to steer the whole profile (screen, concurrency, MSAA, …) toward a coherent tier without having to name each sub-field. |
| `gpu.vendor` | str | `"Google Inc. (NVIDIA)"` | Must exactly match the renderer vendor prefix, otherwise detectors catch the mismatch. |
| `gpu.renderer` | str | `"ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)"` | Windows ANGLE string. Used by WebGL `UNMASKED_RENDERER_WEBGL`. |
**Why `class_tier` is pinnable separately from `renderer`.** They live at different levels of abstraction:
- `class_tier` is a **coarse handle** over the whole Bayesian graph. It gates the distribution of `screen`, `hardware.concurrency`, `webgl.msaa_samples`, and storage quota. Pin `{"gpu.class_tier": "low_end"}` and the sampler returns a *coherent* low-end machine — small screen, 2-4 cores, 4x MSAA — without you having to specify each field.
- `renderer` is an **exact string** that lands verbatim in WebGL's `UNMASKED_RENDERER_WEBGL`. Useful when you want to imitate a specific GPU the target site has seen before. Does **not** condition other fields — if you pin `renderer` to an RTX 4090 but leave `class_tier` unpinned, `class_tier` is re-sampled from scratch and might disagree with the renderer string (see [How sampling + pinning interact](#how-sampling--pinning-interact)).
In practice most users should pin `class_tier` alone, or pin `renderer`+`vendor`+`class_tier` together if they want full control.
### `screen.*`
| Key | Type | Example |
|-----|------|---------|
| `screen.width` | int | `2560` |
| `screen.height` | int | `1440` |
| `screen.avail_width` | int | `2560` |
| `screen.avail_height` | int | `1400` |
| `screen.dpr` | float | `1.0`, `1.25`, `1.5`, `2.0` |
| `screen.tier` | str | `"1080p"`, `"1440p"`, `"4k"`, ... |
### `hardware.*`
| Key | Type | Example | Notes |
|-----|------|---------|-------|
| `hardware.concurrency` | int | `16` | `navigator.hardwareConcurrency`. |
| `hardware.storage_quota_mb` | int | `10_000` | `navigator.storage.estimate().quota / 1024²`. |
### `audio.*`
| Key | Type | Example | Notes |
|-----|------|---------|-------|
| `audio.sample_rate` | int | `48000`, `44100` | `AudioContext.sampleRate`. |
| `audio.output_latency_ms` | float | `20.0` | `AudioContext.outputLatency * 1000`. |
| `audio.max_channel_count` | int | `2`, `6`, `8` | `AudioDestinationNode.maxChannelCount`. |
### `codec.*` (booleans)
| Key | Effect |
|-----|--------|
| `codec.av1_enabled` | `true``canPlayType('video/av01')` returns `"probably"`. |
| `codec.webm_encoder_enabled` | `MediaRecorder` advertises WebM support. |
| `codec.mediasource_webm` | `MediaSource.isTypeSupported('video/webm')`. |
| `codec.mediasource_mp4` | `MediaSource.isTypeSupported('video/mp4')`. |
| `codec.webspeech_synth` | `speechSynthesis.getVoices()` returns a fabricated voice list. |
### `webgl.*`
| Key | Type | Example | Notes |
|-----|------|---------|-------|
| `webgl.msaa_samples` | int | `4`, `8`, `16` | `MAX_SAMPLES` WebGL parameter. Conditioned on `gpu.class_tier` when sampled. |
### Top-level
| Key | Type | Example | Notes |
|-----|------|---------|-------|
| `fonts` | list[str] | `["Arial", "Segoe UI", ...]` | Complete font allowlist. **Every other font is hidden**. The sampler usually picks 1424 system fonts. |
| `dark_theme` | bool | `False` | `prefers-color-scheme: dark`. Real traffic is ~85% light, 15% dark. |
## Reading the chosen values back
Every sampled (or pinned) value lands in a `zoom.stealth.*` pref inside the browser. Open `about:config` in a launched stealthfox session and filter for `zoom.stealth` to see the exact values in effect.
Alternatively, inspect the instance before the `with` block exits:
```python
sf = Stealthfox(seed=42)
with sf as browser:
# sf.seed is set; the full profile is in browser's prefs
...
```
## Common patterns
### Mimic a specific real device
Pin the whole visible tuple — GPU, screen, concurrency, fonts, audio:
```python
pin = {
"gpu.vendor": "Google Inc. (Intel)",
"gpu.renderer": "ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11)",
"gpu.class_tier": "mid_range",
"screen.width": 1920,
"screen.height": 1080,
"screen.dpr": 1.0,
"hardware.concurrency": 8,
"audio.sample_rate": 48000,
}
```
### Test the low-end GPU path only
```python
pin = {"gpu.class_tier": "low_end"}
# screen, msaa, concurrency re-sample from the seed but conditioned
# correctly on the low-end tier.
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
docs/screenshots/webrtc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

13
examples/basic.py Normal file
View file

@ -0,0 +1,13 @@
"""Launch a patched Firefox with a random stealth profile and load example.com."""
from stealthfox import Stealthfox
def main() -> None:
with Stealthfox() as browser:
page = browser.new_page()
page.goto("https://example.com")
print("title:", page.title())
if __name__ == "__main__":
main()

24
examples/with_proxy.py Normal file
View file

@ -0,0 +1,24 @@
"""Same as basic.py but route through a SOCKS5 proxy."""
import os
from stealthfox import Stealthfox
def main() -> None:
proxy = {
"server": os.environ.get("STEALTHFOX_PROXY_SERVER", "socks5://127.0.0.1:1080"),
}
user = os.environ.get("STEALTHFOX_PROXY_USER")
password = os.environ.get("STEALTHFOX_PROXY_PASS")
if user and password:
proxy["username"] = user
proxy["password"] = password
with Stealthfox(proxy=proxy) as browser:
page = browser.new_page()
page.goto("https://httpbin.org/ip")
print(page.content()[:500])
if __name__ == "__main__":
main()

45
pyproject.toml Normal file
View file

@ -0,0 +1,45 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "stealthfox"
version = "0.1.0"
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [{ name = "feder-cr", email = "85809106+feder-cr@users.noreply.github.com" }]
keywords = ["playwright", "firefox", "stealth", "anti-detect", "automation"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"playwright>=1.40",
"platformdirs>=4",
"requests>=2.31",
"tqdm>=4.66",
"pywin32>=306; sys_platform == 'win32'",
]
[project.optional-dependencies]
dev = ["pytest>=7", "pytest-mock>=3", "responses>=0.24"]
[project.scripts]
stealthfox = "stealthfox.cli:main"
[project.urls]
Homepage = "https://github.com/feder-cr/stealthfox"
Issues = "https://github.com/feder-cr/stealthfox/issues"
[tool.hatch.build.targets.wheel]
packages = ["src/stealthfox"]
[tool.hatch.build.targets.wheel.force-include]
"src/stealthfox/data" = "stealthfox/data"
"src/stealthfox/_fpforge/data" = "stealthfox/_fpforge/data"

View file

@ -0,0 +1,271 @@
"""Bayesian network realism audit (one-shot regenerator).
Each (gpu_class, intra_tier) cell maps to a real-world persona.
Distributions derived from:
- Steam HW Survey March 2026 Windows
cores: 2c=3%, 4c=13%, 6c=29%, 8c=27%, 10c=7%, 12c=5%, 14c=5%, 16c=6%, 24c=3%, 32c=0.01%
- Intel ARK + AMD spec sheets (CPU release era -> cores)
- Tom's Hardware: most users 100-250GB free (= 512GB-1TB drives baseline)
"""
import json
import os
import sys
OUT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"src", "stealthfox", "_fpforge", "data",
)
def _norm(d):
"""Normalize prob dict / list of (val, prob) pairs to sum 1.0."""
if isinstance(d, dict):
total = sum(d.values())
return [{"value": v, "prob": round(p / total, 4)} for v, p in d.items()]
total = sum(p for _, p in d)
return [{"value": v, "prob": round(p / total, 4)} for v, p in d]
def _scr(w, h, dpr=1.0):
return {"w": w, "h": h, "aw": w, "ah": h - 40, "dpr": dpr}
# ============================================================
# HW concurrency per (gpu_class, intra_tier)
# ============================================================
HWC = {
("integrated_old", "budget"): {2: 0.65, 4: 0.30, 8: 0.05},
("integrated_old", "standard"): {2: 0.30, 4: 0.55, 8: 0.15},
("integrated_old", "premium"): {4: 0.65, 8: 0.30, 12: 0.05},
("integrated_modern", "budget"): {4: 0.55, 6: 0.20, 8: 0.20, 12: 0.05},
("integrated_modern", "standard"): {6: 0.20, 8: 0.30, 10: 0.20, 12: 0.20, 16: 0.10},
("integrated_modern", "premium"): {8: 0.20, 10: 0.20, 12: 0.30, 14: 0.15, 16: 0.15},
("low_end", "budget"): {4: 0.50, 6: 0.25, 8: 0.20, 12: 0.05},
("low_end", "standard"): {4: 0.10, 6: 0.35, 8: 0.30, 12: 0.18, 16: 0.07},
("low_end", "premium"): {6: 0.10, 8: 0.30, 12: 0.30, 16: 0.22, 24: 0.08},
("mid_range", "budget"): {6: 0.55, 8: 0.30, 12: 0.10, 16: 0.05},
("mid_range", "standard"): {6: 0.40, 8: 0.30, 12: 0.18, 16: 0.10, 24: 0.02},
("mid_range", "premium"): {6: 0.15, 8: 0.45, 12: 0.20, 16: 0.15, 24: 0.05},
("high_end", "budget"): {6: 0.10, 8: 0.55, 12: 0.20, 16: 0.15},
("high_end", "standard"): {8: 0.30, 12: 0.18, 14: 0.15, 16: 0.27, 24: 0.10},
("high_end", "premium"): {12: 0.10, 14: 0.15, 16: 0.30, 24: 0.35, 32: 0.10},
("workstation", "budget"): {8: 0.40, 12: 0.30, 16: 0.20, 24: 0.10},
("workstation", "standard"): {12: 0.10, 16: 0.40, 24: 0.30, 32: 0.20},
("workstation", "premium"): {24: 0.30, 32: 0.45, 48: 0.15, 64: 0.10},
}
# ============================================================
# Screen resolution per (gpu_class, intra_tier)
# Each value list is [(screen_obj, prob), ...].
# ============================================================
SCREEN = {
("integrated_old", "budget"): [(_scr(1366, 768), 0.78), (_scr(1280, 800), 0.15), (_scr(1024, 768), 0.07)],
("integrated_old", "standard"): [(_scr(1366, 768), 0.55), (_scr(1600, 900), 0.25), (_scr(1280, 800), 0.10), (_scr(1920, 1080), 0.10)],
("integrated_old", "premium"): [(_scr(1600, 900), 0.45), (_scr(1920, 1080), 0.40), (_scr(1366, 768), 0.15)],
("integrated_modern", "budget"): [(_scr(1366, 768), 0.30), (_scr(1920, 1080), 0.65), (_scr(1600, 900), 0.05)],
("integrated_modern", "standard"):[(_scr(1920, 1080), 0.70), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.12), (_scr(2560, 1600), 0.08)],
("integrated_modern", "premium"): [(_scr(1920, 1080), 0.30), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.25), (_scr(2560, 1600), 0.15), (_scr(3840, 2160), 0.20)],
("low_end", "budget"): [(_scr(1920, 1080), 0.85), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.05)],
("low_end", "standard"): [(_scr(1920, 1080), 0.60), (_scr(1920, 1200), 0.10), (_scr(2560, 1440), 0.25), (_scr(3840, 2160), 0.05)],
("low_end", "premium"): [(_scr(1920, 1080), 0.25), (_scr(2560, 1440), 0.45), (_scr(3840, 2160), 0.20), (_scr(3440, 1440), 0.05), (_scr(1920, 1200), 0.05)],
("mid_range", "budget"): [(_scr(1920, 1080), 0.75), (_scr(2560, 1440), 0.20), (_scr(1920, 1200), 0.05)],
("mid_range", "standard"): [(_scr(1920, 1080), 0.45), (_scr(2560, 1440), 0.40), (_scr(3840, 2160), 0.10), (_scr(3440, 1440), 0.05)],
("mid_range", "premium"): [(_scr(1920, 1080), 0.15), (_scr(2560, 1440), 0.50), (_scr(3840, 2160), 0.20), (_scr(3440, 1440), 0.10), (_scr(2560, 1080), 0.05)],
("high_end", "budget"): [(_scr(1920, 1080), 0.20), (_scr(2560, 1440), 0.55), (_scr(3840, 2160), 0.20), (_scr(3440, 1440), 0.05)],
("high_end", "standard"): [(_scr(2560, 1440), 0.40), (_scr(3840, 2160), 0.40), (_scr(3440, 1440), 0.15), (_scr(5120, 1440), 0.05)],
("high_end", "premium"): [(_scr(3840, 2160), 0.55), (_scr(2560, 1440), 0.15), (_scr(3440, 1440), 0.10), (_scr(5120, 1440), 0.15), (_scr(7680, 2160), 0.05)],
("workstation", "budget"): [(_scr(2560, 1440), 0.55), (_scr(3840, 2160), 0.30), (_scr(1920, 1200), 0.10), (_scr(2560, 1600), 0.05)],
("workstation", "standard"): [(_scr(2560, 1440), 0.30), (_scr(3840, 2160), 0.45), (_scr(2560, 1600), 0.15), (_scr(3440, 1440), 0.10)],
("workstation", "premium"): [(_scr(3840, 2160), 0.55), (_scr(2560, 1600), 0.15), (_scr(5120, 2160), 0.15), (_scr(3840, 2400), 0.10), (_scr(7680, 2160), 0.05)],
}
# ============================================================
# Storage quota MB per (gpu_class, intra_tier)
# ============================================================
STORAGE = {
("integrated_old", "budget"): {32_000: 0.30, 64_000: 0.40, 128_000: 0.25, 256_000: 0.05},
("integrated_old", "standard"): {64_000: 0.20, 128_000: 0.45, 256_000: 0.25, 500_000: 0.10},
("integrated_old", "premium"): {128_000: 0.20, 256_000: 0.45, 500_000: 0.30, 1_000_000: 0.05},
("integrated_modern", "budget"): {64_000: 0.20, 128_000: 0.30, 256_000: 0.30, 500_000: 0.20},
("integrated_modern", "standard"):{256_000: 0.25, 500_000: 0.45, 1_000_000: 0.25, 2_000_000: 0.05},
("integrated_modern", "premium"): {500_000: 0.25, 1_000_000: 0.50, 2_000_000: 0.20, 4_000_000: 0.05},
("low_end", "budget"): {128_000: 0.20, 256_000: 0.50, 500_000: 0.25, 1_000_000: 0.05},
("low_end", "standard"): {256_000: 0.20, 500_000: 0.50, 1_000_000: 0.25, 2_000_000: 0.05},
("low_end", "premium"): {500_000: 0.20, 1_000_000: 0.50, 2_000_000: 0.25, 4_000_000: 0.05},
("mid_range", "budget"): {256_000: 0.15, 500_000: 0.50, 1_000_000: 0.30, 2_000_000: 0.05},
("mid_range", "standard"):{500_000: 0.20, 1_000_000: 0.55, 2_000_000: 0.20, 4_000_000: 0.05},
("mid_range", "premium"): {1_000_000: 0.40, 2_000_000: 0.45, 4_000_000: 0.15},
("high_end", "budget"): {500_000: 0.15, 1_000_000: 0.50, 2_000_000: 0.30, 4_000_000: 0.05},
("high_end", "standard"):{1_000_000: 0.30, 2_000_000: 0.50, 4_000_000: 0.20},
("high_end", "premium"): {2_000_000: 0.40, 4_000_000: 0.45, 8_000_000: 0.15},
("workstation", "budget"): {1_000_000: 0.30, 2_000_000: 0.50, 4_000_000: 0.20},
("workstation", "standard"):{2_000_000: 0.30, 4_000_000: 0.50, 8_000_000: 0.20},
("workstation", "premium"): {4_000_000: 0.35, 8_000_000: 0.45, 16_000_000: 0.20},
}
# ============================================================
# Audio profile per gpu_class
# ============================================================
AUDIO = {
"integrated_old": [
({"rate": 44100, "latency": 50, "channels": 2}, 0.70),
({"rate": 48000, "latency": 50, "channels": 2}, 0.30),
],
"integrated_modern": [
({"rate": 48000, "latency": 30, "channels": 2}, 0.60),
({"rate": 44100, "latency": 40, "channels": 2}, 0.25),
({"rate": 48000, "latency": 25, "channels": 6}, 0.15),
],
"low_end": [
({"rate": 48000, "latency": 40, "channels": 2}, 0.55),
({"rate": 44100, "latency": 50, "channels": 2}, 0.30),
({"rate": 48000, "latency": 30, "channels": 6}, 0.15),
],
"mid_range": [
({"rate": 48000, "latency": 25, "channels": 2}, 0.45),
({"rate": 48000, "latency": 20, "channels": 6}, 0.30),
({"rate": 48000, "latency": 20, "channels": 8}, 0.15),
({"rate": 44100, "latency": 30, "channels": 2}, 0.10),
],
"high_end": [
({"rate": 48000, "latency": 15, "channels": 6}, 0.30),
({"rate": 48000, "latency": 15, "channels": 8}, 0.30),
({"rate": 48000, "latency": 15, "channels": 2}, 0.20),
({"rate": 96000, "latency": 15, "channels": 6}, 0.10),
({"rate": 96000, "latency": 15, "channels": 8}, 0.10),
],
"workstation": [
({"rate": 48000, "latency": 10, "channels": 8}, 0.25),
({"rate": 96000, "latency": 10, "channels": 8}, 0.30),
({"rate": 96000, "latency": 10, "channels": 6}, 0.20),
({"rate": 192000, "latency": 10, "channels": 8}, 0.15),
({"rate": 48000, "latency": 15, "channels": 6}, 0.10),
],
}
# ============================================================
# Codec per gpu_class
# ============================================================
CODEC = {
"integrated_old": [
({"av1_enabled": False, "webm_encoder_enabled": False, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 1.0),
],
"integrated_modern": [
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.55),
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.35),
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.10),
],
"low_end": [
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.85),
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.15),
],
"mid_range": [
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.55),
({"av1_enabled": False, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.35),
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.10),
],
"high_end": [
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.85),
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.15),
],
"workstation": [
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": True}, 0.70),
({"av1_enabled": True, "webm_encoder_enabled": True, "mediasource_webm": True, "mediasource_mp4": True, "webspeech_synth": False}, 0.30),
],
}
# ============================================================
# MSAA per (gpu_class, screen_tier)
# screen_tier classifier in _sampler.py: "1080p", "1440p", "2160p", "ultrawide"
# Note: prefs.py clamps msaa to >=2 (antialias=True always), so msaa=0 is no longer
# emitted at the browser layer. CPT here keeps the 2/4/8 distribution matching what
# the prefs layer actually exposes.
# ============================================================
MSAA = {
("integrated_old", "1080p"): [(2, 0.75), (4, 0.20), (8, 0.05)],
("integrated_old", "1440p"): [(2, 0.85), (4, 0.15)],
("integrated_old", "2160p"): [(2, 1.0)],
("integrated_old", "ultrawide"): [(2, 1.0)],
("integrated_modern", "1080p"): [(2, 0.50), (4, 0.40), (8, 0.10)],
("integrated_modern", "1440p"): [(2, 0.60), (4, 0.35), (8, 0.05)],
("integrated_modern", "2160p"): [(2, 0.85), (4, 0.15)],
("integrated_modern", "ultrawide"):[(2, 0.80), (4, 0.20)],
("low_end", "1080p"): [(2, 0.40), (4, 0.45), (8, 0.15)],
("low_end", "1440p"): [(2, 0.55), (4, 0.40), (8, 0.05)],
("low_end", "2160p"): [(2, 0.85), (4, 0.15)],
("low_end", "ultrawide"): [(2, 0.70), (4, 0.30)],
("mid_range", "1080p"): [(2, 0.30), (4, 0.50), (8, 0.20)],
("mid_range", "1440p"): [(2, 0.40), (4, 0.45), (8, 0.15)],
("mid_range", "2160p"): [(2, 0.65), (4, 0.30), (8, 0.05)],
("mid_range", "ultrawide"): [(2, 0.55), (4, 0.40), (8, 0.05)],
("high_end", "1080p"): [(2, 0.20), (4, 0.45), (8, 0.35)],
("high_end", "1440p"): [(2, 0.25), (4, 0.50), (8, 0.25)],
("high_end", "2160p"): [(2, 0.40), (4, 0.45), (8, 0.15)],
("high_end", "ultrawide"): [(2, 0.30), (4, 0.50), (8, 0.20)],
("workstation", "1080p"): [(2, 0.15), (4, 0.50), (8, 0.35)],
("workstation", "1440p"): [(2, 0.15), (4, 0.55), (8, 0.30)],
("workstation", "2160p"): [(2, 0.25), (4, 0.55), (8, 0.20)],
("workstation", "ultrawide"): [(2, 0.20), (4, 0.55), (8, 0.25)],
}
def write_pair_table(table, fname, meta):
out = {"_meta": meta, "table": {}}
for key_pair, dist in table.items():
key = json.dumps(list(key_pair))
out["table"][key] = _norm(dist)
with open(os.path.join(OUT, fname), "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
def write_class_table(table, fname, meta):
out = {"_meta": meta, "table": {}}
for cls, dist in table.items():
out["table"][cls] = _norm(dist)
with open(os.path.join(OUT, fname), "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
def main():
write_pair_table(HWC, "cpt_hwc_given_class_tier.json",
"hardware_concurrency given (gpu_class, intra_tier)")
write_pair_table(SCREEN, "cpt_screen_given_class_tier.json",
"screen given (gpu_class, intra_tier)")
write_pair_table(STORAGE, "cpt_storage_given_class_tier.json",
"storage_quota_mb given (gpu_class, intra_tier)")
write_pair_table(MSAA, "cpt_msaa_given_class_screen.json",
"msaa_samples given (gpu_class, screen_tier)")
write_class_table(AUDIO, "cpt_audio_given_class.json",
"audio (rate/latency/channels) given gpu_class")
write_class_table(CODEC, "cpt_codec_given_class.json",
"codec given gpu_class")
print("All 6 CPT files updated.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,22 @@
"""stealthfox — Playwright wrapper for a patched Firefox with stealth profile.
Quickstart:
from stealthfox import Stealthfox
with Stealthfox() as browser: # random seed
page = browser.new_page()
page.goto("https://example.com")
with Stealthfox(seed=42) as browser: # deterministic
...
with Stealthfox(humanize=True) as browser: # human-like cursor motion
page = browser.new_page()
page.click("#submit") # expanded into a Bezier trajectory
"""
from .launcher import Stealthfox
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
__version__ = "0.1.0"
__all__ = ["Stealthfox", "BINARY_VERSION", "FIREFOX_UPSTREAM_VERSION", "__version__"]

View file

@ -0,0 +1,4 @@
from .cli import main
import sys
sys.exit(main())

View file

@ -0,0 +1,26 @@
"""Internal Bayesian fingerprint generator used by stealthfox.
Private module do not import from user code. Use
stealthfox.Stealthfox(seed=..., pin=...) instead.
"""
from .profile import (
AudioProfile,
CodecProfile,
GPUProfile,
HardwareProfile,
Profile,
ScreenProfile,
WebGLProfile,
generate_profile,
)
__all__ = [
"generate_profile",
"Profile",
"GPUProfile",
"ScreenProfile",
"HardwareProfile",
"AudioProfile",
"CodecProfile",
"WebGLProfile",
]

View file

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""Generic Bayesian network for fingerprint sampling.
A Node has:
- name
- parents (list of parent node names)
- CPT: either
* marginal (no parents): flat [{value, prob}, ...]
* conditional: {parent_tuple: [{value, prob}, ...]}
- OR deterministic: a classifier function `(context) -> value` (no CPT)
Sampling:
- Nodes are topologically sorted
- For each node, look up the conditional distribution given parent values
already sampled in `context`, then weighted-pick
- Deterministic nodes apply their classifier directly
Values can be ANY JSON-serializable type (int, str, dict, list, bool).
Complex values (e.g. screen joint {w, h, dpr}) are stored as dicts in the
CPT and returned as-is in the context.
"""
import json
import random
from typing import Any, Callable, Dict, List, Optional, Tuple
class Node:
"""Single Bayesian node."""
__slots__ = ("name", "parents", "cpt", "classifier", "_marginal")
def __init__(
self,
name: str,
parents: Optional[List[str]] = None,
cpt: Optional[Any] = None,
classifier: Optional[Callable[[Dict[str, Any]], Any]] = None,
):
self.name = name
self.parents = list(parents or [])
self.cpt = cpt
self.classifier = classifier
# Precompute: for no-parent nodes, cpt is the marginal list
self._marginal = cpt if not self.parents and classifier is None else None
def sample(self, context: Dict[str, Any], rng: random.Random) -> Any:
# Deterministic nodes don't sample
if self.classifier is not None:
return self.classifier(context)
if not self.parents:
# Marginal root
return _weighted_pick(self._marginal, rng)
# Conditional node: build the key from parent values
key = _parent_key(self.parents, context)
if key not in self.cpt:
# Fallback: concatenate all parents' tables (uniform over union)
# Keeps sampler from crashing if data doesn't cover some combo.
pool = []
for v in self.cpt.values():
pool.extend(v)
if not pool:
raise ValueError(
f"Node {self.name!r}: no CPT entries for {self.parents}={key}"
)
return _weighted_pick(pool, rng)
return _weighted_pick(self.cpt[key], rng)
class Network:
"""Collection of nodes with topological sampling."""
def __init__(self, nodes: List[Node]):
self.nodes = _topsort(nodes)
self.by_name = {n.name: n for n in self.nodes}
def sample(self, rng: random.Random) -> Dict[str, Any]:
context: Dict[str, Any] = {}
for node in self.nodes:
context[node.name] = node.sample(context, rng)
return context
# ── Helpers ─────────────────────────────────────────────────────────────
def _weighted_pick(table: List[Dict[str, Any]], rng: random.Random) -> Any:
"""`table` is a list of {value, prob} dicts. Returns one value."""
values = [e["value"] for e in table]
probs = [float(e["prob"]) for e in table]
if not values:
raise ValueError("Empty CPT entry")
total = sum(probs)
if total <= 0:
return rng.choice(values)
# Normalize to be safe (CPTs can be unnormalized)
probs = [p / total for p in probs]
return rng.choices(values, weights=probs, k=1)[0]
def _parent_key(parents: List[str], context: Dict[str, Any]) -> str:
"""Build a JSON-stable key from parent values in declared order."""
if len(parents) == 1:
v = context[parents[0]]
return v if isinstance(v, str) else json.dumps(v, sort_keys=True)
return json.dumps([context[p] for p in parents], sort_keys=True)
def _topsort(nodes: List[Node]) -> List[Node]:
"""Topological sort by parent-before-child."""
by_name = {n.name: n for n in nodes}
visited: set = set()
order: List[Node] = []
def visit(n: Node, path: set):
if n.name in visited:
return
if n.name in path:
raise ValueError(f"Cycle at {n.name}")
path.add(n.name)
for p in n.parents:
if p not in by_name:
raise ValueError(f"Node {n.name} has unknown parent {p}")
visit(by_name[p], path)
path.discard(n.name)
visited.add(n.name)
order.append(n)
for n in nodes:
visit(n, set())
return order

View file

@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
"""stealth_forge — Bayesian fingerprint generator for Firefox 150 Windows.
Everything the Firefox build exposes to JS (screen, hardwareConcurrency,
WebGL, audio, MSAA, theme, media codecs) is sampled from a Bayesian network
with coherent cross-field dependencies. Identity (userAgent, platform,
oscpu, webdriver=false, maxTouchPoints=0) is locked by the compiled build.
Graph:
gpu (root, 444 real Windows ANGLE renderers)
> gpu_class (deterministic classifier, 6 classes)
> hw_concurrency (CPT per class)
> screen (w/h/dpr/av) (CPT per class)
> msaa_samples (CPT per class)
audio (root, joint rate+latency+channels marginal)
dark_theme (marginal)
av1_enabled (marginal)
webm_encoder_enabled (marginal)
font_exclude deterministic hash of stealth_seed (seed-derived)
CPTs live in `data/*.json` (easy to tune without code changes).
Sampling is deterministic per stealth_seed via a private random.Random.
"""
import json
import os
import re
from typing import Any, Dict
from ._network import Network, Node
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load(filename: str) -> Any:
with open(os.path.join(_HERE, "data", filename), "r", encoding="utf-8") as f:
return json.load(f)
# ═══════════════════════════════════════════════════════════════════════
# LOCKED IDENTITY (compiled into our Firefox 150 build — never varies)
# ═══════════════════════════════════════════════════════════════════════
_LOCKED: Dict[str, Any] = {
"user_agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) "
"Gecko/20100101 Firefox/150.0.1"
),
"platform": "Win32",
"oscpu": "Windows NT 10.0; Win64; x64",
"app_code_name": "Mozilla",
"app_version": "5.0 (Windows)",
"product_sub": "20100101",
"webdriver": False,
"max_touch_points": 0,
}
# ═══════════════════════════════════════════════════════════════════════
# DATA
# ═══════════════════════════════════════════════════════════════════════
_GPU_POOL = _load("webgl_renderer_pool.json")["entries"]
# hwc/screen/storage now keyed on (gpu_class, intra_tier) for triangulation
_CPT_HWC = _load("cpt_hwc_given_class_tier.json")["table"]
_CPT_SCREEN = _load("cpt_screen_given_class_tier.json")["table"]
_CPT_STORAGE = _load("cpt_storage_given_class_tier.json")["table"]
# Hidden tier variable that makes hwc/screen/storage jointly coherent
_CPT_INTRA_TIER = _load("cpt_intra_tier_given_class.json")["table"]
# MSAA depends on (gpu_class, screen_tier) — 4K gaming → MSAA=0, 1080p+GPU → MSAA=4
_CPT_MSAA = _load("cpt_msaa_given_class_screen.json")["table"]
# Codec unchanged
_CPT_CODEC = _load("cpt_codec_given_class.json")["table"]
# Audio now conditional on gpu_class (workstation → pro audio, old → 44.1kHz onboard)
_CPT_AUDIO = _load("cpt_audio_given_class.json")["table"]
_INDEP = _load("priors_independent.json")
_FONT_POOL = _load("font_pool.json")
# Each entry is a dict {"name": "<lowercase family>", "factor": float}.
# - name: the font family advertised to the page.
# - factor: per-family width scale used by the consumer to make the family
# detectable by width-diff probes.
# Core = always-included; Optional = sampled with P(font | gpu_class).
_FONT_CORE: list = _FONT_POOL["core"]
_FONT_OPTIONAL: list = _FONT_POOL["optional"]
_CPT_FONTS_OPT = _load("cpt_fonts_optional_given_class.json")["table"]
# ═══════════════════════════════════════════════════════════════════════
# GPU CLASSIFIER (deterministic function of gpu → gpu_class)
# ═══════════════════════════════════════════════════════════════════════
_GPU_CLASSES = (
"integrated_old", "integrated_modern", "low_end",
"mid_range", "high_end", "workstation",
)
def classify_gpu(gpu_value: Dict[str, str]) -> str:
"""Deterministic: maps (renderer, vendor) dict to one of 6 classes.
See data/cpt_*.json each CPT table has an entry for every class.
"""
r = gpu_value.get("renderer", "")
if re.search(r"Intel.*HD Graphics (3000|4000|2500)", r):
return "integrated_old"
if re.search(
r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)",
r,
):
return "integrated_modern"
if re.search(
r"AMD.*(Radeon(\(TM\))? (Graphics|6\d\dM|7\d\dM|8\d\dM)|Vega [0-9]|"
r"Renoir|Rembrandt|TM Graphics)",
r, re.IGNORECASE,
):
return "integrated_modern"
# NVIDIA: Firefox SanitizeRenderer.cpp collapses every GeForce into one of
# 3 vintage buckets (8800 GTX / GTX 480 / GTX 980). The renderer string
# exposed to JS is therefore vintage; pairing it with modern cores/screen
# creates an internal mismatch that FP Pro's tampering_ml flags. We pick
# `low_end` for all 3 buckets so cores stay 4-12 and screen 1080-1440p,
# consistent with what a real user with each of those (vintage) cards
# would have. Workstation overrides keep their high-tier classification.
if re.search(
r"(GeForce (8\d\d\d?|9\d\d\d?|GTX 980|GTX 480|GT 1030|GT 710|GT 730|"
r"GT 220|GT 240|210|310)|Quadro K\d|Radeon HD [1234]\d\d\d)", r,
):
return "low_end"
# NVIDIA discrete (any other GeForce — should be rare after the pool was
# collapsed to the 3 sanitize buckets, but kept as a safety net).
m = re.search(r"GeForce\s+(?:GTX\s+|RTX\s+)?(\d{3,4})", r)
if m:
if "Quadro" in r or "Workstation" in r:
return "workstation"
# Anything that survives the sanitize collapse stays low_end to avoid
# the modern-cores/vintage-renderer pairing.
return "low_end"
# AMD discrete
m = re.search(r"Radeon[^0-9]*(\d{3,4})", r)
if m:
n = int(m.group(1))
if "FirePro" in r or "Radeon Pro" in r:
return "workstation"
if n >= 5700:
return "high_end"
if 5500 <= n <= 5600 or 580 <= n <= 590:
return "mid_range"
return "low_end"
# Fallback
return "mid_range"
# ═══════════════════════════════════════════════════════════════════════
# NETWORK CONSTRUCTION
# ═══════════════════════════════════════════════════════════════════════
# Build once at import — the network is stateless, only the RNG varies.
def _gpu_marginal():
"""Build marginal distribution over GPU pool (uniform for now)."""
n = len(_GPU_POOL)
p = 1.0 / n
return [{"value": g, "prob": p} for g in _GPU_POOL]
def _cpt_from_table(table: Dict[str, Any]) -> Dict[str, list]:
"""CPT for conditional nodes: `{class_name: [{value, prob}, ...]}`."""
return dict(table)
def _screen_tier(ctx):
"""Classify screen width into tier for (gpu_class, screen_tier) CPTs."""
s = ctx.get("screen", {}) or {}
w = int(s.get("w", 1920))
h = int(s.get("h", 1080))
# Ultrawide: aspect ratio > 2.1 (e.g. 3440x1440, 5120x1440)
if h > 0 and (w / h) > 2.1:
return "ultrawide"
if w <= 1920:
return "1080p"
if w <= 2560:
return "1440p"
if w <= 3840:
return "2160p"
return "ultrawide"
_NETWORK = Network([
Node("gpu", parents=[], cpt=_gpu_marginal()),
Node("gpu_class", parents=["gpu"], classifier=lambda ctx: classify_gpu(ctx["gpu"])),
# Hidden variable: within a gpu_class, user's OTHER components (RAM, SSD,
# cores, screen) correlate — a 'premium' mid_range user has more cores,
# larger SSD, higher-res screen than a 'budget' mid_range user. Without
# this, hwc/screen/storage would be independent given gpu_class (noisy).
Node("intra_tier", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_INTRA_TIER)),
# hwc/screen/storage now jointly coherent via (gpu_class, intra_tier).
Node("hw_concurrency", parents=["gpu_class", "intra_tier"],
cpt=_cpt_from_table(_CPT_HWC)),
Node("screen", parents=["gpu_class", "intra_tier"],
cpt=_cpt_from_table(_CPT_SCREEN)),
# Derive screen_tier from screen for msaa parent lookup.
Node("screen_tier", parents=["screen"], classifier=_screen_tier),
# MSAA: realistic combo (4K + high_end GPU → MSAA=0 due to perf cost;
# 1080p + high_end → MSAA=4 common; 1080p + integrated → MSAA=0).
Node("msaa_samples", parents=["gpu_class", "screen_tier"],
cpt=_cpt_from_table(_CPT_MSAA)),
# Joint codec distribution (gpu_class only).
Node("codec", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_CODEC)),
# Storage quota: coherent within gpu_class × intra_tier (premium workstation
# user → 2-3TB SSD; budget workstation user → 512GB; budget integrated_old
# → 128GB).
Node("storage_quota_mb", parents=["gpu_class", "intra_tier"],
cpt=_cpt_from_table(_CPT_STORAGE)),
# Audio: pro users (workstation) → 48/96kHz 6-8ch; old onboard → 44.1kHz
# 2ch high latency. Workstation GPU + 44.1kHz mono was previously
# implausible; now blocked by the CPT.
Node("audio", parents=["gpu_class"], cpt=_cpt_from_table(_CPT_AUDIO)),
Node("dark_theme", parents=[], cpt=_INDEP["dark_theme"]["table"]),
])
# ═══════════════════════════════════════════════════════════════════════
# FONT WHITELIST (Bayesian: core sampled_optional | gpu_class)
# ═══════════════════════════════════════════════════════════════════════
# Semantic flip: previously exclude-list (block N probed fonts per seed).
# Now whitelist (browser sees ONLY these fonts, everything else hidden).
# Core (~112): always included — fresh Win11 + Office 2021 English.
# Optional (~40): sampled per-session with P(present | gpu_class). Gives
# small realistic variance (~3-8 optional fonts differ per session) while
# keeping the profile strongly centered on 'typical Windows user'.
def derive_font_prefs(gpu_class: str, rng) -> Dict[str, str]:
"""Build COHERENT whitelist + metrics strings for the session.
Sampling:
- Core fonts always included.
- Optional fonts sampled with P(font | gpu_class) from the CPT table.
Returns:
{
"whitelist": "arial,calibri,marlett,...",
"metrics": "arial|0.978,calibri|0.934,marlett|0.855,..."
}
The whitelist is the list of font families to advertise. The metrics
string encodes per-family width scale factors that the consumer can
use to make each family detectable by width-diff font probes.
Each entry in font_pool.json carries its own {name, factor} pair so the
two pref strings are GUARANTEED coherent no chance of a fabricated
font with factor 1.0 (undetectable) or a metrics entry for a font not
in the whitelist (useless).
Markers & add-new-font: simply add an entry to font_pool.json:core (with
a factor at least 4% away from 1.0) no special-case code needed.
"""
cpt = _CPT_FONTS_OPT.get(gpu_class)
if cpt is None:
cpt = _CPT_FONTS_OPT["integrated_modern"]
included: list = list(_FONT_CORE) # always present
for entry in _FONT_OPTIONAL:
name = entry["name"]
p = cpt.get(name, 0.7) # default 0.7 if CPT has no row for this font
if rng.random() < p:
included.append(entry)
# Deterministic ordering: sort by name
included.sort(key=lambda e: e["name"])
whitelist = ",".join(e["name"] for e in included)
metrics = ",".join(
f'{e["name"]}|{e["factor"]:.3f}' for e in included
)
return {"whitelist": whitelist, "metrics": metrics}
# Back-compat shim: legacy callers still import derive_font_whitelist.
def derive_font_whitelist(gpu_class: str, rng) -> str:
return derive_font_prefs(gpu_class, rng)["whitelist"]
# ═══════════════════════════════════════════════════════════════════════
# PUBLIC API: Forge
# ═══════════════════════════════════════════════════════════════════════
import random
class Forge:
"""Fingerprint forge — single seed → coherent bundle."""
def __init__(self, seed: int):
self.seed = int(seed)
self._rng = random.Random(self.seed)
def sample(self) -> Dict[str, Any]:
bundle = _NETWORK.sample(self._rng)
gpu = bundle["gpu"]
screen = bundle["screen"]
audio = bundle["audio"]
codec = bundle["codec"]
return {
# Seed tracking
"stealth_seed": self.seed,
# Locked identity
**_LOCKED,
# GPU (coherent pair from 444 pool)
"webgl_renderer": gpu["renderer"],
"webgl_vendor": gpu["vendor"],
"gpu_class": bundle["gpu_class"],
# Hidden-variable debug metadata (not a Firefox pref, just for
# analysis / test result correlation tracking)
"intra_tier": bundle["intra_tier"],
"screen_tier": bundle["screen_tier"],
# Screen (coherent with GPU class)
"screen_w": int(screen["w"]),
"screen_h": int(screen["h"]),
"screen_avail_w": int(screen.get("aw", screen["w"])),
"screen_avail_h": int(screen.get("ah", screen["h"] - 40)),
"dpr": float(screen["dpr"]),
# Hardware (coherent with GPU class)
"hw_concurrency": int(bundle["hw_concurrency"]),
# WebGL MSAA (coherent with GPU class)
"msaa_samples": int(bundle["msaa_samples"]),
# Audio (independent joint)
"audio_sample_rate": int(audio["rate"]),
"audio_output_latency_ms": int(audio["latency"]),
"audio_max_channel_count": int(audio["channels"]),
# Codec prefs (joint, coherent with GPU class). All 5 are
# JS-visible: av1/webm_encoder via canPlayType/MediaRecorder,
# mediasource_* via MediaSource.isTypeSupported, webspeech_synth
# via 'speechSynthesis' in window (CreepJS voices probe).
"av1_enabled": bool(codec["av1_enabled"]),
"webm_encoder_enabled": bool(codec["webm_encoder_enabled"]),
"mediasource_webm": bool(codec["mediasource_webm"]),
"mediasource_mp4": bool(codec["mediasource_mp4"]),
"webspeech_synth": bool(codec["webspeech_synth"]),
# Storage quota MB (coherent with GPU class — workstation larger SSDs).
"storage_quota_mb": int(bundle["storage_quota_mb"]),
# Independent marginals
"dark_theme": int(bundle["dark_theme"]),
# Bayesian font prefs (coherent pair: whitelist + per-family
# width scale metrics, both sampled from the same font_pool.json
# and conditioned on gpu_class).
**{
f"font_{k}": v
for k, v in derive_font_prefs(
bundle["gpu_class"], self._rng
).items()
},
}
def sample(seed: int) -> Dict[str, Any]:
"""Convenience: `Forge(seed).sample()`."""
return Forge(seed).sample()

View file

@ -0,0 +1,193 @@
{
"_meta": "audio (rate/latency/channels) given gpu_class",
"table": {
"integrated_old": [
{
"value": {
"rate": 44100,
"latency": 50,
"channels": 2
},
"prob": 0.7
},
{
"value": {
"rate": 48000,
"latency": 50,
"channels": 2
},
"prob": 0.3
}
],
"integrated_modern": [
{
"value": {
"rate": 48000,
"latency": 30,
"channels": 2
},
"prob": 0.6
},
{
"value": {
"rate": 44100,
"latency": 40,
"channels": 2
},
"prob": 0.25
},
{
"value": {
"rate": 48000,
"latency": 25,
"channels": 6
},
"prob": 0.15
}
],
"low_end": [
{
"value": {
"rate": 48000,
"latency": 40,
"channels": 2
},
"prob": 0.55
},
{
"value": {
"rate": 44100,
"latency": 50,
"channels": 2
},
"prob": 0.3
},
{
"value": {
"rate": 48000,
"latency": 30,
"channels": 6
},
"prob": 0.15
}
],
"mid_range": [
{
"value": {
"rate": 48000,
"latency": 25,
"channels": 2
},
"prob": 0.45
},
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 6
},
"prob": 0.3
},
{
"value": {
"rate": 48000,
"latency": 20,
"channels": 8
},
"prob": 0.15
},
{
"value": {
"rate": 44100,
"latency": 30,
"channels": 2
},
"prob": 0.1
}
],
"high_end": [
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 6
},
"prob": 0.3
},
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 8
},
"prob": 0.3
},
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 2
},
"prob": 0.2
},
{
"value": {
"rate": 96000,
"latency": 15,
"channels": 6
},
"prob": 0.1
},
{
"value": {
"rate": 96000,
"latency": 15,
"channels": 8
},
"prob": 0.1
}
],
"workstation": [
{
"value": {
"rate": 48000,
"latency": 10,
"channels": 8
},
"prob": 0.25
},
{
"value": {
"rate": 96000,
"latency": 10,
"channels": 8
},
"prob": 0.3
},
{
"value": {
"rate": 96000,
"latency": 10,
"channels": 6
},
"prob": 0.2
},
{
"value": {
"rate": 192000,
"latency": 10,
"channels": 8
},
"prob": 0.15
},
{
"value": {
"rate": 48000,
"latency": 15,
"channels": 6
},
"prob": 0.1
}
]
}
}

View file

@ -0,0 +1,147 @@
{
"_meta": "codec given gpu_class",
"table": {
"integrated_old": [
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": false,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 1.0
}
],
"integrated_modern": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.55
},
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.35
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.1
}
],
"low_end": [
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.85
},
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.15
}
],
"mid_range": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.55
},
{
"value": {
"av1_enabled": false,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.35
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.1
}
],
"high_end": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.85
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.15
}
],
"workstation": [
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": true
},
"prob": 0.7
},
{
"value": {
"av1_enabled": true,
"webm_encoder_enabled": true,
"mediasource_webm": true,
"mediasource_mp4": true,
"webspeech_synth": false
},
"prob": 0.3
}
]
}
}

View file

@ -0,0 +1,295 @@
{
"_meta": {
"name": "optional_font presence | gpu_class",
"parents": [
"gpu_class"
],
"child": "optional_fonts_subset",
"description": "Per-optional-font presence probabilities given gpu_class. Each optional font in font_pool.json sampled INDEPENDENTLY with P(present | gpu_class) given here. Integrated_old: fewer language packs (older/cheaper machines). Workstation: more regional/language packs (international users, enterprise deployments).",
"rationale": "Near-invariant by design: most optional fonts have baseline P ~ 0.60-0.85 across classes. Per-session variance is small (~3-6 fonts toggling on/off out of 40 optional). Result: fingerprint always looks like 'typical Windows 11 + Office', with small realistic per-user variance in regional support."
},
"table": {
"integrated_old": {
"aparajita": 0.45,
"calibri": 0.9,
"dengxian": 0.3,
"dfkai-sb": 0.25,
"dokchampa": 0.2,
"estrangelo edessa": 0.55,
"euphemia": 0.6,
"fangsong": 0.35,
"gadugi": 0.88,
"gautami": 0.55,
"helv": 0.7,
"iskoola pota": 0.5,
"javanese text": 0.85,
"kaiti": 0.3,
"kalinga": 0.5,
"kartika": 0.5,
"khmer ui": 0.35,
"kokila": 0.55,
"lao ui": 0.35,
"latha": 0.65,
"leelawadee ui": 0.87,
"mangal": 0.65,
"meiryo": 0.6,
"microsoft uighur": 0.4,
"ms pmincho": 0.65,
"ms reference sans serif": 0.55,
"ms reference specialty": 0.5,
"ms ui gothic": 0.82,
"myanmar text": 0.84,
"nyala": 0.55,
"plantagenet cherokee": 0.6,
"raavi": 0.55,
"segoe fluent icons": 0.5,
"segoe ui light": 0.75,
"shonar bangla": 0.55,
"shruti": 0.55,
"simkai": 0.25,
"small fonts": 0.75,
"traditional arabic": 0.55,
"tunga": 0.55,
"urdu typesetting": 0.45,
"utsaah": 0.55,
"vani": 0.55,
"vijaya": 0.55,
"yu mincho": 0.55
},
"integrated_modern": {
"aparajita": 0.65,
"calibri": 0.78,
"dengxian": 0.5,
"dfkai-sb": 0.4,
"dokchampa": 0.4,
"estrangelo edessa": 0.7,
"euphemia": 0.78,
"fangsong": 0.55,
"gadugi": 0.96,
"gautami": 0.75,
"helv": 0.6,
"iskoola pota": 0.7,
"javanese text": 0.94,
"kaiti": 0.45,
"kalinga": 0.7,
"kartika": 0.7,
"khmer ui": 0.55,
"kokila": 0.75,
"lao ui": 0.55,
"latha": 0.82,
"leelawadee ui": 0.95,
"mangal": 0.82,
"meiryo": 0.8,
"microsoft uighur": 0.55,
"ms pmincho": 0.85,
"ms reference sans serif": 0.72,
"ms reference specialty": 0.68,
"ms ui gothic": 0.75,
"myanmar text": 0.94,
"nyala": 0.72,
"plantagenet cherokee": 0.78,
"raavi": 0.72,
"segoe fluent icons": 0.92,
"segoe ui light": 0.72,
"shonar bangla": 0.72,
"shruti": 0.72,
"simkai": 0.4,
"small fonts": 0.65,
"traditional arabic": 0.72,
"tunga": 0.72,
"urdu typesetting": 0.65,
"utsaah": 0.72,
"vani": 0.72,
"vijaya": 0.72,
"yu mincho": 0.75
},
"low_end": {
"aparajita": 0.55,
"calibri": 0.94,
"dengxian": 0.4,
"dfkai-sb": 0.3,
"dokchampa": 0.3,
"estrangelo edessa": 0.62,
"euphemia": 0.68,
"fangsong": 0.45,
"gadugi": 0.93,
"gautami": 0.65,
"helv": 0.78,
"iskoola pota": 0.6,
"javanese text": 0.91,
"kaiti": 0.35,
"kalinga": 0.6,
"kartika": 0.6,
"khmer ui": 0.45,
"kokila": 0.65,
"lao ui": 0.45,
"latha": 0.72,
"leelawadee ui": 0.92,
"mangal": 0.72,
"meiryo": 0.7,
"microsoft uighur": 0.48,
"ms pmincho": 0.75,
"ms reference sans serif": 0.62,
"ms reference specialty": 0.58,
"ms ui gothic": 0.9,
"myanmar text": 0.9,
"nyala": 0.62,
"plantagenet cherokee": 0.68,
"raavi": 0.62,
"segoe fluent icons": 0.8,
"segoe ui light": 0.88,
"shonar bangla": 0.62,
"shruti": 0.62,
"simkai": 0.3,
"small fonts": 0.83,
"traditional arabic": 0.62,
"tunga": 0.62,
"urdu typesetting": 0.55,
"utsaah": 0.62,
"vani": 0.62,
"vijaya": 0.62,
"yu mincho": 0.65
},
"mid_range": {
"aparajita": 0.72,
"calibri": 0.98,
"dengxian": 0.6,
"dfkai-sb": 0.5,
"dokchampa": 0.5,
"estrangelo edessa": 0.78,
"euphemia": 0.82,
"fangsong": 0.65,
"gadugi": 0.97,
"gautami": 0.8,
"helv": 0.85,
"iskoola pota": 0.78,
"javanese text": 0.96,
"kaiti": 0.55,
"kalinga": 0.78,
"kartika": 0.78,
"khmer ui": 0.65,
"kokila": 0.8,
"lao ui": 0.65,
"latha": 0.85,
"leelawadee ui": 0.97,
"mangal": 0.85,
"meiryo": 0.85,
"microsoft uighur": 0.65,
"ms pmincho": 0.88,
"ms reference sans serif": 0.78,
"ms reference specialty": 0.75,
"ms ui gothic": 0.96,
"myanmar text": 0.96,
"nyala": 0.78,
"plantagenet cherokee": 0.82,
"raavi": 0.78,
"segoe fluent icons": 0.94,
"segoe ui light": 0.94,
"shonar bangla": 0.78,
"shruti": 0.78,
"simkai": 0.5,
"small fonts": 0.9,
"traditional arabic": 0.78,
"tunga": 0.78,
"urdu typesetting": 0.72,
"utsaah": 0.78,
"vani": 0.78,
"vijaya": 0.78,
"yu mincho": 0.8
},
"high_end": {
"aparajita": 0.8,
"calibri": 0.99,
"dengxian": 0.7,
"dfkai-sb": 0.6,
"dokchampa": 0.6,
"estrangelo edessa": 0.85,
"euphemia": 0.88,
"fangsong": 0.72,
"gadugi": 0.98,
"gautami": 0.85,
"helv": 0.88,
"iskoola pota": 0.82,
"javanese text": 0.97,
"kaiti": 0.65,
"kalinga": 0.82,
"kartika": 0.82,
"khmer ui": 0.72,
"kokila": 0.85,
"lao ui": 0.72,
"latha": 0.9,
"leelawadee ui": 0.98,
"mangal": 0.9,
"meiryo": 0.9,
"microsoft uighur": 0.72,
"ms pmincho": 0.92,
"ms reference sans serif": 0.85,
"ms reference specialty": 0.82,
"ms ui gothic": 0.98,
"myanmar text": 0.97,
"nyala": 0.85,
"plantagenet cherokee": 0.88,
"raavi": 0.85,
"segoe fluent icons": 0.97,
"segoe ui light": 0.96,
"shonar bangla": 0.85,
"shruti": 0.85,
"simkai": 0.6,
"small fonts": 0.92,
"traditional arabic": 0.85,
"tunga": 0.85,
"urdu typesetting": 0.8,
"utsaah": 0.85,
"vani": 0.85,
"vijaya": 0.85,
"yu mincho": 0.88
},
"workstation": {
"aparajita": 0.88,
"calibri": 0.99,
"dengxian": 0.8,
"dfkai-sb": 0.75,
"dokchampa": 0.72,
"estrangelo edessa": 0.9,
"euphemia": 0.92,
"fangsong": 0.82,
"gadugi": 0.99,
"gautami": 0.92,
"helv": 0.9,
"iskoola pota": 0.9,
"javanese text": 0.98,
"kaiti": 0.78,
"kalinga": 0.9,
"kartika": 0.9,
"khmer ui": 0.8,
"kokila": 0.92,
"lao ui": 0.8,
"latha": 0.95,
"leelawadee ui": 0.99,
"mangal": 0.95,
"meiryo": 0.95,
"microsoft uighur": 0.82,
"ms pmincho": 0.95,
"ms reference sans serif": 0.92,
"ms reference specialty": 0.9,
"ms ui gothic": 0.98,
"myanmar text": 0.98,
"nyala": 0.92,
"plantagenet cherokee": 0.92,
"raavi": 0.92,
"segoe fluent icons": 0.98,
"segoe ui light": 0.97,
"shonar bangla": 0.92,
"shruti": 0.92,
"simkai": 0.72,
"small fonts": 0.94,
"traditional arabic": 0.92,
"tunga": 0.92,
"urdu typesetting": 0.88,
"utsaah": 0.92,
"vani": 0.92,
"vijaya": 0.92,
"yu mincho": 0.92
}
}
}

View file

@ -0,0 +1,50 @@
{
"_meta": {
"name": "hw_concurrency | gpu_class",
"parents": ["gpu_class"],
"child": "hw_concurrency",
"source": "Curated from realistic CPU/GPU pairings (Steam HW Survey + DIY build norms + laptop SKU patterns)"
},
"table": {
"integrated_old": [
{"value": 2, "prob": 0.20},
{"value": 4, "prob": 0.60},
{"value": 8, "prob": 0.20}
],
"integrated_modern": [
{"value": 4, "prob": 0.20},
{"value": 6, "prob": 0.15},
{"value": 8, "prob": 0.35},
{"value": 12, "prob": 0.20},
{"value": 16, "prob": 0.10}
],
"low_end": [
{"value": 4, "prob": 0.25},
{"value": 6, "prob": 0.25},
{"value": 8, "prob": 0.35},
{"value": 12, "prob": 0.15}
],
"mid_range": [
{"value": 6, "prob": 0.15},
{"value": 8, "prob": 0.30},
{"value": 12, "prob": 0.30},
{"value": 16, "prob": 0.20},
{"value": 24, "prob": 0.05}
],
"high_end": [
{"value": 8, "prob": 0.10},
{"value": 12, "prob": 0.25},
{"value": 16, "prob": 0.35},
{"value": 20, "prob": 0.05},
{"value": 24, "prob": 0.20},
{"value": 32, "prob": 0.05}
],
"workstation": [
{"value": 8, "prob": 0.15},
{"value": 12, "prob": 0.20},
{"value": 16, "prob": 0.30},
{"value": 24, "prob": 0.20},
{"value": 32, "prob": 0.15}
]
}
}

View file

@ -0,0 +1,349 @@
{
"_meta": "hardware_concurrency given (gpu_class, intra_tier)",
"table": {
"[\"integrated_old\", \"budget\"]": [
{
"value": 2,
"prob": 0.65
},
{
"value": 4,
"prob": 0.3
},
{
"value": 8,
"prob": 0.05
}
],
"[\"integrated_old\", \"standard\"]": [
{
"value": 2,
"prob": 0.3
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.15
}
],
"[\"integrated_old\", \"premium\"]": [
{
"value": 4,
"prob": 0.65
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.05
}
],
"[\"integrated_modern\", \"budget\"]": [
{
"value": 4,
"prob": 0.55
},
{
"value": 6,
"prob": 0.2
},
{
"value": 8,
"prob": 0.2
},
{
"value": 12,
"prob": 0.05
}
],
"[\"integrated_modern\", \"standard\"]": [
{
"value": 6,
"prob": 0.2
},
{
"value": 8,
"prob": 0.3
},
{
"value": 10,
"prob": 0.2
},
{
"value": 12,
"prob": 0.2
},
{
"value": 16,
"prob": 0.1
}
],
"[\"integrated_modern\", \"premium\"]": [
{
"value": 8,
"prob": 0.2
},
{
"value": 10,
"prob": 0.2
},
{
"value": 12,
"prob": 0.3
},
{
"value": 14,
"prob": 0.15
},
{
"value": 16,
"prob": 0.15
}
],
"[\"low_end\", \"budget\"]": [
{
"value": 4,
"prob": 0.5
},
{
"value": 6,
"prob": 0.25
},
{
"value": 8,
"prob": 0.2
},
{
"value": 12,
"prob": 0.05
}
],
"[\"low_end\", \"standard\"]": [
{
"value": 4,
"prob": 0.1
},
{
"value": 6,
"prob": 0.35
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.18
},
{
"value": 16,
"prob": 0.07
}
],
"[\"low_end\", \"premium\"]": [
{
"value": 6,
"prob": 0.1
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.3
},
{
"value": 16,
"prob": 0.22
},
{
"value": 24,
"prob": 0.08
}
],
"[\"mid_range\", \"budget\"]": [
{
"value": 6,
"prob": 0.55
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.1
},
{
"value": 16,
"prob": 0.05
}
],
"[\"mid_range\", \"standard\"]": [
{
"value": 6,
"prob": 0.4
},
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.18
},
{
"value": 16,
"prob": 0.1
},
{
"value": 24,
"prob": 0.02
}
],
"[\"mid_range\", \"premium\"]": [
{
"value": 6,
"prob": 0.15
},
{
"value": 8,
"prob": 0.45
},
{
"value": 12,
"prob": 0.2
},
{
"value": 16,
"prob": 0.15
},
{
"value": 24,
"prob": 0.05
}
],
"[\"high_end\", \"budget\"]": [
{
"value": 6,
"prob": 0.1
},
{
"value": 8,
"prob": 0.55
},
{
"value": 12,
"prob": 0.2
},
{
"value": 16,
"prob": 0.15
}
],
"[\"high_end\", \"standard\"]": [
{
"value": 8,
"prob": 0.3
},
{
"value": 12,
"prob": 0.18
},
{
"value": 14,
"prob": 0.15
},
{
"value": 16,
"prob": 0.27
},
{
"value": 24,
"prob": 0.1
}
],
"[\"high_end\", \"premium\"]": [
{
"value": 12,
"prob": 0.1
},
{
"value": 14,
"prob": 0.15
},
{
"value": 16,
"prob": 0.3
},
{
"value": 24,
"prob": 0.35
},
{
"value": 32,
"prob": 0.1
}
],
"[\"workstation\", \"budget\"]": [
{
"value": 8,
"prob": 0.4
},
{
"value": 12,
"prob": 0.3
},
{
"value": 16,
"prob": 0.2
},
{
"value": 24,
"prob": 0.1
}
],
"[\"workstation\", \"standard\"]": [
{
"value": 12,
"prob": 0.1
},
{
"value": 16,
"prob": 0.4
},
{
"value": 24,
"prob": 0.3
},
{
"value": 32,
"prob": 0.2
}
],
"[\"workstation\", \"premium\"]": [
{
"value": 24,
"prob": 0.3
},
{
"value": 32,
"prob": 0.45
},
{
"value": 48,
"prob": 0.15
},
{
"value": 64,
"prob": 0.1
}
]
}
}

View file

@ -0,0 +1,17 @@
{
"_meta": {
"name": "intra_tier | gpu_class",
"parents": ["gpu_class"],
"child": "intra_tier",
"value_type": "str (budget|standard|premium)",
"rationale": "Hidden variable catching 'intra-class premium-ness'. Within a gpu_class, users vary in how premium their OTHER components are (RAM, SSD, cores). A mid_range GPU paired with 32 cores + 2TB SSD is a 'premium' mid_range user. Without this var, hwc/screen/storage would be statistically independent given gpu_class — unrealistic (real users pick coherent bundles). Tier distribution biased: integrated_old mostly budget/standard, workstation mostly premium."
},
"table": {
"integrated_old": [{"value": "budget", "prob": 0.65}, {"value": "standard", "prob": 0.30}, {"value": "premium", "prob": 0.05}],
"integrated_modern": [{"value": "budget", "prob": 0.30}, {"value": "standard", "prob": 0.55}, {"value": "premium", "prob": 0.15}],
"low_end": [{"value": "budget", "prob": 0.50}, {"value": "standard", "prob": 0.40}, {"value": "premium", "prob": 0.10}],
"mid_range": [{"value": "budget", "prob": 0.25}, {"value": "standard", "prob": 0.50}, {"value": "premium", "prob": 0.25}],
"high_end": [{"value": "budget", "prob": 0.15}, {"value": "standard", "prob": 0.50}, {"value": "premium", "prob": 0.35}],
"workstation": [{"value": "budget", "prob": 0.10}, {"value": "standard", "prob": 0.40}, {"value": "premium", "prob": 0.50}]
}
}

View file

@ -0,0 +1,16 @@
{
"_meta": {
"name": "webgl.msaa-samples | gpu_class",
"parents": ["gpu_class"],
"child": "msaa_samples",
"source": "High-end GPUs more likely to have MSAA enabled in games; integrated usually off"
},
"table": {
"integrated_old": [{"value": 0, "prob": 0.85}, {"value": 2, "prob": 0.15}],
"integrated_modern": [{"value": 0, "prob": 0.70}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.05}],
"low_end": [{"value": 0, "prob": 0.55}, {"value": 2, "prob": 0.35}, {"value": 4, "prob": 0.10}],
"mid_range": [{"value": 0, "prob": 0.35}, {"value": 2, "prob": 0.35}, {"value": 4, "prob": 0.30}],
"high_end": [{"value": 0, "prob": 0.20}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.55}],
"workstation": [{"value": 0, "prob": 0.50}, {"value": 2, "prob": 0.25}, {"value": 4, "prob": 0.25}]
}
}

View file

@ -0,0 +1,305 @@
{
"_meta": "msaa_samples given (gpu_class, screen_tier)",
"table": {
"[\"integrated_old\", \"1080p\"]": [
{
"value": 2,
"prob": 0.75
},
{
"value": 4,
"prob": 0.2
},
{
"value": 8,
"prob": 0.05
}
],
"[\"integrated_old\", \"1440p\"]": [
{
"value": 2,
"prob": 0.85
},
{
"value": 4,
"prob": 0.15
}
],
"[\"integrated_old\", \"2160p\"]": [
{
"value": 2,
"prob": 1.0
}
],
"[\"integrated_old\", \"ultrawide\"]": [
{
"value": 2,
"prob": 1.0
}
],
"[\"integrated_modern\", \"1080p\"]": [
{
"value": 2,
"prob": 0.5
},
{
"value": 4,
"prob": 0.4
},
{
"value": 8,
"prob": 0.1
}
],
"[\"integrated_modern\", \"1440p\"]": [
{
"value": 2,
"prob": 0.6
},
{
"value": 4,
"prob": 0.35
},
{
"value": 8,
"prob": 0.05
}
],
"[\"integrated_modern\", \"2160p\"]": [
{
"value": 2,
"prob": 0.85
},
{
"value": 4,
"prob": 0.15
}
],
"[\"integrated_modern\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.8
},
{
"value": 4,
"prob": 0.2
}
],
"[\"low_end\", \"1080p\"]": [
{
"value": 2,
"prob": 0.4
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.15
}
],
"[\"low_end\", \"1440p\"]": [
{
"value": 2,
"prob": 0.55
},
{
"value": 4,
"prob": 0.4
},
{
"value": 8,
"prob": 0.05
}
],
"[\"low_end\", \"2160p\"]": [
{
"value": 2,
"prob": 0.85
},
{
"value": 4,
"prob": 0.15
}
],
"[\"low_end\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.7
},
{
"value": 4,
"prob": 0.3
}
],
"[\"mid_range\", \"1080p\"]": [
{
"value": 2,
"prob": 0.3
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.2
}
],
"[\"mid_range\", \"1440p\"]": [
{
"value": 2,
"prob": 0.4
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.15
}
],
"[\"mid_range\", \"2160p\"]": [
{
"value": 2,
"prob": 0.65
},
{
"value": 4,
"prob": 0.3
},
{
"value": 8,
"prob": 0.05
}
],
"[\"mid_range\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.55
},
{
"value": 4,
"prob": 0.4
},
{
"value": 8,
"prob": 0.05
}
],
"[\"high_end\", \"1080p\"]": [
{
"value": 2,
"prob": 0.2
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.35
}
],
"[\"high_end\", \"1440p\"]": [
{
"value": 2,
"prob": 0.25
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.25
}
],
"[\"high_end\", \"2160p\"]": [
{
"value": 2,
"prob": 0.4
},
{
"value": 4,
"prob": 0.45
},
{
"value": 8,
"prob": 0.15
}
],
"[\"high_end\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.3
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.2
}
],
"[\"workstation\", \"1080p\"]": [
{
"value": 2,
"prob": 0.15
},
{
"value": 4,
"prob": 0.5
},
{
"value": 8,
"prob": 0.35
}
],
"[\"workstation\", \"1440p\"]": [
{
"value": 2,
"prob": 0.15
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.3
}
],
"[\"workstation\", \"2160p\"]": [
{
"value": 2,
"prob": 0.25
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.2
}
],
"[\"workstation\", \"ultrawide\"]": [
{
"value": 2,
"prob": 0.2
},
{
"value": 4,
"prob": 0.55
},
{
"value": 8,
"prob": 0.25
}
]
}
}

View file

@ -0,0 +1,54 @@
{
"_meta": {
"name": "screen | gpu_class",
"parents": ["gpu_class"],
"child": "screen",
"value_shape": {"w": "int css px", "h": "int css px", "dpr": "float", "aw": "availWidth", "ah": "availHeight"},
"source": "Curated Windows desktop/laptop resolutions. Restricted to width >= 1920 to avoid FP Pro's server-side screen_resolution normalization (FP Pro sorts non-standard landscape resolutions like 1536x864 into [smaller, larger] order in raw_device_attributes.screen_resolution, appearing flipped vs our spoof). All retained resolutions are common and NOT normalized by FP Pro."
},
"table": {
"integrated_old": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.90},
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.10}
],
"integrated_modern": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.60},
{"value": {"w": 1920, "h": 1080, "dpr": 1.25, "aw": 1920, "ah": 1040}, "prob": 0.18},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.10},
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.08},
{"value": {"w": 2560, "h": 1600, "dpr": 1.25, "aw": 2560, "ah": 1560}, "prob": 0.04}
],
"low_end": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.70},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.20},
{"value": {"w": 1920, "h": 1200, "dpr": 1.0, "aw": 1920, "ah": 1160}, "prob": 0.10}
],
"mid_range": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.50},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.25},
{"value": {"w": 1920, "h": 1080, "dpr": 1.25, "aw": 1920, "ah": 1040}, "prob": 0.08},
{"value": {"w": 2560, "h": 1440, "dpr": 1.25, "aw": 2560, "ah": 1400}, "prob": 0.05},
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.04},
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.04},
{"value": {"w": 2560, "h": 1080, "dpr": 1.0, "aw": 2560, "ah": 1040}, "prob": 0.04}
],
"high_end": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.25},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.30},
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.15},
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.10},
{"value": {"w": 3440, "h": 1440, "dpr": 1.0, "aw": 3440, "ah": 1400}, "prob": 0.08},
{"value": {"w": 2560, "h": 1440, "dpr": 1.25, "aw": 2560, "ah": 1400}, "prob": 0.05},
{"value": {"w": 5120, "h": 1440, "dpr": 1.0, "aw": 5120, "ah": 1400}, "prob": 0.04},
{"value": {"w": 3840, "h": 2160, "dpr": 2.0, "aw": 3840, "ah": 2120}, "prob": 0.03}
],
"workstation": [
{"value": {"w": 1920, "h": 1080, "dpr": 1.0, "aw": 1920, "ah": 1040}, "prob": 0.25},
{"value": {"w": 2560, "h": 1440, "dpr": 1.0, "aw": 2560, "ah": 1400}, "prob": 0.25},
{"value": {"w": 3840, "h": 2160, "dpr": 1.0, "aw": 3840, "ah": 2120}, "prob": 0.25},
{"value": {"w": 3840, "h": 2160, "dpr": 1.5, "aw": 3840, "ah": 2120}, "prob": 0.15},
{"value": {"w": 5120, "h": 2880, "dpr": 1.0, "aw": 5120, "ah": 2840}, "prob": 0.05},
{"value": {"w": 3840, "h": 2400, "dpr": 1.5, "aw": 3840, "ah": 2360}, "prob": 0.05}
]
}
}

View file

@ -0,0 +1,761 @@
{
"_meta": "screen given (gpu_class, intra_tier)",
"table": {
"[\"integrated_old\", \"budget\"]": [
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.78
},
{
"value": {
"w": 1280,
"h": 800,
"aw": 1280,
"ah": 760,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 1024,
"h": 768,
"aw": 1024,
"ah": 728,
"dpr": 1.0
},
"prob": 0.07
}
],
"[\"integrated_old\", \"standard\"]": [
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 1600,
"h": 900,
"aw": 1600,
"ah": 860,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 1280,
"h": 800,
"aw": 1280,
"ah": 760,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.1
}
],
"[\"integrated_old\", \"premium\"]": [
{
"value": {
"w": 1600,
"h": 900,
"aw": 1600,
"ah": 860,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.15
}
],
"[\"integrated_modern\", \"budget\"]": [
{
"value": {
"w": 1366,
"h": 768,
"aw": 1366,
"ah": 728,
"dpr": 1.0
},
"prob": 0.3
},
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.65
},
{
"value": {
"w": 1600,
"h": 900,
"aw": 1600,
"ah": 860,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"integrated_modern\", \"standard\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.7
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.12
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.08
}
],
"[\"integrated_modern\", \"premium\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.3
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
}
],
"[\"low_end\", \"budget\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.85
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"low_end\", \"standard\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.6
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"low_end\", \"premium\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.25
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"mid_range\", \"budget\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.75
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"mid_range\", \"standard\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"mid_range\", \"premium\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.5
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1080,
"aw": 2560,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"high_end\", \"budget\"]": [
{
"value": {
"w": 1920,
"h": 1080,
"aw": 1920,
"ah": 1040,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.2
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"high_end\", \"standard\"]": [
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.4
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 5120,
"h": 1440,
"aw": 5120,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"high_end\", \"premium\"]": [
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 5120,
"h": 1440,
"aw": 5120,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 7680,
"h": 2160,
"aw": 7680,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"workstation\", \"budget\"]": [
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.3
},
{
"value": {
"w": 1920,
"h": 1200,
"aw": 1920,
"ah": 1160,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.05
}
],
"[\"workstation\", \"standard\"]": [
{
"value": {
"w": 2560,
"h": 1440,
"aw": 2560,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.3
},
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.45
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3440,
"h": 1440,
"aw": 3440,
"ah": 1400,
"dpr": 1.0
},
"prob": 0.1
}
],
"[\"workstation\", \"premium\"]": [
{
"value": {
"w": 3840,
"h": 2160,
"aw": 3840,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.55
},
{
"value": {
"w": 2560,
"h": 1600,
"aw": 2560,
"ah": 1560,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 5120,
"h": 2160,
"aw": 5120,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.15
},
{
"value": {
"w": 3840,
"h": 2400,
"aw": 3840,
"ah": 2360,
"dpr": 1.0
},
"prob": 0.1
},
{
"value": {
"w": 7680,
"h": 2160,
"aw": 7680,
"ah": 2120,
"dpr": 1.0
},
"prob": 0.05
}
]
}
}

View file

@ -0,0 +1,60 @@
{
"_meta": {
"name": "storage_quota_mb | gpu_class",
"parents": ["gpu_class"],
"child": "storage_quota_mb",
"value_shape": "int megabytes (reported as navigator.storage.estimate().quota / (1024*1024))",
"description": "Storage quota override returned by navigator.storage.estimate(). Real Firefox computes quota from disk space, which is a fingerprinting signal (stable per host). We replace it with a realistic per-session value conditioned on gpu_class — modern/high-end users tend to have larger SSDs, integrated_old older/smaller disks.",
"source": "Typical Windows 11 disk sizes 2024-2026 per segment: budget laptops 256-512GB SSD, mainstream 512GB-1TB SSD, gaming/workstation 1-4TB SSD. Firefox default storage quota is ~50% of available disk (capped). We simulate that computation per-tier.",
"rationale": "Values are pre-quota-limit MB as Firefox would return to navigator.storage.estimate().quota. Probabilities cover realistic ranges centered on the segment median. A single session returns one value; fingerprinters that hash the exact quota get per-session variance, matching cross-user diversity."
},
"table": {
"integrated_old": [
{"value": 61440, "prob": 0.30},
{"value": 102400, "prob": 0.25},
{"value": 122880, "prob": 0.20},
{"value": 204800, "prob": 0.15},
{"value": 40960, "prob": 0.10}
],
"integrated_modern": [
{"value": 204800, "prob": 0.30},
{"value": 307200, "prob": 0.25},
{"value": 122880, "prob": 0.15},
{"value": 409600, "prob": 0.15},
{"value": 102400, "prob": 0.10},
{"value": 512000, "prob": 0.05}
],
"low_end": [
{"value": 122880, "prob": 0.30},
{"value": 204800, "prob": 0.25},
{"value": 102400, "prob": 0.20},
{"value": 61440, "prob": 0.15},
{"value": 307200, "prob": 0.10}
],
"mid_range": [
{"value": 307200, "prob": 0.30},
{"value": 409600, "prob": 0.25},
{"value": 204800, "prob": 0.20},
{"value": 512000, "prob": 0.15},
{"value": 716800, "prob": 0.05},
{"value": 122880, "prob": 0.05}
],
"high_end": [
{"value": 512000, "prob": 0.25},
{"value": 716800, "prob": 0.25},
{"value": 409600, "prob": 0.20},
{"value": 1024000, "prob": 0.15},
{"value": 307200, "prob": 0.10},
{"value": 1536000, "prob": 0.05}
],
"workstation": [
{"value": 1024000, "prob": 0.25},
{"value": 1536000, "prob": 0.20},
{"value": 716800, "prob": 0.20},
{"value": 2048000, "prob": 0.15},
{"value": 512000, "prob": 0.10},
{"value": 3072000, "prob": 0.05},
{"value": 409600, "prob": 0.05}
]
}
}

View file

@ -0,0 +1,305 @@
{
"_meta": "storage_quota_mb given (gpu_class, intra_tier)",
"table": {
"[\"integrated_old\", \"budget\"]": [
{
"value": 32000,
"prob": 0.3
},
{
"value": 64000,
"prob": 0.4
},
{
"value": 128000,
"prob": 0.25
},
{
"value": 256000,
"prob": 0.05
}
],
"[\"integrated_old\", \"standard\"]": [
{
"value": 64000,
"prob": 0.2
},
{
"value": 128000,
"prob": 0.45
},
{
"value": 256000,
"prob": 0.25
},
{
"value": 500000,
"prob": 0.1
}
],
"[\"integrated_old\", \"premium\"]": [
{
"value": 128000,
"prob": 0.2
},
{
"value": 256000,
"prob": 0.45
},
{
"value": 500000,
"prob": 0.3
},
{
"value": 1000000,
"prob": 0.05
}
],
"[\"integrated_modern\", \"budget\"]": [
{
"value": 64000,
"prob": 0.2
},
{
"value": 128000,
"prob": 0.3
},
{
"value": 256000,
"prob": 0.3
},
{
"value": 500000,
"prob": 0.2
}
],
"[\"integrated_modern\", \"standard\"]": [
{
"value": 256000,
"prob": 0.25
},
{
"value": 500000,
"prob": 0.45
},
{
"value": 1000000,
"prob": 0.25
},
{
"value": 2000000,
"prob": 0.05
}
],
"[\"integrated_modern\", \"premium\"]": [
{
"value": 500000,
"prob": 0.25
},
{
"value": 1000000,
"prob": 0.5
},
{
"value": 2000000,
"prob": 0.2
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"low_end\", \"budget\"]": [
{
"value": 128000,
"prob": 0.2
},
{
"value": 256000,
"prob": 0.5
},
{
"value": 500000,
"prob": 0.25
},
{
"value": 1000000,
"prob": 0.05
}
],
"[\"low_end\", \"standard\"]": [
{
"value": 256000,
"prob": 0.2
},
{
"value": 500000,
"prob": 0.5
},
{
"value": 1000000,
"prob": 0.25
},
{
"value": 2000000,
"prob": 0.05
}
],
"[\"low_end\", \"premium\"]": [
{
"value": 500000,
"prob": 0.2
},
{
"value": 1000000,
"prob": 0.5
},
{
"value": 2000000,
"prob": 0.25
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"mid_range\", \"budget\"]": [
{
"value": 256000,
"prob": 0.15
},
{
"value": 500000,
"prob": 0.5
},
{
"value": 1000000,
"prob": 0.3
},
{
"value": 2000000,
"prob": 0.05
}
],
"[\"mid_range\", \"standard\"]": [
{
"value": 500000,
"prob": 0.2
},
{
"value": 1000000,
"prob": 0.55
},
{
"value": 2000000,
"prob": 0.2
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"mid_range\", \"premium\"]": [
{
"value": 1000000,
"prob": 0.4
},
{
"value": 2000000,
"prob": 0.45
},
{
"value": 4000000,
"prob": 0.15
}
],
"[\"high_end\", \"budget\"]": [
{
"value": 500000,
"prob": 0.15
},
{
"value": 1000000,
"prob": 0.5
},
{
"value": 2000000,
"prob": 0.3
},
{
"value": 4000000,
"prob": 0.05
}
],
"[\"high_end\", \"standard\"]": [
{
"value": 1000000,
"prob": 0.3
},
{
"value": 2000000,
"prob": 0.5
},
{
"value": 4000000,
"prob": 0.2
}
],
"[\"high_end\", \"premium\"]": [
{
"value": 2000000,
"prob": 0.4
},
{
"value": 4000000,
"prob": 0.45
},
{
"value": 8000000,
"prob": 0.15
}
],
"[\"workstation\", \"budget\"]": [
{
"value": 1000000,
"prob": 0.3
},
{
"value": 2000000,
"prob": 0.5
},
{
"value": 4000000,
"prob": 0.2
}
],
"[\"workstation\", \"standard\"]": [
{
"value": 2000000,
"prob": 0.3
},
{
"value": 4000000,
"prob": 0.5
},
{
"value": 8000000,
"prob": 0.2
}
],
"[\"workstation\", \"premium\"]": [
{
"value": 4000000,
"prob": 0.35
},
{
"value": 8000000,
"prob": 0.45
},
{
"value": 16000000,
"prob": 0.2
}
]
}
}

View file

@ -0,0 +1,650 @@
{
"_source": "browserforge fingerprint-network.zip, FF/Windows slice, 3 userAgent leaves merged",
"_ff_win_uas": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
],
"_note": "Per-node weighted distributions. Marginal over the 3 Firefox/Windows UAs present in browserforge data.",
"distributions": {
"appCodeName": [
[
"Mozilla",
1.0
]
],
"appVersion": [
[
"5.0 (Windows)",
1.0
]
],
"doNotTrack": [
[
"1",
1.0
]
],
"extraProperties": [
[
{
"vendorFlavors": [],
"globalPrivacyControl": true,
"pdfViewerEnabled": true,
"installedApps": []
},
0.6111111111111112
],
[
{
"vendorFlavors": [],
"globalPrivacyControl": null,
"pdfViewerEnabled": null,
"installedApps": []
},
0.3888888888888889
]
],
"maxTouchPoints": [
[
0,
0.5777777777777778
],
[
1,
0.3666666666666667
],
[
10,
0.05555555555555555
]
],
"oscpu": [
[
"Windows NT 10.0; Win64; x64",
1.0
]
],
"webdriver": [
[
false,
1.0
]
],
"productSub": [
[
"20100101",
1.0
]
],
"hardwareConcurrency": [
[
12,
0.3333333333333333
],
[
16,
0.2777777777777778
],
[
8,
0.13333333333333333
],
[
24,
0.13333333333333333
],
[
32,
0.06666666666666667
],
[
6,
0.05555555555555555
]
],
"platform": [
[
"Win32",
1.0
]
],
"screen": [
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 772,
"hasHDR": false,
"width": 1536,
"height": 960,
"availWidth": 1536,
"availHeight": 960,
"clientWidth": 0,
"clientHeight": 21,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 751,
"outerHeight": 886,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1.25
},
0.3333333333333333
],
[
{
"availTop": 0,
"availLeft": 2176,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 2169,
"hasHDR": false,
"width": 2176,
"height": 1224,
"availWidth": 2176,
"availHeight": 1176,
"clientWidth": 0,
"clientHeight": 19,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 2190,
"outerHeight": 1190,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1.7647058823529411
},
0.13333333333333333
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 318,
"hasHDR": false,
"width": 2560,
"height": 1440,
"availWidth": 2560,
"availHeight": 1440,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 2121,
"outerHeight": 1191,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.1111111111111111
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 652,
"hasHDR": false,
"width": 2752,
"height": 1152,
"availWidth": 2752,
"availHeight": 1104,
"clientWidth": 0,
"clientHeight": 19,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1806,
"outerHeight": 1104,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1.25
},
0.06666666666666667
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 1912,
"hasHDR": false,
"width": 1920,
"height": 1080,
"availWidth": 1920,
"availHeight": 1032,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1936,
"outerHeight": 1048,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.06666666666666667
],
[
{
"availTop": 29,
"availLeft": 21,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 15,
"hasHDR": false,
"width": 1376,
"height": 774,
"availWidth": 1355,
"availHeight": 745,
"clientWidth": 0,
"clientHeight": 21,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1352,
"outerHeight": 749,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 58,
"hasHDR": false,
"width": 1707,
"height": 960,
"availWidth": 1707,
"availHeight": 928,
"clientWidth": 0,
"clientHeight": 19,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1564,
"outerHeight": 793,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 34,
"hasHDR": false,
"width": 5120,
"height": 1440,
"availWidth": 5120,
"availHeight": 1392,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 2462,
"outerHeight": 1399,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 1920,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 1912,
"hasHDR": false,
"width": 1920,
"height": 1080,
"availWidth": 1920,
"availHeight": 1032,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1936,
"outerHeight": 1048,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.05555555555555555
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 95,
"hasHDR": false,
"width": 1366,
"height": 768,
"availWidth": 1366,
"availHeight": 768,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1294,
"outerHeight": 1280,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.03333333333333333
],
[
{
"availTop": 0,
"availLeft": 0,
"pageXOffset": 0,
"pageYOffset": 0,
"screenX": 1273,
"hasHDR": false,
"width": 2560,
"height": 1440,
"availWidth": 2560,
"availHeight": 1400,
"clientWidth": 0,
"clientHeight": 18,
"innerWidth": 0,
"innerHeight": 0,
"outerWidth": 1294,
"outerHeight": 1407,
"colorDepth": 24,
"pixelDepth": 24,
"devicePixelRatio": 1
},
0.03333333333333333
]
],
"pluginsData": [
[
{
"plugins": [
{
"name": "PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "Chrome PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "Chromium PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "Microsoft Edge PDF Viewer",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
},
{
"name": "WebKit built-in PDF",
"description": "Portable Document Format",
"filename": "internal-pdf-viewer",
"mimeTypes": [
{
"type": "application/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
},
{
"type": "text/pdf",
"suffixes": "pdf",
"description": "Portable Document Format",
"enabledPlugin": "PDF Viewer"
}
]
}
],
"mimeTypes": [
"Portable Document Format~~application/pdf~~pdf",
"Portable Document Format~~text/pdf~~pdf"
]
},
0.6111111111111112
],
[
{
"plugins": [],
"mimeTypes": []
},
0.3888888888888889
]
],
"audioCodecs": [
[
{
"ogg": "probably",
"mp3": "maybe",
"wav": "probably",
"m4a": "maybe",
"aac": "maybe"
},
1.0
]
],
"videoCodecs": [
[
{
"ogg": "",
"h264": "probably",
"webm": "probably"
},
1.0
]
],
"videoCard": [
[
{
"renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
"vendor": "Google Inc. (NVIDIA)"
},
0.8444444444444444
],
[
{
"renderer": "ANGLE (AMD, Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0), or similar",
"vendor": "Google Inc. (AMD)"
},
0.08888888888888889
],
[
{
"renderer": "ANGLE (Intel, Intel(R) HD Graphics 400 Direct3D11 vs_5_0 ps_5_0), or similar",
"vendor": "Google Inc. (Intel)"
},
0.06666666666666667
]
],
"multimediaDevices": [
[
{
"speakers": [],
"micros": [],
"webcams": []
},
1.0
]
],
"fonts": [
[
[],
0.3888888888888889
],
[
[
"Calibri",
"HELV",
"MS UI Gothic",
"Marlett",
"Segoe UI Light",
"Small Fonts"
],
0.3222222222222222
],
[
[
"Agency FB",
"Calibri",
"Century",
"Century Gothic",
"Franklin Gothic",
"HELV",
"Haettenschweiler",
"Leelawadee",
"Lucida Bright",
"Lucida Sans",
"MS Outlook",
"MS Reference Specialty",
"MS UI Gothic",
"MT Extra",
"Marlett",
"Microsoft Uighur",
"Monotype Corsiva",
"Pristina",
"Segoe UI Light",
"Small Fonts"
],
0.13333333333333333
],
[
[
"AvantGarde Bk BT",
"Calibri",
"Clarendon",
"Franklin Gothic",
"Futura Bk BT",
"Futura Md BT",
"GOTHAM",
"HELV",
"Humanst521 BT",
"MS UI Gothic",
"MYRIAD PRO",
"Marlett",
"Minion Pro",
"Segoe UI Light",
"Small Fonts",
"TRAJAN PRO"
],
0.06666666666666667
],
[
[
"Calibri",
"MS UI Gothic",
"Marlett",
"Segoe UI Light"
],
0.05555555555555555
],
[
[
"Agency FB",
"Calibri",
"Century",
"Century Gothic",
"Franklin Gothic",
"Futura Bk BT",
"Futura Md BT",
"GOTHAM",
"HELV",
"Haettenschweiler",
"Humanst521 BT",
"Lucida Bright",
"Lucida Sans",
"MS Outlook",
"MS Reference Specialty",
"MS UI Gothic",
"MT Extra",
"MYRIAD PRO",
"Marlett",
"Minion Pro",
"Monotype Corsiva",
"Pristina",
"Segoe UI Light",
"Small Fonts"
],
0.03333333333333333
]
]
}
}

View file

@ -0,0 +1,670 @@
{
"core": [
{
"name": "arial",
"factor": 0.978
},
{
"name": "arial black",
"factor": 1.168
},
{
"name": "arial narrow",
"factor": 0.854
},
{
"name": "bahnschrift",
"factor": 0.951
},
{
"name": "bahnschrift condensed",
"factor": 1.179
},
{
"name": "bahnschrift light",
"factor": 0.908
},
{
"name": "bahnschrift light condensed",
"factor": 1.053
},
{
"name": "bahnschrift light semicondensed",
"factor": 1.063
},
{
"name": "bahnschrift semibold",
"factor": 0.926
},
{
"name": "bahnschrift semibold condensed",
"factor": 1.131
},
{
"name": "bahnschrift semibold semicondensed",
"factor": 1.043
},
{
"name": "bahnschrift semicondensed",
"factor": 1.098
},
{
"name": "bahnschrift semilight",
"factor": 0.873
},
{
"name": "bahnschrift semilight condensed",
"factor": 0.905
},
{
"name": "bahnschrift semilight semicondensed",
"factor": 1.135
},
{
"name": "calibri light",
"factor": 0.901
},
{
"name": "cambria",
"factor": 1.063
},
{
"name": "cambria math",
"factor": 1.041
},
{
"name": "candara",
"factor": 0.927
},
{
"name": "candara light",
"factor": 0.892
},
{
"name": "cascadia code",
"factor": 1.139
},
{
"name": "cascadia mono",
"factor": 0.95
},
{
"name": "comic sans ms",
"factor": 1.087
},
{
"name": "consolas",
"factor": 1.158
},
{
"name": "constantia",
"factor": 1.052
},
{
"name": "corbel",
"factor": 0.895
},
{
"name": "corbel light",
"factor": 0.876
},
{
"name": "courier new",
"factor": 1.187
},
{
"name": "ebrima",
"factor": 0.962
},
{
"name": "franklin gothic medium",
"factor": 1.031
},
{
"name": "gabriola",
"factor": 1.077
},
{
"name": "georgia",
"factor": 1.094
},
{
"name": "hololens mdl2 assets",
"factor": 1.145
},
{
"name": "impact",
"factor": 0.862
},
{
"name": "ink free",
"factor": 1.048
},
{
"name": "leelawadee ui semilight",
"factor": 1.061
},
{
"name": "lucida console",
"factor": 1.131
},
{
"name": "lucida sans unicode",
"factor": 1.014
},
{
"name": "malgun gothic",
"factor": 1.128
},
{
"name": "malgun gothic semilight",
"factor": 1.051
},
{
"name": "marlett",
"factor": 0.855
},
{
"name": "microsoft himalaya",
"factor": 1.022
},
{
"name": "microsoft jhenghei",
"factor": 1.061
},
{
"name": "microsoft jhenghei light",
"factor": 0.94
},
{
"name": "microsoft jhenghei ui",
"factor": 1.136
},
{
"name": "microsoft jhenghei ui light",
"factor": 0.826
},
{
"name": "microsoft new tai lue",
"factor": 0.953
},
{
"name": "microsoft phagspa",
"factor": 0.988
},
{
"name": "microsoft sans serif",
"factor": 0.941
},
{
"name": "microsoft tai le",
"factor": 0.957
},
{
"name": "microsoft yahei",
"factor": 1.076
},
{
"name": "microsoft yahei light",
"factor": 1.043
},
{
"name": "microsoft yahei ui",
"factor": 1.076
},
{
"name": "microsoft yahei ui light",
"factor": 1.094
},
{
"name": "microsoft yi baiti",
"factor": 1.008
},
{
"name": "mingliu-extb",
"factor": 1.173
},
{
"name": "mingliu_hkscs-extb",
"factor": 1.101
},
{
"name": "mongolian baiti",
"factor": 1.056
},
{
"name": "ms gothic",
"factor": 1.119
},
{
"name": "ms pgothic",
"factor": 1.074
},
{
"name": "mv boli",
"factor": 0.932
},
{
"name": "nirmala ui",
"factor": 0.916
},
{
"name": "nirmala ui semilight",
"factor": 1.15
},
{
"name": "nsimsun",
"factor": 1.119
},
{
"name": "palatino linotype",
"factor": 1.067
},
{
"name": "pmingliu-extb",
"factor": 1.16
},
{
"name": "sans serif collection",
"factor": 0.854
},
{
"name": "segoe mdl2 assets",
"factor": 0.858
},
{
"name": "segoe print",
"factor": 1.033
},
{
"name": "segoe script",
"factor": 1.058
},
{
"name": "segoe ui",
"factor": 0.948
},
{
"name": "segoe ui black",
"factor": 1.148
},
{
"name": "segoe ui emoji",
"factor": 0.924
},
{
"name": "segoe ui historic",
"factor": 0.969
},
{
"name": "segoe ui semibold",
"factor": 0.965
},
{
"name": "segoe ui semilight",
"factor": 1.055
},
{
"name": "segoe ui symbol",
"factor": 0.972
},
{
"name": "segoe ui variable",
"factor": 0.907
},
{
"name": "simsun",
"factor": 1.113
},
{
"name": "simsun-extb",
"factor": 1.095
},
{
"name": "sitka banner",
"factor": 1.078
},
{
"name": "sitka display",
"factor": 0.838
},
{
"name": "sitka heading",
"factor": 0.843
},
{
"name": "sitka small",
"factor": 1.131
},
{
"name": "sitka subheading",
"factor": 0.942
},
{
"name": "sitka text",
"factor": 1.044
},
{
"name": "sylfaen",
"factor": 0.971
},
{
"name": "symbol",
"factor": 0.866
},
{
"name": "tahoma",
"factor": 0.962
},
{
"name": "times new roman",
"factor": 0.926
},
{
"name": "trebuchet ms",
"factor": 0.912
},
{
"name": "verdana",
"factor": 1.128
},
{
"name": "webdings",
"factor": 0.849
},
{
"name": "wingdings",
"factor": 0.864
},
{
"name": "wingdings 2",
"factor": 1.093
},
{
"name": "wingdings 3",
"factor": 0.861
},
{
"name": "yu gothic",
"factor": 1.069
},
{
"name": "yu gothic light",
"factor": 1.038
},
{
"name": "yu gothic medium",
"factor": 1.161
},
{
"name": "yu gothic ui",
"factor": 0.886
},
{
"name": "yu gothic ui light",
"factor": 1.093
},
{
"name": "yu gothic ui semibold",
"factor": 0.942
},
{
"name": "yu gothic ui semilight",
"factor": 0.923
}
],
"optional": [
{
"name": "aparajita",
"factor": 0.94
},
{
"name": "arabic typesetting",
"factor": 1.093
},
{
"name": "arial unicode ms",
"factor": 1.047
},
{
"name": "batang",
"factor": 0.911
},
{
"name": "calibri",
"factor": 0.934
},
{
"name": "century",
"factor": 1.078
},
{
"name": "century gothic",
"factor": 0.886
},
{
"name": "dengxian",
"factor": 0.846
},
{
"name": "dfkai-sb",
"factor": 0.946
},
{
"name": "dokchampa",
"factor": 1.162
},
{
"name": "estrangelo edessa",
"factor": 1.095
},
{
"name": "euphemia",
"factor": 1.117
},
{
"name": "fangsong",
"factor": 0.942
},
{
"name": "franklin gothic",
"factor": 0.942
},
{
"name": "gadugi",
"factor": 0.945
},
{
"name": "gautami",
"factor": 1.134
},
{
"name": "haettenschweiler",
"factor": 0.874
},
{
"name": "helv",
"factor": 0.923
},
{
"name": "iskoola pota",
"factor": 0.857
},
{
"name": "javanese text",
"factor": 1.083
},
{
"name": "kaiti",
"factor": 1.145
},
{
"name": "kalinga",
"factor": 0.953
},
{
"name": "kartika",
"factor": 0.882
},
{
"name": "khmer ui",
"factor": 1.137
},
{
"name": "kokila",
"factor": 0.919
},
{
"name": "lao ui",
"factor": 0.904
},
{
"name": "latha",
"factor": 0.839
},
{
"name": "leelawadee",
"factor": 0.982
},
{
"name": "leelawadee ui",
"factor": 0.992
},
{
"name": "levenim mt",
"factor": 1.054
},
{
"name": "mangal",
"factor": 1.154
},
{
"name": "meiryo",
"factor": 1.165
},
{
"name": "meiryo ui",
"factor": 1.081
},
{
"name": "microsoft uighur",
"factor": 0.969
},
{
"name": "monotype corsiva",
"factor": 0.939
},
{
"name": "ms mincho",
"factor": 1.112
},
{
"name": "ms outlook",
"factor": 0.921
},
{
"name": "ms pmincho",
"factor": 0.925
},
{
"name": "ms reference sans serif",
"factor": 0.858
},
{
"name": "ms reference specialty",
"factor": 0.958
},
{
"name": "ms ui gothic",
"factor": 1.097
},
{
"name": "mt extra",
"factor": 0.905
},
{
"name": "myanmar text",
"factor": 0.961
},
{
"name": "nyala",
"factor": 1.108
},
{
"name": "plantagenet cherokee",
"factor": 1.115
},
{
"name": "pmingliu",
"factor": 1.125
},
{
"name": "pristina",
"factor": 1.023
},
{
"name": "raavi",
"factor": 0.826
},
{
"name": "segoe fluent icons",
"factor": 1.049
},
{
"name": "segoe ui light",
"factor": 0.918
},
{
"name": "shonar bangla",
"factor": 1.141
},
{
"name": "shruti",
"factor": 1.172
},
{
"name": "simhei",
"factor": 1.141
},
{
"name": "simkai",
"factor": 1.1
},
{
"name": "small fonts",
"factor": 0.849
},
{
"name": "traditional arabic",
"factor": 0.893
},
{
"name": "tunga",
"factor": 0.825
},
{
"name": "urdu typesetting",
"factor": 0.869
},
{
"name": "utsaah",
"factor": 1.144
},
{
"name": "vani",
"factor": 1.078
},
{
"name": "vijaya",
"factor": 0.917
},
{
"name": "vrinda",
"factor": 0.985
},
{
"name": "yu mincho",
"factor": 0.867
}
]
}

View file

@ -0,0 +1,17 @@
{
"_meta": {
"name": "audio (joint: sample_rate, output_latency_ms, max_channel_count)",
"parents": [],
"child": "audio",
"source": "Curated from common Windows WASAPI/DirectSound configs on FF desktop"
},
"table": [
{"value": {"rate": 44100, "latency": 40, "channels": 2}, "prob": 0.20},
{"value": {"rate": 48000, "latency": 30, "channels": 2}, "prob": 0.25},
{"value": {"rate": 48000, "latency": 20, "channels": 2}, "prob": 0.15},
{"value": {"rate": 48000, "latency": 40, "channels": 6}, "prob": 0.08},
{"value": {"rate": 48000, "latency": 60, "channels": 2}, "prob": 0.12},
{"value": {"rate": 44100, "latency": 50, "channels": 2}, "prob": 0.10},
{"value": {"rate": 48000, "latency": 25, "channels": 2}, "prob": 0.10}
]
}

View file

@ -0,0 +1,14 @@
{
"_meta": {
"name": "Independent marginal priors (no parents)",
"source": "StatCounter GlobalStats + Firefox defaults + community data 2025-2026",
"note": "Codec prefs (av1_enabled, webm_encoder_enabled, hw_video_decoding, wmf_enabled, ffvpx_enabled) moved to cpt_codec_given_class.json — they correlate with GPU class via Firefox version / user-tier distribution."
},
"dark_theme": {
"_note": "0=light, 1=dark. StatCounter ~40% users report dark theme preference on desktop.",
"table": [
{"value": 0, "prob": 0.60},
{"value": 1, "prob": 0.40}
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,259 @@
"""Public dataclass surface for fpforge."""
from __future__ import annotations
from dataclasses import dataclass, field, replace as _dc_replace
from typing import Any, Dict, List, Optional
from ._sampler import sample as _sample_raw
@dataclass(frozen=True)
class GPUProfile:
vendor: str
renderer: str
class_tier: str # "low_end" | "mid_range" | "high_end" | "integrated_old" | "integrated_modern"
@dataclass(frozen=True)
class ScreenProfile:
width: int
height: int
avail_width: int
avail_height: int
dpr: float
tier: str
@dataclass(frozen=True)
class HardwareProfile:
concurrency: int
storage_quota_mb: int
@dataclass(frozen=True)
class AudioProfile:
sample_rate: int
output_latency_ms: int
max_channel_count: int
@dataclass(frozen=True)
class CodecProfile:
av1_enabled: bool
webm_encoder_enabled: bool
mediasource_webm: bool
mediasource_mp4: bool
webspeech_synth: bool
@dataclass(frozen=True)
class WebGLProfile:
msaa_samples: int
# ──────────────────────────────────────────────────────────────────────
# Pin map: flat dotted-path -> value. Set via `pin=` on generate_profile.
#
# Supported keys:
# "gpu.vendor", "gpu.renderer", "gpu.class_tier"
# "screen.width", "screen.height", "screen.avail_width",
# "screen.avail_height", "screen.dpr", "screen.tier"
# "hardware.concurrency", "hardware.storage_quota_mb"
# "audio.sample_rate", "audio.output_latency_ms",
# "audio.max_channel_count"
# "codec.av1_enabled", "codec.webm_encoder_enabled",
# "codec.mediasource_webm", "codec.mediasource_mp4",
# "codec.webspeech_synth"
# "webgl.msaa_samples"
# "fonts" (replaces the whole list)
# "dark_theme"
# ──────────────────────────────────────────────────────────────────────
_PIN_GROUPS = {
"gpu": {"vendor", "renderer", "class_tier"},
"screen": {"width", "height", "avail_width", "avail_height", "dpr", "tier"},
"hardware": {"concurrency", "storage_quota_mb"},
"audio": {"sample_rate", "output_latency_ms", "max_channel_count"},
"codec": {
"av1_enabled", "webm_encoder_enabled",
"mediasource_webm", "mediasource_mp4", "webspeech_synth",
},
"webgl": {"msaa_samples"},
}
_PIN_TOP = {"fonts", "dark_theme"}
def _validate_pin_key(key: str) -> None:
if key in _PIN_TOP:
return
if "." not in key:
raise ValueError(
f"pin key {key!r} is not valid. "
f"Use 'group.field' (e.g. 'screen.width') or one of {sorted(_PIN_TOP)}."
)
group, field_name = key.split(".", 1)
if group not in _PIN_GROUPS:
raise ValueError(
f"pin key {key!r}: unknown group {group!r}. "
f"Known groups: {sorted(_PIN_GROUPS)}."
)
if field_name not in _PIN_GROUPS[group]:
raise ValueError(
f"pin key {key!r}: unknown field {field_name!r} in group {group!r}. "
f"Known fields: {sorted(_PIN_GROUPS[group])}."
)
@dataclass(frozen=True)
class Profile:
"""Coherent browser fingerprint profile sampled from a single integer seed.
Use `generate_profile(seed)` to build one. Pin specific values at build
time with `generate_profile(seed, pin={"screen.width": 2560, ...})`.
"""
seed: int
gpu: GPUProfile
screen: ScreenProfile
hardware: HardwareProfile
audio: AudioProfile
codec: CodecProfile
webgl: WebGLProfile
fonts: List[str]
dark_theme: bool
_raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
def to_prefs_dict(self) -> Dict[str, Any]:
"""Return the flat dict of raw sampler fields, as produced by the
underlying Bayesian sampler. Stable across releases for a given seed."""
return dict(self._raw)
# Mapping from flat pin key -> raw sampler dict key, so `to_prefs_dict()`
# and `stealthfox.prefs.translate_profile_to_prefs` observe the pinned value.
_PIN_TO_RAW = {
"gpu.vendor": "webgl_vendor",
"gpu.renderer": "webgl_renderer",
"gpu.class_tier": "gpu_class",
"screen.width": "screen_w",
"screen.height": "screen_h",
"screen.avail_width": "screen_avail_w",
"screen.avail_height": "screen_avail_h",
"screen.dpr": "dpr",
"screen.tier": "screen_tier",
"hardware.concurrency": "hw_concurrency",
"hardware.storage_quota_mb": "storage_quota_mb",
"audio.sample_rate": "audio_sample_rate",
"audio.output_latency_ms": "audio_output_latency_ms",
"audio.max_channel_count": "audio_max_channel_count",
"codec.av1_enabled": "av1_enabled",
"codec.webm_encoder_enabled": "webm_encoder_enabled",
"codec.mediasource_webm": "mediasource_webm",
"codec.mediasource_mp4": "mediasource_mp4",
"codec.webspeech_synth": "webspeech_synth",
"webgl.msaa_samples": "msaa_samples",
"dark_theme": "dark_theme",
# "fonts" is a list — handled specially (joined into font_whitelist).
}
def _apply_pins_to_raw(raw: Dict[str, Any], pin: Dict[str, Any]) -> Dict[str, Any]:
"""Return a copy of `raw` with the pinned sampler-level fields updated."""
out = dict(raw)
for key, value in pin.items():
if key == "fonts":
if not isinstance(value, (list, tuple)):
raise TypeError("pin 'fonts' must be a list/tuple of strings")
out["font_whitelist"] = ",".join(value)
continue
raw_key = _PIN_TO_RAW.get(key)
if raw_key is None:
# Shouldn't happen after validation, but guard anyway.
continue
out[raw_key] = value
return out
def generate_profile(seed: int, pin: Optional[Dict[str, Any]] = None) -> Profile:
"""Return a deterministic Profile for the given integer seed.
pin: optional dict of dotted-path keys (e.g. "screen.width", "gpu.renderer")
to values that are FORCED in the resulting profile. All other fields
are still sampled from the Bayesian network based on `seed`, so the
same seed + same pin map always yields the same profile.
Example force a specific GPU and screen while letting everything
else vary with the seed (via the public stealthfox API):
from stealthfox import Stealthfox
with Stealthfox(
seed=42,
pin={
"gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11)",
"gpu.vendor": "Google Inc. (NVIDIA)",
"gpu.class_tier": "high_end",
"screen.width": 2560,
"screen.height": 1440,
},
) as browser:
...
Warning: pinning breaks Bayesian coherence across the pinned fields
(if you pin a high-end GPU but leave screen unpinned, you may get a
1080p screen that would be unusual for that GPU class). Pin related
fields together when coherence matters.
Supported keys: see the module-level _PIN_GROUPS / _PIN_TOP tables
or run `help(generate_profile)` after import.
"""
if pin:
for key in pin:
_validate_pin_key(key)
raw = _sample_raw(int(seed))
if pin:
raw = _apply_pins_to_raw(raw, pin)
# Font whitelist is stored as a comma-separated string in raw; split it.
font_wl = raw.get("font_whitelist", "")
if isinstance(font_wl, str):
fonts = [f.strip() for f in font_wl.split(",") if f.strip()]
else:
fonts = list(font_wl) if font_wl else []
return Profile(
seed=int(raw["stealth_seed"]),
gpu=GPUProfile(
vendor=raw["webgl_vendor"],
renderer=raw["webgl_renderer"],
class_tier=raw["gpu_class"],
),
screen=ScreenProfile(
width=int(raw["screen_w"]),
height=int(raw["screen_h"]),
avail_width=int(raw["screen_avail_w"]),
avail_height=int(raw["screen_avail_h"]),
dpr=float(raw["dpr"]),
tier=str(raw.get("screen_tier", "")),
),
hardware=HardwareProfile(
concurrency=int(raw["hw_concurrency"]),
storage_quota_mb=int(raw["storage_quota_mb"]),
),
audio=AudioProfile(
sample_rate=int(raw["audio_sample_rate"]),
output_latency_ms=int(raw["audio_output_latency_ms"]),
max_channel_count=int(raw["audio_max_channel_count"]),
),
codec=CodecProfile(
av1_enabled=bool(raw["av1_enabled"]),
webm_encoder_enabled=bool(raw["webm_encoder_enabled"]),
mediasource_webm=bool(raw["mediasource_webm"]),
mediasource_mp4=bool(raw["mediasource_mp4"]),
webspeech_synth=bool(raw["webspeech_synth"]),
),
webgl=WebGLProfile(msaa_samples=int(raw["msaa_samples"])),
fonts=fonts,
dark_theme=bool(raw["dark_theme"]),
_raw=raw,
)

228
src/stealthfox/_headless.py Normal file
View file

@ -0,0 +1,228 @@
"""Invisible-but-headed browser windows.
Playwright's ``headless=True`` flips Firefox onto a different code path —
no widget tree, software-only rendering, distinct timing and anti-bot
systems can spot the divergence. Running the browser *headed* on a
virtual display gives us the real rendering pipeline while keeping the
windows off the user's screen.
Linux: spawns its own ``Xvfb`` instance, points ``DISPLAY`` at it.
Windows: creates a hidden desktop via ``CreateDesktop`` and binds the
calling thread to it, so Playwright's child processes inherit it.
"""
from __future__ import annotations
import os
import secrets
import subprocess
import sys
import time
from typing import Optional
# Inherited from WSLg / GNOME / etc. these env vars make Firefox prefer a
# Wayland compositor over the X11 DISPLAY we set, so the window leaks onto
# the real desktop. Strip them all before starting.
_WAYLAND_LEAK_VARS = (
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_SESSION_TYPE",
"PULSE_SERVER",
"WSL2_GUI_APPS_ENABLED",
)
class _LinuxVirtualDisplay:
"""Standalone Xvfb instance owned by this Stealthfox session."""
def __init__(self, width: int = 1920, height: int = 1080) -> None:
self._geometry = f"{width}x{height}x24"
self._proc: Optional[subprocess.Popen] = None
self._display: Optional[str] = None
self._saved_env: dict[str, Optional[str]] = {}
def start(self) -> None:
if not _binary_on_path("Xvfb"):
raise RuntimeError(
"stealthfox headless=True requires Xvfb. "
"Install it: sudo apt install xvfb"
)
# Retry: when many workers start in parallel they can pick the same
# display number before any has created its lockfile. Xvfb on the
# losing side exits immediately — try again with a fresh number.
last_err: Optional[Exception] = None
for _ in range(10):
display = self._pick_display()
try:
self._spawn(display)
self._wait_until_ready(display)
self._display = display
self._apply_env(display)
return
except RuntimeError as e:
last_err = e
if self._proc is not None and self._proc.poll() is None:
self._proc.kill()
self._proc = None
raise RuntimeError(f"Xvfb failed to start after 10 attempts: {last_err}")
def _spawn(self, display: str) -> None:
self._proc = subprocess.Popen(
[
"Xvfb", display,
"-screen", "0", self._geometry,
"+extension", "GLX",
"+extension", "RENDER",
"-nolisten", "unix",
"-listen", "tcp",
"-ac",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
def _pick_display(self) -> str:
for n in range(99, 400):
if not os.path.exists(f"/tmp/.X{n}-lock"):
return f":{n}"
raise RuntimeError("no free X display number in :99:399")
def _wait_until_ready(self, display: str) -> None:
# We start Xvfb with -nolisten unix → no /tmp/.X11-unix socket appears.
# Xvfb creates /tmp/.X{n}-lock immediately though — wait for that.
lockfile = f"/tmp/.X{display[1:]}-lock"
deadline = time.monotonic() + 3.0
assert self._proc is not None
while time.monotonic() < deadline:
if self._proc.poll() is not None:
raise RuntimeError(f"Xvfb {display} exited immediately")
if os.path.exists(lockfile):
return
time.sleep(0.02)
raise RuntimeError(f"Xvfb {display} did not become ready in 3s")
def _apply_env(self, display: str) -> None:
keys = ("DISPLAY", "MOZ_ENABLE_WAYLAND", "GDK_BACKEND") + _WAYLAND_LEAK_VARS
for k in keys:
self._saved_env[k] = os.environ.get(k)
for k in _WAYLAND_LEAK_VARS:
os.environ.pop(k, None)
os.environ["DISPLAY"] = display
os.environ["MOZ_ENABLE_WAYLAND"] = "0"
os.environ["GDK_BACKEND"] = "x11"
def stop(self) -> None:
for k, v in self._saved_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
self._saved_env.clear()
if self._proc is not None and self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc.wait(timeout=2)
self._proc = None
self._display = None
class _WindowsVirtualDesktop:
"""A hidden Windows desktop the calling thread is bound to.
Playwright's child processes (node driver → firefox.exe) inherit the
desktop because their ``STARTUPINFO.lpDesktop`` is NULL Windows uses
the calling thread's desktop in that case.
pywin32 ships ``CreateDesktop`` in ``win32service`` but doesn't expose
``SetThreadDesktop`` / ``GetThreadDesktop`` as module functions. We
call them directly via ctypes against ``user32.dll``.
"""
def __init__(self) -> None:
self._desktop = None # PyHDESK from win32service.CreateDesktop
self._original_handle = 0 # raw HDESK int of the previous desktop
def start(self) -> None:
try:
import win32con # type: ignore
import win32service # type: ignore
except ImportError as e:
raise RuntimeError(
"stealthfox headless=True on Windows requires pywin32. "
"Install it: pip install pywin32"
) from e
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
# Save the current desktop handle so we can restore it on stop().
get_thread_desktop = user32.GetThreadDesktop
get_thread_desktop.argtypes = [wintypes.DWORD]
get_thread_desktop.restype = wintypes.HANDLE
self._original_handle = get_thread_desktop(kernel32.GetCurrentThreadId())
name = f"sf_{secrets.token_hex(4)}"
self._desktop = win32service.CreateDesktop(
name, 0, win32con.GENERIC_ALL, None
)
# Bind the calling thread to the new desktop. Children spawned
# afterwards (Playwright driver → firefox.exe) inherit it because
# their STARTUPINFO.lpDesktop is NULL.
set_thread_desktop = user32.SetThreadDesktop
set_thread_desktop.argtypes = [wintypes.HANDLE]
set_thread_desktop.restype = wintypes.BOOL
if not set_thread_desktop(int(self._desktop)):
err = ctypes.get_last_error()
raise RuntimeError(
f"SetThreadDesktop failed (GetLastError={err}). "
"The thread cannot have any windows or hooks; close them first."
)
def stop(self) -> None:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
if self._original_handle:
try:
set_thread_desktop = user32.SetThreadDesktop
set_thread_desktop.argtypes = [wintypes.HANDLE]
set_thread_desktop.restype = wintypes.BOOL
set_thread_desktop(self._original_handle)
except Exception:
pass
self._original_handle = 0
if self._desktop is not None:
try:
self._desktop.CloseDesktop()
except Exception:
pass
self._desktop = None
def make_virtual_display():
"""Return a started/stoppable virtual-display object for this platform.
Stealthfox supports Windows x86_64 and Linux x86_64 only.
"""
if sys.platform == "win32":
return _WindowsVirtualDesktop()
if sys.platform.startswith("linux"):
return _LinuxVirtualDisplay()
raise RuntimeError(
f"stealthfox supports Windows and Linux only (got {sys.platform!r})"
)
def _binary_on_path(name: str) -> bool:
import shutil
return shutil.which(name) is not None

56
src/stealthfox/_proxy.py Normal file
View file

@ -0,0 +1,56 @@
"""Proxy translation shared by sync and async launchers.
SOCKS proxies are driven entirely by the patched Firefox prefs (the
``nsProtocolProxyService`` patch reads ``network.proxy.socks_username``
and ``socks_password``). HTTP/HTTPS proxies go through Playwright's own
``proxy=`` kwarg so it can negotiate Basic auth.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
_SOCKS_SCHEMES = ("socks5://", "socks4://", "socks://")
def configure_proxy(
proxy: Optional[Dict[str, str]],
prefs: Dict[str, Any],
) -> Optional[Dict[str, str]]:
"""Mutate ``prefs`` for SOCKS auth; return what to pass to Playwright.
* ``None`` proxy returns ``None``.
* SOCKS proxy writes the auth prefs and returns ``None`` (Playwright
gets nothing; Firefox does the rest).
* HTTP / HTTPS proxy returns the dict unchanged for Playwright.
"""
if not proxy:
return None
server = (proxy.get("server") or "").strip()
if not server or server.lower() == "direct://":
return None
if not _is_socks_scheme(server):
return proxy
host_port = _strip_scheme(server)
if ":" not in host_port:
return None # malformed, drop silently
host, port_str = host_port.rsplit(":", 1)
prefs["network.proxy.type"] = 1
prefs["network.proxy.socks"] = host
prefs["network.proxy.socks_port"] = int(port_str)
prefs["network.proxy.socks_version"] = 4 if server.lower().startswith("socks4://") else 5
prefs["network.proxy.socks_username"] = proxy.get("username") or ""
prefs["network.proxy.socks_password"] = proxy.get("password") or ""
prefs["network.proxy.socks_remote_dns"] = True
return None
def _is_socks_scheme(server: str) -> bool:
return server.lower().startswith(_SOCKS_SCHEMES)
def _strip_scheme(server: str) -> str:
return server.split("://", 1)[1] if "://" in server else server

178
src/stealthfox/async_api.py Normal file
View file

@ -0,0 +1,178 @@
"""Async Playwright façade — mirrors sync_api but with async/await."""
from __future__ import annotations
import asyncio
import secrets
from typing import Any, Dict, Optional, Union
from playwright.async_api import Browser, Playwright, async_playwright
from ._fpforge import Profile, generate_profile
from ._headless import make_virtual_display
from ._proxy import configure_proxy as _configure_proxy_shared
from .download import ensure_binary
from .launcher import _CHROME_H, _CHROME_W, _TASKBAR_H, _tz_env
from .prefs import translate_profile_to_prefs
def _patch_new_page_sleep(ctx: Any) -> None:
"""Wrap ctx.new_page() to add a brief settle after tab creation.
FF150 with Fission emits an about:newtab navigation ~100ms after a tab
is created. If goto() is called immediately, it races with that internal
navigation and raises "Navigation interrupted by about:newtab". A short
sleep breaks the race without requiring every call-site to know about it.
"""
original_new_page = ctx.new_page
async def patched_new_page(**kw):
page = await original_new_page(**kw)
await asyncio.sleep(0.4)
return page
ctx.new_page = patched_new_page # type: ignore[assignment]
class Stealthfox:
"""Async context manager — see stealthfox.Stealthfox for the sync variant."""
def __init__(
self,
seed: Optional[int] = None,
*,
pin: Optional[Dict[str, Any]] = None,
headless: bool = False,
proxy: Optional[Dict[str, str]] = None,
extra_args: Optional[list[str]] = None,
humanize: Union[bool, float] = True,
locale: str = "en-US",
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
binary_path: Optional[str] = None,
) -> None:
# See sync launcher: `zoom.stealth.fpp.hw_seed` is int32_t — clamp.
self.seed: int = int(seed) if seed is not None else secrets.randbits(31)
self._pin = pin
self._headless = headless
self._proxy = proxy
self._extra_args = list(extra_args or [])
self._humanize = humanize
self._locale = locale
self._timezone = timezone
self._extra_prefs = extra_prefs
self._binary_path = binary_path
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
self._pw: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._virtual_display: Any = None
async def __aenter__(self) -> Browser:
import sys as _sys
executable = self._binary_path or ensure_binary()
prefs = translate_profile_to_prefs(
self._profile,
locale=self._locale,
timezone=self._timezone,
extra_prefs=self._extra_prefs,
virtual_display=bool(self._headless and _sys.platform == "win32"),
)
prefs["stealthfox.humanize"] = bool(self._humanize)
if self._humanize:
cap = 1.5 if self._humanize is True else float(self._humanize)
prefs["stealthfox.humanize.maxTime"] = str(cap)
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
pw_headless = self._resolve_headless()
env = self._build_env()
try:
self._pw = await async_playwright().start()
self._browser = await self._pw.firefox.launch(
executable_path=str(executable),
headless=pw_headless,
firefox_user_prefs=prefs,
proxy=playwright_proxy,
args=self._extra_args,
env=env,
)
except BaseException:
await self._teardown()
raise
self._patch_new_context_defaults(self._browser)
return self._browser
def _patch_new_context_defaults(self, browser: Browser) -> None:
original = browser.new_context
defaults = self._default_context_kwargs()
async def patched(**kw):
merged = dict(defaults)
merged.update(kw)
ctx = await original(**merged)
_patch_new_page_sleep(ctx)
return ctx
browser.new_context = patched # type: ignore[assignment]
def _default_context_kwargs(self) -> Dict[str, Any]:
p = self._profile
kwargs: Dict[str, Any] = {
"viewport": {"width": p.screen.width - _CHROME_W,
"height": p.screen.height - _TASKBAR_H - _CHROME_H},
"screen": {"width": p.screen.width, "height": p.screen.height},
"device_scale_factor": p.screen.dpr,
"color_scheme": "dark" if p.dark_theme else "light",
}
# Pass timezone via Playwright per-realm override (works for every
# IANA name, including no-DST zones that Windows ICU silently drops
# on the global pref path).
if self._timezone:
kwargs["timezone_id"] = self._timezone
if self._locale:
kwargs["locale"] = self._locale
return kwargs
async def __aexit__(self, *exc: Any) -> None:
await self._teardown()
async def _teardown(self) -> None:
if self._browser is not None:
try:
await self._browser.close()
except Exception:
pass
self._browser = None
if self._pw is not None:
try:
await self._pw.stop()
except Exception:
pass
self._pw = None
if self._virtual_display is not None:
try:
self._virtual_display.stop()
except Exception:
pass
self._virtual_display = None
def _build_env(self) -> Dict[str, str]:
import os as _os
env = _os.environ.copy()
if self._timezone:
env["TZ"] = _tz_env(self._timezone)
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate
# matching the proxy egress IP. This avoids the StaticPref IPC
# propagation timing issue between parent and socket processes.
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"):
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"]
return env
def _resolve_headless(self) -> bool:
if not self._headless:
return False
vd = make_virtual_display()
vd.start()
self._virtual_display = vd
return False
__all__ = ["Stealthfox"]

68
src/stealthfox/cli.py Normal file
View file

@ -0,0 +1,68 @@
"""Command-line interface for stealthfox."""
from __future__ import annotations
import argparse
import shutil
import sys
from . import __version__
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
from .download import cache_root, ensure_binary
def _cmd_fetch(_args: argparse.Namespace) -> int:
path = ensure_binary()
print(path)
return 0
def _cmd_path(_args: argparse.Namespace) -> int:
try:
path = ensure_binary()
except Exception as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(path)
return 0
def _cmd_version(_args: argparse.Namespace) -> int:
print(f"stealthfox {__version__}")
print(f"BINARY_VERSION={BINARY_VERSION} (Firefox {FIREFOX_UPSTREAM_VERSION})")
return 0
def _cmd_clear_cache(_args: argparse.Namespace) -> int:
root = cache_root()
if root.exists():
shutil.rmtree(root)
print(f"removed: {root}")
else:
print(f"nothing to remove: {root}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="stealthfox", description="stealthfox CLI")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("fetch", help="download the patched Firefox binary")
sub.add_parser("path", help="print the absolute path to the cached binary")
sub.add_parser("version", help="print wrapper and binary versions")
sub.add_parser("clear-cache", help="remove all cached binaries")
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
dispatch = {
"fetch": _cmd_fetch,
"path": _cmd_path,
"version": _cmd_version,
"clear-cache": _cmd_clear_cache,
}
return dispatch[args.cmd](args)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,48 @@
"""Compile-time constants that pin the wrapper to a specific Firefox build.
BINARY_VERSION is bumped every time new Firefox patches are released. It is
deliberately decoupled from the Python package version so that pure-Python
bugfixes don't force a multi-hour Firefox rebuild.
"""
from __future__ import annotations
# Bump this when a new patched Firefox build is released on GitHub.
BINARY_VERSION: str = "firefox-1"
# Underlying Firefox version (for display only; does not drive downloads).
FIREFOX_UPSTREAM_VERSION: str = "150.0.1"
# The base filename prefix used inside archives.
BINARY_BASENAME: str = f"firefox-{FIREFOX_UPSTREAM_VERSION}-stealth"
def ARCHIVE_NAME(platform_key: str, machine: str) -> str:
"""Return the platform-specific archive filename.
platform_key: sys.platform ("win32", "linux")
machine: platform.machine() ("AMD64", "x86_64", ...)
"""
pk = platform_key.lower()
m = machine.lower()
if m in {"amd64", "x86_64"}:
arch = "x86_64"
else:
raise NotImplementedError(f"unsupported arch: {machine}")
if pk == "win32":
return f"{BINARY_BASENAME}-win-{arch}.zip"
if pk == "linux":
return f"{BINARY_BASENAME}-linux-{arch}.tar.gz"
raise NotImplementedError(f"unsupported platform: {platform_key}")
# Binary entry point relative path inside the extracted archive root.
BINARY_ENTRY_REL = {
"win32": "firefox.exe",
"linux": "firefox",
}
# GitHub release URL template. The "TODO" owner is resolved at publication time.
RELEASE_URL_TEMPLATE = (
"https://github.com/feder-cr/stealthfox/releases/download/{tag}/{asset}"
)

File diff suppressed because it is too large Load diff

151
src/stealthfox/download.py Normal file
View file

@ -0,0 +1,151 @@
"""Download and cache the patched Firefox binary from GitHub Releases."""
from __future__ import annotations
import hashlib
import os
import platform
import re
import sys
import tarfile
import tempfile
import zipfile
from pathlib import Path
import platformdirs
import requests
from .constants import (
ARCHIVE_NAME,
BINARY_ENTRY_REL,
BINARY_VERSION,
RELEASE_URL_TEMPLATE,
)
def _github_token() -> str | None:
return os.environ.get("STEALTHFOX_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
def _parse_owner_repo(template: str) -> tuple[str, str]:
"""Extract (owner, repo) from RELEASE_URL_TEMPLATE."""
m = re.match(r"https://github\.com/([^/]+)/([^/]+)/releases/", template)
if not m:
raise RuntimeError(f"cannot parse owner/repo from {template!r}")
return m.group(1), m.group(2)
def cache_root() -> Path:
"""Directory where all cached binaries live."""
return Path(platformdirs.user_cache_dir("stealthfox"))
def cache_dir_for_version(version: str = BINARY_VERSION) -> Path:
return cache_root() / version
def _resolve_asset_url(tag: str, asset_name: str) -> str:
"""Return a downloadable URL for the asset.
For private repos the direct `releases/download/<tag>/<asset>` URL returns
404 even with a token, so we resolve via the API: list assets for the
release tag, find the one matching `asset_name`, and use its API URL with
`Accept: application/octet-stream` (which 302-redirects to a signed URL).
For public repos the direct URL still works without a token.
"""
token = _github_token()
if not token:
return RELEASE_URL_TEMPLATE.format(tag=tag, asset=asset_name)
owner, repo = _parse_owner_repo(RELEASE_URL_TEMPLATE)
api = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
r = requests.get(api, headers={"Authorization": f"token {token}"}, timeout=30)
r.raise_for_status()
for a in r.json().get("assets", []):
if a.get("name") == asset_name:
return a["url"]
raise RuntimeError(f"asset {asset_name!r} not found in release {tag!r}")
def _download_file(url: str, dst: Path, chunk_size: int = 1 << 16) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
headers: dict[str, str] = {}
token = _github_token()
if token and url.startswith("https://api.github.com/"):
headers["Authorization"] = f"token {token}"
headers["Accept"] = "application/octet-stream"
with requests.get(url, stream=True, timeout=60, headers=headers) as r:
r.raise_for_status()
with open(dst, "wb") as f:
for chunk in r.iter_content(chunk_size):
if chunk:
f.write(chunk)
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
return h.hexdigest()
def _parse_checksums(text: str) -> dict[str, str]:
out: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) >= 2:
out[parts[-1]] = parts[0]
return out
def _extract(archive: Path, dst: Path) -> None:
dst.mkdir(parents=True, exist_ok=True)
if archive.suffix == ".zip":
with zipfile.ZipFile(archive) as zf:
zf.extractall(dst)
elif archive.name.endswith(".tar.gz") or archive.suffix in {".tgz", ".gz"}:
with tarfile.open(archive, "r:gz") as tf:
tf.extractall(dst)
else:
raise RuntimeError(f"unknown archive format: {archive}")
def ensure_binary(version: str = BINARY_VERSION) -> Path:
"""Return a path to a runnable Firefox executable. Download if needed."""
plat = sys.platform
mach = platform.machine()
asset = ARCHIVE_NAME(plat, mach)
entry_rel = BINARY_ENTRY_REL.get(plat)
if entry_rel is None:
raise NotImplementedError(f"no binary entry for platform {plat}")
version_dir = cache_dir_for_version(version)
entry = version_dir / entry_rel
if entry.exists():
return entry
url_archive = _resolve_asset_url(version, asset)
url_sums = _resolve_asset_url(version, "checksums.txt")
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
archive_path = tmp / asset
_download_file(url_archive, archive_path)
sums_path = tmp / "checksums.txt"
_download_file(url_sums, sums_path)
sums = _parse_checksums(sums_path.read_text())
expected = sums.get(asset)
if expected is None:
raise RuntimeError(f"no SHA256 for {asset} in checksums.txt")
actual = _sha256_file(archive_path)
if actual.lower() != expected.lower():
raise RuntimeError(
f"SHA256 mismatch for {asset}: got {actual}, expected {expected}"
)
_extract(archive_path, version_dir)
if not entry.exists():
raise RuntimeError(f"binary not found after extraction: {entry}")
return entry

307
src/stealthfox/launcher.py Normal file
View file

@ -0,0 +1,307 @@
"""Sync Playwright launcher for stealthfox."""
from __future__ import annotations
import secrets
from typing import Any, Dict, Optional, Union
from playwright.sync_api import Browser, Playwright, sync_playwright
from ._fpforge import Profile, generate_profile
from ._headless import make_virtual_display
from ._proxy import configure_proxy as _configure_proxy_shared
from .download import ensure_binary
from .prefs import translate_profile_to_prefs
def _patch_sync_new_page_sleep(ctx: Any) -> None:
"""Wrap ctx.new_page() to add a brief settle after tab creation.
FF150 with Fission emits an about:newtab navigation ~100ms after a tab
is created. If goto() is called immediately, it races with that internal
navigation and raises "Navigation interrupted by about:newtab". A short
sleep breaks the race without requiring every call-site to know about it.
"""
import time as _time
original_new_page = ctx.new_page
def patched_new_page(**kw):
page = original_new_page(**kw)
_time.sleep(0.4)
return page
ctx.new_page = patched_new_page # type: ignore[assignment]
# Window-chrome and taskbar offsets measured empirically on a headed
# Firefox 150 (no compositor). Used to derive the default new_context
# viewport so it fits inside the spoofed screen without out-of-bounds.
_CHROME_W = 14
_CHROME_H = 91
_TASKBAR_H = 40
# IANA → POSIX TZ mapping. Linux glibc accepts IANA names directly via
# /usr/share/zoneinfo, but Windows MSVCRT only understands the POSIX form
# ("EST5EDT") — convert here so ``TZ`` works on both platforms when the
# binary runs on Windows. Common US zones cover the vast majority of
# residential proxies; everything else falls through to its IANA name.
_IANA_TO_POSIX_TZ = {
"America/New_York": "EST5EDT",
"America/Detroit": "EST5EDT",
"America/Indiana/Indianapolis": "EST5EDT",
"America/Kentucky/Louisville": "EST5EDT",
"America/Chicago": "CST6CDT",
"America/Denver": "MST7MDT",
"America/Los_Angeles": "PST8PDT",
# Arizona (except Navajo Nation) does NOT observe DST. Mapping it to
# MST7MDT made libc apply DST → Date.getTimezoneOffset() returned -360
# in summer (Denver-like) instead of -420 (true Phoenix), and FP Pro
# deduced vpn_origin_timezone="America/Denver" → timezone_mismatch.
"America/Phoenix": "MST7",
"America/Anchorage": "AKST9AKDT",
# Hawaii does not observe DST.
"Pacific/Honolulu": "HST10",
}
def _tz_env(timezone: str) -> str:
"""Return the value to set in ``TZ`` for the given IANA zone."""
return _IANA_TO_POSIX_TZ.get(timezone, timezone)
class Stealthfox:
"""Context manager launching a patched Firefox with a deterministic profile.
Usage:
from stealthfox import Stealthfox
# random seed (different fingerprint each call)
with Stealthfox() as browser:
page = browser.new_page()
page.goto("https://example.com")
# explicit seed → same profile every time
with Stealthfox(seed=42) as browser:
...
# human-like cursor motion (Bezier trajectory on every mousemove)
with Stealthfox(humanize=True) as browser:
...
Optional ``pin`` forces specific fingerprint fields while the rest still
varies with ``seed``::
with Stealthfox(seed=42, pin={"screen.width": 2560}) as browser:
...
After construction, the chosen seed is available as ``self.seed`` useful
to reproduce a random run later.
"""
def __init__(
self,
seed: Optional[int] = None,
*,
pin: Optional[Dict[str, Any]] = None,
headless: bool = False,
proxy: Optional[Dict[str, str]] = None,
extra_args: Optional[list[str]] = None,
humanize: Union[bool, float] = True,
locale: str = "en-US",
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
binary_path: Optional[str] = None,
) -> None:
"""
Args:
seed: Integer seed driving the Bayesian fingerprint sampler.
Same seed same fingerprint. ``None`` = fresh random.
pin: Force specific fingerprint fields (see docs/pinning.md).
headless: When ``True``, browser renders on a hidden virtual
display (Xvfb on Linux, ``CreateDesktop`` on Windows) so
Firefox stays in *headed* mode (real rendering pipeline,
coherent fingerprint) without showing windows.
proxy: ``{"server": "...", "username": "...", "password": "..."}``.
``socks5://`` / ``socks4://`` go through the patched
``nsProtocolProxyService``; ``http(s)://`` go through
Playwright's own ``proxy=`` kwarg.
extra_args: Extra command-line args forwarded to Firefox.
humanize: Every mouse move is expanded by the patched Juggler
into a Bezier trajectory with ~10 ms between waypoints.
Default ``True`` (~1.5 s max motion). ``False`` disables;
a float caps the motion in seconds.
locale: BCP-47 tag (e.g. ``"en-US"``). Drives the
``Accept-Language`` header and ``navigator.language``.
timezone: IANA timezone (e.g. ``"America/New_York"``). Empty
means use the host TZ.
extra_prefs: Optional dict of Firefox prefs overlayed on top
of the generated profile useful for niche tweaks
without monkey-patching the package.
"""
# Constrain to int31 — Firefox's `zoom.stealth.fpp.hw_seed` and
# related stealth prefs are declared as ``int32_t`` in
# ``StaticPrefList.yaml``. A 32-bit seed risks the high bit being
# interpreted as negative on the C++ side, where the noise hooks
# bail out on ``seed <= 0`` — which produces bit-identical audio
# / canvas fingerprints across half the sessions.
self.seed: int = int(seed) if seed is not None else secrets.randbits(31)
self._pin = pin
self._headless = headless
self._proxy = proxy
self._extra_args = list(extra_args or [])
self._humanize = humanize
self._locale = locale
self._timezone = timezone
self._extra_prefs = extra_prefs
self._binary_path = binary_path
self._profile: Profile = generate_profile(self.seed, pin=self._pin)
self._pw: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._virtual_display: Any = None
def __enter__(self) -> Browser:
executable = self._binary_path or ensure_binary()
prefs = self._build_prefs()
playwright_proxy = _configure_proxy_shared(self._proxy, prefs)
pw_headless = self._resolve_headless()
env = self._build_env()
try:
self._pw = sync_playwright().start()
self._browser = self._pw.firefox.launch(
executable_path=str(executable),
headless=pw_headless,
firefox_user_prefs=prefs,
proxy=playwright_proxy,
args=self._extra_args,
env=env,
)
except BaseException:
# Python doesn't call __exit__ when __enter__ raises — clean up
# the virtual display + Playwright manually so we don't leak Xvfb
# / desktop handles into the user's process.
self._teardown()
raise
self._patch_new_context_defaults(self._browser)
return self._browser
def _patch_new_context_defaults(self, browser: Browser) -> None:
"""Wrap ``browser.new_context`` so its defaults derive from the
profile (viewport, screen, DPR, color-scheme). Users get a
coherent context for free; explicit kwargs still override.
"""
original = browser.new_context
defaults = self._default_context_kwargs()
def patched(**kw):
merged = dict(defaults)
merged.update(kw) # user-supplied wins
ctx = original(**merged)
_patch_sync_new_page_sleep(ctx)
return ctx
browser.new_context = patched # type: ignore[assignment]
def _default_context_kwargs(self) -> Dict[str, Any]:
p = self._profile
kwargs: Dict[str, Any] = {
"viewport": {"width": p.screen.width - _CHROME_W,
"height": p.screen.height - _TASKBAR_H - _CHROME_H},
"screen": {"width": p.screen.width, "height": p.screen.height},
"device_scale_factor": p.screen.dpr,
"color_scheme": "dark" if p.dark_theme else "light",
}
# Pass timezone via Playwright's per-realm override (docShell.overrideTimezone
# → JS::SetRealmTimezoneOverride). The juggler.timezone.override pref path
# uses JS::SetTimeZoneOverride globally, which is broken on Windows ICU for
# no-DST IANA names (America/Phoenix, Pacific/Honolulu, ...) — those silently
# fall back to the host system TZ. The per-realm path works for every zone.
if self._timezone:
kwargs["timezone_id"] = self._timezone
if self._locale:
kwargs["locale"] = self._locale
return kwargs
def __exit__(self, *exc: Any) -> None:
self._teardown()
def _teardown(self) -> None:
if self._browser is not None:
try:
self._browser.close()
except Exception:
pass
self._browser = None
if self._pw is not None:
try:
self._pw.stop()
except Exception:
pass
self._pw = None
if self._virtual_display is not None:
try:
self._virtual_display.stop()
except Exception:
pass
self._virtual_display = None
# ── helpers ─────────────────────────────────────────────────────────
def _build_prefs(self) -> Dict[str, Any]:
"""Fingerprint prefs plus humanize toggle (always set explicitly)."""
import sys as _sys
prefs = translate_profile_to_prefs(
self._profile,
locale=self._locale,
timezone=self._timezone,
extra_prefs=self._extra_prefs,
virtual_display=bool(self._headless and _sys.platform == "win32"),
)
prefs["stealthfox.humanize"] = bool(self._humanize)
if self._humanize:
prefs["stealthfox.humanize.maxTime"] = str(self._humanize_max_seconds())
return prefs
def _build_env(self) -> Dict[str, str]:
"""Env vars passed to the Firefox subprocess.
``TZ`` tunes the libc clock the content process reads for
``Date`` / ``Intl.DateTimeFormat`` so the JS-visible timezone
matches ``self._timezone`` regardless of the host TZ.
``STEALTHFOX_WEBRTC_PUBLIC_IP`` is propagated when the calling
process has set it read by nICEr's nr_stealth_bridge to inject
a synthetic srflx candidate matching the proxy egress IP, avoiding
the StaticPref IPC propagation timing issue between parent and
socket processes.
"""
import os as _os
env = _os.environ.copy()
if self._timezone:
env["TZ"] = _tz_env(self._timezone)
# Propagate STEALTHFOX_WEBRTC_PUBLIC_IP if the process set it — read
# by nICEr's nr_stealth_bridge to inject a synthetic srflx candidate
# matching the proxy egress IP. This avoids the StaticPref IPC
# propagation timing issue between parent and socket processes.
if _os.environ.get("STEALTHFOX_WEBRTC_PUBLIC_IP"):
env["STEALTHFOX_WEBRTC_PUBLIC_IP"] = _os.environ["STEALTHFOX_WEBRTC_PUBLIC_IP"]
return env
def _resolve_headless(self) -> bool:
"""Translate the user's ``headless`` flag.
When ``True``, we keep Firefox in headed mode (real rendering
pipeline coherent fingerprint) and hide the windows on a fresh
Xvfb (Linux) or hidden Windows desktop.
"""
if not self._headless:
return False
vd = make_virtual_display()
vd.start()
self._virtual_display = vd
return False
def _humanize_max_seconds(self) -> float:
if self._humanize is True:
return 1.5
return float(self._humanize)

568
src/stealthfox/prefs.py Normal file
View file

@ -0,0 +1,568 @@
"""Translate an internal Profile into the Firefox prefs dict that the
patched Firefox binary expects.
The output dict keys map 1:1 to ``user.js`` preferences. Playwright passes
them via ``firefox_user_prefs=``. The patched binary propagates them to all
content processes over IPC; C++ patches read the ``zoom.stealth.*``
namespace.
The translation is split into:
* ``_BASELINE`` global stealth policy (RFP off, WebRTC leaks blocked,
safebrowsing disabled, debugger detach, ) plus Windows-canonical
constants that don't depend on the Profile (system colors palette,
WebGL extensions whitelist, speech voices, navigator identity).
* ``translate_profile_to_prefs`` overlays the Profile fields plus the
user-supplied ``locale`` and ``timezone``.
"""
from __future__ import annotations
import sys
from typing import Any, Dict, Optional
from ._fpforge import Profile
# ──────────────────────────────────────────────────────────────────────
# Navigator identity — locked to Firefox 150 Windows so the binary
# reports the same UA / platform / oscpu regardless of the host OS.
# ──────────────────────────────────────────────────────────────────────
_NAVIGATOR_OVERRIDES: Dict[str, str] = {
"general.useragent.override":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) "
"Gecko/20100101 Firefox/150.0.1",
"general.platform.override": "Win32",
"general.oscpu.override": "Windows NT 10.0; Win64; x64",
# general.buildID.override removed 2026-04-28: the previous value
# "20181001000000" was a 2018 buildID stuck on a 2026-built Firefox 150
# binary (real BuildID=20260426192818 from application.ini). The 7.5-yr
# discrepancy is the kind of internal-consistency check Google reCAPTCHA
# can use to flag bot/spoofed browsers. Deleting the override lets
# Firefox emit its compiled-in buildID, which auto-tracks the binary.
# A/B knockout 2026-04-28 (n=30): F2 delete +0.083 RC vs BASE; n=100
# confirm: +0.021; overnight isolated: +0.155 single-variant. Variable
# signal, but the underlying data error is unambiguous.
"general.appversion.override": "5.0 (Windows)",
}
# ──────────────────────────────────────────────────────────────────────
# System colors — FP Pro probes getComputedStyle(div) with CSS system
# keywords (ButtonFace, Menu, Highlight, …) and hashes the result into
# signal s142. On Linux, Firefox resolves these via GTK theme → GTK
# RGB values diverge from Windows Win32 palette → server-side anomaly
# even with Windows UA. Pinning the palette to Win10 default closes
# the gap (see project_css_system_colors.md memory).
# ──────────────────────────────────────────────────────────────────────
_WIN_LIGHT_COLORS: Dict[str, str] = {
"ui.activeborder": "#B4B4B4",
"ui.activecaption": "#99B4D1",
"ui.appworkspace": "#ABABAB",
"ui.background": "#000000",
"ui.buttonface": "#F0F0F0",
"ui.buttonhighlight": "#FFFFFF",
"ui.buttonshadow": "#A0A0A0",
"ui.buttontext": "#000000",
"ui.buttonborder": "#000000",
"ui.captiontext": "#000000",
"ui.graytext": "#6D6D6D",
"ui.highlight": "#0078D7",
"ui.highlighttext": "#FFFFFF",
"ui.inactiveborder": "#F4F7FC",
"ui.inactivecaption": "#BFCDDB",
"ui.inactivecaptiontext": "#434E54",
"ui.infobackground": "#FFFFE1",
"ui.infotext": "#000000",
"ui.menu": "#F9F9FB",
"ui.menutext": "#000000",
"ui.scrollbar": "#C8C8C8",
"ui.threeddarkshadow": "#696969",
"ui.threedface": "#F0F0F0",
"ui.threedhighlight": "#FFFFFF",
"ui.threedlightshadow": "#E3E3E3",
"ui.threedshadow": "#A0A0A0",
"ui.window": "#FFFFFF",
"ui.windowframe": "#646464",
"ui.windowtext": "#000000",
"ui.mark": "#FFFF00",
"ui.marktext": "#000000",
"ui.accentcolor": "#0078D4",
"ui.accentcolortext": "#FFFFFF",
"ui.selecteditem": "#0078D7",
"ui.selecteditemtext": "#FFFFFF",
"ui.-moz-hyperlinktext": "#0066CC",
"ui.-moz-activehyperlinktext": "#EE0000",
"ui.-moz-visitedhyperlinktext": "#551A8B",
}
# ──────────────────────────────────────────────────────────────────────
# WebGL extensions — Windows ANGLE canonical lists. Empty string =
# fall back to native Mesa/ANGLE; non-empty = `getSupportedExtensions`
# returns this list verbatim and `IsSupported()` rejects anything else.
# ──────────────────────────────────────────────────────────────────────
_WEBGL1_EXTENSIONS = ",".join([
"ANGLE_instanced_arrays",
"EXT_blend_minmax",
"EXT_color_buffer_half_float",
"EXT_float_blend",
"EXT_frag_depth",
"EXT_sRGB",
"EXT_shader_texture_lod",
"EXT_texture_compression_bptc",
"EXT_texture_compression_rgtc",
"EXT_texture_filter_anisotropic",
"OES_element_index_uint",
"OES_fbo_render_mipmap",
"OES_standard_derivatives",
"OES_texture_float",
"OES_texture_float_linear",
"OES_texture_half_float",
"OES_texture_half_float_linear",
"OES_vertex_array_object",
"WEBGL_color_buffer_float",
"WEBGL_compressed_texture_s3tc",
"WEBGL_compressed_texture_s3tc_srgb",
"WEBGL_debug_renderer_info",
"WEBGL_debug_shaders",
"WEBGL_depth_texture",
"WEBGL_draw_buffers",
"WEBGL_lose_context",
"WEBGL_provoking_vertex",
])
_WEBGL2_EXTENSIONS = ",".join([
"EXT_color_buffer_float",
"EXT_color_buffer_half_float",
"EXT_float_blend",
"EXT_texture_compression_bptc",
"EXT_texture_compression_rgtc",
"EXT_texture_filter_anisotropic",
"OES_draw_buffers_indexed",
"OES_texture_float_linear",
"OES_texture_half_float_linear",
"OVR_multiview2",
"WEBGL_compressed_texture_s3tc",
"WEBGL_compressed_texture_s3tc_srgb",
"WEBGL_debug_renderer_info",
"WEBGL_debug_shaders",
"WEBGL_lose_context",
"WEBGL_provoking_vertex",
])
# ──────────────────────────────────────────────────────────────────────
# Speech voices — Windows canonical "Microsoft *" set. Format:
# "NAME|LANG|DEFAULT|LOCAL,...". Non-empty value drives the
# speechSynthesis.getVoices() patch; empty disables it.
# ──────────────────────────────────────────────────────────────────────
_WIN_VOICES = ",".join([
"Microsoft David - English (United States)|en-US|1|1",
"Microsoft Zira - English (United States)|en-US|0|1",
"Microsoft Mark - English (United States)|en-US|0|1",
"Microsoft David Desktop - English (United States)|en-US|0|1",
"Microsoft Zira Desktop - English (United States)|en-US|0|1",
])
# ──────────────────────────────────────────────────────────────────────
# Linux font compensation — Linux Firefox uses DejaVu / Liberation
# fonts which have wider/narrower glyphs than Windows Arial / Segoe.
# These per-generic factors are prepended to ``zoom.stealth.font.metrics``
# on Linux only; Windows-native rendering already matches the canonical
# widths so we pass an empty string (any factor !=1 would distort real
# metrics).
# ──────────────────────────────────────────────────────────────────────
_LINUX_GENERIC_FONT_FACTORS = (
# Calibrated to bring DejaVu/Liberation widths in line with what Windows
# FP Pro probes report for native Segoe/Times. Linux base measurements
# (font_preferences) and Windows targets:
# serif: 162 → 149 factor 0.920
# sans: 162 → 144 factor 0.889
# monospace:121 → 121 factor 1.000
# system: 162 → 147 factor 0.910
"serif|0.920,sans-serif|0.889,monospace|1.000,"
"system-ui|0.910,cursive|0.932,fantasy|0.812,"
)
# ──────────────────────────────────────────────────────────────────────
# Baseline — applied to every session regardless of Profile.
# ──────────────────────────────────────────────────────────────────────
_BASELINE: Dict[str, Any] = {
# Turn off Firefox's own resistFingerprinting; we do our own via patches.
"privacy.resistFingerprinting": False,
"privacy.resistFingerprinting.letterboxing": False,
# FF150 fingerprintingProtection — enabled by default (or remotely via
# Mozilla webcompat overrides). FP Pro detects the side-effects and
# flips `privacy_settings: true`. On FF146 these were all off → False.
# Force off so FP Pro reports privacy_settings:false (matches FF146).
"privacy.fingerprintingProtection": False,
"privacy.fingerprintingProtection.pbmode": False,
"privacy.fingerprintingProtection.remoteOverrides.enabled": False,
# WebRTC: enabled, no public IP leak.
# obfuscate_host_addresses=false: our C++ injection handles candidate
# selection; mDNS causes mDNS-IPC to hang in sandboxed content processes.
# disableIPv6=true keeps IPv6 out of gathering (less entropy, no IPv6 leak).
"media.peerconnection.enabled": True,
"media.peerconnection.ice.no_host": False,
"media.peerconnection.ice.default_address_only": False,
"media.peerconnection.ice.obfuscate_host_addresses": False,
"media.peerconnection.ice.disableIPv6": True,
"media.peerconnection.ice.proxy_only": False,
"media.peerconnection.ice.relay_only": False,
"media.peerconnection.use_document_iceservers": True,
# Proxy — route DNS through SOCKS proxies to avoid local DNS leaks.
"network.proxy.socks_remote_dns": True,
"network.proxy.failover_direct": False,
# Safebrowsing — chatty and fingerprintable.
"browser.safebrowsing.malware.enabled": False,
"browser.safebrowsing.phishing.enabled": False,
"browser.safebrowsing.downloads.enabled": False,
"browser.safebrowsing.downloads.remote.enabled": False,
# First-run / welcome UI noise.
"browser.startup.page": 0,
"browser.shell.checkDefaultBrowser": False,
"browser.aboutwelcome.enabled": False,
"browser.startup.upgradeDialog.enabled": False,
"termsofuse.acceptedVersion": 999,
# Disable about:newtab auto-load — TopSitesFeed.sys.mjs auto-fetches when
# a tab opens, triggering a cross-process BC swap that hijacks the first
# page.goto() (NS_BINDING_ABORTED on creepjs/peet/sannysoft/fppro).
"browser.newtabpage.enabled": False,
"browser.newtab.preload": False,
"browser.newtabpage.activity-stream.feeds.topsites": False,
"browser.newtabpage.activity-stream.feeds.section.topstories": False,
"browser.newtabpage.activity-stream.enabled": False,
# Disable Firefox internal services that hit the network on startup.
# Through a residential SOCKS5 proxy these compete with the test
# navigation and trigger NS_BINDING_FAILED (server-side rate-limit /
# connection drops). Domains observed in MOZ_LOG: push.services,
# firefox.settings.services, detectportal, ohttp-gateway, location.
"browser.aboutConfig.showWarning": False,
"network.captive-portal-service.enabled": False,
"network.connectivity-service.enabled": False,
"dom.push.enabled": False,
"dom.push.connection.enabled": False,
"geo.enabled": False,
"geo.provider.network.url": "",
"browser.region.network.url": "",
"browser.region.update.enabled": False,
"services.settings.server": "",
"browser.search.geoSpecificDefaults": False,
"browser.contentblocking.report.lockwise.enabled": False,
"browser.contentblocking.report.monitor.enabled": False,
"extensions.systemAddon.update.enabled": False,
"extensions.update.enabled": False,
"extensions.getAddons.cache.enabled": False,
"browser.discovery.enabled": False,
"browser.ping-centre.telemetry": False,
"app.normandy.enabled": False,
"dom.private-attribution.submission.enabled": False,
"browser.translations.enable": False,
"browser.search.update": False,
# HTTP/3 + speculative + Alt-Svc disabled. SOCKS5 proxy doesn't
# support UDP ASSOCIATE so HTTP/3 fails. Speculative connections
# under load cause early channel cancel (NS_BINDING_FAILED).
"network.http.http3.enable": False,
"network.http.http3.enabled": False,
"network.http.altsvc.enabled": False,
"network.http.altsvc.oe": False,
"network.http.speculative-parallel-limit": 0,
"network.predictor.enabled": False,
"network.dns.disablePrefetch": True,
"network.dns.disablePrefetchFromHTTPS": True,
"network.dns.echconfig.enabled": False,
"network.dns.use_https_rr_as_altsvc": False,
# === A/B VARIANT B: Fission disabled ===
# Force single content-process model (e10s only, no BC outer/inner split).
# Diagnostic for the FF150 BC-swap theory: if peet_ws/fppro/sannysoft
# work with this off, the Juggler FF146 baseline breaks specifically on
# cross-process navigation tracking.
"fission.autostart": False,
"fission.autostart.session": False,
"dom.ipc.processCount.webIsolated": 1,
# Telemetry & data reporting.
"datareporting.healthreport.uploadEnabled": False,
"datareporting.policy.dataSubmissionEnabled": False,
"toolkit.telemetry.enabled": False,
"toolkit.telemetry.unified": False,
"app.shield.optoutstudies.enabled": False,
# Update channels.
"app.update.enabled": False,
"app.update.auto": False,
# Speech synth: enabled (the C++ patch fabricates voices from the
# comma list above) regardless of the host OS.
"media.webspeech.synth.enabled": True,
"zoom.stealth.voices.list": _WIN_VOICES,
# WebGL extensions whitelist — non-empty pre-empts native enumeration.
"zoom.stealth.webgl.extensions": _WEBGL1_EXTENSIONS,
"zoom.stealth.webgl2.extensions": _WEBGL2_EXTENSIONS,
# WebGL numeric param overrides — kept empty (A/B test 2026-04-22 showed
# mismatches between the values we shipped and ANGLE's real envelope
# raised FP Pro's ML tampering score). Slot kept for future experiments.
"zoom.stealth.webgl.int_params": "",
"zoom.stealth.webgl.int2_params": "",
"zoom.stealth.webgl.shader_precisions": "",
"zoom.stealth.webgl.float_params": "",
# DevTools anti-detection.
"zoom.stealth.debugger.force_detach": True,
# Canvas substitution — additive ±1 noise over the OS base pattern;
# set to True to replace pixels with hash(seed, idx) instead.
"zoom.stealth.canvas.substitute_pixels": False,
# Navigator identity (locked to Windows Firefox 150).
**_NAVIGATOR_OVERRIDES,
}
# ──────────────────────────────────────────────────────────────────────
# Linux-only Xvfb workarounds — the Linux Firefox build under Xvfb
# cannot run WebRender (`ConnectToCompositor` retries forever). We
# disable WebRender + force WebGL through the GL software path so
# webgl_basics / webgl_extensions still report.
# ──────────────────────────────────────────────────────────────────────
_LINUX_XVFB_WORKAROUNDS: Dict[str, Any] = {
"gfx.webrender.all": False,
"gfx.webrender.force-disabled": True,
"webgl.force-enabled": True,
# webgl.software-rendering-enabled / webgl.force-layers-readback removed in FF150.
}
# ──────────────────────────────────────────────────────────────────────
# Windows virtual-desktop workarounds — when headless=True on Windows,
# Firefox runs on a CreateDesktop virtual desktop. The hardware GPU is
# inaccessible from the virtual desktop, so the GPU process crashes when
# it tries to initialize the D3D11 compositor with hardware acceleration.
#
# Approach: force D3D11 WARP (CPU software renderer) for the GPU process.
# layers.d3d11.force-warp=True → compositor uses WARP → GPU process stable.
# webgl.angle.force-warp=True → ANGLE uses WARP → WebGL context creates.
#
# CRITICAL: do NOT set webgl.out-of-process=False. That moves WebGL from the
# GPU process to the sandboxed content process. The content process sandbox
# blocks D3D11 access entirely → ANGLE crashes the content process →
# canvas.getContext('webgl') throws instead of returning null.
#
# gfx.canvas.accelerated=False: default is true, disabling avoids any
# hardware GPU dependency for 2D canvas in the content process.
# ──────────────────────────────────────────────────────────────────────
_WIN_VIRT_DESKTOP_WORKAROUNDS: Dict[str, Any] = {
# FF150 regression vs FF146 on CreateDesktop alt-desktop:
# The GPU process sandbox (level=1, default since FF110) tries to parent
# its compositor window to the parent process's window. Our worker spawns
# Firefox on a CreateDesktop-created alt desktop — parent and GPU process
# do not share the same desktop/HWND namespace, so window parenting fails
# silently. WebRender falls back to "Software D3D11" and OOP-WebGL never
# publishes a hardware ANGLE renderer → getContext('webgl') returns a
# context but extensions/parameters/$hash all come back null/empty (FF146
# had a more permissive sandbox, so the same setup worked there).
# Bugzilla refs: 1798091, 1524591, 1229829. Lowering the GPU sandbox to 0
# restores hardware compositor + functional WebGL on alt desktops.
"security.sandbox.gpu.level": 0,
}
# ──────────────────────────────────────────────────────────────────────
# Public helpers
# ──────────────────────────────────────────────────────────────────────
def _accept_language(locale: str) -> str:
lang = locale.replace("_", "-")
base = lang.split("-")[0]
return f"{lang}, {base}" if base != lang else lang
def _font_metrics_for_platform(profile_metrics: str) -> str:
"""Return ``zoom.stealth.font.metrics`` value.
Windows: empty string. The C++ width-scale hook is a no-op and
Firefox renders Arial/Segoe/Calibri/etc. at their native canonical
widths. Applying the Bayesian-sampled per-font factors on a Windows
build would *distort* real metrics and surface as a font_preferences
width anomaly to FP Pro / reCAPTCHA.
Linux: prepend generic-family compensation factors so DejaVu /
Liberation render at the widths Windows JS expects, then append the
per-font factors that make each fabricated family detectable by
width-diff probes.
"""
if not profile_metrics:
return ""
if sys.platform.startswith("linux"):
return _LINUX_GENERIC_FONT_FACTORS + profile_metrics
return "" # Windows: NEVER apply width-scale factors.
def translate_profile_to_prefs(
profile: Profile,
*,
locale: str = "en-US",
timezone: str = "",
extra_prefs: Optional[Dict[str, Any]] = None,
virtual_display: bool = False,
) -> Dict[str, Any]:
"""Return a complete prefs dict ready for Playwright's firefox_user_prefs=.
Args:
profile: Bayesian-sampled fingerprint (from ``generate_profile``).
locale: BCP-47 tag, e.g. ``"en-US"``.
timezone: IANA timezone name, e.g. ``"America/New_York"``.
extra_prefs: Optional overlay applied LAST.
virtual_display: When True on Windows, apply GPU-disabling workarounds
to prevent the GPU process from crashing on virtual
desktops that have no D3D11 backend.
"""
prefs: Dict[str, Any] = dict(_BASELINE)
# GPU / WebGL renderer/vendor.
# On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer)
# so cross-platform sessions report a consistent Windows GPU identity.
# On Windows, spoofing a different GPU creates a renderer/parameters hash
# mismatch: FP Pro hashes all 81 CN-set getParameter() values including
# enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750
# parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro
# ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine).
# Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware
# (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent.
if sys.platform.startswith("linux"):
prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer
prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor
_renderer_lo = (profile.gpu.renderer or "").lower()
else:
prefs["zoom.stealth.webgl.renderer"] = ""
prefs["zoom.stealth.webgl.vendor"] = ""
_renderer_lo = "intel" # test hardware is Intel Arc A750
# MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is
# constant across all sessions. Different MSAA values cause different CN-set
# parameters hashes even with the same renderer → detectable variation.
# Vanilla Intel Arc A750 parameters hash (66544db8) verified at msaa=4.
_msaa = profile.webgl.msaa_samples if sys.platform.startswith("linux") else 4
prefs["zoom.stealth.webgl.msaa"] = _msaa
prefs["webgl.msaa-samples"] = _msaa
prefs["webgl.msaa-force"] = _msaa > 0
# Canvas pixel-noise density per vendor. Intel has lower natural
# rendering variance than NVIDIA/AMD, so the default 1/8 noise rate
# over-amplifies the FP Pro tampering ML signal. Drop to 1/16 for Intel
# to keep tampering_ml below the detection threshold while still
# breaking the canvas geometry hash.
if "intel" in _renderer_lo:
prefs["zoom.stealth.canvas.noise_skip_mask"] = 15 # 1/16, ~6.25%
else:
prefs["zoom.stealth.canvas.noise_skip_mask"] = 7 # 1/8, ~12.5%
# Screen
prefs["zoom.stealth.screen.width"] = profile.screen.width
prefs["zoom.stealth.screen.height"] = profile.screen.height
prefs["zoom.stealth.screen.avail_width"] = profile.screen.avail_width
prefs["zoom.stealth.screen.avail_height"] = profile.screen.avail_height
prefs["zoom.stealth.screen.dpr"] = profile.screen.dpr
prefs["layout.css.devPixelsPerPx"] = str(profile.screen.dpr)
# Hardware
prefs["zoom.stealth.hw_concurrency"] = profile.hardware.concurrency
prefs["zoom.stealth.storage.quota_mb"] = profile.hardware.storage_quota_mb
# Audio
prefs["zoom.stealth.audio.sample_rate"] = profile.audio.sample_rate
prefs["zoom.stealth.audio.output_latency_ms"] = profile.audio.output_latency_ms
prefs["zoom.stealth.audio.max_channel_count"] = profile.audio.max_channel_count
# Codec
prefs["media.av1.enabled"] = profile.codec.av1_enabled
prefs["media.encoder.webm.enabled"] = profile.codec.webm_encoder_enabled
prefs["media.mediasource.webm.enabled"] = profile.codec.mediasource_webm
prefs["media.mediasource.mp4.enabled"] = profile.codec.mediasource_mp4
# Fonts
prefs["zoom.stealth.font.whitelist"] = ",".join(profile.fonts)
prefs["zoom.stealth.font.metrics"] = _font_metrics_for_platform(
profile._raw.get("font_metrics", "") or ""
)
# UI / dark mode + Windows colors palette (only when light theme).
prefs["ui.systemUsesDarkTheme"] = int(profile.dark_theme)
if not profile.dark_theme:
prefs.update(_WIN_LIGHT_COLORS)
# Locale prefs.
locale = locale or "en-US"
lang = locale.replace("_", "-")
prefs["intl.accept_languages"] = _accept_language(locale)
prefs["general.useragent.locale"] = lang
prefs["intl.locale.requested"] = lang
prefs["privacy.spoof_english"] = 0
if timezone:
prefs["zoom.stealth.timezone"] = timezone
prefs["juggler.timezone.override"] = timezone
# Cross-process seed (canvas noise + DWrite gamma share this).
prefs["zoom.stealth.fpp.hw_seed"] = profile.seed
prefs["zoom.stealth.seed"] = profile.seed
# Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5
# proxy suppresses all local addresses so Firefox can't gather host cands).
# LAN IP is seed-derived so it's consistent per session and looks like a
# real home router assignment (192.168.x.x range).
_s = profile.seed
_lan_ip = f"192.168.{(_s >> 8) % 254 + 1}.{_s % 254 + 1}"
prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip
# On Windows, native ANGLE extension list already matches real Windows users.
# The baseline hard-codes a curated _WEBGL1/2_EXTENSIONS list designed for
# Linux Mesa → clear it so Windows sessions report the native extension set
# (hash matches real Intel Arc A750 vanilla captures).
if not sys.platform.startswith("linux"):
prefs["zoom.stealth.webgl.extensions"] = ""
prefs["zoom.stealth.webgl2.extensions"] = ""
# Linux Xvfb workarounds (no-op on Windows).
if sys.platform.startswith("linux"):
for k, v in _LINUX_XVFB_WORKAROUNDS.items():
prefs.setdefault(k, v)
# Windows virtual-desktop workarounds (headless=True on Windows).
if virtual_display and sys.platform == "win32":
for k, v in _WIN_VIRT_DESKTOP_WORKAROUNDS.items():
prefs.setdefault(k, v)
# Caller overlay LAST so users can override anything we set. A value of
# None is treated as a sentinel meaning "delete this pref entirely from
# the final dict" — useful for A/B harnesses that need to test what
# happens when an override is unset (vs set to empty string, which for
# some prefs like general.useragent.override means literally empty UA).
if extra_prefs:
for k, v in extra_prefs.items():
if v is None:
prefs.pop(k, None)
else:
prefs[k] = v
return prefs

View file

@ -0,0 +1,4 @@
"""Synchronous API — re-exports Stealthfox for parity with async_api."""
from .launcher import Stealthfox
__all__ = ["Stealthfox"]

22
tests/test_cli.py Normal file
View file

@ -0,0 +1,22 @@
import subprocess
import sys
def test_version_subcommand():
r = subprocess.run(
[sys.executable, "-m", "stealthfox", "version"],
capture_output=True, text=True, check=True,
)
assert "firefox-" in r.stdout
assert "stealthfox" in r.stdout.lower()
def test_help_subcommand():
r = subprocess.run(
[sys.executable, "-m", "stealthfox", "--help"],
capture_output=True, text=True,
)
assert r.returncode == 0
assert "fetch" in r.stdout
assert "path" in r.stdout
assert "clear-cache" in r.stdout

29
tests/test_constants.py Normal file
View file

@ -0,0 +1,29 @@
from stealthfox.constants import BINARY_VERSION, BINARY_BASENAME, ARCHIVE_NAME
def test_binary_version_format():
assert BINARY_VERSION.startswith("firefox-")
assert BINARY_VERSION.split("-", 1)[1].isdigit()
def test_archive_name_windows():
name = ARCHIVE_NAME("win32", "AMD64")
assert name.endswith(".zip")
assert "win-x86_64" in name
def test_archive_name_linux():
name = ARCHIVE_NAME("linux", "x86_64")
assert name.endswith(".tar.gz")
assert "linux-x86_64" in name
def test_archive_name_unsupported_raises():
import pytest
with pytest.raises(NotImplementedError):
ARCHIVE_NAME("darwin", "arm64")
def test_binary_basename_format():
assert "firefox" in BINARY_BASENAME.lower()
assert "stealth" in BINARY_BASENAME.lower()

71
tests/test_download.py Normal file
View file

@ -0,0 +1,71 @@
import hashlib
from pathlib import Path
import pytest
import responses
from stealthfox.download import ensure_binary
from stealthfox.constants import BINARY_VERSION
def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes:
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(inner_name, payload)
data = buf.getvalue()
path.write_bytes(data)
return data
@responses.activate
def test_ensure_binary_downloads_and_verifies(tmp_path, monkeypatch):
"""Full path: cache miss -> HTTP GET -> SHA256 check -> extract -> return path."""
cache = tmp_path / "cache"
monkeypatch.setattr("stealthfox.download.cache_root", lambda: cache)
archive_path = tmp_path / "archive.zip"
archive_bytes = _make_zip(archive_path, "firefox.exe", b"PEX!")
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
from stealthfox.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("win32", "AMD64")
url_archive = f"https://github.com/feder-cr/stealthfox/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/stealthfox/releases/download/{BINARY_VERSION}/checksums.txt"
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
content_type="application/zip")
responses.add(responses.GET, url_sums,
body=f"{archive_sha} {asset}\n", status=200)
monkeypatch.setattr("sys.platform", "win32")
import platform
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
path = ensure_binary()
assert Path(path).exists()
assert Path(path).name == "firefox.exe"
@responses.activate
def test_ensure_binary_rejects_sha_mismatch(tmp_path, monkeypatch):
cache = tmp_path / "cache"
monkeypatch.setattr("stealthfox.download.cache_root", lambda: cache)
archive_path = tmp_path / "archive.zip"
archive_bytes = _make_zip(archive_path, "firefox.exe", b"PEX!")
wrong_sha = "0" * 64
from stealthfox.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("win32", "AMD64")
url_archive = f"https://github.com/feder-cr/stealthfox/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/stealthfox/releases/download/{BINARY_VERSION}/checksums.txt"
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
responses.add(responses.GET, url_sums, body=f"{wrong_sha} {asset}\n", status=200)
monkeypatch.setattr("sys.platform", "win32")
import platform
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
with pytest.raises(RuntimeError, match="SHA256"):
ensure_binary()

35
tests/test_prefs.py Normal file
View file

@ -0,0 +1,35 @@
from stealthfox._fpforge import generate_profile
from stealthfox.prefs import translate_profile_to_prefs
def test_translate_includes_gpu_renderer():
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.webgl.renderer"] == p.gpu.renderer
assert prefs["zoom.stealth.webgl.vendor"] == p.gpu.vendor
def test_translate_includes_screen():
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
assert prefs["zoom.stealth.screen.width"] == p.screen.width
assert prefs["zoom.stealth.screen.height"] == p.screen.height
def test_translate_is_deterministic_per_seed():
a = translate_profile_to_prefs(generate_profile(seed=42))
b = translate_profile_to_prefs(generate_profile(seed=42))
assert a == b
def test_translate_varies_across_seeds():
a = translate_profile_to_prefs(generate_profile(seed=1))
b = translate_profile_to_prefs(generate_profile(seed=2))
assert a != b
def test_translate_has_stealth_baseline_constants():
p = generate_profile(seed=42)
prefs = translate_profile_to_prefs(p)
assert prefs.get("privacy.resistFingerprinting") is False
assert "media.peerconnection.enabled" in prefs