Skip to content

Anti-fingerprint: measured baseline, harness, T3 patch + CI#39

Open
mdheller wants to merge 61 commits into
mainfrom
anti-fingerprint-verify
Open

Anti-fingerprint: measured baseline, harness, T3 patch + CI#39
mdheller wants to merge 61 commits into
mainfrom
anti-fingerprint-verify

Conversation

@mdheller

Copy link
Copy Markdown
Contributor

Kicks off the anti-fingerprint CI (.github/workflows/anti-fingerprint.yml).

What runs on this PR

  • Tier 1 — harness + measure: config harness (hard gate) + empirical fingerprint measurement on real Gecko (Playwright Firefox), both profiles, scorecards uploaded as artifacts.
  • Tier 2 — patch-apply: dry-run applies gecko-patches/*.patch against pinned Firefox 150.0.1 source; fails on any reject.
  • Tier 3 (full build) is workflow_dispatch-only — needs a large/self-hosted runner.

Contents (accumulated main work + this session)

  • Config hardening: RFP audit, Quad9 DoH + ECS/ECH/HTTPS-RR, QUIC/TLS 0-RTT, anti-MITM, cross-profile cohort — all behind a self-enforcing harness.
  • T0 measurement: measure-fingerprint.mjs (75% baseline, every leak named).
  • T3: spec + no-holes implementation runbook + build on-ramp; first real engine patch (anti-fp-canvas-text-metrics.patch) verified to apply+reverse cleanly.

Note: this branch carries the accumulated main history (remote main is behind), so the diff is large — the new work is the anti-fingerprint commits.

🤖 Generated with Claude Code

mdheller added 30 commits June 17, 2026 20:20
Profile hardening:
- agent-runtime: 6 prefs → 285-line comprehensive lockdown (private
  browsing autostart, WebRTC fully off, service workers/push/notifications
  disabled, all sensor APIs off, full dFPI, DoH strict, disk cache zeroed)
- agent-runtime policies.json: 9 → 246 lines with Locked:true on all
  permission surfaces, extension install blocked, SanitizeOnShutdown locked
- human-secure: TLS/OCSP hardening, Fission site isolation, CRLite
  revocation, IDN homograph protection, media fingerprinting resistance
- human-secure: uBlock Origin pre-installed via ExtensionSettings,
  enterprise CA import disabled (prevents corporate MITM)

Local PolicyFabric enforcement engine:
- bearbrowser-policy-engine.py: reads local-default-actions.yaml and
  enforces allow/hold/deny with correct exit codes (0/2/3). Zero external
  deps. 21/21 verifier tests passing.
- bearbrowser-verify-policy-engine.py: full test suite

Governed automation sessions:
- bearbrowser-playwright.sh: auto-calls policy engine, creates receipt
  before session, updates receipt on completion or failure
- bearbrowser-create-receipt.py + bearbrowser-update-receipt.py:
  full BrowserAutomationReceipt lifecycle

Governance dashboard:
- bearbrowser-sidecar-server.py: /api/receipts, /api/hold-queue,
  /resolve endpoints; dark-mode dashboard with auto-refresh

Binary overlay + source build pipeline:
- bearbrowser-fetch-librewolf-base.sh: Homebrew-verified base fetch
- bearbrowser-overlay-binary.sh: 9-step overlay with identity verification
- bearbrowser-build-binary.sh: --lane overlay|source modes
- verify-macos-app.sh: --skip-signing flag for development builds
- Source build: Makefile-driven Firefox fetch → patch → bootstrap → build
  with BearBrowser MOZ_APP overrides baked in at compile time

CI:
- .github/workflows/policy-engine.yml: 9-step policy engine CI
- feature-plane.yml: updated to include receipt scripts

Homebrew:
- 8 new commands exposed: policy-engine, verify-policy-engine,
  create-receipt, update-receipt, verify-automation-receipt,
  fetch-librewolf-base, overlay-binary, verify-macos-app
Source build:
- bearbrowser-package-source-build.sh: packages obj-*/dist/BearBrowser.app
  from a completed mach build into a settings-injected, ad-hoc-signed
  BearBrowser.app ready for development use. Auto-detects workspace,
  injects user.js and policies.json, writes Info.plist, signs depth-first.

macOS native launcher:
- BearBrowserWebKitLauncher.m: updated launcher implementation
- BearBrowser-start.html: updated start page
- install-macos-app-launcher.sh: updated installer
- repair-macos-app-launcher.sh: updated repair script

Packaging:
- Info.plist.template: completed with URL schemes and document types
- package-linux-rpm.sh: updated RPM packaging
… filter engine

Activates Firefox's built-in ContentClassifierService (already wired into
the network stack via AsyncUrlChannelClassifier) with two curated filter
lists covering ~500 ad network rules and ~300 tracker/telemetry rules.

The service uses adblock-rust 0.12.1 (already vendored) compiled to a Rust
FFI layer — no extension process, no JS overhead, requests blocked at the
C++ network layer before any page code runs.

BearBlockerChild JSWindowActor handles cosmetic filtering: injects a CSS
style element hiding ad placeholder elements (the containers left behind
after network blocking) plus a MutationObserver for SPA route changes.

BearBlockerPolicy provides a PolicyFabric bridge for agent-runtime: every
block event appends a signed record to bearblocker-receipts.jsonl, and
uncertain matches can be held for human approval via consultPolicy().

bearbrowser-patches.py wires everything into the Firefox source tree at
make-dir time: copies filter lists and actor files, patches moz.build DIRS,
FINAL_TARGET_FILES, and DesktopActorRegistry actor registration.
Extends bearbrowser-patches.py with six steps that install BearBlocker
into the Firefox source tree at make-dir time:

1. Copy bearblocker-ads.txt and bearblocker-privacy.txt into browser/bearblocker/
2. Create browser/bearblocker/moz.build (FINAL_TARGET_FILES.bearblocker)
3. Copy JSWindowActor files into browser/actors/
4. Add "bearblocker" to browser/moz.build DIRS
5. Add BearBlocker actor files to browser/actors/moz.build FINAL_TARGET_FILES
6. Register BearBlocker JSWindowActor in DesktopActorRegistry.sys.mjs

Also promotes bearbrowser-patches.py to scripts/ (tracked) so the canonical
patch pipeline is version-controlled outside the gitignored build/ workspace.

The canonical bearbrowser.* StaticPrefList block now includes:
  bearbrowser.bearblocker.cosmetic.enabled (default true)
  bearbrowser.runtime.agent (default false, set by agent-runtime profile)
  bearbrowser.webgl.prompt / bearbrowser.webgl.prompt.hide
Runs on macos-15 (Apple Silicon) at 4 AM UTC daily, on manual dispatch,
and on pushes to main that touch settings, patches, or branding.

Pipeline:
  apply-sourceos-overlays.sh → make fetch → make dir → mach bootstrap
  → mach build → bearbrowser-package-source-build.sh → hdiutil → gh release

Artifacts:
  - GitHub Actions artifact retained 30 days (always)
  - GitHub Release tagged nightly-YYYY-MM-DD (main branch only), prerelease,
    same-day release replaced on re-run

No notarization — ad-hoc signed only. For developer/internal use.
Users open via right-click → Open to bypass Gatekeeper on first launch.

Cache layers: Mozilla toolchain (.mozbuild), Firefox source tarball,
Cargo registry. Estimated wall time ~2.5 h on macos-15.
- BearBrowserPolicyQueue.swift: native macOS NSStatusItem app that polls
  actions.jsonl every 3s for unresolved PolicyFabric hold entries, shows
  badge count (⚖ N), and exposes per-action Allow/Deny + bulk controls via
  menubar dropdown; resolution calls bearbrowser-resolve-action.py
- scripts/build-hold-queue-app.sh: one-shot swiftc build script; --install
  flag copies binary to /usr/local/bin
