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>
This commit is contained in:
Jake Writer
2026-09-25 15:22:28 -06:00
co-authored by Claude Opus 5.5
parent 6a22248c0b
commit a3dbe40d1a
17 changed files with 116 additions and 129 deletions
+1 -1
View File
@@ -196,7 +196,7 @@ async def AsyncNewContext(
Creates a new browser context with a unique fingerprint identity.
Each context gets its own real fingerprint preset (navigator, screen, WebGL, fonts, etc.)
with unique seeds for audio, canvas, and font spacing noise. All values are applied
with its own audio noise seed. All values are applied
via addInitScript so they self-destruct before page scripts can detect them.
Parameters:
+1 -5
View File
@@ -1644,12 +1644,11 @@ def from_preset(preset: Dict, ff_version: Optional[str] = None, salt: Optional[i
if webgl.get('unmaskedRenderer'):
config['webGl:renderer'] = webgl['unmaskedRenderer']
# Generate unique random seeds per launch (1 to 2^32-1, excluding 0 which is a no-op in C++)
# Generate a unique audio seed per launch (1 to 2^32-1, excluding 0 which is a no-op in C++)
# fonts:spacing_seed stays 0 (off): glyph-advance perturbation produces text
# widths no real machine emits (see launch_options in utils.py).
config['fonts:spacing_seed'] = 0
config['audio:seed'] = randint(1, 4_294_967_295) # nosec
config['canvas:seed'] = randint(1, 4_294_967_295) # nosec
if preset.get('timezone'):
config['timezone'] = preset['timezone']
@@ -1700,7 +1699,6 @@ def _build_init_script(values: Dict[str, Any]) -> str:
setters = [
('fontSpacingSeed', 'setFontSpacingSeed', '{val}'),
('audioFingerprintSeed', 'setAudioFingerprintSeed', '{val}'),
('canvasSeed', 'setCanvasSeed', '{val}'),
('navigatorPlatform', 'setNavigatorPlatform', '{val}'),
('navigatorOscpu', 'setNavigatorOscpu', '{val}'),
('navigatorUserAgent', 'setNavigatorUserAgent', '{val}'),
@@ -1820,7 +1818,6 @@ def generate_context_fingerprint(
# Add seeds (the generator doesn't produce these)
config.setdefault('fonts:spacing_seed', 0) # perturbation off; see utils.launch_options
config.setdefault('audio:seed', randint(1, 4_294_967_295)) # nosec
config.setdefault('canvas:seed', randint(1, 4_294_967_295)) # nosec
# Determine target OS from platform for font/voice generation
plat = config.get('navigator.platform', '')
@@ -1917,7 +1914,6 @@ def generate_context_fingerprint(
init_values: Dict[str, Any] = {
'fontSpacingSeed': config.get('fonts:spacing_seed'),
'audioFingerprintSeed': config.get('audio:seed'),
'canvasSeed': config.get('canvas:seed'),
'navigatorPlatform': nav.get('platform'),
'navigatorOscpu': config.get('navigator.oscpu'),
'navigatorUserAgent': config.get('navigator.userAgent'),
-3
View File
@@ -18,13 +18,10 @@ navigator:
# fpgen's UAs trail the current release; the version is rewritten to the
# Camoufox Firefox version in _cast_to_properties.
userAgent: navigator.userAgent
appCodeName: navigator.appCodeName
appName: navigator.appName
appVersion: navigator.appVersion
oscpu: navigator.oscpu
platform: navigator.platform
hardwareConcurrency: navigator.hardwareConcurrency
product: navigator.product
# Never override productSub (#105)
# deviceMemory is not in Firefox, and fpgen reports the string "undefined"
# Locale is handled separately (locale:*)
+1 -1
View File
@@ -174,7 +174,7 @@ def NewContext(
Creates a new browser context with a unique fingerprint identity.
Each context gets its own real fingerprint preset
with unique seeds for audio, canvas, and font spacing noise. All values are applied
with its own audio noise seed. All values are applied
via addInitScript so they self-destruct before page scripts can detect them.
Parameters:
+8 -10
View File
@@ -994,7 +994,7 @@ def launch_options(
_user_set_dnt = 'navigator.doNotTrack' in config
_user_set_gpc = 'navigator.globalPrivacyControl' in config
_user_set_accept_encoding = 'headers.Accept-Encoding' in config
_user_set_noise_seeds = {k for k in ('audio:seed', 'canvas:seed') if k in config}
_user_set_audio_seed = 'audio:seed' in config
# The salt that makes every seeded draw belong to this identity (see
# fingerprints.identity_salt): stable when the caller pinned the identity
@@ -1274,16 +1274,14 @@ def launch_options(
# on every measureText), which is a fingerprint no stock Firefox emits.
# Pass fonts:spacing_seed explicitly to opt back in.
set_into(config, 'fonts:spacing_seed', 0)
# audio/canvas noise seeds follow the identity: a returning "same device"
# must reproduce its audio and canvas hashes (#442/#765). Derived, not
# equal, so the two streams differ; never 0 (0 disables the noise).
# A preset draws its own random seeds; they are replaced here too so a
# pinned preset reproduces them, but a seed the caller set is kept.
_ident = identity_seed(config, _identity_salt)
if 'audio:seed' not in _user_set_noise_seeds:
# The audio noise seed follows the identity: a returning "same device" must
# reproduce its audio hash (#442/#765). Never 0 (0 disables the noise). A
# preset draws its own random seed; it is replaced here too so a pinned
# preset reproduces it, but a seed the caller set is kept. There is no
# canvas seed: the browser adds no canvas noise (#528).
if not _user_set_audio_seed:
_ident = identity_seed(config, _identity_salt)
config['audio:seed'] = ((_ident * 2654435761 + 97) & 0xFFFFFFFF) or 1
if 'canvas:seed' not in _user_set_noise_seeds:
config['canvas:seed'] = ((_ident * 40503 + 12345) & 0xFFFFFFFF) or 1
# Set geolocation
if geoip:
+40
View File
@@ -38,6 +38,18 @@ MASKCONFIG_READ = re.compile(r'MaskConfig::(?:Get|Has)\w*\(\s*"([^"]+)"')
# Add here (with a reason) only when the read genuinely cannot name its key.
ALLOWED_UNDECLARED: set = set()
# Declared keys the browser never reads, each with the reason it is declared
# anyway. Everything else in properties.json must be read by a patch or by
# Juggler: a key nothing reads does nothing, silently.
NOT_READ_BY_THE_BROWSER = {
"locale:script": "the launcher joins it with locale:language/region into the UI locale",
"navigator.doNotTrack": "the launcher applies it as privacy.donottrackheader.enabled (#760)",
"navigator.buildID": "declared ahead of the patch that reads it (#780)",
}
# How Juggler and the patches name a key: a quoted string literal.
QUOTED = '"{key}"', "'{key}'"
def _sources():
for pattern in ("patches/**/*.patch", "additions/**/*"):
@@ -97,6 +109,34 @@ def test_every_key_the_browser_reads_is_declared():
pytest.fail("\n".join(lines))
def test_every_declared_key_is_read_by_the_browser():
"""The other direction: canvas:seed (#721) was declared, generated by both
launchers and sent on every launch for three releases after the patch that
read it was removed (#528)."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
unread = sorted(
key
for key in _declared_keys()
if key not in NOT_READ_BY_THE_BROWSER
and not any(form.format(key=key) in text for form in QUOTED)
)
assert not unread, (
"settings/properties.json declares keys that no patch or Juggler file "
f"reads, so setting them does nothing: {unread}. Remove them, or add them "
"to NOT_READ_BY_THE_BROWSER with the reason they are declared."
)
def test_keys_not_read_by_the_browser_are_really_unread():
"""An exemption must lapse once the browser starts reading the key."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
now_read = [
key for key in NOT_READ_BY_THE_BROWSER
if any(form.format(key=key) in text for form in QUOTED)
]
assert not now_read, f"remove from NOT_READ_BY_THE_BROWSER, the browser reads them now: {now_read}"
@pytest.mark.parametrize("key", ["media:spoof_codecs"])
def test_known_previously_missing_keys_stay_declared(key):
"""Pin the specific keys this guard was written for, so a schema edit that
+14 -6
View File
@@ -42,7 +42,7 @@ def launch(**kwargs):
return config_of(utils.launch_options(**kwargs))
DRAWN = ("canvas:seed", "audio:seed", "fonts", "voices", "webGl:renderer")
DRAWN = ("audio:seed", "fonts", "voices", "webGl:renderer")
def drawn(config):
@@ -51,7 +51,7 @@ def drawn(config):
class TestUnpinnedLaunchesAreDistinct:
def test_noise_seeds_do_not_collide(self):
seeds = [launch()["canvas:seed"] for _ in range(40)]
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)
@@ -77,7 +77,7 @@ class TestPinnedIdentityIsStable:
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["audio:seed"] == second["audio:seed"]
assert first["fonts"] == second["fonts"]
@pytest.mark.parametrize("os_name", ["windows", "macos", "linux"])
@@ -101,9 +101,17 @@ class TestPinnedIdentityIsStable:
assert config.get("webGl:parameters"), (os_name, i)
sample_webgl(key, config["webGl:vendor"], config["webGl:renderer"])
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_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")