refactor: better error handling and prevention of creating ephemeral password protected profiles

This commit is contained in:
zhom
2026-05-12 13:03:34 +04:00
parent 06b5a41b37
commit 2633e2ba09
20 changed files with 170 additions and 9 deletions
+1
View File
@@ -702,6 +702,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
}
}
+1
View File
@@ -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);
+108 -6
View File
@@ -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 `<profiles_dir>/<profile_id>/.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<u64> {
let path = last_fp_refresh_path(profile_id, profiles_dir);
let content = std::fs::read_to_string(&path).ok()?;
content.trim().parse::<u64>().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();
}
+1
View File
@@ -280,6 +280,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
created_at: None,
}
}
+1
View File
@@ -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" {
+14
View File
@@ -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)?;
+9
View File
@@ -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)
+5
View File
@@ -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<u64>,
}
pub fn default_release_type() -> String {
+8
View File
@@ -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)?;
+4
View File
@@ -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