Files
camoufox/pythonlib/camoufox/fingerprints.py
T
Jake WriterandClaude Opus 5.5 676fb3f7c1 fix: remove the glyph-spacing seed from the browser and the launcher
anti-font-fingerprinting.patch added a seeded amount to every glyph advance,
so that text widths differed per context. No real machine produces those
widths: the same font on the same OS measures the same everywhere. So the
noise was itself a fingerprint, measured in #779 at +1 px per ~100 glyphs
plus fractional deltas on every measureText. #779 defaulted the seed to 0
and kept it as an opt-in, but an opt-in whose only effect is to become
detectable is not worth carrying. Removed:

- The browser side:
  - FontSpacingSeedManager and window.setFontSpacingSeed;
  - the HarfBuzz hook;
  - the plumbing that existed only to carry the context id down to the
    shaper: the userContextId on gfxTextRun, gfxShapedWord and the word-cache
    key, and the extra MakeTextRun argument in nsTextFrame, nsFontMetrics,
    MathML and canvas.
  The font group keeps its userContextId, which font-list-spoofing.patch
  uses to apply the per-context font list. Text is now shaped exactly as
  stock Firefox shapes it.
- The fonts:spacing_seed key. The launcher had been sending 0 on every
  launch, plus a setFontSpacingSeed(0) call in every context's init script.
- tests/patches/config-overrides.py, which tested only the spacing override.
  A pythonlib test now covers config_overrides with another key.

timezone-spoofing, webrtc-ip-spoofing and window-setter-seal change only in
context lines and the setter seal list. Every patch applies cleanly to a
fresh tree, and the result builds. The settled decision is recorded as
no-glyph-spacing-noise, with an automated check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:20:15 -06:00

2117 lines
97 KiB
Python

