fix(pythonlib): per-identity salt for seeded draws; core pinning under concurrency

Found in review of the previous commits:

- identity_seed() hashed only the UA, platform, screen size and core count.
  Those take a handful of values per OS, so over 500 launches the seed took
  12-30 distinct values and every install drew its fonts, voices, GPU, media
  devices and canvas/audio noise seeds from that same short list. The seed now
  mixes in identity_salt(): derived from what the caller pinned the identity
  with (a Fingerprint, a preset dict, a config naming the UA) so relaunching
  that identity reproduces every draw, and random otherwise. A pinned preset
  now reproduces its noise seeds too; seeds the caller sets are kept.
- Concurrent AsyncNewBrowser launches on one driver interleaved pin/restore:
  one browser inherited the other's mask and the driver could stay pinned.
  pin -> launch -> restore is serialized per driver.
- Every pinned browser landed on cores 0..N-1; pins now take N adjacent cores
  from a random start.
- A pinnable host with 1-3 cores reported 1, 2 or 3 (2 is the
  resistFingerprinting value); the table floor of 4 applies as on other hosts.
- launch_options() callers that launch the browser themselves (launch_server,
  direct use) kept the drawn core count although nothing pins the browser;
  only Camoufox/AsyncCamoufox pass pin_cpu_cores=True now, everyone else
  reports the host's snapped count.
- PLAUSIBLE_CORE_COUNTS gains 18, 22, 28 and 32, all recorded in the -v150
  corpus.
- The Windows voice list was drawn before the locale was resolved, so an
  fr-FR identity got en-US voices; it is drawn after locale/geoip now.
- macOS "Alex" gets its com.apple.speech.synthesis.voice identifier.
- CAMOU_PREFS env chunks are ASCII-only JSON (Windows getenv goes through the
  ANSI code page).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-16 22:17:40 -06:00
