diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py index 0d219e6..09c5bd6 100644 --- a/pythonlib/camoufox/async_api.py +++ b/pythonlib/camoufox/async_api.py @@ -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", "")) diff --git a/pythonlib/camoufox/cpu_affinity.py b/pythonlib/camoufox/cpu_affinity.py index 5ad55c3..244ce48 100644 --- a/pythonlib/camoufox/cpu_affinity.py +++ b/pythonlib/camoufox/cpu_affinity.py @@ -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 diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py index d85365f..4a360ca 100644 --- a/pythonlib/camoufox/fingerprints.py +++ b/pythonlib/camoufox/fingerprints.py @@ -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 diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py index 04e09a9..96a02ee 100644 --- a/pythonlib/camoufox/sync_api.py +++ b/pythonlib/camoufox/sync_api.py @@ -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 diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index bc61ce1..88d6bb1 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -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') diff --git a/pythonlib/tests/test_fingerprint_fixes.py b/pythonlib/tests/test_fingerprint_fixes.py index a8a694c..5817bed 100644 --- a/pythonlib/tests/test_fingerprint_fixes.py +++ b/pythonlib/tests/test_fingerprint_fixes.py @@ -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), diff --git a/pythonlib/tests/test_identity_salt.py b/pythonlib/tests/test_identity_salt.py new file mode 100644 index 0000000..fb5104e --- /dev/null +++ b/pythonlib/tests/test_identity_salt.py @@ -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