mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 18:17:49 +02:00
1307 lines
42 KiB
Rust
1307 lines
42 KiB
Rust
//! Tauri commands for profile password lifecycle: set, change, remove,
|
|
//! unlock, lock, status.
|
|
//!
|
|
//! All error responses returned to the frontend are JSON-encoded
|
|
//! `{ "code": "<ERROR_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<HashMap<uuid::Uuid, HashMap<String, SystemTime>>> =
|
|
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<HashSet<uuid::Uuid>> = Mutex::new(HashSet::new());
|
|
|
|
/// Per-profile failed unlock attempt tracking for rate-limiting.
|
|
static ref FAILED_ATTEMPTS: Mutex<HashMap<uuid::Uuid, FailureRecord>> = 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<FailureRecord> {
|
|
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<FailureRecord> {
|
|
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<std::time::Duration> {
|
|
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, String> {
|
|
uuid::Uuid::parse_str(profile_id).map_err(|_| err_code("INVALID_PROFILE_ID"))
|
|
}
|
|
|
|
fn load_profile(profile_id: &uuid::Uuid) -> Result<crate::profile::BrowserProfile, String> {
|
|
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<bool, String> {
|
|
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"));
|
|
}
|
|
|
|
// Ephemeral profiles live in RAM-backed dirs that get wiped on quit, so
|
|
// there's no on-disk data to encrypt. The two features are mutually
|
|
// exclusive by design — fail loudly rather than silently producing a
|
|
// half-broken state where `password_protected` is true but the encrypted
|
|
// dir vanishes between launches.
|
|
if profile.ephemeral {
|
|
return Err(err_code("PROFILE_EPHEMERAL"));
|
|
}
|
|
|
|
if profile
|
|
.process_id
|
|
.is_some_and(crate::proxy_storage::is_process_running)
|
|
{
|
|
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);
|
|
crate::sync::queue_profile_sync_if_eligible(&profile);
|
|
emit_profiles_changed();
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify a profile password without unlocking. Used by the Settings UI's
|
|
/// "Validate" button so users can confirm they remember the password without
|
|
/// performing a destructive change. Honors the same lockout schedule as
|
|
/// `unlock_profile` so a brute-force attacker can't bypass rate-limiting by
|
|
/// hammering this command.
|
|
#[tauri::command]
|
|
pub async fn verify_profile_password(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"))?;
|
|
let key = derive_profile_key(&password, salt).map_err(err_internal)?;
|
|
let dir = profile_data_dir(&profile);
|
|
match verify_key_against_dir(&key, &dir) {
|
|
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 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);
|
|
crate::sync::queue_profile_sync_if_eligible(&profile);
|
|
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);
|
|
crate::sync::queue_profile_sync_if_eligible(&profile);
|
|
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<String, SystemTime> {
|
|
let mut out: HashMap<String, SystemTime> = HashMap::new();
|
|
fn walk(
|
|
base: &Path,
|
|
current: &Path,
|
|
out: &mut HashMap<String, SystemTime>,
|
|
) -> 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<PathBuf, String> {
|
|
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<usize> {
|
|
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
|
|
}
|
|
|
|
/// Re-encrypt a password-protected profile's ephemeral dir back to the
|
|
/// on-disk encrypted dir after the browser process exits. Optionally purges
|
|
/// the ephemeral dir + cached key based on the global setting. Returns the
|
|
/// number of files re-encrypted (`None` when nothing to do or the profile
|
|
/// isn't protected).
|
|
///
|
|
/// Callers that release a queued sync run after a browser quit MUST await
|
|
/// this future — releasing sync while re-encryption is still in-flight
|
|
/// uploads the stale on-disk snapshot and leaves the fresh ciphertext
|
|
/// orphaned until the next scheduler tick.
|
|
pub async fn complete_after_quit_and_wait(
|
|
profile: &crate::profile::BrowserProfile,
|
|
) -> Option<usize> {
|
|
if !profile.password_protected {
|
|
return None;
|
|
}
|
|
let keep_decrypted = read_keep_decrypted_setting();
|
|
let profile = profile.clone();
|
|
|
|
tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
log::error!("complete_after_quit_and_wait join error: {e}");
|
|
None
|
|
})
|
|
}
|
|
|
|
#[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<String> {
|
|
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<String> = 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::<u64>().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);
|
|
}
|
|
}
|