Files
camoufox/pythonlib/tests/test_identity_salt.py
T
Jake WriterandClaude Opus 5 fd501e5d01 fix(pythonlib): stop overriding the corpus on core counts; pinning is opt-in
Two findings from auditing the sweep's fixes against one bar: a difference is a
leak only if a page's JavaScript can actually read it.

hardwareConcurrency 2 was excluded from PLAUSIBLE_CORE_COUNTS because "2 is what
Firefox reports under resistFingerprinting". That has not been true for years:
RuntimeService::ClampedHardwareConcurrency hardcodes 4, and 8 on macOS, both of
which are already in the table. The exclusion protected against nothing and cost
every genuinely dual-core machine -- 20% of the macOS presets in the recorded
corpus, 4.2% of Linux draws. It also made the small-host tail worse: a 3-core
host reported 4, which cannot be pinned, so a page measured 3 while being told 4.
At 2 the pin succeeds.

pin_cpu_cores now defaults to False. What it buys is defence against a page
timing N parallel workers; what it costs is a browser-wide CPU cap, a per-driver
launch lock, and nothing at all on macOS. Unpinned, the host's own snapped count
is reported, so reported and measurable still agree -- the identity just loses
one drawn value. Callers who want the draw kept can still ask for it.

The WebGL sampler keeps rejecting software rasterisers, and its docstring now
says so: it described the opposite of what the code does. llvmpipe as the
presented GPU is a live check on a string every fingerprint script reads, which
is worth ~1.5% of corpus fidelity.

251 pythonlib tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:01:51 -06:00

149 lines
5.9 KiB
Python

"""Per-identity draws: unrelated launches must not share them, a pinned identity must.
identity_seed() used to hash only the UA, platform, screen size and core count.
Those take a handful of values per OS, so over 500 launches the seed took 12-30
distinct values, and every install drew its fonts, voices, GPU, media devices and
canvas/audio noise seeds from that same short list.
"""
from contextlib import contextmanager
from unittest import mock
import orjson
import pytest
from camoufox import cpu_affinity, utils
from camoufox import fingerprints as fp
@contextmanager
def host():
with mock.patch.object(utils, "get_screen_cons", lambda headless: None), (
mock.patch.object(utils, "has_display", lambda env: False)
), mock.patch.object(utils, "installed_verstr", lambda: "150.0.2"), (
mock.patch.object(utils, "launch_path", lambda **kwargs: "/nonexistent/camoufox")
):
yield
def config_of(options):
env = options["env"]
chunks = sorted(
(int(k.rsplit("_", 1)[1]), v) for k, v in env.items() if k.startswith("CAMOU_CONFIG_")
)
return orjson.loads("".join(chunk for _, chunk in chunks))
def launch(**kwargs):
kwargs.setdefault("os", "linux")
kwargs.setdefault("headless", True)
kwargs.setdefault("i_know_what_im_doing", True)
with host():
return config_of(utils.launch_options(**kwargs))
DRAWN = ("canvas:seed", "audio:seed", "fonts", "voices", "webGl:renderer")
def drawn(config):
return {k: orjson.dumps(config.get(k)) for k in DRAWN}
class TestUnpinnedLaunchesAreDistinct:
def test_noise_seeds_do_not_collide(self):
seeds = [launch()["canvas:seed"] for _ in range(40)]
# 40 draws from 2**32: any collision means the seed space collapsed.
assert len(set(seeds)) == len(seeds)
def test_same_presented_values_still_differ(self):
# The same UA/platform/screen/cores, i.e. what two users on the same
# common machine present, must not yield the same noise seeds.
config = {"navigator.userAgent": "x", "navigator.platform": "Win32",
"screen.width": 1920, "screen.height": 1080, "navigator.hardwareConcurrency": 8}
seeds = {fp.identity_seed(config, fp.identity_salt()) for _ in range(200)}
assert len(seeds) == 200
class TestPinnedIdentityIsStable:
def test_fixed_fingerprint_reproduces_every_draw(self):
fingerprint = fp.generate_fingerprint(os="linux")
first = launch(fingerprint=fingerprint)
second = launch(fingerprint=fingerprint)
assert drawn(first) == drawn(second)
def test_fixed_preset_reproduces_noise_seeds(self):
preset = fp.get_random_preset(os="windows", ff_version="150")
if not preset:
pytest.skip("no presets bundled")
first = launch(os="windows", fingerprint_preset=preset)
second = launch(os="windows", fingerprint_preset=preset)
assert (first["canvas:seed"], first["audio:seed"]) == (second["canvas:seed"], second["audio:seed"])
assert first["fonts"] == second["fonts"]
def test_caller_seeds_are_kept(self):
config = launch(config={"canvas:seed": 7, "audio:seed": 9})
assert (config["canvas:seed"], config["audio:seed"]) == (7, 9)
def test_salt_of_equal_objects_is_equal(self):
a = fp.generate_fingerprint(os="windows")
assert fp.identity_salt(a) == fp.identity_salt(a)
assert fp.identity_salt({"a": 1, "b": 2}) == fp.identity_salt({"b": 2, "a": 1})
class TestVoicesFollowLocale:
def test_windows_fr_identity_has_french_voices(self, monkeypatch):
for _ in range(5):
config = launch(os="windows", locale="fr-FR")
langs = {v["lang"] for v in config["voices"]}
assert "fr-FR" in langs, langs
class TestCoreCountFloor:
def test_small_pinnable_host_reports_table_floor(self, monkeypatch):
# A 1-3 core host reports 2, the table's floor and the lowest count the
# corpus records. It used to report 4, which no 2-core machine can back
# up: 4 cannot be pinned on a 3-core host, so the page measured 3 while
# being told 4. At 2 the pin succeeds on a 2- or 3-core host, and the
# 1-core tail (told 2, measures 1) is closer than 4 was.
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
for host_cores in (1, 2, 3):
monkeypatch.setattr(fp, "host_cpu_count", lambda n=host_cores: n)
for drawn_cores in (1, 2, 3, 8):
c = {"navigator.hardwareConcurrency": drawn_cores}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == 2, (host_cores, drawn_cores)
def test_unpinned_launch_reports_host(self, monkeypatch):
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
c = {"navigator.hardwareConcurrency": 8}
fp.fix_hardware_concurrency(c, can_pin=False)
assert c["navigator.hardwareConcurrency"] == 16
def test_recorded_counts_are_in_the_table(self):
for n in (18, 22, 28, 32):
assert n in fp.PLAUSIBLE_CORE_COUNTS
class TestAffinityPick:
def test_adjacent_cores_from_a_random_start(self):
cores = list(range(16))
starts = set()
for _ in range(200):
picked = cpu_affinity._pick(cores, 4)
assert len(picked) == 4 and set(picked) <= set(cores)
ring = sorted(picked)
# adjacent modulo 16
assert any(all((s + i) % 16 in picked for i in range(4)) for s in ring)
starts.add(tuple(picked))
assert len(starts) > 4
class TestPrefsEnvIsAscii:
def test_non_ascii_pref_round_trips(self):
prefs = {"font.name.serif.ja": "游明朝", "intl.accept_languages": "fr-FR, fr"}
env = utils.get_pref_env_vars(prefs)
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
assert joined.isascii()
assert orjson.loads(joined) == prefs