Files
sovereign_browser_rust/plans/browser-fingerprint-spoofing.md
Laan Tungir f4250de54c v0.0.16 - Add browser fingerprint spoofing (desktop + mobile identities)
Present the browser as Chrome, Firefox, Safari, Edge, or a mobile browser
(Chrome Android, Safari iOS, Samsung Internet, Firefox Android) plus
Googlebot/Bingbot, at both the User-Agent and JavaScript level.

- src/fingerprint.rs: 15 internally-consistent presets, resolve(), JS config
- www/js/fingerprint-spoof.js: stealthy overrides for navigator.*,
  userAgentData (Client Hints), screen, devicePixelRatio, outer size,
  touch points, matchMedia, WebGL vendor/renderer, window.chrome
- Engine UA via Settings::set_user_agent with site-specific quirks disabled
- Settings UI (Browser Identity section), hamburger submenu, and a
  sovereign://fingerprint debug page comparing spoofed vs real values
- Settings persist and sync via NIP-78

Verified in real WebKitGTK 2.52.6 across six presets; sovereign:// pages
are never spoofed. Sec-CH-UA request headers are deferred (needs a
WebKitGTK web-process extension).
2026-10-06 16:16:50 -04:00

12 KiB
Raw Permalink Blame History

Browser Fingerprint Spoofing (Desktop + Mobile)

Goal

Let sovereign_browser present itself as a different browser — Chrome, Firefox, Safari, Edge, and mobile browsers (Chrome Android, Safari iOS, Samsung Internet, Firefox Android) — at both the User-Agent string level and the JavaScript fingerprint level, so sites and scripts see a consistent impersonated browser.

Scope

  • In scope: UA string, navigator.* overrides, navigator.userAgentData (Client Hints JS API), screen.*, devicePixelRatio, WebGL vendor/renderer, touch points, languages, and (optionally) Sec-CH-UA* request headers.
  • Out of scope (documented as caveats): TLS/JA3/JA4 fingerprints, HTTP/2 frame fingerprints, and true engine-level differences. WebKitGTK remains the engine; this is UA + JS-surface spoofing only.

Architecture