- settings/profiles/agent-runtime/user.js: sections 5 and 6 added —
  BearBlocker filter lists + runtime identity prefs (bearbrowser.runtime.agent,
  debugger.force_detach, console.logging_disabled)
Three things:

1. bearbrowser-open.sh — auto-launches BearBrowserPolicyQueue on BearBrowser
   start if the binary exists and isn't already running; syncs any accumulated
   BearBlocker receipts into events.jsonl at each launch.

2. bearbrowser-sync-blocker-receipts.py — idempotent bridge that reads new
   lines from bearblocker-receipts.jsonl (cursor-tracked) and emits each
   network_block / cosmetic_applied entry as an automation.observed event into
   the main provenance events pipeline. Callable one-shot or with --poll N.

3. LaunchAgent: ai.socioprophet.bearbrowser.policy-queue.plist + install script
   — installs BearBrowserPolicyQueue as a persistent login item via launchctl
   bootstrap so hold-queue UI is always present, not just when bearbrowser-open
   is used. install-policy-queue-agent.sh builds, installs, and loads in one
   step; --unload tears it down cleanly.
…x CI

Extension lockdown (human-secure):
- policies.json: InstallAddonsPermission.Default → false; ExtensionSettings.*
  installation_mode → blocked; removed uBlock auto-install (BearBlocker native
  replacement makes it redundant); added explicit blocked entries for 1Password
  extension (use desktop app integration) and known-bad extensions
- settings/extensions/registry.json: authoritative registry of 26 evaluated
  extensions with dispositions: native (8), allowlist (6), blocked (9), review (2)
  — rationale documented for every entry

Unseal mechanism:
- scripts/bearbrowser-unseal-extensions.sh: checks registry disposition before
  unsealing (refuses blocked, warns on review/unknown); backs up policies.json
  with timestamp; patches ExtensionSettings to normal_installed; schedules
  auto-reseal background job after N minutes (default 15); --reseal flag for
  immediate restore; --list to enumerate allowlist-tier extensions

Linux CI:
- .github/workflows/nightly-linux.yml: ubuntu-24.04 runner, same cache
  strategy as macOS (mozbuild, tarball, cargo), produces tarball + AppImage
  (non-fatal if appimage script fails), uploads artifacts and appends to the
  same nightly-YYYY-MM-DD release tag created by the macOS job
BearSponsor (native SponsorBlock replacement):
- BearSponsorParent.sys.mjs: fetches segments from sponsor.ajay.app using
  4-char SHA-256 hash prefix — full videoId never leaves the browser
- BearSponsorChild.sys.mjs: MutationObserver on <title> for SPA nav detection,
  500ms poll on video.currentTime, skips on match, shows 2.5s toast
- Categories: sponsor, selfpromo, interaction, intro, outro, preview, filler
- pref: bearbrowser.sponsorblock.enabled (true in human-secure, false in agent)

BearNav (native Vimium replacement):
- BearNavChild.sys.mjs: f=link hints (2-char labels, golden overlay), F=new-tab
  hints, j/k/d/u scroll, gg/G top/bottom, H/L history, r reload, / URL bar
- BearNavParent.sys.mjs: focuses gURLBar on BearNav:FocusUrlBar message
- pref: bearbrowser.nav.keyboard.enabled (true in human-secure, false in agent)

Build wiring (patches.py):
- Step 3 extended to copy from settings/actors/ (BearSponsor, BearNav) in
  addition to settings/bearblocker/ (BearBlocker)
- Step 5 moz.build entry now covers all 7 actor files
- Step 6 DesktopActorRegistry now registers BearBlocker, BearNav, BearSponsor
- StaticPrefList canonical block updated: adds nav.keyboard.enabled and
  sponsorblock.enabled in correct alphabetical position

Extension registry: SponsorBlock and Vimium C moved from allowlist → native

Linux CI: ubuntu/fedora matrix on nightly-linux.yml
- matrix.include with distro-specific pkg_install commands (apt vs dnf)
- Fedora 41 container on ubuntu-24.04 runner
- fail-fast: false so one distro failure doesn't cancel the other
- Both distros upload artifacts and append to same nightly release tag
WebKit 21623+ (macOS 26) requires that the WKWebView returned from
createWebViewWithConfiguration:forNavigationAction:windowFeatures: be
created with the exact cfg argument passed in. Previously we were
creating fresh WKWebViewConfiguration objects (via baseConfig: for real
tabs, via [[WKWebViewConfiguration alloc]init] for decoy/honeypot views),
causing WebKit's SOAuthorizationCoordinator to throw NSException in
createNewPage at the first popup attempt.

Fix: use cfg directly for both paths.
- Decoy (script popup): cfg satisfies WebKit's constraint; we cancel all
  navigations via decidePolicyForNavigationAction without loading anything.
- User-initiated (OAuth, target=_blank): cfg ensures the new view shares
  the parent's websiteDataStore, which SOAuthorizationCoordinator requires
  for SSO session cookie access.
lastUserGestureTime was a unique behavioral signal: sites could call
window.open() at controlled intervals, probe whether they got a real
window or silent absorption, and map the threshold to fingerprint
BearBrowser specifically (entropy matching).

WebKit's own popup blocker already gates window.open() by user gesture
at the engine level before calling createWebViewWithConfiguration:. Our
delegate-level re-gate was redundant and exposed a distinguishing signal
no other browser has.

Removed: lastUserGestureTime property and update, decoyViews set and
honeypot WKWebView path, dead decoy check in decidePolicyForNavigation.
createWebViewWithConfiguration: now always opens a real tab — matching
Safari's implementation exactly.
Cross-referenced against Mozilla Bugzilla RFP bugs and Firefox's
resistfingerprinting test suite. Vectors added:

Canvas (HIGH)
- Hook toBlob in addition to toDataURL — both paths now apply LSB noise
- Hook CanvasRenderingContext2D.getImageData directly — closes the bypass
  where fingerprinters call getImageData instead of toDataURL

WebGL (MEDIUM)
- VENDOR/RENDERER now return Safari-on-M1 strings instead of "BearBrowser"
  (distinguishable) — matches actual Safari Metal renderer identity
- getSupportedExtensions() frozen to fixed Safari/M1 subset — removes
  GPU-specific extension list that was fully intact before

Screen/window (HIGH — bug 418986)
- screen.width/height/availWidth/availHeight → 1280×800 fixed
- screen.pixelDepth added alongside colorDepth
- window.devicePixelRatio → 2 (Retina — matches dominant Safari traffic)
- window.outerWidth/outerHeight → innerWidth/innerHeight (hides chrome height)

Navigator (MEDIUM)
- navigator.platform → 'MacIntel'
- navigator.maxTouchPoints → 0
- navigator.userAgentData deleted (UA-CH leaks OS/arch; Safari omits it)
- navigator.connection deleted (NetworkInformation exposes network quality)
- navigator.plugins/mimeTypes frozen to empty arrays

Timing (HIGH — Firefox browser_reduceTimePrecision_iframes.js)
- performance.now() jittered to 1ms granularity + ±0.1ms noise
- Date.now() rounded to 100ms buckets

Timezone (HIGH — bug 1896836, browser_timezone.js)
- Intl.DateTimeFormat.prototype.resolvedOptions → timeZone: 'UTC'
- Date.prototype.getTimezoneOffset → 0

AudioContext (HIGH — bug 1358149 FIXED in Firefox)
- sampleRate normalized to 44100
- baseLatency normalized to 0.01
- getFloatFrequencyData adds ±0.00005 LSB noise to AnalyserNode output

WebSpeech (LOW — bug 2043367, unfixed upstream)
- speechSynthesis.getVoices() → empty array
- SpeechSynthesisVoice removed

window.name (MEDIUM)
- Cleared on beforeunload to prevent cross-navigation tracking
…e sensors

These were the remaining surfaces Firefox either hasn't fixed (Bug 2043403,
Bug 2043367 still open/assigned) or explicitly declined (Bug 1336208 WONTFIX).
We fix them all.

