diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 5196ea8..dbce456 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -702,6 +702,7 @@ mod tests { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, } } diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 1d94ea9..f287245 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -1219,6 +1219,7 @@ mod tests { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, }; let path = profile.get_profile_data_path(&profiles_dir); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index af123d3..1ba9616 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -7,10 +7,78 @@ use crate::platform_browser; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::{WayfernConfig, WayfernManager}; +use chrono::{Datelike, TimeZone, Utc}; use serde::Serialize; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sysinfo::System; + +/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a +/// low-traffic window for the average user; everyone shares the same UTC +/// instant so the value here doesn't track any one user's local schedule. +const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4; + +/// File name of the per-profile marker recording the last fingerprint +/// refresh time. Lives at `//.last-fp-refresh` +/// and is excluded from cloud sync (see `sync::manifest`) so each device +/// runs its own refresh schedule. +const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh"; + +/// Most recent rollover instant on or before `now` — used as a staleness +/// threshold for Wayfern fingerprints. Anything generated before this +/// timestamp is considered stale and gets regenerated on next launch. +fn most_recent_rollover_epoch() -> u64 { + let now = Utc::now(); + let today_threshold = Utc + .with_ymd_and_hms( + now.year(), + now.month(), + now.day(), + FINGERPRINT_ROLLOVER_HOUR_UTC, + 0, + 0, + ) + .single() + .unwrap_or(now); + let threshold = if now >= today_threshold { + today_threshold + } else { + today_threshold - chrono::Duration::days(1) + }; + threshold.timestamp().max(0) as u64 +} + +fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf { + profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE) +} + +/// Read the epoch-seconds timestamp stored in the per-profile refresh marker. +/// Returns `None` if the file doesn't exist or its content can't be parsed — +/// both signal "needs a refresh" to the caller. +fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option { + let path = last_fp_refresh_path(profile_id, profiles_dir); + let content = std::fs::read_to_string(&path).ok()?; + content.trim().parse::().ok() +} + +/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for +/// this profile. Failure is logged but never propagated — a missing marker +/// only costs an extra regen on the next launch, never blocks one. +fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) { + let path = last_fp_refresh_path(profile_id, profiles_dir); + if let Some(parent) = path.parent() { + if !parent.exists() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}"); + return; + } + } + } + if let Err(e) = std::fs::write(&path, ts.to_string()) { + log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}"); + } +} + pub struct BrowserRunner { pub profile_manager: &'static ProfileManager, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, @@ -544,12 +612,32 @@ impl BrowserRunner { wayfern_config.proxy ); - // Check if we need to generate a new fingerprint on every launch + // Decide whether to (re)generate the Wayfern fingerprint for this + // launch. Two triggers: + // + // 1. `randomize_fingerprint_on_launch = true` — explicit per-launch + // randomization the user opted into. + // 2. The fingerprint hasn't been refreshed since the most recent + // rollover instant. We check the per-profile marker file first + // (`.last-fp-refresh`); if it's absent we fall back to + // `profile.created_at` so brand-new profiles don't immediately + // regenerate the fingerprint they were just created with. + // Profiles with neither (truly legacy) are treated as ancient + // and refresh on next launch — once. let mut updated_profile = profile.clone(); - if wayfern_config.randomize_fingerprint_on_launch == Some(true) { + let stale_threshold = most_recent_rollover_epoch(); + let profile_id_str = profile.id.to_string(); + let profiles_dir_for_marker = self.profile_manager.get_profiles_dir(); + let effective_last_refresh = + read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at); + let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold); + let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true); + if randomize_every_launch || is_stale_profile { log::info!( - "Generating random fingerprint for Wayfern profile: {}", - profile.name + "Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})", + profile.name, + randomize_every_launch, + is_stale_profile ); // Create a config copy without the existing fingerprint to force generation of a new one @@ -571,10 +659,24 @@ impl BrowserRunner { // Update the config with the new fingerprint for launching wayfern_config.fingerprint = Some(new_fingerprint.clone()); - // Save the updated fingerprint to the profile so it persists + // Write the marker so the next launch within the same rollover + // window skips this branch. The marker is excluded from cloud + // sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each + // device's refresh schedule is independent. + let now_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(stale_threshold); + write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch); + + // Save the updated fingerprint to the profile so it persists. let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default(); updated_wayfern_config.fingerprint = Some(new_fingerprint); - updated_wayfern_config.randomize_fingerprint_on_launch = Some(true); + // Preserve the user's randomize-on-launch preference rather than + // forcing it on. The rollover path must not silently flip this + // flag for users who only opted into the scheduled refresh. + updated_wayfern_config.randomize_fingerprint_on_launch = + wayfern_config.randomize_fingerprint_on_launch; if wayfern_config.os.is_some() { updated_wayfern_config.os = wayfern_config.os.clone(); } diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 20bc07e..5808d9c 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -280,6 +280,7 @@ mod tests { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b600fd..4804c85 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1161,6 +1161,7 @@ async fn generate_sample_fingerprint( created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, }; if browser == "camoufox" { diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index c122dc7..dab19e3 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -185,6 +185,7 @@ impl ProfileManager { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, }; match self @@ -287,6 +288,7 @@ impl ProfileManager { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, }; match self @@ -343,6 +345,12 @@ impl ProfileManager { created_by_email: None, dns_blocklist, password_protected: false, + created_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ), }; // Save profile info @@ -989,6 +997,12 @@ impl ProfileManager { created_by_email: None, dns_blocklist: source.dns_blocklist, password_protected: false, + created_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ), }; self.save_profile(&new_profile)?; diff --git a/src-tauri/src/profile/password.rs b/src-tauri/src/profile/password.rs index 944d329..1c309ee 100644 --- a/src-tauri/src/profile/password.rs +++ b/src-tauri/src/profile/password.rs @@ -233,6 +233,15 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul return Err(err_code("PROFILE_ALREADY_PROTECTED")); } + // Ephemeral profiles live in RAM-backed dirs that get wiped on quit, so + // there's no on-disk data to encrypt. The two features are mutually + // exclusive by design — fail loudly rather than silently producing a + // half-broken state where `password_protected` is true but the encrypted + // dir vanishes between launches. + if profile.ephemeral { + return Err(err_code("PROFILE_EPHEMERAL")); + } + if profile .process_id .is_some_and(crate::proxy_storage::is_process_running) diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 30526c1..4e3dfe3 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -73,6 +73,11 @@ pub struct BrowserProfile { /// Decryption goes to a RAM-backed ephemeral dir, never to disk. #[serde(default)] pub password_protected: bool, + /// Profile creation timestamp (epoch seconds, UTC). `None` for legacy + /// profiles that pre-date this field — those are treated as ancient by + /// any staleness check. + #[serde(default)] + pub created_at: Option, } pub fn default_release_type() -> String { diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 9a7eef8..51562f8 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -585,6 +585,7 @@ impl ProfileImporter { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, }; match self @@ -666,6 +667,7 @@ impl ProfileImporter { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: None, }; match self @@ -718,6 +720,12 @@ impl ProfileImporter { created_by_email: None, dns_blocklist: None, password_protected: false, + created_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ), }; self.profile_manager.save_profile(&profile)?; diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index 2fccbc8..214f325 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -52,6 +52,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "**/BrowserMetrics*", "**/.DS_Store", ".donut-sync/**", + // Local-only marker recording when Wayfern last refreshed this profile's + // fingerprint. Each device decides its own refresh cadence, so syncing + // this would cause one device's refresh to silence others. + ".last-fp-refresh", ]; /// A single file entry in the manifest diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index f1ca93b..2a3af32 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -1931,9 +1931,7 @@ export function ProfilesDataTable({ const meta = table.options.meta as TableMeta; const profile = row.original; const browser = profile.browser; - const IconComponent = profile.password_protected - ? LuLock - : getProfileIcon(profile); + const IconComponent = getProfileIcon(profile); const isCrossOs = isCrossOsProfile(profile); const isSelected = meta.isProfileSelected(profile.id); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 060a855..f952b78 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1734,6 +1734,7 @@ "profileNotProtected": "Profile is not password protected", "profileAlreadyProtected": "Profile is already password protected", "profileRunning": "Cannot perform this action while the profile is running", + "profileEphemeral": "Ephemeral profiles cannot be password-protected — their data wipes on quit.", "profileMissingSalt": "Profile is missing its encryption salt", "profileLocked": "Profile is locked. Enter the password first.", "invalidProfileId": "Invalid profile id", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a4195ee..b625480 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1734,6 +1734,7 @@ "profileNotProtected": "El perfil no está protegido por contraseña", "profileAlreadyProtected": "El perfil ya está protegido por contraseña", "profileRunning": "No se puede realizar esta acción mientras el perfil está en ejecución", + "profileEphemeral": "Los perfiles efímeros no pueden tener contraseña — sus datos se borran al salir.", "profileMissingSalt": "Al perfil le falta su sal de cifrado", "profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.", "invalidProfileId": "ID de perfil no válido", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e26f2ba..3bb4164 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1734,6 +1734,7 @@ "profileNotProtected": "Le profil n'est pas protégé par mot de passe", "profileAlreadyProtected": "Le profil est déjà protégé par mot de passe", "profileRunning": "Impossible d'effectuer cette action pendant que le profil est en cours d'exécution", + "profileEphemeral": "Les profils éphémères ne peuvent pas être protégés par mot de passe — leurs données s'effacent à la fermeture.", "profileMissingSalt": "Le sel de chiffrement du profil est manquant", "profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.", "invalidProfileId": "Identifiant de profil non valide", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4f74464..b472746 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1734,6 +1734,7 @@ "profileNotProtected": "プロファイルはパスワード保護されていません", "profileAlreadyProtected": "プロファイルはすでにパスワード保護されています", "profileRunning": "プロファイルの実行中はこの操作を実行できません", + "profileEphemeral": "エフェメラル プロファイルにはパスワードを設定できません — 終了時にデータが消去されます。", "profileMissingSalt": "プロファイルに暗号化ソルトがありません", "profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。", "invalidProfileId": "無効なプロファイルIDです", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7efe2e9..43110d0 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1734,6 +1734,7 @@ "profileNotProtected": "O perfil não está protegido por senha", "profileAlreadyProtected": "O perfil já está protegido por senha", "profileRunning": "Não é possível realizar esta ação enquanto o perfil está em execução", + "profileEphemeral": "Perfis efêmeros não podem ser protegidos por senha — seus dados são apagados ao sair.", "profileMissingSalt": "O perfil está sem o sal de criptografia", "profileLocked": "O perfil está bloqueado. Digite a senha primeiro.", "invalidProfileId": "ID de perfil inválido", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2e62bf5..bb34fa7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1734,6 +1734,7 @@ "profileNotProtected": "Профиль не защищён паролем", "profileAlreadyProtected": "Профиль уже защищён паролем", "profileRunning": "Невозможно выполнить это действие, пока профиль запущен", + "profileEphemeral": "Эфемерные профили не могут быть защищены паролем — их данные удаляются при выходе.", "profileMissingSalt": "У профиля отсутствует соль шифрования", "profileLocked": "Профиль заблокирован. Сначала введите пароль.", "invalidProfileId": "Недействительный идентификатор профиля", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 10d6c49..8c22f0a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1734,6 +1734,7 @@ "profileNotProtected": "配置文件未受密码保护", "profileAlreadyProtected": "配置文件已受密码保护", "profileRunning": "配置文件运行时无法执行此操作", + "profileEphemeral": "临时配置文件无法设置密码 — 退出时数据会被清除。", "profileMissingSalt": "配置文件缺少加密盐", "profileLocked": "配置文件已锁定。请先输入密码。", "invalidProfileId": "配置文件 ID 无效", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 71a63e0..9a27aa3 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -11,6 +11,7 @@ export type BackendErrorCode = | "PROFILE_NOT_PROTECTED" | "PROFILE_ALREADY_PROTECTED" | "PROFILE_RUNNING" + | "PROFILE_EPHEMERAL" | "PROFILE_MISSING_SALT" | "PROFILE_LOCKED" | "INVALID_PROFILE_ID" @@ -74,6 +75,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.profileAlreadyProtected"); case "PROFILE_RUNNING": return t("backendErrors.profileRunning"); + case "PROFILE_EPHEMERAL": + return t("backendErrors.profileEphemeral"); case "PROFILE_MISSING_SALT": return t("backendErrors.profileMissingSalt"); case "PROFILE_LOCKED": diff --git a/src/lib/browser-utils.ts b/src/lib/browser-utils.ts index c2f8c51..f466a88 100644 --- a/src/lib/browser-utils.ts +++ b/src/lib/browser-utils.ts @@ -9,6 +9,7 @@ import { FaFire, FaFirefox, } from "react-icons/fa"; +import { LuLock } from "react-icons/lu"; /** * Map internal browser names to display names @@ -42,7 +43,13 @@ export function getBrowserIcon(browserType: string) { export function getProfileIcon(profile: { browser: string; ephemeral?: boolean; + password_protected?: boolean; }) { + // `password_protected` and `ephemeral` are mutually exclusive (the backend + // rejects setting a password on an ephemeral profile), so the order here + // doesn't matter — checking lock first only matters if the invariant is + // ever violated, in which case showing the lock is the safer default. + if (profile.password_protected) return LuLock; if (profile.ephemeral) return FaFire; return getBrowserIcon(profile.browser); }