flowchart TD
    A[Settings UI sovereign://settings] --> B[Bridge settings/set]
    B --> C[BrowserSettings store]
    C --> D[fingerprint module resolve]
    D --> E[FingerprintProfile]
    E --> F[WebKit Settings set_user_agent]
    E --> G[UserContentManager injected JS]
    E --> H[Optional Sec-CH-UA request headers]
    F --> I[WebView]
    G --> I
    H --> I

Data Model

New module src/fingerprint.rs:

pub struct FingerprintProfile {
    pub id: String,
    pub label: String,
    pub category: Category,      // Desktop | Mobile | Bot | Custom
    pub user_agent: String,
    pub platform: String,        // navigator.platform
    pub vendor: String,          // navigator.vendor
    pub app_version: String,     // navigator.appVersion
    pub oscpu: Option<String>,   // navigator.oscpu (Firefox)
    pub product_sub: String,     // navigator.productSub
    pub ua_brands: Vec<(String, String)>, // userAgentData.brands
    pub ua_mobile: bool,         // userAgentData.mobile
    pub ua_platform: String,     // userAgentData.platform
    pub hardware_concurrency: u32,
    pub device_memory: u32,
    pub max_touch_points: u32,   // 0 desktop, 5 mobile
    pub screen: ScreenInfo,      // width/height/avail/colorDepth/pixelDepth
    pub device_pixel_ratio: f64,
    pub languages: Vec<String>,
    pub webgl_vendor: String,
    pub webgl_renderer: String,
}

resolve(settings) -> Option<FingerprintProfile> returns the active profile from user_agent_mode (default | preset | custom), or None when spoofing is off.

Preset Catalog

Built-in presets, grouped by category:

  • Desktop: Chrome (Windows/macOS/Linux), Firefox (Windows/macOS/Linux), Safari (macOS), Edge (Windows).
  • Mobile: Chrome Android, Safari iOS (iPhone), Safari iOS (iPad), Samsung Internet (Android), Firefox Android.
  • Bots: Googlebot, Bingbot.

Each preset carries a fully consistent set of values (UA string, platform, vendor, brands, screen size, DPR, touch points, WebGL strings) so the spoofed surface does not contradict itself.

Injection Strategy

  1. UA string (engine level): call Settings::set_user_agent(Some(ua)) on each tab's WebKitSettings in tab_manager_new_tab().
  2. JS surface (main world, document-start): new www/js/fingerprint-spoof.js embedded via include_str!, plus a small preamble that sets window.__SB_FP = { ...profile json... }. Added to the tab's UserContentManager before the webview is built. A preamble guard skips sovereign: pages (same pattern as the perf probe) so internal pages are unaffected.
  3. Overrides applied by the script:
    • navigator.userAgent, appVersion, platform, vendor, productSub, oscpu, hardwareConcurrency, deviceMemory, maxTouchPoints, language, languages
    • navigator.userAgentData (brands, mobile, platform, and getHighEntropyValues)
    • screen.width/height/availWidth/availHeight/colorDepth/pixelDepth, window.devicePixelRatio
    • WebGLRenderingContext.prototype.getParameter and WebGL2RenderingContext.prototype.getParameter for UNMASKED_VENDOR_WEBGL (37445) and UNMASKED_RENDERER_WEBGL (37446)
    • 'ontouchstart' in window and navigator.maxTouchPoints for mobile
    • Intl.DateTimeFormat().resolvedOptions().timeZone (optional)
  4. Optional request headers: WebView::connect_send_request to add Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform so HTTP-level Client Hints match the JS surface.

Settings & Persistence

Add to BrowserSettings (src/settings.rs):

  • fingerprint_spoof_enabled: bool (default false)
  • user_agent_mode: String (default "default")
  • user_agent_preset: String (default "")
  • user_agent_custom: String (default "")

Update Default, settings_update, settings_save, and settings_load. Include the fields in the NIP-78 payload in src/settings_sync.rs.

Bridge API (src/nostr_bridge.rs)

  • handle_settings_config_json: add fingerprint_spoof_enabled, user_agent_mode, user_agent_preset, user_agent_custom, and a user_agent_presets array (id, label, category, ua) for the UI.
  • handle_settings_set:
    • feature toggle fingerprint_spoof_enabled
    • keys user_agent_mode, user_agent_preset, user_agent_custom
    • on change, reload open tabs so the new identity takes effect (add tab_manager_reload_all_tabs()).

Settings UI (www/settings.html, www/settings.js)

New Browser Identity section:

  • Master toggle: "Spoof browser fingerprint"
  • Preset dropdown, grouped by Desktop / Mobile / Bots / Custom
  • Custom UA text input (enabled when Custom is selected)
  • Link to a fingerprint test page (e.g. browserleaks.com)

applyConfig populates the controls; toggle/save call the bridge.

Optional Enhancements

  • Hamburger menu submenu "Browser Identity" with radio items for quick preset switching (src/tab_manager.rs build_hamburger_menu).
  • Internal debug page sovereign://fingerprint showing the active spoofed values for verification.

Deferred

  • Plan item 9 — Sec-CH-UA* request headers (deferred). Sending Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform (and the high-entropy variants) on subresource requests is not implemented. WebKitGTK's UI process cannot modify subresource request headers: WebView::connect_send_request only fires for the main resource, and there is no UI-process API to rewrite headers for subresources. Doing this correctly requires a web-process extension (a WebKitWebProcessExtension loaded into the WebProcess) that intercepts requests there. Until then the HTTP-level Client Hints do not match the JS navigator.userAgentData surface, which is itself a fingerprinting signal — documented as a known limitation in www/js/fingerprint-spoof.js.

Caveats

  • Engine-level fingerprints (TLS, HTTP/2, JS engine quirks) still identify WebKitGTK; this feature spoofs the UA and JS-visible surface only.
  • All spoofed values must stay internally consistent; inconsistent presets are themselves a fingerprinting signal.
  • Mobile spoofing changes JS-visible values, not the actual rendering viewport.

Testing

  • Verify against browserleaks.com, whatismybrowser.com, and a Client Hints test page for each preset (desktop + mobile).
  • Confirm navigator.userAgent matches the Sec-CH-UA headers and the engine-level UA.
  • Confirm internal sovereign:// pages are unaffected.
  • Confirm settings persist across restart and sync via NIP-78.

File-by-File Change List

  • src/fingerprint.rs (new) — profile model, preset catalog, resolve, JS config
  • src/lib.rs, src/main.rs — register module
  • src/settings.rs — new fields, defaults, update/save/load
  • src/settings_sync.rs — include new fields in payload
  • src/web_context.rs — apply UA string in settings configuration
  • src/nostr_inject.rs — add fingerprint script to content manager
  • www/js/fingerprint-spoof.js (new) — JS overrides
  • src/tab_manager.rs — wire UA + injection into tab creation; reload-all helper
  • src/nostr_bridge.rs — config JSON + set handlers
  • www/settings.html, www/settings.js — Browser Identity UI

Implementation status

Phases 1–2 (data layer, UA application, JS injection) are done and reviewed. Phase 3 (settings UI, bridge wiring, hamburger submenu, debug page) is done.

Phase 3 fixes from review

  • www/js/fingerprint-spoof.js: devicePixelRatio, outerWidth and outerHeight are now installed as own accessor properties of the window instance (via a new defineWindowGetter helper) instead of on Window.prototype. In WebKitGTK these are own accessors of the window instance, so prototype getters were shadowed and had no effect. The getters are still passed through the native-toString masking helper.
  • src/fingerprint.rs: chrome-android, samsung-internet and firefox-android now set ua_architecture = "arm" (Android devices report "arm"). deviceMemory and the Firefox buildID were left unchanged.

Bridge (src/nostr_bridge.rs)

  • handle_settings_config_json: added fingerprint_spoof_enabled, user_agent_mode, user_agent_preset, user_agent_custom, user_agent_presets (presets_json()) and active_fingerprint (serde_json::to_value(resolve(&s)), or null).
  • handle_settings_set:
    • feature fingerprint_spoof_enabled — flips and saves; when turning ON while user_agent_mode == "default" it also sets the mode to "preset".
    • key user_agent_mode — validates default|preset|custom (else 400).
    • key user_agent_preset — validates via preset_by_id (else 400 "Unknown preset"); also sets user_agent_mode = "preset".
    • key user_agent_custom — trims; also sets user_agent_mode = "custom" when non-empty.
    • All of the above schedule tab_manager_apply_fingerprint_all() with glib::idle_add_local_once(...) (never inline — we are inside a URI-scheme request callback and must not reload webviews mid-request). Response is {key, value} with no reload flag.
  • New handle_fingerprint_data_json for sovereign://fingerprint/data.

Settings UI (www/settings.html, www/settings.js)

  • New "Browser Identity" section (after Search, before Network): master toggle (data-feature="fingerprint_spoof_enabled"), grouped preset <select> (Desktop/Mobile/Bots optgroups + Custom…), custom-UA input row (shown only for Custom…), read-only "Active User-Agent" line, and a "View details" link to sovereign://fingerprint.
  • applyConfig adds fingerprint_spoof_enabled to toggleMap and calls the new renderIdentity(d); saveIdentity() / saveCustomUA() call the bridge and then refreshConfig() (re-fetch + applyConfig, no full reload); updateCustomRowVisibility() toggles the custom row; controls are disabled (but visible) when spoofing is off.

Hamburger submenu (src/tab_manager.rs)

  • build_hamburger_menu adds a "Browser Identity" submenu in the Network group. Contents are rebuilt lazily on connect_show (like Recents/Bookmarks) so the checked state is always current. Radio items are all in one group; the active state is set before toggled is connected, so building the menu never fires an action. Helpers: identity_add_radio and identity_apply (persists settings, then glib::idle_add_local_once(tab_manager_apply_fingerprint_all)).

Debug page sovereign://fingerprint

  • New www/fingerprint.html, www/fingerprint.css, www/fingerprint.js, registered in src/embedded_web_content.rs (get_fingerprint_html, WWW_FINGERPRINT_CSS, WWW_FINGERPRINT_JS, and the get_content_with_mime match arms). The fingerprint/data route is dispatched in handle_sovereign_scheme (src/nostr_bridge.rs).
  • The page shows status, a spoofed-vs-real comparison table (the real column is read live from the unspoofed sovereign:// page), known limitations, and links to browserleaks / whatismybrowser / creepjs.

Verification

  • cargo build — clean, no warnings.
  • cargo test — 38 passed, 0 failed.
  • node --check www/settings.js, node --check www/fingerprint.js, and node --check on a temp copy of www/js/fingerprint-spoof.js (with ({}); appended) — all pass.