WebGPU (Bug 2043403 — ASSIGNED in Firefox, unfixed)
- navigator.gpu → undefined; GPU{Adapter,Device,Buffer,Texture} deleted
- GPU adapter requestAdapterInfo() exposes vendor/arch/device at hardware-serial
  granularity. No partial spoof is adequate; the whole API surface goes.

Font enumeration via measureText (Bug 1336208 — WONTFIX in Firefox, we fix)
- CanvasRenderingContext2D.prototype.measureText hooked: adds per-session
  consistent FNV-1a noise (±0.2px) keyed on (font, text, session-salt)
- Same font+text always returns the same delta within a session, preventing
  statistical averaging; differs across sessions, defeating cross-session tracking
- document.fonts.check() → false: blocks CSS local() font presence oracle
  that fingerprinters use to enumerate installed system fonts without canvas

requestAnimationFrame timing (Firefox browser_animationapi_iframes.js)
- requestAnimationFrame callback timestamp truncated to Math.floor (1ms)
- Consistent with performance.now() and Date.now() precision floor already set

Device sensors (Firefox browser_device_sensor_event.js)
- addEventListener blocked for: deviceorientation, devicemotion,
  deviceorientationabsolute, compassneedscalibration
- DeviceOrientationEvent, DeviceMotionEvent → undefined
- Generic Sensor API deleted: Accelerometer, Gyroscope, Magnetometer,
  AbsoluteOrientationSensor, RelativeOrientationSensor, LinearAccelerationSensor,
  GravitySensor, AmbientLightSensor
- screen.orientation normalized to landscape-primary / 0° (fixed form-factor signal)
Three coordinated layers blocking the remaining two JS-unreachable
fingerprinting vectors:

1. @font-face local() CSS resolution (Gecko build)
   - patches.py: gfxUserFontSet.cpp — wraps LookupLocalFont() to return
     nullptr when bearbrowser.privacy.block_local_fonts is set, making all
     local() font-face entries fail to match installed fonts
   - patches.py: StaticPrefList.yaml — registers the new pref
   - user.js: layout.css.font-visibility.{standard,private,trackingprotection}
     set to 2/1/2 (base fonts only / hidden in private) — the Gecko pref-level
     guard that backs the C++ patch

2. Worker / compositor performance.now() precision (Gecko build)
   - patches.py: Performance.cpp + PerformanceWorker.cpp — wraps
     ReduceTimePrecisionAsMSecs() in std::floor() to enforce 1ms integer
     granularity unconditionally; registers bearbrowser.privacy.reduce_time_precision
   - user.js: privacy.resistFingerprinting.reduceTimerPrecision{,.microseconds}
     set to true / 1000 — the engine-level RFP hook covering all timing paths

3. Web Worker timing (WKWebView overlay)
   - BearBrowserWebKitLauncher.m: wraps the Worker constructor to produce a
     blob URL that prepends our performance.now() / Date.now() precision patch
     before importScripts()-ing the original script; falls back to the native
     Worker() if blob creation fails (module workers, CSP restrictions)
…egistry

Native actor implementations from prior session, now committed:

BearCapture (BearCaptureChild/BearCaptureParent)
  Scans DOM for video/audio/media links, shows floating badge with count,
  Cmd+Shift+D panel, queues to Firefox Downloads. Replaces Video DownloadHelper
  which used webRequest API (full network intercept) for what is a DOM scan.

BearClip (BearClipChild/BearClipParent)
  Cmd+Shift+S captures bibliographic metadata from the current page: citation_*
  meta tags, Dublin Core, Open Graph, JSON-LD, arXiv IDs, DOIs. Saves to a
  local library. Zero extension surface.

BearVault (BearVaultChild/BearVaultParent)
  Detects password fields, shows badge, fills from OS Keychain (macOS) /
  libsecret (Linux) on Cmd+Shift+L. Replaces Bitwarden extension — credentials
  never enter an extension sandbox or leave the OS keychain.

BearSponsor: add #forceHighQuality() to set YouTube player to 4K via
  localStorage + player API polling.

registry.json: mark Video DownloadHelper and Bitwarden as native (replaced).

Also adds bundled woff2 font assets for native/macos/fonts/ and build/CI
helper scripts (bearbrowser-install-fedora, bearbrowser-invoke-build,
download-bundled-fonts, update-content-rules).
…gaps

Native function toString() spoofing
  All overridden browser APIs now return "function x() { [native code] }"
  when .toString() is called on them. Previously fingerprinters could trivially
  detect our shield by checking HTMLCanvasElement.prototype.toDataURL.toString()
  or performance.now.toString(). WeakMap-backed registry with object method
  shorthand for correct .name and .length — indistinguishable from real natives.

performance.now precision fix
  Changed from Math.floor(_pNow()) + Math.random()*0.1 to pure Math.floor().
  Random sub-ms jitter was counterproductive: attackers average many samples
  to recover the true value. Fixed 1ms integer buckets are resistant to
  averaging and match Firefox RFP / Tor Browser's actual approach.

Additional identity normalization
  - navigator.doNotTrack = '1'
  - navigator.webdriver = undefined (false leaks automation detection surface)
  - window.chrome deleted (its presence signals non-Safari vs our UA claim)

Intl locale normalization (JS + engine)
  Intl.Collator, Intl.NumberFormat, Intl.ListFormat, Intl.PluralRules
  resolvedOptions() all patched to return locale:'en-US'. Previously sites
  could recover the OS locale via Intl even though navigator.languages was
  spoofed. Gecko build: intl.accept_languages pref default set in
  StaticPrefList.yaml; also added to user.js with javascript.use_us_english_locale.

Resource timing restriction
  setResourceTimingBufferSize(0) + clearResourceTimings() prevents resource
  timing entries from accumulating. PerformanceObserver wrapped to strip
  'resource' type entries before delivering to callbacks. Resource timing
  exposes precise network transfer durations that fingerprint connection topology.

Retroactive native registration
  End-of-shield pass registers all prototype method overrides (canvas, WebGL,
  Intl, Date, EventTarget, etc.) plus window-level overrides (rAF, eval,
  postMessage, Worker, RTCPeerConnection) in the native map.
…k prefs

WKWebView: Accept-Language header at network layer
  Set _HTTPAdditionalHeaders on WKWebViewConfiguration to lock Accept-Language
  to en-US,en;q=0.9 for all requests. JS-layer Intl spoofing only covers the
  JS context; the HTTP header was still leaking the OS locale on every request.

WKWebView: complete navigator identity for Safari UA consistency
  Added vendor, vendorSub, productSub, appName, product assertions matching
  Safari 17.6 on macOS. Deleted oscpu and buildID which are Firefox-only
  properties that reveal Gecko to fingerprinters even when the UA claims Safari.

Gecko build: network normalization prefs
  - network.http.accept-language: en-US,en;q=0.5 (explicit; RFP sets this
    but making it explicit survives profile imports and pref resets)
  - network.http.altsvc.enabled/oe: false — Alt-Svc headers allow servers
    to redirect to different ports/protocols, creating a connection-timing
    fingerprint vector via HTTP/3 upgrade probing
…x vector failures

Root causes fixed:
- AudioContext: Object.create(NativeConstructor) produces a plain object whose
  inherited .prototype is non-writable; assigning it threw TypeError in strict
  mode and silently aborted the entire shield after navigator/perf setup. Fix:
  use the _cAC wrapper function directly (it was built but never wired in).
- window.SpeechSynthesisVoice=undefined: bare assignment to a non-writable
  global in strict mode. Fix: Object.defineProperty with configurable:true.
- navigator.doNotTrack / webdriver: Object.defineProperty on the instance fails
  when the property doesn't exist in non-extensible Playwright context; fix
  targets Navigator.prototype instead.
- canvas toDataURL/getImageData: retroactive _nat() registration was never
  reached (crash above). Fix: wrap assignments with _nat() at point of definition.
- speechSynthesis.getVoices: instance assignment fails silently; fix targets
  SpeechSynthesis.prototype via Object.defineProperty for guaranteed override.
