mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 10:08:04 +02:00
refactor: better error handling and prevention of creating ephemeral password protected profiles
This commit is contained in:
@@ -702,6 +702,7 @@ mod tests {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ mod tests {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1734,6 +1734,7 @@
|
||||
"profileNotProtected": "プロファイルはパスワード保護されていません",
|
||||
"profileAlreadyProtected": "プロファイルはすでにパスワード保護されています",
|
||||
"profileRunning": "プロファイルの実行中はこの操作を実行できません",
|
||||
"profileEphemeral": "エフェメラル プロファイルにはパスワードを設定できません — 終了時にデータが消去されます。",
|
||||
"profileMissingSalt": "プロファイルに暗号化ソルトがありません",
|
||||
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
|
||||
"invalidProfileId": "無効なプロファイルIDです",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1734,6 +1734,7 @@
|
||||
"profileNotProtected": "Профиль не защищён паролем",
|
||||
"profileAlreadyProtected": "Профиль уже защищён паролем",
|
||||
"profileRunning": "Невозможно выполнить это действие, пока профиль запущен",
|
||||
"profileEphemeral": "Эфемерные профили не могут быть защищены паролем — их данные удаляются при выходе.",
|
||||
"profileMissingSalt": "У профиля отсутствует соль шифрования",
|
||||
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
|
||||
"invalidProfileId": "Недействительный идентификатор профиля",
|
||||
|
||||
@@ -1734,6 +1734,7 @@
|
||||
"profileNotProtected": "配置文件未受密码保护",
|
||||
"profileAlreadyProtected": "配置文件已受密码保护",
|
||||
"profileRunning": "配置文件运行时无法执行此操作",
|
||||
"profileEphemeral": "临时配置文件无法设置密码 — 退出时数据会被清除。",
|
||||
"profileMissingSalt": "配置文件缺少加密盐",
|
||||
"profileLocked": "配置文件已锁定。请先输入密码。",
|
||||
"invalidProfileId": "配置文件 ID 无效",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user