From b4a8fd04d8c0d1e1e06d360c1ee11ab7b43c1b3c Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 10 May 2026 04:32:59 +0400 Subject: [PATCH] feat: password protected profiles --- scripts/sync-test-harness.mjs | 13 +- src-tauri/src/auto_updater.rs | 1 + src-tauri/src/browser.rs | 1 + src-tauri/src/browser_runner.rs | 23 +- src-tauri/src/ephemeral_dirs.rs | 3 +- src-tauri/src/lib.rs | 21 + src-tauri/src/profile/encryption.rs | 702 +++++++++++ src-tauri/src/profile/manager.rs | 4 + src-tauri/src/profile/mod.rs | 2 + src-tauri/src/profile/password.rs | 1251 ++++++++++++++++++++ src-tauri/src/profile/types.rs | 4 + src-tauri/src/profile_importer.rs | 3 + src-tauri/src/settings_manager.rs | 7 + src/app/page.tsx | 84 ++ src/components/create-profile-dialog.tsx | 93 ++ src/components/profile-data-table.tsx | 13 +- src/components/profile-info-dialog.tsx | 49 + src/components/profile-password-dialog.tsx | 302 +++++ src/components/settings-dialog.tsx | 25 + src/i18n/locales/en.json | 82 +- src/i18n/locales/es.json | 82 +- src/i18n/locales/fr.json | 82 +- src/i18n/locales/ja.json | 82 +- src/i18n/locales/pt.json | 82 +- src/i18n/locales/ru.json | 82 +- src/i18n/locales/zh.json | 82 +- src/lib/backend-errors.ts | 121 ++ src/types.ts | 1 + 28 files changed, 3253 insertions(+), 44 deletions(-) create mode 100644 src-tauri/src/profile/encryption.rs create mode 100644 src-tauri/src/profile/password.rs create mode 100644 src/components/profile-password-dialog.tsx create mode 100644 src/lib/backend-errors.ts diff --git a/scripts/sync-test-harness.mjs b/scripts/sync-test-harness.mjs index cd252d0..775c8c1 100755 --- a/scripts/sync-test-harness.mjs +++ b/scripts/sync-test-harness.mjs @@ -171,10 +171,21 @@ async function startMinio(minioBin) { async function buildDonutSync() { log("Building donut-sync..."); + // `nest build` runs incremental tsc, which silently skips emit when + // tsconfig.build.tsbuildinfo says nothing changed — even if dist/ was + // wiped. Drop the cache so we always produce a fresh dist. + const syncDir = path.join(ROOT_DIR, "donut-sync"); + await rm(path.join(syncDir, "tsconfig.build.tsbuildinfo"), { + force: true, + }); + await rm(path.join(syncDir, "dist"), { recursive: true, force: true }); execSync("pnpm build", { - cwd: path.join(ROOT_DIR, "donut-sync"), + cwd: syncDir, stdio: process.env.VERBOSE ? "inherit" : "ignore", }); + if (!existsSync(path.join(syncDir, "dist", "main.js"))) { + throw new Error("donut-sync build did not produce dist/main.js"); + } log("donut-sync built"); } diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 7898653..5196ea8 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -701,6 +701,7 @@ mod tests { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, } } diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 74dc7aa..1d94ea9 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -1218,6 +1218,7 @@ mod tests { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, }; 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 4726f17..451c45d 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -291,8 +291,12 @@ impl BrowserRunner { ); } - // Create ephemeral dir for ephemeral profiles - let override_profile_path = if profile.ephemeral { + // Create ephemeral dir for ephemeral or password-protected profiles + let override_profile_path = if profile.password_protected { + let dir = crate::profile::password::prepare_for_launch(profile) + .map_err(|e| -> Box { e.into() })?; + Some(dir) + } else if profile.ephemeral { let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()) .map_err(|e| -> Box { e.into() })?; Some(dir) @@ -542,8 +546,11 @@ impl BrowserRunner { ); } - // Create ephemeral dir for ephemeral profiles - if profile.ephemeral { + // Create ephemeral dir for ephemeral or password-protected profiles + if profile.password_protected { + crate::profile::password::prepare_for_launch(profile) + .map_err(|e| -> Box { e.into() })?; + } else if profile.ephemeral { crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()) .map_err(|e| -> Box { e.into() })?; } @@ -1431,7 +1438,9 @@ impl BrowserRunner { ); } - if profile.ephemeral { + if profile.password_protected { + crate::profile::password::complete_after_quit(profile); + } else if profile.ephemeral { crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); } @@ -1771,7 +1780,9 @@ impl BrowserRunner { ); } - if profile.ephemeral { + if profile.password_protected { + crate::profile::password::complete_after_quit(profile); + } else if profile.ephemeral { crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); } diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index b69ce53..20bc07e 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -240,7 +240,7 @@ fn cleanup_legacy_dirs() { } pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf { - if profile.ephemeral { + if profile.ephemeral || profile.password_protected { if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) { return dir; } @@ -279,6 +279,7 @@ mod tests { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 41128af..b954931 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -72,6 +72,11 @@ use profile::manager::{ update_wayfern_config, }; +use profile::password::{ + change_profile_password, is_profile_locked, lock_profile, remove_profile_password, + set_profile_password, unlock_profile, +}; + use browser_version_manager::{ fetch_browser_versions_cached_first, fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first, get_supported_browsers, @@ -1133,6 +1138,7 @@ async fn generate_sample_fingerprint( created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, }; if browser == "camoufox" { @@ -1769,6 +1775,13 @@ pub fn run() { } } + // Re-encrypt password-protected profiles when the browser + // exits naturally (user closing the window) — the explicit + // kill path in browser_runner.rs handles app-driven stops. + if !is_running && profile.password_protected { + crate::profile::password::complete_after_quit(&profile); + } + last_running_states.insert(profile_id, is_running); } else { // Update the state even if unchanged to ensure we have it tracked @@ -2092,6 +2105,13 @@ pub fn run() { // DNS blocklist commands dns_blocklist::get_dns_blocklist_cache_status, dns_blocklist::refresh_dns_blocklists, + // Profile password commands + set_profile_password, + change_profile_password, + remove_profile_password, + unlock_profile, + lock_profile, + is_profile_locked, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -2138,6 +2158,7 @@ mod tests { "generate_sample_fingerprint", "cloud_get_wayfern_token", "cloud_refresh_wayfern_token", + "lock_profile", ]; // Extract command names from the generate_handler! macro in this file diff --git a/src-tauri/src/profile/encryption.rs b/src-tauri/src/profile/encryption.rs new file mode 100644 index 0000000..7ea36e4 --- /dev/null +++ b/src-tauri/src/profile/encryption.rs @@ -0,0 +1,702 @@ +//! Per-file encryption for password-protected profiles. +//! +//! Each on-disk file in `profiles/{uuid}/profile/` has: +//! - **Filename**: `urlsafe_no_pad(HMAC-SHA256(profile_key, plaintext_relpath))[..32]`. +//! Deterministic so cross-machine sync sees stable filenames; same plaintext +//! path with same key always produces the same on-disk name. +//! - **Content**: `nonce(12B) || AES-256-GCM(profile_key, path_len(2B-LE) || plaintext_path || file_bytes)`. +//! The plaintext relpath is encoded inside the ciphertext so a launch can +//! reconstruct the directory tree without a separate manifest. +//! +//! Wrong password fails the AES-GCM auth tag on the first decrypt, which +//! doubles as password verification. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use ring::hmac; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; + +use crate::sync::encryption::{decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt}; + +/// Length of the on-disk HMAC filename in chars. +const HMAC_FILENAME_LEN: usize = 32; + +/// Marker file written into encrypted profile dirs so launch code can verify +/// the password before attempting to decrypt actual user data files. +const VERIFY_FILE_NAME: &str = ".donut-pw-verify"; +const VERIFY_FILE_PATH: &str = "__donut_pw_verify__"; + +lazy_static::lazy_static! { + /// In-memory cache of derived per-profile encryption keys, keyed by profile UUID. + /// Only populated while a profile is unlocked / running. Never persisted. + static ref KEY_CACHE: Mutex> = Mutex::new(HashMap::new()); +} + +#[derive(Debug, thiserror::Error)] +pub enum PasswordError { + #[error("io error: {0}")] + Io(String), + #[error("encryption error: {0}")] + Encryption(String), + #[error("invalid password")] + WrongPassword, + #[error("invalid file format")] + InvalidFormat, +} + +pub type PasswordResult = Result; + +impl From for PasswordError { + fn from(e: std::io::Error) -> Self { + PasswordError::Io(e.to_string()) + } +} + +/// Compute the HMAC-SHA256 derived on-disk filename for a plaintext relative path. +pub fn hmac_filename(key: &[u8; 32], plaintext_relpath: &str) -> String { + let signing_key = hmac::Key::new(hmac::HMAC_SHA256, key); + let tag = hmac::sign(&signing_key, plaintext_relpath.as_bytes()); + let encoded = URL_SAFE_NO_PAD.encode(tag.as_ref()); + encoded.chars().take(HMAC_FILENAME_LEN).collect() +} + +/// Encrypt a single file's contents with its plaintext relative path embedded. +pub fn encrypt_profile_file( + key: &[u8; 32], + plaintext_relpath: &str, + file_bytes: &[u8], +) -> PasswordResult> { + let path_bytes = plaintext_relpath.as_bytes(); + if path_bytes.len() > u16::MAX as usize { + return Err(PasswordError::Encryption("relpath too long".into())); + } + let mut plaintext = Vec::with_capacity(2 + path_bytes.len() + file_bytes.len()); + plaintext.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); + plaintext.extend_from_slice(path_bytes); + plaintext.extend_from_slice(file_bytes); + encrypt_bytes(key, &plaintext).map_err(PasswordError::Encryption) +} + +/// Decrypt one file's bytes back into `(plaintext_relpath, file_bytes)`. +pub fn decrypt_profile_file( + key: &[u8; 32], + encrypted_bytes: &[u8], +) -> PasswordResult<(String, Vec)> { + let plaintext = decrypt_bytes(key, encrypted_bytes).map_err(|_| PasswordError::WrongPassword)?; + if plaintext.len() < 2 { + return Err(PasswordError::InvalidFormat); + } + let path_len = u16::from_le_bytes([plaintext[0], plaintext[1]]) as usize; + if plaintext.len() < 2 + path_len { + return Err(PasswordError::InvalidFormat); + } + let path = std::str::from_utf8(&plaintext[2..2 + path_len]) + .map_err(|_| PasswordError::InvalidFormat)? + .to_string(); + let content = plaintext[2 + path_len..].to_vec(); + Ok((path, content)) +} + +fn build_excludes(patterns: &[&str]) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + for p in patterns { + if let Ok(g) = Glob::new(p) { + builder.add(g); + } + } + builder.build().unwrap_or_else(|_| GlobSet::empty()) +} + +fn walk_files( + base: &Path, + current: &Path, + excludes: &GlobSet, + out: &mut Vec<(String, PathBuf)>, +) -> std::io::Result<()> { + for entry in std::fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let relative = path + .strip_prefix(base) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + + if excludes.is_match(&relative) { + continue; + } + + let metadata = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_dir() { + walk_files(base, &path, excludes, out)?; + } else if metadata.is_file() { + out.push((relative, path)); + } + } + Ok(()) +} + +fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("donut-tmp"); + std::fs::write(&tmp, data)?; + std::fs::rename(&tmp, path) +} + +fn write_verifier(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> { + let encrypted = encrypt_profile_file(key, VERIFY_FILE_PATH, b"donut-verify")?; + let path = encrypted_dir.join(VERIFY_FILE_NAME); + atomic_write(&path, &encrypted)?; + Ok(()) +} + +/// Verify a derived key against an encrypted profile dir. Returns Ok(()) on +/// success, `Err(WrongPassword)` if the password is wrong, or another error +/// for I/O / format problems. +pub fn verify_key_against_dir(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> { + let path = encrypted_dir.join(VERIFY_FILE_NAME); + if !path.exists() { + return Err(PasswordError::InvalidFormat); + } + let bytes = std::fs::read(&path)?; + let (relpath, content) = decrypt_profile_file(key, &bytes)?; + if relpath != VERIFY_FILE_PATH || content != b"donut-verify" { + return Err(PasswordError::InvalidFormat); + } + Ok(()) +} + +/// Encrypt every file under `plaintext_dir` into `encrypted_dir`, replacing +/// it. Files matching `exclude_patterns` are dropped. +pub fn encrypt_profile_dir( + key: &[u8; 32], + plaintext_dir: &Path, + encrypted_dir: &Path, + exclude_patterns: &[&str], +) -> PasswordResult<()> { + if encrypted_dir.exists() { + std::fs::remove_dir_all(encrypted_dir)?; + } + std::fs::create_dir_all(encrypted_dir)?; + + let excludes = build_excludes(exclude_patterns); + let mut files = Vec::new(); + if plaintext_dir.exists() { + walk_files(plaintext_dir, plaintext_dir, &excludes, &mut files)?; + } + + for (relpath, abs) in files { + let bytes = std::fs::read(&abs)?; + let encrypted = encrypt_profile_file(key, &relpath, &bytes)?; + let on_disk = encrypted_dir.join(hmac_filename(key, &relpath)); + atomic_write(&on_disk, &encrypted)?; + } + + write_verifier(key, encrypted_dir)?; + Ok(()) +} + +/// Decrypt every file in `encrypted_dir` back into `plaintext_dir` (which is +/// created if missing). Returns the per-file mtimes captured after writing, +/// keyed by plaintext relpath. Caller can use them as the "before-launch" +/// snapshot to skip unchanged files on re-encrypt. +pub fn decrypt_profile_dir( + key: &[u8; 32], + encrypted_dir: &Path, + plaintext_dir: &Path, +) -> PasswordResult> { + std::fs::create_dir_all(plaintext_dir)?; + let mut mtimes = HashMap::new(); + + let entries: Vec<_> = std::fs::read_dir(encrypted_dir)? + .filter_map(|r| r.ok()) + .collect(); + + for entry in entries { + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + if name == VERIFY_FILE_NAME { + continue; + } + let bytes = std::fs::read(&path)?; + let (relpath, content) = decrypt_profile_file(key, &bytes)?; + let dest = plaintext_dir.join(&relpath); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&dest, &content)?; + if let Ok(m) = dest.metadata().and_then(|m| m.modified()) { + mtimes.insert(relpath, m); + } + } + + Ok(mtimes) +} + +/// Re-encrypt the contents of `plaintext_dir` back into `encrypted_dir`, +/// preserving on-disk filenames for files whose plaintext content didn't +/// change. Returns the number of files re-encrypted. +/// +/// `before_launch_mtimes` is the snapshot captured by `decrypt_profile_dir`. +/// Files whose mtime hasn't moved are left untouched on disk. +pub fn reencrypt_changed_files( + key: &[u8; 32], + plaintext_dir: &Path, + encrypted_dir: &Path, + exclude_patterns: &[&str], + before_launch_mtimes: &HashMap, +) -> PasswordResult { + std::fs::create_dir_all(encrypted_dir)?; + let excludes = build_excludes(exclude_patterns); + + let mut current_files = Vec::new(); + if plaintext_dir.exists() { + walk_files(plaintext_dir, plaintext_dir, &excludes, &mut current_files)?; + } + + let mut current_paths: HashSet = HashSet::new(); + let mut rewrote = 0usize; + for (relpath, abs) in current_files { + current_paths.insert(relpath.clone()); + + let cur_mtime = abs.metadata().and_then(|m| m.modified()).ok(); + let unchanged = match (cur_mtime, before_launch_mtimes.get(&relpath)) { + (Some(now), Some(before)) => now == *before, + _ => false, + }; + if unchanged { + continue; + } + + let bytes = std::fs::read(&abs)?; + let encrypted = encrypt_profile_file(key, &relpath, &bytes)?; + let on_disk = encrypted_dir.join(hmac_filename(key, &relpath)); + atomic_write(&on_disk, &encrypted)?; + rewrote += 1; + } + + // Delete on-disk files for plaintext paths that no longer exist + let valid_names: HashSet = current_paths + .iter() + .map(|p| hmac_filename(key, p)) + .collect(); + + for entry in std::fs::read_dir(encrypted_dir)?.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + if name == VERIFY_FILE_NAME { + continue; + } + if !valid_names.contains(&name) { + let _ = std::fs::remove_file(&path); + } + } + + write_verifier(key, encrypted_dir)?; + Ok(rewrote) +} + +/// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in +/// place. Used when changing a profile password without launching it. +pub fn rekey_profile_dir( + old_key: &[u8; 32], + new_key: &[u8; 32], + encrypted_dir: &Path, +) -> PasswordResult<()> { + let entries: Vec<_> = std::fs::read_dir(encrypted_dir)? + .filter_map(|r| r.ok()) + .collect(); + + let mut decrypted: Vec<(String, Vec)> = Vec::new(); + for entry in &entries { + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + if name == VERIFY_FILE_NAME { + continue; + } + let bytes = std::fs::read(&path)?; + let (relpath, content) = decrypt_profile_file(old_key, &bytes)?; + decrypted.push((relpath, content)); + } + + // Decryption succeeded for every file; safe to rewrite the directory. + for entry in entries { + let path = entry.path(); + if path.is_file() { + let _ = std::fs::remove_file(&path); + } + } + + for (relpath, content) in decrypted { + let encrypted = encrypt_profile_file(new_key, &relpath, &content)?; + let on_disk = encrypted_dir.join(hmac_filename(new_key, &relpath)); + atomic_write(&on_disk, &encrypted)?; + } + + write_verifier(new_key, encrypted_dir)?; + Ok(()) +} + +// ---------- key cache ---------- + +pub fn cache_key(profile_id: uuid::Uuid, key: [u8; 32]) { + if let Ok(mut guard) = KEY_CACHE.lock() { + guard.insert(profile_id, key); + } +} + +pub fn get_cached_key(profile_id: &uuid::Uuid) -> Option<[u8; 32]> { + KEY_CACHE.lock().ok()?.get(profile_id).copied() +} + +pub fn drop_cached_key(profile_id: &uuid::Uuid) { + if let Ok(mut guard) = KEY_CACHE.lock() { + guard.remove(profile_id); + } +} + +pub fn has_cached_key(profile_id: &uuid::Uuid) -> bool { + KEY_CACHE + .lock() + .map(|g| g.contains_key(profile_id)) + .unwrap_or(false) +} + +/// Convenience: derive + verify against the encrypted dir + cache the key on success. +pub fn unlock( + profile_id: uuid::Uuid, + password: &str, + salt: &str, + encrypted_dir: &Path, +) -> PasswordResult<()> { + let key = derive_profile_key(password, salt).map_err(PasswordError::Encryption)?; + verify_key_against_dir(&key, encrypted_dir)?; + cache_key(profile_id, key); + Ok(()) +} + +pub fn fresh_salt() -> String { + generate_salt() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_key() -> [u8; 32] { + derive_profile_key("hunter2", &generate_salt()).unwrap() + } + + #[test] + fn test_hmac_filename_deterministic() { + let key = [7u8; 32]; + let a = hmac_filename(&key, "Default/Cookies"); + let b = hmac_filename(&key, "Default/Cookies"); + assert_eq!(a, b); + assert_eq!(a.len(), HMAC_FILENAME_LEN); + } + + #[test] + fn test_hmac_filename_different_keys() { + let a = hmac_filename(&[1u8; 32], "Default/Cookies"); + let b = hmac_filename(&[2u8; 32], "Default/Cookies"); + assert_ne!(a, b); + } + + #[test] + fn test_hmac_filename_different_paths() { + let key = [1u8; 32]; + let a = hmac_filename(&key, "Default/Cookies"); + let b = hmac_filename(&key, "Default/Login Data"); + assert_ne!(a, b); + } + + #[test] + fn test_file_roundtrip() { + let key = make_key(); + let original = b"hello world".to_vec(); + let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap(); + let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap(); + assert_eq!(path, "Default/Cookies"); + assert_eq!(content, original); + } + + #[test] + fn test_file_wrong_key_fails() { + let key1 = make_key(); + let key2 = make_key(); + let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap(); + assert!(matches!( + decrypt_profile_file(&key2, &encrypted), + Err(PasswordError::WrongPassword) + )); + } + + #[test] + fn test_file_truncated_ciphertext() { + let key = make_key(); + let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap(); + // Drop the auth tag + let truncated = &encrypted[..encrypted.len() - 1]; + assert!(decrypt_profile_file(&key, truncated).is_err()); + } + + #[test] + fn test_dir_roundtrip() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(plain.join("Default")).unwrap(); + std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap(); + std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap(); + std::fs::write(plain.join("Local State"), b"state").unwrap(); + + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + // No plaintext filenames on disk + let names: Vec = std::fs::read_dir(&enc) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + for n in &names { + assert!(!n.contains("Cookies"), "plaintext leaked: {n}"); + assert!(!n.contains("Bookmarks")); + assert!(!n.contains("Local State")); + } + + // Verify file present + assert!(enc.join(VERIFY_FILE_NAME).exists()); + + let restored = work.path().join("restored"); + let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + assert_eq!(mtimes.len(), 3); + + assert_eq!( + std::fs::read(restored.join("Default/Cookies")).unwrap(), + b"sqlite-data" + ); + assert_eq!( + std::fs::read(restored.join("Default/Bookmarks")).unwrap(), + b"{\"x\":1}" + ); + assert_eq!( + std::fs::read(restored.join("Local State")).unwrap(), + b"state" + ); + } + + #[test] + fn test_dir_excludes() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(plain.join("Default/Cache")).unwrap(); + std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap(); + std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap(); + + encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap(); + + let restored = work.path().join("restored"); + let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + + // Only Cookies (1 file) should be present, not Cache contents + assert_eq!(mtimes.len(), 1); + assert!(mtimes.contains_key("Default/Cookies")); + assert!(restored.join("Default/Cookies").exists()); + assert!(!restored.join("Default/Cache/data").exists()); + } + + #[test] + fn test_verify_against_wrong_key() { + let key1 = make_key(); + let key2 = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("file"), b"data").unwrap(); + encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap(); + assert!(verify_key_against_dir(&key1, &enc).is_ok()); + assert!(matches!( + verify_key_against_dir(&key2, &enc), + Err(PasswordError::WrongPassword) + )); + } + + #[test] + fn test_reencrypt_skips_unchanged() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("a"), b"AAA").unwrap(); + std::fs::write(plain.join("b"), b"BBB").unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + let restored = work.path().join("restored"); + let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + + // Capture pre-rewrite ciphertext bytes + let name_a = hmac_filename(&key, "a"); + let name_b = hmac_filename(&key, "b"); + let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap(); + let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap(); + + // Modify only "a" in the restored tree + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap(); + + let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); + assert_eq!(rewrote, 1); + + let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap(); + let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap(); + assert_ne!( + cipher_a_before, cipher_a_after, + "changed file should have new ciphertext" + ); + assert_eq!( + cipher_b_before, cipher_b_after, + "unchanged file should have stable ciphertext" + ); + } + + #[test] + fn test_reencrypt_handles_added_and_removed() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("keep"), b"k").unwrap(); + std::fs::write(plain.join("delete"), b"d").unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + let restored = work.path().join("restored"); + let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + + std::fs::remove_file(restored.join("delete")).unwrap(); + std::fs::write(restored.join("new"), b"n").unwrap(); + + reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); + + let names: HashSet = std::fs::read_dir(&enc) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + + assert!(names.contains(&hmac_filename(&key, "keep"))); + assert!(names.contains(&hmac_filename(&key, "new"))); + assert!(!names.contains(&hmac_filename(&key, "delete"))); + assert!(names.contains(VERIFY_FILE_NAME)); + } + + #[test] + fn test_rekey_changes_filenames_and_content() { + let old = make_key(); + let new = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("x"), b"data").unwrap(); + encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap(); + + let old_name = hmac_filename(&old, "x"); + let new_name = hmac_filename(&new, "x"); + assert_ne!(old_name, new_name); + + rekey_profile_dir(&old, &new, &enc).unwrap(); + + assert!(!enc.join(&old_name).exists()); + assert!(enc.join(&new_name).exists()); + verify_key_against_dir(&new, &enc).unwrap(); + assert!(matches!( + verify_key_against_dir(&old, &enc), + Err(PasswordError::WrongPassword) + )); + + let restored = work.path().join("restored"); + decrypt_profile_dir(&new, &enc, &restored).unwrap(); + assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data"); + } + + #[test] + fn test_atomic_write_leaves_original_intact_if_tmp_lingers() { + let work = TempDir::new().unwrap(); + let target = work.path().join("file"); + std::fs::write(&target, b"original").unwrap(); + + // Simulate a stale tmp from a crashed write + std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap(); + + // A successful write should overwrite the original even when stale tmp exists + atomic_write(&target, b"new").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + } + + #[test] + fn test_key_cache_lifecycle() { + let id = uuid::Uuid::new_v4(); + assert!(!has_cached_key(&id)); + cache_key(id, [9u8; 32]); + assert!(has_cached_key(&id)); + assert_eq!(get_cached_key(&id), Some([9u8; 32])); + drop_cached_key(&id); + assert!(!has_cached_key(&id)); + } + + #[test] + fn test_unlock_helper() { + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("x"), b"data").unwrap(); + + let salt = generate_salt(); + let key = derive_profile_key("correct horse", &salt).unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + let id = uuid::Uuid::new_v4(); + drop_cached_key(&id); + assert!(unlock(id, "wrong", &salt, &enc).is_err()); + assert!(!has_cached_key(&id)); + assert!(unlock(id, "correct horse", &salt, &enc).is_ok()); + assert!(has_cached_key(&id)); + drop_cached_key(&id); + } +} diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 3d071bb..9414c9e 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -184,6 +184,7 @@ impl ProfileManager { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, }; match self @@ -285,6 +286,7 @@ impl ProfileManager { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, }; match self @@ -340,6 +342,7 @@ impl ProfileManager { created_by_id: None, created_by_email: None, dns_blocklist, + password_protected: false, }; // Save profile info @@ -987,6 +990,7 @@ impl ProfileManager { created_by_id: None, created_by_email: None, dns_blocklist: source.dns_blocklist, + password_protected: false, }; self.save_profile(&new_profile)?; diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index 7e4259f..703c063 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -1,4 +1,6 @@ +pub mod encryption; pub mod manager; +pub mod password; pub mod types; pub use manager::ProfileManager; diff --git a/src-tauri/src/profile/password.rs b/src-tauri/src/profile/password.rs new file mode 100644 index 0000000..944d329 --- /dev/null +++ b/src-tauri/src/profile/password.rs @@ -0,0 +1,1251 @@ +//! Tauri commands for profile password lifecycle: set, change, remove, +//! unlock, lock, status. +//! +//! All error responses returned to the frontend are JSON-encoded +//! `{ "code": "", "params"?: { ... } }` so the UI can render a +//! localized message. Helpers `err_code` / `err_with` build them. The set of +//! codes is documented at `BackendErrorCode` in TypeScript; keep them in sync. + +use crate::events; +use crate::profile::encryption::{ + cache_key, decrypt_profile_dir, drop_cached_key, encrypt_profile_dir, fresh_salt, get_cached_key, + has_cached_key, rekey_profile_dir, unlock as unlock_dir, verify_key_against_dir, +}; +use crate::profile::ProfileManager; +use crate::sync::encryption::derive_profile_key; +use crate::sync::manifest::DEFAULT_EXCLUDE_PATTERNS; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; + +/// Build a JSON error payload with just a code. +fn err_code(code: &'static str) -> String { + json!({ "code": code }).to_string() +} + +/// Build a JSON error payload with a code and params. +fn err_with(code: &'static str, params: &[(&str, String)]) -> String { + let mut map = serde_json::Map::new(); + for (k, v) in params { + map.insert((*k).to_string(), serde_json::Value::String(v.clone())); + } + json!({ "code": code, "params": serde_json::Value::Object(map) }).to_string() +} + +/// Internal-error wrapper used for unexpected failures; the detail string is +/// raw English (developer-facing) but the surrounding template translates. +fn err_internal(detail: impl std::fmt::Display) -> String { + err_with("INTERNAL_ERROR", &[("detail", detail.to_string())]) +} + +lazy_static::lazy_static! { + /// Per-profile snapshot of plaintext file mtimes captured at launch time. + /// Used by `complete_after_quit` to skip re-encrypting unchanged files. + static ref LAUNCH_SNAPSHOTS: Mutex>> = + Mutex::new(HashMap::new()); + + /// Profile IDs whose ephemeral dir is currently populated and matches the + /// on-disk encrypted state, so we can skip re-decrypting on the next launch + /// when `keep_decrypted_profiles_in_ram` is enabled. + static ref POPULATED_EPHEMERAL: Mutex> = Mutex::new(HashSet::new()); + + /// Per-profile failed unlock attempt tracking for rate-limiting. + static ref FAILED_ATTEMPTS: Mutex> = Mutex::new(HashMap::new()); +} + +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +struct FailureRecord { + count: u32, + /// Stored as epoch seconds for portable on-disk persistence. + last_failed_at_secs: u64, +} + +impl FailureRecord { + fn last_failed_at(&self) -> SystemTime { + SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(self.last_failed_at_secs) + } +} + +fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn lockout_sidecar_path(profile_id: &uuid::Uuid) -> PathBuf { + ProfileManager::instance() + .get_profiles_dir() + .join(profile_id.to_string()) + .join(".unlock-attempts.json") +} + +fn load_persisted_record(profile_id: &uuid::Uuid) -> Option { + let path = lockout_sidecar_path(profile_id); + let content = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() +} + +fn persist_record(profile_id: &uuid::Uuid, record: &FailureRecord) { + let path = lockout_sidecar_path(profile_id); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(record) { + let _ = std::fs::write(&path, json); + } +} + +fn clear_persisted_record(profile_id: &uuid::Uuid) { + let path = lockout_sidecar_path(profile_id); + let _ = std::fs::remove_file(&path); +} + +/// Read the current FailureRecord, falling back to disk if the in-memory +/// cache doesn't have one (e.g. fresh app launch). +fn current_record(profile_id: &uuid::Uuid) -> Option { + if let Ok(guard) = FAILED_ATTEMPTS.lock() { + if let Some(rec) = guard.get(profile_id) { + return Some(*rec); + } + } + let from_disk = load_persisted_record(profile_id)?; + if let Ok(mut guard) = FAILED_ATTEMPTS.lock() { + guard.insert(*profile_id, from_disk); + } + Some(from_disk) +} + +/// Lockout schedule. Index is the failure count (1-based); returns the +/// duration the user must wait before the next attempt is allowed. +/// Attempts 1-4 have no lockout; attempt 5 onward triggers progressive +/// back-off, capped at 24 hours. +fn lockout_for_count(count: u32) -> Option { + use std::time::Duration; + let secs: u64 = match count { + 0..=4 => return None, + 5 => 60, + 6 => 5 * 60, + 7 => 15 * 60, + 8 => 60 * 60, + 9 => 2 * 60 * 60, + 10 => 4 * 60 * 60, + 11 => 8 * 60 * 60, + _ => 24 * 60 * 60, + }; + Some(Duration::from_secs(secs)) +} + +/// Returns Ok(()) if no lockout is active, or Err with remaining seconds. +fn check_lockout(profile_id: &uuid::Uuid) -> Result<(), u64> { + let Some(record) = current_record(profile_id) else { + return Ok(()); + }; + let Some(lockout) = lockout_for_count(record.count) else { + return Ok(()); + }; + let elapsed = SystemTime::now() + .duration_since(record.last_failed_at()) + .unwrap_or_default(); + if elapsed >= lockout { + Ok(()) + } else { + Err((lockout - elapsed).as_secs().max(1)) + } +} + +fn record_failed_attempt(profile_id: uuid::Uuid) { + let updated = if let Ok(mut guard) = FAILED_ATTEMPTS.lock() { + let entry = guard.entry(profile_id).or_insert(FailureRecord { + count: 0, + last_failed_at_secs: now_epoch_secs(), + }); + entry.count = entry.count.saturating_add(1); + entry.last_failed_at_secs = now_epoch_secs(); + Some(*entry) + } else { + None + }; + if let Some(record) = updated { + persist_record(&profile_id, &record); + } +} + +fn clear_failed_attempts(profile_id: &uuid::Uuid) { + if let Ok(mut guard) = FAILED_ATTEMPTS.lock() { + guard.remove(profile_id); + } + clear_persisted_record(profile_id); +} + +const MIN_PASSWORD_LEN: usize = 8; + +fn validate_password(password: &str) -> Result<(), String> { + if password.len() < MIN_PASSWORD_LEN { + return Err(err_with( + "PASSWORD_TOO_SHORT", + &[("min", MIN_PASSWORD_LEN.to_string())], + )); + } + Ok(()) +} + +fn parse_uuid(profile_id: &str) -> Result { + uuid::Uuid::parse_str(profile_id).map_err(|_| err_code("INVALID_PROFILE_ID")) +} + +fn load_profile(profile_id: &uuid::Uuid) -> Result { + let manager = ProfileManager::instance(); + let profiles = manager.list_profiles().map_err(err_internal)?; + profiles + .into_iter() + .find(|p| p.id == *profile_id) + .ok_or_else(|| err_code("PROFILE_NOT_FOUND")) +} + +fn profile_data_dir(profile: &crate::profile::BrowserProfile) -> PathBuf { + profile.get_profile_data_path(&ProfileManager::instance().get_profiles_dir()) +} + +fn emit_profiles_changed() { + let _ = events::emit_empty("profiles-changed"); +} + +#[tauri::command] +pub async fn is_profile_locked(profile_id: String) -> Result { + let id = parse_uuid(&profile_id)?; + let profile = load_profile(&id)?; + if !profile.password_protected { + return Ok(false); + } + Ok(!has_cached_key(&id)) +} + +#[tauri::command] +pub async fn set_profile_password(profile_id: String, password: String) -> Result<(), String> { + validate_password(&password)?; + let id = parse_uuid(&profile_id)?; + let mut profile = load_profile(&id)?; + + if profile.password_protected { + return Err(err_code("PROFILE_ALREADY_PROTECTED")); + } + + if profile + .process_id + .is_some_and(crate::proxy_storage::is_process_running) + { + return Err(err_code("PROFILE_RUNNING")); + } + + let plaintext_dir = profile_data_dir(&profile); + // An empty/missing profile dir is fine — we just produce an encrypted dir + // that contains only the verifier file. This lets callers attach a password + // immediately on creation, before the browser has run. + if !plaintext_dir.exists() { + std::fs::create_dir_all(&plaintext_dir).map_err(err_internal)?; + } + + let salt = fresh_salt(); + let key = derive_profile_key(&password, &salt).map_err(err_internal)?; + + // Encrypt into a sibling staging dir, then atomically swap. + let staging = plaintext_dir.with_extension("encrypting"); + if staging.exists() { + let _ = std::fs::remove_dir_all(&staging); + } + encrypt_profile_dir(&key, &plaintext_dir, &staging, DEFAULT_EXCLUDE_PATTERNS) + .map_err(err_internal)?; + + // Move plaintext aside, swap in encrypted, then delete plaintext. + let backup = plaintext_dir.with_extension("plaintext-backup"); + if backup.exists() { + let _ = std::fs::remove_dir_all(&backup); + } + std::fs::rename(&plaintext_dir, &backup).map_err(err_internal)?; + if let Err(e) = std::fs::rename(&staging, &plaintext_dir) { + let _ = std::fs::rename(&backup, &plaintext_dir); + return Err(err_internal(e)); + } + if let Err(e) = std::fs::remove_dir_all(&backup) { + log::warn!( + "Failed to remove plaintext backup at {}: {e}", + backup.display() + ); + } + + profile.password_protected = true; + profile.encryption_salt = Some(salt); + ProfileManager::instance() + .save_profile(&profile) + .map_err(err_internal)?; + + cache_key(id, key); + emit_profiles_changed(); + Ok(()) +} + +#[tauri::command] +pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> { + let id = parse_uuid(&profile_id)?; + let profile = load_profile(&id)?; + if !profile.password_protected { + return Err(err_code("PROFILE_NOT_PROTECTED")); + } + if let Err(secs) = check_lockout(&id) { + return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); + } + let salt = profile + .encryption_salt + .as_deref() + .ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?; + + match unlock_dir(id, &password, salt, &profile_data_dir(&profile)) { + Ok(()) => { + clear_failed_attempts(&id); + Ok(()) + } + Err(crate::profile::encryption::PasswordError::WrongPassword) => { + record_failed_attempt(id); + Err(err_code("INCORRECT_PASSWORD")) + } + Err(other) => Err(err_internal(other)), + } +} + +#[tauri::command] +pub async fn lock_profile(profile_id: String) -> Result<(), String> { + let id = parse_uuid(&profile_id)?; + let profile = load_profile(&id)?; + if !profile.password_protected { + return Ok(()); + } + if profile + .process_id + .is_some_and(crate::proxy_storage::is_process_running) + { + return Err(err_code("PROFILE_RUNNING")); + } + drop_cached_key(&id); + // Purge any leftover ephemeral dir in case keep_decrypted_profiles_in_ram was on. + crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); + emit_profiles_changed(); + Ok(()) +} + +#[tauri::command] +pub async fn change_profile_password( + profile_id: String, + old_password: String, + new_password: String, +) -> Result<(), String> { + validate_password(&new_password)?; + let id = parse_uuid(&profile_id)?; + let mut profile = load_profile(&id)?; + + if !profile.password_protected { + return Err(err_code("PROFILE_NOT_PROTECTED")); + } + if profile + .process_id + .is_some_and(crate::proxy_storage::is_process_running) + { + return Err(err_code("PROFILE_RUNNING")); + } + + if let Err(secs) = check_lockout(&id) { + return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); + } + + let old_salt = profile + .encryption_salt + .as_deref() + .ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?; + let old_key = derive_profile_key(&old_password, old_salt).map_err(err_internal)?; + let dir = profile_data_dir(&profile); + if let Err(e) = verify_key_against_dir(&old_key, &dir) { + return match e { + crate::profile::encryption::PasswordError::WrongPassword => { + record_failed_attempt(id); + Err(err_code("INCORRECT_PASSWORD")) + } + other => Err(err_internal(other)), + }; + } + clear_failed_attempts(&id); + + let new_salt = fresh_salt(); + let new_key = derive_profile_key(&new_password, &new_salt).map_err(err_internal)?; + rekey_profile_dir(&old_key, &new_key, &dir).map_err(err_internal)?; + + profile.encryption_salt = Some(new_salt); + ProfileManager::instance() + .save_profile(&profile) + .map_err(err_internal)?; + + drop_cached_key(&id); + cache_key(id, new_key); + emit_profiles_changed(); + Ok(()) +} + +#[tauri::command] +pub async fn remove_profile_password(profile_id: String, password: String) -> Result<(), String> { + let id = parse_uuid(&profile_id)?; + let mut profile = load_profile(&id)?; + if !profile.password_protected { + return Err(err_code("PROFILE_NOT_PROTECTED")); + } + if profile + .process_id + .is_some_and(crate::proxy_storage::is_process_running) + { + return Err(err_code("PROFILE_RUNNING")); + } + + if let Err(secs) = check_lockout(&id) { + return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())])); + } + + let salt = profile + .encryption_salt + .as_deref() + .ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?; + let key = derive_profile_key(&password, salt).map_err(err_internal)?; + let encrypted_dir = profile_data_dir(&profile); + if let Err(e) = verify_key_against_dir(&key, &encrypted_dir) { + return match e { + crate::profile::encryption::PasswordError::WrongPassword => { + record_failed_attempt(id); + Err(err_code("INCORRECT_PASSWORD")) + } + other => Err(err_internal(other)), + }; + } + clear_failed_attempts(&id); + + let staging = encrypted_dir.with_extension("decrypting"); + if staging.exists() { + let _ = std::fs::remove_dir_all(&staging); + } + decrypt_profile_dir(&key, &encrypted_dir, &staging).map_err(err_internal)?; + + let backup = encrypted_dir.with_extension("encrypted-backup"); + if backup.exists() { + let _ = std::fs::remove_dir_all(&backup); + } + std::fs::rename(&encrypted_dir, &backup).map_err(err_internal)?; + if let Err(e) = std::fs::rename(&staging, &encrypted_dir) { + let _ = std::fs::rename(&backup, &encrypted_dir); + return Err(err_internal(e)); + } + if let Err(e) = std::fs::remove_dir_all(&backup) { + log::warn!( + "Failed to remove encrypted backup at {}: {e}", + backup.display() + ); + } + + profile.password_protected = false; + profile.encryption_salt = None; + ProfileManager::instance() + .save_profile(&profile) + .map_err(err_internal)?; + + drop_cached_key(&id); + emit_profiles_changed(); + Ok(()) +} + +// ---------- helpers used by browser_runner ---------- + +/// Capture a per-file mtime snapshot of the given decrypted dir. +fn snapshot_mtimes(plaintext_dir: &Path) -> HashMap { + let mut out: HashMap = HashMap::new(); + fn walk( + base: &Path, + current: &Path, + out: &mut HashMap, + ) -> std::io::Result<()> { + for entry in std::fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let meta = entry.metadata()?; + if meta.is_dir() { + walk(base, &path, out)?; + } else if meta.is_file() { + let rel = path + .strip_prefix(base) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + if let Ok(m) = meta.modified() { + out.insert(rel, m); + } + } + } + Ok(()) + } + let _ = walk(plaintext_dir, plaintext_dir, &mut out); + out +} + +/// Decrypt a password-protected profile's encrypted dir into an ephemeral +/// dir, take a mtime snapshot for diff-on-quit, and return the ephemeral +/// path the browser should launch from. +/// +/// Returns an error if the profile isn't unlocked yet — the frontend should +/// prompt for the password and call `unlock_profile` first. +pub fn prepare_for_launch(profile: &crate::profile::BrowserProfile) -> Result { + let id = profile.id; + let key = get_cached_key(&id).ok_or_else(|| err_code("PROFILE_LOCKED"))?; + + let id_str = id.to_string(); + let ephemeral = match crate::ephemeral_dirs::get_ephemeral_dir(&id_str) { + Some(p) => p, + None => crate::ephemeral_dirs::create_ephemeral_dir(&id_str).map_err(err_internal)?, + }; + + let already_populated = POPULATED_EPHEMERAL + .lock() + .map(|g| g.contains(&id)) + .unwrap_or(false); + + let encrypted_dir = profile_data_dir(profile); + + let snapshot = if already_populated && ephemeral_has_files(&ephemeral) { + // Reusing a kept-in-RAM copy from the previous session; just snapshot. + snapshot_mtimes(&ephemeral) + } else { + // Wipe any stale contents and re-decrypt. + if let Err(e) = clear_dir_contents(&ephemeral) { + log::warn!("Failed to clear stale ephemeral contents: {e}"); + } + decrypt_profile_dir(&key, &encrypted_dir, &ephemeral).map_err(|e| match e { + crate::profile::encryption::PasswordError::WrongPassword => err_code("INCORRECT_PASSWORD"), + other => err_internal(other), + })? + }; + + if let Ok(mut guard) = LAUNCH_SNAPSHOTS.lock() { + guard.insert(id, snapshot); + } + if let Ok(mut guard) = POPULATED_EPHEMERAL.lock() { + guard.insert(id); + } + + Ok(ephemeral) +} + +fn ephemeral_has_files(dir: &Path) -> bool { + std::fs::read_dir(dir) + .map(|mut iter| iter.next().is_some()) + .unwrap_or(false) +} + +fn clear_dir_contents(dir: &Path) -> std::io::Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + std::fs::remove_dir_all(&path)?; + } else { + std::fs::remove_file(&path)?; + } + } + Ok(()) +} + +fn read_keep_decrypted_setting() -> bool { + crate::settings_manager::SettingsManager::instance() + .load_settings() + .map(|s| s.keep_decrypted_profiles_in_ram) + .unwrap_or(false) +} + +/// Synchronous core of `complete_after_quit`: re-encrypts ephemeral → disk +/// and (unless `keep_decrypted` is true) drops cached key + purges ephemeral. +/// Returns the number of files re-encrypted, or `None` if there was nothing +/// to do. Public for testability. +pub fn complete_after_quit_blocking( + profile: &crate::profile::BrowserProfile, + keep_decrypted: bool, +) -> Option { + use crate::profile::encryption::reencrypt_changed_files; + + let id = profile.id; + if !profile.password_protected { + return None; + } + + // Snapshot is an optimization (skip re-encrypting unchanged files). When + // it's missing — e.g. natural-exit detection firing twice, or status + // checker firing for a profile whose snapshot was already consumed — we + // fall back to treating every ephemeral file as new. Empty `before` + // forces all files through encrypt, which is slower but correct. + let snapshot = LAUNCH_SNAPSHOTS + .lock() + .ok() + .and_then(|mut g| g.remove(&id)) + .unwrap_or_default(); + + let id_str = id.to_string(); + let ephemeral = crate::ephemeral_dirs::get_ephemeral_dir(&id_str)?; + let encrypted = profile_data_dir(profile); + let key = get_cached_key(&id)?; + + let result = match reencrypt_changed_files( + &key, + &ephemeral, + &encrypted, + DEFAULT_EXCLUDE_PATTERNS, + &snapshot, + ) { + Ok(n) => { + log::info!("Re-encrypted {n} changed file(s) for profile {id}"); + Some(n) + } + Err(e) => { + log::error!("Re-encryption failed for profile {id}: {e}"); + None + } + }; + + if keep_decrypted { + log::info!("Keeping decrypted copy of profile {id} in RAM (per settings)"); + } else { + drop_cached_key(&id); + if let Ok(mut guard) = POPULATED_EPHEMERAL.lock() { + guard.remove(&id); + } + crate::ephemeral_dirs::remove_ephemeral_dir(&id_str); + } + + result +} + +/// Async re-encrypt of a password-protected profile's ephemeral dir back to +/// disk, called after the browser process exits. Optionally purges the +/// ephemeral dir + cached key based on the global setting. +pub fn complete_after_quit(profile: &crate::profile::BrowserProfile) { + if !profile.password_protected { + return; + } + let keep_decrypted = read_keep_decrypted_setting(); + let profile = profile.clone(); + + tauri::async_runtime::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + complete_after_quit_blocking(&profile, keep_decrypted); + }) + .await; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::BrowserProfile; + use tempfile::TempDir; + + fn make_profile(name: &str) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + browser: "wayfern".to_string(), + version: "1.0".to_string(), + release_type: "stable".to_string(), + ..Default::default() + } + } + + fn populate_plaintext_dir(dir: &Path) { + std::fs::create_dir_all(dir.join("Default")).unwrap(); + std::fs::write(dir.join("Default/Cookies"), b"sqlite-data").unwrap(); + std::fs::write(dir.join("Default/Bookmarks"), b"{\"x\":1}").unwrap(); + std::fs::write(dir.join("Local State"), b"local-state").unwrap(); + // Cache files should be excluded: + std::fs::create_dir_all(dir.join("Default/Cache")).unwrap(); + std::fs::write(dir.join("Default/Cache/data_0"), b"cache-blob").unwrap(); + } + + fn parse_err_code(err: &str) -> Option<&'static str> { + let v: serde_json::Value = serde_json::from_str(err).ok()?; + let code = v.get("code")?.as_str()?; + Some(match code { + "INCORRECT_PASSWORD" => "INCORRECT_PASSWORD", + "LOCKED_OUT" => "LOCKED_OUT", + "PROFILE_NOT_FOUND" => "PROFILE_NOT_FOUND", + "PROFILE_NOT_PROTECTED" => "PROFILE_NOT_PROTECTED", + "PROFILE_ALREADY_PROTECTED" => "PROFILE_ALREADY_PROTECTED", + "PROFILE_RUNNING" => "PROFILE_RUNNING", + "PROFILE_MISSING_SALT" => "PROFILE_MISSING_SALT", + "PROFILE_LOCKED" => "PROFILE_LOCKED", + "INVALID_PROFILE_ID" => "INVALID_PROFILE_ID", + "PASSWORD_TOO_SHORT" => "PASSWORD_TOO_SHORT", + "INTERNAL_ERROR" => "INTERNAL_ERROR", + _ => return None, + }) + } + + fn parse_err_param(err: &str, key: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(err).ok()?; + Some(v.get("params")?.get(key)?.as_str()?.to_string()) + } + + fn fresh_test_state(id: &uuid::Uuid) { + drop_cached_key(id); + let _ = LAUNCH_SNAPSHOTS.lock().map(|mut g| g.remove(id)); + let _ = POPULATED_EPHEMERAL.lock().map(|mut g| g.remove(id)); + crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); + } + + fn profile_full_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf { + profiles_dir.join(profile.id.to_string()).join("profile") + } + + #[test] + #[serial_test::serial] + fn integration_set_password_encrypts_dir() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let mut profile = make_profile("test-set"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + assert!(profile.password_protected); + assert!(profile.encryption_salt.is_some()); + + // No plaintext filenames should remain on disk + let names: Vec = std::fs::read_dir(&plain_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + for n in &names { + assert!(!n.contains("Cookies"), "plaintext name leaked: {n}"); + assert!(!n.contains("Bookmarks")); + assert!(!n.contains("Local State")); + } + + fresh_test_state(&profile.id); + } + + #[test] + #[serial_test::serial] + fn integration_full_lifecycle_persists_data() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-lifecycle"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + let mut profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + + // Simulate launch: prepare_for_launch decrypts to ephemeral + let ephemeral = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(ephemeral.join("Default/Cookies")).unwrap(), + b"sqlite-data" + ); + + // Simulate user activity: modify Cookies, leave Bookmarks alone + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(ephemeral.join("Default/Cookies"), b"sqlite-modified").unwrap(); + + // Capture pre-quit ciphertext for the unchanged Bookmarks file + let key = get_cached_key(&profile.id).unwrap(); + let bookmarks_name = crate::profile::encryption::hmac_filename(&key, "Default/Bookmarks"); + let bookmarks_cipher_before = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap(); + + // Simulate quit (purge=true): re-encrypts and clears cached key + ephemeral + let n = complete_after_quit_blocking(&profile, false); + assert!(n.is_some(), "should have re-encrypted at least one file"); + assert!( + get_cached_key(&profile.id).is_none(), + "key should be dropped" + ); + assert!( + crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none(), + "ephemeral should be purged" + ); + + // Unchanged file's ciphertext should be byte-identical + let bookmarks_cipher_after = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap(); + assert_eq!( + bookmarks_cipher_before, bookmarks_cipher_after, + "unchanged file's ciphertext should be stable across quit" + ); + + // Wrong password rejected + let r = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into())); + assert!(r.is_err()); + + // Correct password unlocks + rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap(); + + // Re-launch and verify the modification persisted + profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + let ephemeral2 = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(), + b"sqlite-modified", + "modification should persist across the encrypt/decrypt cycle" + ); + assert_eq!( + std::fs::read(ephemeral2.join("Default/Bookmarks")).unwrap(), + b"{\"x\":1}", + "unchanged file should still be present" + ); + + fresh_test_state(&profile.id); + } + + #[test] + #[serial_test::serial] + fn integration_keep_decrypted_keeps_ephemeral_but_still_re_encrypts() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-keep"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + let profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + let ephemeral = prepare_for_launch(&profile).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(ephemeral.join("Default/Cookies"), b"new-bytes").unwrap(); + + // keep_decrypted=true: ephemeral stays, key stays cached + let n = complete_after_quit_blocking(&profile, true); + assert!(n.is_some()); + assert!( + get_cached_key(&profile.id).is_some(), + "key should still be cached" + ); + assert!( + crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some(), + "ephemeral should be preserved" + ); + + // The on-disk encrypted dir was still updated + let key = get_cached_key(&profile.id).unwrap(); + let cookies_name = crate::profile::encryption::hmac_filename(&key, "Default/Cookies"); + let cipher = std::fs::read(plain_dir.join(&cookies_name)).unwrap(); + let (path, content) = crate::profile::encryption::decrypt_profile_file(&key, &cipher).unwrap(); + assert_eq!(path, "Default/Cookies"); + assert_eq!(content, b"new-bytes"); + + fresh_test_state(&profile.id); + } + + #[test] + #[serial_test::serial] + fn integration_change_and_remove_password() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-change"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + let rt = tokio::runtime::Runtime::new().unwrap(); + + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + let salt_v1 = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap() + .encryption_salt + .clone() + .unwrap(); + + // Wrong old password should fail + let r = rt.block_on(change_profile_password( + profile.id.to_string(), + "wrong".into(), + "newpassword!".into(), + )); + assert!(r.is_err()); + + // Correct old password works, salt should change + rt.block_on(change_profile_password( + profile.id.to_string(), + "hunter2!".into(), + "newpassword!".into(), + )) + .unwrap(); + let salt_v2 = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap() + .encryption_salt + .clone() + .unwrap(); + assert_ne!(salt_v1, salt_v2, "salt should rotate on password change"); + + // Old password rejected, new accepted + assert!(rt + .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .is_err()); + rt.block_on(unlock_profile( + profile.id.to_string(), + "newpassword!".into(), + )) + .unwrap(); + + // Remove password: data should be plaintext again + rt.block_on(remove_profile_password( + profile.id.to_string(), + "newpassword!".into(), + )) + .unwrap(); + + let final_profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + assert!(!final_profile.password_protected); + assert!(final_profile.encryption_salt.is_none()); + assert_eq!( + std::fs::read(plain_dir.join("Default/Cookies")).unwrap(), + b"sqlite-data" + ); + + fresh_test_state(&profile.id); + } + + #[test] + #[serial_test::serial] + fn integration_empty_profile_session_survives_restart() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + // Mimic a freshly created profile with no browser data yet + let profile = make_profile("test-empty"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + std::fs::create_dir_all(&plain_dir).unwrap(); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + // After encrypting an empty profile, only the verifier file lives on disk + let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count(); + assert_eq!( + on_disk_count, 1, + "fresh encrypted profile should have only the verifier file" + ); + + let profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + + // Launch — ephemeral starts empty (only the verifier in encrypted, which is skipped) + let ephemeral = prepare_for_launch(&profile).unwrap(); + assert!( + std::fs::read_dir(&ephemeral).unwrap().next().is_none(), + "ephemeral should start empty for a fresh encrypted profile" + ); + + // Simulate the browser writing a session + std::fs::create_dir_all(ephemeral.join("Default")).unwrap(); + std::fs::write(ephemeral.join("Default/Cookies"), b"session-cookies").unwrap(); + std::fs::write(ephemeral.join("Default/places.sqlite"), b"places-data").unwrap(); + std::fs::write(ephemeral.join("prefs.js"), b"user_pref(\"x\", 1);").unwrap(); + + // Browser exits — re-encrypt back to disk + let n = complete_after_quit_blocking(&profile, false); + assert!( + matches!(n, Some(rewrote) if rewrote >= 3), + "expected at least 3 files re-encrypted, got {n:?}" + ); + + // Encrypted dir should now have verifier + 3 user files + let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count(); + assert!( + on_disk_count >= 4, + "encrypted dir should contain session data + verifier, got {on_disk_count} files" + ); + + // Simulate full app restart: drop key, drop ephemeral tracking, remove ephemeral + fresh_test_state(&profile.id); + + // Unlock with same password + rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap(); + + // Re-launch — session must come back + let ephemeral2 = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(), + b"session-cookies", + "Cookies should survive across encrypt/quit/restart/unlock cycle" + ); + assert_eq!( + std::fs::read(ephemeral2.join("Default/places.sqlite")).unwrap(), + b"places-data" + ); + assert_eq!( + std::fs::read(ephemeral2.join("prefs.js")).unwrap(), + b"user_pref(\"x\", 1);" + ); + + fresh_test_state(&profile.id); + } + + #[test] + #[serial_test::serial] + fn integration_progressive_backoff_on_wrong_password() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-backoff"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + drop_cached_key(&profile.id); + + // First 4 wrong attempts produce the INCORRECT_PASSWORD code + for _ in 0..4 { + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "wrong".into())) + .unwrap_err(); + assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD")); + } + + // 5th wrong attempt also returns the code, but the next one will be locked out + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "wrong".into())) + .unwrap_err(); + assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD")); + + // 6th attempt is rate-limited regardless of password correctness + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap_err(); + assert_eq!(parse_err_code(&err), Some("LOCKED_OUT")); + let secs = parse_err_param(&err, "seconds") + .and_then(|v| v.parse::().ok()) + .unwrap(); + assert!(secs > 0 && secs <= 60, "expected 1m countdown, got {secs}s"); + + // Bypass the timer by manually expiring last_failed_at past the lockout + if let Ok(mut guard) = FAILED_ATTEMPTS.lock() { + if let Some(record) = guard.get_mut(&profile.id) { + record.last_failed_at_secs = now_epoch_secs().saturating_sub(120); + } + } + if let Some(record) = FAILED_ATTEMPTS + .lock() + .ok() + .and_then(|g| g.get(&profile.id).copied()) + { + persist_record(&profile.id, &record); + } + + // Correct password now succeeds, clearing the failure history + rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap(); + let post = FAILED_ATTEMPTS + .lock() + .map(|g| g.contains_key(&profile.id)) + .unwrap_or(true); + assert!(!post, "successful unlock should clear failure record"); + + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); + } + + #[test] + #[serial_test::serial] + fn integration_lockout_survives_restart() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-restart"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + drop_cached_key(&profile.id); + + // 5 wrong attempts to trigger lockout + for _ in 0..5 { + let _ = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into())); + } + + // Sidecar file should now exist + let sidecar = lockout_sidecar_path(&profile.id); + assert!(sidecar.exists(), "sidecar should be persisted to disk"); + + // Simulate app restart by clearing the in-memory cache (but NOT the sidecar) + if let Ok(mut g) = FAILED_ATTEMPTS.lock() { + g.clear(); + } + + // Lockout should still apply because state was loaded from disk + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap_err(); + assert_eq!( + parse_err_code(&err), + Some("LOCKED_OUT"), + "expected lockout to persist across restart, got: {err}" + ); + + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); + } + + #[test] + fn lockout_schedule_progression() { + use std::time::Duration; + assert_eq!(lockout_for_count(0), None); + assert_eq!(lockout_for_count(4), None); + assert_eq!(lockout_for_count(5), Some(Duration::from_secs(60))); + assert_eq!(lockout_for_count(6), Some(Duration::from_secs(5 * 60))); + assert_eq!(lockout_for_count(7), Some(Duration::from_secs(15 * 60))); + assert_eq!(lockout_for_count(8), Some(Duration::from_secs(60 * 60))); + assert_eq!(lockout_for_count(9), Some(Duration::from_secs(2 * 3600))); + assert_eq!(lockout_for_count(10), Some(Duration::from_secs(4 * 3600))); + assert_eq!(lockout_for_count(11), Some(Duration::from_secs(8 * 3600))); + assert_eq!(lockout_for_count(12), Some(Duration::from_secs(24 * 3600))); + assert_eq!(lockout_for_count(50), Some(Duration::from_secs(24 * 3600))); + } + + #[test] + #[serial_test::serial] + fn integration_lock_drops_key() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-lock"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + assert!(get_cached_key(&profile.id).is_some()); + assert!(!rt + .block_on(is_profile_locked(profile.id.to_string())) + .unwrap()); + + rt.block_on(lock_profile(profile.id.to_string())).unwrap(); + assert!(get_cached_key(&profile.id).is_none()); + assert!(rt + .block_on(is_profile_locked(profile.id.to_string())) + .unwrap()); + + fresh_test_state(&profile.id); + } +} diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index c2f8505..30526c1 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -69,6 +69,10 @@ pub struct BrowserProfile { pub created_by_email: Option, #[serde(default)] pub dns_blocklist: Option, + /// True when the on-disk profile dir is encrypted with a per-profile password. + /// Decryption goes to a RAM-backed ephemeral dir, never to disk. + #[serde(default)] + pub password_protected: bool, } pub fn default_release_type() -> String { diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 50d2d20..9a7eef8 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -584,6 +584,7 @@ impl ProfileImporter { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, }; match self @@ -664,6 +665,7 @@ impl ProfileImporter { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, }; match self @@ -715,6 +717,7 @@ impl ProfileImporter { created_by_id: None, created_by_email: None, dns_blocklist: None, + password_protected: false, }; self.profile_manager.save_profile(&profile)?; diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index b239703..62718f8 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -57,6 +57,11 @@ pub struct AppSettings { pub window_resize_warning_dismissed: bool, #[serde(default)] pub disable_auto_updates: bool, + /// When true, the decrypted in-RAM copy of a password-protected profile is + /// preserved between launches for faster subsequent startups. The on-disk + /// copy is always re-encrypted regardless of this flag. + #[serde(default)] + pub keep_decrypted_profiles_in_ram: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -92,6 +97,7 @@ impl Default for AppSettings { language: None, window_resize_warning_dismissed: false, disable_auto_updates: false, + keep_decrypted_profiles_in_ram: false, } } } @@ -1070,6 +1076,7 @@ mod tests { language: None, window_resize_warning_dismissed: false, disable_auto_updates: false, + keep_decrypted_profiles_in_ram: false, }; let save_result = manager.save_settings(&test_settings); diff --git a/src/app/page.tsx b/src/app/page.tsx index cfed694..009a634 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,6 +24,10 @@ import { IntegrationsDialog } from "@/components/integrations-dialog"; import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; +import { + type PasswordDialogMode, + ProfilePasswordDialog, +} from "@/components/profile-password-dialog"; import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProfileSyncDialog } from "@/components/profile-sync-dialog"; import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog"; @@ -47,6 +51,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; +import { translateBackendError } from "@/lib/backend-errors"; import { dismissToast, showErrorToast, @@ -183,6 +188,11 @@ export default function Home() { const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] = useState(null); const [cloneProfile, setCloneProfile] = useState(null); + const [passwordDialogProfile, setPasswordDialogProfile] = + useState(null); + const [passwordDialogMode, setPasswordDialogMode] = + useState("set"); + const pendingLaunchAfterUnlockRef = useRef(null); const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false); const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false); @@ -532,6 +542,7 @@ export default function Home() { ephemeral?: boolean; dnsBlocklist?: string; launchHook?: string; + password?: string; }) => { try { const profile = await invoke( @@ -565,6 +576,21 @@ export default function Home() { } } + if (profileData.password && !profileData.ephemeral) { + try { + await invoke("set_profile_password", { + profileId: profile.id, + password: profileData.password, + }); + } catch (err) { + showErrorToast( + t("errors.setProfilePasswordFailed", { + error: translateBackendError(t, err), + }), + ); + } + } + // No need to manually reload - useProfileEvents will handle the update } catch (error) { showErrorToast( @@ -581,6 +607,23 @@ export default function Home() { async (profile: BrowserProfile) => { console.log("Starting launch for profile:", profile.name); + // Password-protected: must be unlocked before launch + if (profile.password_protected) { + try { + const isLocked = await invoke("is_profile_locked", { + profileId: profile.id, + }); + if (isLocked) { + pendingLaunchAfterUnlockRef.current = profile; + setPasswordDialogMode("unlock"); + setPasswordDialogProfile(profile); + return; + } + } catch (err) { + console.error("Failed to check profile lock state:", err); + } + } + // Show one-time warning about window resizing for fingerprinted browsers if (profile.browser === "camoufox" || profile.browser === "wayfern") { try { @@ -623,6 +666,24 @@ export default function Home() { setCloneProfile(profile); }, []); + const handleSetPassword = useCallback((profile: BrowserProfile) => { + pendingLaunchAfterUnlockRef.current = null; + setPasswordDialogMode("set"); + setPasswordDialogProfile(profile); + }, []); + + const handleChangePassword = useCallback((profile: BrowserProfile) => { + pendingLaunchAfterUnlockRef.current = null; + setPasswordDialogMode("change"); + setPasswordDialogProfile(profile); + }, []); + + const handleRemovePassword = useCallback((profile: BrowserProfile) => { + pendingLaunchAfterUnlockRef.current = null; + setPasswordDialogMode("remove"); + setPasswordDialogProfile(profile); + }, []); + const handleDeleteProfile = useCallback( async (profile: BrowserProfile) => { console.log("Attempting to delete profile:", profile.name); @@ -1110,6 +1171,9 @@ export default function Home() { onLaunchProfile={launchProfile} onKillProfile={handleKillProfile} onCloneProfile={handleCloneProfile} + onSetPassword={handleSetPassword} + onChangePassword={handleChangePassword} + onRemovePassword={handleRemovePassword} onDeleteProfile={handleDeleteProfile} onRenameProfile={handleRenameProfile} onConfigureCamoufox={handleConfigureCamoufox} @@ -1215,6 +1279,26 @@ export default function Home() { profile={cloneProfile} /> + { + pendingLaunchAfterUnlockRef.current = null; + setPasswordDialogProfile(null); + }} + profile={passwordDialogProfile} + mode={passwordDialogMode} + onSuccess={(p) => { + if ( + passwordDialogMode === "unlock" && + pendingLaunchAfterUnlockRef.current?.id === p.id + ) { + const target = pendingLaunchAfterUnlockRef.current; + pendingLaunchAfterUnlockRef.current = null; + void launchProfile(target); + } + }} + /> + { diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 8842f33..4af5d06 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -86,6 +86,7 @@ interface CreateProfileDialogProps { ephemeral?: boolean; dnsBlocklist?: string; launchHook?: string; + password?: string; }) => Promise; selectedGroupId?: string; crossOsUnlocked?: boolean; @@ -170,6 +171,11 @@ export function CreateProfileDialog({ const [showProxyForm, setShowProxyForm] = useState(false); const [isCreating, setIsCreating] = useState(false); const [ephemeral, setEphemeral] = useState(false); + const [enablePassword, setEnablePassword] = useState(false); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [passwordError, setPasswordError] = useState(null); + const PASSWORD_MIN_LEN = 8; const [selectedExtensionGroupId, setSelectedExtensionGroupId] = useState(); const [extensionGroups, setExtensionGroups] = useState< @@ -370,12 +376,30 @@ export function CreateProfileDialog({ const handleCreate = async () => { if (!profileName.trim()) return; + if (enablePassword && !ephemeral) { + if (password.length < PASSWORD_MIN_LEN) { + setPasswordError( + t("profilePassword.errors.tooShort", { min: PASSWORD_MIN_LEN }), + ); + return; + } + if (password !== passwordConfirm) { + setPasswordError(t("profilePassword.errors.mismatch")); + return; + } + } + setPasswordError(null); + setIsCreating(true); const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false; const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId; const resolvedVpnId = isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined; + const passwordToSet = + enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN + ? password + : undefined; try { if (activeTab === "anti-detect") { // Anti-detect browser - check if Wayfern or Camoufox is selected @@ -403,6 +427,7 @@ export function CreateProfileDialog({ ephemeral, dnsBlocklist: dnsBlocklist || undefined, launchHook: launchHook.trim() || undefined, + password: passwordToSet, }); } else { // Default to Camoufox @@ -430,6 +455,7 @@ export function CreateProfileDialog({ ephemeral, dnsBlocklist: dnsBlocklist || undefined, launchHook: launchHook.trim() || undefined, + password: passwordToSet, }); } } else { @@ -455,6 +481,7 @@ export function CreateProfileDialog({ groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, dnsBlocklist: dnsBlocklist || undefined, launchHook: launchHook.trim() || undefined, + password: passwordToSet, }); } @@ -488,6 +515,10 @@ export function CreateProfileDialog({ os: getCurrentOS() as WayfernOS, // Reset to current OS }); setEphemeral(false); + setEnablePassword(false); + setPassword(""); + setPasswordConfirm(""); + setPasswordError(null); onClose(); }; @@ -718,6 +749,68 @@ export function CreateProfileDialog({

+ {/* Password Option */} + {!ephemeral && ( +
+
+ { + setEnablePassword(checked === true); + if (checked !== true) { + setPassword(""); + setPasswordConfirm(""); + setPasswordError(null); + } + }} + /> + +
+

+ {t("createProfile.passwordProtect.description")} +

+ {enablePassword && ( +
+ { + setPassword(e.target.value); + setPasswordError(null); + }} + placeholder={t( + "profilePassword.fields.newPassword", + )} + autoComplete="new-password" + /> + { + setPasswordConfirm(e.target.value); + setPasswordError(null); + }} + placeholder={t( + "profilePassword.fields.confirm", + )} + autoComplete="new-password" + /> + {passwordError && ( +

+ {passwordError} +

+ )} +
+ )} +
+ )} + {selectedBrowser === "wayfern" ? ( // Wayfern Configuration
diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index c8b6062..5a117eb 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -854,6 +854,9 @@ interface ProfilesDataTableProps { } | undefined; onLaunchWithSync?: (profile: BrowserProfile) => void; + onSetPassword?: (profile: BrowserProfile) => void; + onChangePassword?: (profile: BrowserProfile) => void; + onRemovePassword?: (profile: BrowserProfile) => void; } export function ProfilesDataTable({ @@ -883,6 +886,9 @@ export function ProfilesDataTable({ syncUnlocked = false, getProfileSyncInfo, onLaunchWithSync, + onSetPassword, + onChangePassword, + onRemovePassword, }: ProfilesDataTableProps) { const { t } = useTranslation(); const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); @@ -1695,7 +1701,9 @@ export function ProfilesDataTable({ const meta = table.options.meta as TableMeta; const profile = row.original; const browser = profile.browser; - const IconComponent = getProfileIcon(profile); + const IconComponent = profile.password_protected + ? LuLock + : getProfileIcon(profile); const isCrossOs = isCrossOsProfile(profile); const isSelected = meta.isProfileSelected(profile.id); @@ -2732,6 +2740,9 @@ export function ProfilesDataTable({ }} onCloneProfile={onCloneProfile} onLaunchWithSync={onLaunchWithSync} + onSetPassword={onSetPassword} + onChangePassword={onChangePassword} + onRemovePassword={onRemovePassword} onDeleteProfile={(profile) => { setProfileForInfoDialog(null); setProfileToDelete(profile); diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 92a96a2..d672d91 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -13,7 +13,10 @@ import { LuFingerprint, LuGlobe, LuGroup, + LuKey, LuLink, + LuLock, + LuLockOpen, LuPlus, LuPuzzle, LuRefreshCw, @@ -71,6 +74,9 @@ interface ProfileInfoDialogProps { onCloneProfile?: (profile: BrowserProfile) => void; onDeleteProfile?: (profile: BrowserProfile) => void; onLaunchWithSync?: (profile: BrowserProfile) => void; + onSetPassword?: (profile: BrowserProfile) => void; + onChangePassword?: (profile: BrowserProfile) => void; + onRemovePassword?: (profile: BrowserProfile) => void; crossOsUnlocked?: boolean; isRunning?: boolean; isDisabled?: boolean; @@ -119,6 +125,9 @@ export function ProfileInfoDialog({ onCloneProfile, onDeleteProfile, onLaunchWithSync, + onSetPassword, + onChangePassword, + onRemovePassword, crossOsUnlocked = false, isRunning = false, isDisabled = false, @@ -354,6 +363,40 @@ export function ProfileInfoDialog({ }, hidden: !onOpenLaunchHook, }, + { + icon: , + label: t("profiles.actions.setPassword"), + onClick: () => { + handleAction(() => onSetPassword?.(profile)); + }, + disabled: isDisabled || isRunning, + runningBadge: isRunning, + hidden: + profile.password_protected === true || + profile.ephemeral === true || + !onSetPassword, + }, + { + icon: , + label: t("profiles.actions.changePassword"), + onClick: () => { + handleAction(() => onChangePassword?.(profile)); + }, + disabled: isDisabled || isRunning, + runningBadge: isRunning, + hidden: profile.password_protected !== true || !onChangePassword, + }, + { + icon: , + label: t("profiles.actions.removePassword"), + onClick: () => { + handleAction(() => onRemovePassword?.(profile)); + }, + disabled: isDisabled || isRunning, + runningBadge: isRunning, + hidden: profile.password_protected !== true || !onRemovePassword, + destructive: true, + }, { icon: , label: t("profiles.actions.delete"), @@ -417,6 +460,12 @@ export function ProfileInfoDialog({ {t("profiles.ephemeralBadge")} )} + {profile.password_protected && ( + + + {t("profiles.passwordProtectedBadge")} + + )} {showCrossOs && ( void; + profile: BrowserProfile | null; + mode: PasswordDialogMode; + onSuccess?: (profile: BrowserProfile) => void; +} + +const MIN_LEN = 8; + +export function ProfilePasswordDialog({ + isOpen, + onClose, + profile, + mode, + onSuccess, +}: ProfilePasswordDialogProps) { + const { t } = useTranslation(); + const [oldPassword, setOldPassword] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [confirm, setConfirm] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [lockoutSecondsRemaining, setLockoutSecondsRemaining] = React.useState< + number | null + >(null); + const firstInputRef = React.useRef(null); + + React.useEffect(() => { + if (isOpen) { + setOldPassword(""); + setPassword(""); + setConfirm(""); + setIsSubmitting(false); + setLockoutSecondsRemaining(null); + setTimeout(() => firstInputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Tick down the lockout timer + React.useEffect(() => { + if (lockoutSecondsRemaining == null) return; + if (lockoutSecondsRemaining <= 0) { + setLockoutSecondsRemaining(null); + return; + } + const handle = window.setTimeout(() => { + setLockoutSecondsRemaining((prev) => (prev == null ? null : prev - 1)); + }, 1000); + return () => { + window.clearTimeout(handle); + }; + }, [lockoutSecondsRemaining]); + + if (!profile) return null; + + const needsConfirm = mode === "set" || mode === "change"; + const needsOldPassword = mode === "change" || mode === "remove"; + + const validate = (): string | null => { + if (needsOldPassword && !oldPassword) { + return t("profilePassword.errors.oldPasswordRequired"); + } + if (mode === "set" || mode === "change") { + if (password.length < MIN_LEN) { + return t("profilePassword.errors.tooShort", { min: MIN_LEN }); + } + if (password !== confirm) { + return t("profilePassword.errors.mismatch"); + } + } + if (mode === "unlock" && !password) { + return t("profilePassword.errors.passwordRequired"); + } + if (mode === "remove" && !oldPassword) { + return t("profilePassword.errors.passwordRequired"); + } + return null; + }; + + const handleSubmit = async () => { + if (isSubmitting || lockoutSecondsRemaining != null) return; + const error = validate(); + if (error) { + showErrorToast(error); + return; + } + setIsSubmitting(true); + try { + switch (mode) { + case "set": + await invoke("set_profile_password", { + profileId: profile.id, + password, + }); + showSuccessToast(t("profilePassword.toasts.set")); + break; + case "unlock": + await invoke("unlock_profile", { + profileId: profile.id, + password, + }); + break; + case "change": + await invoke("change_profile_password", { + profileId: profile.id, + oldPassword, + newPassword: password, + }); + showSuccessToast(t("profilePassword.toasts.changed")); + break; + case "remove": + await invoke("remove_profile_password", { + profileId: profile.id, + password: oldPassword, + }); + showSuccessToast(t("profilePassword.toasts.removed")); + break; + } + onSuccess?.(profile); + onClose(); + } catch (err: unknown) { + const lockoutSeconds = extractLockoutSeconds(err); + if (lockoutSeconds != null) { + setLockoutSecondsRemaining(lockoutSeconds); + } else { + showErrorToast(translateBackendError(t, err)); + } + } finally { + setIsSubmitting(false); + } + }; + + const titleKey = + mode === "set" + ? "profilePassword.set.title" + : mode === "unlock" + ? "profilePassword.unlock.title" + : mode === "change" + ? "profilePassword.change.title" + : "profilePassword.remove.title"; + + const descriptionKey = + mode === "set" + ? "profilePassword.set.description" + : mode === "unlock" + ? "profilePassword.unlock.description" + : mode === "change" + ? "profilePassword.change.description" + : "profilePassword.remove.description"; + + const submitLabelKey = + mode === "set" + ? "profilePassword.set.button" + : mode === "unlock" + ? "profilePassword.unlock.button" + : mode === "change" + ? "profilePassword.change.button" + : "profilePassword.remove.button"; + + return ( + { + if (!open) onClose(); + }} + > + + + {t(titleKey)} + + {t(descriptionKey, { name: profile.name })} + + +
+ {(mode === "set" || mode === "change") && ( +
+

+ {t("profilePassword.warnings.forgetWarningTitle")} +

+

+ {t("profilePassword.warnings.forgetWarningBody")} +

+
+ )} + {lockoutSecondsRemaining != null && ( +
+ {t("backendErrors.lockedOut", { + duration: formatLockoutDuration(t, lockoutSecondsRemaining), + })} +
+ )} + {needsOldPassword && ( +
+ + setOldPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSubmit(); + }} + disabled={isSubmitting} + autoComplete="current-password" + /> +
+ )} + {(mode === "set" || mode === "change" || mode === "unlock") && ( +
+ + setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSubmit(); + }} + disabled={isSubmitting} + autoComplete={ + mode === "unlock" ? "current-password" : "new-password" + } + /> +
+ )} + {needsConfirm && ( +
+ + setConfirm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSubmit(); + }} + disabled={isSubmitting} + autoComplete="new-password" + /> +
+ )} +
+ + + {t("common.buttons.cancel")} + + void handleSubmit()} + isLoading={isSubmitting} + disabled={lockoutSecondsRemaining != null} + variant={mode === "remove" ? "destructive" : "default"} + > + {t(submitLabelKey)} + + +
+
+ ); +} diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index e4adba2..53b0387 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -63,6 +63,7 @@ interface AppSettings { api_port: number; api_token?: string; disable_auto_updates?: boolean; + keep_decrypted_profiles_in_ram?: boolean; } interface CustomThemeState { @@ -1129,6 +1130,30 @@ export function SettingsDialog({
)} +
+ { + updateSetting( + "keep_decrypted_profiles_in_ram", + checked as boolean, + ); + }} + /> +
+ +

+ {t("settings.keepDecryptedProfilesInRamDescription")} +

+
+
+ { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 60eef83..cee2e19 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -172,7 +172,9 @@ "clearCacheFailed": "Failed to clear cache" }, "disableAutoUpdates": "Disable App Auto Updates", - "disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected." + "disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.", + "keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM", + "keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless." }, "header": { "searchPlaceholder": "Search profiles...", @@ -221,7 +223,10 @@ "assignToGroup": "Assign to Group", "changeFingerprint": "Change Fingerprint", "copyCookiesToProfile": "Copy Cookies to Profile", - "launchHook": "Launch Hook URL" + "launchHook": "Launch Hook URL", + "setPassword": "Set Password", + "changePassword": "Change Password", + "removePassword": "Remove Password" }, "synchronizer": { "launchWithSync": "Launch with Synchronizer", @@ -265,7 +270,8 @@ "assignProxy": "Assign Proxy", "assignExtensionGroup": "Assign Extension Group", "copyCookies": "Copy Cookies" - } + }, + "passwordProtectedBadge": "Password Protected" }, "createProfile": { "title": "Create New Profile", @@ -312,7 +318,11 @@ "firefoxLabel": "Firefox", "firefoxSubtitle": "Powered by Camoufox", "camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium.", - "platformUnavailable": "{{browser}} is not available on your platform yet." + "platformUnavailable": "{{browser}} is not available on your platform yet.", + "passwordProtect": { + "label": "Password protect this profile", + "description": "Encrypts the on-disk profile data. Required to launch." + } }, "deleteDialog": { "title": "Delete Profile", @@ -892,7 +902,8 @@ "setupProxyListenersFailed": "Failed to setup proxy event listeners: {{error}}", "loadVpnConfigsFailed": "Failed to load VPN configs: {{error}}", "setupVpnListenersFailed": "Failed to setup VPN event listeners: {{error}}", - "themeNotFound": "Tokyo Night theme not found" + "themeNotFound": "Tokyo Night theme not found", + "setProfilePasswordFailed": "Failed to set profile password: {{error}}" }, "browser": { "camoufox": "Camoufox", @@ -1589,5 +1600,66 @@ "upToDateDescription": "All browser versions are up to date", "updateAllFailed": "Failed to update browser versions" } + }, + "profilePassword": { + "set": { + "title": "Set Profile Password", + "description": "Encrypt the on-disk data for {{name}}. You will need this password every time you launch the profile.", + "button": "Encrypt Profile" + }, + "unlock": { + "title": "Unlock Profile", + "description": "Enter the password to unlock {{name}}.", + "button": "Unlock" + }, + "change": { + "title": "Change Profile Password", + "description": "Re-encrypt {{name}} with a new password.", + "button": "Change Password" + }, + "remove": { + "title": "Remove Profile Password", + "description": "Decrypt the on-disk data for {{name}}. The profile will no longer be password protected.", + "button": "Remove Password" + }, + "fields": { + "password": "Password", + "currentPassword": "Current password", + "newPassword": "New password", + "confirm": "Confirm password" + }, + "errors": { + "oldPasswordRequired": "Current password is required", + "passwordRequired": "Password is required", + "tooShort": "Password must be at least {{min}} characters", + "mismatch": "Passwords do not match" + }, + "toasts": { + "set": "Profile is now password protected", + "changed": "Profile password changed", + "removed": "Profile password removed" + }, + "warnings": { + "forgetWarningTitle": "Important: this password is not recoverable", + "forgetWarningBody": "Donut Browser cannot reset, recover, or bypass this password. If you forget it, you will permanently lose access to this profile's data." + } + }, + "backendErrors": { + "incorrectPassword": "Incorrect password", + "lockedOut": "Too many incorrect attempts. Try again in {{duration}}.", + "lockedOutDuration": { + "seconds": "{{seconds}}s", + "minutes": "{{minutes}} min", + "hours": "{{hours}} h" + }, + "profileNotFound": "Profile not found", + "profileNotProtected": "Profile is not password protected", + "profileAlreadyProtected": "Profile is already password protected", + "profileRunning": "Cannot perform this action while the profile is running", + "profileMissingSalt": "Profile is missing its encryption salt", + "profileLocked": "Profile is locked. Enter the password first.", + "invalidProfileId": "Invalid profile id", + "passwordTooShort": "Password must be at least {{min}} characters", + "internal": "Something went wrong: {{detail}}" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 9372610..01faedc 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -172,7 +172,9 @@ "clearCacheFailed": "Error al limpiar la caché" }, "disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App", - "disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas." + "disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.", + "keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM", + "keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso." }, "header": { "searchPlaceholder": "Buscar perfiles...", @@ -221,7 +223,10 @@ "assignToGroup": "Asignar a Grupo", "changeFingerprint": "Cambiar Huella Digital", "copyCookiesToProfile": "Copiar Cookies al Perfil", - "launchHook": "URL del hook de inicio" + "launchHook": "URL del hook de inicio", + "setPassword": "Establecer Contraseña", + "changePassword": "Cambiar Contraseña", + "removePassword": "Quitar Contraseña" }, "synchronizer": { "launchWithSync": "Lanzar con Sincronizador", @@ -265,7 +270,8 @@ "assignProxy": "Asignar proxy", "assignExtensionGroup": "Asignar grupo de extensiones", "copyCookies": "Copiar cookies" - } + }, + "passwordProtectedBadge": "Protegido por Contraseña" }, "createProfile": { "title": "Crear Nuevo Perfil", @@ -312,7 +318,11 @@ "firefoxLabel": "Firefox", "firefoxSubtitle": "Impulsado por Camoufox", "camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium.", - "platformUnavailable": "{{browser}} aún no está disponible en tu plataforma." + "platformUnavailable": "{{browser}} aún no está disponible en tu plataforma.", + "passwordProtect": { + "label": "Proteger este perfil con contraseña", + "description": "Cifra los datos del perfil en disco. Necesario para abrirlo." + } }, "deleteDialog": { "title": "Eliminar Perfil", @@ -892,7 +902,8 @@ "setupProxyListenersFailed": "Error al configurar los listeners de eventos de proxies: {{error}}", "loadVpnConfigsFailed": "Error al cargar las configuraciones de VPN: {{error}}", "setupVpnListenersFailed": "Error al configurar los listeners de eventos de VPN: {{error}}", - "themeNotFound": "Tema Tokyo Night no encontrado" + "themeNotFound": "Tema Tokyo Night no encontrado", + "setProfilePasswordFailed": "Error al establecer la contraseña del perfil: {{error}}" }, "browser": { "camoufox": "Camoufox", @@ -1589,5 +1600,66 @@ "upToDateDescription": "Todas las versiones del navegador están actualizadas", "updateAllFailed": "Error al actualizar las versiones del navegador" } + }, + "profilePassword": { + "set": { + "title": "Establecer Contraseña del Perfil", + "description": "Cifra los datos en disco de {{name}}. Necesitarás esta contraseña cada vez que abras el perfil.", + "button": "Cifrar Perfil" + }, + "unlock": { + "title": "Desbloquear Perfil", + "description": "Introduce la contraseña para desbloquear {{name}}.", + "button": "Desbloquear" + }, + "change": { + "title": "Cambiar Contraseña del Perfil", + "description": "Vuelve a cifrar {{name}} con una nueva contraseña.", + "button": "Cambiar Contraseña" + }, + "remove": { + "title": "Quitar Contraseña del Perfil", + "description": "Descifra los datos en disco de {{name}}. El perfil dejará de estar protegido por contraseña.", + "button": "Quitar Contraseña" + }, + "fields": { + "password": "Contraseña", + "currentPassword": "Contraseña actual", + "newPassword": "Nueva contraseña", + "confirm": "Confirmar contraseña" + }, + "errors": { + "oldPasswordRequired": "Se requiere la contraseña actual", + "passwordRequired": "Se requiere la contraseña", + "tooShort": "La contraseña debe tener al menos {{min}} caracteres", + "mismatch": "Las contraseñas no coinciden" + }, + "toasts": { + "set": "El perfil ahora está protegido por contraseña", + "changed": "Contraseña del perfil cambiada", + "removed": "Contraseña del perfil eliminada" + }, + "warnings": { + "forgetWarningTitle": "Importante: esta contraseña no se puede recuperar", + "forgetWarningBody": "Donut Browser no puede restablecer, recuperar ni omitir esta contraseña. Si la olvidas, perderás permanentemente el acceso a los datos de este perfil." + } + }, + "backendErrors": { + "incorrectPassword": "Contraseña incorrecta", + "lockedOut": "Demasiados intentos incorrectos. Vuelve a intentar en {{duration}}.", + "lockedOutDuration": { + "seconds": "{{seconds}}s", + "minutes": "{{minutes}} min", + "hours": "{{hours}} h" + }, + "profileNotFound": "Perfil no encontrado", + "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", + "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", + "passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres", + "internal": "Algo salió mal: {{detail}}" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a798205..74dda9c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -172,7 +172,9 @@ "clearCacheFailed": "Échec de la suppression du cache" }, "disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app", - "disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées." + "disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.", + "keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM", + "keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas." }, "header": { "searchPlaceholder": "Rechercher des profils...", @@ -221,7 +223,10 @@ "assignToGroup": "Assigner au Groupe", "changeFingerprint": "Changer l'Empreinte", "copyCookiesToProfile": "Copier les Cookies vers le Profil", - "launchHook": "URL du hook de lancement" + "launchHook": "URL du hook de lancement", + "setPassword": "Définir un mot de passe", + "changePassword": "Changer le mot de passe", + "removePassword": "Supprimer le mot de passe" }, "synchronizer": { "launchWithSync": "Lancer avec le synchroniseur", @@ -265,7 +270,8 @@ "assignProxy": "Assigner un proxy", "assignExtensionGroup": "Assigner un groupe d’extensions", "copyCookies": "Copier les cookies" - } + }, + "passwordProtectedBadge": "Protégé par mot de passe" }, "createProfile": { "title": "Créer un nouveau profil", @@ -312,7 +318,11 @@ "firefoxLabel": "Firefox", "firefoxSubtitle": "Propulsé par Camoufox", "camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium.", - "platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme." + "platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme.", + "passwordProtect": { + "label": "Protéger ce profil par mot de passe", + "description": "Chiffre les données du profil sur disque. Requis au lancement." + } }, "deleteDialog": { "title": "Supprimer le profil", @@ -892,7 +902,8 @@ "setupProxyListenersFailed": "Échec de la configuration des écouteurs d’événements de proxies : {{error}}", "loadVpnConfigsFailed": "Échec du chargement des configurations VPN : {{error}}", "setupVpnListenersFailed": "Échec de la configuration des écouteurs d’événements VPN : {{error}}", - "themeNotFound": "Thème Tokyo Night introuvable" + "themeNotFound": "Thème Tokyo Night introuvable", + "setProfilePasswordFailed": "Échec de la définition du mot de passe du profil : {{error}}" }, "browser": { "camoufox": "Camoufox", @@ -1589,5 +1600,66 @@ "upToDateDescription": "Toutes les versions des navigateurs sont à jour", "updateAllFailed": "Échec de la mise à jour des versions des navigateurs" } + }, + "profilePassword": { + "set": { + "title": "Définir un mot de passe de profil", + "description": "Chiffre les données sur disque de {{name}}. Vous devrez saisir ce mot de passe à chaque lancement du profil.", + "button": "Chiffrer le profil" + }, + "unlock": { + "title": "Déverrouiller le profil", + "description": "Saisissez le mot de passe pour déverrouiller {{name}}.", + "button": "Déverrouiller" + }, + "change": { + "title": "Changer le mot de passe du profil", + "description": "Re-chiffre {{name}} avec un nouveau mot de passe.", + "button": "Changer le mot de passe" + }, + "remove": { + "title": "Supprimer le mot de passe du profil", + "description": "Déchiffre les données sur disque de {{name}}. Le profil ne sera plus protégé par mot de passe.", + "button": "Supprimer le mot de passe" + }, + "fields": { + "password": "Mot de passe", + "currentPassword": "Mot de passe actuel", + "newPassword": "Nouveau mot de passe", + "confirm": "Confirmer le mot de passe" + }, + "errors": { + "oldPasswordRequired": "Le mot de passe actuel est requis", + "passwordRequired": "Le mot de passe est requis", + "tooShort": "Le mot de passe doit comporter au moins {{min}} caractères", + "mismatch": "Les mots de passe ne correspondent pas" + }, + "toasts": { + "set": "Le profil est maintenant protégé par mot de passe", + "changed": "Mot de passe du profil modifié", + "removed": "Mot de passe du profil supprimé" + }, + "warnings": { + "forgetWarningTitle": "Important : ce mot de passe ne peut pas être récupéré", + "forgetWarningBody": "Donut Browser ne peut ni réinitialiser, ni récupérer, ni contourner ce mot de passe. Si vous l'oubliez, vous perdrez définitivement l'accès aux données de ce profil." + } + }, + "backendErrors": { + "incorrectPassword": "Mot de passe incorrect", + "lockedOut": "Trop de tentatives incorrectes. Réessayez dans {{duration}}.", + "lockedOutDuration": { + "seconds": "{{seconds}}s", + "minutes": "{{minutes}} min", + "hours": "{{hours}} h" + }, + "profileNotFound": "Profil introuvable", + "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", + "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", + "passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères", + "internal": "Une erreur s'est produite : {{detail}}" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 887e07b..b30e078 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -172,7 +172,9 @@ "clearCacheFailed": "キャッシュのクリアに失敗しました" }, "disableAutoUpdates": "アプリの自動更新を無効にする", - "disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。" + "disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。", + "keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持", + "keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。" }, "header": { "searchPlaceholder": "プロファイルを検索...", @@ -221,7 +223,10 @@ "assignToGroup": "グループに割り当て", "changeFingerprint": "フィンガープリントを変更", "copyCookiesToProfile": "Cookieをプロファイルにコピー", - "launchHook": "起動フックURL" + "launchHook": "起動フックURL", + "setPassword": "パスワードを設定", + "changePassword": "パスワードを変更", + "removePassword": "パスワードを削除" }, "synchronizer": { "launchWithSync": "シンクロナイザーで起動", @@ -265,7 +270,8 @@ "assignProxy": "プロキシを割り当て", "assignExtensionGroup": "拡張機能グループを割り当て", "copyCookies": "Cookieをコピー" - } + }, + "passwordProtectedBadge": "パスワード保護" }, "createProfile": { "title": "新しいプロファイルを作成", @@ -312,7 +318,11 @@ "firefoxLabel": "Firefox", "firefoxSubtitle": "Camoufox搭載", "camoufoxWarning": "Firefox(Camoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。", - "platformUnavailable": "{{browser}} はまだお使いのプラットフォームで利用できません。" + "platformUnavailable": "{{browser}} はまだお使いのプラットフォームで利用できません。", + "passwordProtect": { + "label": "このプロファイルをパスワードで保護", + "description": "ディスク上のプロファイルデータを暗号化します。起動に必要です。" + } }, "deleteDialog": { "title": "プロファイルを削除", @@ -892,7 +902,8 @@ "setupProxyListenersFailed": "プロキシイベントリスナーの設定に失敗しました: {{error}}", "loadVpnConfigsFailed": "VPN設定の読み込みに失敗しました: {{error}}", "setupVpnListenersFailed": "VPNイベントリスナーの設定に失敗しました: {{error}}", - "themeNotFound": "Tokyo Night テーマが見つかりません" + "themeNotFound": "Tokyo Night テーマが見つかりません", + "setProfilePasswordFailed": "プロファイルのパスワード設定に失敗しました: {{error}}" }, "browser": { "camoufox": "Camoufox", @@ -1589,5 +1600,66 @@ "upToDateDescription": "すべてのブラウザバージョンは最新です", "updateAllFailed": "ブラウザバージョンの更新に失敗しました" } + }, + "profilePassword": { + "set": { + "title": "プロファイルにパスワードを設定", + "description": "{{name}} のディスク上のデータを暗号化します。プロファイルを起動するたびにこのパスワードが必要になります。", + "button": "プロファイルを暗号化" + }, + "unlock": { + "title": "プロファイルを解除", + "description": "{{name}} を解除するためのパスワードを入力してください。", + "button": "解除" + }, + "change": { + "title": "プロファイルのパスワードを変更", + "description": "新しいパスワードで {{name}} を再暗号化します。", + "button": "パスワードを変更" + }, + "remove": { + "title": "プロファイルのパスワードを削除", + "description": "{{name}} のディスク上のデータを復号します。プロファイルはパスワード保護されなくなります。", + "button": "パスワードを削除" + }, + "fields": { + "password": "パスワード", + "currentPassword": "現在のパスワード", + "newPassword": "新しいパスワード", + "confirm": "パスワードの確認" + }, + "errors": { + "oldPasswordRequired": "現在のパスワードが必要です", + "passwordRequired": "パスワードが必要です", + "tooShort": "パスワードは {{min}} 文字以上必要です", + "mismatch": "パスワードが一致しません" + }, + "toasts": { + "set": "プロファイルがパスワードで保護されました", + "changed": "プロファイルのパスワードを変更しました", + "removed": "プロファイルのパスワードを削除しました" + }, + "warnings": { + "forgetWarningTitle": "重要: このパスワードは復元できません", + "forgetWarningBody": "Donut Browserはこのパスワードをリセット、復元、回避することはできません。忘れた場合、このプロファイルのデータへのアクセスは永続的に失われます。" + } + }, + "backendErrors": { + "incorrectPassword": "パスワードが正しくありません", + "lockedOut": "失敗回数が多すぎます。{{duration}}後に再試行してください。", + "lockedOutDuration": { + "seconds": "{{seconds}}秒", + "minutes": "{{minutes}}分", + "hours": "{{hours}}時間" + }, + "profileNotFound": "プロファイルが見つかりません", + "profileNotProtected": "プロファイルはパスワード保護されていません", + "profileAlreadyProtected": "プロファイルはすでにパスワード保護されています", + "profileRunning": "プロファイルの実行中はこの操作を実行できません", + "profileMissingSalt": "プロファイルに暗号化ソルトがありません", + "profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。", + "invalidProfileId": "無効なプロファイルIDです", + "passwordTooShort": "パスワードは {{min}} 文字以上必要です", + "internal": "問題が発生しました: {{detail}}" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7819307..61895fe 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -172,7 +172,9 @@ "clearCacheFailed": "Falha ao limpar o cache" }, "disableAutoUpdates": "Desativar Atualizações Automáticas do App", - "disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas." + "disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.", + "keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM", + "keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso." }, "header": { "searchPlaceholder": "Pesquisar perfis...", @@ -221,7 +223,10 @@ "assignToGroup": "Atribuir ao Grupo", "changeFingerprint": "Alterar Impressão Digital", "copyCookiesToProfile": "Copiar Cookies para o Perfil", - "launchHook": "URL do hook de inicialização" + "launchHook": "URL do hook de inicialização", + "setPassword": "Definir Senha", + "changePassword": "Alterar Senha", + "removePassword": "Remover Senha" }, "synchronizer": { "launchWithSync": "Iniciar com Sincronizador", @@ -265,7 +270,8 @@ "assignProxy": "Atribuir proxy", "assignExtensionGroup": "Atribuir grupo de extensões", "copyCookies": "Copiar cookies" - } + }, + "passwordProtectedBadge": "Protegido por Senha" }, "createProfile": { "title": "Criar Novo Perfil", @@ -312,7 +318,11 @@ "firefoxLabel": "Firefox", "firefoxSubtitle": "Desenvolvido com Camoufox", "camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium.", - "platformUnavailable": "{{browser}} ainda não está disponível para sua plataforma." + "platformUnavailable": "{{browser}} ainda não está disponível para sua plataforma.", + "passwordProtect": { + "label": "Proteger este perfil com senha", + "description": "Criptografa os dados do perfil em disco. Necessário para iniciar." + } }, "deleteDialog": { "title": "Excluir Perfil", @@ -892,7 +902,8 @@ "setupProxyListenersFailed": "Falha ao configurar os listeners de eventos de proxies: {{error}}", "loadVpnConfigsFailed": "Falha ao carregar as configurações de VPN: {{error}}", "setupVpnListenersFailed": "Falha ao configurar os listeners de eventos de VPN: {{error}}", - "themeNotFound": "Tema Tokyo Night não encontrado" + "themeNotFound": "Tema Tokyo Night não encontrado", + "setProfilePasswordFailed": "Falha ao definir a senha do perfil: {{error}}" }, "browser": { "camoufox": "Camoufox", @@ -1589,5 +1600,66 @@ "upToDateDescription": "Todas as versões dos navegadores estão atualizadas", "updateAllFailed": "Falha ao atualizar as versões dos navegadores" } + }, + "profilePassword": { + "set": { + "title": "Definir Senha do Perfil", + "description": "Criptografa os dados em disco de {{name}}. Você precisará desta senha sempre que abrir o perfil.", + "button": "Criptografar Perfil" + }, + "unlock": { + "title": "Desbloquear Perfil", + "description": "Digite a senha para desbloquear {{name}}.", + "button": "Desbloquear" + }, + "change": { + "title": "Alterar Senha do Perfil", + "description": "Recriptografe {{name}} com uma nova senha.", + "button": "Alterar Senha" + }, + "remove": { + "title": "Remover Senha do Perfil", + "description": "Descriptografa os dados em disco de {{name}}. O perfil deixará de estar protegido por senha.", + "button": "Remover Senha" + }, + "fields": { + "password": "Senha", + "currentPassword": "Senha atual", + "newPassword": "Nova senha", + "confirm": "Confirmar senha" + }, + "errors": { + "oldPasswordRequired": "A senha atual é obrigatória", + "passwordRequired": "A senha é obrigatória", + "tooShort": "A senha deve ter pelo menos {{min}} caracteres", + "mismatch": "As senhas não coincidem" + }, + "toasts": { + "set": "O perfil agora está protegido por senha", + "changed": "Senha do perfil alterada", + "removed": "Senha do perfil removida" + }, + "warnings": { + "forgetWarningTitle": "Importante: esta senha não pode ser recuperada", + "forgetWarningBody": "O Donut Browser não pode redefinir, recuperar ou contornar esta senha. Se você esquecê-la, perderá permanentemente o acesso aos dados deste perfil." + } + }, + "backendErrors": { + "incorrectPassword": "Senha incorreta", + "lockedOut": "Tentativas incorretas demais. Tente novamente em {{duration}}.", + "lockedOutDuration": { + "seconds": "{{seconds}}s", + "minutes": "{{minutes}} min", + "hours": "{{hours}} h" + }, + "profileNotFound": "Perfil não encontrado", + "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", + "profileMissingSalt": "O perfil está sem o sal de criptografia", + "profileLocked": "O perfil está bloqueado. Digite a senha primeiro.", + "invalidProfileId": "ID de perfil inválido", + "passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres", + "internal": "Algo deu errado: {{detail}}" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 9fb8447..18826e3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -172,7 +172,9 @@ "clearCacheFailed": "Не удалось очистить кэш" }, "disableAutoUpdates": "Отключить автообновление приложения", - "disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются." + "disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.", + "keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ", + "keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной." }, "header": { "searchPlaceholder": "Поиск профилей...", @@ -221,7 +223,10 @@ "assignToGroup": "Назначить группе", "changeFingerprint": "Изменить отпечаток", "copyCookiesToProfile": "Копировать Cookie в профиль", - "launchHook": "URL хука запуска" + "launchHook": "URL хука запуска", + "setPassword": "Установить пароль", + "changePassword": "Изменить пароль", + "removePassword": "Удалить пароль" }, "synchronizer": { "launchWithSync": "Запустить с синхронизатором", @@ -265,7 +270,8 @@ "assignProxy": "Назначить прокси", "assignExtensionGroup": "Назначить группу расширений", "copyCookies": "Копировать cookies" - } + }, + "passwordProtectedBadge": "Защищено паролем" }, "createProfile": { "title": "Создать новый профиль", @@ -312,7 +318,11 @@ "firefoxLabel": "Firefox", "firefoxSubtitle": "На базе Camoufox", "camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium.", - "platformUnavailable": "{{browser}} пока недоступен на вашей платформе." + "platformUnavailable": "{{browser}} пока недоступен на вашей платформе.", + "passwordProtect": { + "label": "Защитить этот профиль паролем", + "description": "Шифрует данные профиля на диске. Требуется для запуска." + } }, "deleteDialog": { "title": "Удалить профиль", @@ -892,7 +902,8 @@ "setupProxyListenersFailed": "Не удалось настроить слушатели событий прокси: {{error}}", "loadVpnConfigsFailed": "Не удалось загрузить конфигурации VPN: {{error}}", "setupVpnListenersFailed": "Не удалось настроить слушатели событий VPN: {{error}}", - "themeNotFound": "Тема Tokyo Night не найдена" + "themeNotFound": "Тема Tokyo Night не найдена", + "setProfilePasswordFailed": "Не удалось установить пароль профиля: {{error}}" }, "browser": { "camoufox": "Camoufox", @@ -1589,5 +1600,66 @@ "upToDateDescription": "Все версии браузеров актуальны", "updateAllFailed": "Не удалось обновить версии браузеров" } + }, + "profilePassword": { + "set": { + "title": "Установить пароль профиля", + "description": "Шифрует данные {{name}} на диске. Этот пароль потребуется при каждом запуске профиля.", + "button": "Зашифровать профиль" + }, + "unlock": { + "title": "Разблокировать профиль", + "description": "Введите пароль, чтобы разблокировать {{name}}.", + "button": "Разблокировать" + }, + "change": { + "title": "Изменить пароль профиля", + "description": "Перезашифровать {{name}} с новым паролем.", + "button": "Изменить пароль" + }, + "remove": { + "title": "Удалить пароль профиля", + "description": "Расшифровывает данные {{name}} на диске. Профиль больше не будет защищён паролем.", + "button": "Удалить пароль" + }, + "fields": { + "password": "Пароль", + "currentPassword": "Текущий пароль", + "newPassword": "Новый пароль", + "confirm": "Подтвердите пароль" + }, + "errors": { + "oldPasswordRequired": "Требуется текущий пароль", + "passwordRequired": "Требуется пароль", + "tooShort": "Пароль должен быть не короче {{min}} символов", + "mismatch": "Пароли не совпадают" + }, + "toasts": { + "set": "Профиль защищён паролем", + "changed": "Пароль профиля изменён", + "removed": "Пароль профиля удалён" + }, + "warnings": { + "forgetWarningTitle": "Важно: пароль восстановить нельзя", + "forgetWarningBody": "Donut Browser не может сбросить, восстановить или обойти этот пароль. Если вы его забудете, доступ к данным этого профиля будет утрачен навсегда." + } + }, + "backendErrors": { + "incorrectPassword": "Неверный пароль", + "lockedOut": "Слишком много неудачных попыток. Повторите через {{duration}}.", + "lockedOutDuration": { + "seconds": "{{seconds}}с", + "minutes": "{{minutes}} мин", + "hours": "{{hours}} ч" + }, + "profileNotFound": "Профиль не найден", + "profileNotProtected": "Профиль не защищён паролем", + "profileAlreadyProtected": "Профиль уже защищён паролем", + "profileRunning": "Невозможно выполнить это действие, пока профиль запущен", + "profileMissingSalt": "У профиля отсутствует соль шифрования", + "profileLocked": "Профиль заблокирован. Сначала введите пароль.", + "invalidProfileId": "Недействительный идентификатор профиля", + "passwordTooShort": "Пароль должен быть не короче {{min}} символов", + "internal": "Что-то пошло не так: {{detail}}" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f4972d6..022e108 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -172,7 +172,9 @@ "clearCacheFailed": "清除缓存失败" }, "disableAutoUpdates": "禁用应用自动更新", - "disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。" + "disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。", + "keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件", + "keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。" }, "header": { "searchPlaceholder": "搜索配置文件...", @@ -221,7 +223,10 @@ "assignToGroup": "分配到组", "changeFingerprint": "更改指纹", "copyCookiesToProfile": "复制 Cookies 到配置文件", - "launchHook": "启动钩子 URL" + "launchHook": "启动钩子 URL", + "setPassword": "设置密码", + "changePassword": "更改密码", + "removePassword": "移除密码" }, "synchronizer": { "launchWithSync": "使用同步器启动", @@ -265,7 +270,8 @@ "assignProxy": "分配代理", "assignExtensionGroup": "分配扩展分组", "copyCookies": "复制 Cookie" - } + }, + "passwordProtectedBadge": "密码保护" }, "createProfile": { "title": "创建新配置文件", @@ -312,7 +318,11 @@ "firefoxLabel": "Firefox", "firefoxSubtitle": "由 Camoufox 驱动", "camoufoxWarning": "Firefox(Camoufox)由第三方组织维护。在生产环境中,请使用 Chromium。", - "platformUnavailable": "{{browser}} 在您的平台上尚不可用。" + "platformUnavailable": "{{browser}} 在您的平台上尚不可用。", + "passwordProtect": { + "label": "为此配置文件设置密码保护", + "description": "加密磁盘上的配置文件数据。启动时需要密码。" + } }, "deleteDialog": { "title": "删除配置文件", @@ -892,7 +902,8 @@ "setupProxyListenersFailed": "设置代理事件监听器失败: {{error}}", "loadVpnConfigsFailed": "加载 VPN 配置失败: {{error}}", "setupVpnListenersFailed": "设置 VPN 事件监听器失败: {{error}}", - "themeNotFound": "未找到 Tokyo Night 主题" + "themeNotFound": "未找到 Tokyo Night 主题", + "setProfilePasswordFailed": "设置配置文件密码失败: {{error}}" }, "browser": { "camoufox": "Camoufox", @@ -1589,5 +1600,66 @@ "upToDateDescription": "所有浏览器版本都是最新的", "updateAllFailed": "更新浏览器版本失败" } + }, + "profilePassword": { + "set": { + "title": "设置配置文件密码", + "description": "加密 {{name}} 的磁盘数据。每次启动配置文件时都需要输入此密码。", + "button": "加密配置文件" + }, + "unlock": { + "title": "解锁配置文件", + "description": "输入密码以解锁 {{name}}。", + "button": "解锁" + }, + "change": { + "title": "更改配置文件密码", + "description": "使用新密码重新加密 {{name}}。", + "button": "更改密码" + }, + "remove": { + "title": "移除配置文件密码", + "description": "解密 {{name}} 的磁盘数据。配置文件将不再受密码保护。", + "button": "移除密码" + }, + "fields": { + "password": "密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirm": "确认密码" + }, + "errors": { + "oldPasswordRequired": "需要当前密码", + "passwordRequired": "需要密码", + "tooShort": "密码至少需要 {{min}} 个字符", + "mismatch": "密码不匹配" + }, + "toasts": { + "set": "配置文件现在受密码保护", + "changed": "配置文件密码已更改", + "removed": "配置文件密码已移除" + }, + "warnings": { + "forgetWarningTitle": "重要:此密码无法恢复", + "forgetWarningBody": "Donut Browser 无法重置、恢复或绕过此密码。如果忘记,您将永久无法访问此配置文件的数据。" + } + }, + "backendErrors": { + "incorrectPassword": "密码不正确", + "lockedOut": "尝试次数过多。请在 {{duration}} 后重试。", + "lockedOutDuration": { + "seconds": "{{seconds}}秒", + "minutes": "{{minutes}} 分钟", + "hours": "{{hours}} 小时" + }, + "profileNotFound": "未找到配置文件", + "profileNotProtected": "配置文件未受密码保护", + "profileAlreadyProtected": "配置文件已受密码保护", + "profileRunning": "配置文件运行时无法执行此操作", + "profileMissingSalt": "配置文件缺少加密盐", + "profileLocked": "配置文件已锁定。请先输入密码。", + "invalidProfileId": "配置文件 ID 无效", + "passwordTooShort": "密码至少需要 {{min}} 个字符", + "internal": "出现问题:{{detail}}" } } diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts new file mode 100644 index 0000000..1cf192e --- /dev/null +++ b/src/lib/backend-errors.ts @@ -0,0 +1,121 @@ +import type { TFunction } from "i18next"; + +/** + * Backend error codes returned from Rust Tauri commands. + * Keep this list in sync with the codes used in `src-tauri/src/profile/password.rs`. + */ +export type BackendErrorCode = + | "INCORRECT_PASSWORD" + | "LOCKED_OUT" + | "PROFILE_NOT_FOUND" + | "PROFILE_NOT_PROTECTED" + | "PROFILE_ALREADY_PROTECTED" + | "PROFILE_RUNNING" + | "PROFILE_MISSING_SALT" + | "PROFILE_LOCKED" + | "INVALID_PROFILE_ID" + | "PASSWORD_TOO_SHORT" + | "INTERNAL_ERROR"; + +export interface BackendError { + code: BackendErrorCode; + params?: Record; +} + +/** + * Try to parse a backend error string as a structured `{code, params}` payload. + * Returns null if the string isn't structured (e.g. raw error from a command + * that doesn't yet emit codes — caller should fall back to showing the raw text). + */ +export function parseBackendError(err: unknown): BackendError | null { + const message = err instanceof Error ? err.message : String(err); + if (!message.startsWith("{")) return null; + try { + const parsed = JSON.parse(message); + if ( + parsed && + typeof parsed === "object" && + typeof parsed.code === "string" + ) { + return parsed as BackendError; + } + } catch { + // not JSON + } + return null; +} + +/** + * Translate a backend error to a localized string. Falls back to the raw + * message if the error isn't a structured backend error. + */ +export function translateBackendError(t: TFunction, err: unknown): string { + const parsed = parseBackendError(err); + if (!parsed) { + return err instanceof Error ? err.message : String(err); + } + switch (parsed.code) { + case "INCORRECT_PASSWORD": + return t("backendErrors.incorrectPassword"); + case "LOCKED_OUT": { + const seconds = Number.parseInt(parsed.params?.seconds ?? "0", 10); + return t("backendErrors.lockedOut", { + duration: formatLockoutDuration(t, seconds), + }); + } + case "PROFILE_NOT_FOUND": + return t("backendErrors.profileNotFound"); + case "PROFILE_NOT_PROTECTED": + return t("backendErrors.profileNotProtected"); + case "PROFILE_ALREADY_PROTECTED": + return t("backendErrors.profileAlreadyProtected"); + case "PROFILE_RUNNING": + return t("backendErrors.profileRunning"); + case "PROFILE_MISSING_SALT": + return t("backendErrors.profileMissingSalt"); + case "PROFILE_LOCKED": + return t("backendErrors.profileLocked"); + case "INVALID_PROFILE_ID": + return t("backendErrors.invalidProfileId"); + case "PASSWORD_TOO_SHORT": { + const min = Number.parseInt(parsed.params?.min ?? "8", 10); + return t("backendErrors.passwordTooShort", { min }); + } + case "INTERNAL_ERROR": + return t("backendErrors.internal", { + detail: parsed.params?.detail ?? "", + }); + default: + return err instanceof Error ? err.message : String(err); + } +} + +export function formatLockoutDuration(t: TFunction, seconds: number): string { + if (seconds < 60) + return t("backendErrors.lockedOutDuration.seconds", { seconds }); + const minutes = Math.ceil(seconds / 60); + if (minutes < 60) + return t("backendErrors.lockedOutDuration.minutes", { minutes }); + const hours = Math.ceil(minutes / 60); + return t("backendErrors.lockedOutDuration.hours", { hours }); +} + +/** + * Extract the lockout countdown in seconds from a backend error, or null. + */ +export function extractLockoutSeconds(err: unknown): number | null { + const parsed = parseBackendError(err); + if (parsed?.code !== "LOCKED_OUT") return null; + const secs = Number.parseInt(parsed.params?.seconds ?? "0", 10); + return Number.isFinite(secs) && secs > 0 ? secs : null; +} + +/** + * True if the error is a known structured backend error code. + */ +export function isBackendErrorCode( + err: unknown, + code: BackendErrorCode, +): boolean { + return parseBackendError(err)?.code === code; +} diff --git a/src/types.ts b/src/types.ts index 4457cf3..5e35288 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,7 @@ export interface BrowserProfile { created_by_id?: string; created_by_email?: string; dns_blocklist?: string; + password_protected?: boolean; } export interface Extension {