- FontFaceSet.prototype.check: instance assignment fails; fix targets prototype.

Also adds scripts/verify-fingerprint-shield.mjs (36-vector Playwright WebKit
regression harness) so future edits can't regress without being caught.
WKWebView JS shield additions:
- navigator.usb/bluetooth/hid/serial/xr/keyboard/credentials → undefined
- navigator.getGamepads() → [] (no controller hardware enumeration)
- navigator.mediaDevices.enumerateDevices() → [] (blocks camera/mic presence)
- navigator.permissions.query() → 'prompt' for sensitive APIs (normalizes
  per-user permission state which is otherwise a stable identifier)
- StorageManager.prototype.estimate() → fixed 120GB/4MB (storage quota and
  usage vary by device and form a unique fingerprint signal)
- window.matchMedia() → normalized for 15 privacy-sensitive CSS media features
  including prefers-color-scheme, prefers-reduced-motion, color-gamut, HDR,
  pointer type, hover — all of which reflect system/display characteristics
- Element.prototype.getBoundingClientRect() → ±0.1px per-session position
  offset (prevents sub-pixel font metric fingerprinting via layout geometry)
- TextMetrics bounding box properties → same session-consistent FNV noise
  applied to actualBoundingBoxAscent/Descent and fontBoundingBoxAscent/Descent

AudioContext crash fix:
- _cAC wrapper function was created but _ACc (Object.create result) was used
  instead; assigning .prototype on a plain object that inherits from a native
  constructor threw TypeError in strict mode, silently aborting the shield

Gecko user.js additions:
- Gamepad/WebXR/VR APIs disabled
- keyboard.layout_map disabled
- Extension detection hardening (block_mozAddonManager)
- CSS media feature normalization prefs (prefers-color-scheme, reduced-motion)
- devPixelsPerPx = 2.0 to match spoofed JS devicePixelRatio
- HTTP/3 QUIC disabled (reduces QUIC transport fingerprinting surface)
- TLS version max set to 4 (TLS 1.3 ceiling, normalizes ClientHello)

Test suite: 36 → 47 vectors (11 new vectors covering all new shield additions)
…tries, mediaCapabilities, RTCRtp codecs, fonts enum
…VG metrics, screen avail*, screenX/Y, window.name, Error.stack, fonts.load
Override 14 Math functions where JavaScriptCore diverges from V8 at the
ULP level. creepjs hashes these outputs to detect WKWebView-as-Chrome;
returning V8's exact float64 values for each probed input eliminates the
signal.
…sOf, RTCPeerConnection ICE, Notification.permission

- performance.timeOrigin clamped to 100ms buckets (was returning full-precision float)
- Intl.supportedValuesOf intercepted with fixed Chrome-matching calendar/collation/numberingSystem lists; timeZone/currency sorted for ICU-version stability
- WebRTC ICE candidate leak properly suppressed: previous iceServers:[] approach did not block mDNS candidates; now drops all icecandidate event listeners at the object level
- Notification.permission locked to 'denied' matching Brave/Tor
- Canvas noise upgraded from deterministic XOR-1-every-400 to per-session
  random seed (1-4) at stride 100; different noise pattern each launch
  prevents cross-session hash correlation while remaining imperceptible
- navigator.pdfViewerEnabled = true (Chrome 104+ property; WKWebView omits it)
…mance.memory stub

Remove WebKit-exclusive APIs that creepjs/fingerprintjs probe to distinguish
WebKit from Chrome: caretRangeFromPoint, WebKitCSSMatrix, webkitStorageInfo,
webkitRequestFileSystem. Add performance.memory stub (Chrome-only non-standard
API; its absence in WKWebView was a hard browser signal).
fingerprintjs isChromium() checks ≥5 of 7 legacy webkit-prefixed signals;
add stubs for webkitPersistentStorage, webkitTemporaryStorage,
webkitResolveLocalFileSystemURL, BatteryManager, webkitMediaStream,
webkitSpeechGrammar so the session passes as Chromium.

creepjs hashes Object.getOwnPropertyNames(window) against Chrome's key list;
add stubs for showOpenFilePicker/SaveFilePicker/DirectoryPicker, EyeDropper,
scheduler, trustedTypes, navigation so Chrome-exclusive keys are present.
…r shape

Replace window.chrome=undefined with a Chrome-matching object that includes
app.isInstalled, csi(), loadTimes(), and runtime.connect/sendMessage/onMessage.
fingerprintjs and creepjs both probe window.chrome's presence and structure;
undefined immediately identifies the session as non-Chromium.
fingerprintjs isChromium() requires navigator.vendor.indexOf('Google')===0.
The shield was returning Apple's vendor string, directly failing this check
and causing fingerprintjs to classify the session as non-Chromium.
…rgence

BearBrowser uses WKWebView with a Safari 17.6 UA. Mixing Chrome-only signals
(vendor='Google Inc.', window.chrome object, showOpenFilePicker, EyeDropper,
scheduler, trustedTypes, navigation, BatteryManager) with a Safari UA creates
a unique 'misconfigured' fingerprint worse than either consistent identity.

Revert to Safari-consistent profile:
- navigator.vendor: 'Apple Computer, Inc.' (matches Safari UA)
- window.chrome: undefined (absent in Safari — consistent)
- Remove Chrome-only API stubs that contradict the Safari UA
- Remove performance.memory stub (Chrome-only API)
- Remove document.caretRangeFromPoint deletion (valid Safari API)
- Replace Chrome-specific Chromium-probe stubs with WebKit-prefix consistency stubs
- Retain WebKitCSSMatrix/webkitStorageInfo removal (removed from modern Safari 12+)
- Retain all genuine privacy improvements (timing, canvas, WebGL, audio, etc.)
… coverage

Math overrides from _mPatch now pass through _nat so Math.acos.toString()
returns '[native code]' — plain function bodies would expose monkey-patching.
Add Intl.supportedValuesOf to retroactive registration block for same reason.
mdheller added 30 commits June 18, 2026 14:58
…at, AudioContext

AudioContext constructor and WebGL2.getShaderPrecisionFormat were patched but
not registered, so their .toString() leaked the modified implementation body.
The JS shield forces matchMedia('prefers-color-scheme: light') to return true,
but WKWebView on a dark-mode OS still renders CSS system color keywords (Canvas,
ButtonText, etc.) using dark-mode palette values. Setting wv.appearance to Aqua
makes these resolve to light-mode RGB, consistent with the JS override.
Applied to both makeWebViewPrivate: and the tab-replacement WKWebView path.
…prefs

Gecko-first pivot. Audited human-secure + agent-runtime user.js against the
current Tor Browser / arkenfox RFP baseline. The config was already strong;
the issues were manual spoofs fighting RFP plus latent bugs:

- Remove privacy.resistFingerprinting.randomDataOnCanvasExtract (both profiles):
  removed from Firefox (bug 1670447); canvas noise is always-on under RFP now
  (bug 1816189). The pref was a silent no-op.
- Remove layout.css.devPixelsPerPx="2.0": the WKWebView JS shield's spoofed DPR
  leaked into the Gecko profile. Forcing 2x render scale breaks non-Retina/
  external displays and desyncs from RFP's own DPR rounding — making the browser
  MORE fingerprintable. RFP owns devicePixelRatio.
- Fix HTTP/3 contradiction: line set the misspelled pref network.http.http3.
  enabled (trailing 'd') to false — a non-existent pref, silent no-op — while
  the correct network.http.http3.enable was true. Net: HTTP/3 was on. Keep it
  on (blends with the QUIC-speaking majority; Tor does not disable it) and
  remove the broken line.
- Add network.http.referer.XOriginTrimmingPolicy=2: genuinely additive
  cross-origin referer trimming not covered by RFP (arkenfox baseline).