co-authored by Claude Opus 5
parent 3f0f850bf3
commit 072ad02797
7 changed files with 358 additions and 115 deletions
+43 -16
View File
@@ -105,6 +105,7 @@ async def AsyncNewBrowser(
virtual_display = None
if not from_options:
kwargs.setdefault('pin_cpu_cores', True)
from_options = await asyncio.get_event_loop().run_in_executor(
None,
partial(launch_options, headless=headless, debug=debug, **kwargs),
@@ -122,25 +123,51 @@ async def AsyncNewBrowser(
pin_to = pinned_core_count(from_options)
pid = driver_pid(playwright) if pin_to else None
previous = cpu_affinity.pin(pid, pin_to) if pid else None
try:
# Persistent context
if persistent_context:
if no_viewport_default and not ('viewport' in from_options or 'no_viewport' in from_options):
from_options = {**from_options, 'no_viewport': True}
context = await playwright.firefox.launch_persistent_context(**from_options)
return await async_attach_vd(context, virtual_display)
# Browser
browser = await playwright.firefox.launch(**from_options)
if no_viewport_default:
attach_no_viewport_default(browser)
return await async_attach_vd(browser, virtual_display)
finally:
if pid:
if not pid:
return await _launch(playwright, from_options, persistent_context, no_viewport_default, virtual_display)
# The browser inherits the driver's mask at spawn, so two concurrent launches
# on one driver must not interleave pin/restore: the second pin would land on
# the first browser, and the first restore would leave the driver pinned.
async with _pin_lock(pid):
previous = cpu_affinity.pin(pid, pin_to)
try:
return await _launch(playwright, from_options, persistent_context, no_viewport_default, virtual_display)
finally:
cpu_affinity.restore(pid, previous)
_PIN_LOCKS: Dict[int, asyncio.Lock] = {}
def _pin_lock(pid: int) -> asyncio.Lock:
# One lock per driver: a driver belongs to one event loop.
lock = _PIN_LOCKS.get(pid)
if lock is None:
lock = _PIN_LOCKS[pid] = asyncio.Lock()
return lock
async def _launch(
playwright: Playwright,
from_options: Dict[str, Any],
persistent_context: bool,
no_viewport_default: bool,
virtual_display: Optional[VirtualDisplay],
) -> Union[Browser, BrowserContext]:
# Persistent context
if persistent_context:
if no_viewport_default and not ('viewport' in from_options or 'no_viewport' in from_options):
from_options = {**from_options, 'no_viewport': True}
context = await playwright.firefox.launch_persistent_context(**from_options)
return await async_attach_vd(context, virtual_display)
# Browser
browser = await playwright.firefox.launch(**from_options)
if no_viewport_default:
attach_no_viewport_default(browser)
return await async_attach_vd(browser, virtual_display)
def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
"""Builds a proxy URL string with embedded credentials."""
parsed = urlparse(proxy.get("server", ""))
+18 -4
View File
@@ -16,6 +16,7 @@ the host's (snapped) count.
import os
import platform
import random
from typing import Iterable, List, Optional, Sequence
@@ -42,9 +43,22 @@ def host_cores() -> Optional[List[int]]:
return list(range(n)) if n else None
def _pick(cores: Sequence[int], count: int) -> List[int]:
"""`count` adjacent cores from a random starting point (wrapping). Always
taking the first `count` stacked every browser on one host onto cores
0..count-1, so concurrent browsers measured far less parallelism than they
report; adjacent cores keep the SMT topology a real machine of that size
would have."""
start = random.randrange(len(cores))
return sorted((list(cores[start:]) + list(cores[:start]))[:count])
def pin(pid: int, count: int) -> Optional[Sequence[int]]:
"""Restrict `pid` to its first `count` cores. Returns the previous set so
it can be handed back to `restore()`, or None if nothing was changed."""
"""Restrict `pid` to `count` of its cores. Returns the previous set so it
can be handed back to `restore()`, or None if nothing was changed.
The caller must not pin the same process for two launches at once: the
browser inherits whatever mask the driver has when it is spawned."""
if count < 1 or not supported():
return None
system = platform.system()
@@ -53,7 +67,7 @@ def pin(pid: int, count: int) -> Optional[Sequence[int]]:
before = sorted(os.sched_getaffinity(pid)) # type: ignore[attr-defined]
if count >= len(before):
return None
os.sched_setaffinity(pid, set(before[:count])) # type: ignore[attr-defined]
os.sched_setaffinity(pid, set(_pick(before, count))) # type: ignore[attr-defined]
return before
except OSError:
return None
@@ -64,7 +78,7 @@ def pin(pid: int, count: int) -> Optional[Sequence[int]]:
before = _mask_to_cores(before_mask)
if count >= len(before):
return None
return before if _win_set_mask(pid, _cores_to_mask(before[:count])) else None
return before if _win_set_mask(pid, _cores_to_mask(_pick(before, count))) else None
return None
+71 -52
View File
@@ -1,8 +1,10 @@
import hashlib
import json
import os
import re
import secrets
import unicodedata
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from random import Random, choice, randint, randrange, random, sample, shuffle
from typing import Any, Dict, FrozenSet, List, Optional, Tuple
@@ -333,16 +335,40 @@ WINDOWS_11_MARKER_FONTS = frozenset(_BASE_VARIANT_FONTS_WINDOWS[1])
def identity_seed(config: Dict[str, Any]) -> int:
"""A stable seed for the per-identity draws (fonts, voices).
def identity_salt(pinned: Any = None) -> int:
"""The entropy that makes identity_seed() belong to ONE identity.
Two launches that present the same identity (same UA, platform, screen,
cores, GPU) 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). Deriving the seed from the identity
itself makes the draw a pure function of the fingerprint, so `from_options`
replays and persistent contexts are stable without any new state.
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 = [
@@ -352,6 +378,7 @@ def identity_seed(config: Dict[str, Any]) -> int:
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
@@ -587,7 +614,9 @@ def _voice_uri(os_key: str, name: str, lang: str) -> str:
_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'}
'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
@@ -760,30 +789,6 @@ def _generate_random_voice_subset(
# (SAPI), macOS or Linux (measured 2026-09-14 on all three), so a spoofed
# default would be the odd one out.
return voices
if os_key == 'mac':
pref = next((i for i, v in enumerate(voices) if v['name'] in ('Samantha', 'Alex') and (not locale or v['lang'].lower() == locale.lower())), -1)
if pref >= 0:
voices[pref]['isDefault'] = True
return voices
# Mark a default voice matching the spoofed locale prefix so it lines up
# with Intl.DateTimeFormat().resolvedOptions().locale (CreepJS flags a
# voiceLangMismatch otherwise).
if voices:
prefix = locale.split('-')[0].lower() if locale else 'en'
idx = next(
(i for i, v in enumerate(voices) if locale and v['lang'].lower() == locale.lower()),
-1,
)
if idx < 0:
idx = next(
(i for i, v in enumerate(voices) if v['lang'].split('-')[0].lower() == prefix),
-1,
)
if idx < 0:
idx = 0
voices[idx]['isDefault'] = True
return voices
def _normalize_preset_voices(
@@ -837,11 +842,10 @@ def host_cpu_count() -> Optional[int]:
# 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, 20 and 24.
#
# 24 was missing from this table and is restored (2026-09-15): it is a real
# recorded value on Windows (2/75) and Linux (2/18), and excluding it snapped
# genuine 24-core machines down to 20 for no reason.
# -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 recorded too -- and is common, 6/30 macOS presets (20%) -- but is
# deliberately EXCLUDED (user, 2026-09-15): 2 is what Firefox reports under
@@ -849,13 +853,13 @@ def host_cpu_count() -> Optional[int]:
# RFP is not. So a draw of 2 snaps up to the table floor of 4.
#
# A host outside this table would hand its own oddity to the fingerprint: a
# 64-thread build box reports 24, anything under 4 threads reports 4. Odd
# 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, 20, 24)
PLAUSIBLE_CORE_COUNTS = (4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28, 32)
def fix_hardware_concurrency(config: Dict[str, Any]) -> None:
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.
@@ -889,12 +893,17 @@ def fix_hardware_concurrency(config: Dict[str, Any]) -> None:
# pin (macOS), falls back to the snapped host count.
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 _can_pin() and isinstance(drawn, int) and drawn >= 1:
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 low/odd draws through: over 400 linux draws, 8.0% were
@@ -906,8 +915,10 @@ def fix_hardware_concurrency(config: Dict[str, Any]) -> None:
# 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: min(4, cap)
# reported 1, 2 or 3 there, and 2 is the resistFingerprinting value.
config['navigator.hardwareConcurrency'] = (
allowed[-1] if allowed else min(PLAUSIBLE_CORE_COUNTS[0], cap)
allowed[-1] if allowed else PLAUSIBLE_CORE_COUNTS[0]
)
return
config['navigator.hardwareConcurrency'] = host_value
@@ -1049,7 +1060,7 @@ def clamp_window_position(config: Dict[str, Any]) -> None:
config[pos_key] = max(0, min(pos, screen - outer))
def set_media_devices_defaults(config: Dict[str, Any]) -> None:
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
@@ -1076,7 +1087,7 @@ def set_media_devices_defaults(config: Dict[str, Any]) -> None:
os_key = 'mac'
else:
os_key = 'lin'
config.update(draw_media_devices(os_key, identity_seed(config)))
config.update(draw_media_devices(os_key, identity_seed(config, salt)))
_MEDIA_DEVICES_CACHE: Optional[Dict[str, Any]] = None
@@ -1488,10 +1499,15 @@ def _app_version_from_user_agent(user_agent: str) -> Optional[str]:
return f"5.0 ({'; '.join(kept)})" if kept else None
def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any]:
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', {})
@@ -1578,7 +1594,7 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any
else:
target_os = 'macos'
try:
config['fonts'] = _generate_random_font_subset(target_os, seed=identity_seed(config))
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'):
@@ -1591,7 +1607,7 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None) -> Dict[str, Any
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))
config['voices'] = _generate_random_voice_subset(target_os, seed=identity_seed(config, salt))
except Exception:
if preset.get('speechVoices'):
config['voices'] = _normalize_preset_voices(
@@ -1727,6 +1743,9 @@ def generate_context_fingerprint(
fp = generate_fingerprint(os=os)
config = from_browserforge(fp, ff_version)
# A fresh identity: every seeded draw below gets its own salt.
_salt = identity_salt()
# Add seeds (BrowserForge doesn't generate these)
config.setdefault('fonts:spacing_seed', 0) # perturbation off; see utils.launch_options
config.setdefault('audio:seed', randint(1, 4_294_967_295)) # nosec
@@ -1743,14 +1762,14 @@ def generate_context_fingerprint(
# Add fonts (BrowserForge doesn't generate these)
if 'fonts' not in config:
try:
config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config))
config['fonts'] = _generate_random_font_subset(os_name, seed=identity_seed(config, _salt))
except Exception:
pass
# Add voices (BrowserForge doesn't generate these)
if 'voices' not in config:
try:
config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config))
config['voices'] = _generate_random_voice_subset(os_name, seed=identity_seed(config, _salt))
except Exception:
pass
+1
View File
@@ -107,6 +107,7 @@ def NewBrowser(
virtual_display = None
if not from_options:
kwargs.setdefault('pin_cpu_cores', True)
from_options = launch_options(headless=headless, debug=debug, **kwargs)
# Playwright's default viewport deadlocks Juggler when the window is spoofed
+76 -39
View File
@@ -1,3 +1,4 @@
import json
import os
import platform
import sys
@@ -22,7 +23,7 @@ from .exceptions import (
InvalidPropertyType,
NonFirefoxFingerprint,
)
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_seed, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults, WINDOWS_11_MARKER_FONTS
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_salt, identity_seed, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults, WINDOWS_11_MARKER_FONTS
from .geolocation import geoip_allowed, get_geolocation
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
from .locales import handle_locales
@@ -173,7 +174,10 @@ def get_pref_env_vars(prefs: Dict[str, Any]) -> Dict[str, str]:
"""
if not prefs:
return {}
data = orjson.dumps(prefs).decode('utf-8')
# ASCII only: on Windows autoconfig's getenv() reads the environment through
# the ANSI code page, which would mangle a raw UTF-8 pref value (\u escapes
# survive it and JSON.parse restores them).
data = json.dumps(prefs, ensure_ascii=True, separators=(',', ':'))
chunk_size = 2047 if OS_NAME == 'win' else 32767
return {
f"CAMOU_PREFS_{(i // chunk_size) + 1}": data[i : i + chunk_size]
@@ -715,6 +719,7 @@ def launch_options(
i_know_what_im_doing: Optional[bool] = None,
debug: Optional[bool] = None,
virtual_display: Optional[str] = None,
pin_cpu_cores: Optional[bool] = None,
**launch_options: Dict[str, Any],
) -> Dict[str, Any]:
"""
@@ -806,6 +811,11 @@ def launch_options(
Prints the config being sent to Camoufox.
virtual_display (Optional[str]):
Virtual display number. Ex: ':99'. This is handled by Camoufox & AsyncCamoufox.
pin_cpu_cores (Optional[bool]):
The browser will be pinned to navigator.hardwareConcurrency cores
(Linux/Windows), so the fingerprint's core count can be kept. Set by
Camoufox & AsyncCamoufox, which apply the pin; without it the host's
own (snapped) core count is reported, since nothing pins the browser.
webgl_config (Optional[Tuple[str, str]]):
Use a specific WebGL vendor/renderer pair. Passed as a tuple of (vendor, renderer).
**launch_options (Dict[str, Any]):
@@ -871,6 +881,20 @@ def launch_options(
_user_set_dnt = 'navigator.doNotTrack' in config
_user_set_gpc = 'navigator.globalPrivacyControl' in config
_user_set_accept_encoding = 'headers.Accept-Encoding' in config
_user_set_noise_seeds = {k for k in ('audio:seed', 'canvas:seed') if k in config}
# The salt that makes every seeded draw belong to this identity (see
# fingerprints.identity_salt): stable when the caller pinned the identity
# -- a Fingerprint, a preset dict, or their own config naming the UA --
# and fresh otherwise.
if fingerprint is not None:
_identity_salt = identity_salt(fingerprint)
elif isinstance(fingerprint_preset, dict):
_identity_salt = identity_salt(fingerprint_preset)
elif 'navigator.userAgent' in config:
_identity_salt = identity_salt(dict(config))
else:
_identity_salt = identity_salt()
# Assert the target OS is valid
if os:
@@ -908,7 +932,7 @@ def launch_options(
else:
preset = get_random_preset(os=os, ff_version=ff_version_str)
if preset:
merge_into(config, from_preset(preset, ff_version_str))
merge_into(config, from_preset(preset, ff_version_str, salt=_identity_salt))
_used_preset = True
# Bound the geometry to the real display. BrowserForge only honours this when
@@ -939,7 +963,7 @@ def launch_options(
# impossible-geometry tells, unless the user is driving these themselves.
if not _user_set_navigator:
fix_navigator_arch(config, target_os)
fix_hardware_concurrency(config)
fix_hardware_concurrency(config, can_pin=bool(pin_cpu_cores))
if not _user_set_screen_window:
# Lift netbook-era geometry to something current hardware reports,
# before the display clamp below so a genuinely small real monitor
@@ -993,7 +1017,7 @@ def launch_options(
try:
config['fonts'] = _generate_random_font_subset(
os_name,
seed=identity_seed(config),
seed=identity_seed(config, _identity_salt),
# host's own OS on macOS/Windows: the real system fonts are used
# (font-hijacker.patch keeps the bundle inactive), so only the
# OS base is claimed
@@ -1002,39 +1026,12 @@ def launch_options(
except Exception:
update_fonts(config, target_os)
# Spoof the speech-synthesis voice list.
#
# This has to fail CLOSED. Firefox registers the host's speech-dispatcher /
# SAPI / NSSpeech voices unless something stops it, and nsSynthVoiceRegistry
# only stops it when Camoufox owns the list. Leaving `voices` unset -- which
# the old `except Exception: pass` did on any generation failure -- exposed
# every native voice on the box (14805 espeak-ng entries on a stock Linux
# install) under a fingerprint claiming macOS or Windows: it both leaks the
# real host OS and contradicts the rest of the profile (#731).
if not _user_set_voices or 'voices' not in config:
os_name_v = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
try:
config['voices'] = _generate_random_voice_subset(
os_name_v, config.get('navigator.language'), seed=identity_seed(config)
)
except Exception:
# An empty list still blocks the host's voices (see below), so a
# generation failure degrades to "no voices" rather than "all of
# the host's".
config['voices'] = []
# Pin the block explicitly instead of relying on a non-empty list to imply
# it, so an empty list -- or one whose entries the browser rejects as
# malformed -- cannot fall through to the host's native voices. set_into
# leaves an explicit caller value alone.
set_into(config, 'voices:blockIfNotDefined', True)
# Draw the identity's media devices (counts + OS-style labels/groups from
# media-devices.json, seeded by the identity) unless the caller set any
# mediaDevices: key. An empty enumerateDevices() list is a headless tell;
# a wrong label after a grant is a spoof tell.
if not _user_set_media_devices:
set_media_devices_defaults(config)
set_media_devices_defaults(config, _identity_salt)
# Scrollbars: a stock Firefox on a GNOME/KDE desktop and on macOS draws
# overlay scrollbars (no layout gutter, scrollbar-width "auto"). On Windows it
@@ -1144,9 +1141,13 @@ def launch_options(
# audio/canvas noise seeds follow the identity: a returning "same device"
# must reproduce its audio and canvas hashes (#442/#765). Derived, not
# equal, so the two streams differ; never 0 (0 disables the noise).
_ident = identity_seed(config)
set_into(config, 'audio:seed', ((_ident * 2654435761 + 97) & 0xFFFFFFFF) or 1)
set_into(config, 'canvas:seed', ((_ident * 40503 + 12345) & 0xFFFFFFFF) or 1)
# A preset draws its own random seeds; they are replaced here too so a
# pinned preset reproduces them, but a seed the caller set is kept.
_ident = identity_seed(config, _identity_salt)
if 'audio:seed' not in _user_set_noise_seeds:
config['audio:seed'] = ((_ident * 2654435761 + 97) & 0xFFFFFFFF) or 1
if 'canvas:seed' not in _user_set_noise_seeds:
config['canvas:seed'] = ((_ident * 40503 + 12345) & 0xFFFFFFFF) or 1
# Set geolocation
if geoip:
@@ -1221,6 +1222,42 @@ def launch_options(
requested = 'en-US'
firefox_user_prefs.setdefault('intl.locale.requested', requested)
# Spoof the speech-synthesis voice list.
#
# This has to fail CLOSED. Firefox registers the host's speech-dispatcher /
# SAPI / NSSpeech voices unless something stops it, and nsSynthVoiceRegistry
# only stops it when Camoufox owns the list. Leaving `voices` unset -- which
# the old `except Exception: pass` did on any generation failure -- exposed
# every native voice on the box (14805 espeak-ng entries on a stock Linux
# install) under a fingerprint claiming macOS or Windows: it both leaks the
# real host OS and contradicts the rest of the profile (#731).
#
# Drawn after the locale is resolved (locale= or geoip): the Windows voice
# list is the display language's pack, so an fr-FR identity has French
# voices, not the en-US ones.
if not _user_set_voices or 'voices' not in config:
os_name_v = {'win': 'windows', 'mac': 'macos', 'lin': 'linux'}.get(target_os, 'macos')
voice_locale = config.get('navigator.language')
if config.get('locale:language'):
voice_locale = '-'.join(
part for part in (config['locale:language'], config.get('locale:region')) if part
)
try:
config['voices'] = _generate_random_voice_subset(
os_name_v, voice_locale, seed=identity_seed(config, _identity_salt)
)
except Exception:
# An empty list still blocks the host's voices (see below), so a
# generation failure degrades to "no voices" rather than "all of
# the host's".
config['voices'] = []
# Pin the block explicitly instead of relying on a non-empty list to imply
# it, so an empty list -- or one whose entries the browser rejects as
# malformed -- cannot fall through to the host's native voices. set_into
# leaves an explicit caller value alone.
set_into(config, 'voices:blockIfNotDefined', True)
# Pass the humanize option
if humanize:
set_into(config, 'humanize', True)
@@ -1254,10 +1291,10 @@ def launch_options(
else:
# If the user has provided a specific WebGL vendor/renderer pair, use it
if webgl_config:
webgl_fp = sample_webgl(target_os, *webgl_config, seed=identity_seed(config))
webgl_fp = sample_webgl(target_os, *webgl_config, seed=identity_seed(config, _identity_salt))
elif config.get('webGl:vendor') and config.get('webGl:renderer'):
# Preset already set vendor/renderer — sample matching WebGL params
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config))
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config, _identity_salt))
else:
# Synthetic path: keep the GPU coherent with the screen BrowserForge
# already picked. Sampling the two independently yields pairs no
@@ -1265,7 +1302,7 @@ def launch_options(
# panel -- which consistency checks read as masking (#729).
webgl_fp = sample_webgl_for_screen(
target_os, config.get('screen.width'), config.get('screen.height'),
seed=identity_seed(config),
seed=identity_seed(config, _identity_salt),
)
enable_webgl2 = webgl_fp.pop('webGl2Enabled')
+6 -4
View File
@@ -321,9 +321,9 @@ class TestFixHardwareConcurrency:
def test_snaps_host_parallelism_when_it_cannot_pin(self, monkeypatch):
# The host count, snapped DOWN into the
# counts real machines ship with; the tails report 24 / 4. Used when the
# counts real machines ship with; the tails report 32 / 4. Used when the
# draw exceeds the host or the host cannot pin (macOS).
# 24 is in the table (recorded on real Windows/Linux devices); 2 is NOT,
# 18/22/24/28/32 are in the table (recorded on real devices); 2 is NOT,
# although it is recorded, because 2 is the resistFingerprinting value.
from camoufox import cpu_affinity, fingerprints as fp
@@ -332,8 +332,10 @@ class TestFixHardwareConcurrency:
(16, 16),
(10, 10),
(24, 24),
(32, 24),
(64, 24),
(26, 24),
(32, 32),
(64, 32),
(22, 22),
(7, 6),
(5, 4),
(2, 4),
+143
View File
@@ -0,0 +1,143 @@
"""Per-identity draws: unrelated launches must not share them, a pinned identity must.
identity_seed() used to hash only the UA, platform, screen size and core count.
Those take a handful of values per OS, so over 500 launches the seed took 12-30
distinct values, and every install drew its fonts, voices, GPU, media devices and
canvas/audio noise seeds from that same short list.
"""
from contextlib import contextmanager
from unittest import mock
import orjson
import pytest
from camoufox import cpu_affinity, utils
from camoufox import fingerprints as fp
@contextmanager
def host():
with mock.patch.object(utils, "get_screen_cons", lambda headless: None), (
mock.patch.object(utils, "has_display", lambda env: False)
), mock.patch.object(utils, "installed_verstr", lambda: "150.0.2"), (
mock.patch.object(utils, "launch_path", lambda **kwargs: "/nonexistent/camoufox")
):
yield
def config_of(options):
env = options["env"]
chunks = sorted(
(int(k.rsplit("_", 1)[1]), v) for k, v in env.items() if k.startswith("CAMOU_CONFIG_")
)
return orjson.loads("".join(chunk for _, chunk in chunks))
def launch(**kwargs):
kwargs.setdefault("os", "linux")
kwargs.setdefault("headless", True)
kwargs.setdefault("i_know_what_im_doing", True)
with host():
return config_of(utils.launch_options(**kwargs))
DRAWN = ("canvas:seed", "audio:seed", "fonts", "voices", "webGl:renderer")
def drawn(config):
return {k: orjson.dumps(config.get(k)) for k in DRAWN}
class TestUnpinnedLaunchesAreDistinct:
def test_noise_seeds_do_not_collide(self):
seeds = [launch()["canvas:seed"] for _ in range(40)]
# 40 draws from 2**32: any collision means the seed space collapsed.
assert len(set(seeds)) == len(seeds)
def test_same_presented_values_still_differ(self):
# The same UA/platform/screen/cores, i.e. what two users on the same
# common machine present, must not yield the same noise seeds.
config = {"navigator.userAgent": "x", "navigator.platform": "Win32",
"screen.width": 1920, "screen.height": 1080, "navigator.hardwareConcurrency": 8}
seeds = {fp.identity_seed(config, fp.identity_salt()) for _ in range(200)}
assert len(seeds) == 200
class TestPinnedIdentityIsStable:
def test_fixed_fingerprint_reproduces_every_draw(self):
fingerprint = fp.generate_fingerprint(os="linux")
first = launch(fingerprint=fingerprint)
second = launch(fingerprint=fingerprint)
assert drawn(first) == drawn(second)
def test_fixed_preset_reproduces_noise_seeds(self):
preset = fp.get_random_preset(os="windows", ff_version="150")
if not preset:
pytest.skip("no presets bundled")
first = launch(os="windows", fingerprint_preset=preset)
second = launch(os="windows", fingerprint_preset=preset)
assert (first["canvas:seed"], first["audio:seed"]) == (second["canvas:seed"], second["audio:seed"])
assert first["fonts"] == second["fonts"]
def test_caller_seeds_are_kept(self):
config = launch(config={"canvas:seed": 7, "audio:seed": 9})
assert (config["canvas:seed"], config["audio:seed"]) == (7, 9)
def test_salt_of_equal_objects_is_equal(self):
a = fp.generate_fingerprint(os="windows")
assert fp.identity_salt(a) == fp.identity_salt(a)
assert fp.identity_salt({"a": 1, "b": 2}) == fp.identity_salt({"b": 2, "a": 1})
class TestVoicesFollowLocale:
def test_windows_fr_identity_has_french_voices(self, monkeypatch):
for _ in range(5):
config = launch(os="windows", locale="fr-FR")
langs = {v["lang"] for v in config["voices"]}
assert "fr-FR" in langs, langs
class TestCoreCountFloor:
def test_small_pinnable_host_reports_table_floor(self, monkeypatch):
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
for host_cores in (1, 2, 3):
monkeypatch.setattr(fp, "host_cpu_count", lambda n=host_cores: n)
for drawn_cores in (1, 2, 3, 8):
c = {"navigator.hardwareConcurrency": drawn_cores}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == 4, (host_cores, drawn_cores)
def test_unpinned_launch_reports_host(self, monkeypatch):
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
c = {"navigator.hardwareConcurrency": 8}
fp.fix_hardware_concurrency(c, can_pin=False)
assert c["navigator.hardwareConcurrency"] == 16
def test_recorded_counts_are_in_the_table(self):
for n in (18, 22, 28, 32):
assert n in fp.PLAUSIBLE_CORE_COUNTS
class TestAffinityPick:
def test_adjacent_cores_from_a_random_start(self):
cores = list(range(16))
starts = set()
for _ in range(200):
picked = cpu_affinity._pick(cores, 4)
assert len(picked) == 4 and set(picked) <= set(cores)
ring = sorted(picked)
# adjacent modulo 16
assert any(all((s + i) % 16 in picked for i in range(4)) for s in ring)
starts.add(tuple(picked))
assert len(starts) > 4
class TestPrefsEnvIsAscii:
def test_non_ascii_pref_round_trips(self):
prefs = {"font.name.serif.ja": "游明朝", "intl.accept_languages": "fr-FR, fr"}
env = utils.get_pref_env_vars(prefs)
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
assert joined.isascii()
assert orjson.loads(joined) == prefs