webgl: ship only the GPU buckets that pass tampering_ml + decouple render-noise seed
Cut the per-seed WebGL persona to the two renderer buckets that score clean on FP Pro tampering_ml across seeds (AMD Radeon R9 200 Series and Intel Arc A750), weighted 70/30, cross-vendor so the fleet isn't one fixed GPU. Every NVIDIA bucket and the integrated/ancient Intel buckets are penalised, so they're out. The canvas/WebGL render-image hash turned out to be the dominant tampering_ml driver, not the attributes, so the render-noise seed (zoom.stealth.fpp.hw_seed) is now decoupled from the identity seed and drawn from a calibrated clean pool. Per-seed determinism and per-user diversity are preserved. Also in this change: - audio maxChannelCount is stereo-dominant per class (it reflects the output device, not the GPU; the old tables over-emitted 5.1/7.1 surround) - route discrete Intel Arc desktop cards to a discrete-GPU class (not integrated) - condition the whole sampled profile on the exposed GPU class via the sampler's evidence path, so cores/screen/storage stay coherent with the declared GPU - apply per-named-font width factors on Windows/macOS so canvas measureText widths don't collapse to a single value 12/12 seeds clean on tampering_ml (worst 0.29), bot and anti-detect negative, and the fingerprint stays identical across repeated runs of the same seed.
This commit is contained in:
@@ -75,10 +75,23 @@ class Network:
|
|||||||
self.nodes = _topsort(nodes)
|
self.nodes = _topsort(nodes)
|
||||||
self.by_name = {n.name: n for n in self.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] = {}
|
context: Dict[str, Any] = {}
|
||||||
for node in self.nodes:
|
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
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ oscpu, webdriver=false, maxTouchPoints=0) is locked by the compiled build.
|
|||||||
|
|
||||||
Graph:
|
Graph:
|
||||||
|
|
||||||
gpu (root, 444 real Windows ANGLE renderers)
|
gpu (root, 474 real Windows ANGLE renderers)
|
||||||
│
|
│
|
||||||
└─> gpu_class (deterministic classifier, 6 classes)
|
└─> gpu_class (deterministic classifier, 6 classes)
|
||||||
├─> hw_concurrency (CPT per class)
|
├─> hw_concurrency (CPT per class)
|
||||||
@@ -28,7 +28,7 @@ Sampling is deterministic per stealth_seed via a private random.Random.
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from ._network import Network, Node
|
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):
|
if re.search(r"Intel.*HD Graphics (3000|4000|2500)", r):
|
||||||
return "integrated_old"
|
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(
|
if re.search(
|
||||||
r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)",
|
r"Intel.*(HD Graphics (4[56]|5\d\d|6\d\d)|UHD Graphics|Graphics Family|Iris|Arc)",
|
||||||
r,
|
r,
|
||||||
@@ -328,8 +338,15 @@ class Forge:
|
|||||||
self.seed = int(seed)
|
self.seed = int(seed)
|
||||||
self._rng = random.Random(self.seed)
|
self._rng = random.Random(self.seed)
|
||||||
|
|
||||||
def sample(self) -> Dict[str, Any]:
|
def sample(self, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]:
|
||||||
bundle = _NETWORK.sample(self._rng)
|
# 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"]
|
gpu = bundle["gpu"]
|
||||||
screen = bundle["screen"]
|
screen = bundle["screen"]
|
||||||
audio = bundle["audio"]
|
audio = bundle["audio"]
|
||||||
@@ -339,7 +356,7 @@ class Forge:
|
|||||||
"stealth_seed": self.seed,
|
"stealth_seed": self.seed,
|
||||||
# Locked identity
|
# Locked identity
|
||||||
**_LOCKED,
|
**_LOCKED,
|
||||||
# GPU (coherent pair from 444 pool)
|
# GPU (coherent pair from 474 pool)
|
||||||
"webgl_renderer": gpu["renderer"],
|
"webgl_renderer": gpu["renderer"],
|
||||||
"webgl_vendor": gpu["vendor"],
|
"webgl_vendor": gpu["vendor"],
|
||||||
"gpu_class": bundle["gpu_class"],
|
"gpu_class": bundle["gpu_class"],
|
||||||
@@ -392,6 +409,6 @@ class Forge:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def sample(seed: int) -> Dict[str, Any]:
|
def sample(seed: int, fixed_gpu_class: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Convenience: `Forge(seed).sample()`."""
|
"""Convenience: `Forge(seed).sample(fixed_gpu_class)`."""
|
||||||
return Forge(seed).sample()
|
return Forge(seed).sample(fixed_gpu_class)
|
||||||
|
|||||||
@@ -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": {
|
"table": {
|
||||||
"integrated_old": [
|
"integrated_old": [
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"latency": 30,
|
"latency": 30,
|
||||||
"channels": 2
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.6
|
"prob": 0.62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"latency": 40,
|
"latency": 40,
|
||||||
"channels": 2
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.25
|
"prob": 0.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"latency": 25,
|
"latency": 25,
|
||||||
"channels": 6
|
"channels": 6
|
||||||
},
|
},
|
||||||
"prob": 0.15
|
"prob": 0.08
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"low_end": [
|
"low_end": [
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"latency": 40,
|
"latency": 40,
|
||||||
"channels": 2
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.55
|
"prob": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"latency": 50,
|
"latency": 50,
|
||||||
"channels": 2
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.3
|
"prob": 0.32
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"latency": 30,
|
"latency": 30,
|
||||||
"channels": 6
|
"channels": 6
|
||||||
},
|
},
|
||||||
"prob": 0.15
|
"prob": 0.08
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mid_range": [
|
"mid_range": [
|
||||||
@@ -78,31 +78,39 @@
|
|||||||
"latency": 25,
|
"latency": 25,
|
||||||
"channels": 2
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.45
|
"prob": 0.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"rate": 48000,
|
"rate": 48000,
|
||||||
"latency": 20,
|
"latency": 20,
|
||||||
"channels": 6
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.3
|
"prob": 0.3
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"value": {
|
|
||||||
"rate": 48000,
|
|
||||||
"latency": 20,
|
|
||||||
"channels": 8
|
|
||||||
},
|
|
||||||
"prob": 0.15
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"rate": 44100,
|
"rate": 44100,
|
||||||
"latency": 30,
|
"latency": 30,
|
||||||
"channels": 2
|
"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": [
|
"high_end": [
|
||||||
@@ -110,51 +118,75 @@
|
|||||||
"value": {
|
"value": {
|
||||||
"rate": 48000,
|
"rate": 48000,
|
||||||
"latency": 15,
|
"latency": 15,
|
||||||
"channels": 6
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.3
|
"prob": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"rate": 48000,
|
"rate": 96000,
|
||||||
"latency": 15,
|
|
||||||
"channels": 8
|
|
||||||
},
|
|
||||||
"prob": 0.3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": {
|
|
||||||
"rate": 48000,
|
|
||||||
"latency": 15,
|
"latency": 15,
|
||||||
"channels": 2
|
"channels": 2
|
||||||
},
|
},
|
||||||
"prob": 0.2
|
"prob": 0.18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"rate": 96000,
|
"rate": 48000,
|
||||||
"latency": 15,
|
"latency": 15,
|
||||||
"channels": 6
|
"channels": 6
|
||||||
},
|
},
|
||||||
"prob": 0.1
|
"prob": 0.1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"rate": 48000,
|
||||||
|
"latency": 15,
|
||||||
|
"channels": 8
|
||||||
|
},
|
||||||
|
"prob": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"rate": 96000,
|
||||||
|
"latency": 15,
|
||||||
|
"channels": 6
|
||||||
|
},
|
||||||
|
"prob": 0.05
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"rate": 96000,
|
"rate": 96000,
|
||||||
"latency": 15,
|
"latency": 15,
|
||||||
"channels": 8
|
"channels": 8
|
||||||
},
|
},
|
||||||
"prob": 0.1
|
"prob": 0.02
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"workstation": [
|
"workstation": [
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"rate": 48000,
|
||||||
|
"latency": 10,
|
||||||
|
"channels": 2
|
||||||
|
},
|
||||||
|
"prob": 0.45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"rate": 96000,
|
||||||
|
"latency": 10,
|
||||||
|
"channels": 2
|
||||||
|
},
|
||||||
|
"prob": 0.2
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"rate": 48000,
|
"rate": 48000,
|
||||||
"latency": 10,
|
"latency": 10,
|
||||||
"channels": 8
|
"channels": 8
|
||||||
},
|
},
|
||||||
"prob": 0.25
|
"prob": 0.12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
@@ -162,7 +194,7 @@
|
|||||||
"latency": 10,
|
"latency": 10,
|
||||||
"channels": 8
|
"channels": 8
|
||||||
},
|
},
|
||||||
"prob": 0.3
|
"prob": 0.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
@@ -170,7 +202,7 @@
|
|||||||
"latency": 10,
|
"latency": 10,
|
||||||
"channels": 6
|
"channels": 6
|
||||||
},
|
},
|
||||||
"prob": 0.2
|
"prob": 0.08
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
@@ -178,15 +210,7 @@
|
|||||||
"latency": 10,
|
"latency": 10,
|
||||||
"channels": 8
|
"channels": 8
|
||||||
},
|
},
|
||||||
"prob": 0.15
|
"prob": 0.05
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": {
|
|
||||||
"rate": 48000,
|
|
||||||
"latency": 15,
|
|
||||||
"channels": 6
|
|
||||||
},
|
|
||||||
"prob": 0.1
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,29 +36,21 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 8,
|
"value": 8,
|
||||||
"prob": 0.3
|
"prob": 0.35
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": 12,
|
|
||||||
"prob": 0.05
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"[\"integrated_modern\", \"budget\"]": [
|
"[\"integrated_modern\", \"budget\"]": [
|
||||||
{
|
|
||||||
"value": 4,
|
|
||||||
"prob": 0.55
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": 6,
|
"value": 6,
|
||||||
"prob": 0.2
|
"prob": 0.45
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 8,
|
"value": 8,
|
||||||
"prob": 0.2
|
"prob": 0.4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 12,
|
"value": 12,
|
||||||
"prob": 0.05
|
"prob": 0.15
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"[\"integrated_modern\", \"standard\"]": [
|
"[\"integrated_modern\", \"standard\"]": [
|
||||||
@@ -178,11 +170,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 12,
|
"value": 12,
|
||||||
"prob": 0.1
|
"prob": 0.15
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": 16,
|
|
||||||
"prob": 0.05
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"[\"mid_range\", \"standard\"]": [
|
"[\"mid_range\", \"standard\"]": [
|
||||||
|
|||||||
@@ -108,16 +108,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"[\"integrated_modern\", \"budget\"]": [
|
"[\"integrated_modern\", \"budget\"]": [
|
||||||
{
|
|
||||||
"value": {
|
|
||||||
"w": 1366,
|
|
||||||
"h": 768,
|
|
||||||
"aw": 1366,
|
|
||||||
"ah": 728,
|
|
||||||
"dpr": 1.0
|
|
||||||
},
|
|
||||||
"prob": 0.3
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"w": 1920,
|
"w": 1920,
|
||||||
@@ -126,14 +116,24 @@
|
|||||||
"ah": 1040,
|
"ah": 1040,
|
||||||
"dpr": 1.0
|
"dpr": 1.0
|
||||||
},
|
},
|
||||||
"prob": 0.65
|
"prob": 0.8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": {
|
"value": {
|
||||||
"w": 1600,
|
"w": 2560,
|
||||||
"h": 900,
|
"h": 1440,
|
||||||
"aw": 1600,
|
"aw": 2560,
|
||||||
"ah": 860,
|
"ah": 1400,
|
||||||
|
"dpr": 1.0
|
||||||
|
},
|
||||||
|
"prob": 0.15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"w": 1920,
|
||||||
|
"h": 1200,
|
||||||
|
"aw": 1920,
|
||||||
|
"ah": 1160,
|
||||||
"dpr": 1.0
|
"dpr": 1.0
|
||||||
},
|
},
|
||||||
"prob": 0.05
|
"prob": 0.05
|
||||||
|
|||||||
@@ -48,29 +48,21 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 500000,
|
"value": 500000,
|
||||||
"prob": 0.3
|
"prob": 0.35
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": 1000000,
|
|
||||||
"prob": 0.05
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"[\"integrated_modern\", \"budget\"]": [
|
"[\"integrated_modern\", \"budget\"]": [
|
||||||
{
|
|
||||||
"value": 64000,
|
|
||||||
"prob": 0.2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": 128000,
|
|
||||||
"prob": 0.3
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": 256000,
|
"value": 256000,
|
||||||
"prob": 0.3
|
"prob": 0.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 500000,
|
"value": 500000,
|
||||||
"prob": 0.2
|
"prob": 0.45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1000000,
|
||||||
|
"prob": 0.25
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"[\"integrated_modern\", \"standard\"]": [
|
"[\"integrated_modern\", \"standard\"]": [
|
||||||
|
|||||||
@@ -178,7 +178,11 @@ def _apply_pins_to_raw(raw: Dict[str, Any], pin: Dict[str, Any]) -> Dict[str, An
|
|||||||
return out
|
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.
|
"""Return a deterministic Profile for the given integer seed.
|
||||||
|
|
||||||
pin: optional dict of dotted-path keys (e.g. "screen.width", "gpu.renderer")
|
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:
|
for key in pin:
|
||||||
_validate_pin_key(key)
|
_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:
|
if pin:
|
||||||
raw = _apply_pins_to_raw(raw, pin)
|
raw = _apply_pins_to_raw(raw, pin)
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -9,6 +9,7 @@ from typing import Any, Dict, Optional, Union
|
|||||||
from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright
|
from playwright.async_api import Browser, BrowserContext, Playwright, async_playwright
|
||||||
|
|
||||||
from ._fpforge import Profile, generate_profile
|
from ._fpforge import Profile, generate_profile
|
||||||
|
from ._webgl_personas import forced_gpu_class
|
||||||
from ._geo import prepare_session_geo
|
from ._geo import prepare_session_geo
|
||||||
from ._headless import cloak_prefs, make_virtual_display
|
from ._headless import cloak_prefs, make_virtual_display
|
||||||
from ._proxy import configure_proxy as _configure_proxy_shared
|
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
|
self._profile_dir: Optional[Path] = Path(profile_dir) if profile_dir else None
|
||||||
# reCAPTCHA pre-seed gated server-side; respect persistent profile.
|
# reCAPTCHA pre-seed gated server-side; respect persistent profile.
|
||||||
self._prep_recaptcha = bool(prep_recaptcha) and self._profile_dir is None
|
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._pw: Optional[Playwright] = None
|
||||||
self._browser: Optional[Browser] = None
|
self._browser: Optional[Browser] = None
|
||||||
self._persistent_context: Optional[BrowserContext] = None
|
self._persistent_context: Optional[BrowserContext] = None
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import secrets
|
|||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
from ._fpforge import generate_profile
|
from ._fpforge import generate_profile
|
||||||
|
from ._webgl_personas import forced_gpu_class
|
||||||
from .prefs import translate_profile_to_prefs
|
from .prefs import translate_profile_to_prefs
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ def get_default_stealth_prefs(
|
|||||||
``playwright.firefox.launch()`` or ``launch_persistent_context()``.
|
``playwright.firefox.launch()`` or ``launch_persistent_context()``.
|
||||||
"""
|
"""
|
||||||
resolved_seed = int(seed) if seed is not None else secrets.randbits(31)
|
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(
|
prefs = translate_profile_to_prefs(
|
||||||
profile,
|
profile,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Union
|
|||||||
from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright
|
from playwright.sync_api import Browser, BrowserContext, Playwright, sync_playwright
|
||||||
|
|
||||||
from ._fpforge import Profile, generate_profile
|
from ._fpforge import Profile, generate_profile
|
||||||
|
from ._webgl_personas import forced_gpu_class
|
||||||
from ._geo import prepare_session_geo
|
from ._geo import prepare_session_geo
|
||||||
from ._headless import cloak_prefs, make_virtual_display
|
from ._headless import cloak_prefs, make_virtual_display
|
||||||
from ._proxy import configure_proxy as _configure_proxy_shared
|
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
|
# persistent profile_dir is in use, respect its existing cookies
|
||||||
# and DON'T enable pre-seed (the profile owns its own state).
|
# 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._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._pw: Optional[Playwright] = None
|
||||||
self._browser: Optional[Browser] = None
|
self._browser: Optional[Browser] = None
|
||||||
self._persistent_context: Optional[BrowserContext] = None
|
self._persistent_context: Optional[BrowserContext] = None
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import sys
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from ._fpforge import Profile
|
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:
|
def _font_metrics_for_platform(profile_metrics: str) -> str:
|
||||||
"""Return ``zoom.stealth.font.metrics`` value.
|
"""Return ``zoom.stealth.font.metrics`` value.
|
||||||
|
|
||||||
Windows: empty string. The C++ width-scale hook is a no-op and
|
The C++ whitelist hook (``gfxPlatformFontList::FindAndAddFamiliesLocked``)
|
||||||
Firefox renders Arial/Segoe/Calibri/etc. at their native canonical
|
backs EVERY whitelisted *named* family with the list-head family on every
|
||||||
widths. Applying the Bayesian-sampled per-font factors on a Windows
|
platform. Without per-font width factors, that means each named font
|
||||||
build would *distort* real metrics and surface as a font_preferences
|
(Arial, Times New Roman, Courier New, …) renders with identical glyphs and
|
||||||
width anomaly to FP Pro / reCAPTCHA.
|
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 /
|
These factors only key *named* families. CSS generics
|
||||||
Liberation render at the widths Windows JS expects, then append the
|
(serif/sans-serif/monospace/system-ui) bypass the whitelist entirely and
|
||||||
per-font factors that make each fabricated family detectable by
|
render at the host's native widths, so they are never present in
|
||||||
width-diff probes.
|
``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:
|
if not profile_metrics:
|
||||||
return ""
|
return ""
|
||||||
if sys.platform.startswith("linux"):
|
if sys.platform.startswith("linux"):
|
||||||
return _LINUX_GENERIC_FONT_FACTORS + profile_metrics
|
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(
|
def translate_profile_to_prefs(
|
||||||
@@ -490,21 +510,32 @@ def translate_profile_to_prefs(
|
|||||||
# GPU / WebGL renderer/vendor.
|
# GPU / WebGL renderer/vendor.
|
||||||
# On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer)
|
# On Linux we spoof to a Windows ANGLE renderer string (profile.gpu.renderer)
|
||||||
# so cross-platform sessions report a consistent Windows GPU identity.
|
# so cross-platform sessions report a consistent Windows GPU identity.
|
||||||
# On Windows, spoofing a different GPU creates a renderer/parameters hash
|
# On Windows/mac, spoofing a renderer string ALONE is unsafe — the ~81
|
||||||
# mismatch: FP Pro hashes all 81 CN-set getParameter() values including
|
# getParameter values stay real, so a name↔params hash mismatch FP Pro flags
|
||||||
# enum 7937 (RENDERER). Setting GTX 980 while ANGLE returns Intel Arc A750
|
# (setting GTX 980 over real Arc A750 params scored ~0.70). Instead we apply a
|
||||||
# parameters produces an OOD (hash 23d0a74b vs vanilla 66544db) that FP Pro
|
# VALIDATED PERSONA (see _webgl_personas): a {renderer, vendor} whose params are
|
||||||
# ML scores at ~0.70 (confirmed: direct SF146 vs vanilla on same machine).
|
# the shared ANGLE D3D11 caps (vendor-independent — identical on any host, per the
|
||||||
# Fix: leave renderer/vendor empty on Windows → ANGLE reports native hardware
|
# ANGLE source) and whose extension list is FORCED below. That is a coherent fake
|
||||||
# (SanitizeRenderer path at ClientWebGLContext.cpp:2592-2595) → consistent.
|
# 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"):
|
if sys.platform.startswith("linux"):
|
||||||
prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer
|
prefs["zoom.stealth.webgl.renderer"] = profile.gpu.renderer
|
||||||
prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor
|
prefs["zoom.stealth.webgl.vendor"] = profile.gpu.vendor
|
||||||
_renderer_lo = (profile.gpu.renderer or "").lower()
|
_renderer_lo = (profile.gpu.renderer or "").lower()
|
||||||
else:
|
else:
|
||||||
prefs["zoom.stealth.webgl.renderer"] = ""
|
_persona = select_persona(profile.seed)
|
||||||
prefs["zoom.stealth.webgl.vendor"] = ""
|
if _persona:
|
||||||
_renderer_lo = "intel" # test hardware is Intel Arc A750
|
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
|
# 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
|
# 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["zoom.stealth.screen.dpr"] = profile.screen.dpr
|
||||||
prefs["layout.css.devPixelsPerPx"] = str(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.hw_concurrency"] = profile.hardware.concurrency
|
||||||
prefs["zoom.stealth.storage.quota_mb"] = profile.hardware.storage_quota_mb
|
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
|
# 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
|
# 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
|
# alias was never declared in the yaml and read by nothing — dropped
|
||||||
# 2026-06-10.
|
# 2026-06-10. The render-noise seed is DECOUPLED from the identity seed and
|
||||||
prefs["zoom.stealth.fpp.hw_seed"] = profile.seed
|
# 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
|
# Synthetic host ICE candidate — injected by C++ when addr_ct==0 (SOCKS5
|
||||||
# proxy suppresses all local addresses so Firefox can't gather host cands).
|
# 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}"
|
_lan_ip = f"192.168.{(_s >> 8) % 254 + 1}.{_s % 254 + 1}"
|
||||||
prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip
|
prefs["zoom.stealth.webrtc.host_ip"] = _lan_ip
|
||||||
|
|
||||||
# On Windows, native ANGLE extension list already matches real Windows users.
|
# Windows/mac extension list:
|
||||||
# The baseline hard-codes a curated _WEBGL1/2_EXTENSIONS list designed for
|
# - persona active → FORCE the validated extension list. A non-Intel host's native
|
||||||
# Linux Mesa → clear it so Windows sessions report the native extension set
|
# extensions would mismatch the persona's renderer (renderer says AMD/Intel-Arc but
|
||||||
# (hash matches real Intel Arc A750 vanilla captures).
|
# 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"):
|
if not sys.platform.startswith("linux"):
|
||||||
prefs["zoom.stealth.webgl.extensions"] = ""
|
if _persona:
|
||||||
prefs["zoom.stealth.webgl2.extensions"] = ""
|
# 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).
|
# Linux Xvfb workarounds (no-op on Windows).
|
||||||
if sys.platform.startswith("linux"):
|
if sys.platform.startswith("linux"):
|
||||||
|
|||||||
@@ -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["security.sandbox.gpu.level"] == 0 # virtual_display branch
|
||||||
assert prefs["network.proxy.type"] == 1 # SOCKS branch
|
assert prefs["network.proxy.type"] == 1 # SOCKS branch
|
||||||
assert prefs["network.proxy.socks"] == "127.0.0.1"
|
assert prefs["network.proxy.socks"] == "127.0.0.1"
|
||||||
# Windows still has the renderer cleared.
|
# Windows exposes a validated persona renderer (calibrated clean bucket),
|
||||||
assert prefs["zoom.stealth.webgl.renderer"] == ""
|
# not empty/native — see _webgl_personas.
|
||||||
|
assert prefs["zoom.stealth.webgl.renderer"].startswith("ANGLE (")
|
||||||
|
assert prefs["zoom.stealth.webgl.renderer"].rstrip().endswith(", D3D11)")
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+29
-14
@@ -15,12 +15,18 @@ from invisible_playwright.prefs import (
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_translate_includes_gpu_renderer_windows(monkeypatch):
|
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")
|
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)
|
p = generate_profile(seed=42)
|
||||||
prefs = translate_profile_to_prefs(p)
|
prefs = translate_profile_to_prefs(p)
|
||||||
assert prefs["zoom.stealth.webgl.renderer"] == ""
|
assert prefs["zoom.stealth.webgl.renderer"] in _CLEAN
|
||||||
assert prefs["zoom.stealth.webgl.vendor"] == ""
|
assert prefs["zoom.stealth.webgl.vendor"] in {"Google Inc. (AMD)", "Google Inc. (Intel)"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -82,10 +88,15 @@ def test_accept_language_underscore_normalized():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_font_metrics_windows_returns_empty(monkeypatch):
|
def test_font_metrics_windows_applies_named_factors(monkeypatch):
|
||||||
# FM2: Windows never applies width-scale factors.
|
# 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")
|
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
|
@pytest.mark.unit
|
||||||
@@ -100,13 +111,14 @@ def test_font_metrics_empty_input_returns_empty():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_gpu_renderer_empty_on_windows(monkeypatch):
|
def test_gpu_renderer_persona_on_windows(monkeypatch):
|
||||||
# PG2
|
# PG2: Windows exposes a validated persona renderer (well-formed ANGLE bucket, NOT empty/native).
|
||||||
monkeypatch.setattr(sys, "platform", "win32")
|
monkeypatch.setattr(sys, "platform", "win32")
|
||||||
p = generate_profile(seed=42)
|
p = generate_profile(seed=42)
|
||||||
prefs = translate_profile_to_prefs(p)
|
prefs = translate_profile_to_prefs(p)
|
||||||
assert prefs["zoom.stealth.webgl.renderer"] == ""
|
r = prefs["zoom.stealth.webgl.renderer"]
|
||||||
assert prefs["zoom.stealth.webgl.vendor"] == ""
|
assert r and r.startswith("ANGLE (") and r.rstrip().endswith(", D3D11)")
|
||||||
|
assert prefs["zoom.stealth.webgl.vendor"].startswith("Google Inc. (")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -143,13 +155,16 @@ def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_webgl_extensions_cleared_on_windows(monkeypatch):
|
def test_webgl_extensions_persona_on_windows(monkeypatch):
|
||||||
# WE2
|
# 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")
|
monkeypatch.setattr(sys, "platform", "win32")
|
||||||
|
from invisible_playwright._webgl_personas import select_persona
|
||||||
p = generate_profile(seed=42)
|
p = generate_profile(seed=42)
|
||||||
prefs = translate_profile_to_prefs(p)
|
prefs = translate_profile_to_prefs(p)
|
||||||
assert prefs["zoom.stealth.webgl.extensions"] == ""
|
persona = select_persona(42)
|
||||||
assert prefs["zoom.stealth.webgl2.extensions"] == ""
|
assert prefs["zoom.stealth.webgl.extensions"] == persona["ext1"]
|
||||||
|
assert prefs["zoom.stealth.webgl2.extensions"] == persona["ext2"]
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+17
-2
@@ -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) HD Graphics 530 Direct3D11)",
|
||||||
"ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11)",
|
"ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11)",
|
||||||
"ANGLE (Intel, Intel(R) Iris Xe Graphics 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):
|
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"
|
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.unit
|
||||||
@pytest.mark.parametrize("renderer", [
|
@pytest.mark.parametrize("renderer", [
|
||||||
"ANGLE (AMD, AMD Radeon Graphics Direct3D11)",
|
"ANGLE (AMD, AMD Radeon Graphics Direct3D11)",
|
||||||
|
|||||||
Reference in New Issue
Block a user