- Dedupe the duplicated BearBlocker pref block in human-secure.
verify-gecko-rfp.mjs is the Gecko-surface counterpart to verify-fingerprint-
shield.mjs. Two phases:

  Phase A (static, zero-dep, always runs): audits both user.js profiles for the
  RFP backbone (required prefs + values), dead/no-op prefs, cohort-desyncing
  prefs (UA overrides, manual DPR spoof, non-native-theme=false), known typo
  pref names that silently no-op (e.g. http3.enabled vs http3.enable), and
  duplicate keys. Would have caught all three bugs from the RFP audit.

  Phase B (runtime, skips cleanly without a Gecko binary): launches Firefox with
  the profile's prefs and asserts RFP behaviours (timezone→UTC, getTimezoneOffset
  →0, hardwareConcurrency→2, languages→en, maxTouchPoints→0). Resolves a binary
  from $BEARBROWSER_BIN or Playwright's bundled Firefox.

On first run Phase A caught real cross-profile drift: agent-runtime was missing
reduceTimerPrecision, font-visibility, and locale normalization that human-secure
sets — despite its header stating agents must blend into the same RFP cohort.
Added those prefs to agent-runtime to match human-secure exactly.
bearbrowser:rfp:verify (both phases) and bearbrowser:rfp:verify:static
(static pref audit only, zero-dep, CI-friendly).
With HTTP/3 + DoH enabled, the real cross-connection tracking vectors are
server-issued, client-replayed opaque blobs that act as supercookies:
  - QUIC NEW_TOKEN address-validation tokens (RFC 9000 §8.1/§19.7/§21)
  - TLS 1.3 / QUIC 0-RTT session tickets / PSK (RFC 9001 §4.6, RFC 8446 §C.4)

The decisive mitigation, privacy.partition.network_state, was already set in
both profiles — it partitions the connection pool, TLS tickets, 0-RTT state and
the QUIC NEW_TOKEN store by first-party site, so a tracker on two sites cannot
link the visits. (RFP does NOT cover this — it is purely partitioning + TLS.)

Add defense-in-depth: security.tls.enable_0rtt_data=false in both profiles,
removing the 0-RTT early-data replay surface (RFC 8446 §8). Lock both
privacy.partition.network_state and the 0-RTT pref into the Gecko RFP harness
REQUIRED set so the protection cannot silently regress.
Provider change (user preference + privacy rationale): Quad9 is a Swiss
non-profit (GDPR jurisdiction, no commercial data incentive) and does NOT
forward EDNS Client Subnet (RFC 7871) to authoritative servers, unlike
ECS-forwarding resolvers. Uses 9.9.9.9 (filtered + DNSSEC, no ECS); 9.9.9.11
(ECS-forwarding) is explicitly avoided.

