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).
274 lines
12 KiB
Markdown
274 lines
12 KiB
Markdown
# 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
|
||
|
||
```mermaid
|
||
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`:
|
||
|
||
```rust
|
||
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.
|