mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-06 00:00:33 +00:00
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>
149 lines
5.9 KiB
Python
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
|