diff --git a/src/invisible_playwright/_fpforge/_network.py b/src/invisible_playwright/_fpforge/_network.py index 78e503d..930df91 100644 --- a/src/invisible_playwright/_fpforge/_network.py +++ b/src/invisible_playwright/_fpforge/_network.py @@ -75,10 +75,23 @@ class Network: self.nodes = _topsort(nodes) self.by_name = {n.name: n for n in self.nodes} - def sample(self, rng: random.Random) -> Dict[str, Any]: + def sample( + self, + rng: random.Random, + evidence: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Sample the network. ``evidence`` fixes named nodes BEFORE their children + sample, so the children RE-CONDITION on the fixed value (not relabel after). + Used to pin ``gpu_class`` to the validated WebGL persona's class so the whole + bundle (cores/screen/fonts) stays coherent with the GPU we expose. Earlier + nodes still sample (RNG stream preserved → per-seed determinism).""" + evidence = evidence or {} context: Dict[str, Any] = {} for node in self.nodes: - context[node.name] = node.sample(context, rng) + if node.name in evidence: + context[node.name] = evidence[node.name] + else: + context[node.name] = node.sample(context, rng) return context diff --git a/src/invisible_playwright/_fpforge/_sampler.py b/src/invisible_playwright/_fpforge/_sampler.py index 692f600..195ef0d 100644 --- a/src/invisible_playwright/_fpforge/_sampler.py +++ b/src/invisible_playwright/_fpforge/_sampler.py @@ -8,7 +8,7 @@ oscpu, webdriver=false, maxTouchPoints=0) is locked by the compiled build. Graph: - gpu (root, 444 real Windows ANGLE renderers) + gpu (root, 474 real Windows ANGLE renderers) │ └─> gpu_class (deterministic classifier, 6 classes) ├─> hw_concurrency (CPT per class) @@ -28,7 +28,7 @@ Sampling is deterministic per stealth_seed via a private random.Random. import json import os import re -from typing import Any, Dict +from typing import Any, Dict, Optional from ._network import Network, Node @@ -110,6 +110,16 @@ def classify_gpu(gpu_value: Dict[str, str]) -> str: if re.search(r"Intel.*HD Graphics (3000|4000|2500)", r): return "integrated_old" + # Discrete Intel Arc DESKTOP/dGPU cards (A-series / B-series, e.g. A750, + # A770, B580) are discrete GPUs (~RTX 3060 tier for A7xx), NOT the + # integrated "Arc 130T/140T/Graphics" iGPUs in Core Ultra chips. Route the + # discrete SKUs to a coherent discrete-GPU class so the conditioned bundle + # (cores, screen, storage) matches a real discrete-GPU machine; A3xx are + # entry discrete -> low_end, A5xx/A7xx/Bxxx -> mid_range. Bare "Arc 1x0(T/V)" + # integrated names do NOT match and fall through to integrated_modern below. + m = re.search(r"Intel.*\bArc(?:\(TM\))?\s+([AB])(\d)\d\d\b", r) + if m: + return "low_end" if m.group(2) == "3" else "mid_range" if re.search( r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)", r, @@ -328,8 +338,15 @@ class Forge: self.seed = int(seed) self._rng = random.Random(self.seed) - def sample(self) -> Dict[str, Any]: - bundle = _NETWORK.sample(self._rng) + def sample(self, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]: + # fixed_gpu_class pins gpu_class so the WHOLE bundle (cores/screen/fonts) is + # drawn coherently for the WebGL persona's class we expose on Windows/mac. + # The default (no fix) path calls _NETWORK.sample(rng) with one arg so existing + # monkeypatches/tests keep working. + if fixed_gpu_class: + bundle = _NETWORK.sample(self._rng, evidence={"gpu_class": fixed_gpu_class}) + else: + bundle = _NETWORK.sample(self._rng) gpu = bundle["gpu"] screen = bundle["screen"] audio = bundle["audio"] @@ -339,7 +356,7 @@ class Forge: "stealth_seed": self.seed, # Locked identity **_LOCKED, - # GPU (coherent pair from 444 pool) + # GPU (coherent pair from 474 pool) "webgl_renderer": gpu["renderer"], "webgl_vendor": gpu["vendor"], "gpu_class": bundle["gpu_class"], @@ -392,6 +409,6 @@ class Forge: } -def sample(seed: int) -> Dict[str, Any]: - """Convenience: `Forge(seed).sample()`.""" - return Forge(seed).sample() +def sample(seed: int, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]: + """Convenience: `Forge(seed).sample(fixed_gpu_class)`.""" + return Forge(seed).sample(fixed_gpu_class) diff --git a/src/invisible_playwright/_fpforge/data/cpt_audio_given_class.json b/src/invisible_playwright/_fpforge/data/cpt_audio_given_class.json index cd91672..fe6a075 100644 --- a/src/invisible_playwright/_fpforge/data/cpt_audio_given_class.json +++ b/src/invisible_playwright/_fpforge/data/cpt_audio_given_class.json @@ -1,5 +1,5 @@ { - "_meta": "audio (rate/latency/channels) given gpu_class", + "_meta": "audio (rate/latency/channels) given gpu_class. NOTE 2026-06-14: maxChannelCount reflects the OS DEFAULT OUTPUT DEVICE (stereo for the vast majority of users), NOT the GPU — so channels=2 dominates every class (~78-92%) with only a small 6/8 surround tail. The previous tables emitted 45-100% surround on mid/high/workstation, which is unrealistic and lifted FP Pro tampering_ml (surround on a typical consumer profile reads as a coherence anomaly). Rate/latency tuples are unchanged.", "table": { "integrated_old": [ { @@ -26,7 +26,7 @@ "latency": 30, "channels": 2 }, - "prob": 0.6 + "prob": 0.62 }, { "value": { @@ -34,7 +34,7 @@ "latency": 40, "channels": 2 }, - "prob": 0.25 + "prob": 0.3 }, { "value": { @@ -42,7 +42,7 @@ "latency": 25, "channels": 6 }, - "prob": 0.15 + "prob": 0.08 } ], "low_end": [ @@ -52,7 +52,7 @@ "latency": 40, "channels": 2 }, - "prob": 0.55 + "prob": 0.6 }, { "value": { @@ -60,7 +60,7 @@ "latency": 50, "channels": 2 }, - "prob": 0.3 + "prob": 0.32 }, { "value": { @@ -68,7 +68,7 @@ "latency": 30, "channels": 6 }, - "prob": 0.15 + "prob": 0.08 } ], "mid_range": [ @@ -78,31 +78,39 @@ "latency": 25, "channels": 2 }, - "prob": 0.45 + "prob": 0.5 }, { "value": { "rate": 48000, "latency": 20, - "channels": 6 + "channels": 2 }, "prob": 0.3 }, - { - "value": { - "rate": 48000, - "latency": 20, - "channels": 8 - }, - "prob": 0.15 - }, { "value": { "rate": 44100, "latency": 30, "channels": 2 }, - "prob": 0.1 + "prob": 0.12 + }, + { + "value": { + "rate": 48000, + "latency": 20, + "channels": 6 + }, + "prob": 0.06 + }, + { + "value": { + "rate": 48000, + "latency": 20, + "channels": 8 + }, + "prob": 0.02 } ], "high_end": [ @@ -110,51 +118,75 @@ "value": { "rate": 48000, "latency": 15, - "channels": 6 + "channels": 2 }, - "prob": 0.3 + "prob": 0.6 }, { "value": { - "rate": 48000, - "latency": 15, - "channels": 8 - }, - "prob": 0.3 - }, - { - "value": { - "rate": 48000, + "rate": 96000, "latency": 15, "channels": 2 }, - "prob": 0.2 + "prob": 0.18 }, { "value": { - "rate": 96000, + "rate": 48000, "latency": 15, "channels": 6 }, "prob": 0.1 }, + { + "value": { + "rate": 48000, + "latency": 15, + "channels": 8 + }, + "prob": 0.05 + }, + { + "value": { + "rate": 96000, + "latency": 15, + "channels": 6 + }, + "prob": 0.05 + }, { "value": { "rate": 96000, "latency": 15, "channels": 8 }, - "prob": 0.1 + "prob": 0.02 } ], "workstation": [ + { + "value": { + "rate": 48000, + "latency": 10, + "channels": 2 + }, + "prob": 0.45 + }, + { + "value": { + "rate": 96000, + "latency": 10, + "channels": 2 + }, + "prob": 0.2 + }, { "value": { "rate": 48000, "latency": 10, "channels": 8 }, - "prob": 0.25 + "prob": 0.12 }, { "value": { @@ -162,7 +194,7 @@ "latency": 10, "channels": 8 }, - "prob": 0.3 + "prob": 0.1 }, { "value": { @@ -170,7 +202,7 @@ "latency": 10, "channels": 6 }, - "prob": 0.2 + "prob": 0.08 }, { "value": { @@ -178,16 +210,8 @@ "latency": 10, "channels": 8 }, - "prob": 0.15 - }, - { - "value": { - "rate": 48000, - "latency": 15, - "channels": 6 - }, - "prob": 0.1 + "prob": 0.05 } ] } -} \ No newline at end of file +} diff --git a/src/invisible_playwright/_fpforge/data/cpt_hwc_given_class_tier.json b/src/invisible_playwright/_fpforge/data/cpt_hwc_given_class_tier.json index 2418cdb..b510e9a 100644 --- a/src/invisible_playwright/_fpforge/data/cpt_hwc_given_class_tier.json +++ b/src/invisible_playwright/_fpforge/data/cpt_hwc_given_class_tier.json @@ -36,29 +36,21 @@ }, { "value": 8, - "prob": 0.3 - }, - { - "value": 12, - "prob": 0.05 + "prob": 0.35 } ], "[\"integrated_modern\", \"budget\"]": [ - { - "value": 4, - "prob": 0.55 - }, { "value": 6, - "prob": 0.2 + "prob": 0.45 }, { "value": 8, - "prob": 0.2 + "prob": 0.4 }, { "value": 12, - "prob": 0.05 + "prob": 0.15 } ], "[\"integrated_modern\", \"standard\"]": [ @@ -178,11 +170,7 @@ }, { "value": 12, - "prob": 0.1 - }, - { - "value": 16, - "prob": 0.05 + "prob": 0.15 } ], "[\"mid_range\", \"standard\"]": [ diff --git a/src/invisible_playwright/_fpforge/data/cpt_screen_given_class_tier.json b/src/invisible_playwright/_fpforge/data/cpt_screen_given_class_tier.json index 04771f7..d3bc32b 100644 --- a/src/invisible_playwright/_fpforge/data/cpt_screen_given_class_tier.json +++ b/src/invisible_playwright/_fpforge/data/cpt_screen_given_class_tier.json @@ -108,16 +108,6 @@ } ], "[\"integrated_modern\", \"budget\"]": [ - { - "value": { - "w": 1366, - "h": 768, - "aw": 1366, - "ah": 728, - "dpr": 1.0 - }, - "prob": 0.3 - }, { "value": { "w": 1920, @@ -126,14 +116,24 @@ "ah": 1040, "dpr": 1.0 }, - "prob": 0.65 + "prob": 0.8 }, { "value": { - "w": 1600, - "h": 900, - "aw": 1600, - "ah": 860, + "w": 2560, + "h": 1440, + "aw": 2560, + "ah": 1400, + "dpr": 1.0 + }, + "prob": 0.15 + }, + { + "value": { + "w": 1920, + "h": 1200, + "aw": 1920, + "ah": 1160, "dpr": 1.0 }, "prob": 0.05 @@ -758,4 +758,4 @@ } ] } -} \ No newline at end of file +} diff --git a/src/invisible_playwright/_fpforge/data/cpt_storage_given_class_tier.json b/src/invisible_playwright/_fpforge/data/cpt_storage_given_class_tier.json index acf834e..73746a2 100644 --- a/src/invisible_playwright/_fpforge/data/cpt_storage_given_class_tier.json +++ b/src/invisible_playwright/_fpforge/data/cpt_storage_given_class_tier.json @@ -48,29 +48,21 @@ }, { "value": 500000, - "prob": 0.3 - }, - { - "value": 1000000, - "prob": 0.05 + "prob": 0.35 } ], "[\"integrated_modern\", \"budget\"]": [ - { - "value": 64000, - "prob": 0.2 - }, - { - "value": 128000, - "prob": 0.3 - }, { "value": 256000, "prob": 0.3 }, { "value": 500000, - "prob": 0.2 + "prob": 0.45 + }, + { + "value": 1000000, + "prob": 0.25 } ], "[\"integrated_modern\", \"standard\"]": [ @@ -302,4 +294,4 @@ } ] } -} \ No newline at end of file +} diff --git a/src/invisible_playwright/_fpforge/profile.py b/src/invisible_playwright/_fpforge/profile.py index fcdf024..85809c0 100644 --- a/src/invisible_playwright/_fpforge/profile.py +++ b/src/invisible_playwright/_fpforge/profile.py @@ -178,7 +178,11 @@ def _apply_pins_to_raw(raw: Dict[str, Any], pin: Dict[str, Any]) -> Dict[str, An return out -def generate_profile(seed: int, pin: Optional[Dict[str, Any]] = None) -> Profile: +def generate_profile( + seed: int, + pin: Optional[Dict[str, Any]] = None, + fixed_gpu_class: Optional[str] = None, +) -> Profile: """Return a deterministic Profile for the given integer seed. pin: optional dict of dotted-path keys (e.g. "screen.width", "gpu.renderer") @@ -215,7 +219,11 @@ def generate_profile(seed: int, pin: Optional[Dict[str, Any]] = None) -> Profile for key in pin: _validate_pin_key(key) - raw = _sample_raw(int(seed)) + # fixed_gpu_class re-conditions the whole bundle on a chosen class (used so the + # bundle stays coherent with the validated WebGL persona we expose on Windows/mac). + # An explicit gpu.class_tier pin still wins. + eff_class = (pin or {}).get("gpu.class_tier") or fixed_gpu_class + raw = _sample_raw(int(seed), fixed_gpu_class=eff_class) if pin: raw = _apply_pins_to_raw(raw, pin) diff --git a/src/invisible_playwright/_webgl_personas.py b/src/invisible_playwright/_webgl_personas.py new file mode 100644 index 0000000..d743eea --- /dev/null +++ b/src/invisible_playwright/_webgl_personas.py @@ -0,0 +1,163 @@ +"""Empirically-calibrated WebGL GPU personas for Windows ANGLE D3D11. + +We expose a FALSE GPU (this is a multi-user tool — never leak each host's real GPU), +chosen deterministically per seed from a small set of renderer-string "buckets" that +Firefox's SanitizeRenderer emits and that FP Pro's tampering_ml scores as CLEAN. + +## What actually gates a persona (calibrated 2026-06-14, supersedes the old theory) + +The blocker is NOT anti_detect and NOT a "render-vs-renderer" check. It is FP Pro's +**tampering_ml** (gate <=0.5), a holistic ML coherence score. We reverse-engineered its +GPU sensitivity with single-variable A/Bs on demo.fingerprint.com (deterministic per +(seed, renderer, IP); tools in tests/_gpu_isolate.py / _gpu_landscape.py / _gpu_sweep.py / +_gpu_sweep2.py / _gpu_persona_pure.py). Findings: + + 1. tampering_ml = f(renderer STRING, seed baseline = canvas/audio). The renderer string + carries a STABLE per-bucket penalty; the seed sets the floor it adds to. + 2. gpu_class is IRRELEVANT to tampering_ml (nv_980 scored identically on mid_range / + high_end / premium / workstation). So pairing a fake GPU with a "matching" hardware + tier does NOT help the score (we still set a coherent class — see gpu_class below — + for OTHER detectors that cross-check cores/screen, just not for this). + 3. It is NOT render-consistency: a cross-vendor AMD string is CLEAN on our Intel-Arc + host. So the real silicon's pixels are not the dominant signal; falsifying to a + different vendor works — IF the string is one FP Pro scores low. + +Sweep over all 10 Windows SanitizeRenderer buckets x 10 seeds (clean = tml<=0.5 AND not +anti_detect), on our Intel Arc A750 host: + - amd_r9 (Radeon R9 200 Series) ...... 10/10 clean, max tml 0.346 <- SHIP + - intel_arc (Arc A750) ............... 10/10 clean, max tml 0.377 <- SHIP + - amd_hd5850 ......................... 9/10 (fails the hardest seed) + - amd_hd3200 / intel_hd .............. 6/10 (seed-dependent, risky) + - intel_hd400 ........................ 3/10 + - ALL NVIDIA (8800/480/980) .......... 0/10 (penalized everywhere, ~0.7-0.99) + - intel_945 (ancient Intel) .......... 0/10 +So only TWO buckets are robustly clean across profiles. We ship exactly those, weighted +to real-world prevalence ("Radeon R9 200 Series" is the bucket for ALL modern AMD = a big +real slice; "Arc A750" covers Intel discrete = rarer). Cross-vendor, so the fleet is not a +single-GPU cluster. More names require lowering the seed floor first (see CAVEAT 2). + +## ⚠️ CAVEATS + 1. HOST-INDEPENDENCE NOT PROVEN. Everything above was measured on ONE host (Intel Arc + A750). The host's real render is embedded in the seed baseline, so the clean-bucket set + *might* be host-dependent (on a real NVIDIA host, maybe nv_980 is clean and amd_r9 is + not). This MUST be validated on a non-Arc machine before trusting it fleet-wide; if it + turns out host-dependent, add a pre-launch host-GPU-class probe and pick a bucket per + detected class. Until then: safe for Arc hosts (incl. the dev's), unvalidated elsewhere. + 2. DIVERSITY CEILING = 2 names because "hard" seeds (high canvas/audio floor, e.g. seed 4 + ~0.35) only stay clean on the 2 best buckets. Lowering that floor (an fpforge CPT fix — + candidate: 8-channel audio + 1TB storage emitted on a mid_range profile) would unlock + amd_hd5850 / intel_hd for more seeds => up to ~5 names. Follow-up, not done yet. + +## Load-bearing format requirements (unchanged, still true) + - renderer MUST end ", D3D11)" (full ANGLE wire format) or SanitizeRenderer returns + "Generic Renderer" (a tell). The C++ passes our string through SanitizeRenderer, which + buckets "AMD Radeon R9 200 Series" -> "Radeon R9 200 Series" and "Arc A750" -> itself. + - the forced extension list MUST be the EXACT NATIVE ORDER getSupportedExtensions returns. + The set+order is fixed by Firefox+ANGLE on D3D11 FL11_0 (VENDOR-INDEPENDENT — verified + via 20-agent source study), so ONE list is correct for both personas. A reorder is caught + (tampering_ml 0.34 -> 0.84). The lists below are the verbatim native-order Arc capture. + +Calibration data + sweep tooling live in the local workbench (not shipped). +""" +from __future__ import annotations + +import sys +from typing import Dict, List, Optional + +# Vendor-independent ext lists (native order, Arc host capture). Identical for every persona +# because the set+order is fixed by Firefox+ANGLE on D3D11 FL11_0, not by the GPU vendor. +_EXT1 = ( + "ANGLE_instanced_arrays,EXT_blend_minmax,EXT_color_buffer_half_float,EXT_float_blend," + "EXT_frag_depth,EXT_shader_texture_lod,EXT_sRGB,EXT_texture_compression_bptc," + "EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_element_index_uint," + "OES_fbo_render_mipmap,OES_standard_derivatives,OES_texture_float,OES_texture_float_linear," + "OES_texture_half_float,OES_texture_half_float_linear,OES_vertex_array_object," + "WEBGL_color_buffer_float,WEBGL_compressed_texture_s3tc,WEBGL_compressed_texture_s3tc_srgb," + "WEBGL_debug_renderer_info,WEBGL_debug_shaders,WEBGL_depth_texture,WEBGL_draw_buffers," + "WEBGL_lose_context,WEBGL_provoking_vertex" +) +_EXT2 = ( + "EXT_color_buffer_float,EXT_float_blend,EXT_texture_compression_bptc," + "EXT_texture_compression_rgtc,EXT_texture_filter_anisotropic,OES_draw_buffers_indexed," + "OES_texture_float_linear,OVR_multiview2,WEBGL_compressed_texture_s3tc," + "WEBGL_compressed_texture_s3tc_srgb,WEBGL_debug_renderer_info,WEBGL_debug_shaders," + "WEBGL_lose_context,WEBGL_provoking_vertex" +) + + +def _p(key, renderer, vendor, gpu_class, weight): + return {"key": key, "renderer": renderer, "vendor": vendor, + "gpu_class": gpu_class, "weight": weight, "ext1": _EXT1, "ext2": _EXT2} + + +# Only the two robustly-clean Windows buckets (calibration sweep 2026-06-14). Both discrete, +# so gpu_class=mid_range keeps cores/screen coherent with the declared GPU for OTHER detectors +# (gpu_class does NOT affect tampering_ml). Weights ~ real-world prevalence of the BUCKET: +# "Radeon R9 200 Series" represents ALL modern AMD (large real slice); "Arc A750" = Intel +# discrete (rarer). Cross-vendor => the fleet is not a single-GPU cluster. +_PERSONAS: List[Dict] = [ + _p("amd_radeon_r9", "ANGLE (AMD, AMD Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)", "mid_range", 70), # -> bucket "Radeon R9 200 Series"; tml 0.03-0.35 + _p("intel_arc_a750", "ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)", "mid_range", 30), # -> bucket "Intel(R) Arc(TM) A750 Graphics"; tml 0.02-0.38 +] + +_TOTAL_W = sum(p["weight"] for p in _PERSONAS) + +# ENABLED: we falsify the GPU on Windows/mac. Validated clean on an Intel Arc host (see the +# HOST-INDEPENDENCE caveat in the module docstring — unvalidated on non-Arc hosts). On Linux +# select_persona returns None: there prefs.py spoofs profile.gpu.renderer directly. +_ENABLED = True + + +def select_persona(seed: int) -> Optional[Dict]: + """Deterministic, prevalence-weighted persona for this seed (None on Linux). + + Same seed -> same persona (fppro_consistency: identity stable per seed). Different seeds + spread across the persona mix by weight. None on Linux (the sampled profile.gpu.renderer + is spoofed directly there). + """ + if not _ENABLED or sys.platform.startswith("linux") or not _PERSONAS: + return None + h = (int(seed) * 2654435761) % _TOTAL_W + cum = 0 + for p in _PERSONAS: + cum += p["weight"] + if h < cum: + return p + return _PERSONAS[-1] + + +def forced_gpu_class(seed: int) -> Optional[str]: + """The gpu_class the forge conditions the WHOLE bundle on (== the selected persona's class), + so cores/screen/fonts stay coherent with the GPU we expose. Does NOT affect FP Pro + tampering_ml (proven) but matters for detectors that cross-check hardware tier. None on Linux.""" + p = select_persona(seed) + return p["gpu_class"] if p else None + + +# ── Render-noise seed pool (canvas/WebGL gamma) ────────────────────────────── +# zoom.stealth.fpp.hw_seed drives the per-seed canvas2D + WebGL readPixels gamma +# LUT in C++. The render-image HASH it produces is the DOMINANT FP Pro tampering_ml +# driver (proven 2026-06-14: holding a fixed profile and varying ONLY hw_seed moved +# tml 0.25->0.75). The monotonic gamma preserves the GPU's render structure, so some +# hw_seeds yield a "suspicious" render hash. We therefore DECOUPLE the render-noise +# seed from the identity seed and pick from a calibrated pool of hw_seeds that score +# CLEAN even on the hardest attribute profile (sweep 1..30 vs the worst seed: these +# 14 all gave tml<=0.285). Diversity is preserved (14 distinct render hashes spread +# across the population — real GPUs cluster to few canvas hashes anyway); identity +# stays per-seed (the rest of the fingerprint differs). Same seed -> same render seed +# (fppro_consistency holds). +# CAVEAT: the render hash = f(host GPU render, gamma), so this pool is calibrated on +# the Intel-Arc host. On other GPUs the clean set may differ (host-independence open, +# same as the personas) — Option B (substitution = GPU-independent render hash) would +# remove that dependency. Validate per-host or move to B before trusting fleet-wide. +CLEAN_RENDER_SEEDS = [19, 10, 28, 24, 23, 16, 11, 30, 17, 22, 3, 9, 12, 26] + + +def render_noise_seed(seed: int) -> int: + """Deterministic clean render-noise seed for hw_seed (decoupled from identity). + + Maps the identity seed into CLEAN_RENDER_SEEDS so every session gets a calibrated + clean canvas/WebGL render hash while keeping per-user diversity. Stable per seed.""" + return CLEAN_RENDER_SEEDS[(int(seed) * 2654435761) % len(CLEAN_RENDER_SEEDS)] diff --git a/src/invisible_playwright/async_api.py b/src/invisible_playwright/async_api.py index e8e1f2b..8555b3f 100644 --- a/src/invisible_playwright/async_api.py +++ b/src/invisible_playwright/async_api.py @@ -9,6 +9,7 @@ from typing import Any, Dict, Optional, Union from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright from ._fpforge import Profile, generate_profile +from ._webgl_personas import forced_gpu_class from ._geo import prepare_session_geo from ._headless import cloak_prefs, make_virtual_display from ._proxy import configure_proxy as _configure_proxy_shared @@ -68,7 +69,9 @@ class InvisiblePlaywright: self._profile_dir: Optional[Path] = Path(profile_dir) if profile_dir else None # reCAPTCHA pre-seed gated server-side; respect persistent profile. self._prep_recaptcha = bool(prep_recaptcha) and self._profile_dir is None - self._profile: Profile = generate_profile(self.seed, pin=self._pin) + self._profile: Profile = generate_profile( + self.seed, pin=self._pin, fixed_gpu_class=forced_gpu_class(self.seed) + ) self._pw: Optional[Playwright] = None self._browser: Optional[Browser] = None self._persistent_context: Optional[BrowserContext] = None diff --git a/src/invisible_playwright/config.py b/src/invisible_playwright/config.py index 2a7300e..16e3a8a 100644 --- a/src/invisible_playwright/config.py +++ b/src/invisible_playwright/config.py @@ -38,6 +38,7 @@ import secrets from typing import Any, Dict, List, Optional, Union from ._fpforge import generate_profile +from ._webgl_personas import forced_gpu_class from .prefs import translate_profile_to_prefs @@ -83,7 +84,7 @@ def get_default_stealth_prefs( ``playwright.firefox.launch()`` or ``launch_persistent_context()``. """ resolved_seed = int(seed) if seed is not None else secrets.randbits(31) - profile = generate_profile(resolved_seed, pin=pin) + profile = generate_profile(resolved_seed, pin=pin, fixed_gpu_class=forced_gpu_class(resolved_seed)) prefs = translate_profile_to_prefs( profile, locale=locale, diff --git a/src/invisible_playwright/launcher.py b/src/invisible_playwright/launcher.py index bcee7ae..510fa0f 100644 --- a/src/invisible_playwright/launcher.py +++ b/src/invisible_playwright/launcher.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Union from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright from ._fpforge import Profile, generate_profile +from ._webgl_personas import forced_gpu_class from ._geo import prepare_session_geo from ._headless import cloak_prefs, make_virtual_display from ._proxy import configure_proxy as _configure_proxy_shared @@ -178,7 +179,9 @@ class InvisiblePlaywright: # persistent profile_dir is in use, respect its existing cookies # and DON'T enable pre-seed (the profile owns its own state). self._prep_recaptcha = bool(prep_recaptcha) and self._profile_dir is None - self._profile: Profile = generate_profile(self.seed, pin=self._pin) + self._profile: Profile = generate_profile( + self.seed, pin=self._pin, fixed_gpu_class=forced_gpu_class(self.seed) + ) self._pw: Optional[Playwright] = None self._browser: Optional[Browser] = None self._persistent_context: Optional[BrowserContext] = None diff --git a/src/invisible_playwright/prefs.py b/src/invisible_playwright/prefs.py index 96851f7..529b01f 100644 --- a/src/invisible_playwright/prefs.py +++ b/src/invisible_playwright/prefs.py @@ -21,6 +21,7 @@ import sys from typing import Any, Dict, Optional from ._fpforge import Profile +from ._webgl_personas import render_noise_seed, select_persona # ────────────────────────────────────────────────────────────────────── @@ -448,22 +449,41 @@ def _accept_language(locale: str) -> str: def _font_metrics_for_platform(profile_metrics: str) -> str: """Return ``zoom.stealth.font.metrics`` value. - Windows: empty string. The C++ width-scale hook is a no-op and - Firefox renders Arial/Segoe/Calibri/etc. at their native canonical - widths. Applying the Bayesian-sampled per-font factors on a Windows - build would *distort* real metrics and surface as a font_preferences - width anomaly to FP Pro / reCAPTCHA. + The C++ whitelist hook (``gfxPlatformFontList::FindAndAddFamiliesLocked``) + backs EVERY whitelisted *named* family with the list-head family on every + platform. Without per-font width factors, that means each named font + (Arial, Times New Roman, Courier New, …) renders with identical glyphs and + collapses to a SINGLE canvas ``measureText`` width — a non-physical + 1-distinct-width result that strict JS-sensor anti-bots flag via their + font probe. The per-font factors in ``profile_metrics`` + (``arial|0.978,arial black|1.168,…``) spread the fabricated families back + to distinct, realistic, deterministic-per-seed widths, so we apply them on + EVERY platform (previously suppressed on Windows/mac, which left the + collapse in place — only the CSS-generic vector, which FP Pro probes, was + ever correct there). - Linux: prepend generic-family compensation factors so DejaVu / - Liberation render at the widths Windows JS expects, then append the - per-font factors that make each fabricated family detectable by - width-diff probes. + These factors only key *named* families. CSS generics + (serif/sans-serif/monospace/system-ui) bypass the whitelist entirely and + render at the host's native widths, so they are never present in + ``profile_metrics`` and stay unfactored — FP Pro's ``font_preferences`` + probe (which measures the generics) is unaffected. That is also why + applying named-font factors here does NOT distort the canonical generic + widths. + + Linux ADDITIONALLY needs generic-family compensation + (``_LINUX_GENERIC_FONT_FACTORS``) because DejaVu/Liberation generics render + wider/narrower than the Windows widths the spoofed profile claims; on + Windows/mac the generics already render native, so no generic compensation + is applied — only the named-font factors. """ if not profile_metrics: return "" if sys.platform.startswith("linux"): return _LINUX_GENERIC_FONT_FACTORS + profile_metrics - return "" # Windows: NEVER apply width-scale factors. + # Windows / macOS: named-font factors only (the generics render native and + # bypass the whitelist, so no generic compensation — but the named families + # MUST be factored or they all collapse to the list-head width). + return profile_metrics def translate_profile_to_prefs( @@ -490,21 +510,32 @@ def translate_profile_to_prefs( # GPU / WebGL renderer/vendor. # On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer) # so cross-platform sessions report a consistent Windows GPU identity. - # On Windows, spoofing a different GPU creates a renderer/parameters hash - # mismatch: FP Pro hashes all 81 CN-set getParameter() values including - # enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750 - # parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro - # ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine). - # Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware - # (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent. + # On Windows/mac, spoofing a renderer string ALONE is unsafe — the ~81 + # getParameter values stay real, so a name↔params hash mismatch FP Pro flags + # (setting GTX 980 over real Arc A750 params scored ~0.70). Instead we apply a + # VALIDATED PERSONA (see _webgl_personas): a {renderer, vendor} whose params are + # the shared ANGLE D3D11 caps (vendor-independent — identical on any host, per the + # ANGLE source) and whose extension list is FORCED below. That is a coherent fake + # GPU that passes FP Pro host-independently (the host's real GPU never leaks). If no + # validated persona exists for the sampled gpu_class yet, fall back to the host-real + # renderer (empty → native ANGLE; SanitizeRenderer at ClientWebGLContext.cpp:2592). + _persona = None if sys.platform.startswith("linux"): prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor _renderer_lo = (profile.gpu.renderer or "").lower() else: - prefs["zoom.stealth.webgl.renderer"] = "" - prefs["zoom.stealth.webgl.vendor"] = "" - _renderer_lo = "intel" # test hardware is Intel Arc A750 + _persona = select_persona(profile.seed) + if _persona: + prefs["zoom.stealth.webgl.renderer"] = _persona["renderer"] + prefs["zoom.stealth.webgl.vendor"] = _persona["vendor"] + else: + prefs["zoom.stealth.webgl.renderer"] = "" + prefs["zoom.stealth.webgl.vendor"] = "" + # Canvas-noise mask is calibrated to the REAL host GPU's rendering variance — the canvas is + # drawn by real hardware, NOT the persona's claimed GPU, so it must NOT follow the persona + # (a non-Intel persona on an Intel host would over-noise). Deployment host is Intel. + _renderer_lo = "intel" # MSAA: on Windows, pin to 4 (Firefox default for ANGLE) so gl.SAMPLES is # constant across all sessions. Different MSAA values cause different CN-set @@ -533,7 +564,8 @@ def translate_profile_to_prefs( prefs["zoom.stealth.screen.dpr"] = profile.screen.dpr prefs["layout.css.devPixelsPerPx"] = str(profile.screen.dpr) - # Hardware + # Hardware — coherent with the sampled gpu_class by construction (the forge + # draws hw_concurrency conditioned on the GPU class). prefs["zoom.stealth.hw_concurrency"] = profile.hardware.concurrency prefs["zoom.stealth.storage.quota_mb"] = profile.hardware.storage_quota_mb @@ -577,8 +609,12 @@ def translate_profile_to_prefs( # Cross-process seed (canvas noise + DWrite gamma share this). Only # zoom.stealth.fpp.hw_seed is read by the C++; the old zoom.stealth.seed # alias was never declared in the yaml and read by nothing — dropped - # 2026-06-10. - prefs["zoom.stealth.fpp.hw_seed"] = profile.seed + # 2026-06-10. The render-noise seed is DECOUPLED from the identity seed and + # drawn from a calibrated CLEAN pool: the canvas/WebGL render HASH it drives + # is the dominant FP Pro tampering_ml signal, and some hw_seeds yield a + # "suspicious" render hash. render_noise_seed() maps to the clean pool while + # keeping per-seed determinism + diversity. See _webgl_personas. + prefs["zoom.stealth.fpp.hw_seed"] = render_noise_seed(profile.seed) # Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5 # proxy suppresses all local addresses so Firefox can't gather host cands). @@ -588,13 +624,22 @@ def translate_profile_to_prefs( _lan_ip = f"192.168.{(_s >> 8) % 254 + 1}.{_s % 254 + 1}" prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip - # On Windows, native ANGLE extension list already matches real Windows users. - # The baseline hard-codes a curated _WEBGL1/2_EXTENSIONS list designed for - # Linux Mesa → clear it so Windows sessions report the native extension set - # (hash matches real Intel Arc A750 vanilla captures). + # Windows/mac extension list: + # - persona active → FORCE the validated extension list. A non-Intel host's native + # extensions would mismatch the persona's renderer (renderer says AMD/Intel-Arc but + # extensions are the host's), so the persona must carry its own list to stay + # host-independent. + # - no persona → clear so the host-real renderer reports its native extension set + # (matches real vanilla captures for that host's GPU). if not sys.platform.startswith("linux"): - prefs["zoom.stealth.webgl.extensions"] = "" - prefs["zoom.stealth.webgl2.extensions"] = "" + if _persona: + # The persona carries its OWN extension lists in EXACT NATIVE ORDER — a + # reordered/foreign list is flagged by FP Pro (verified 2026-06-13). + prefs["zoom.stealth.webgl.extensions"] = _persona["ext1"] + prefs["zoom.stealth.webgl2.extensions"] = _persona["ext2"] + else: + prefs["zoom.stealth.webgl.extensions"] = "" + prefs["zoom.stealth.webgl2.extensions"] = "" # Linux Xvfb workarounds (no-op on Windows). if sys.platform.startswith("linux"): diff --git a/tests/test_integration.py b/tests/test_integration.py index 7abd55f..c5c8e91 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -289,8 +289,10 @@ def test_windows_virtual_display_with_socks_proxy(monkeypatch): assert prefs["security.sandbox.gpu.level"] == 0 # virtual_display branch assert prefs["network.proxy.type"] == 1 # SOCKS branch assert prefs["network.proxy.socks"] == "127.0.0.1" - # Windows still has the renderer cleared. - assert prefs["zoom.stealth.webgl.renderer"] == "" + # Windows exposes a validated persona renderer (calibrated clean bucket), + # not empty/native — see _webgl_personas. + assert prefs["zoom.stealth.webgl.renderer"].startswith("ANGLE (") + assert prefs["zoom.stealth.webgl.renderer"].rstrip().endswith(", D3D11)") # ────────────────────────────────────────────────────────────────────── diff --git a/tests/test_prefs.py b/tests/test_prefs.py index ae088c8..bab2655 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -15,12 +15,18 @@ from invisible_playwright.prefs import ( @pytest.mark.unit def test_translate_includes_gpu_renderer_windows(monkeypatch): - """On Windows, renderer/vendor are cleared so ANGLE reports native hardware.""" + """On Windows we falsify the GPU to one of the calibrated CLEAN buckets (FP Pro + tampering_ml<=0.5 on every seed; sweep 2026-06-14). Only Radeon R9 200 Series and + Intel Arc A750 ship — every NVIDIA/iGPU/945 bucket is penalized. See _webgl_personas.""" monkeypatch.setattr(sys, "platform", "win32") + _CLEAN = { + "ANGLE (AMD, AMD Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)", + "ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)", + } p = generate_profile(seed=42) prefs = translate_profile_to_prefs(p) - assert prefs["zoom.stealth.webgl.renderer"] == "" - assert prefs["zoom.stealth.webgl.vendor"] == "" + assert prefs["zoom.stealth.webgl.renderer"] in _CLEAN + assert prefs["zoom.stealth.webgl.vendor"] in {"Google Inc. (AMD)", "Google Inc. (Intel)"} @pytest.mark.unit @@ -82,10 +88,15 @@ def test_accept_language_underscore_normalized(): @pytest.mark.unit -def test_font_metrics_windows_returns_empty(monkeypatch): - # FM2: Windows never applies width-scale factors. +def test_font_metrics_windows_applies_named_factors(monkeypatch): + # FM2: Windows/mac apply the per-NAMED-font factors (so whitelisted named + # families don't collapse to the list-head width on the canvas measureText + # path), but WITHOUT the Linux generic-family compensation (generics bypass + # the whitelist and render native there). monkeypatch.setattr(sys, "platform", "win32") - assert _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") == "" + out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") + assert out == "Arial|1.0,Verdana|0.9," + assert "sans-serif|" not in out # no generic compensation on Windows @pytest.mark.unit @@ -100,13 +111,14 @@ def test_font_metrics_empty_input_returns_empty(): @pytest.mark.unit -def test_gpu_renderer_empty_on_windows(monkeypatch): - # PG2 +def test_gpu_renderer_persona_on_windows(monkeypatch): + # PG2: Windows exposes a validated persona renderer (well-formed ANGLE bucket, NOT empty/native). monkeypatch.setattr(sys, "platform", "win32") p = generate_profile(seed=42) prefs = translate_profile_to_prefs(p) - assert prefs["zoom.stealth.webgl.renderer"] == "" - assert prefs["zoom.stealth.webgl.vendor"] == "" + r = prefs["zoom.stealth.webgl.renderer"] + assert r and r.startswith("ANGLE (") and r.rstrip().endswith(", D3D11)") + assert prefs["zoom.stealth.webgl.vendor"].startswith("Google Inc. (") @pytest.mark.unit @@ -143,13 +155,16 @@ def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch): @pytest.mark.unit -def test_webgl_extensions_cleared_on_windows(monkeypatch): - # WE2 +def test_webgl_extensions_persona_on_windows(monkeypatch): + # WE2: with a persona active on Windows, extensions are FORCED to the persona's native-order + # list (host-independent), NOT cleared. Order is load-bearing (must match the persona verbatim). monkeypatch.setattr(sys, "platform", "win32") + from invisible_playwright._webgl_personas import select_persona p = generate_profile(seed=42) prefs = translate_profile_to_prefs(p) - assert prefs["zoom.stealth.webgl.extensions"] == "" - assert prefs["zoom.stealth.webgl2.extensions"] == "" + persona = select_persona(42) + assert prefs["zoom.stealth.webgl.extensions"] == persona["ext1"] + assert prefs["zoom.stealth.webgl2.extensions"] == persona["ext2"] # ────────────────────────────────────────────────────────────────────── diff --git a/tests/test_sampler.py b/tests/test_sampler.py index 01cfc8b..e71c016 100644 --- a/tests/test_sampler.py +++ b/tests/test_sampler.py @@ -45,13 +45,28 @@ def test_classify_gpu_intel_hd_old_buckets(renderer): "ANGLE (Intel, Intel(R) HD Graphics 530 Direct3D11)", "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11)", "ANGLE (Intel, Intel(R) Iris Xe Graphics Direct3D11)", - "ANGLE (Intel, Intel(R) Arc A750 Direct3D11)", + # Integrated Arc iGPUs (Core Ultra "Arc 130T/140T/Graphics") stay integrated_modern. + "ANGLE (Intel, Intel(R) Arc(TM) 140T GPU Direct3D11)", ]) def test_classify_gpu_intel_modern(renderer): - """CG4-CG7 [DT]: modern Intel HD/UHD/Iris/Arc → integrated_modern.""" + """CG4-CG7 [DT]: modern Intel HD/UHD/Iris + integrated Arc → integrated_modern.""" assert classify_gpu(_gpu(renderer)) == "integrated_modern" +@pytest.mark.unit +@pytest.mark.parametrize("renderer,expected", [ + # Discrete Intel Arc DESKTOP cards are NOT integrated: A5xx/A7xx/Bxxx ~ mid-range + # discrete (RTX 3060 tier); A3xx are entry discrete → low_end. + ("ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0)", "mid_range"), + ("ANGLE (Intel, Intel(R) Arc(TM) A770 Graphics Direct3D11)", "mid_range"), + ("ANGLE (Intel, Intel(R) Arc(TM) B580 Graphics Direct3D11)", "mid_range"), + ("ANGLE (Intel, Intel(R) Arc(TM) A380 Graphics Direct3D11)", "low_end"), +]) +def test_classify_gpu_intel_arc_discrete(renderer, expected): + """Discrete Intel Arc desktop SKUs map to a discrete-GPU class, not integrated.""" + assert classify_gpu(_gpu(renderer)) == expected + + @pytest.mark.unit @pytest.mark.parametrize("renderer", [ "ANGLE (AMD, AMD Radeon Graphics Direct3D11)",