Critical: the provider was pinned in TWO places. policies.json DNSOverHTTPS.
ProviderURL (agent-runtime's is Locked:true) is authoritative and silently
overrides user.js network.trr.uri — so changing only user.js would have shipped
Cloudflare anyway. Updated both user.js and policies.json in both profiles.

Also removed a confirmed dead pref: network.trr.bootstrapAddress was renamed to
network.trr.bootstrapAddr in Firefox 89 (bug 1703216) — a silent no-op that only
"worked" because 1.1.1.1 is an IP needing no bootstrap. IP-form 9.9.9.9 likewise
needs no bootstrap (cert SAN covers the IP).

DNS hardening (belt-and-suspenders; mostly default-safe):
- network.trr.disable-ECS=true       suppress ECS on the wire (RFC 7871)
- network.trr.allow-rfc1918=false     reject private-IP answers (DNS rebinding)
- network.trr.send_user-agent-headers=false   no UA leak to resolver
- network.trr.padding=true            EDNS(0) padding (RFC 8467)
- network.dns.use_https_rr_as_altsvc=true / upgrade_with_https_rr=true
                                      HTTP/3 discovery + HTTPS upgrade via DNS
                                      HTTPS RR (RFC 9460), since Alt-Svc is off
- network.dns.echconfig.enabled=true / http3_echconfig.enabled=true
                                      Encrypted ClientHello (encrypts SNI)

Harness (verify-gecko-rfp.mjs) extended to lock all of this in:
- REQUIRED: trr.mode=3, disable-ECS, allow-rfc1918=false, no-UA-to-resolver
- KNOWN_TYPOS: network.trr.bootstrapAddress -> bootstrapAddr
- FORBIDDEN value: trr.uri must not be the Quad9 ECS endpoint
- NEW cross-file check: policies.json DNSOverHTTPS.ProviderURL must agree with
  user.js network.trr.uri (catches the Locked-policy-overrides-user.js trap) and
  must not be an ECS endpoint
Investigated the JSONC-policies.json concern: it was already handled — the build
strips comments via strip-json-comments.py in both apply-sourceos-overlays.sh and
bearbrowser-overlay-binary.sh before Firefox reads the file (Firefox rejects a
commented policies.json wholesale, silently disabling ALL policies including the
Locked DoH). So the locked Quad9 DoH policy does apply.

Add a regression guard: the Gecko RFP harness now runs the real build stripper in
--check mode against each profile's policies.json, so a future structural break
(trailing comma, etc.) fails the harness instead of silently shipping a policy
file that disables every locked policy. Verified the guard rejects broken JSON.
Cross-profile drift sweep (the recurring bug class: profiles meant to share one
RFP/privacy cohort silently diverging). Found and closed real parity gaps —
several introduced by the recent per-profile DNS/ECH work:

agent-runtime gained (parity with human-secure):
- HTTP/3 + DNS HTTPS-RR posture (http3.enable, altsvc off, use_https_rr_as_altsvc,
  upgrade_with_https_rr) so H3 is discovered via DNS HTTPS records in both
- ECH (echconfig.enabled, http3_echconfig.enabled) — encrypts SNI
- TLS floor/ceiling (version.min=3/max=4) + refuse unsafe renegotiation
- referer XOriginTrimmingPolicy=2

human-secure gained:
- privacy.partition.network_state.ocsp_cache (agent-runtime already had it)

Left intentional differences alone: agent-runtime disables WebRTC entirely and
clears-on-shutdown (ephemeral agents) vs human-secure's hardened-but-on WebRTC
and persistent state; keyboard-nav UI toggle.

Locked the shared invariants into the harness REQUIRED set (tls.version.min/max,
echconfig.enabled, referer XOriginTrimmingPolicy, ocsp_cache partition) so this
drift class fails CI instead of shipping.
…MITM

policies.json audit beyond DNS. agent-runtime was missing two security policies
that human-secure has:
- Certificates.ImportEnterpriseRoots=false — do NOT import OS/enterprise root CAs;
  importing them is what lets a corporate MITM proxy decrypt the agent's HTTPS
  traffic. Trust only Mozilla's vetted bundle. Especially important for an
  automation runtime handling sensitive sessions.
- EncryptedMediaExtensions {Enabled:false, Locked:true} — Widevine DRM blob off
  at the policy layer (belt-and-suspenders with user.js media.eme.enabled=false).

Harness: after validating policies.json strips to valid JSON, parse it and assert
Certificates.ImportEnterpriseRoots !== true in both profiles, so a future
MITM-enabling policy fails CI. (Absent key = Firefox default false = passes.)

Note: the inline comments in agent-runtime/policies.json were removed by the
concurrent JSONC task; the build-script wiring + strip-json-comments.py from that
task remain unstaged here for it to commit.
measure-fingerprint.mjs launches a real Gecko engine x3 (bare control + our RFP
profile x2 sessions) and scores entropy reduction across the surface the real
suites (Cover Your Tracks / creepjs / fingerprintjs) hash. Classifies each vector
NORMALIZED / RANDOMIZED (per-session, unlinkable) / MATCHES-COHORT / LEAKING.
npm: bearbrowser:fp:measure.

First measured baseline (human-secure, Gecko 141 via Playwright):
neutralized 14/17 high-entropy vectors (82%). Per-device signals all handled:
canvas RANDOMIZED, WebGL renderer masked, hardwareConcurrency->2, DPR->2,
timezone offset->0, deviceMemory/touch/connection at cohort.

Three residuals, characterized:
- screen WxH: Playwright headless viewport artifact (not the real monitor);
  letterboxing only operates windowed -> verify on real build.
- audio (OfflineAudioContext): RFP randomizes canvas but NOT audio; value stable
  across sessions. Known Gecko/Tor residual, no pref fixes it (needs engine patch;
  the legacy WKWebView shield hand-noised it).
- non-base fonts: 13/14 macOS system fonts still detectable under font-visibility=2
  (OS-bundled, so uniform across Macs, but a cross-OS signal). The Tor/Mullvad fix
  is bundled fonts (tranche T3).

This converts "we believe it's hardened" into a measured, repeatable 82% with a
named residual list driving T1/T3.
WebRTC ICE leak test added to the measurement harness. Empirically verified:
- bare Firefox leaks mDNS candidates (obfuscated, not raw IP)
- human-secure suppresses ALL candidates (ice.no_host + default_address_only)
- agent-runtime removes RTCPeerConnection entirely
No raw private/public IP leaks in either profile. Baseline now 15/18 (83%).

Empirical conclusion: the config-addressable fingerprint surface is essentially
complete. The 3 remaining residuals all need NON-config fixes:
- screen letterboxing: verify on real windowed build (headless artifact here)
- audio randomization: Gecko engine patch (RFP doesn't noise OfflineAudioContext)
- non-base fonts: bundled fonts (font-visibility 1/2/3 all leave 13/14 macOS
  system fonts detectable — confirmed by experiment; pref is not the lever)
Measured proof that bundling fonts alone is insufficient: measureText width =
453.54998779296875 (full sub-pixel), IDENTICAL control vs RFP — RFP does nothing
to text metrics. That float encodes font + HarfBuzz GPOS kerning + platform
rasterizer = high entropy, fully exposed via measureText/TextMetrics/
getBoundingClientRect. Added 'text-metric readback' vector to the measurement
harness (target 'int', currently LEAKING).

docs/anti-fingerprint-T3-fonts-text-metrics.md specs the three-layer fix:
- Layer A: bundle a metric-compatible font set (Arimo/Tinos/Cousine + Noto +
  emoji) AND restrict gfxPlatformFontList to an allowlist via source patch so
  system fonts are invisible to enumeration AND local() fallback (font-visibility
  pref proven insufficient — 1/2/3 all leak 13/14 macOS fonts).
- Layer B: deterministic shaping/kerning — advances from font hmtx/GPOS, disable
  platform hinting/optical adjustments.
- Layer C ("do our own kerning"): nsRFPService patch quantizing measureText, all
  TextMetrics fields, getBoundingClientRect/getClientRects (Element+Range), and
  SVG text length APIs. Best form derives advances from font units so the value is
  identical on every OS. CRITICAL: text metrics must be UNIFORM, not randomized
  (randomizing would split the cohort — opposite of canvas/audio).

Rollout T3a (prefs+packaging+allowlist) -> T3b (integer-round quantizer) ->
T3c (font-unit-derived advances, true cross-OS uniformity).
Measured the FULL text-layout readback surface, not just measureText. Found two
distinct metric paths that produce different numbers and both leak sub-pixel,
RFP-noop:
- canvas: measureText = 453.549987
- layout/SVG: getBoundingClientRect/getComputedTextLength = 439.600006
plus offsetWidth (454, integer but carries the metric). A fix on one API leaves
the others open. Added 'layout text metric' vector alongside 'canvas text metric'
so the harness tracks both (now 15/20).

docs/anti-fingerprint-T3-implementation-plan.md is the execution runbook:
- Complete surface inventory / holes register (every API + path + coverage).
- Architecture: ONE chokepoint at gfxShapedText per-glyph advance — every
  consumer (canvas/layout/SVG/Range/offsetWidth) inherits the quantized value,
  vs whack-a-mole per-API (which leaves holes).
- Work items W1 font bundle, W2 gfxPlatformFontList allowlist (load-bearing
  source patch; font-visibility proven insufficient), W3 generic remap (gated to
  ship WITH W1/W2), W4 advance quantizer (the chokepoint; UNIFORM not randomized),
  W5 deterministic advance source (HarfBuzz/hmtx not CoreText optical), W6 fold in
  audio randomization, W7 verification incl. cross-OS proof.
- Sequencing, risk/holes register with coverage mapping, rollback gating.

Authored ahead of build; compile/integrate/ship is the "after" step.
Grounded in the actual build system (inspected the LibreWolf-style build repo:
Makefile, scripts/bearbrowser-patches.py, assets/patches.txt, patches/).

Key facts the on-ramp pins down:
- Precedent: the project ALREADY ships RFP/FPP patches via this mechanism
  (patches/fpp-canvas-fix.patch, ui-patches/website-appearance-ui-rfp.patch in
  assets/patches.txt). Our 3 T3 patches follow that exact pattern.
- Source tree to edit: `make patches` extracts firefox-<ver>.source.tar.xz and
  runs bearbrowser-patches.py -> bearbrowser-<ver>-<release>/ (the tree that only
  exists post-extract; that's why it vanished mid-session).
- Inner loop: edit -> git diff to patches/<name>.patch -> register in
  assets/patches.txt -> `make check-patchfail` (FAST, verifies apply without a
  compile) -> `make build` (slow) -> `make run`.
- Real-binary measurement needs geckodriver/Marionette, NOT Playwright (Playwright
  only drives its Juggler build, not stock LibreWolf); reuse the same PROBE.
- Where each artifact lands (patches/ + assets/patches.txt for the 3 patches;
  assets/fonts/ + packaging for fonts; settings/profiles for the gated prefs).
- Per-item definition-of-done tied to specific harness vectors + a cross-OS proof.

The implementation itself still needs a build env (stable checkout + compiler);
this makes that step turnkey rather than exploratory.
Read the existing patches/fpp-canvas-fix.patch to learn how THIS tree plumbs RFP.
It plumbs canvas randomization at the DOM layer (dom/canvas/*) via
nsRFPService::RandomizePixels(GetCookieJarSettings(), PrincipalOrNull(), ...),
because RFP gating needs the principal + cookie-jar settings — which are NOT
available deep in gfx/thebes.

Correction: the first draft's "single chokepoint in gfxShapedText" is elegant but
wrong — gfx code can't gate on RFP. Real architecture (mirrors the canvas patch):
one nsRFPService::SpoofTextMetrics helper, called from each DOM entry point that
exposes a text metric — CanvasRenderingContext2D::MeasureText, Element/Range
getBoundingClientRect, SVGTextContentElement::*. A few faithful call sites instead
of one gfx hook, but each has the RFP context and matches the accepted pattern, so
it will actually compile. Quantizer math (integer px / font-unit-derived) unchanged.
…es cleanly

First actual engine patch, not a sketch. Authored against extracted Firefox
150.0.1 source, grounded in real APIs and verified to apply+reverse cleanly
against pristine source (patch -p1 --dry-run + reverse = the check-patchfail
equivalent).

gecko-patches/anti-fingerprint/anti-fp-canvas-text-metrics.patch:
- Adds RFPTarget::CanvasTextMetrics (id 81) to RFPTargets.inc.
- In CanvasRenderingContext2D::DrawOrMeasureText MEASURE branch, gates on
  nsContentUtils::ShouldResistFingerprinting(doc, RFPTarget::CanvasTextMetrics)
  (the real (const Document*, RFPTarget) overload, confirmed at nsContentUtils.h
  :406) and quantizes every TextMetrics field to whole CSS px via std::round
  (already used in-file). Uniform, never randomized.

Confirmed present in-tree so it should compile: the overload, std::round,
nsContentUtils.h/nsRFPService.h includes, RFPTarget usage. NOT yet compiled —
./mach build is the remaining gate (needs the build env).

Follows the existing patches/fpp-canvas-fix.patch pattern. README documents
wiring (copy to build repo patches/ + register in assets/patches.txt) and the
remaining W4 call sites (layout BCR, SVG) + W2 allowlist + W6 audio as TODO.

Honest status: covers the canvas text-metric path (harness vector
'canvas text metric' -> int); layout + SVG paths are separate call sites still
to author the same way.
Three tiers, matching what each runner class can actually do:

Tier 1 (harness-and-measure, every push/PR, ubuntu-latest): installs Playwright
Firefox and runs the config harness (verify-gecko-rfp.mjs — HARD GATE) plus the
empirical fingerprint measurement on real Gecko for both profiles, uploading the
scorecards as artifacts. This continuously regression-gates the config posture and
the measured baseline. Works on stock runners today.

Tier 2 (patch-apply, every push/PR, ubuntu-latest): fetches the pinned Firefox
source from archive.mozilla.org and dry-run applies every gecko-patches/*.patch,
failing on any reject — the check-patchfail equivalent. No compile, so it runs on
a stock runner and proves our engine patches still apply as Firefox moves.

Tier 3 (full-build, workflow_dispatch only): checks out the build repo, registers
our patches into assets/patches.txt, runs make check-patchfail -> make bootstrap
-> make build, then measures the real binary via geckodriver. HONEST: a full
Firefox build OOMs/overflows the stock 14GB GitHub runner — needs a large or
self-hosted runner (repo var BUILD_RUNNER); the project's Forgejo/Woodpecker
pipeline is the production build path. This is the GitHub scaffold + wiring.

So the compile CAN happen in CI; Tiers 1-2 run anywhere now, Tier 3 needs a big
runner (or the existing Forgejo build).
CI on PR #39 caught two real bugs in the tooling (not the config/patches):

1. harness-and-measure failed: verify-gecko-rfp.mjs Phase B asserted the RFP
   timezone string === 'UTC', but Firefox RFP reports 'Atlantic/Reykjavik'
   (UTC+0, no DST — privacy-equivalent; offset 0 is checked separately). Same
   issue I fixed in measure-fingerprint.mjs but missed here. Now accepts either
   (rfp_timezone_neutral). Verified passing locally.

2. patch-apply failed: double '../' in the patch path — `find ../gecko-patches`
   already yields a relative path and the step prepended another '../', so
   `patch -i ../../gecko-patches/...` couldn't open the file. The 150.0.1 source
   DID download+extract fine (URL was correct). Switched to absolute paths
   (captured pwd before cd). Verified end-to-end against the real extracted
   source: anti-fp-canvas-text-metrics.patch applies OK, exit 0.

The 3 other PR failures (manifest-validation, validate-feature-plane,
validate-native-shell) are pre-existing repo validators on the accumulated main
history — confirmed none of this session's files touch their inputs.
No-holes pass on the canvas patch found a real leak path: OffscreenCanvas-
RenderingContext2D subclasses CanvasRenderingContext2D and inherits
DrawOrMeasureText, but my RFP gate read mCanvasElement->OwnerDoc() — and an
OffscreenCanvas (Worker / transferControlToOffscreen) has mCanvasElement == null,
so the gate went false and measureText stayed UNQUANTIZED in workers.

Fix: gate on both contexts, mirroring the codebase's own pattern (pre-existing
uses at CanvasRenderingContext2D.cpp:5512/5556):
  if (mCanvasElement)      -> nsContentUtils::ShouldResistFingerprinting(doc, ...)
  else if (mOffscreenCanvas) -> mOffscreenCanvas->ShouldResistFingerprinting(...)

Re-verified: applies + reverses cleanly against pristine Firefox 150.0.1.
The biggest measured leak (13/14 macOS fonts detectable, font-visibility 1/2/3
all useless) is now closed — WITHOUT a risky engine patch. Discovery: Gecko has a
built-in anti-fingerprint allowlist, font.system.whitelist, whose ApplyWhitelist()
filters the installed font list to only the named families. Empirically verified
on real Gecko: detection drops 13/14 -> 0/14.

W1 — bundle (packaging/bundled-fonts/): Arimo (Arial-metric), Tinos (Times),
Cousine (Courier) — the Croscore set Tor/Mullvad use, SIL OFL, 10 TTFs / 4.4MB.
Activated via gfx.bundled-fonts.activate=1; ActivateBundledFonts() loads from
NS_GRE_DIR/fonts = Contents/Resources/fonts. Wired into bearbrowser-overlay-
binary.sh (step 6b).

W2 — allowlist (both profiles' user.js): font.system.whitelist="Arimo, Tinos,
Cousine" so every OS exposes the SAME font set (cross-platform uniformity) +
generic remap (serif->Tinos, sans-serif->Arimo, monospace->Cousine).

SAFETY: ApplyWhitelist ignores the list if none of the families are present, so
the prefs are safe to ship before bundling is verified in a build — worst case a
no-op, never a zero-font browser. So no W1/W2 atomicity risk.

Harness locks gfx.bundled-fonts.activate + font.system.whitelist (both profiles).
Note: on plain Playwright Firefox (no bundle) the measurement font vector stays at
13/14 (whitelist falls back) — real verification is on the built binary, like the
screen/letterboxing residual.

(Includes strip-json-comments.py + userjs-to-autoconfig.py — helpers the overlay
script references — so the packaging step is self-consistent.)
RFP randomizes canvas but NOT WebAudio, so the OfflineAudioContext hash is stable
across sessions (measured: identical 152.77... across control + both sessions).
Brave "farbles" audio; we didn't. This closes it.

gecko-patches/anti-fingerprint/anti-fp-audio.patch (5 files, real Gecko source):
- RFPTarget::WebAudioFarble (id 82, placed by AudioSampleRate so it can't collide
  with the canvas patch's RFPTargets edit).
- nsRFPService::FarbleAudioData(global, data, len) — modeled on RandomizePixels;
  gated on ShouldResistFingerprinting; applies a per-session, inaudible (±1e-7
  multiplicative) factor from RandomUint64 (deterministic within a session so audio
  still works, random across sessions so the fingerprint can't be linked).
- Call sites at the audio READ paths: AudioBuffer::RestoreJSChannelData (the single
  materialization point behind getChannelData/copyFromChannel — once-per-buffer, no
  compounding) and AnalyserNode::GetFloat{Frequency,TimeDomain}Data. Byte variants
  skipped (0-255 quantized, 1e-7 is a no-op there — not a hole).
- Forward-declares nsIGlobalObject in nsRFPService.h; adds mozilla/RandomNum.h.

Verified: applies + reverses cleanly on pristine Firefox 150.0.1 AND sequences
cleanly after the canvas patch (non-overlapping RFPTargets edits). Not yet
compiled. Flips harness 'audio (oac)' -> randomized on the real build.

Caveat: per-session (per-process), not yet per-origin like Brave — defeats the
measured cross-session residual; per-origin is a documented follow-up.
…pass

Makes Tier-3 real (was a TODO placeholder). measure-fingerprint.mjs is now
engine-agnostic: --bin <path> / $BEARBROWSER_BIN drives the actual built binary
via geckodriver (Playwright's Juggler build can't drive a stock LibreWolf). Same
PROBE + scoring. Validated locally driving a real Firefox binary end-to-end.

The geckodriver path immediately earned its keep — it sees what Playwright masks:
- screen letterboxing shows as normalized (1366x768 -> 1200x600); headless
  Playwright couldn't show it.
- TIMEZONE: with RFP on (hwConcurrency=2 confirms), geckodriver shows
  tz=America/New_York, offset=240 — RFP timezone NOT spoofed — while Playwright
  reports offset 0 because it sets the TZ env var. So the harness Phase-B timezone
  check was a FALSE PASS. Relabeled rfp_timezone_neutral/tz_offset_zero ->
  rfp_timezone_indicative (reported, never gated); the authoritative check is the
  geckodriver/real-binary measurement. (The Playwright FF is a Juggler-modified
  build, so whether OUR LibreWolf build spoofs tz is decided by Tier-3 — if it
  doesn't, that's a found gap to fix.)

Tier-3 full-build job now: builds, locates the dist binary, runs measure-
fingerprint --bin for both profiles, prints LEAKING vectors, uploads scorecards.
Adds geckodriver + selenium-webdriver as devDependencies.

Net: the stack moves from "verified-applies" toward "verified-works" — and the
adapter already caught a real measurement-integrity gap Playwright was hiding.
User chose the Forgejo pipeline for the Tier-3 compile. The build repo is the
read-only librewolf-source-mirror (AGENTS.md: no direct commits), so our patches
must NOT go there — they stay in the overlay (gecko-patches/) and are injected
into the transient workspace clone at overlay-application time.

apply-sourceos-overlays.sh now copies gecko-patches/anti-fingerprint/*.patch into
<workspace>/source/patches/ and registers them in assets/patches.txt (canvas then
audio), so bearbrowser-patches.py applies them during the build.

VERIFIED with the build repo's own check-patchfail.sh: the FULL upstream patch
sequence (~40 LibreWolf patches) + both of ours applied to a fresh Firefox 150.0.1
extraction with ZERO rejects ("All patches were applied successfully"). The
fpp-canvas-fix.patch co-location on CanvasRenderingContext2D.cpp / nsRFPService.cpp
is handled by patch offset detection. So these are proven to apply in the REAL
build order, not just against pristine.

docs/anti-fingerprint-forgejo-build.md documents the build+measure flow:
apply-sourceos-overlays -> make build -> measure-fingerprint --bin (geckodriver,
authoritative). The measurement answers what Playwright masks (timezone,
letterboxing) and flips the patch vectors green on the real binary.
…sure binary

The build infra is NOT Forgejo — the overlay has real GitHub Actions build lanes
(nightly-linux on ubuntu-24.04, nightly-dmg on macos-15) that run
apply-sourceos-overlays.sh -> make fetch -> make dir -> ./mach bootstrap ->
./mach build. Since apply-sourceos-overlays now injects our patches, these lanes
compile BearBrowser WITH the anti-fingerprint patches.

The 0s "failures" weren't build failures: the push trigger was main-only, so
feature-branch pushes produced empty records. workflow_dispatch only works from
the default branch (remote main is behind). So to compile THIS branch:
- add anti-fingerprint-verify to push branches + gecko-patches/bundled-fonts/
  workflow paths (this commit's push triggers it). Revert once merged to main.
- disk-cleanup step (frees ~30GB; Firefox build needs ~40GB).
- measurement step after ./mach build: measure-fingerprint.mjs --bin drives the
  compiled binary via geckodriver -> authoritative scorecard (what Playwright
  masks: timezone, real letterboxing; and whether canvas/audio/fonts flipped
  green), uploaded as fp-real.json.

This commit's push starts the compile of our patched binary.
…e compile

ROOT CAUSE of the 0-job / 0s "failures": the "Publish nightly release" step's
run: | block had a --notes "..." string whose continuation lines (Not signed...,
Commit:...) sat at column 0 — less indented than the block scalar, which ENDS the
scalar and makes GitHub's YAML parser reject the entire workflow at startup. So
the lane never created jobs and never built (pre-existing, since before this work).

Fix: build the release notes with a single-line printf (--notes "$NOTES") so no
line breaks out of the block scalar. YAML now parses cleanly (18 steps).

With this + the earlier wiring (apply-sourceos-overlays injects our patches, disk
cleanup, geckodriver measurement step), this push should finally make the lane
create jobs and compile BearBrowser WITH the anti-fingerprint patches, then print
the authoritative real-binary scorecard.
…ches.py

The real compile (nightly-linux) got through setup + fetch + most of patch
application, then died at:
  sed -i '' 's/9456ca.../60cd124.../g' .../encoding_rs/.cargo-checksum.json
`sed -i ''` is BSD/macOS syntax — on GNU sed (Linux CI) the empty '' is parsed as
the script and the filename as a second file, so it fails. The author had already
converted some seds to Python "for macOS BSD sed compatibility" but missed these.
Our patches were never reached — they apply after.

Converted all remaining in-place seds to cross-platform Python file replacement:
- encoding_rs .cargo-checksum.json (two checksums for rust-build.patch)
- appstrings.properties Firefox->BearBrowser rename (os.walk instead of
  find -exec sed)
Script compiles; no `sed -i ''` commands remain.

This unblocks patch application so the build reaches ./mach build. (Also carries
the concurrent task's _gfx_new sed->Python conversion, same theme.) This push
re-triggers the compile.
…apply)

After the sed fix, the real Linux build got further and PROVED our patches apply:
  patch -p1 -i ../patches/anti-fp-canvas-text-metrics.patch -> Hunk #1 succeeded
  patch -p1 -i ../patches/anti-fp-audio.patch -> applied
(authoritative CI proof, beyond local check-patchfail). It then hit another
pre-existing infra gap: `cp ../patches/pref-pane/category-bearbrowser.svg` — the
pref-pane UI assets (svg/css/xhtml/js) aren't committed anywhere in the repo.

The pref-pane is an optional UI feature, orthogonal to the engine and to anti-
fingerprinting. Guard the whole block (patch + copies) behind an existence check
so the build proceeds without it when the assets are missing, instead of hard-
failing. Lets the build reach ./mach build to produce a measurable binary.
The build reached ./mach build and the compiler checks PASSED (clang 20.1.8
working), then configure hard-failed:
  ERROR: Invalid value --with-l10n-base, .../lw/l10n doesn't exist
This is a dev build (en-US only; l10n download is intentionally skipped), so the
l10n base dir never exists. A code comment claims --with-l10n-base was removed
from the mozconfig, but the mirror's mozconfig.new still carries it. Strip it in
bearbrowser-patches.py (overlay-canonical) after both mozconfig copies.

Progress so far on the never-worked build lane: BSD sed -> Python, missing
pref-pane assets -> graceful skip, now stale l10n-base -> stripped. Our anti-fp
patches already proven to apply in CI. This should let configure complete and the
actual compile proceed.
Closes the one gap RFP can't reach — the NETWORK LAYER (JA3/JA4, HTTP/2 frame,
real IP) — by routing through Tor's uniform exit. Shipped as a TIER (user's
choice per session), not a default:
- BearBrowser mode: our best-in-class direct-connection hardening (text-metrics,
  audio, fonts) — beats Tor/Brave on the JS surface.
- Tor mode: route through Tor + blend into the Tor Browser cohort.

settings/profiles/tor-mode/ (the Tor delta on the human-secure baseline):
- Fail-closed SOCKS routing: all traffic -> 127.0.0.1:9050, socks_remote_dns
  (DNS via Tor), failover_direct=false (never silently go direct).
- Every proxy-bypass vector killed: WebRTC off, DoH/TRR off, IPv6 off, HTTP/3 off,
  no prefetch/predictor/speculative.
- Proxy LOCKED via enterprise policy (Proxy + Locked:true) so no site/script can
  deanonymize.
- Cohort alignment: keep the bundled Croscore fonts (Tor ships the SAME set), but
  DISABLE our unique RFP targets (CanvasTextMetrics, WebAudioFarble) via
  fingerprintingProtection.overrides — uniqueness defeats anonymity, so we blend
  into the Tor cohort instead of standing out.

THE COHORT PARADOX (docs/tor-mode.md): you can't be uniquely-best-BearBrowser AND
network-anonymous at once — uniqueness is the enemy of anonymity. So Tor mode
trades our novel protections for blending into Tor's crowd. Honest limit: we're
ALIGNED to the Tor cohort, not byte-identical to Tor Browser (different build);
network-layer anonymity is full, JS blend-in is best-effort.

Skipped JonDonym (defunct) and exotic mixnets (cohort fragmentation); obfs4/
Snowflake ride with Tor in Phase 2. tor-mode registered in the build/overlay
profile allowlists.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant