diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index 96e651f..3d38e86 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -1803,7 +1803,7 @@ dependencies = [ [[package]] name = "donutbrowser" -version = "0.29.2" +version = "0.29.3" dependencies = [ "aes 0.9.1", "aes-gcm 0.11.0", diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 891de91..6c7946e 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -56,6 +56,12 @@ pub struct ApiProfile { /// Such a profile cannot be launched locally, and must only ever run on a /// remote host of its own OS — Chromium profile state is OS-specific. pub is_cross_os: bool, + /// The fingerprint operating system set at creation via `wayfern_config.os` + /// (`"windows"`, `"macos"`, `"linux"`, `"android"` or `"ios"`), or `null` + /// when the fingerprint was generated for the host. This is what the browser + /// reports to sites; `host_os` is the machine the profile was created on and + /// is a different thing. + pub fingerprint_os: Option, } impl From<&crate::profile::types::BrowserProfile> for ApiProfile { @@ -83,6 +89,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile { cloud_sync_enabled: profile.is_sync_enabled(), host_os: profile.resolved_os().map(|os| os.to_string()), is_cross_os: profile.is_cross_os(), + fingerprint_os: profile.wayfern_config.as_ref().and_then(|c| c.os.clone()), } } } @@ -1228,15 +1235,19 @@ async fn get_profile( /// locally (this endpoint does not download new versions); 400 if none is. /// - Omitting the matching `wayfern_config`, or passing an /// empty object `{}`, generates a fresh fingerprint automatically. +/// - `wayfern_config.os` picks the fingerprint OS (`"windows"`, `"macos"`, +/// `"linux"`, `"android"`, `"ios"`). Omit it to match the host. Any other +/// OS is cross-OS spoofing and needs an active Pro plan; 402 otherwise. +/// A `wayfern_config` that fails to parse is a 400, never a silent default. #[utoipa::path( post, path = "/v1/profiles", request_body = CreateProfileRequest, responses( (status = 200, description = "Profile created successfully", body = ApiProfileResponse), - (status = 400, description = "Invalid browser, or no downloaded version available"), + (status = 400, description = "Invalid browser, invalid wayfern_config, or no downloaded version available"), (status = 401, description = "Unauthorized"), - (status = 402, description = "Selected proxy requires payment"), + (status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint requires Pro"), (status = 500, description = "Internal server error") ), security( @@ -1292,13 +1303,33 @@ async fn create_profile( } }; - // Parse wayfern config if provided - let wayfern_config = if let Some(config) = &request.wayfern_config { - serde_json::from_value(config.clone()).ok() - } else { - None + // Parse wayfern config if provided. A malformed config is a 400, never a + // silent fallback: swallowing it here produced a host-OS profile from a + // request that explicitly asked for another OS, with a 200 and no diagnostic. + let wayfern_config: Option = match &request.wayfern_config + { + Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid wayfern_config: {e}"), + ) + })?), + None => None, }; + // Cross-OS fingerprints are a paid capability. The Tauri command, the + // importer and MCP each check this; REST did not, so the restriction was + // bypassable through this endpoint alone. + if !crate::cloud_auth::CLOUD_AUTH + .is_fingerprint_os_allowed(wayfern_config.as_ref().and_then(|c| c.os.as_deref())) + .await + { + return Err(( + StatusCode::PAYMENT_REQUIRED, + serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string(), + )); + } + // Reject a dead/unreachable proxy or VPN before creating the profile. A 402 // (expired proxy subscription) maps to 402; anything else is a 400. if let Err(err) = @@ -3769,10 +3800,19 @@ async fn import_profiles_api( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { - let wayfern_config: Option = request - .wayfern_config - .as_ref() - .and_then(|config| serde_json::from_value(config.clone()).ok()); + // A malformed config is a 400. Dropping it silently also dropped the `os` + // it carried, which made `is_fingerprint_os_allowed(None)` return true and + // bypassed the Pro gate below while generating host-OS fingerprints. + let wayfern_config: Option = + match request.wayfern_config.as_ref() { + Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid wayfern_config: {e}"), + ) + })?), + None => None, + }; // The Pro gate for fingerprint OS spoofing lives inside import_profiles, so // every surface inherits it; manager_error_response maps the code to 402. @@ -4148,6 +4188,55 @@ mod tests { assert!(parsed.wayfern_config.is_none()); } + #[test] + fn wayfern_config_os_survives_the_untyped_request_field() { + // `wayfern_config` arrives as an untyped Value and is only turned into a + // WayfernConfig inside the handler. That second hop is where an `os` used + // to be lost, so assert it round-trips. + let json = r#"{"name": "p", "browser": "wayfern", "wayfern_config": {"os": "android"}}"#; + let parsed: CreateProfileRequest = serde_json::from_str(json).expect("body must parse"); + let config: crate::wayfern_manager::WayfernConfig = + serde_json::from_value(parsed.wayfern_config.expect("config present")) + .expect("a well-formed config must parse"); + assert_eq!(config.os.as_deref(), Some("android")); + } + + #[test] + fn malformed_wayfern_config_is_an_error_not_a_default() { + // `fingerprint` is a JSON-encoded string, so passing an object fails to + // parse. The handler must surface that as a 400: previously `.ok()` threw + // the whole config away, dropping the caller's `os` with it and returning + // a host-OS profile with 200 and no diagnostic. + let json = r#"{"os": "android", "fingerprint": {"platform": "Linux armv81"}}"#; + let value: serde_json::Value = serde_json::from_str(json).expect("value parses"); + let parsed = serde_json::from_value::(value); + assert!( + parsed.is_err(), + "an object fingerprint must not silently deserialize" + ); + } + + #[test] + fn api_profile_exposes_the_fingerprint_os_separately_from_host_os() { + // host_os is the machine; fingerprint_os is what the browser reports. A + // cross-OS profile has to be distinguishable through the API alone. + let spec = ApiDoc::openapi(); + let spec = serde_json::to_value(&spec).expect("spec serializes"); + let props = &spec["components"]["schemas"]["ApiProfile"]["properties"]; + assert!( + props.get("fingerprint_os").is_some(), + "ApiProfile must publish fingerprint_os" + ); + let required = spec["components"]["schemas"]["ApiProfile"]["required"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + !required.iter().any(|r| r == "fingerprint_os"), + "fingerprint_os is nullable and must stay optional" + ); + } + #[test] fn create_profile_browser_validation_matches_supported_engines() { // The handler rejects anything that isn't a launchable engine; this is the diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 935de6e..becc4b4 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -1138,6 +1138,11 @@ impl CloudAuthManager { /// is nothing to fetch and nothing wrong. pub async fn request_wayfern_token(&self) -> Result<(), String> { if !self.is_entitled_to_wayfern_token().await { + // Ok(()) here means callers log nothing, so a session that declined to + // mint left no trace at all and looked identical to one that succeeded. + log::info!( + "Skipping wayfern token request: the cached plan does not include browser automation" + ); self.clear_wayfern_token().await; return Ok(()); } @@ -1273,9 +1278,11 @@ impl CloudAuthManager { } } - // Refresh profile data periodically + // Refresh profile data periodically. A failure here leaves the cached + // plan stale, which silently gates paid features, so it belongs at warn + // rather than debug where the shipped log level hides it. if let Err(e) = CLOUD_AUTH.fetch_profile().await { - log::debug!("Failed to refresh cloud profile: {e}"); + log::warn!("Failed to refresh cloud profile: {e}"); } // Reconnect profile lock manager if needed @@ -1291,7 +1298,14 @@ impl CloudAuthManager { // Refresh wayfern token every 10 hours (60 iterations of 10-minute loop). // request_wayfern_token owns the entitlement check and clears the cached // token when the plan doesn't include automation. - if wayfern_refresh_counter >= 60 { + // + // Also mint one as soon as the plan starts granting it. `fetch_profile` + // above picks up an upgrade within ten minutes, but nothing watched that + // transition, so a session that signed in before upgrading stayed + // tokenless for up to ten hours while reporting the feature as unlocked. + let missing_entitled_token = CLOUD_AUTH.is_entitled_to_wayfern_token().await + && CLOUD_AUTH.get_wayfern_token().await.is_none(); + if wayfern_refresh_counter >= 60 || missing_entitled_token { wayfern_refresh_counter = 0; if let Err(e) = CLOUD_AUTH.request_wayfern_token().await { log::warn!("Failed to refresh wayfern token: {e}"); @@ -1411,6 +1425,20 @@ pub async fn cloud_get_user() -> Result, String> { pub async fn cloud_refresh_profile() -> Result { let mut user = CLOUD_AUTH.fetch_profile().await?; user.entitlements = Some(user.entitlements()); + + // Minting the token is what actually unlocks cross-OS fingerprints, and it + // only happened at login, at startup and once every 10 hours. An account + // that upgraded after its last sign-in therefore refreshed into the correct + // entitlements while still holding no token, and "Refresh" did not fix it. + // Only mint when one is genuinely missing, so this stays a no-op afterwards. + if CLOUD_AUTH.is_entitled_to_wayfern_token().await + && CLOUD_AUTH.get_wayfern_token().await.is_none() + { + if let Err(e) = CLOUD_AUTH.request_wayfern_token().await { + log::warn!("Refresh could not obtain a wayfern token: {e}"); + } + } + Ok(user) } diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 24f29ff..c54240f 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -2,7 +2,7 @@ use crate::browser::{create_browser, BrowserType}; use crate::cloud_auth::CLOUD_AUTH; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; use crate::events; -use crate::profile::types::{get_host_os, BrowserProfile, SyncMode}; +use crate::profile::types::{get_host_os, is_host_os, BrowserProfile, SyncMode}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::WayfernConfig; use std::fs::{self, create_dir_all}; @@ -384,11 +384,23 @@ impl ProfileManager { }; // Backfill host_os from browser config for profiles created before - // the field existed (or synced without it). - if profile.host_os.is_none() { - let inferred_os = profile.resolved_os().map(str::to_string); - if let Some(os) = inferred_os { - profile.host_os = Some(os); + // the field existed (or synced without it), and repair any profile + // already stamped with a fingerprint-only OS. + // + // Only a real host OS may be stored here. The fallback in + // `resolved_os` reads `wayfern_config.os`, which is a fingerprint OS + // and may be "android"/"ios". Persisting that made `is_cross_os` + // permanently true and locked the profile out of every local launch, + // with no way to undo it from the UI. Leaving `host_os` as None keeps + // the profile launchable, which is what it was before the field. + let needs_repair = profile.host_os.as_deref().is_some_and(|os| !is_host_os(os)); + if profile.host_os.is_none() || needs_repair { + let inferred_os = profile + .resolved_os() + .filter(|os| is_host_os(os)) + .map(str::to_string); + if inferred_os != profile.host_os { + profile.host_os = inferred_os; if let Ok(json) = serde_json::to_string_pretty(&profile) { let _ = atomic_write(&metadata_file, json.as_bytes()); } @@ -1924,7 +1936,7 @@ pub async fn create_browser_profile_new( .is_fingerprint_os_allowed(fingerprint_os) .await { - return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string()); + return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string()); } // A dead/unreachable proxy or VPN (or a 402 from an expired proxy @@ -1968,7 +1980,7 @@ pub async fn update_wayfern_config( .is_fingerprint_os_allowed(config.os.as_deref()) .await { - return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string()); + return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string()); } let profile_manager = ProfileManager::instance(); diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 024325c..44f4118 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -103,6 +103,16 @@ pub fn get_host_os() -> String { } } +/// Whether a value is one `get_host_os` can actually return. +/// +/// A fingerprint OS is a wider set than a host OS: `"android"` and `"ios"` are +/// valid fingerprints but no machine ever reports them as its host. Storing one +/// in `host_os` makes `is_cross_os` permanently true, which bars the profile +/// from every local launch path on the very machine that created it. +pub fn is_host_os(value: &str) -> bool { + matches!(value, "macos" | "windows" | "linux") +} + impl BrowserProfile { /// Get the path to the profile data directory (profiles/{uuid}/profile) pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf { @@ -138,3 +148,28 @@ impl BrowserProfile { self.sync_mode == SyncMode::Encrypted } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_os_is_always_a_valid_host_os() { + // The invariant the host_os backfill guard rests on: whatever this machine + // reports must satisfy is_host_os, on every platform. + assert!(is_host_os(&get_host_os())); + } + + #[test] + fn mobile_fingerprint_targets_are_not_host_operating_systems() { + // Backfilling host_os from a fingerprint OS used to store these, and since + // get_host_os can never return them, is_cross_os stayed true forever and + // the profile could not be launched on the machine that created it. + for os in ["macos", "windows", "linux"] { + assert!(is_host_os(os), "{os} must count as a host OS"); + } + for os in ["android", "ios", "", "Windows", "chromeos"] { + assert!(!is_host_os(os), "{os} must not be stored as a host OS"); + } + } +} diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 4c43874..8db4b94 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -657,9 +657,13 @@ impl WayfernManager { let fingerprint_json = serde_json::to_string(&fingerprint) .map_err(|e| format!("Failed to serialize fingerprint: {e}"))?; + // Report the platform the engine actually produced alongside the one that + // was asked for. Logging only the request made this line useless for + // diagnosing a fingerprint that came back as something else. log::info!( - "Generated Wayfern fingerprint for OS: {}, fields: {:?}", + "Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}", os, + fingerprint.get("platform").and_then(|p| p.as_str()), fingerprint .as_object() .map(|o| o.keys().collect::>())