import hashlib
import json
import os
import re
import secrets
import unicodedata
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from random import Random, choice, randint, randrange
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
from camoufox.pkgman import load_yaml
from camoufox.webgl import sample_webgl
# Load the fpgen mapping file
FPGEN_DATA = load_yaml('fpgen.yml')
# fpgen's OS names, from Camoufox's.
_FPGEN_OS = {'lin': 'Linux', 'linux': 'Linux', 'mac': 'macOS', 'macos': 'macOS',
'win': 'Windows', 'windows': 'Windows'}
# fpgen unpacks ~7 MB of model data on first use, so the generator is built on
# demand rather than at import: `import camoufox` must stay cheap for callers
# who never generate a fingerprint (they passed their own, or a preset).
_FP_GENERATOR = None
def _generator():
global _FP_GENERATOR
if _FP_GENERATOR is None:
from fpgen import Generator
_FP_GENERATOR = Generator()
return _FP_GENERATOR
@dataclass
class Screen:
"""A bound on the screen a generated fingerprint may claim.
Replaces browserforge.fingerprints.Screen, which left with the BrowserForge
dependency. Same four fields, same meaning: the generated screen must fit
inside them.
"""
min_width: Optional[int] = None
max_width: Optional[int] = None
min_height: Optional[int] = None
max_height: Optional[int] = None
def as_conditions(self) -> Dict[str, Any]:
"""The bound, as fpgen conditions.
fpgen takes a predicate per field and rejects a value the predicate
refuses, so a range is a closure rather than a list of allowed values.
"""
conditions: Dict[str, Any] = {}
lo_w, hi_w = self.min_width, self.max_width
lo_h, hi_h = self.min_height, self.max_height
if lo_w is not None or hi_w is not None:
conditions['screen.width'] = lambda w: (
isinstance(w, int) and (lo_w is None or w >= lo_w) and (hi_w is None or w <= hi_w)
)
if lo_h is not None or hi_h is not None:
conditions['screen.height'] = lambda h: (
isinstance(h, int) and (lo_h is None or h >= lo_h) and (hi_h is None or h <= hi_h)
)
return conditions
# Bundled real fingerprint presets
PRESETS_FILE = Path(__file__).parent / 'fingerprint-presets.json'
PRESETS_V150_FILE = Path(__file__).parent / 'fingerprint-presets-v150.json'
# Firefox major version at which the v150 preset bundle becomes preferred.
PRESETS_V150_MIN_FF = 149
_PRESETS_CACHE: Dict[Path, Dict] = {}
# CreepJS OS marker fonts used for OS detection. Twin of MARKER_FONTS in
# scripts/gen-fonts-json.py. Every name must be in fonts.json for its OS
# (scripts/verify-fonts.py checks): a marker the bundle cannot render would be a
# reverse leak. That is why PingFang HK/SC/TC are no longer macOS markers -- the
# bundle carries no PingFang file. On Linux the first three are also
# the families bundle/fontconfig/linux/fonts.conf resolves sans-serif / serif /
# monospace to, so an identity without them would have no face behind any CSS
# generic; Arimo / Cousine / Tinos stay because a real Ubuntu answers "present"
# for them via its metric aliases.
_MACOS_MARKER_FONTS = [
'Helvetica Neue',
]
_LINUX_MARKER_FONTS = [
'Noto Sans', 'Noto Serif', 'DejaVu Sans Mono', 'Arimo', 'Cousine', 'Tinos', 'Twemoji Mozilla',
]
_WINDOWS_MARKER_FONTS = [
'Segoe UI', 'Tahoma', 'Cambria Math', 'Nirmala UI',
]
def _ensure_marker_fonts(fonts: List[str], markers: List[str]) -> None:
"""Add any missing marker fonts to the font list (in-place)."""
existing = set(fonts)
for m in markers:
if m not in existing:
fonts.append(m)
# OS font lists loaded from fonts.json
_OS_FONTS_CACHE: Optional[Dict[str, List[str]]] = None
def _load_os_fonts() -> Dict[str, List[str]]:
"""Load the full OS font lists from fonts.json."""
global _OS_FONTS_CACHE
if _OS_FONTS_CACHE is not None:
return _OS_FONTS_CACHE
fonts_path = os.path.join(os.path.dirname(__file__), 'fonts.json')
with open(fonts_path, 'rb') as f:
import orjson
_OS_FONTS_CACHE = orjson.loads(f.read())
return _OS_FONTS_CACHE
# Essential fonts per OS that must always be included in subsets.
#
# These are the OS BASE font sets: every family a real machine of that OS ships
# by default. A real machine has ALL of its OS defaults -- only the additions
# (Office, LibreOffice, Adobe CC, developer and web fonts) vary from box to box --
# so the base is never subsetted; the random 30-78% draw below applies to the
# additions only (fonts.json minus the base).
#
# Source: the per-OS bases in scripts/data/font-manifests.json (Windows 10 with
# the stock CJK families; macOS Sonoma; Ubuntu and its Mint variant),
# intersected with fonts.json so only names the bundle can
# render are listed (Sonoma's PingFang / Kefa / Hiragino families are in the real
# base but not bundled, so they are absent here). Regenerate together with
# fonts.json: `python3 scripts/gen-fonts-json.py --print-bases`.
#
# Windows: the seven GDI-substitution names (Courier, Helvetica, MS Sans Serif,
# MS Serif, Roman, Small Fonts, Times) and the six Light/Semilight names have no
# file of their own; bundle/fontconfig/windows/fonts.conf rewrites each to its
# bundled target unconditionally, so they MUST stay in this always-reported set
# (an identity that did not report Helvetica would still render it otherwise).
_ESSENTIAL_FONTS_MACOS = [
'Academy Engraved LET', 'Al Bayan', 'Al Nile', 'Al Tarikh', 'American Typewriter', 'American Typewriter Semibold',
'Andale Mono', 'Apple Braille', 'Apple Chancery', 'Apple Color Emoji', 'Apple SD Gothic Neo',
'Apple SD Gothic Neo ExtraBold', 'Apple Symbols', 'AppleGothic', 'AppleMyungjo', 'Arial',
'Arial Black', 'Arial Hebrew', 'Arial Hebrew Scholar', 'Arial Narrow', 'Arial Rounded MT Bold',
'Arial Unicode MS', 'Athelas', 'Avenir', 'Avenir Black', 'Avenir Black Oblique', 'Avenir Book',
'Avenir Heavy', 'Avenir Light', 'Avenir Medium', 'Avenir Next', 'Avenir Next Demi Bold',
'Avenir Next Heavy', 'Avenir Next Medium', 'Avenir Next Ultra Light', 'Ayuthaya', 'Baghdad',
'Bangla MN', 'Bangla Sangam MN', 'Baskerville', 'Beirut', 'Big Caslon', 'Bodoni 72', 'Bodoni 72 Oldstyle',
'Bodoni 72 Smallcaps', 'Bodoni Ornaments', 'Bradley Hand', 'Brush Script MT', 'Chalkboard',
'Chalkboard SE', 'Chalkduster', 'Charter', 'Charter Black', 'Cochin', 'Comic Sans MS',
'Copperplate', 'Corsiva Hebrew', 'Courier', 'Courier New', 'DIN Alternate', 'DIN Condensed',
'Damascus', 'DecoType Naskh', 'Devanagari MT', 'Devanagari Sangam MN', 'Didot', 'Diwan Kufi',
'Diwan Thuluth', 'Euphemia UCAS', 'Farah', 'Farisi', 'Futura', 'Futura Bold', 'GB18030 Bitmap',
'Galvji', 'Geeza Pro', 'Geneva', 'Georgia', 'Gill Sans', 'Grantha Sangam MN', 'Gujarati MT',
'Gujarati Sangam MN', 'Gurmukhi MN', 'Gurmukhi MT', 'Gurmukhi Sangam MN', 'Heiti SC', 'Heiti TC',
'Helvetica', 'Helvetica Neue', 'Hiragino Kaku Gothic Pro', 'Hiragino Kaku Gothic Std',
'Hiragino Kaku Gothic StdN', 'Hiragino Maru Gothic Pro', 'Hiragino Maru Gothic ProN', 'Hiragino Maru Gothic ProN W4',
'Hiragino Mincho Pro', 'Hiragino Mincho ProN', 'Hiragino Mincho ProN W3', 'Hiragino Mincho ProN W6',
'Hiragino Sans', 'Hiragino Sans GB', 'Hiragino Sans GB W3', 'Hiragino Sans GB W6', 'Hiragino Sans W0',
'Hiragino Sans W1', 'Hiragino Sans W2', 'Hiragino Sans W3', 'Hiragino Sans W4', 'Hiragino Sans W5',
'Hiragino Sans W6', 'Hiragino Sans W7', 'Hiragino Sans W8', 'Hiragino Sans W9', 'Hoefler Text',
'Hoefler Text Ornaments', 'ITF Devanagari', 'ITF Devanagari Marathi', 'Impact', 'InaiMathi',
'InaiMathi Bold', 'Iowan Old Style', 'Kailasa', 'Kannada MN', 'Kannada Sangam MN', 'Khmer MN',
'Khmer Sangam MN', 'Kohinoor Bangla', 'Kohinoor Devanagari', 'Kohinoor Devanagari Medium',
'Kohinoor Gujarati', 'Kohinoor Telugu', 'Kokonor', 'Krungthep', 'KufiStandardGK', 'Lao MN',
'Lao Sangam MN', 'Lucida Grande', 'Luminari', 'Malayalam MN', 'Malayalam Sangam MN', 'Marker Felt',
'Menlo', 'Microsoft Sans Serif', 'Mishafi', 'Mishafi Gold', 'Monaco', 'Mshtakan', 'MuktaMahee Bold',
'MuktaMahee ExtraBold', 'MuktaMahee ExtraLight', 'MuktaMahee Light', 'MuktaMahee Medium',
'MuktaMahee Regular', 'MuktaMahee SemiBold', 'Muna', 'Myanmar MN', 'Myanmar Sangam MN',
'Nadeem', 'New Peninim MT', 'Noteworthy', 'Noto Nastaliq Urdu', 'Noto Sans Adlam', 'Noto Sans Armenian',
'Noto Sans Armenian Blk', 'Noto Sans Armenian ExtBd', 'Noto Sans Armenian ExtLt', 'Noto Sans Armenian Light',
'Noto Sans Armenian Med', 'Noto Sans Armenian SemBd', 'Noto Sans Armenian Thin', 'Noto Sans Avestan',
'Noto Sans Bamum', 'Noto Sans Bassa Vah', 'Noto Sans Batak', 'Noto Sans Bhaiksuki', 'Noto Sans Buginese',
'Noto Sans Buhid', 'Noto Sans Canadian Aboriginal Regular', 'Noto Sans Carian', 'Noto Sans CaucAlban',
'Noto Sans Chakma', 'Noto Sans Cham', 'Noto Sans Coptic', 'Noto Sans Cuneiform', 'Noto Sans Cypriot',
'Noto Sans Duployan', 'Noto Sans EgyptHiero', 'Noto Sans Elbasan', 'Noto Sans Glagolitic',
'Noto Sans Gothic', 'Noto Sans Gunjala Gondi', 'Noto Sans HanifiRohg', 'Noto Sans Hanunoo',
'Noto Sans Hatran', 'Noto Sans ImpAramaic', 'Noto Sans InsPahlavi', 'Noto Sans InsParthi',
'Noto Sans Javanese', 'Noto Sans Kaithi', 'Noto Sans Kannada', 'Noto Sans Kannada Black',
'Noto Sans Kannada ExtraBold', 'Noto Sans Kannada ExtraLight', 'Noto Sans Kannada Light',
'Noto Sans Kannada Medium', 'Noto Sans Kannada SemiBold', 'Noto Sans Kannada Thin', 'Noto Sans Kayah Li',
'Noto Sans Kharoshthi', 'Noto Sans Khojki', 'Noto Sans Khudawadi', 'Noto Sans Lepcha',
'Noto Sans Limbu', 'Noto Sans Linear A', 'Noto Sans Linear B', 'Noto Sans Lisu', 'Noto Sans Lycian',
'Noto Sans Lydian', 'Noto Sans Mahajani', 'Noto Sans Mandaic', 'Noto Sans Manichaean',
'Noto Sans Marchen', 'Noto Sans Masaram Gondi', 'Noto Sans Mende Kikakui', 'Noto Sans Meroitic',
'Noto Sans Miao', 'Noto Sans Modi', 'Noto Sans Mongolian', 'Noto Sans Mro', 'Noto Sans Multani',
'Noto Sans Myanmar', 'Noto Sans Myanmar Blk', 'Noto Sans Myanmar ExtBd', 'Noto Sans Myanmar ExtLt',
'Noto Sans Myanmar Light', 'Noto Sans Myanmar Med', 'Noto Sans Myanmar SemBd', 'Noto Sans Myanmar Thin',
'Noto Sans NKo', 'Noto Sans Nabataean', 'Noto Sans Newa', 'Noto Sans Ol Chiki', 'Noto Sans Old Italic',
'Noto Sans Old Permic', 'Noto Sans Old Turkic', 'Noto Sans OldHung', 'Noto Sans OldNorArab',
'Noto Sans OldSouArab', 'Noto Sans Oriya', 'Noto Sans Osage', 'Noto Sans Osmanya', 'Noto Sans Pahawh Hmong',
'Noto Sans Palmyrene', 'Noto Sans PhagsPa', 'Noto Sans Phoenician', 'Noto Sans PsaPahlavi',
'Noto Sans Rejang', 'Noto Sans Samaritan', 'Noto Sans Saurashtra', 'Noto Sans Sharada',
'Noto Sans Siddham', 'Noto Sans SoraSomp', 'Noto Sans Sundanese', 'Noto Sans Syloti Nagri',
'Noto Sans Syriac', 'Noto Sans Tagalog', 'Noto Sans Tagbanwa', 'Noto Sans Tai Le', 'Noto Sans Tai Tham',
'Noto Sans Tai Viet', 'Noto Sans Takri', 'Noto Sans Thaana', 'Noto Sans Tifinagh', 'Noto Sans Tirhuta',
'Noto Sans Ugaritic', 'Noto Sans Vai', 'Noto Sans Wancho', 'Noto Sans Yi', 'Noto Sans Zawgyi',
'Noto Sans Zawgyi Blk', 'Noto Sans Zawgyi ExtBd', 'Noto Sans Zawgyi ExtLt', 'Noto Sans Zawgyi Light',
'Noto Sans Zawgyi Med', 'Noto Sans Zawgyi SemBd', 'Noto Sans Zawgyi Thin', 'Noto Serif Ahom',
'Noto Serif Balinese', 'Noto Serif Hmong Nyiakeng', 'Noto Serif Myanmar', 'Noto Serif Myanmar Blk',
'Noto Serif Myanmar ExtBd', 'Noto Serif Myanmar ExtLt', 'Noto Serif Myanmar Light', 'Noto Serif Myanmar Med',
'Noto Serif Myanmar SemBd', 'Noto Serif Myanmar Thin', 'Noto Serif Yezidi', 'Optima', 'Oriya MN',
'Oriya Sangam MN', 'PT Mono', 'PT Sans', 'PT Sans Caption', 'PT Sans Narrow', 'PT Serif',
'PT Serif Caption', 'Palatino', 'Papyrus', 'Party LET', 'Phosphate', 'PingFang HK', 'PingFang SC',
'PingFang TC', 'Plantagenet Cherokee', 'Raanana', 'Rockwell', 'STIX Two Math', 'STIX Two Math Regular',
'STIX Two Text', 'STIX Two Text Regular', 'STIXGeneral', 'STIXIntegralsD', 'STIXIntegralsSm',
'STIXIntegralsUp', 'STIXIntegralsUpD', 'STIXIntegralsUpSm', 'STIXNonUnicode', 'STIXSizeFiveSym',
'STIXSizeFourSym', 'STIXSizeOneSym', 'STIXSizeThreeSym', 'STIXSizeTwoSym', 'STIXVariants',
'STSong', 'Sana', 'Sathu', 'Savoye LET', 'Shree Devanagari 714', 'SignPainter-HouseScript',
'Silom', 'Sinhala MN', 'Sinhala Sangam MN', 'Skia', 'Snell Roundhand', 'Songti SC', 'Songti TC',
'Sukhumvit Set', 'Superclarendon', 'Symbol', 'System Font', 'Tahoma', 'Tamil MN', 'Tamil Sangam MN',
'Telugu MN', 'Telugu Sangam MN', 'Thonburi', 'Times', 'Times New Roman', 'Trattatello',
'Trebuchet MS', 'Verdana', 'Waseem', 'Webdings', 'Wingdings', 'Wingdings 2', 'Wingdings 3',
'Zapf Dingbats', 'Zapfino',
]
_ESSENTIAL_FONTS_WINDOWS = [
'Arial', 'Arial Black', 'Bahnschrift', 'Calibri', 'Calibri Light', 'Cambria', 'Cambria Math',
'Candara', 'Candara Light', 'Comic Sans MS', 'Consolas', 'Constantia', 'Corbel', 'Corbel Light',
'Courier', 'Courier New', 'Ebrima', 'Franklin Gothic Medium', 'Gabriola', 'Gadugi', 'Georgia',
'Helvetica', 'Impact', 'Ink Free', 'Javanese Text', 'Leelawadee UI', 'Leelawadee UI Semilight',
'Lucida Console', 'Lucida Sans Unicode', 'MS Gothic', 'MS PGothic', 'MS Sans Serif', 'MS Serif',
'MS UI Gothic', 'MV Boli', 'Malgun Gothic', 'Malgun Gothic Semilight', 'Marlett', 'Microsoft Himalaya',
'Microsoft JhengHei', 'Microsoft JhengHei Light', 'Microsoft JhengHei UI', 'Microsoft JhengHei UI Light',
'Microsoft New Tai Lue', 'Microsoft PhagsPa', 'Microsoft Sans Serif', 'Microsoft Tai Le',
'Microsoft YaHei', 'Microsoft YaHei Light', 'Microsoft YaHei UI', 'Microsoft YaHei UI Light',
'Microsoft Yi Baiti', 'MingLiU-ExtB', 'MingLiU_HKSCS-ExtB', 'MingLiU_MSCS-ExtB', 'Mongolian Baiti',
'Myanmar Text', 'NSimSun', 'Nirmala Text', 'Nirmala Text Semilight', 'Nirmala UI', 'Nirmala UI Semilight',
'PMingLiU-ExtB', 'Palatino Linotype', 'Roman', 'Sans Serif Collection', 'Segoe Fluent Icons',
'Segoe MDL2 Assets', 'Segoe Print', 'Segoe Script', 'Segoe UI', 'Segoe UI Black', 'Segoe UI Emoji',
'Segoe UI Historic', 'Segoe UI Light', 'Segoe UI Semibold', 'Segoe UI Semilight', 'Segoe UI Symbol',
'Segoe UI Variable', 'Segoe UI Variable Display', 'Segoe UI Variable Small', 'Segoe UI Variable Text',
'SimSun', 'SimSun-ExtB', 'Sitka Banner', 'Sitka Display', 'Sitka Heading', 'Sitka Small',
'Sitka Subheading', 'Sitka Text', 'Small Fonts', 'Sylfaen', 'Symbol', 'Tahoma', 'Times',
'Times New Roman', 'Trebuchet MS', 'Twemoji Mozilla', 'Verdana', 'Webdings', 'Wingdings',
'Yu Gothic', 'Yu Gothic Light', 'Yu Gothic Medium', 'Yu Gothic UI', 'Yu Gothic UI Light',
'Yu Gothic UI Semibold', 'Yu Gothic UI Semilight', '宋体', '微軟正黑體', '微軟正黑體 Light', '微软雅黑',
'微软雅黑 Light', '新宋体', '新細明體-ExtB', '游ゴシック', '游ゴシック Light', '游ゴシック Medium', '細明體-ExtB', '細明體_HKSCS-ExtB',
'細明體_MSCS-ExtB', '맑은 고딕', '맑은 고딕 Semilight', 'MS ゴシック', 'MS Pゴシック',
]
_ESSENTIAL_FONTS_LINUX = [
'AR PL UKai CN', 'AR PL UKai HK', 'AR PL UKai TW', 'AR PL UKai TW MBE', 'AR PL UMing CN',
'AR PL UMing HK', 'AR PL UMing TW', 'AR PL UMing TW MBE', 'Arial', 'Arial Narrow', 'Avant Garde',
'Bookman Old Style', 'C059', 'Calibri', 'Cambria', 'Century Schoolbook', 'Courier', 'Courier New',
'D050000L', 'DejaVu Sans', 'DejaVu Sans Mono', 'DejaVu Serif', 'Droid Sans Fallback', 'Helvetica',
'Helvetica Narrow', 'Liberation Mono', 'Liberation Sans', 'Liberation Sans Narrow', 'Liberation Serif',
'Nimbus Mono PS', 'Nimbus Roman', 'Nimbus Sans', 'Nimbus Sans Narrow', 'Noto Color Emoji',
'Noto Kufi Arabic', 'Noto Looped Lao', 'Noto Looped Lao Bold', 'Noto Looped Lao Regular',
'Noto Looped Thai', 'Noto Looped Thai Bold', 'Noto Looped Thai Regular', 'Noto Mono', 'Noto Music',
'Noto Naskh Arabic', 'Noto Nastaliq Urdu', 'Noto Rashi Hebrew', 'Noto Sans', 'Noto Sans Adlam',
'Noto Sans Adlam Unjoined', 'Noto Sans AnatoHiero', 'Noto Sans Anatolian Hieroglyphs',
'Noto Sans Arabic', 'Noto Sans Armenian', 'Noto Sans Avestan', 'Noto Sans Balinese', 'Noto Sans Bamum',
'Noto Sans Bassa Vah', 'Noto Sans Batak', 'Noto Sans Bengali', 'Noto Sans Bhaiksuki', 'Noto Sans Brahmi',
'Noto Sans Buginese', 'Noto Sans Buhid', 'Noto Sans CJK HK', 'Noto Sans CJK JP', 'Noto Sans CJK KR',
'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Noto Sans CanAborig', 'Noto Sans Canadian Aboriginal',
'Noto Sans Carian', 'Noto Sans CaucAlban', 'Noto Sans Caucasian Albanian', 'Noto Sans Chakma',
'Noto Sans Cham', 'Noto Sans Cherokee', 'Noto Sans Coptic', 'Noto Sans Cuneiform', 'Noto Sans Cypriot',
'Noto Sans Deseret', 'Noto Sans Devanagari', 'Noto Sans Display', 'Noto Sans Duployan',
'Noto Sans EgyptHiero', 'Noto Sans Egyptian Hieroglyphs', 'Noto Sans Elbasan', 'Noto Sans Elymaic',
'Noto Sans Ethiopic', 'Noto Sans Georgian', 'Noto Sans Glagolitic', 'Noto Sans Gothic',
'Noto Sans Grantha', 'Noto Sans Gujarati', 'Noto Sans Gunjala Gondi', 'Noto Sans Gurmukhi',
'Noto Sans Hanifi Rohingya', 'Noto Sans Hanunoo', 'Noto Sans Hatran', 'Noto Sans Hebrew',
'Noto Sans ImpAramaic', 'Noto Sans Imperial Aramaic', 'Noto Sans Indic Siyaq Numbers',
'Noto Sans InsPahlavi', 'Noto Sans InsParthi', 'Noto Sans Inscriptional Pahlavi', 'Noto Sans Inscriptional Parthian',
'Noto Sans Javanese', 'Noto Sans Kaithi', 'Noto Sans Kannada', 'Noto Sans Kayah Li', 'Noto Sans Kharoshthi',
'Noto Sans Khmer', 'Noto Sans Khojki', 'Noto Sans Khudawadi', 'Noto Sans Lao', 'Noto Sans Lepcha',
'Noto Sans Limbu', 'Noto Sans Linear A', 'Noto Sans Linear B', 'Noto Sans Lisu', 'Noto Sans Lycian',
'Noto Sans Lydian', 'Noto Sans Mahajani', 'Noto Sans Malayalam', 'Noto Sans Mandaic', 'Noto Sans Manichaean',
'Noto Sans Marchen', 'Noto Sans Masaram Gondi', 'Noto Sans Math', 'Noto Sans Mayan Numerals',
'Noto Sans Medefaidrin', 'Noto Sans Meetei Mayek', 'Noto Sans Mende Kikakui', 'Noto Sans Meroitic',
'Noto Sans Miao', 'Noto Sans Modi', 'Noto Sans Mongolian', 'Noto Sans Mono', 'Noto Sans Mono CJK HK',
'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK KR', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC',
'Noto Sans Mro', 'Noto Sans Multani', 'Noto Sans Myanmar', 'Noto Sans NKo', 'Noto Sans Nabataean',
'Noto Sans New Tai Lue', 'Noto Sans Newa', 'Noto Sans Nushu', 'Noto Sans Ogham', 'Noto Sans Ol Chiki',
'Noto Sans Old Hungarian', 'Noto Sans Old Italic', 'Noto Sans Old North Arabian', 'Noto Sans Old Permic',
'Noto Sans Old Persian', 'Noto Sans Old Sogdian', 'Noto Sans Old South Arabian', 'Noto Sans Old Turkic',
'Noto Sans OldHung', 'Noto Sans OldNorArab', 'Noto Sans OldSouArab', 'Noto Sans Oriya',
'Noto Sans Osage', 'Noto Sans Osmanya', 'Noto Sans Pahawh Hmong', 'Noto Sans Palmyrene',
'Noto Sans Pau Cin Hau', 'Noto Sans PhagsPa', 'Noto Sans Phoenician', 'Noto Sans PsaPahlavi',
'Noto Sans Psalter Pahlavi', 'Noto Sans Rejang', 'Noto Sans Runic', 'Noto Sans Samaritan',
'Noto Sans Saurashtra', 'Noto Sans Sharada', 'Noto Sans Shavian', 'Noto Sans Siddham',
'Noto Sans SignWrit', 'Noto Sans SignWriting', 'Noto Sans Sinhala', 'Noto Sans Sogdian',
'Noto Sans Sora Sompeng', 'Noto Sans Soyombo', 'Noto Sans Sundanese', 'Noto Sans Syloti Nagri',
'Noto Sans Symbols', 'Noto Sans Symbols2', 'Noto Sans Syriac', 'Noto Sans Tagalog', 'Noto Sans Tagbanwa',
'Noto Sans Tai Le', 'Noto Sans Tai Tham', 'Noto Sans Tai Viet', 'Noto Sans Takri', 'Noto Sans Tamil',
'Noto Sans Tamil Supplement', 'Noto Sans Telugu', 'Noto Sans Thaana', 'Noto Sans Thai',
'Noto Sans Tifinagh', 'Noto Sans Tifinagh APT', 'Noto Sans Tifinagh Adrar', 'Noto Sans Tifinagh Agraw Imazighen',
'Noto Sans Tifinagh Ahaggar', 'Noto Sans Tifinagh Air', 'Noto Sans Tifinagh Azawagh', 'Noto Sans Tifinagh Ghat',
'Noto Sans Tifinagh Hawad', 'Noto Sans Tifinagh Rhissa Ixa', 'Noto Sans Tifinagh SIL',
'Noto Sans Tifinagh Tawellemmet', 'Noto Sans Tirhuta', 'Noto Sans Ugaritic', 'Noto Sans Vai',
'Noto Sans Wancho', 'Noto Sans Warang Citi', 'Noto Sans Yi', 'Noto Sans Zanabazar', 'Noto Sans Zanabazar Square',
'Noto Serif', 'Noto Serif Ahom', 'Noto Serif Armenian', 'Noto Serif Balinese', 'Noto Serif Bengali',
'Noto Serif CJK HK', 'Noto Serif CJK JP', 'Noto Serif CJK KR', 'Noto Serif CJK SC', 'Noto Serif CJK TC',
'Noto Serif Devanagari', 'Noto Serif Display', 'Noto Serif Dogra', 'Noto Serif Ethiopic',
'Noto Serif Georgian', 'Noto Serif Grantha', 'Noto Serif Gujarati', 'Noto Serif Gurmukhi',
'Noto Serif Hebrew', 'Noto Serif Hmong Nyiakeng', 'Noto Serif Kannada', 'Noto Serif Khmer',
'Noto Serif Khojki', 'Noto Serif Lao', 'Noto Serif Malayalam', 'Noto Serif Myanmar', 'Noto Serif Sinhala',
'Noto Serif Tamil', 'Noto Serif Tamil Slanted', 'Noto Serif Tangut', 'Noto Serif Telugu',
'Noto Serif Thai', 'Noto Serif Tibetan', 'Noto Serif Yezidi', 'Noto Traditional Nushu',
'OpenSymbol', 'P052', 'Palatino', 'Palatino Linotype', 'Standard Symbols PS', 'Symbol',
'Times', 'Times New Roman', 'URW Bookman', 'URW Gothic', 'Ubuntu', 'Ubuntu Mono', 'Ubuntu Sans',
'Ubuntu Sans Mono', 'Z003', 'Zapf Chancery',
]
# OS-version variants of the base, drawn ALL-OR-NOTHING on top of the essential
# core with the real-world share of that version (the manifest's base weights). A
# Windows 11 machine (65%) has every one of the Win11 additions and a Windows 10
# machine none of them; Ubuntu (65%) ships Liberation Sans Narrow, Mint (35%)
# does not. macOS has a single bundled base (Sonoma). Format: (probability, fonts).
_BASE_VARIANT_FONTS_MACOS = (0.0, [])
_BASE_VARIANT_FONTS_WINDOWS = (1.0, [
# Verified present on win-i9 (real Windows 11 build 26200.9457). Windows 10
# was dropped 2026-09-22 (end of support Oct 2025), so every Windows identity
# is Windows 11 and these are always drawn. Cascadia Code/Mono are NOT here:
# they are not on a stock Windows 11 and are modelled as an addition.
'Sans Serif Collection', 'Segoe Fluent Icons', 'Segoe UI Variable',
'Segoe UI Variable Display', 'Segoe UI Variable Small', 'Segoe UI Variable Text',
])
# Linux needs no variant: both shipped bases are exact, whole font sets taken
# from the official Ubuntu 24.04 / 26.04 desktop ISO manifests, and
# fonts-liberation-sans-narrow is in both of them.
_BASE_VARIANT_FONTS_LINUX = (0.0, [])
# Fonts only a Windows 11 base has: a Windows identity whose font list contains
# them presents Windows 11, and the rest of the identity (overlay scrollbars,
# utils.launch_options) must agree.
WINDOWS_11_MARKER_FONTS = frozenset(_BASE_VARIANT_FONTS_WINDOWS[1])
def identity_salt(pinned: Any = None) -> int:
"""The entropy that makes identity_seed() belong to ONE identity.
The presented values identity_seed() hashes are shared by many unrelated
launches: browserforge gives each OS only a handful of screens and UAs, so
over 500 launches per OS the unsalted seed took 12-30 distinct values, and
every Camoufox install everywhere drew its fonts, voices, GPU, media devices
and canvas/audio noise seeds from that same short list.
Pass whatever the caller pinned the identity with -- a browserforge
Fingerprint, a preset dict, the caller's own config -- to get a salt that
is stable across launches of that identity (daijro/camoufox#442, #765);
pass nothing for a fresh identity, which gets a random salt.
"""
if pinned is None:
return secrets.randbits(64)
if is_dataclass(pinned) and not isinstance(pinned, type):
pinned = asdict(pinned)
import orjson
blob = orjson.dumps(pinned, option=orjson.OPT_SORT_KEYS | orjson.OPT_NON_STR_KEYS, default=str)
return int.from_bytes(hashlib.sha256(blob).digest()[:8], 'big')
def identity_seed(config: Dict[str, Any], salt: int = 0) -> int:
"""A seed for the per-identity draws (fonts, voices, GPU, media devices,
noise seeds): a pure function of the presented identity and its salt.
Two launches that present the same identity must present the same font and
voice lists: a page that keeps cookies across launches and sees the font
set or the voice list change under an otherwise identical device reads it
as a spoofed browser (daijro/camoufox#442, #765, #378). The presented
values alone are far too common to tell identities apart, so callers mix in
identity_salt() (see there).
"""
import zlib
parts = [
str(config.get('navigator.userAgent', '')),
str(config.get('navigator.platform', '')),
str(config.get('screen.width', '')),
str(config.get('screen.height', '')),
str(config.get('navigator.hardwareConcurrency', '')),
# not the GPU: it is sampled after the font draw in launch_options
str(salt),
]
return zlib.crc32('|'.join(parts).encode('utf-8')) & 0xFFFFFFFF
def _rng(seed: Optional[int]) -> Random:
"""Seeded generator for a draw, or the module-level one when unseeded."""
return Random(seed) if seed is not None else Random()
_FONT_GROUPS_CACHE: Optional[Dict[str, List[Dict[str, Any]]]] = None
def _load_font_groups() -> Dict[str, List[Dict[str, Any]]]:
"""The addition units per OS (font-groups.json, generated from the manifest
by scripts/gen-font-groups.py). Each entry is
{"id", "kind", "prob", "fonts", ["requiresLocale"], ["sizes"]}
and carries its OWN real-world probability, because that is what makes a
drawn machine plausible: Office is on ~60% of Windows boxes and the
Pan-European supplemental pack on ~2.8%, and a draw that treated them alike
would put a font nobody has on a third of its identities."""
global _FONT_GROUPS_CACHE
if _FONT_GROUPS_CACHE is None:
path = os.path.join(os.path.dirname(__file__), 'font-groups.json')
try:
with open(path, 'rb') as f:
_FONT_GROUPS_CACHE = json.loads(f.read())
except (OSError, ValueError):
_FONT_GROUPS_CACHE = {}
return _FONT_GROUPS_CACHE
_FONT_BASES_CACHE: Optional[Dict[str, List[Dict[str, Any]]]] = None
def _load_font_bases() -> Dict[str, List[Dict[str, Any]]]:
"""The OS-VERSION bases per OS (font-bases.json, same generator).
One machine runs one OS version and has that version's whole default font
set, so a base is drawn entire, by weight, and never subsetted. Windows 11's
base happens to be Windows 10's plus a few families, but macOS 26's is not a
superset of Sonoma's -- Apple renamed Kefa to "Kefa III", added PingFang MO
and respelled several Noto families (measured on macOS 26.6.2, build 25G83)
-- so they are alternatives, not a core plus extras."""
global _FONT_BASES_CACHE
if _FONT_BASES_CACHE is None:
path = os.path.join(os.path.dirname(__file__), 'font-bases.json')
try:
with open(path, 'rb') as f:
_FONT_BASES_CACHE = json.loads(f.read())
except (OSError, ValueError):
_FONT_BASES_CACHE = {}
return _FONT_BASES_CACHE
def _pick_base(os_key: str, rng: Any) -> List[str]:
"""Draw one OS-version base by its real-world weight."""
bases = _load_font_bases().get(os_key) or []
if not bases:
return []
roll = rng.random()
cumulative = 0.0
for base in bases:
cumulative += base.get('weight', 0.0)
if roll < cumulative:
return list(base['fonts'])
return list(bases[-1]['fonts'])
def _draw_units(os_key: str, rng: Any, exclude: Set[str],
locale: Optional[str] = None) -> List[str]:
"""The additions this machine has, each unit judged on its own probability.
A "bundle" unit is all-or-nothing: the software either installed its fonts
or it did not. An "alacarte" unit is a category people install piecemeal, so
it first has to be present at all and then contributes `sizes` of its
members -- which is what stops a draw from reporting all 32 web fonts or
none, neither of which is what a real machine looks like.
"""
out: List[str] = []
for unit in _load_font_groups().get(os_key, []):
required = unit.get('requiresLocale')
if required and not (locale or '').lower().startswith(required.lower()):
# e.g. the Traditional-Chinese supplemental pack is on ~90% of zh-TW
# machines and essentially no others; claiming it on an en-US
# identity is the kind of mismatch a WAF checks for.
continue
if rng.random() >= unit.get('prob', 0.0):
continue
members = [f for f in unit['fonts'] if f not in exclude]
if not members:
continue
if unit['kind'] == 'alacarte':
sizes = unit.get('sizes') or [{'n': len(members), 'w': 1.0}]
roll = rng.random() * sum(s['w'] for s in sizes)
cumulative = 0.0
count = sizes[-1]['n']
for size in sizes:
cumulative += size['w']
if roll < cumulative:
count = size['n']
break
count = max(1, min(count, len(members)))
members = rng.sample(members, count)
out.extend(members)
return out
def _host_has_variant_fonts(target_os: str) -> bool:
"""Whether the host itself ships the OS-version font variant (native identities only)."""
if target_os != 'windows':
return False
import os
fonts_dir = os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Fonts')
# SegUIVar.ttf is Segoe UI Variable, present on every Windows 11 install and on no Windows 10.
return os.path.exists(os.path.join(fonts_dir, 'SegUIVar.ttf'))
def _generate_random_font_subset(
target_os: str,
seed: Optional[int] = None,
native: bool = False,
locale: Optional[str] = None,
) -> List[str]:
"""
Generate the font list of one plausible machine of the given OS.
A real machine is one OS-version base plus whatever software the owner
installed, so that is how this draws: one base by weight, in full and never
subsetted, then each addition unit independently at its own measured
probability (font-groups.json). The marker fonts are ensured last.
`locale` gates the units a manifest marks `requiresLocale` -- the
Traditional-Chinese supplemental pack is on ~90% of zh-TW machines and
almost no others, so claiming it on an en-US identity is a mismatch a WAF
can check.
`native`: the identity is the host's own OS (macOS / Windows), where the
browser uses the real system fonts and not the bundle. Only the OS base
is claimed then: an "addition" the host does not have would be listed but
fall back when measured, which a page can see (measured 2026-09-14 on a
stock Mac mini: Fira Code / Lato claimed, rendered as Menlo).
"""
rng = _rng(seed)
os_fonts_data = _load_os_fonts()
os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac')
full_list = os_fonts_data.get(os_key, os_fonts_data.get('mac', []))
if target_os == 'windows':
essential = set(_ESSENTIAL_FONTS_WINDOWS)
markers = _WINDOWS_MARKER_FONTS
variant_prob, variant_fonts = _BASE_VARIANT_FONTS_WINDOWS
elif target_os == 'linux':
essential = set(_ESSENTIAL_FONTS_LINUX)
markers = _LINUX_MARKER_FONTS
variant_prob, variant_fonts = _BASE_VARIANT_FONTS_LINUX
else:
essential = set(_ESSENTIAL_FONTS_MACOS)
markers = _MACOS_MARKER_FONTS
variant_prob, variant_fonts = _BASE_VARIANT_FONTS_MACOS
variant = set(variant_fonts)
if native:
# The host's own OS: the browser is using the real system fonts, so the
# claim is the base and nothing else. An "addition" the host does not
# have would be listed and then fall back when measured, which a page
# can see (measured 2026-09-14 on a stock Mac mini: Fira Code / Lato
# claimed, rendered as Menlo).
result = [f for f in full_list if f in essential]
result.extend(sorted(f for f in essential if f not in set(full_list)))
# The OS-version variant is real system fonts too: claim it exactly when
# the host has it. A Windows 11 host presented as Windows 10 hides Segoe UI
# Variable etc. and, with the matching classic scrollbars, differs from the
# stock Firefox on the same machine (Windows 11 test host, 2026-09-16:
# 0 px overlay).
# Windows 10 was dropped as a spoofing target on 2026-09-22 (end of
# support Oct 2025), so the drawn base IS Windows 11 and already carries
# the Win11-only families. A native identity is different: camoufox may
# be RUNNING on a Windows 10 host, and that host cannot render them, so
# they are removed when the host lacks them rather than added when it
# has them.
if variant and not _host_has_variant_fonts(target_os):
absent = set(variant_fonts)
result = [f for f in result if f not in absent]
return result
# One machine runs one OS version, and has that version's default font set
# in full -- so the base is drawn whole, by weight, and never subsetted.
base = _pick_base(os_key, rng)
if not base:
# No generated base data: fall back to the always-present core so a
# draw is still coherent rather than empty.
base = [f for f in full_list if f in essential]
result = list(base)
chosen = set(result)
# _ESSENTIAL_FONTS_* is the guaranteed floor underneath whichever base was
# drawn: the GDI-substitution names on Windows and the alias names fonts.conf
# rewrites unconditionally have no file of their own, so they render for
# every identity and must be reported by every identity.
for font in full_list:
if font in essential and font not in chosen:
result.append(font)
chosen.add(font)
# Everything else is an addition, and each unit is judged on its own
# real-world probability rather than by a flat sample: Office lands on ~60%
# of Windows machines, the Pan-European pack on ~2.8%, msttcorefonts on
# ~13.9% of Linux ones (all measured against the fpgen corpus). Units are
# atomic, so a draw never produces a partial group -- a partial group is a
# synthetic artifact no real machine shows (sundial "co-shipped families
# not split").
for font in _draw_units(os_key, rng, exclude=chosen, locale=locale):
if font not in chosen:
result.append(font)
chosen.add(font)
# Ensure marker fonts are present
_ensure_marker_fonts(result, markers)
return result
# Essential speech voices per OS that must always be included in subsets
_ESSENTIAL_VOICES_MACOS = [
'Samantha', 'Alex', 'Fred', 'Victoria', 'Karen', 'Daniel',
]
_ESSENTIAL_VOICES_WINDOWS = [
'Microsoft David - English (United States)',
'Microsoft Zira - English (United States)',
'Microsoft Mark - English (United States)',
]
# Real Firefox speechSynthesis URI prefixes per backend.
# macOS NSSpeechSynthesizer -> "urn:moz-tts:osx:<dotted-slug>"
# Windows SAPI -> "urn:moz-tts:sapi:<dotted-slug>"
# Linux speech-dispatcher -> "urn:moz-tts:speechd:<escaped-name>?<lang>"
_VOICE_URI_PREFIX = {
'mac': 'urn:moz-tts:osx:',
'win': 'urn:moz-tts:sapi:',
'lin': 'urn:moz-tts:speechd:',
}
def _voice_uri_slug(name: str) -> str:
"""Stable dotted slug for mac/win URIs (shape-plausible, not catalog-exact)."""
return re.sub(r'^\.|\.$', '', re.sub(r'[^a-z0-9]+', '.', name.lower()))
def _voice_uri(os_key: str, name: str, lang: str) -> str:
"""Build a voiceUri matching what real Firefox emits for the OS backend."""
if os_key == 'lin':
# Firefox's SpeechDispatcherService.cpp builds:
# "urn:moz-tts:speechd:" + NS_EscapeURL(name, OnlyNonASCII|Spaces) + "?" + lang
# i.e. spaces -> %20 and non-ASCII bytes -> %XX, ASCII punctuation intact.
escaped = []
for ch in name:
if ch == ' ':
escaped.append('%20')
elif ord(ch) <= 0x7F:
escaped.append(ch)
else:
escaped.append(''.join(f'%{b:02X}' for b in ch.encode('utf-8')))
return f"{_VOICE_URI_PREFIX['lin']}{''.join(escaped)}?{lang}"
if os_key == 'win':
# SapiService.cpp: "urn:moz-tts:sapi:" + name + "?" + lang, verbatim
# (measured 2026-09-14 on a stock Windows 11: spaces and parentheses
# unescaped, e.g. "...sapi:Microsoft David - English (United States)?en-US").
return f"{_VOICE_URI_PREFIX['win']}{name}?{lang}"
if os_key == 'mac':
# OSXSpeechSynthesizerService: "urn:moz-tts:osx:" + AVSpeechSynthesisVoice
# identifier. Catalogue captured from a stock macOS (voice-uris.json);
# voices outside it follow Apple's identifier families.
uri = _load_voice_uris().get('mac', {}).get(f'{name}|{lang}')
if uri:
return uri
ascii_name = re.sub(r'[^A-Za-z0-9]', '', unicodedata.normalize('NFKD', name))
if name in _MAC_NOVELTY_VOICES:
# e.g. com.apple.speech.synthesis.voice.Albert / .Fred / .Victoria
# (capitalised as the voice name; multi-word names are joined)
return f"{_VOICE_URI_PREFIX['mac']}com.apple.speech.synthesis.voice.{ascii_name}"
if name in _MAC_ELOQUENCE_VOICES:
return f"{_VOICE_URI_PREFIX['mac']}com.apple.eloquence.{lang}.{ascii_name}"
return f"{_VOICE_URI_PREFIX['mac']}com.apple.voice.compact.{lang}.{ascii_name}"
return f"{_VOICE_URI_PREFIX.get(os_key, '')}{_voice_uri_slug(name)}"
_MAC_NOVELTY_VOICES = frozenset(
{'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos', 'Wobble', 'Good News', 'Jester',
'Organ', 'Superstar', 'Trinoids', 'Whisper', 'Zarvox', 'Fred', 'Junior', 'Kathy', 'Ralph',
'Bruce', 'Vicki', 'Victoria', 'Agnes', 'Princess', 'Hysterical', 'Pipe Organ', 'Deranged',
# not a novelty voice, but the same MacinTalk identifier family
'Alex'}
)
_MAC_ELOQUENCE_VOICES = frozenset({'Eddy', 'Flo', 'Grandma', 'Grandpa', 'Reed', 'Rocko', 'Sandy', 'Shelley'})
_VOICE_URIS_CACHE: Optional[Dict[str, Dict[str, str]]] = None
def _load_voice_uris() -> Dict[str, Dict[str, str]]:
"""Real voiceURI per "Name|lang" as a stock browser reports it (voice-uris.json)."""
global _VOICE_URIS_CACHE
if _VOICE_URIS_CACHE is None:
path = os.path.join(os.path.dirname(__file__), 'voice-uris.json')
try:
with open(path, 'rb') as f:
_VOICE_URIS_CACHE = json.loads(f.read())
except OSError:
_VOICE_URIS_CACHE = {}
return _VOICE_URIS_CACHE
def _load_voice_manifests() -> Dict[str, Any]:
"""The per-OS installed-voice model (voice-manifests.json): a base the OS
always ships, Windows language packs keyed by display locale, and additions
drawn as atomic bundles / a-la-carte voices / whole language packs."""
global _VOICE_MANIFESTS_CACHE
if _VOICE_MANIFESTS_CACHE is None:
path = os.path.join(os.path.dirname(__file__), 'voice-manifests.json')
with open(path, 'rb') as f:
_VOICE_MANIFESTS_CACHE = json.loads(f.read())
return _VOICE_MANIFESTS_CACHE
_VOICE_MANIFESTS_CACHE: Optional[Dict[str, Any]] = None
def _split_voice_entry(entry: str) -> Tuple[str, str, str]:
name, lang, vtype = entry.rsplit(':', 2)
return name, lang, vtype
def _weighted_pick(rng: Random, items: List[Dict[str, Any]], wkey: str = 'w') -> Dict[str, Any]:
total = sum(float(i.get(wkey, 0)) for i in items)
r = rng.random() * total
for i in items:
r -= float(i.get(wkey, 0))
if r <= 0:
return i
return items[-1]
def _weighted_sample(rng: Random, items: List[Any], k: int, weight) -> List[Any]:
pool = list(items)
out: List[Any] = []
while pool and len(out) < k:
total = sum(weight(x) for x in pool)
r = rng.random() * total
for x in pool:
r -= weight(x)
if r <= 0:
out.append(x)
pool.remove(x)
break
else:
out.append(pool.pop())
return out
def _resolve_display_pack(packs: Dict[str, Any], fallback: str, locale: Optional[str]) -> str:
if locale:
if locale in packs:
return locale
lang = locale.split('-')[0].lower()
for key in packs:
if key.split('-')[0].lower() == lang:
return key
return fallback if fallback in packs else next(iter(packs))
def _generate_random_voice_subset(
target_os: str, locale: Optional[str] = None, seed: Optional[int] = None) -> List[Dict[str, Any]]:
"""Generate the speech voice list for the given OS as MaskConfig objects.
Returns a list of {lang, name, voiceUri, isDefault, isLocalService} dicts,
the shape MaskConfig::MVoices() requires (it silently drops any entry
missing a field, so raw name strings would register nothing).
Without this override, Firefox registers the HOST machine's
speech-dispatcher / SAPI / NSSpeech voices, leaking the OS the wrapper
actually runs on. The list follows a measured model of what a stock
machine exposes (voice-manifests.json):
Windows: the display language's OneCore pack (en-US: David/Mark/Zira),
its legacy "Desktop" tokens, and occasionally extra packs;
macOS: the compact + Eloquence base (~184 voices) plus rare downloads;
Linux: speech-dispatcher's fixed espeak-ng list (131 voices).
Seeded by the identity so the same identity always reports the same list.
"""
rng = _rng(seed)
os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac')
manifest = _load_voice_manifests().get(os_key) or _load_voice_manifests()['mac']
out: List[str] = []
seen = set()
def add(entries):
for e in entries or []:
if e not in seen:
seen.add(e)
out.append(e)
legacy: List[str] = []
packs = manifest.get('langPacks') or {}
def take_pack(pack):
add(pack.get('oneCore'))
if pack.get('desktop') and rng.random() < float(pack.get('desktopProb') or 0):
for e in pack['desktop']:
if e not in legacy:
legacy.append(e)
add(manifest.get('base'))
chosen = set()
if packs:
key = _resolve_display_pack(packs, manifest.get('fallbackLocale') or 'en-US', locale)
chosen.add(key)
take_pack(packs[key])
for addition in manifest.get('additions', []):
if addition.get('deferred'):
continue
req = addition.get('requiresLocale')
if req and not (locale or '').lower().startswith(req.lower()):
continue
if rng.random() >= float(addition.get('prob') or 0):
continue
kind = addition.get('kind')
if kind == 'bundle':
add(addition.get('voices'))
elif kind == 'alacarte':
sizes = addition.get('sizes') or [{'n': 1, 'w': 1}]
k = int(_weighted_pick(rng, sizes)['n'])
for e in _weighted_sample(rng, addition.get('voices') or [], k, lambda x: 1.0):
if e not in seen:
seen.add(e)
# a downloaded voice sits in its alphabetical place
idx = next((i for i, v in enumerate(out) if v.lower() > e.lower()), len(out))
out.insert(idx, e)
elif kind == 'groups' and packs:
eligible = [g for g in addition.get('groups') or [] if g in packs and g not in chosen]
if not eligible:
continue
k = int(_weighted_pick(rng, addition['sizes'])['n']) if addition.get('sizes') else len(eligible)
for g in _weighted_sample(rng, eligible, k, lambda g: float(packs[g].get('weight') or 0.01)):
chosen.add(g)
take_pack(packs[g])
selected = [_split_voice_entry(e) for e in out + legacy]
if not selected:
return []
voices: List[Dict[str, Any]] = [
{
'name': name,
'lang': lang,
'voiceUri': _voice_uri(os_key, name, lang),
'isDefault': False,
'isLocalService': vtype == 'local',
}
for (name, lang, vtype) in selected
]
# No voice carries default=true: stock Firefox 152 marks none on Windows
# (SAPI), macOS or Linux (measured 2026-09-14 on all three), so a spoofed
# default would be the odd one out.
return voices
def _normalize_preset_voices(
voices: Any, target_os: str
) -> List[Dict[str, Any]]:
"""Coerce a preset's `speechVoices` into MaskConfig voice objects.
Presets historically store voices as "Name:lang:type" strings, which the
C++ MaskConfig::MVoices() silently drops (it needs full objects). Convert
them; pass through entries that are already objects.
"""
os_key = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}.get(target_os, 'mac')
result: List[Dict[str, Any]] = []
for entry in voices:
if isinstance(entry, dict):
result.append(entry)
continue
last = entry.rfind(':')
if last < 0:
continue
vtype = entry[last + 1:]
before = entry[:last]
langsep = before.rfind(':')
if langsep < 0:
continue
lang = before[langsep + 1:]
name = before[:langsep]
if not name or not lang:
continue
result.append(
{
'name': name,
'lang': lang,
'voiceUri': _voice_uri(os_key, name, lang),
'isDefault': False,
'isLocalService': vtype == 'local',
}
)
if result and not any(v['isDefault'] for v in result):
result[0]['isDefault'] = True
return result
def host_cpu_count() -> Optional[int]:
"""Logical CPUs this process may actually run on (cgroup/affinity aware)."""
try:
return len(os.sched_getaffinity(0)) or None # type: ignore[attr-defined]
except (AttributeError, OSError):
return os.cpu_count()
# Core counts real desktop machines ship with, taken from the RECORDED
# fingerprint corpus rather than invented: fingerprint-presets.json and
# -v150.json between them contain 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24,
# 28 and 32 (18: macOS 2/67; 22: Windows 6/180, Linux 2/65; 28: Windows 2/180,
# Linux 1/65; 32: Linux 1/65 -- all in -v150). Leaving any of them out snapped
# genuine machines with that count down to the next entry for no reason.
#
# 2 is EXCLUDED, and stays excluded -- but not for the reason first written
# here. That reason ("2 is what Firefox reports under resistFingerprinting") is
# false and has been for years: RuntimeService::ClampedHardwareConcurrency
# (dom/workers/RuntimeService.cpp, read in the 152 tree on 2026-09-17) hardcodes
# 4 under RFP, and 8 on macOS -- both already in this table.
#
# The real reason is coherence with the rest of the identity. 85% of macOS
# identities draw "Apple M1, or similar" as the WebGL renderer, and no Apple
# Silicon part has ever had 2 cores; the lowest is 8. A page reading
# navigator.hardwareConcurrency and UNMASKED_RENDERER_WEBGL together -- two
# property reads -- would see a machine that does not exist.
#
# The recorded corpus does contain 2 (11/67 macOS presets, 17/180 Windows,
# 6/65 Linux), and that is not a reason to ship it: those rows report 2 more
# often than 4 on macOS, which no real hardware population does. The corpus is
# scraped from live traffic, so it carries other people's privacy-hardened
# browsers, 2-vCPU VMs and bots. "The corpus says so" settles what real MACHINES
# report only where the field is hardware; this one is a number the browser can
# be made to say.
#
# A host outside this table would hand its own oddity to the fingerprint: a
# 64-thread build box reports 32, anything under 4 threads reports 4. Odd
# counts (5, 7, 9, 11, 13, 15) never appear in the corpus -- they are
# browserforge Bayesian synthesis -- so they keep getting snapped down.
PLAUSIBLE_CORE_COUNTS = (4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28, 32)
def fix_hardware_concurrency(config: Dict[str, Any], can_pin: Optional[bool] = None) -> None:
"""navigator.hardwareConcurrency = the host's parallelism, snapped DOWN
into PLAUSIBLE_CORE_COUNTS.
A drawn value that differs from the machine the browser runs on is
measurable from a page: timing N parallel workers reveals how many cores
are really usable, and both "more usable than reported" and "fewer usable
than reported" are flagged by WebCPU-style checks (sundial "CPU: reported
vs measured cores"; daijro/camoufox#442 for the drift across launches).
So the drawn value is discarded, not clamped: min(drawn, host) still lets
a draw of 2 be measured as 16.
Stock Firefox 152 reports the true count in a normal window (capped by
dom.maxHardwareConcurrency = 128; measured 2026-09-14: 16-thread Linux and
Windows hosts -> 16, a 10-core Mac mini -> 10). Its 8/4 tiering
(RFPTarget NavigatorHWConcurrencyTiered: >= 8 -> 8, else 4) applies only
with fingerprinting protection on, i.e. private windows and ETP strict,
and resistFingerprinting hardcodes 4 (8 on macOS) -- neither is a normal
window's behaviour, so nothing is rounded here beyond the table snap.
The two tails (host > 20 or < 4) are the residual where reported and
measurable can disagree; closing them needs CPU affinity pinning, not a
launcher value. A caller that sets navigator.hardwareConcurrency
themselves is left alone (see the _user_set_navigator guard).
"""
n = host_cpu_count()
if not n:
return
# The fingerprint's own value survives when the browser can be pinned to
# that many cores (cpu_affinity: Linux, Windows): reported and measurable
# then agree by construction, and the identity keeps its diversity. A draw
# the host cannot honour (more cores than it has), a host that cannot pin
# (macOS), or pin_cpu_cores left off -- the default since 2026-09-17 --
# falls back to the snapped host count, which is equally coherent.
from .cpu_affinity import supported as _can_pin
# can_pin=False: the caller launches the browser itself and nothing will
# pin it (launch_server, launch_options used directly), so a kept draw
# would be measured as the host count. None: whatever the host supports.
pinnable = _can_pin() and can_pin is not False
cap = int(n)
host_allowed = [c for c in PLAUSIBLE_CORE_COUNTS if c <= cap]
host_value = host_allowed[-1] if host_allowed else PLAUSIBLE_CORE_COUNTS[0]
drawn = config.get('navigator.hardwareConcurrency')
if pinnable and isinstance(drawn, int) and drawn >= 1:
# The fingerprint's value is kept for diversity, but it still has to be
# a count a real desktop ships with. Accepting any 1..host let
# browserforge's synthetic odd draws through (5, 7, 9, 11, 13, 15 --
# counts the corpus never records). Snap the draw DOWN into the table
# instead, capped by the host so pinning can honour it.
target = min(drawn, cap)
allowed = [c for c in PLAUSIBLE_CORE_COUNTS if c <= target]
# The floor is the table's even on a 1-3 core host: reporting the host's
# own 1, 2 or 3 would contradict the drawn GPU (see the table above).
config['navigator.hardwareConcurrency'] = (
allowed[-1] if allowed else PLAUSIBLE_CORE_COUNTS[0]
)
return
config['navigator.hardwareConcurrency'] = host_value
def fix_navigator_arch(config: Dict[str, Any], target_os: str) -> None:
"""Force navigator.platform AND navigator.oscpu to match the UA's arch.
~8% of Linux Firefox fingerprints in the BrowserForge pool report
"Linux armv81" for platform/oscpu while the UA says "Linux x86_64". That
arch mismatch is itself a CreepJS lie signal (CreepJS cross-checks oscpu,
platform, and the UA arch). Mac/Windows pools are consistent and need no
correction.
"""
if target_os != 'lin':
return
ua = config.get('navigator.userAgent')
if not ua:
return
target = ''
if 'Linux x86_64' in ua:
target = 'Linux x86_64'
elif 'Linux i686' in ua:
target = 'Linux i686'
if not target:
return
if config.get('navigator.platform') != target:
config['navigator.platform'] = target
if config.get('navigator.oscpu') != target:
config['navigator.oscpu'] = target
def fix_screen_no_taskbar(config: Dict[str, Any], target_os: str) -> None:
"""Ensure screen.availHeight < screen.height so CreepJS's noTaskbar flag
(screen.height == availHeight and screen.width == availWidth) doesn't flip.
Every desktop OS keeps some chrome visible (Mac menu bar ~25px, Win taskbar
~40px, Linux panel ~27px); the pool occasionally ships fingerprints with
identical screen/avail values which leak as a headless tell. Also clamp
window.outerHeight (and innerHeight) to the new avail so the window isn't
taller than the available area.
The trigger is the HEIGHT alone, not both axes. Requiring `aw == sw` too
missed the shape `availWidth < width, availHeight == height` -- a Windows
taskbar docked left or right. That is a real geometry, but a rare one, and
letting it through means claiming no vertical chrome at all: no menu bar on
a Mac, no bottom taskbar on Windows, no panel on Linux. Those defaults are
overwhelmingly more common than a side dock, so the vertical delta is worth
more than the handful of genuine side-docked machines it overwrites. fpgen's
2026 model surfaced this: conditioned on a small display it produced that
shape in ~30% of draws, where the unconditioned rate is under 1%.
"""
sw = config.get('screen.width')
sh = config.get('screen.height')
ah = config.get('screen.availHeight')
if not (sw and sh and ah == sh):
return
taskbar = 40 if target_os == 'win' else 25 if target_os == 'mac' else 27
new_avail = sh - taskbar
config['screen.availHeight'] = new_avail
oh = config.get('window.outerHeight')
if oh and oh > new_avail:
ih = config.get('window.innerHeight')
chrome = oh - ih if ih else 0
config['window.outerHeight'] = new_avail
if ih:
config['window.innerHeight'] = new_avail - chrome
def clamp_window_dimensions(config: Dict[str, Any]) -> None:
"""Enforce inner <= outer <= avail <= screen on BOTH axes.
The browser faithfully reports whatever we inject, so a BrowserForge
fingerprint that ships e.g. outerWidth > screen.width or innerWidth >
outerWidth leaks as an impossible geometry. Shrink each level down to its
container, preserving the chrome delta between outer and inner where
possible. Complements fix_screen_no_taskbar (which only clamps height).
"""
for axis in ('Width', 'Height'):
screen = config.get(f'screen.{axis.lower()}')
avail = config.get(f'screen.avail{axis}')
outer = config.get(f'window.outer{axis}')
inner = config.get(f'window.inner{axis}')
# avail must not exceed screen
if screen and avail and avail > screen:
config[f'screen.avail{axis}'] = screen
avail_clamped = config.get(f'screen.avail{axis}', screen)
# outer must not exceed avail (or screen if avail is unknown)
outer_cap = avail_clamped if avail_clamped is not None else screen
if outer and outer_cap and outer > outer_cap:
chrome = max(0, outer - inner) if inner else 0
config[f'window.outer{axis}'] = outer_cap
if inner:
config[f'window.inner{axis}'] = max(1, outer_cap - chrome)
# inner must not exceed outer
outer_clamped = config.get(f'window.outer{axis}', outer)
inner_now = config.get(f'window.inner{axis}')
if inner_now and outer_clamped and inner_now > outer_clamped:
config[f'window.inner{axis}'] = outer_clamped
def clamp_screen_to_display(
config: Dict[str, Any],
max_width: Optional[int],
max_height: Optional[int],
) -> None:
"""Shrink screen.width/height down to the bounds of the real display.
BrowserForge takes a Screen constraint but drops it silently whenever it
filters the fingerprint pool too far: FingerprintGenerator.partial_csp
swallows the resulting failure and deletes the constraint unless strict=True.
So the bound from get_screen_cons() is best-effort only, and a 1366x768
laptop routinely gets a 2560x1440 fingerprint. browser-init.patch resizes the
real chrome window to window.outerWidth/outerHeight, so an unbounded value
renders past the edge of the monitor (daijro/camoufox#499).
Keeps the taskbar delta (screen - avail) intact so fix_screen_no_taskbar's
invariant survives. Callers must run clamp_window_dimensions afterwards to
cascade the new bounds down to avail/outer/inner.
"""
for axis, cap in (('width', max_width), ('height', max_height)):
screen = config.get(f'screen.{axis}')
if not (screen and cap) or screen <= cap:
continue
avail_key = 'screen.availWidth' if axis == 'width' else 'screen.availHeight'
avail = config.get(avail_key)
config[f'screen.{axis}'] = cap
if avail:
config[avail_key] = max(1, cap - max(0, screen - avail))
def clamp_window_position(config: Dict[str, Any]) -> None:
"""Keep the window box inside the screen: 0 <= screenX/Y <= screen - outer.
BrowserForge's screenX/screenY are consistent with the screen it generated
them against, so clamp_screen_to_display invalidates them. A window
positioned partly off its own reported screen is an impossible geometry.
"""
for axis, pos_key in (('Width', 'window.screenX'), ('Height', 'window.screenY')):
screen = config.get(f'screen.{axis.lower()}')
outer = config.get(f'window.outer{axis}')
pos = config.get(pos_key)
if pos is None or not (screen and outer):
continue
config[pos_key] = max(0, min(pos, screen - outer))
def set_media_devices_defaults(config: Dict[str, Any], salt: int = 0) -> None:
"""Give the identity a plausible set of media devices.
The patched media backend (media-device-spoofing.patch) enumerates and
captures exactly the devices described by mediaDevices:{enabled, micros,
webcams, speakers} and the aligned mediaDevices:{microphone,webcam,
speaker}{Labels,Groups} lists, and Firefox's own pre-/post-grant exposure
rules apply to them. Nothing is drawn when the caller already set any
mediaDevices: key.
"""
if any(k.startswith('mediaDevices:') for k in config):
return
# Before any getUserMedia grant Firefox exposes at most ONE device per
# input kind and no audiooutput; after a grant it lists every device with
# the OS's own labels ("Microphone Array (Realtek(R) Audio)", "MacBook Pro
# Microphone", "Built-in Audio Analog Stereo"...). Draw a whole machine's
# worth from the common desktop population for the claimed OS
# (media-devices.json), seeded by the identity so the same identity
# always reports the same devices. The browser applies the stock
# pre-/post-grant exposure rules to this list.
plat = str(config.get('navigator.platform', ''))
if plat.startswith('Win'):
os_key = 'win'
elif plat.startswith('Mac'):
os_key = 'mac'
else:
os_key = 'lin'
config.update(draw_media_devices(os_key, identity_seed(config, salt)))
_MEDIA_DEVICES_CACHE: Optional[Dict[str, Any]] = None
def _load_media_devices() -> Dict[str, Any]:
"""Per-OS catalogue of common sound cards / headsets / display audio /
cameras with their post-grant labels (media-devices.json)."""
global _MEDIA_DEVICES_CACHE
if _MEDIA_DEVICES_CACHE is None:
path = os.path.join(os.path.dirname(__file__), 'media-devices.json')
with open(path, 'rb') as f:
_MEDIA_DEVICES_CACHE = json.loads(f.read())
return _MEDIA_DEVICES_CACHE
def _weighted_choice(rng: Random, items: List[Dict[str, Any]]) -> Dict[str, Any]:
total = float(sum(item.get('w', 1) for item in items))
r = rng.random() * total
for item in items:
r -= item.get('w', 1)
if r < 0:
return item
return items[-1]
# Share of machines with no microphone at all (a desktop tower with only a
# line-out) and with a built-in camera, per OS. macOS is modelled by the
# machine line itself (Mac mini / Mac Studio have neither).
_MEDIA_P_NO_MIC = {'win': 0.08, 'mac': 0.0, 'lin': 0.20}
_MEDIA_P_BUILTIN_CAM = {'win': 0.78, 'mac': 0.0, 'lin': 0.45}
def draw_media_devices(os_key: str, seed: Optional[int]) -> Dict[str, Any]:
"""Draw one machine's media devices for `os_key` ('win'|'mac'|'lin').
Returns the mediaDevices:* config keys: counts plus aligned label and
group lists. Devices of one piece of hardware (a sound card's microphone
and speakers, a webcam and its microphone) share a group, as their
groupId does on a real machine; Linux additionally lists the PulseAudio
"Monitor of ..." source of every output as a microphone, as Firefox does.
"""
rng = _rng(seed)
cat = _load_media_devices().get(os_key) or _load_media_devices()['win']
mics: List[Tuple[str, str]] = []
outs: List[Tuple[str, str]] = []
cams: List[Tuple[str, str]] = []
counter = [0]
def group() -> str:
counter[0] += 1
return f'hw-{counter[0]}'
def add(item: Dict[str, Any], grp: str) -> None:
for m in item.get('mics', []):
mics.append((m, grp))
for o in item.get('outs', []):
outs.append((o, grp))
if item.get('cam'):
cams.append((item['cam'], group()))
# 1. the machine's own sound card (+ built-in camera on macOS models)
card = _weighted_choice(rng, cat['cards'])
no_mic = rng.random() < _MEDIA_P_NO_MIC.get(os_key, 0.0)
card_grp = group()
if no_mic:
add({**card, 'mics': []}, card_grp)
else:
add(card, card_grp)
# 2. a built-in laptop camera (Windows/Linux); rare on a mic-less tower
p_cam = _MEDIA_P_BUILTIN_CAM.get(os_key, 0.0)
if rng.random() < (p_cam * 0.3 if no_mic else p_cam):
builtin = [c for c in cat['cameras'] if not c.get('mic')]
if builtin:
cams.append((_weighted_choice(rng, builtin)['cam'], group()))
# 3. a headset / USB microphone
if rng.random() < cat.get('p_headset', 0.0):
add(_weighted_choice(rng, cat['headsets']), group())
# 4. display audio (HDMI/DP) -- occasionally a display with mic + camera
if rng.random() < cat.get('p_display', 0.0):
add(_weighted_choice(rng, cat['displays']), group())
# 5. an external webcam, usually with its own microphone
if rng.random() < cat.get('p_extra_camera', 0.0):
external = [c for c in cat['cameras'] if c.get('mic')] or cat['cameras']
cam = _weighted_choice(rng, external)
grp = group()
cams.append((cam['cam'], grp))
if cam.get('mic'):
mics.append((cam['mic'], grp))
# 6. PulseAudio exposes a monitor source per output as a capture device
if cat.get('monitor_sources'):
for label, grp in list(outs):
mics.append((f'Monitor of {label}', grp))
return {
'mediaDevices:enabled': True,
'mediaDevices:micros': len(mics),
'mediaDevices:webcams': len(cams),
'mediaDevices:speakers': len(outs),
'mediaDevices:microphoneLabels': [m for m, _ in mics],
'mediaDevices:microphoneGroups': [g for _, g in mics],
'mediaDevices:webcamLabels': [c for c, _ in cams],
'mediaDevices:webcamGroups': [g for _, g in cams],
'mediaDevices:speakerLabels': [o for o, _ in outs],
'mediaDevices:speakerGroups': [g for _, g in outs],
}
# -- WebGL <-> screen coherence (#729) ---------------------------------------
#
# BrowserForge picks navigator/screen; the GPU is drawn separately from
# webgl_data.db weighted only by OS. Nothing ties the two together, so the
# synthetic path can emit pairs no real machine ships -- a discrete GPU behind
# a 1024x600 netbook panel. Consistency checks (Pixelscan, Fingerprint.com)
# read that as masking even when every individual value is plausible alone.
#
# What can honestly be claimed here is narrow, because Firefox never reports
# the GPU it actually sees. dom/canvas/SanitizeRenderer.cpp collapses every
# renderer string into one of ~11 representative device buckets before a page
# sees it (prefs webgl.sanitize-unmasked-renderer and
# webgl.enable-renderer-query, both default true; resistFingerprinting
# replaces the value with "Mozilla" outright). That file's own header comment
# gives the flavour: `"GeForce RTX 3090" => "GeForce GTX 980"`. So every RTX,
# every Quadro M/P/V/T and every GeForce 900-7999 arrive as one string, while
# "Intel(R) UHD Graphics 620" and "Mesa Intel(R) Iris(R) Xe Graphics" both
# arrive as an "Intel(R) HD Graphics" spelling.
#
# Two consequences. Matching on raw model names -- RTX, Quadro, RX, UHD, Iris,
# Mesa Intel -- can never fire, because those are exactly the strings Gecko
# collapses away. And a bucket spanning a desktop RTX 4090 and a mobile GTX
# 1650 Max-Q carries no useful screen floor: 1366x768 laptops with discrete
# NVIDIA GPUs are ordinary hardware, not a tell.
#
# So the rule below holds only what is true of *every* part behind a bucket,
# and renderers are reduced to their bucket first (see _renderer_bucket) so
# the ANGLE, nouveau and /PCIe/SSE2 spellings of one GPU land on one rule
# instead of three different ones.
# Software rasterizers. A VM or headless host reports whatever resolution the
# window manager hands it, so no screen constrains them -- and the sampler
# must never come to *prefer* them, because a software renderer is a far
# stronger "this is a bot" signal than any GPU/screen mismatch.
_SOFTWARE_RENDERERS: Tuple[str, ...] = (
'llvmpipe',
'Microsoft Basic Render Driver',
'SwiftShader',
'Generic Renderer',
)
# Discrete NVIDIA, plus the AMD R5/R7/R9/RX/Vega bucket. Everything else in
# webgl_data.db reaches down into netbook territory and gets no floor at all:
# the "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook chipset,
# "Radeon HD 3200 Graphics" is Gecko's catch-all for a bare "AMD"/"Radeon"
# (the C-50/E-350 netbook APUs included), and Apple silicon drives arbitrary
# external monitors from a Mac mini or Mac Studio.
_DISCRETE_GPU_BUCKETS: FrozenSet[str] = frozenset(
{
'GeForce 8800 GTX',
'GeForce GTX 480',
'GeForce GTX 980',
'Radeon R9 200 Series',
}
)
# Discrete GPUs did not ship in netbooks, and netbook panels topped out at
# 1024x600. That is the whole of the claim.
#
# It is an area rather than a width x height pair because real panels do not
# dominate one another: 1280x800 and 1366x768 are both ordinary laptop
# screens, and a per-axis floor taken from either one rejects the other. A
# 1366x768 laptop with a discrete GPU is common hardware, not a tell.
_NETBOOK_MAX_PIXELS = 1024 * 600
# The three shapes SanitizeRenderer wraps a device bucket in.
_ANGLE_D3D_RE = re.compile(r'^ANGLE \([^,]*, (.*?) Direct3D.*\)$')
_ANGLE_VULKAN_RE = re.compile(r'^ANGLE \((.*)\) on Vulkan$')
_PCIE_SSE2_RE = re.compile(r'^(.*)/PCIe?/SSE2$')
def _renderer_bucket(renderer: str) -> str:
"""Reduce a reported renderer to Gecko's sanitized device bucket.
"ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or
similar" (Windows), "NVIDIA GeForce GTX 980/PCIe/SSE2" (Linux proprietary
driver) and "GeForce GTX 980, or similar" (nouveau, which loses the vendor
prefix) are one GPU class in three spellings. Without this they land on
three different rules, or none.
"""
core = renderer.removesuffix(', or similar')
match = _ANGLE_D3D_RE.match(core) or _ANGLE_VULKAN_RE.match(core)
if match:
core = match.group(1)
match = _PCIE_SSE2_RE.match(core)
if match:
core = match.group(1)
# SanitizeRenderer re-adds the "NVIDIA " prefix only when the raw string
# carried it, so one bucket arrives both with and without it.
return core.removeprefix('NVIDIA ')
# The smallest screen mainstream hardware still ships. BrowserForge's pool
# carries netbook-era geometry that essentially no 2026 device reports, and
# that is a tell on its own, whatever GPU sits behind it.
MODERN_SCREEN_FLOOR: Tuple[int, int] = (1366, 768)
def raise_screen_to_modern_floor(config: Dict[str, Any]) -> None:
"""Lift netbook-era screen geometry to something current hardware reports.
BrowserForge still draws 1024x600 and friends. Those panels left
production a decade and a half ago, so the screen is what has to move --
no GPU choice makes that profile look current.
Keeps the screen-to-avail gap intact so fix_screen_no_taskbar's invariant
survives; the window box is reconciled by clamp_window_dimensions and
clamp_window_position, which run after this. Call BEFORE
clamp_screen_to_display so a genuinely small real monitor still wins.
"""
min_w, min_h = MODERN_SCREEN_FLOOR
sw = config.get('screen.width')
sh = config.get('screen.height')
if not (sw and sh) or (sw >= min_w and sh >= min_h):
return
# Measure the gaps before mutating, or they get folded into themselves.
aw = config.get('screen.availWidth')
ah = config.get('screen.availHeight')
gap_w = sw - aw if aw else None
gap_h = sh - ah if ah else None
new_w, new_h = max(sw, min_w), max(sh, min_h)
config['screen.width'] = new_w
config['screen.height'] = new_h
if gap_w is not None:
config['screen.availWidth'] = max(1, new_w - max(0, gap_w))
if gap_h is not None:
config['screen.availHeight'] = max(1, new_h - max(0, gap_h))
def is_software_renderer(renderer: Optional[str]) -> bool:
"""Whether `renderer` is a software rasterizer rather than real hardware."""
return bool(renderer) and any(name in renderer for name in _SOFTWARE_RENDERERS)
def gpu_screen_is_plausible(
renderer: Optional[str], width: Optional[int], height: Optional[int]
) -> bool:
"""Whether `renderer` is a GPU that plausibly drives a `width` x `height` screen.
Unconstrained buckets and software rasterizers pass. The set only names
buckets whose floor holds for every part behind them, so anything absent
from it is genuinely unconstrained rather than merely unrecognised.
"""
if not renderer or not width or not height:
return True
if is_software_renderer(renderer):
return True
if _renderer_bucket(renderer) not in _DISCRETE_GPU_BUCKETS:
return True
return width * height > _NETBOOK_MAX_PIXELS
def sample_webgl_for_screen(
target_os: str,
width: Optional[int] = None,
height: Optional[int] = None,
attempts: int = 32,
seed: Optional[int] = None,
) -> Dict[str, str]:
"""Sample a WebGL profile that is coherent with the screen already chosen.
Rejection sampling, so the GPU keeps webgl_data.db's real OS-weighted
distribution -- we only drop draws that contradict the screen. The screen
itself is left alone on purpose: it has already been reconciled with the
real display and the window box (clamp_screen_to_display,
fix_screen_no_taskbar, clamp_window_dimensions, clamp_window_position),
and widening it here to flatter the GPU would push a headful window back
off the monitor it is drawn on (#499).
Software rasterisers are the one exception to keeping the pool's rate: a
draw that lands on llvmpipe / WARP / SwiftShader is resampled, so they
never present as the GPU (see below). That does cost fidelity -- the corpus
records them at ~1.5%, because real users do run without working drivers --
but "no consumer machine reports llvmpipe" is a live, standard check on a
string every fingerprint script already reads, so the trade is worth it.
Reviewed against the JS-detectability bar on 2026-09-17 and kept.
Falls back to that first draw when the pool holds nothing coherent, so an
unusual screen degrades to today's behaviour rather than raising.
"""
# A software rasteriser (llvmpipe / SwiftShader / WARP) as the presented
# GPU is what every consumer-hardware check flags first ("no consumer
# machine reports llvmpipe" -- sundial, measured 2026-09-14), so the draw
# never settles on one: keep drawing until a hardware renderer that fits
# the screen comes up, and only fall back to the first draw if the pool
# holds nothing better.
first = sample_webgl(target_os, seed=seed)
renderer = first.get('webGl:renderer')
if not is_software_renderer(renderer) and gpu_screen_is_plausible(renderer, width, height):
return first
fallback = None if is_software_renderer(renderer) else first
for attempt in range(attempts - 1):
candidate = sample_webgl(target_os, seed=None if seed is None else seed + 1 + attempt)
renderer = candidate.get('webGl:renderer')
if is_software_renderer(renderer):
continue
if gpu_screen_is_plausible(renderer, width, height):
return candidate
fallback = fallback or candidate
return fallback or first
def _select_presets_file(ff_version: Optional[Any] = None) -> Path:
"""Pick the bundled-presets file appropriate for a given Firefox version.
For Firefox >= PRESETS_V150_MIN_FF, prefer the v150 bundle (real
fingerprints scraped from contemporary browsers); otherwise fall back to
the original bundle.
"""
try:
major = int(str(ff_version).split('.', 1)[0]) if ff_version else 0
except (ValueError, TypeError):
major = 0
if major >= PRESETS_V150_MIN_FF and PRESETS_V150_FILE.exists():
return PRESETS_V150_FILE
return PRESETS_FILE
def load_presets(ff_version: Optional[Any] = None) -> Optional[Dict]:
"""Load bundled fingerprint presets from JSON file."""
path = _select_presets_file(ff_version)
if path in _PRESETS_CACHE:
return _PRESETS_CACHE[path]
if not path.exists():
return None
with open(path) as f:
_PRESETS_CACHE[path] = json.load(f)
return _PRESETS_CACHE[path]
# Map OS names to preset keys
_OS_TO_PRESET_KEY = {
'windows': 'windows',
'macos': 'macos',
'linux': 'linux',
'win': 'windows',
'mac': 'macos',
'lin': 'linux',
}
def get_random_preset(
os: Optional[str] = None,
ff_version: Optional[Any] = None,
) -> Optional[Dict]:
"""
Get a random preset for the given OS.
Returns None if no presets are available.
"""
presets = load_presets(ff_version)
if not presets:
return None
all_os_keys = ['macos', 'windows', 'linux']
if os:
# Normalize OS name
if isinstance(os, (list, tuple)):
os_keys = [_OS_TO_PRESET_KEY.get(o, o) for o in os]
else:
os_keys = [_OS_TO_PRESET_KEY.get(os, os)]
else:
os_keys = all_os_keys
# Collect all matching presets
candidates: List[Dict] = []
for key in os_keys:
candidates.extend(presets.get('presets', {}).get(key, []))
if not candidates:
return None
return choice(candidates) # nosec
# Tokens that name the machine rather than the platform: Firefox leaves every one
# of them out of appVersion.
_APP_VERSION_DROPPED = ('Win64', 'x64', 'Mobile', 'Tablet')
def _app_version_from_user_agent(user_agent: str) -> Optional[str]:
"""The appVersion Firefox reports for a browser sending this user agent.
"5.0 (<OS tokens>)": the parenthesised part of the UA without the
architecture, the Gecko revision, or the Windows build number.
"""
block = re.match(r'Mozilla/5\.0 \(([^)]*)\)', user_agent or '')
if not block:
return None
kept = []
for token in (part.strip() for part in block.group(1).split(';')):
if (
token.startswith('rv:')
or token in _APP_VERSION_DROPPED
or token.startswith('Linux ')
or token.startswith('Intel Mac OS X')
):
continue
kept.append('Windows' if token.startswith('Windows') else token)
return f"5.0 ({'; '.join(kept)})" if kept else None
def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[int] = None) -> Dict[str, Any]:
"""
Convert a real fingerprint preset to CAMOU_CONFIG format.
`salt` (identity_salt) keys the font/voice draws; None draws a fresh one, so
two users of the same recorded device do not also share its font list.
"""
if salt is None:
salt = identity_salt()
config: Dict[str, Any] = {}
nav = preset.get('navigator', {})
if nav.get('userAgent'):
ua = nav['userAgent']
# Replace Firefox version in UA if ff_version is provided
if ff_version:
ua = re.sub(r'Firefox/\d+\.0', f'Firefox/{ff_version}.0', ua)
ua = re.sub(r'rv:\d+\.0', f'rv:{ff_version}.0', ua)
config['navigator.userAgent'] = ua
if nav.get('platform'):
config['navigator.platform'] = nav['platform']
if nav.get('hardwareConcurrency'):
config['navigator.hardwareConcurrency'] = nav['hardwareConcurrency']
if nav.get('oscpu'):
config['navigator.oscpu'] = nav['oscpu']
elif nav.get('platform'):
# Derive oscpu from platform when not explicitly in the preset
plat = nav['platform']
if plat == 'MacIntel':
config['navigator.oscpu'] = 'Intel Mac OS X 10.15'
elif plat == 'Win32':
config['navigator.oscpu'] = 'Windows NT 10.0; Win64; x64'
elif 'Linux' in plat or 'linux' in plat:
config['navigator.oscpu'] = 'Linux x86_64'
if nav.get('appVersion'):
config['navigator.appVersion'] = nav['appVersion']
elif config.get('navigator.userAgent'):
# Left unset, appVersion falls through to the *host's* value and then
# contradicts the userAgent and platform set above: a Linux preset on a
# macOS host reported "5.0 (Macintosh)" beside platform "Linux x86_64",
# which any page can read in two properties.
#
# Firefox builds it from the same OS tokens as the userAgent, minus the
# architecture and rv, with Windows collapsed to its family name. Deriving
# it from the UA rather than from the platform keeps the distro token that
# 20 of the bundled Linux presets carry ("X11; Ubuntu"), which a platform
# lookup would flatten to "X11" — a mismatch of the same kind, if a
# smaller one. Checked against 800 browserforge fingerprints: exact every
# time.
derived = _app_version_from_user_agent(config['navigator.userAgent'])
if derived:
config['navigator.appVersion'] = derived
if 'maxTouchPoints' in nav:
config['navigator.maxTouchPoints'] = nav['maxTouchPoints']
screen = preset.get('screen', {})
if screen.get('width'):
config['screen.width'] = screen['width']
if screen.get('height'):
config['screen.height'] = screen['height']
if screen.get('colorDepth'):
config['screen.colorDepth'] = screen['colorDepth']
config['screen.pixelDepth'] = screen['colorDepth']
if screen.get('availWidth'):
config['screen.availWidth'] = screen['availWidth']
if screen.get('availHeight'):
config['screen.availHeight'] = screen['availHeight']
webgl = preset.get('webgl', {})
if webgl.get('unmaskedVendor'):
config['webGl:vendor'] = webgl['unmaskedVendor']
if webgl.get('unmaskedRenderer'):
config['webGl:renderer'] = webgl['unmaskedRenderer']
# Generate a unique audio seed per launch (1 to 2^32-1, excluding 0 which is a no-op in C++)
config['audio:seed'] = randint(1, 4_294_967_295) # nosec
if preset.get('timezone'):
config['timezone'] = preset['timezone']
# Generate a unique random font subset from the OS font list.
plat = nav.get('platform', '')
if plat == 'MacIntel':
target_os = 'macos'
elif plat == 'Win32':
target_os = 'windows'
elif 'Linux' in plat or 'linux' in plat:
target_os = 'linux'
else:
target_os = 'macos'
try:
config['fonts'] = _generate_random_font_subset(target_os, seed=identity_seed(config, salt))
except Exception:
# Fallback to preset fonts if font generation fails
if preset.get('fonts'):
fonts = list(preset['fonts'])
_ensure_marker_fonts(fonts, {
'macos': _MACOS_MARKER_FONTS,
'windows': _WINDOWS_MARKER_FONTS,
'linux': _LINUX_MARKER_FONTS,
}.get(target_os, _MACOS_MARKER_FONTS))
config['fonts'] = fonts
# Generate a unique random voice subset from the OS voice list
try:
config['voices'] = _generate_random_voice_subset(target_os, seed=identity_seed(config, salt))
except Exception:
if preset.get('speechVoices'):
config['voices'] = _normalize_preset_voices(
preset['speechVoices'], target_os
)
return config
def _build_init_script(values: Dict[str, Any]) -> str:
"""
Builds the JavaScript init script that calls per-context window.setXxx() functions.
These functions self-destruct after first call, so they must run via addInitScript.
"""
import json as _json
lines = ['(function(v) {', ' var w = window;']
setters = [
('audioFingerprintSeed', 'setAudioFingerprintSeed', '{val}'),
('navigatorPlatform', 'setNavigatorPlatform', '{val}'),
('navigatorOscpu', 'setNavigatorOscpu', '{val}'),
('navigatorUserAgent', 'setNavigatorUserAgent', '{val}'),
('hardwareConcurrency', 'setNavigatorHardwareConcurrency', '{val}'),
('webglVendor', 'setWebGLVendor', '{val}'),
('webglRenderer', 'setWebGLRenderer', '{val}'),
]
for key, fn_name, _template in setters:
val = values.get(key)
if val is not None:
js_val = _json.dumps(val)
lines.append(
f' if (typeof w.{fn_name} === "function") w.{fn_name}({js_val});'
)
# Screen dimensions (requires width + height together)
sw = values.get('screenWidth')
sh = values.get('screenHeight')
if sw and sh:
lines.append(
f' if (typeof w.setScreenDimensions === "function") w.setScreenDimensions({sw}, {sh});'
)
scd = values.get('screenColorDepth')
if scd:
lines.append(
f' if (typeof w.setScreenColorDepth === "function") w.setScreenColorDepth({scd});'
)
# Timezone — only call setTimezone() when we have an explicit value.
# Without this, the C++ MaskConfig fallback (from CAMOU_CONFIG set by geoip
# in launch_options) handles timezone for both main thread and workers via
# SetNewDocument() and TimezoneManager::GetTimezone().
# The old fallback read system TZ and poisoned RoverfoxStorageManager,
# preventing MaskConfig from ever being consulted.
tz = values.get('timezone')
if tz:
lines.append(
f' if (typeof w.setTimezone === "function") w.setTimezone({_json.dumps(tz)});'
)
# WebRTC IP
ip = values.get('webrtcIP')
if ip:
lines.append(
f' if (typeof w.setWebRTCIPv4 === "function") w.setWebRTCIPv4({_json.dumps(ip)});'
)
else:
lines.append(
' if (typeof w.setWebRTCIPv4 === "function") w.setWebRTCIPv4("");'
)
# Font list (comma-separated)
font_list = values.get('fontList')
if font_list and len(font_list) > 0:
joined = ','.join(font_list)
lines.append(
f' if (typeof w.setFontList === "function") w.setFontList({_json.dumps(joined)});'
)
# Speech voices (comma-separated names). config['voices'] holds MaskConfig
# voice objects; extract the display name from each (tolerating a legacy
# list of plain name strings).
voices = values.get('speechVoices')
if voices and len(voices) > 0:
names = [v['name'] if isinstance(v, dict) else v for v in voices]
joined = ','.join(names)
lines.append(
f' if (typeof w.setSpeechVoices === "function") w.setSpeechVoices({_json.dumps(joined)});'
)
lines.append('})();')
return '\n'.join(lines)
def generate_context_fingerprint(
preset: Optional[Dict] = None,
os: Optional[str] = None,
ff_version: Optional[str] = None,
webrtc_ip: Optional[str] = None,
timezone: Optional[str] = None,
locale: Optional[str] = None,
config_overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Generate fingerprint values for a single per-context identity.
Returns a dict with init_script (JS string) and context_options (Playwright options).
By default, fpgen generates a unique synthetic fingerprint.
Pass a preset dict to use a real fingerprint preset instead.
Parameters:
timezone: IANA timezone string (e.g. 'Europe/London'). When provided,
injected into config before init_script generation. Takes priority
over any timezone from the preset.
locale: BCP-47 locale string (e.g. 'en-GB'). When provided, parsed via
normalize_locale() and injected into config. Also sets
context_options['locale'] for Playwright.
config_overrides: Dict of CAMOU_CONFIG keys to override after config
is built but before init_script is rendered (e.g. {'audio:seed': 7}).
"""
if preset is not None:
# Use real fingerprint preset
config = from_preset(preset, ff_version)
nav = preset.get('navigator', {})
screen = preset.get('screen', {})
webgl = preset.get('webgl', {})
else:
# Fall back to synthetic generation
fp = generate_fingerprint(os=os)
config = from_fpgen(fp, ff_version)
# A fresh identity: every seeded draw below gets its own salt.
_salt = identity_salt()
# Add seeds (the generator doesn't produce these)
config.setdefault('audio:seed', randint(1, 4_294_967_295)) # nosec
# Determine target OS from platform for font/voice generation
plat = config.get('navigator.platform', '')
os_name = 'macos'
if plat == 'Win32':
os_name = 'windows'
elif 'Linux' in plat or 'linux' in plat:
os_name = 'linux'
# Add fonts (fpgen.yml does not map these yet)
if 'fonts' not in config:
try:
config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config, _salt))
except Exception:
pass
# Add voices (fpgen.yml does not map these yet)
if 'voices' not in config:
try:
config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config, _salt))
except Exception:
pass
# Derive oscpu if the fingerprint didn't provide it
if 'navigator.oscpu' not in config:
plat = config.get('navigator.platform', '')
if plat == 'MacIntel':
config['navigator.oscpu'] = 'Intel Mac OS X 10.15'
elif plat == 'Win32':
config['navigator.oscpu'] = 'Windows NT 10.0; Win64; x64'
elif 'Linux' in plat or 'linux' in plat:
config['navigator.oscpu'] = 'Linux x86_64'
# Sample WebGL vendor/renderer from database (fpgen.yml does not map these yet)
if not config.get('webGl:vendor') or not config.get('webGl:renderer'):
_os_map = {'macos': 'mac', 'linux': 'lin', 'windows': 'win'}
_target_os = _os_map.get(os or '', None)
if not _target_os:
plat = config.get('navigator.platform', '')
if plat == 'Win32':
_target_os = 'win'
elif 'Linux' in plat or 'linux' in plat:
_target_os = 'lin'
else:
_target_os = 'mac'
try:
# Same coherence treatment launch_options applies (#729): lift
# netbook geometry, then keep the GPU consistent with whatever
# screen this identity ended up with. This path has no real
# display to reconcile against, so the floor is unconditional.
raise_screen_to_modern_floor(config)
webgl_fp = sample_webgl_for_screen(
_target_os, config.get('screen.width'), config.get('screen.height')
)
webgl_fp.pop('webGl2Enabled', None)
config.update(webgl_fp)
except Exception:
pass
# Build source dicts from the fingerprint config for init_values
nav = {
'platform': config.get('navigator.platform'),
'hardwareConcurrency': config.get('navigator.hardwareConcurrency'),
}
screen = {
'width': config.get('screen.width'),
'height': config.get('screen.height'),
'colorDepth': config.get('screen.colorDepth'),
'devicePixelRatio': None,
}
webgl = {
'unmaskedVendor': config.get('webGl:vendor'),
'unmaskedRenderer': config.get('webGl:renderer'),
}
preset = {'navigator': nav, 'screen': screen, 'webgl': webgl}
# Inject explicit timezone/locale into config (takes priority over preset)
if timezone:
config['timezone'] = timezone
if locale:
from .locales import normalize_locale
parsed = normalize_locale(locale)
config['locale:language'] = parsed.language
config['locale:region'] = parsed.region
config['navigator.language'] = parsed.as_string
if parsed.script:
config['locale:script'] = parsed.script
# Apply caller overrides before rendering init_script
if config_overrides:
config.update(config_overrides)
# Build the values dict for the init script (works for both paths)
init_values: Dict[str, Any] = {
'audioFingerprintSeed': config.get('audio:seed'),
'navigatorPlatform': nav.get('platform'),
'navigatorOscpu': config.get('navigator.oscpu'),
'navigatorUserAgent': config.get('navigator.userAgent'),
'hardwareConcurrency': nav.get('hardwareConcurrency') or config.get('navigator.hardwareConcurrency'),
'webglVendor': webgl.get('unmaskedVendor'),
'webglRenderer': webgl.get('unmaskedRenderer'),
'screenWidth': screen.get('width'),
'screenHeight': screen.get('height'),
'screenColorDepth': screen.get('colorDepth'),
'timezone': preset.get('timezone') if isinstance(preset.get('timezone'), str) else config.get('timezone'),
'fontList': config.get('fonts'),
'speechVoices': config.get('voices'),
'webrtcIP': webrtc_ip or '',
}
init_script = _build_init_script(init_values)
# Playwright context options that must be set at context creation
context_options: Dict[str, Any] = {}
ua = config.get('navigator.userAgent')
if ua:
context_options['user_agent'] = ua
sw = screen.get('width')
sh = screen.get('height')
if sw and sh:
context_options['viewport'] = {
'width': sw,
'height': max(sh - 28, 600),
}
dpr = screen.get('devicePixelRatio')
if dpr:
context_options['device_scale_factor'] = dpr
tz = config.get('timezone')
if not tz and isinstance(preset, dict):
tz = preset.get('timezone')
if tz:
context_options['timezone_id'] = tz
nav_lang = config.get('navigator.language')
if nav_lang:
context_options['locale'] = nav_lang
return {
'init_script': init_script,
'context_options': context_options,
'config': config,
'preset': preset,
}
def _cast_to_properties(
camoufox_data: Dict[str, Any],
cast_enum: Dict[str, Any],
bf_dict: Dict[str, Any],
ff_version: Optional[str] = None,
) -> None:
"""
Casts a generated fingerprint to Camoufox config properties.
"""
for key, data in bf_dict.items():
# Ignore non-truthy values
if not data:
continue
# Get the associated Camoufox property
type_key = cast_enum.get(key)
if not type_key:
continue
# If the value is a dictionary, recursively recall
if isinstance(data, dict):
_cast_to_properties(camoufox_data, type_key, data, ff_version)
continue
# fpgen carries header values as a list of the values seen for that
# header; a single one is the header itself, and the config wants the
# string. More than one is ambiguous, so take none of them.
if isinstance(data, list):
if len(data) == 1 and isinstance(data[0], str):
data = data[0]
else:
continue
# Fix values that are out of bounds
if type_key.startswith("screen.") and isinstance(data, int) and data < 0:
data = 0
# Replace the Firefox versions with ff_version
if ff_version and isinstance(data, str):
data = re.sub(r'(?<!\d)(1[0-9]{2})(\.0)(?!\d)', rf'{ff_version}\2', data)
camoufox_data[type_key] = data
def handle_screenXY(camoufox_data: Dict[str, Any], fingerprint: Dict[str, Any]) -> None:
"""
Helper method to set window.screenY based on the generated screenX value.
"""
# Skip if manually provided
if 'window.screenY' in camoufox_data:
return
screen = fingerprint.get('screen') or {}
window = fingerprint.get('window') or {}
# Default screenX to 0 if not provided
screenX = window.get('screenX')
if not screenX:
camoufox_data['window.screenX'] = 0
camoufox_data['window.screenY'] = 0
return
# If screenX is within [-50, 50], use the same value for screenY
if screenX in range(-50, 51):
camoufox_data['window.screenY'] = screenX
return
# The generator thinks the browser is windowed. Randomly generate a screenY.
screenY = (screen.get('availHeight') or 0) - (window.get('outerHeight') or 0)
if screenY == 0:
camoufox_data['window.screenY'] = 0
elif screenY > 0:
camoufox_data['window.screenY'] = randrange(0, screenY) # nosec
else:
camoufox_data['window.screenY'] = randrange(screenY, 0) # nosec
def from_fpgen(fingerprint: Dict[str, Any], ff_version: Optional[str] = None) -> Dict[str, Any]:
"""
Converts an fpgen fingerprint to a Camoufox config.
"""
camoufox_data: Dict[str, Any] = {}
_cast_to_properties(
camoufox_data,
cast_enum=FPGEN_DATA,
bf_dict=fingerprint,
ff_version=ff_version,
)
handle_screenXY(camoufox_data, fingerprint)
return camoufox_data
def handle_window_size(fp: Dict[str, Any], outer_width: int, outer_height: int) -> None:
"""
Helper method to set a custom outer window size, and center it in the screen
"""
screen = fp.setdefault('screen', {})
window = fp.setdefault('window', {})
# Center the window on the screen
window['screenX'] = (window.get('screenX') or 0) + (
(screen.get('width') or outer_width) - outer_width
) // 2
window['screenY'] = ((screen.get('height') or outer_height) - outer_height) // 2
# Update inner dimensions if set
if window.get('innerWidth'):
window['innerWidth'] = max(
outer_width - (window.get('outerWidth') or 0) + window['innerWidth'], 0
)
if window.get('innerHeight'):
window['innerHeight'] = max(
outer_height - (window.get('outerHeight') or 0) + window['innerHeight'], 0
)
# Set outer dimensions
window['outerWidth'] = outer_width
window['outerHeight'] = outer_height
def generate_fingerprint(
window: Optional[Tuple[int, int]] = None,
screen: Optional[Screen] = None,
os: Optional[Any] = None,
**conditions: Any,
) -> Dict[str, Any]:
"""
Generates a Firefox fingerprint with fpgen.
`screen` bounds the generated screen; `window` overrides the outer window
size afterwards. `os` is Camoufox's name for the platform ('linux', 'macos',
'windows', or several); anything else is passed to fpgen as a condition.
"""
if os:
names = [os] if isinstance(os, str) else list(os)
try:
resolved = [_FPGEN_OS[str(n).lower()] for n in names]
except KeyError as exc:
raise ValueError(f'Unknown OS for fingerprint generation: {exc.args[0]!r}') from None
# fpgen takes one value or a predicate, not a list of alternatives.
conditions['os'] = resolved[0] if len(resolved) == 1 else (lambda v: v in set(resolved))
screen_conditions = screen.as_conditions() if screen is not None else {}
try:
fingerprint = _generator().generate(browser='Firefox', **conditions, **screen_conditions)
except Exception as exc: # fpgen.exceptions.InvalidConstraints
if not screen_conditions or type(exc).__name__ != 'InvalidConstraints':
raise
# The screen bound is best-effort, as it was under BrowserForge, which
# dropped a constraint that filtered its pool empty instead of raising.
# It comes from the real display (get_screen_cons), and a display the
# pool has nothing to fit -- a 1x1 Xvfb, an unusually small panel --
# must not stop a fingerprint being generated. clamp_screen_to_display()
# still bounds the result afterwards.
fingerprint = _generator().generate(browser='Firefox', **conditions)
if window: # User-specified outer window size
handle_window_size(fingerprint, *window)
return fingerprint
if __name__ == "__main__":
from pprint import pprint
fp = generate_fingerprint()
pprint(from_fpgen(fp))