Files
camoufox/pythonlib/tests/test_identity_salt.py
T
Jake WriterandClaude Opus 5.5 a3dbe40d1a fix: stop generating a canvas seed, and drop config keys nothing reads
The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:22:28 -06:00

181 lines
7.5 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 = ("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()["audio: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["audio:seed"] == second["audio:seed"]
assert first["fonts"] == second["fonts"]
@pytest.mark.parametrize("os_name", ["windows", "macos", "linux"])
def test_every_bundled_preset_launches(self, os_name):
# The test above draws ONE preset at random, so a preset that cannot
# launch shows up as a 1-in-11 flake rather than a failure -- which is
# how it reached CI. 39 of the 435 bundled presets name a GPU that is
# not among the 33 in webgl_data.db, and sample_webgl raises for those.
# Every preset has to produce launch options; see the fallback in
# utils.launch_options.
from camoufox.webgl import sample_webgl
presets = fp.load_presets("150")["presets"][os_name]
key = {"windows": "win", "macos": "mac", "linux": "lin"}[os_name]
for i, preset in enumerate(presets):
config = launch(os=os_name, fingerprint_preset=preset)
# Whatever GPU survives, the renderer the page reads and the
# parameters behind it must come from the SAME recorded device --
# merge_into does not overwrite, so a fallback that forgets to drop
# the preset's pair leaves one device's name on another's data.
assert config.get("webGl:parameters"), (os_name, i)
sample_webgl(key, config["webGl:vendor"], config["webGl:renderer"])
def test_caller_seed_is_kept(self):
assert launch(config={"audio:seed": 9})["audio:seed"] == 9
def test_no_canvas_seed_is_generated():
"""The browser adds no canvas noise (#528), and no patch reads canvas:seed
(#721). Generating one only sent the browser a value it ignored."""
assert "canvas:seed" not in launch()
context = fp.generate_context_fingerprint(os="linux")
assert "canvas:seed" not in context["config"]
assert "setCanvasSeed" not in context["init_script"]
def test_salt_of_equal_objects_is_equal(self):
a = fp.generate_fingerprint(os="windows")
assert fp.identity_salt(a) == fp.identity_salt(a)
assert fp.identity_salt({"a": 1, "b": 2}) == fp.identity_salt({"b": 2, "a": 1})
class TestVoicesFollowLocale:
def test_windows_fr_identity_has_french_voices(self, monkeypatch):
for _ in range(5):
config = launch(os="windows", locale="fr-FR")
langs = {v["lang"] for v in config["voices"]}
assert "fr-FR" in langs, langs
class TestCoreCountFloor:
def test_small_pinnable_host_reports_table_floor(self, monkeypatch):
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
for host_cores in (1, 2, 3):
monkeypatch.setattr(fp, "host_cpu_count", lambda n=host_cores: n)
for drawn_cores in (1, 2, 3, 8):
c = {"navigator.hardwareConcurrency": drawn_cores}
fp.fix_hardware_concurrency(c)
assert c["navigator.hardwareConcurrency"] == 4, (host_cores, drawn_cores)
def test_unpinned_launch_reports_host(self, monkeypatch):
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
c = {"navigator.hardwareConcurrency": 8}
fp.fix_hardware_concurrency(c, can_pin=False)
assert c["navigator.hardwareConcurrency"] == 16
def test_recorded_counts_are_in_the_table(self):
for n in (18, 22, 28, 32):
assert n in fp.PLAUSIBLE_CORE_COUNTS
class TestAffinityPick:
def test_adjacent_cores_from_a_random_start(self):
cores = list(range(16))
starts = set()
for _ in range(200):
picked = cpu_affinity._pick(cores, 4)
assert len(picked) == 4 and set(picked) <= set(cores)
ring = sorted(picked)
# adjacent modulo 16
assert any(all((s + i) % 16 in picked for i in range(4)) for s in ring)
starts.add(tuple(picked))
assert len(starts) > 4
class TestPrefsEnvIsAscii:
def test_non_ascii_pref_round_trips(self):
prefs = {"font.name.serif.ja": "游明朝", "intl.accept_languages": "fr-FR, fr"}
env = utils.get_pref_env_vars(prefs)
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
assert joined.isascii()
assert orjson.loads(joined) == prefs
@pytest.mark.parametrize("off", [None, False])
def test_fingerprint_preset_off_never_draws_a_preset(off):
"""`fingerprint_preset=False` means off, the same as None. It used to be
checked with `is not None`, so False drew a random bundled preset."""
with mock.patch.object(utils, "get_random_preset", side_effect=AssertionError("preset drawn")):
launch(fingerprint_preset=off)