feat: add tips

This commit is contained in:
zhom
2026-09-09 06:16:14 -07:00
parent 3a0e5a41c0
commit a01901ef07
83 changed files with 8032 additions and 2732 deletions
+14 -7
View File
@@ -37,13 +37,20 @@ fn main() {
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
}
// Inject vault password at build time
if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") {
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}");
} else {
// Use default password if environment variable is not set
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
}
// The sealing password of every build before the per-install vault key.
// Still compiled in so an update can open the files those builds sealed
// and re-seal them under the installation's own key (see `src/vault.rs`).
// It reaches the crate through a file in OUT_DIR rather than a rustc-env
// line, so the build log never carries it.
let legacy_vault_password = std::env::var("DONUT_BROWSER_VAULT_PASSWORD")
.unwrap_or_else(|_| "donutbrowser-api-vault-password".to_string());
let out_dir = std::env::var("OUT_DIR").expect("cargo sets OUT_DIR for build scripts");
std::fs::write(
std::path::Path::new(&out_dir).join("legacy_vault_password.txt"),
legacy_vault_password,
)
.expect("write the legacy vault password for include_str!");
println!("cargo:rerun-if-env-changed=DONUT_BROWSER_VAULT_PASSWORD");
// Tell Cargo to rebuild if the proxy binary source changes
println!("cargo:rerun-if-changed=src/bin/proxy_server.rs");
+9 -4
View File
@@ -1,6 +1,7 @@
use crate::browser::ProxySettings;
use crate::events;
use crate::group_manager::GROUP_MANAGER;
use crate::log_redaction::ShortId;
use crate::profile::manager::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
@@ -2542,7 +2543,8 @@ fn resolve_extension_source(
Ok(Some(ExtensionSource::Upload { file_name, data }))
}
(None, Some(path)) => Ok(Some(ExtensionSource::LocalPath {
path: std::path::PathBuf::from(path),
path: crate::extension_manager::client_named_path(&path)
.map_err(|_| extension_request_error("EXTENSION_PATH_INVALID"))?,
link,
})),
(None, None) => Ok(None),
@@ -3472,7 +3474,10 @@ async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_ta
() = to_relay => {}
() = to_client => {}
}
log::info!("CDP proxy for remote session {session_id} closed");
log::info!(
"CDP proxy for remote session {} closed",
ShortId(&session_id)
);
}
// API Handler - Every remote session this account currently owns
@@ -4159,7 +4164,7 @@ async fn batch_run_profiles(
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut results = Vec::with_capacity(request.profile_ids.len());
let mut results = Vec::new();
for profile_id in &request.profile_ids {
let fail = |error: &str| BatchRunResult {
profile_id: profile_id.clone(),
@@ -4285,7 +4290,7 @@ async fn batch_stop_profiles(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let browser_runner = crate::browser_runner::BrowserRunner::instance();
let mut results = Vec::with_capacity(request.profile_ids.len());
let mut results = Vec::new();
for profile_id in &request.profile_ids {
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
results.push(BatchStopResult {
+7 -2
View File
@@ -2,6 +2,7 @@ use crate::browser::ProxySettings;
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::log_redaction::ShortId;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
@@ -1076,7 +1077,8 @@ impl BrowserRunner {
};
log::info!(
"Stopping remote session {session_id} for profile {} ({profile_id})",
"Stopping remote session {} for profile {} ({profile_id})",
ShortId(&session_id),
profile.name
);
crate::remote_session::end_remote_session(&session_id)
@@ -1085,7 +1087,10 @@ impl BrowserRunner {
// Surfaced rather than swallowed. A failure here means the browser is
// STILL RUNNING; reporting success would tell the user their profile is
// free when a remote host is still writing to it.
log::warn!("Failed to stop remote session {session_id}: {e}");
log::warn!(
"Failed to stop remote session {}: {e}",
ShortId(&session_id)
);
e.to_error_json().into()
})?;
+7 -3
View File
@@ -16,6 +16,7 @@
//! never holds any credential or hostname belonging to the machine the browser
//! runs on. That boundary is why this is a relay and not a direct connection.
use crate::log_redaction::ShortId;
use crate::profile::types::BrowserProfile;
use serde_json::Value;
use std::time::Duration;
@@ -88,7 +89,7 @@ impl CdpTarget {
pub fn describe(&self) -> String {
match self {
Self::Local { .. } => "local browser".to_string(),
Self::Remote { session_id, .. } => format!("remote session {session_id}"),
Self::Remote { session_id, .. } => format!("remote session {}", ShortId(session_id)),
}
}
}
@@ -212,7 +213,7 @@ pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError
log::info!(
"Driving profile '{}' through remote session {}",
profile.name,
session.session_id
ShortId(&session.session_id)
);
return Ok(CdpTarget::Remote {
ws_url: endpoint.ws_url,
@@ -720,7 +721,10 @@ impl CdpTarget {
} => {
let mut connection = dial_relay(ws_url, bearer).await?;
if let Err(e) = connection.attach_to_page().await {
log::warn!("Could not attach to a page in remote session {session_id}: {e}");
log::warn!(
"Could not attach to a page in remote session {}: {e}",
ShortId(session_id)
);
return Err(e);
}
Ok(connection)
+12 -110
View File
@@ -1,15 +1,10 @@
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use chrono::Utc;
use lazy_static::lazy_static;
use rand::RngExt;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tokio::sync::Mutex;
use crate::browser::ProxySettings;
@@ -378,114 +373,21 @@ impl CloudAuthManager {
SettingsManager::instance().get_settings_dir()
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
// --- Encrypted file storage (shared with settings_manager.rs via crate::vault) ---
fn magic(header: &[u8; 5]) -> [u8; 6] {
let mut magic = [0u8; 6];
magic[..5].copy_from_slice(header);
magic[5] = 2;
magic
}
// --- Encrypted file storage (same pattern as settings_manager.rs) ---
fn encrypt_and_store(file_path: &PathBuf, header: &[u8; 5], data: &str) -> Result<(), String> {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
let vault_password = Self::get_vault_password();
let salt_bytes: [u8; 16] = rand::rng().random();
let salt = crate::sync::encryption::encode_salt(&salt_bytes);
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, data.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(header);
file_data.push(2u8);
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
file_data.extend_from_slice(&nonce);
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
fs::write(file_path, file_data).map_err(|e| format!("Failed to write file: {e}"))?;
crate::app_dirs::restrict_to_owner(file_path);
Ok(())
fn encrypt_and_store(file_path: &Path, header: &[u8; 5], data: &str) -> Result<(), String> {
crate::vault::seal(file_path, &Self::magic(header), data)
}
fn decrypt_from_file(file_path: &PathBuf, header: &[u8; 5]) -> Result<Option<String>, String> {
if !file_path.exists() {
return Ok(None);
}
let file_data = fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?;
if file_data.len() < 6 || &file_data[0..5] != header {
return Ok(None);
}
let version = file_data[5];
if version != 2 {
return Ok(None);
}
let mut offset = 6;
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
offset += salt_len;
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce length".to_string())?;
let nonce = Nonce::from(nonce_bytes);
offset += 12;
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len = u32::from_le_bytes([
file_data[offset],
file_data[offset + 1],
file_data[offset + 2],
file_data[offset + 3],
]) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = Self::get_vault_password();
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed".to_string())?;
match String::from_utf8(plaintext) {
Ok(token) => Ok(Some(token)),
Err(_) => Ok(None),
}
fn decrypt_from_file(file_path: &Path, header: &[u8; 5]) -> Result<Option<String>, String> {
crate::vault::open(file_path, &Self::magic(header))
}
// --- Token storage methods ---
+21 -4
View File
@@ -9,6 +9,7 @@ use crate::api_client::ApiClient;
use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
use crate::log_redaction::Plain;
// Maximum time to wait for the next chunk of a streaming download before treating
// the connection as stalled. Converts an indefinite hang into a terminal error so
@@ -705,7 +706,11 @@ impl Downloader {
return Ok(version);
} else {
// Registry says it's downloaded but files don't exist - clean up registry
log::info!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry.");
log::info!(
"Registry indicates {} {} is downloaded, but files are missing. Cleaning up registry entry.",
Plain(&browser_str),
Plain(&version)
);
self.registry.remove_browser(&browser_str, &version);
self
.registry
@@ -811,7 +816,11 @@ impl Downloader {
// Do not remove the archive here. We keep it until verification succeeds.
}
Err(e) => {
log::error!("Extraction failed for {browser_str} {version}: {e}");
log::error!(
"Extraction failed for {} {}: {e}",
Plain(&browser_str),
Plain(&version)
);
// Delete the corrupt/invalid archive so a fresh download happens next time
if download_path.exists() {
@@ -857,7 +866,11 @@ impl Downloader {
let _ = events::emit("download-progress", &progress);
// Verify the browser was downloaded correctly
log::info!("Verifying download for browser: {browser_str}, version: {version}");
log::info!(
"Verifying download for browser: {}, version: {}",
Plain(&browser_str),
Plain(&version)
);
// Use the browser's own verification method
if !browser.is_version_downloaded(&version, &binaries_dir) {
@@ -912,7 +925,11 @@ impl Downloader {
.registry
.mark_download_completed(&browser_str, &version, browser_dir.clone())
{
log::warn!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
log::warn!(
"Warning: Could not mark {} {} as completed in registry: {e}",
Plain(&browser_str),
Plain(&version)
);
}
self
.registry
+13
View File
@@ -405,6 +405,19 @@ fn err_code(code: &str) -> Box<dyn std::error::Error> {
serde_json::json!({ "code": code }).to_string().into()
}
/// A filesystem path named by an automation client (REST or MCP).
///
/// Automation loads an extension from whatever folder or archive the caller
/// names, so the location is the caller's to choose. What a request may not
/// do is climb: a `..` component is refused before the path is touched, and
/// the path is then used exactly as given.
pub fn client_named_path(raw: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
if raw.contains("..") {
return Err(err_code("EXTENSION_PATH_INVALID"));
}
Ok(PathBuf::from(raw))
}
/// Validate that `dir` is a loadable unpacked extension and return its parsed
/// manifest.
fn validate_unpacked_dir(dir: &Path) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
+8 -2
View File
@@ -121,6 +121,7 @@ mod mcp_remote;
mod mcp_server;
mod tag_manager;
mod team_lock;
mod vault;
mod version_updater;
pub mod vpn;
mod vpn_extension_detect;
@@ -167,8 +168,9 @@ use downloader::{cancel_download, download_browser};
use settings_manager::{
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings,
get_tips_state, get_window_resize_warning_dismissed, mark_tip_seen, observe_cloud_plan,
open_log_directory, read_log_files, save_app_settings, save_sync_settings,
save_table_sorting_settings, set_tips_auto_show,
};
use sync::{
@@ -3480,6 +3482,10 @@ pub fn run_with_builder(
get_window_resize_warning_dismissed,
get_onboarding_completed,
complete_onboarding,
get_tips_state,
mark_tip_seen,
set_tips_auto_show,
observe_cloud_plan,
data_root::get_data_root_info,
data_root::move_data_root,
data_root::clear_data_root_choice,
+53
View File
@@ -39,6 +39,43 @@ static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
.expect("valid UUID regex")
});
/// A caller-supplied string as it may appear in a log line: control
/// characters, a newline above all, are shown escaped, so no request can
/// forge a second log entry or hide the end of the real one.
pub struct Plain<'a>(pub &'a str);
impl std::fmt::Display for Plain<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::fmt::Write;
for c in self.0.chars() {
if c.is_control() {
for escaped in c.escape_default() {
f.write_char(escaped)?;
}
} else {
f.write_char(c)?;
}
}
Ok(())
}
}
/// The first characters of an identifier: enough to match log lines up by
/// eye, and not the whole value, which for a session is a bearer of sorts.
pub struct ShortId<'a>(pub &'a str);
impl std::fmt::Display for ShortId<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
const SHOWN: usize = 8;
let shown: String = Plain(self.0).to_string().chars().take(SHOWN).collect();
f.write_str(&shown)?;
if self.0.chars().count() > SHOWN {
f.write_str("\u{2026}")?;
}
Ok(())
}
}
pub fn url_label(value: &str) -> String {
url::Url::parse(value)
.map(|parsed| format!("{}://<redacted>", parsed.scheme()))
@@ -66,6 +103,22 @@ pub fn text(value: &str) -> String {
mod tests {
use super::*;
#[test]
fn plain_escapes_every_control_character() {
assert_eq!(Plain("wayfern").to_string(), "wayfern");
assert_eq!(
Plain("1.0\nINFO forged line\r\t").to_string(),
"1.0\\nINFO forged line\\r\\t"
);
}
#[test]
fn short_id_keeps_a_prefix_and_marks_the_cut() {
assert_eq!(ShortId("abcdef").to_string(), "abcdef");
assert_eq!(ShortId("0123456789abcdef").to_string(), "01234567\u{2026}");
assert_eq!(ShortId("ab\ncd").to_string(), "ab\\ncd");
}
#[test]
fn redacts_sensitive_log_content() {
let input = format!(
+22 -8
View File
@@ -23,6 +23,7 @@ use crate::browser::ProxySettings;
use crate::cdp_target::{CdpError, CdpTarget};
use crate::cloud_auth::CLOUD_AUTH;
use crate::group_manager::GROUP_MANAGER;
use crate::log_redaction::ShortId;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::settings_manager::SettingsManager;
@@ -2479,7 +2480,7 @@ impl McpServer {
let mut inner = self.inner.lock().await;
match inner.sessions.remove(session_id) {
Some(session) => {
log::info!("[mcp] Session terminated: {session_id}");
log::info!("[mcp] Session terminated: {}", ShortId(session_id));
session.cached_pages
}
None => return,
@@ -2501,8 +2502,9 @@ impl McpServer {
.is_err()
{
log::debug!(
"[mcp] Session {session_id} ended before its element caches could be cleared; the \
page-side slot cap will reclaim them"
"[mcp] Session {} ended before its element caches could be cleared; the \
page-side slot cap will reclaim them",
ShortId(session_id)
);
}
}
@@ -4606,7 +4608,7 @@ impl McpServer {
"instructions": "Donut Browser MCP server. Use tools/list to discover available browser automation tools."
});
log::info!("[mcp] New session initialized: {}", session_id);
log::info!("[mcp] New session initialized: {}", ShortId(&session_id));
Ok((session_id, (id, result)))
}
@@ -7605,9 +7607,14 @@ impl McpServer {
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let path = crate::extension_manager::client_named_path(path).map_err(|e| McpError {
code: -32602,
message: format!("Invalid path: {e}"),
data: None,
})?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = mgr
.add_extension_from_path(name, std::path::Path::new(path), link)
.add_extension_from_path(name, &path, link)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension: {e}"),
@@ -7651,11 +7658,18 @@ impl McpServer {
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let path = path
.map(|path| {
crate::extension_manager::client_named_path(path).map_err(|e| McpError {
code: -32602,
message: format!("Invalid path: {e}"),
data: None,
})
})
.transpose()?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = match path {
Some(path) => {
mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link)
}
Some(path) => mgr.update_extension_from_path(extension_id, name, &path, link),
None => mgr.update_extension(extension_id, name, None, None),
}
.map_err(|e| McpError {
+2 -293
View File
@@ -407,296 +407,5 @@ pub fn fresh_salt() -> String {
}
#[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<String> = 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<String> = 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);
}
}
#[path = "encryption_tests.rs"]
mod tests;
+291
View File
@@ -0,0 +1,291 @@
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<String> = 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<String> = 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);
}
+1 -1
View File
@@ -1016,7 +1016,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
let mut seen = std::collections::HashSet::new();
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
let mut deduped: Vec<String> = Vec::new();
for t in tags.into_iter() {
if seen.insert(t.clone()) {
deduped.push(t);
+2 -626
View File
@@ -745,629 +745,5 @@ pub async fn complete_after_quit_and_wait(
}
#[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);
}
#[tokio::test]
async fn attempt_lock_serializes_one_profile_without_blocking_others() {
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
// One lock per profile is what turns check-lockout -> verify -> record
// into a critical section instead of a check-then-act race.
assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a)));
assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b)));
let held = attempt_lock(&a);
let guard = held.lock().await;
assert!(
attempt_lock(&a).try_lock().is_err(),
"a concurrent attempt on the same profile must wait for the window"
);
assert!(
attempt_lock(&b).try_lock().is_ok(),
"a different profile must not be serialized behind it"
);
drop(guard);
assert!(attempt_lock(&a).try_lock().is_ok());
}
#[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);
}
}
#[path = "password_tests.rs"]
mod tests;
+624
View File
@@ -0,0 +1,624 @@
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);
}
#[tokio::test]
async fn attempt_lock_serializes_one_profile_without_blocking_others() {
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
// One lock per profile is what turns check-lockout -> verify -> record
// into a critical section instead of a check-then-act race.
assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a)));
assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b)));
let held = attempt_lock(&a);
let guard = held.lock().await;
assert!(
attempt_lock(&a).try_lock().is_err(),
"a concurrent attempt on the same profile must wait for the window"
);
assert!(
attempt_lock(&b).try_lock().is_ok(),
"a different profile must not be serialized behind it"
);
drop(guard);
assert!(attempt_lock(&a).try_lock().is_ok());
}
#[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);
}
+2 -188
View File
@@ -377,191 +377,5 @@ impl SourceKeyring {
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn empty_password_key_matches_chromium_constant() {
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
}
#[test]
fn peanuts_key_matches_known_vector() {
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
// breaks every Linux `--password-store=basic` import.
assert_eq!(
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
[
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
0x78
]
);
}
#[test]
fn cbc_round_trip() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"session-token").expect("encrypt");
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
}
#[test]
fn cbc_round_trip_empty_plaintext() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"").expect("encrypt");
// PKCS7 always emits a full padding block, so this must not be empty.
assert_eq!(sealed.len(), 16);
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
}
#[test]
fn gcm_round_trip_with_fresh_nonce_each_time() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let a = key.encrypt(b"session-token").expect("encrypt");
let b = key.encrypt(b"session-token").expect("encrypt");
assert_ne!(a, b, "nonce must be random per call");
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
}
#[test]
fn gcm_rejects_tampered_ciphertext() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
let last = sealed.len() - 1;
sealed[last] ^= 0xff;
assert!(key.decrypt(&sealed).is_none());
}
#[test]
fn target_key_is_stable_across_calls() {
let dir = TempDir::new().unwrap();
let first = TargetKey::ensure(dir.path()).expect("mint");
let sealed = first.encrypt(b"value").expect("encrypt");
let second = TargetKey::ensure(dir.path()).expect("reuse");
// Re-running import over the same directory must not orphan what the
// previous run wrote.
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
assert_eq!(
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
second.encrypt(b"probe").map(|v| v[..3].to_vec())
);
let mut keyring = SourceKeyring::default();
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
install_host_key(&mut keyring, &contents);
match keyring.decrypt(&sealed) {
Decrypted::Value(v) => assert_eq!(v, b"value"),
_ => panic!("target key must round-trip through the source keyring"),
}
}
#[test]
fn minted_key_matches_wayfern_file_format() {
let dir = TempDir::new().unwrap();
TargetKey::ensure(dir.path()).expect("mint");
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
assert_eq!(
contents.len(),
32,
"DPAPIKeyProvider only adopts a 32-byte portable key"
);
#[cfg(not(target_os = "windows"))]
{
// The non-Windows key file is base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
base64::engine::general_purpose::STANDARD
.decode(&text)
.map(|b| b.len())
== Ok(16),
"expected base64 of 16 bytes, got {text}"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
let keyring = SourceKeyring::default();
assert!(matches!(
keyring.decrypt(b"plain cookie value"),
Decrypted::NotEncrypted
));
}
#[test]
fn app_bound_records_are_flagged_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v20".to_vec();
sealed.extend_from_slice(&[0u8; 40]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
assert!(
keyring.saw_app_bound.get(),
"v20 must be reported to the user, not silently dropped"
);
}
#[test]
fn missing_key_for_known_tag_is_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v10".to_vec();
sealed.extend_from_slice(&[0u8; 32]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
}
#[test]
fn empty_password_fallback_recovers_the_record() {
// A record sealed with the empty-password key must still open when the
// keyring holds a different primary key, mirroring Chromium.
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
.encrypt(b"legacy")
.unwrap();
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&sealed_body);
let keyring = SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
..Default::default()
};
match keyring.decrypt(&stored) {
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
_ => panic!("empty-password fallback must be attempted"),
}
}
/// Load the host-format key into a keyring under the host tag, for tests
/// that need to verify what we wrote is what Wayfern will read.
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().unwrap();
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
}
#[cfg(target_os = "macos")]
{
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
}
#[cfg(target_os = "linux")]
{
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
}
}
}
#[path = "os_crypt_tests.rs"]
mod tests;
@@ -0,0 +1,186 @@
use super::*;
use tempfile::TempDir;
#[test]
fn empty_password_key_matches_chromium_constant() {
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
}
#[test]
fn peanuts_key_matches_known_vector() {
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
// breaks every Linux `--password-store=basic` import.
assert_eq!(
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
[
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
0x78
]
);
}
#[test]
fn cbc_round_trip() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"session-token").expect("encrypt");
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
}
#[test]
fn cbc_round_trip_empty_plaintext() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"").expect("encrypt");
// PKCS7 always emits a full padding block, so this must not be empty.
assert_eq!(sealed.len(), 16);
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
}
#[test]
fn gcm_round_trip_with_fresh_nonce_each_time() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let a = key.encrypt(b"session-token").expect("encrypt");
let b = key.encrypt(b"session-token").expect("encrypt");
assert_ne!(a, b, "nonce must be random per call");
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
}
#[test]
fn gcm_rejects_tampered_ciphertext() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
let last = sealed.len() - 1;
sealed[last] ^= 0xff;
assert!(key.decrypt(&sealed).is_none());
}
#[test]
fn target_key_is_stable_across_calls() {
let dir = TempDir::new().unwrap();
let first = TargetKey::ensure(dir.path()).expect("mint");
let sealed = first.encrypt(b"value").expect("encrypt");
let second = TargetKey::ensure(dir.path()).expect("reuse");
// Re-running import over the same directory must not orphan what the
// previous run wrote.
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
assert_eq!(
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
second.encrypt(b"probe").map(|v| v[..3].to_vec())
);
let mut keyring = SourceKeyring::default();
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
install_host_key(&mut keyring, &contents);
match keyring.decrypt(&sealed) {
Decrypted::Value(v) => assert_eq!(v, b"value"),
_ => panic!("target key must round-trip through the source keyring"),
}
}
#[test]
fn minted_key_matches_wayfern_file_format() {
let dir = TempDir::new().unwrap();
TargetKey::ensure(dir.path()).expect("mint");
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
assert_eq!(
contents.len(),
32,
"DPAPIKeyProvider only adopts a 32-byte portable key"
);
#[cfg(not(target_os = "windows"))]
{
// The non-Windows key file is base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
base64::engine::general_purpose::STANDARD
.decode(&text)
.map(|b| b.len())
== Ok(16),
"expected base64 of 16 bytes, got {text}"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
let keyring = SourceKeyring::default();
assert!(matches!(
keyring.decrypt(b"plain cookie value"),
Decrypted::NotEncrypted
));
}
#[test]
fn app_bound_records_are_flagged_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v20".to_vec();
sealed.extend_from_slice(&[0u8; 40]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
assert!(
keyring.saw_app_bound.get(),
"v20 must be reported to the user, not silently dropped"
);
}
#[test]
fn missing_key_for_known_tag_is_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v10".to_vec();
sealed.extend_from_slice(&[0u8; 32]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
}
#[test]
fn empty_password_fallback_recovers_the_record() {
// A record sealed with the empty-password key must still open when the
// keyring holds a different primary key, mirroring Chromium.
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
.encrypt(b"legacy")
.unwrap();
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&sealed_body);
let keyring = SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
..Default::default()
};
match keyring.decrypt(&stored) {
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
_ => panic!("empty-password fallback must be attempted"),
}
}
/// Load the host-format key into a keyring under the host tag, for tests
/// that need to verify what we wrote is what Wayfern will read.
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().unwrap();
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
}
#[cfg(target_os = "macos")]
{
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
}
#[cfg(target_os = "linux")]
{
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
}
}
+7 -3
View File
@@ -40,8 +40,12 @@ pub struct ProfileImportReport {
pub cookies_migrated: usize,
/// Cookies carried over as rows but whose value could not be recovered.
pub cookies_unrecoverable: usize,
pub passwords_migrated: usize,
pub passwords_unrecoverable: usize,
/// Saved logins whose secret is readable in the new profile. `passwords_migrated` on the wire.
#[serde(rename = "passwords_migrated")]
pub logins_migrated: usize,
/// Saved logins carried as rows whose secret could not be recovered. `passwords_unrecoverable` on the wire.
#[serde(rename = "passwords_unrecoverable")]
pub logins_unrecoverable: usize,
/// Saved cards / IBANs / autofill secrets re-encrypted.
pub payment_methods_migrated: usize,
pub payment_methods_unrecoverable: usize,
@@ -66,7 +70,7 @@ impl ProfileImportReport {
/// should present the import as a success or as a warning.
pub fn is_empty_import(&self) -> bool {
self.cookies_migrated == 0
&& self.passwords_migrated == 0
&& self.logins_migrated == 0
&& self.history_entries == 0
&& self.bookmarks == 0
&& self.local_storage_origins == 0
+4 -585
View File
@@ -306,8 +306,8 @@ pub fn reencrypt_profile(
if let Some(conn) = open_rw(&default_dir.join("Login Data")) {
for (table, column) in LOGIN_COLUMNS {
let counts = reencrypt_column(&conn, table, column, source, target);
report.passwords_migrated += counts.migrated;
report.passwords_unrecoverable += counts.unrecoverable;
report.logins_migrated += counts.migrated;
report.logins_unrecoverable += counts.unrecoverable;
}
}
@@ -515,586 +515,5 @@ pub fn finalize_profile(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile_import::os_crypt::{derive_key, CryptoKey};
use tempfile::TempDir;
fn source_keyring_with(password: &[u8]) -> SourceKeyring {
// Match the host's CBC iteration count so tests exercise the real path.
#[cfg(target_os = "linux")]
let key = CryptoKey::Aes128Cbc(derive_key(
password,
super::super::os_crypt::POSIX_ITERATIONS,
));
#[cfg(not(target_os = "linux"))]
let key = CryptoKey::Aes128Cbc(derive_key(password, super::super::os_crypt::MAC_ITERATIONS));
#[cfg(target_os = "linux")]
return SourceKeyring {
v11: Some(key),
..Default::default()
};
#[cfg(not(target_os = "linux"))]
SourceKeyring {
v10: Some(key),
..Default::default()
}
}
fn seal_as_source(keyring: &SourceKeyring, plaintext: &[u8]) -> Vec<u8> {
let (tag, key) = if let Some(k) = keyring.v10.as_ref() {
(b"v10", k)
} else {
(b"v11", keyring.v11.as_ref().unwrap())
};
let mut out = tag.to_vec();
out.extend_from_slice(&key.encrypt(plaintext).unwrap());
out
}
fn make_cookie_db(path: &Path, version: i64) -> Connection {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let conn = Connection::open(path).unwrap();
conn
.execute_batch(
"CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
encrypted_value BLOB NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '/'
);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);",
)
.unwrap();
conn
.execute(
"INSERT INTO meta VALUES('version', ?1)",
[version.to_string()],
)
.unwrap();
conn
.execute(
"INSERT INTO meta VALUES('last_compatible_version', ?1)",
[version.to_string()],
)
.unwrap();
conn
}
#[test]
fn v24_cookie_is_reframed_for_the_target_key() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let source = source_keyring_with(b"source-password");
let mut framed = Sha256::digest(b"example.com").to_vec();
framed.extend_from_slice(b"tasty");
let sealed = seal_as_source(&source, &framed);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
assert_eq!(report.cookies_unrecoverable, 0);
// Read it back exactly the way Wayfern will.
let conn = Connection::open(&cookie_path).unwrap();
let (value, encrypted): (String, Vec<u8>) = conn
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert!(
value.is_empty(),
"a row with both value and encrypted_value set is dropped at load"
);
let target_keyring = target_as_keyring(dir.path());
let Decrypted::Value(plain) = target_keyring.decrypt(&encrypted) else {
panic!("target must be able to open what it sealed");
};
assert_eq!(&plain[..32], &Sha256::digest(b"example.com")[..]);
assert_eq!(&plain[32..], b"tasty");
}
#[test]
fn cookie_sealed_as_sqlite_text_is_still_recovered() {
// Chromium's own v23->v24 migration binds `encrypted_value` with
// BindString, so an established profile's cookies carry storage class TEXT
// in a column declared BLOB. Reading them as a strict blob returns empty,
// which used to blank every cookie and report it as migrated.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let source = source_keyring_with(b"source-password");
let mut framed = Sha256::digest(b"example.com").to_vec();
framed.extend_from_slice(b"tasty");
let sealed = seal_as_source(&source, &framed);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', CAST(?1 AS TEXT), '/')",
rusqlite::params![sealed],
)
.unwrap();
let stored_type: String = conn
.query_row("SELECT typeof(encrypted_value) FROM cookies", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
stored_type, "text",
"fixture must reproduce Chromium's binding"
);
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
let conn = Connection::open(&cookie_path).unwrap();
let encrypted: Vec<u8> = conn
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
panic!("expected a readable cookie");
};
assert_eq!(&plain[32..], b"tasty", "the cookie value must survive");
}
#[test]
fn password_note_sealed_as_sqlite_text_is_still_recovered() {
// `password_notes.value` is written with BindString on every platform, so
// this is not an edge case — it is how the column always looks.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
let source = source_keyring_with(b"source-password");
let sealed = seal_as_source(&source, b"a private note");
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
conn
.execute_batch(
"CREATE TABLE logins(password_value BLOB);
CREATE TABLE password_notes(id INTEGER PRIMARY KEY, value BLOB);",
)
.unwrap();
conn
.execute(
"INSERT INTO password_notes(value) VALUES(CAST(?1 AS TEXT))",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_profile(&default_dir, &source, &target, &mut report);
assert_eq!(report.passwords_migrated, 1);
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
let stored: Vec<u8> = conn
.query_row("SELECT value FROM password_notes", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
panic!("note must be readable with the target key");
};
assert_eq!(plain, b"a private note");
}
#[test]
fn windows_extension_paths_are_recognised_as_absolute_on_every_host() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("Secure Preferences");
std::fs::write(
&path,
serde_json::json!({
"extensions": { "settings": {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "C:\\Program Files\\Google\\Chrome\\Application\\151.0.0\\resources\\pdf" },
"cccccccccccccccccccccccccccccccc": { "path": "//host/share/ext" }
}}
})
.to_string(),
)
.unwrap();
let mut report = ProfileImportReport::default();
sanitize_secure_preferences(&path, &mut report);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let settings = value["extensions"]["settings"].as_object().unwrap();
assert_eq!(
settings.len(),
1,
"a Windows-syntax path is still absolute when imported onto macOS"
);
assert!(settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assert_eq!(report.extensions_migrated, 1);
}
#[test]
fn plaintext_cookie_is_sealed_and_value_cleared() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
[],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"unused");
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
let conn = Connection::open(&cookie_path).unwrap();
let (value, encrypted): (String, Vec<u8>) = conn
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert!(value.is_empty());
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
panic!("expected a readable cookie");
};
assert_eq!(&plain[32..], b"plain");
}
#[test]
fn v23_cookie_has_no_prefix_to_strip_and_is_upgraded_to_v24() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let source = source_keyring_with(b"source-password");
// v23 stores the bare value, with no SHA256(host) prefix.
let sealed = seal_as_source(&source, b"tasty");
let conn = make_cookie_db(&cookie_path, 23);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
let conn = Connection::open(&cookie_path).unwrap();
let version: String = conn
.query_row("SELECT value FROM meta WHERE key='version'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
version, "24",
"we wrote v24 framing, so the store must declare v24 or Chromium re-prefixes it"
);
let encrypted: Vec<u8> = conn
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
panic!("expected a readable cookie");
};
assert_eq!(&plain[32..], b"tasty");
}
#[test]
fn unrecoverable_cookie_row_is_deleted_and_counted() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let conn = make_cookie_db(&cookie_path, 24);
let mut app_bound = b"v20".to_vec();
app_bound.extend_from_slice(&[0u8; 48]);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
rusqlite::params![app_bound],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"source-password");
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_unrecoverable, 1);
assert_eq!(report.cookies_migrated, 0);
let conn = Connection::open(&cookie_path).unwrap();
let remaining: i64 = conn
.query_row("SELECT count(*) FROM cookies", [], |r| r.get(0))
.unwrap();
assert_eq!(remaining, 0, "a row no key can open is dead weight");
}
#[test]
fn cookie_store_older_than_chromium_migrates_is_removed_with_a_warning() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
make_cookie_db(&cookie_path, 22);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"x");
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert!(report
.warnings
.contains(&warning::STORE_TOO_OLD.to_string()));
assert!(!cookie_path.exists());
}
#[test]
fn passwords_are_reencrypted() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
let source = source_keyring_with(b"source-password");
let sealed = seal_as_source(&source, b"hunter2");
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
conn
.execute_batch("CREATE TABLE logins(origin_url VARCHAR, password_value BLOB);")
.unwrap();
conn
.execute(
"INSERT INTO logins VALUES('https://example.com', ?1)",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_profile(&default_dir, &source, &target, &mut report);
assert_eq!(report.passwords_migrated, 1);
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
let stored: Vec<u8> = conn
.query_row("SELECT password_value FROM logins", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
panic!("password must be readable with the target key");
};
assert_eq!(plain, b"hunter2");
}
#[test]
fn missing_optional_tables_are_not_an_error() {
// `password_notes` and most payment tables only exist on some schemas.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
conn
.execute_batch("CREATE TABLE logins(password_value BLOB);")
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"x");
let mut report = ProfileImportReport::default();
reencrypt_profile(&default_dir, &source, &target, &mut report);
assert_eq!(report.passwords_migrated, 0);
}
#[test]
fn secure_preferences_keeps_extensions_and_drops_protection() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("Secure Preferences");
std::fs::write(
&path,
serde_json::json!({
"protection": { "macs": { "extensions": { "settings": "deadbeef" } }, "super_mac": "x" },
"extensions": { "settings": {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "/Applications/Chromium.app/Contents/Resources/x" }
}}
})
.to_string(),
)
.unwrap();
let mut report = ProfileImportReport::default();
sanitize_secure_preferences(&path, &mut report);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(value.get("protection").is_none());
let settings = value["extensions"]["settings"].as_object().unwrap();
assert!(
settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
"a relative path is the user's real extension and must survive"
);
assert!(
!settings.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
"an absolute path points into the source browser's bundle"
);
assert_eq!(report.extensions_migrated, 1);
assert!(report
.warnings
.contains(&warning::SECURE_PREFERENCES_RESET.to_string()));
}
#[test]
fn preferences_lose_machine_paths_and_crash_state() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("Preferences");
std::fs::write(
&path,
serde_json::json!({
"download": { "default_directory": "/Users/someone-else/Downloads" },
"profile": { "exit_type": "Crashed", "exited_cleanly": false, "name": "Person 1" },
"intl": { "accept_languages": "de,de-DE" }
})
.to_string(),
)
.unwrap();
let mut report = ProfileImportReport::default();
sanitize_preferences(&path, &mut report);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(value["download"].get("default_directory").is_none());
assert_eq!(value["profile"]["exit_type"], "Normal");
assert_eq!(value["profile"]["exited_cleanly"], true);
assert!(value["intl"].get("accept_languages").is_none());
assert_eq!(
value["profile"]["name"], "Person 1",
"unrelated preferences must be preserved"
);
}
#[test]
fn plaintext_cookies_still_migrate_when_no_source_key_is_available() {
// A declined Keychain prompt loses the encrypted rows, but a profile whose
// cookies were stored in plaintext has nothing to lose. Reporting zero for
// it would be the same silent-empty-import failure this work exists to fix.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
[],
)
.unwrap();
let mut sealed_elsewhere = b"v10".to_vec();
sealed_elsewhere.extend_from_slice(&[9u8; 32]);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(1, 'other.example', '', 'sid', '', ?1, '/')",
rusqlite::params![sealed_elsewhere],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let empty = SourceKeyring::default();
let mut report = ProfileImportReport::default();
finalize_profile(&default_dir, &empty, &target, &mut report);
assert_eq!(
report.cookies_migrated, 1,
"the plaintext row is recoverable"
);
assert_eq!(report.cookies_unrecoverable, 1, "the sealed row is not");
assert!(report
.warnings
.contains(&warning::SECRETS_NOT_MIGRATED.to_string()));
}
#[test]
fn bookmarks_are_counted_recursively() {
let roots = serde_json::json!({
"bookmark_bar": { "type": "folder", "children": [
{ "type": "url", "url": "https://a.example" },
{ "type": "folder", "children": [{ "type": "url", "url": "https://b.example" }] }
]},
"other": { "type": "folder", "children": [] }
});
assert_eq!(count_bookmarks(Some(&roots)), 2);
}
/// Load the freshly minted `os_crypt_key` back as a keyring, so tests assert
/// against what Wayfern will actually do rather than against our own writer.
fn target_as_keyring(user_data_dir: &Path) -> SourceKeyring {
let contents =
std::fs::read(user_data_dir.join(crate::profile_import::os_crypt::KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.as_slice().try_into().unwrap();
SourceKeyring {
v10: Some(CryptoKey::Aes256Gcm(bytes)),
..Default::default()
}
}
#[cfg(target_os = "macos")]
{
SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(
&contents,
super::super::os_crypt::MAC_ITERATIONS,
))),
..Default::default()
}
}
#[cfg(target_os = "linux")]
{
SourceKeyring {
v11: Some(CryptoKey::Aes128Cbc(derive_key(
&contents,
super::super::os_crypt::POSIX_ITERATIONS,
))),
..Default::default()
}
}
}
}
#[path = "rewrite_tests.rs"]
mod tests;
@@ -0,0 +1,581 @@
use super::*;
use crate::profile_import::os_crypt::{derive_key, CryptoKey};
use tempfile::TempDir;
fn source_keyring_with(password: &[u8]) -> SourceKeyring {
// Match the host's CBC iteration count so tests exercise the real path.
#[cfg(target_os = "linux")]
let key = CryptoKey::Aes128Cbc(derive_key(
password,
super::super::os_crypt::POSIX_ITERATIONS,
));
#[cfg(not(target_os = "linux"))]
let key = CryptoKey::Aes128Cbc(derive_key(password, super::super::os_crypt::MAC_ITERATIONS));
#[cfg(target_os = "linux")]
return SourceKeyring {
v11: Some(key),
..Default::default()
};
#[cfg(not(target_os = "linux"))]
SourceKeyring {
v10: Some(key),
..Default::default()
}
}
fn seal_as_source(keyring: &SourceKeyring, plaintext: &[u8]) -> Vec<u8> {
let (tag, key) = if let Some(k) = keyring.v10.as_ref() {
(b"v10", k)
} else {
(b"v11", keyring.v11.as_ref().unwrap())
};
let mut out = tag.to_vec();
out.extend_from_slice(&key.encrypt(plaintext).unwrap());
out
}
fn make_cookie_db(path: &Path, version: i64) -> Connection {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let conn = Connection::open(path).unwrap();
conn
.execute_batch(
"CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
encrypted_value BLOB NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '/'
);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);",
)
.unwrap();
conn
.execute(
"INSERT INTO meta VALUES('version', ?1)",
[version.to_string()],
)
.unwrap();
conn
.execute(
"INSERT INTO meta VALUES('last_compatible_version', ?1)",
[version.to_string()],
)
.unwrap();
conn
}
#[test]
fn v24_cookie_is_reframed_for_the_target_key() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let source = source_keyring_with(b"source-password");
let mut framed = Sha256::digest(b"example.com").to_vec();
framed.extend_from_slice(b"tasty");
let sealed = seal_as_source(&source, &framed);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
assert_eq!(report.cookies_unrecoverable, 0);
// Read it back exactly the way Wayfern will.
let conn = Connection::open(&cookie_path).unwrap();
let (value, encrypted): (String, Vec<u8>) = conn
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert!(
value.is_empty(),
"a row with both value and encrypted_value set is dropped at load"
);
let target_keyring = target_as_keyring(dir.path());
let Decrypted::Value(plain) = target_keyring.decrypt(&encrypted) else {
panic!("target must be able to open what it sealed");
};
assert_eq!(&plain[..32], &Sha256::digest(b"example.com")[..]);
assert_eq!(&plain[32..], b"tasty");
}
#[test]
fn cookie_sealed_as_sqlite_text_is_still_recovered() {
// Chromium's own v23->v24 migration binds `encrypted_value` with
// BindString, so an established profile's cookies carry storage class TEXT
// in a column declared BLOB. Reading them as a strict blob returns empty,
// which used to blank every cookie and report it as migrated.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let source = source_keyring_with(b"source-password");
let mut framed = Sha256::digest(b"example.com").to_vec();
framed.extend_from_slice(b"tasty");
let sealed = seal_as_source(&source, &framed);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', CAST(?1 AS TEXT), '/')",
rusqlite::params![sealed],
)
.unwrap();
let stored_type: String = conn
.query_row("SELECT typeof(encrypted_value) FROM cookies", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
stored_type, "text",
"fixture must reproduce Chromium's binding"
);
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
let conn = Connection::open(&cookie_path).unwrap();
let encrypted: Vec<u8> = conn
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
panic!("expected a readable cookie");
};
assert_eq!(&plain[32..], b"tasty", "the cookie value must survive");
}
#[test]
fn password_note_sealed_as_sqlite_text_is_still_recovered() {
// `password_notes.value` is written with BindString on every platform, so
// this is not an edge case — it is how the column always looks.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
let source = source_keyring_with(b"source-password");
let sealed = seal_as_source(&source, b"a private note");
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
conn
.execute_batch(
"CREATE TABLE logins(password_value BLOB);
CREATE TABLE password_notes(id INTEGER PRIMARY KEY, value BLOB);",
)
.unwrap();
conn
.execute(
"INSERT INTO password_notes(value) VALUES(CAST(?1 AS TEXT))",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_profile(&default_dir, &source, &target, &mut report);
assert_eq!(report.logins_migrated, 1);
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
let stored: Vec<u8> = conn
.query_row("SELECT value FROM password_notes", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
panic!("note must be readable with the target key");
};
assert_eq!(plain, b"a private note");
}
#[test]
fn windows_extension_paths_are_recognised_as_absolute_on_every_host() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("Secure Preferences");
std::fs::write(
&path,
serde_json::json!({
"extensions": { "settings": {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "C:\\Program Files\\Google\\Chrome\\Application\\151.0.0\\resources\\pdf" },
"cccccccccccccccccccccccccccccccc": { "path": "//host/share/ext" }
}}
})
.to_string(),
)
.unwrap();
let mut report = ProfileImportReport::default();
sanitize_secure_preferences(&path, &mut report);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let settings = value["extensions"]["settings"].as_object().unwrap();
assert_eq!(
settings.len(),
1,
"a Windows-syntax path is still absolute when imported onto macOS"
);
assert!(settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assert_eq!(report.extensions_migrated, 1);
}
#[test]
fn plaintext_cookie_is_sealed_and_value_cleared() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
[],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"unused");
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
let conn = Connection::open(&cookie_path).unwrap();
let (value, encrypted): (String, Vec<u8>) = conn
.query_row("SELECT value, encrypted_value FROM cookies", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert!(value.is_empty());
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
panic!("expected a readable cookie");
};
assert_eq!(&plain[32..], b"plain");
}
#[test]
fn v23_cookie_has_no_prefix_to_strip_and_is_upgraded_to_v24() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let source = source_keyring_with(b"source-password");
// v23 stores the bare value, with no SHA256(host) prefix.
let sealed = seal_as_source(&source, b"tasty");
let conn = make_cookie_db(&cookie_path, 23);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_migrated, 1);
let conn = Connection::open(&cookie_path).unwrap();
let version: String = conn
.query_row("SELECT value FROM meta WHERE key='version'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
version, "24",
"we wrote v24 framing, so the store must declare v24 or Chromium re-prefixes it"
);
let encrypted: Vec<u8> = conn
.query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else {
panic!("expected a readable cookie");
};
assert_eq!(&plain[32..], b"tasty");
}
#[test]
fn unrecoverable_cookie_row_is_deleted_and_counted() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let conn = make_cookie_db(&cookie_path, 24);
let mut app_bound = b"v20".to_vec();
app_bound.extend_from_slice(&[0u8; 48]);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', '', ?1, '/')",
rusqlite::params![app_bound],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"source-password");
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert_eq!(report.cookies_unrecoverable, 1);
assert_eq!(report.cookies_migrated, 0);
let conn = Connection::open(&cookie_path).unwrap();
let remaining: i64 = conn
.query_row("SELECT count(*) FROM cookies", [], |r| r.get(0))
.unwrap();
assert_eq!(remaining, 0, "a row no key can open is dead weight");
}
#[test]
fn cookie_store_older_than_chromium_migrates_is_removed_with_a_warning() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
make_cookie_db(&cookie_path, 22);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"x");
let mut report = ProfileImportReport::default();
reencrypt_cookies(&default_dir, &source, &target, &mut report);
assert!(report
.warnings
.contains(&warning::STORE_TOO_OLD.to_string()));
assert!(!cookie_path.exists());
}
#[test]
fn passwords_are_reencrypted() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
let source = source_keyring_with(b"source-password");
let sealed = seal_as_source(&source, b"hunter2");
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
conn
.execute_batch("CREATE TABLE logins(origin_url VARCHAR, password_value BLOB);")
.unwrap();
conn
.execute(
"INSERT INTO logins VALUES('https://example.com', ?1)",
rusqlite::params![sealed],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let mut report = ProfileImportReport::default();
reencrypt_profile(&default_dir, &source, &target, &mut report);
assert_eq!(report.logins_migrated, 1);
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
let stored: Vec<u8> = conn
.query_row("SELECT password_value FROM logins", [], |r| r.get(0))
.unwrap();
let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else {
panic!("password must be readable with the target key");
};
assert_eq!(plain, b"hunter2");
}
#[test]
fn missing_optional_tables_are_not_an_error() {
// `password_notes` and most payment tables only exist on some schemas.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
let conn = Connection::open(default_dir.join("Login Data")).unwrap();
conn
.execute_batch("CREATE TABLE logins(password_value BLOB);")
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let source = source_keyring_with(b"x");
let mut report = ProfileImportReport::default();
reencrypt_profile(&default_dir, &source, &target, &mut report);
assert_eq!(report.logins_migrated, 0);
}
#[test]
fn secure_preferences_keeps_extensions_and_drops_protection() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("Secure Preferences");
std::fs::write(
&path,
serde_json::json!({
"protection": { "macs": { "extensions": { "settings": "deadbeef" } }, "super_mac": "x" },
"extensions": { "settings": {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" },
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "/Applications/Chromium.app/Contents/Resources/x" }
}}
})
.to_string(),
)
.unwrap();
let mut report = ProfileImportReport::default();
sanitize_secure_preferences(&path, &mut report);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(value.get("protection").is_none());
let settings = value["extensions"]["settings"].as_object().unwrap();
assert!(
settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
"a relative path is the user's real extension and must survive"
);
assert!(
!settings.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
"an absolute path points into the source browser's bundle"
);
assert_eq!(report.extensions_migrated, 1);
assert!(report
.warnings
.contains(&warning::SECURE_PREFERENCES_RESET.to_string()));
}
#[test]
fn preferences_lose_machine_paths_and_crash_state() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("Preferences");
std::fs::write(
&path,
serde_json::json!({
"download": { "default_directory": "/Users/someone-else/Downloads" },
"profile": { "exit_type": "Crashed", "exited_cleanly": false, "name": "Person 1" },
"intl": { "accept_languages": "de,de-DE" }
})
.to_string(),
)
.unwrap();
let mut report = ProfileImportReport::default();
sanitize_preferences(&path, &mut report);
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(value["download"].get("default_directory").is_none());
assert_eq!(value["profile"]["exit_type"], "Normal");
assert_eq!(value["profile"]["exited_cleanly"], true);
assert!(value["intl"].get("accept_languages").is_none());
assert_eq!(
value["profile"]["name"], "Person 1",
"unrelated preferences must be preserved"
);
}
#[test]
fn plaintext_cookies_still_migrate_when_no_source_key_is_available() {
// A declined Keychain prompt loses the encrypted rows, but a profile whose
// cookies were stored in plaintext has nothing to lose. Reporting zero for
// it would be the same silent-empty-import failure this work exists to fix.
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
let cookie_path = layout::host_cookie_path(&default_dir);
let conn = make_cookie_db(&cookie_path, 24);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')",
[],
)
.unwrap();
let mut sealed_elsewhere = b"v10".to_vec();
sealed_elsewhere.extend_from_slice(&[9u8; 32]);
conn
.execute(
"INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path)
VALUES(1, 'other.example', '', 'sid', '', ?1, '/')",
rusqlite::params![sealed_elsewhere],
)
.unwrap();
drop(conn);
let target = TargetKey::ensure(dir.path()).unwrap();
let empty = SourceKeyring::default();
let mut report = ProfileImportReport::default();
finalize_profile(&default_dir, &empty, &target, &mut report);
assert_eq!(
report.cookies_migrated, 1,
"the plaintext row is recoverable"
);
assert_eq!(report.cookies_unrecoverable, 1, "the sealed row is not");
assert!(report
.warnings
.contains(&warning::SECRETS_NOT_MIGRATED.to_string()));
}
#[test]
fn bookmarks_are_counted_recursively() {
let roots = serde_json::json!({
"bookmark_bar": { "type": "folder", "children": [
{ "type": "url", "url": "https://a.example" },
{ "type": "folder", "children": [{ "type": "url", "url": "https://b.example" }] }
]},
"other": { "type": "folder", "children": [] }
});
assert_eq!(count_bookmarks(Some(&roots)), 2);
}
/// Load the freshly minted `os_crypt_key` back as a keyring, so tests assert
/// against what Wayfern will actually do rather than against our own writer.
fn target_as_keyring(user_data_dir: &Path) -> SourceKeyring {
let contents =
std::fs::read(user_data_dir.join(crate::profile_import::os_crypt::KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.as_slice().try_into().unwrap();
SourceKeyring {
v10: Some(CryptoKey::Aes256Gcm(bytes)),
..Default::default()
}
}
#[cfg(target_os = "macos")]
{
SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(
&contents,
super::super::os_crypt::MAC_ITERATIONS,
))),
..Default::default()
}
}
#[cfg(target_os = "linux")]
{
SourceKeyring {
v11: Some(CryptoKey::Aes128Cbc(derive_key(
&contents,
super::super::os_crypt::POSIX_ITERATIONS,
))),
..Default::default()
}
}
}
+10 -10
View File
@@ -711,7 +711,7 @@ impl ProfileImporter {
.collect();
let total = items.len();
let mut results = Vec::with_capacity(total);
let mut results = Vec::new();
let mut imported_count = 0usize;
let mut skipped_count = 0usize;
let mut failed_count = 0usize;
@@ -857,10 +857,10 @@ impl ProfileImporter {
let profile_id = uuid::Uuid::new_v4();
let profiles_dir = self.profile_manager.get_profiles_dir();
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
let new_profile_data_dir = new_profile_uuid_dir.join("profile");
let new_profile_dir = profiles_dir.join(profile_id.to_string());
let new_profile_data_dir = new_profile_dir.join("profile");
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Profile dirs can be multiple GB and the migration hits SQLite and the
@@ -884,7 +884,7 @@ impl ProfileImporter {
// every other error path here, or the half-copied — possibly multi-GB
// — directory is orphaned with no metadata pointing at it, so nothing
// ever reclaims it.
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
let _ = fs::remove_dir_all(&new_profile_dir);
return Err(
serde_json::json!({
"code": "INTERNAL_ERROR",
@@ -898,7 +898,7 @@ impl ProfileImporter {
let report = match migrate_result {
Ok(report) => report,
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
let _ = fs::remove_dir_all(&new_profile_dir);
// Structured codes (an unimportable source, a running browser) pass
// through so the frontend can translate them; anything else is
// internal.
@@ -915,7 +915,7 @@ impl ProfileImporter {
let version = match self.get_default_version_for_browser(mapped) {
Ok(version) => version,
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
let _ = fs::remove_dir_all(&new_profile_dir);
return Err(e);
}
};
@@ -1013,7 +1013,7 @@ impl ProfileImporter {
};
}
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
let _ = fs::remove_dir_all(&new_profile_dir);
return Err(
serde_json::json!({
"code": "INTERNAL_ERROR",
@@ -1099,9 +1099,9 @@ impl ProfileImporter {
new_profile_name,
source_path.display(),
report.cookies_migrated,
report.passwords_migrated,
report.logins_migrated,
report.history_entries,
report.cookies_unrecoverable + report.passwords_unrecoverable,
report.cookies_unrecoverable + report.logins_unrecoverable,
report.warnings
);
}
+1 -1
View File
@@ -3120,7 +3120,7 @@ this line has no colon\r\n\
] {
assert!(
!as_text.contains(secret),
"{secret:?} reached the wire in the clear on an httpstls upstream"
"a CONNECT detail or credential reached the wire in the clear on an httpstls upstream"
);
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ async fn socks5_udp_associate(settings: &ProxySettings) -> std::io::Result<UdpSu
.username
.as_deref()
.filter(|user| !user.is_empty())
.map(|user| (user, settings.password.as_deref().unwrap_or("")));
.map(|user| (user, settings.password.as_deref().unwrap_or_default()));
let greeting: Vec<u8> = match credentials {
Some(_) => vec![SOCKS5, 2, AUTH_NONE, AUTH_USERPASS],
+4 -2
View File
@@ -28,6 +28,7 @@
//! and the work is sitting in cloud storage. This is the window that used to
//! be wide open.
use crate::log_redaction::ShortId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::RwLock;
@@ -287,8 +288,9 @@ pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<St
.collect();
for (profile_id, session_id) in stale {
log::info!(
"Remote session {session_id} for profile {profile_id} ended while this machine was not \
watching; its work is still in cloud storage"
"Remote session {} for profile {profile_id} ended while this machine was not \
watching; its work is still in cloud storage",
ShortId(&session_id)
);
store.insert(
profile_id.clone(),
+301 -113
View File
@@ -2,12 +2,6 @@ use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use rand::RngExt;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
@@ -98,6 +92,26 @@ pub struct AppSettings {
/// `profile::trash::configured_retention_days`.
#[serde(default = "default_trash_retention_days")]
pub trash_retention_days: u32,
/// Feature tips. Whether one tip the user has not seen yet may open by
/// itself shortly after launch. Off is the user's choice, made in the tips
/// dialog.
#[serde(default = "default_tips_auto_show")]
pub tips_auto_show: bool,
/// Ids of the tips that have been shown, in the automatic or the browse
/// flow, so the automatic flow never repeats one.
#[serde(default)]
pub tips_seen: Vec<String>,
/// Unix seconds of the last tip that opened by itself. Paces the automatic
/// flow to one tip a day at most.
#[serde(default)]
pub tips_last_auto_shown_at: Option<u64>,
/// Cloud user ids that have had the paid-plan welcome.
#[serde(default)]
pub paid_welcome_seen_for: Vec<String>,
/// The plan status last observed per cloud user id, `"free"` or `"paid"`.
/// A change from free to paid is what earns the paid-plan welcome.
#[serde(default)]
pub cloud_plan_memory: std::collections::HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -118,6 +132,18 @@ fn default_trash_retention_days() -> u32 {
crate::profile::trash::DEFAULT_RETENTION_DAYS
}
fn default_tips_auto_show() -> bool {
true
}
/// How long the automatic tip flow waits between two tips, so a busy day of
/// restarts does not turn into a tip on every launch.
pub const TIPS_AUTO_INTERVAL_SECS: u64 = 20 * 60 * 60;
/// The plan status remembered per cloud user.
const PLAN_STATUS_PAID: &str = "paid";
const PLAN_STATUS_FREE: &str = "free";
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -144,6 +170,11 @@ impl Default for AppSettings {
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS,
tips_auto_show: true,
tips_seen: Vec::new(),
tips_last_auto_shown_at: None,
paid_welcome_seen_for: Vec::new(),
cloud_plan_memory: std::collections::HashMap::new(),
}
}
}
@@ -159,6 +190,20 @@ pub struct StoredMcpRemoteKey {
pub struct SettingsManager;
/// Write `content` to `path` in one step: to a sibling first, then renamed
/// into place. A reader that opens the file mid-write, and there are several
/// at startup, sees the old settings or the new ones, never an empty file
/// that parses as the defaults.
fn write_whole(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
let staging = path.with_extension("json.tmp");
fs::write(&staging, content)?;
if let Err(e) = fs::rename(&staging, path) {
let _ = fs::remove_file(&staging);
return Err(e);
}
Ok(())
}
impl SettingsManager {
pub(crate) fn new() -> Self {
Self
@@ -213,7 +258,7 @@ impl SettingsManager {
let settings_file = self.get_settings_file();
let json = serde_json::to_string_pretty(&on_disk)?;
fs::write(settings_file, json)?;
write_whole(&settings_file, json.as_bytes())?;
Ok(())
}
@@ -240,55 +285,23 @@ impl SettingsManager {
let sorting_file = self.get_table_sorting_file();
let json = serde_json::to_string_pretty(sorting)?;
fs::write(sorting_file, json)?;
write_whole(&sorting_file, json.as_bytes())?;
Ok(())
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
/// Encrypt `secret` into `file` under the vault password.
/// Seal `secret` into `file`.
///
/// One implementation for every secret this manager keeps on disk. The API,
/// MCP and sync tokens each carried their own copy of this routine, and the
/// remote MCP credential would have been the fourth; the file layout is the
/// same for all of them and only the five-byte header tells them apart.
/// One implementation for every secret this manager keeps on disk, in
/// `crate::vault`: the API, MCP and sync tokens and the remote MCP
/// credential share the layout, and only the five-byte header tells them
/// apart.
fn encrypt_to_file(
file: &std::path::Path,
header: &[u8; 5],
secret: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
let salt_bytes: [u8; 16] = rand::rng().random();
let salt = crate::sync::encryption::encode_salt(&salt_bytes);
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, secret.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(header);
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
file_data.extend_from_slice(&nonce);
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(file, file_data)?;
crate::app_dirs::restrict_to_owner(file);
crate::vault::seal(file, &Self::magic(header), secret)?;
Ok(())
}
@@ -301,74 +314,15 @@ impl SettingsManager {
file: &std::path::Path,
header: &[u8; 5],
) -> Result<Option<String>, Box<dyn std::error::Error>> {
if !file.exists() {
return Ok(None);
}
Ok(crate::vault::open(file, &Self::magic(header))?)
}
let file_data = std::fs::read(file)?;
if file_data.len() < 6 || &file_data[0..5] != header {
return Ok(None);
}
let version = file_data[5];
if version != 2 {
return Ok(None);
}
let mut offset = 6;
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
offset += salt_len;
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce length")?;
let nonce = Nonce::from(nonce_bytes);
offset += 12;
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len = u32::from_le_bytes([
file_data[offset],
file_data[offset + 1],
file_data[offset + 2],
file_data[offset + 3],
]) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = Self::get_vault_password();
let key_bytes =
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
match String::from_utf8(plaintext) {
Ok(token) => Ok(Some(token)),
Err(_) => Ok(None),
}
/// The header plus the layout version every file of this manager carries.
fn magic(header: &[u8; 5]) -> [u8; 6] {
let mut magic = [0u8; 6];
magic[..5].copy_from_slice(header);
magic[5] = 2;
magic
}
fn remove_secret_file(file: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
@@ -959,6 +913,158 @@ pub async fn complete_onboarding() -> Result<(), String> {
.map_err(|e| format!("Failed to save settings: {e}"))
}
/// What the tips dialog needs to decide what to open and what to skip.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct TipsState {
pub auto_show: bool,
pub seen: Vec<String>,
pub last_auto_shown_at: Option<u64>,
/// Whether the automatic flow may open a tip right now: it is switched on
/// and the last automatic tip is old enough.
pub auto_due: bool,
}
impl TipsState {
fn of(settings: &AppSettings, now: u64) -> Self {
Self {
auto_show: settings.tips_auto_show,
seen: settings.tips_seen.clone(),
last_auto_shown_at: settings.tips_last_auto_shown_at,
auto_due: settings.tips_auto_show
&& settings
.tips_last_auto_shown_at
.is_none_or(|last| now.saturating_sub(last) >= TIPS_AUTO_INTERVAL_SECS),
}
}
}
/// Serialises every read-modify-write of the tips fields. Two tips shown in
/// quick succession are two concurrent commands, and without this the second
/// load could precede the first save and drop it.
static TIPS_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Remembers a tip as shown. `auto` marks it as the tip that opened by
/// itself, which restarts the daily pacing.
fn record_tip_seen(settings: &mut AppSettings, tip_id: &str, auto: bool, now: u64) {
if !settings.tips_seen.iter().any(|id| id == tip_id) {
settings.tips_seen.push(tip_id.to_string());
}
if auto {
settings.tips_last_auto_shown_at = Some(now);
}
}
/// Records the plan status seen for a cloud account and answers whether the
/// paid-plan welcome is due for it.
///
/// The welcome is for an account that just became paid: one this desktop last
/// saw as free, or one it sees for the first time right after the user signed
/// in (they bought a plan on the website and came back). An account that was
/// already paid the last time anybody looked, or that turns up paid in an old
/// session after an app update, is not new to its plan and is recorded as
/// greeted without a dialog.
fn paid_welcome_due(
settings: &mut AppSettings,
user_id: &str,
paid: bool,
fresh_login: bool,
) -> bool {
let status = if paid {
PLAN_STATUS_PAID
} else {
PLAN_STATUS_FREE
};
let previous = settings
.cloud_plan_memory
.insert(user_id.to_string(), status.to_string());
if !paid {
return false;
}
if settings
.paid_welcome_seen_for
.iter()
.any(|id| id == user_id)
{
return false;
}
let due = match previous.as_deref() {
Some(PLAN_STATUS_FREE) => true,
Some(_) => false,
None => fresh_login,
};
settings.paid_welcome_seen_for.push(user_id.to_string());
due
}
#[tauri::command]
pub async fn get_tips_state() -> Result<TipsState, String> {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
Ok(TipsState::of(&settings, unix_now()))
}
#[tauri::command]
pub async fn mark_tip_seen(tip_id: String, auto: bool) -> Result<TipsState, String> {
let _serial = TIPS_WRITE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
let now = unix_now();
record_tip_seen(&mut settings, &tip_id, auto, now);
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
Ok(TipsState::of(&settings, now))
}
#[tauri::command]
pub async fn set_tips_auto_show(enabled: bool) -> Result<TipsState, String> {
let _serial = TIPS_WRITE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
settings.tips_auto_show = enabled;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
Ok(TipsState::of(&settings, unix_now()))
}
#[tauri::command]
pub async fn observe_cloud_plan(
user_id: String,
paid: bool,
fresh_login: bool,
) -> Result<bool, String> {
let _serial = TIPS_WRITE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
let due = paid_welcome_due(&mut settings, &user_id, paid, fresh_login);
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
Ok(due)
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
@@ -1031,6 +1137,83 @@ mod tests {
let (_manager, _temp_dir, _guard) = create_test_settings_manager();
}
#[test]
fn tips_state_defaults_to_automatic_and_due() {
let settings = AppSettings::default();
let state = TipsState::of(&settings, 1_000_000);
assert!(state.auto_show);
assert!(state.seen.is_empty());
assert_eq!(state.last_auto_shown_at, None);
assert!(state.auto_due, "a fresh install owes its first tip");
}
#[test]
fn tips_seen_dedupes_and_paces_the_automatic_flow() {
let mut settings = AppSettings::default();
record_tip_seen(&mut settings, "dns", false, 100);
record_tip_seen(&mut settings, "dns", false, 200);
assert_eq!(settings.tips_seen, vec!["dns".to_string()]);
assert_eq!(
settings.tips_last_auto_shown_at, None,
"a browsed tip must not restart the daily pacing"
);
record_tip_seen(&mut settings, "proxy", true, 1_000);
assert_eq!(settings.tips_last_auto_shown_at, Some(1_000));
assert!(
!TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS - 1).auto_due,
"the next automatic tip waits a day"
);
assert!(TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS).auto_due);
settings.tips_auto_show = false;
assert!(
!TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS * 3).auto_due,
"switched off means never due"
);
}
#[test]
fn paid_welcome_is_due_once_when_an_account_turns_paid() {
let mut settings = AppSettings::default();
assert!(!paid_welcome_due(&mut settings, "u1", false, true));
assert!(
settings.paid_welcome_seen_for.is_empty(),
"a free account is not greeted, so nothing is recorded"
);
assert!(
paid_welcome_due(&mut settings, "u1", true, false),
"free to paid is the upgrade the welcome exists for"
);
assert!(!paid_welcome_due(&mut settings, "u1", true, true), "once");
assert_eq!(settings.paid_welcome_seen_for, vec!["u1".to_string()]);
}
#[test]
fn paid_welcome_greets_a_fresh_sign_in_but_not_an_old_paid_session() {
let mut settings = AppSettings::default();
assert!(
paid_welcome_due(&mut settings, "bought-on-web", true, true),
"first sight right after signing in: they came back from checkout"
);
assert!(
!paid_welcome_due(&mut settings, "long-paid", true, false),
"an app update on a machine that was already paid is not a new plan"
);
assert!(
!paid_welcome_due(&mut settings, "long-paid", true, true),
"and it is recorded as greeted, so it never fires later"
);
assert_eq!(
settings
.cloud_plan_memory
.get("long-paid")
.map(String::as_str),
Some(PLAN_STATUS_PAID)
);
}
#[test]
fn test_default_app_settings() {
let default_settings = AppSettings::default();
@@ -1105,6 +1288,11 @@ mod tests {
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: 14,
tips_auto_show: true,
tips_seen: Vec::new(),
tips_last_auto_shown_at: None,
paid_welcome_seen_for: Vec::new(),
cloud_plan_memory: std::collections::HashMap::new(),
};
let save_result = manager.save_settings(&test_settings);
+12 -251
View File
@@ -19,7 +19,9 @@ use base64::{
/// silently lock every user out of their encrypted data, so the defaults are
/// pinned by the test below rather than trusted.
pub fn derive_vault_key(password: &[u8], salt: &[u8]) -> Result<[u8; 32], String> {
let mut key = [0u8; 32];
// Filled by the KDF. It starts as noise rather than zeros so that no
// failure path can ever hand back an all-zero key.
let mut key: [u8; 32] = rand::rng().random();
Argon2::default()
.hash_password_into(password, salt, &mut key)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
@@ -42,9 +44,6 @@ use rand::RngExt;
use std::collections::HashMap;
use std::sync::Mutex;
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
const E2E_FILE_VERSION: u8 = 1;
/// Argon2id is intentionally expensive (~80150 ms per call). During an
/// encryption rollover, every synced entity (proxy, group, vpn, extension,
/// extension group, profile metadata) goes through `derive_profile_key`,
@@ -61,10 +60,7 @@ fn password_fingerprint(pwd: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(pwd.as_bytes());
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
hasher.finalize().into()
}
fn invalidate_key_cache() {
@@ -77,122 +73,16 @@ fn get_e2e_password_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("e2e_password.dat")
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
/// Header plus layout version of the sync password file.
const E2E_MAGIC: [u8; 6] = *b"DBE2E\x01";
pub fn store_e2e_password(password: &str) -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path();
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
let vault_password = get_vault_password();
let salt_bytes: [u8; 16] = rand::rng().random();
let salt = encode_salt(&salt_bytes);
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = aes_gcm::Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, password.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(E2E_FILE_HEADER);
file_data.push(E2E_FILE_VERSION);
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
file_data.extend_from_slice(&nonce);
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(&file_path, file_data)
.map_err(|e| format!("Failed to write e2e password file: {e}"))?;
crate::app_dirs::restrict_to_owner(std::path::Path::new(&file_path));
Ok(())
crate::vault::seal(&get_e2e_password_path(), &E2E_MAGIC, password)
}
pub fn load_e2e_password() -> Result<Option<String>, String> {
let file_path = get_e2e_password_path();
if !file_path.exists() {
return Ok(None);
}
let file_data =
std::fs::read(&file_path).map_err(|e| format!("Failed to read e2e password file: {e}"))?;
if file_data.len() < E2E_FILE_HEADER.len() + 1 {
return Ok(None);
}
if &file_data[..E2E_FILE_HEADER.len()] != E2E_FILE_HEADER {
return Ok(None);
}
let version = file_data[E2E_FILE_HEADER.len()];
if version != E2E_FILE_VERSION {
return Ok(None);
}
let mut offset = E2E_FILE_HEADER.len() + 1;
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_str = std::str::from_utf8(&file_data[offset..offset + salt_len])
.map_err(|_| "Invalid salt encoding")?;
offset += salt_len;
let salt_bytes = decode_salt(salt_str)?;
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
.try_into()
.map_err(|_| "Invalid nonce")?;
let nonce = aes_gcm::Nonce::from(nonce_bytes);
offset += 12;
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len =
u32::from_le_bytes(file_data[offset..offset + 4].try_into().unwrap()) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
let vault_password = get_vault_password();
let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
let key = Key::<Aes256Gcm>::from(key_bytes);
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))?;
let password =
String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in password: {e}"))?;
Ok(Some(password))
crate::vault::open(&get_e2e_password_path(), &E2E_MAGIC)
}
pub fn has_e2e_password() -> bool {
@@ -381,138 +271,9 @@ async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = [42u8; 32];
let plaintext = b"Hello, World!";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_decrypt_empty_data() {
let key = [1u8; 32];
let plaintext = b"";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext.to_vec());
}
#[test]
fn test_encrypt_decrypt_large_data() {
let key = [7u8; 32];
let plaintext = vec![0xABu8; 1_048_576]; // 1MB
let encrypted = encrypt_bytes(&key, &plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_different_keys_different_ciphertext() {
let key1 = [1u8; 32];
let key2 = [2u8; 32];
let plaintext = b"same data";
let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap();
// Nonces are random so ciphertexts will differ regardless,
// but decrypting with wrong key should fail
assert!(decrypt_bytes(&key2, &encrypted1).is_err());
assert!(decrypt_bytes(&key1, &encrypted2).is_err());
}
#[test]
fn test_nonce_uniqueness() {
let key = [5u8; 32];
let plaintext = b"same data encrypted twice";
let encrypted1 = encrypt_bytes(&key, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key, plaintext).unwrap();
// Different nonces should produce different ciphertext
assert_ne!(encrypted1, encrypted2);
// But both should decrypt to the same plaintext
assert_eq!(
decrypt_bytes(&key, &encrypted1).unwrap(),
decrypt_bytes(&key, &encrypted2).unwrap()
);
}
#[test]
fn test_wrong_key_fails() {
let key = [10u8; 32];
let wrong_key = [20u8; 32];
let plaintext = b"secret data";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
assert!(decrypt_bytes(&wrong_key, &encrypted).is_err());
}
#[test]
fn test_key_derivation_deterministic() {
let salt = generate_salt();
let key1 = derive_profile_key("my_password", &salt).unwrap();
let key2 = derive_profile_key("my_password", &salt).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn test_key_derivation_different_salts() {
let salt1 = generate_salt();
let salt2 = generate_salt();
let key1 = derive_profile_key("my_password", &salt1).unwrap();
let key2 = derive_profile_key("my_password", &salt2).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn test_salt_generation_unique() {
let salt1 = generate_salt();
let salt2 = generate_salt();
assert_ne!(salt1, salt2);
}
#[test]
fn test_password_storage_roundtrip() {
let password = "test_password_12345";
store_e2e_password(password).unwrap();
assert!(has_e2e_password());
let loaded = load_e2e_password().unwrap();
assert_eq!(loaded, Some(password.to_string()));
remove_e2e_password().unwrap();
assert!(!has_e2e_password());
}
#[test]
fn test_decrypt_too_short_data() {
let key = [1u8; 32];
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
}
}
#[path = "encryption_tests.rs"]
mod tests;
#[cfg(test)]
mod vault_key_tests {
use super::{decode_salt, derive_vault_key, encode_salt};
/// A stored vault is only readable while this vector holds. It pins the
/// Argon2id parameters and the salt encoding together: a dependency bump
/// that changed either would fail here instead of at the user's data.
#[test]
fn vault_key_derivation_is_pinned() {
let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap();
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex,
"799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928"
);
}
#[test]
fn salt_encoding_round_trips_without_padding() {
let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4];
let encoded = encode_salt(&salt);
assert!(!encoded.contains('='), "PHC B64 carries no padding");
assert_eq!(decode_salt(&encoded).unwrap(), salt);
assert!(decode_salt("not*valid").is_err());
}
}
#[path = "encryption_vault_key_tests.rs"]
mod vault_key_tests;
+106
View File
@@ -0,0 +1,106 @@
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = [42u8; 32];
let plaintext = b"Hello, World!";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_decrypt_empty_data() {
let key = [1u8; 32];
let plaintext = b"";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext.to_vec());
}
#[test]
fn test_encrypt_decrypt_large_data() {
let key = [7u8; 32];
let plaintext = vec![0xABu8; 1_048_576]; // 1MB
let encrypted = encrypt_bytes(&key, &plaintext).unwrap();
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_different_keys_different_ciphertext() {
let key1 = [1u8; 32];
let key2 = [2u8; 32];
let plaintext = b"same data";
let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap();
// Nonces are random so ciphertexts will differ regardless,
// but decrypting with wrong key should fail
assert!(decrypt_bytes(&key2, &encrypted1).is_err());
assert!(decrypt_bytes(&key1, &encrypted2).is_err());
}
#[test]
fn test_nonce_uniqueness() {
let key = [5u8; 32];
let plaintext = b"same data encrypted twice";
let encrypted1 = encrypt_bytes(&key, plaintext).unwrap();
let encrypted2 = encrypt_bytes(&key, plaintext).unwrap();
// Different nonces should produce different ciphertext
assert_ne!(encrypted1, encrypted2);
// But both should decrypt to the same plaintext
assert_eq!(
decrypt_bytes(&key, &encrypted1).unwrap(),
decrypt_bytes(&key, &encrypted2).unwrap()
);
}
#[test]
fn test_wrong_key_fails() {
let key = [10u8; 32];
let wrong_key = [20u8; 32];
let plaintext = b"secret data";
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
assert!(decrypt_bytes(&wrong_key, &encrypted).is_err());
}
#[test]
fn test_key_derivation_deterministic() {
let salt = generate_salt();
let key1 = derive_profile_key("my_password", &salt).unwrap();
let key2 = derive_profile_key("my_password", &salt).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn test_key_derivation_different_salts() {
let salt1 = generate_salt();
let salt2 = generate_salt();
let key1 = derive_profile_key("my_password", &salt1).unwrap();
let key2 = derive_profile_key("my_password", &salt2).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn test_salt_generation_unique() {
let salt1 = generate_salt();
let salt2 = generate_salt();
assert_ne!(salt1, salt2);
}
#[test]
fn test_password_storage_roundtrip() {
let password = "test_password_12345";
store_e2e_password(password).unwrap();
assert!(has_e2e_password());
let loaded = load_e2e_password().unwrap();
assert_eq!(loaded, Some(password.to_string()));
remove_e2e_password().unwrap();
assert!(!has_e2e_password());
}
#[test]
fn test_decrypt_too_short_data() {
let key = [1u8; 32];
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
}
@@ -0,0 +1,23 @@
use super::{decode_salt, derive_vault_key, encode_salt};
/// A stored vault is only readable while this vector holds. It pins the
/// Argon2id parameters and the salt encoding together: a dependency bump
/// that changed either would fail here instead of at the user's data.
#[test]
fn vault_key_derivation_is_pinned() {
let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap();
let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex,
"799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928"
);
}
#[test]
fn salt_encoding_round_trips_without_padding() {
let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4];
let encoded = encode_salt(&salt);
assert!(!encoded.contains('='), "PHC B64 carries no padding");
assert_eq!(decode_salt(&encoded).unwrap(), salt);
assert!(decode_salt("not*valid").is_err());
}
+8 -3
View File
@@ -5,6 +5,7 @@ use super::manifest::{
};
use super::types::*;
use crate::events;
use crate::log_redaction::Plain;
use crate::profile::types::{BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::settings_manager::SettingsManager;
@@ -3555,7 +3556,7 @@ pub async fn set_profile_sync_mode(
let _ = engine.client.delete(&manifest_key, None).await;
log::info!(
"Deleted remote manifest for profile {} due to sync mode change ({:?} -> {:?})",
profile_id,
Plain(&profile_id),
old_mode,
new_mode
);
@@ -3658,9 +3659,13 @@ pub async fn set_profile_sync_mode(
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id).await {
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
log::warn!(
"Failed to delete profile {} from sync: {}",
Plain(&profile_id),
e
);
} else {
log::info!("Profile {} deleted from sync service", profile_id);
log::info!("Profile {} deleted from sync service", Plain(&profile_id));
}
}
Err(e) => {
+2 -2
View File
@@ -282,7 +282,7 @@ mod tests {
// Exercises the real probe against a host that cannot resolve, which is
// what a container-only endpoint looks like from the desktop.
let client = probe_client();
let error = probe_storage_endpoint(&client, "http://minio.invalid:9000")
let error = probe_storage_endpoint(&client, "https://minio.invalid:9000")
.await
.expect_err("an unresolvable host must not report as reachable");
assert!(
@@ -313,7 +313,7 @@ mod tests {
// names and a bare "connection failed", and could not tell that the host
// their server had signed into every URL was one only the server could
// resolve.
let url = "http://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc";
let url = "https://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc";
let error = probe_client()
.put(url)
.body(b"payload".to_vec())
+3 -2
View File
@@ -1,6 +1,7 @@
use super::engine::SyncEngine;
use super::subscription::SyncWorkItem;
use crate::events;
use crate::log_redaction::Plain;
use crate::profile::ProfileManager;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -241,7 +242,7 @@ impl SyncScheduler {
);
log::debug!(
"Profile {} is running, queued sync for after stop",
profile_id
Plain(&profile_id)
);
} else {
// Profile is not running - sync immediately (set stopped_at to past)
@@ -252,7 +253,7 @@ impl SyncScheduler {
queued: true,
},
);
log::debug!("Profile {} queued for immediate sync", profile_id);
log::debug!("Profile {} queued for immediate sync", Plain(&profile_id));
}
}
+10 -4
View File
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use tauri::Emitter;
use tokio::sync::Mutex as AsyncMutex;
use crate::log_redaction::ShortId;
use crate::profile::manager::ProfileManager;
use crate::profile::types::BrowserProfile;
@@ -777,7 +778,7 @@ impl SynchronizerManager {
tokio::select! {
_ = cancel_rx.changed() => {
if *cancel_rx.borrow() {
log::info!("Synchronizer session {session_id}: cancelled");
log::info!("Synchronizer session {}: cancelled", ShortId(&session_id));
break;
}
}
@@ -827,7 +828,10 @@ impl SynchronizerManager {
}
// Leader closed or session cancelled — kill all followers
log::info!("Synchronizer session {session_id}: stopping all followers");
log::info!(
"Synchronizer session {}: stopping all followers",
ShortId(&session_id)
);
let follower_ids: Vec<String> = {
let inner = manager.lock().await;
if let Some(session) = inner.sessions.get(&session_id) {
@@ -1145,7 +1149,8 @@ impl SynchronizerManager {
let info = session.info();
let _ = app_handle.emit("sync-session-changed", &info);
log::info!(
"Synchronizer session {session_id}: mirroring {}",
"Synchronizer session {}: mirroring {}",
ShortId(session_id),
if paused { "paused" } else { "resumed" }
);
Ok(info)
@@ -1239,7 +1244,8 @@ impl SynchronizerManager {
return Err(serde_json::json!({ "code": "SYNC_ARRANGE_FAILED" }).to_string());
}
log::info!(
"Synchronizer session {session_id}: placed {placed} of {} windows",
"Synchronizer session {}: placed {placed} of {} windows",
ShortId(session_id),
follower_ids.len()
);
Ok(info)
+304
View File
@@ -0,0 +1,304 @@
//! Sealing of the secrets Donut keeps on this machine.
//!
//! The API and MCP tokens, the cloud session, the sync token and the sync
//! encryption password each live in a small file under the settings folder.
//! They are sealed with AES-256-GCM under a key derived (Argon2id, per-file
//! salt) from this installation's own vault key: 32 random bytes minted on
//! first use and kept in `vault.key`, readable by the owner only.
//!
//! Every build before the per-install key sealed those files under one
//! password compiled into the binary, the same for every install whose build
//! did not set `DONUT_BROWSER_VAULT_PASSWORD`. A file that still carries that
//! seal is opened with the legacy password and re-sealed under the
//! installation key on the spot, so an update keeps every login and token.
//!
//! File layout, unchanged from the earlier per-module copies:
//! `magic (5-byte header + 1 version byte) | salt length | PHC base64 salt |
//! 12-byte nonce | 4-byte little-endian ciphertext length | ciphertext`.
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use rand::RngExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::sync::encryption::{decode_salt, derive_vault_key, encode_salt};
pub const VAULT_KEY_FILE: &str = "vault.key";
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;
/// The sealing password of every build before the per-install key. Written
/// by `build.rs` from the build environment, with the historical default when
/// nothing was set. Only ever used to open a file sealed by such a build.
const LEGACY_PASSWORD: &str = include_str!(concat!(env!("OUT_DIR"), "/legacy_vault_password.txt"));
/// The installation key, cached with the file it came from so a test that
/// moves the settings folder never reads a key from the previous one.
static INSTALL_KEY: Mutex<Option<(PathBuf, [u8; KEY_LEN])>> = Mutex::new(None);
fn key_file() -> PathBuf {
crate::app_dirs::settings_dir().join(VAULT_KEY_FILE)
}
/// This installation's vault key, minted the first time anything needs it.
///
/// A key file of the wrong size is refused rather than replaced: minting a
/// new key over it would silently orphan every file sealed under the old one.
pub fn install_key() -> Result<[u8; KEY_LEN], String> {
let path = key_file();
if let Ok(cached) = INSTALL_KEY.lock() {
if let Some((cached_path, key)) = cached.as_ref() {
if *cached_path == path {
return Ok(*key);
}
}
}
let key = match std::fs::read(&path) {
Ok(bytes) if bytes.len() == KEY_LEN => <[u8; KEY_LEN]>::try_from(bytes.as_slice())
.map_err(|_| "The vault key file could not be read whole".to_string())?,
Ok(bytes) => {
return Err(format!(
"The vault key file {} holds {} bytes instead of {KEY_LEN}",
path.display(),
bytes.len()
));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => mint_key(&path)?,
Err(e) => return Err(format!("Could not read the vault key: {e}")),
};
if let Ok(mut cached) = INSTALL_KEY.lock() {
*cached = Some((path, key));
}
Ok(key)
}
/// Write a fresh key next to the sealed files. Written to a sibling first and
/// renamed into place, so a crash mid-write never leaves a short key behind.
fn mint_key(path: &Path) -> Result<[u8; KEY_LEN], String> {
let key: [u8; KEY_LEN] = rand::rng().random();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Could not create the settings folder: {e}"))?;
}
let staging = path.with_extension("key.tmp");
std::fs::write(&staging, key).map_err(|e| format!("Could not write the vault key: {e}"))?;
crate::app_dirs::restrict_to_owner(&staging);
if let Err(e) = std::fs::rename(&staging, path) {
let _ = std::fs::remove_file(&staging);
// Another process minted the key first; theirs is the one to keep.
if path.exists() {
return install_key();
}
return Err(format!("Could not place the vault key: {e}"));
}
crate::app_dirs::restrict_to_owner(path);
Ok(key)
}
/// Seal `secret` into `file` under this installation's key.
pub fn seal(file: &Path, magic: &[u8; 6], secret: &str) -> Result<(), String> {
let key = install_key()?;
seal_with(file, magic, secret, &key)
}
fn seal_with(file: &Path, magic: &[u8; 6], secret: &str, material: &[u8]) -> Result<(), String> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
let salt_bytes: [u8; 16] = rand::rng().random();
let salt = encode_salt(&salt_bytes);
let key = Key::<Aes256Gcm>::from(derive_vault_key(material, &salt_bytes)?);
let cipher = Aes256Gcm::new(&key);
let nonce_bytes: [u8; NONCE_LEN] = rand::rng().random();
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, secret.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut data = Vec::new();
data.extend_from_slice(magic);
let salt_str = salt.as_str();
data.push(salt_str.len() as u8);
data.extend_from_slice(salt_str.as_bytes());
data.extend_from_slice(&nonce);
data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
data.extend_from_slice(&ciphertext);
std::fs::write(file, data).map_err(|e| format!("Failed to write file: {e}"))?;
crate::app_dirs::restrict_to_owner(file);
Ok(())
}
/// The parts of a sealed file, once the layout has been checked.
struct Sealed<'a> {
salt: Vec<u8>,
nonce: [u8; NONCE_LEN],
ciphertext: &'a [u8],
}
/// Take a sealed file apart. A foreign magic or a layout this version does not
/// know reads as "no secret", never as an error.
fn parse<'a>(data: &'a [u8], magic: &[u8; 6]) -> Result<Option<Sealed<'a>>, String> {
if data.len() < magic.len() + 1 || &data[..magic.len()] != magic {
return Ok(None);
}
let mut offset = magic.len();
let salt_len = data[offset] as usize;
offset += 1;
if offset + salt_len > data.len() {
return Ok(None);
}
let salt_str =
std::str::from_utf8(&data[offset..offset + salt_len]).map_err(|_| "Invalid salt encoding")?;
let salt = decode_salt(salt_str)?;
offset += salt_len;
if offset + NONCE_LEN > data.len() {
return Ok(None);
}
let nonce: [u8; NONCE_LEN] = data[offset..offset + NONCE_LEN]
.try_into()
.map_err(|_| "Invalid nonce length".to_string())?;
offset += NONCE_LEN;
if offset + 4 > data.len() {
return Ok(None);
}
let ciphertext_len = u32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]) as usize;
offset += 4;
if offset + ciphertext_len > data.len() {
return Ok(None);
}
Ok(Some(Sealed {
salt,
nonce,
ciphertext: &data[offset..offset + ciphertext_len],
}))
}
fn unseal(sealed: &Sealed<'_>, material: &[u8]) -> Result<Option<String>, String> {
let key = Key::<Aes256Gcm>::from(derive_vault_key(material, &sealed.salt)?);
let cipher = Aes256Gcm::new(&key);
let Ok(plaintext) = cipher.decrypt(&Nonce::from(sealed.nonce), sealed.ciphertext) else {
return Ok(None);
};
Ok(String::from_utf8(plaintext).ok())
}
/// Read back a secret written by `seal`.
///
/// A missing file, a foreign magic or a damaged layout all read as "no
/// secret" so a stale file never blocks the feature it belongs to. A file
/// that opens only under the legacy build password is re-sealed under this
/// installation's key before the secret is returned. A seal that neither key
/// opens is an error: the file is real, and the caller must not mint over it
/// as if it were absent.
pub fn open(file: &Path, magic: &[u8; 6]) -> Result<Option<String>, String> {
if !file.exists() {
return Ok(None);
}
let data = std::fs::read(file).map_err(|e| format!("Failed to read file: {e}"))?;
let Some(sealed) = parse(&data, magic)? else {
return Ok(None);
};
let key = install_key()?;
if let Some(secret) = unseal(&sealed, &key)? {
return Ok(Some(secret));
}
match unseal(&sealed, LEGACY_PASSWORD.trim().as_bytes())? {
Some(secret) => {
if let Err(e) = seal_with(file, magic, &secret, &key) {
log::warn!(
"Could not re-seal {} under the vault key: {e}",
file.display()
);
}
Ok(Some(secret))
}
None => Err("Decryption failed".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn isolated() -> (TempDir, crate::app_dirs::TestDirGuard) {
let dir = TempDir::new().unwrap();
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
(dir, guard)
}
#[test]
fn the_key_is_minted_once_and_reused() {
let (_dir, _guard) = isolated();
let first = install_key().unwrap();
let second = install_key().unwrap();
assert_eq!(first, second);
assert_eq!(std::fs::read(key_file()).unwrap().len(), KEY_LEN);
}
#[test]
fn a_seal_round_trips_and_a_foreign_magic_reads_as_nothing() {
let (dir, _guard) = isolated();
let file = dir.path().join("secret.dat");
seal(&file, b"DBTST\x02", "hunter's token").unwrap();
assert_eq!(
open(&file, b"DBTST\x02").unwrap().as_deref(),
Some("hunter's token")
);
assert_eq!(open(&file, b"DBOTH\x02").unwrap(), None);
assert_eq!(
open(&dir.path().join("missing.dat"), b"DBTST\x02").unwrap(),
None
);
}
#[test]
fn a_legacy_seal_opens_once_and_comes_back_under_the_install_key() {
let (dir, _guard) = isolated();
let file = dir.path().join("legacy.dat");
seal_with(
&file,
b"DBTST\x02",
"kept",
LEGACY_PASSWORD.trim().as_bytes(),
)
.unwrap();
assert_eq!(open(&file, b"DBTST\x02").unwrap().as_deref(), Some("kept"));
// Re-sealed: the legacy password no longer opens the file, the key does.
let data = std::fs::read(&file).unwrap();
let sealed = parse(&data, b"DBTST\x02").unwrap().unwrap();
assert_eq!(
unseal(&sealed, LEGACY_PASSWORD.trim().as_bytes()).unwrap(),
None
);
assert_eq!(
unseal(&sealed, &install_key().unwrap()).unwrap().as_deref(),
Some("kept")
);
}
#[test]
fn a_seal_under_an_unknown_key_is_an_error_not_an_absence() {
let (dir, _guard) = isolated();
let file = dir.path().join("foreign.dat");
let other: [u8; KEY_LEN] = rand::rng().random();
seal_with(&file, b"DBTST\x02", "elsewhere", &other).unwrap();
assert!(open(&file, b"DBTST\x02").is_err());
}
#[test]
fn a_damaged_key_file_is_refused_rather_than_replaced() {
let (_dir, _guard) = isolated();
std::fs::create_dir_all(key_file().parent().unwrap()).unwrap();
std::fs::write(key_file(), b"short").unwrap();
assert!(install_key().is_err());
}
}
+1 -3
View File
@@ -77,9 +77,7 @@ impl VpnStorage {
};
let encryption_key = if key_path.exists() {
if let Ok(key_data) = fs::read(&key_path) {
if key_data.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&key_data);
if let Ok(key) = <[u8; 32]>::try_from(key_data.as_slice()) {
key
} else {
let key: [u8; 32] = rand::rng().random();
+43
View File
@@ -367,11 +367,54 @@ fn badge_fonts() -> std::sync::Arc<resvg::usvg::fontdb::Database> {
.get_or_init(|| {
let mut db = resvg::usvg::fontdb::Database::new();
db.load_system_fonts();
if let Some(family) = badge_sans_family(&db) {
db.set_sans_serif_family(family);
}
std::sync::Arc::new(db)
})
.clone()
}
/// The family the badge's `sans-serif` resolves to.
///
/// The database names Arial for the generic family, which macOS and Windows
/// have and a Linux desktop usually does not: Ubuntu ships Noto, DejaVu,
/// Liberation and Ubuntu instead. An unresolved family draws no initial at
/// all, so the first family that is actually installed is chosen, and failing
/// every known name, any installed font at all.
fn badge_sans_family(db: &resvg::usvg::fontdb::Database) -> Option<String> {
use resvg::usvg::fontdb::{Family, Query, Stretch, Style, Weight};
const PREFERRED: [&str; 10] = [
"Arial",
"Helvetica Neue",
"Helvetica",
"Segoe UI",
"Noto Sans",
"DejaVu Sans",
"Liberation Sans",
"Ubuntu",
"Cantarell",
"Roboto",
];
let installed = |name: &str| {
db.query(&Query {
families: &[Family::Name(name)],
weight: Weight::NORMAL,
stretch: Stretch::Normal,
style: Style::Normal,
})
.is_some()
};
PREFERRED
.iter()
.find(|name| installed(name))
.map(|name| name.to_string())
.or_else(|| {
db.faces()
.find_map(|face| face.families.first().map(|(name, _)| name.clone()))
})
}
/// The first letter (or digit) of a profile name, upper-cased, for its badge.
pub fn badge_initial(name: &str) -> String {
name
+6 -6
View File
@@ -40,10 +40,10 @@ pub const FIELD_IDS: [&str; 9] = [
"postal_code",
];
/// FNV-1a with the salt folded into the initial state, then splitmix64, so
/// neighbouring salts do not produce visibly related values.
fn draw(seed: &str, salt: u64) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ salt;
/// FNV-1a with the stream number folded into the initial state, then
/// splitmix64, so neighbouring streams do not produce visibly related values.
fn draw(seed: &str, stream: u64) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ stream;
for byte in seed.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
@@ -54,8 +54,8 @@ fn draw(seed: &str, salt: u64) -> u64 {
z ^ (z >> 31)
}
fn pick<'a>(seed: &str, salt: u64, options: &[&'a str]) -> &'a str {
options[(draw(seed, salt) % options.len() as u64) as usize]
fn pick<'a>(seed: &str, stream: u64, options: &[&'a str]) -> &'a str {
options[(draw(seed, stream) % options.len() as u64) as usize]
}
const GIVEN_NAMES: [&str; 32] = [
+2 -161
View File
@@ -647,164 +647,5 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proxy_storage::process_start_time;
#[cfg(unix)]
fn short_lived_child() -> Child {
Command::new("sh")
.args(["-c", "sleep 0.2"])
.spawn()
.unwrap()
}
#[cfg(windows)]
fn short_lived_child() -> Child {
Command::new("cmd")
.args(["/C", "ping -n 2 127.0.0.1 >NUL"])
.spawn()
.unwrap()
}
#[test]
fn supervisor_child_is_reaped_after_exit() {
let child = short_lived_child();
let pid = child.id();
let start_time = resolve_process_start_time(pid).unwrap();
spawn_supervisor_reaper(child).join().unwrap();
assert!(!process_identity_matches(pid, Some(start_time)));
}
fn readiness_config(port: u16) -> XrayWorkerConfig {
let mut config = XrayWorkerConfig::new(
"readiness".to_string(),
None,
"vless://unused".to_string(),
port,
"local-user".to_string(),
"local-password".to_string(),
);
let pid = std::process::id();
config.xray_pid = Some(pid);
config.xray_pid_start_time = process_start_time(pid);
config
}
#[test]
fn parses_macos_major_versions_for_sidecar_compatibility() {
assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11));
assert_eq!(parse_macos_major_version("12.0"), Some(12));
assert_eq!(parse_macos_major_version("15.5.1"), Some(15));
assert_eq!(parse_macos_major_version("unknown"), None);
}
#[tokio::test]
async fn readiness_requires_the_expected_authenticated_socks_endpoint() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let expected_username = config.username.clone();
let expected_password = config.password.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [5, 1, 2]);
stream.write_all(&[5, 2]).await.unwrap();
let mut header = [0_u8; 2];
stream.read_exact(&mut header).await.unwrap();
assert_eq!(header[0], 1);
let mut username = vec![0_u8; header[1] as usize];
stream.read_exact(&mut username).await.unwrap();
let password_len = stream.read_u8().await.unwrap();
let mut password = vec![0_u8; password_len as usize];
stream.read_exact(&mut password).await.unwrap();
assert_eq!(username, expected_username.as_bytes());
assert_eq!(password, expected_password.as_bytes());
stream.write_all(&[1, 0]).await.unwrap();
});
assert!(authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[tokio::test]
async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
stream.write_all(&[5, 0]).await.unwrap();
});
assert!(!authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[test]
fn browser_identity_is_persisted_on_the_exact_worker() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4());
let config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
save_xray_worker_config(&config).unwrap();
let browser_pid = std::process::id();
assert!(set_browser_pid(&id, browser_pid));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(browser_pid));
assert_eq!(
saved.browser_pid_start_time,
process_start_time(browser_pid)
);
assert!(delete_xray_worker_config(&id));
}
#[test]
fn reusable_worker_requires_and_persists_the_exact_live_owner() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4());
let mut config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
config.browser_pid = Some(u32::MAX);
config.browser_pid_start_time = Some(1);
save_xray_worker_config(&config).unwrap();
let owner_pid = std::process::id();
let owner_start_time = process_start_time(owner_pid).unwrap();
assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time));
assert!(persist_browser_identity(
&mut config,
owner_pid,
owner_start_time
));
assert!(worker_is_leased_to(&config, owner_pid, owner_start_time));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(owner_pid));
assert_eq!(saved.browser_pid_start_time, Some(owner_start_time));
assert!(delete_xray_worker_config(&id));
}
}
#[path = "xray_worker_runner_tests.rs"]
mod tests;
+159
View File
@@ -0,0 +1,159 @@
use super::*;
use crate::proxy_storage::process_start_time;
#[cfg(unix)]
fn short_lived_child() -> Child {
Command::new("sh")
.args(["-c", "sleep 0.2"])
.spawn()
.unwrap()
}
#[cfg(windows)]
fn short_lived_child() -> Child {
Command::new("cmd")
.args(["/C", "ping -n 2 127.0.0.1 >NUL"])
.spawn()
.unwrap()
}
#[test]
fn supervisor_child_is_reaped_after_exit() {
let child = short_lived_child();
let pid = child.id();
let start_time = resolve_process_start_time(pid).unwrap();
spawn_supervisor_reaper(child).join().unwrap();
assert!(!process_identity_matches(pid, Some(start_time)));
}
fn readiness_config(port: u16) -> XrayWorkerConfig {
let mut config = XrayWorkerConfig::new(
"readiness".to_string(),
None,
"vless://unused".to_string(),
port,
"local-user".to_string(),
"local-password".to_string(),
);
let pid = std::process::id();
config.xray_pid = Some(pid);
config.xray_pid_start_time = process_start_time(pid);
config
}
#[test]
fn parses_macos_major_versions_for_sidecar_compatibility() {
assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11));
assert_eq!(parse_macos_major_version("12.0"), Some(12));
assert_eq!(parse_macos_major_version("15.5.1"), Some(15));
assert_eq!(parse_macos_major_version("unknown"), None);
}
#[tokio::test]
async fn readiness_requires_the_expected_authenticated_socks_endpoint() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let expected_username = config.username.clone();
let expected_password = config.password.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [5, 1, 2]);
stream.write_all(&[5, 2]).await.unwrap();
let mut header = [0_u8; 2];
stream.read_exact(&mut header).await.unwrap();
assert_eq!(header[0], 1);
let mut username = vec![0_u8; header[1] as usize];
stream.read_exact(&mut username).await.unwrap();
let password_len = stream.read_u8().await.unwrap();
let mut password = vec![0_u8; password_len as usize];
stream.read_exact(&mut password).await.unwrap();
assert_eq!(username, expected_username.as_bytes());
assert_eq!(password, expected_password.as_bytes());
stream.write_all(&[1, 0]).await.unwrap();
});
assert!(authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[tokio::test]
async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
stream.write_all(&[5, 0]).await.unwrap();
});
assert!(!authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[test]
fn browser_identity_is_persisted_on_the_exact_worker() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4());
let config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
save_xray_worker_config(&config).unwrap();
let browser_pid = std::process::id();
assert!(set_browser_pid(&id, browser_pid));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(browser_pid));
assert_eq!(
saved.browser_pid_start_time,
process_start_time(browser_pid)
);
assert!(delete_xray_worker_config(&id));
}
#[test]
fn reusable_worker_requires_and_persists_the_exact_live_owner() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4());
let mut config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
config.browser_pid = Some(u32::MAX);
config.browser_pid_start_time = Some(1);
save_xray_worker_config(&config).unwrap();
let owner_pid = std::process::id();
let owner_start_time = process_start_time(owner_pid).unwrap();
assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time));
assert!(persist_browser_identity(
&mut config,
owner_pid,
owner_start_time
));
assert!(worker_is_leased_to(&config, owner_pid, owner_start_time));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(owner_pid));
assert_eq!(saved.browser_pid_start_time, Some(owner_start_time));
assert!(delete_xray_worker_config(&id));
}
+2 -179
View File
@@ -347,182 +347,5 @@ pub fn generate_xray_worker_id() -> String {
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config(id: &str) -> XrayWorkerConfig {
XrayWorkerConfig::new(
id.to_string(),
Some("profile".to_string()),
"vless://example".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
)
}
#[test]
fn local_proxy_settings_use_authenticated_loopback_socks() {
let config = test_config("id");
let proxy = config.local_proxy_settings();
assert_eq!(proxy.proxy_type, "socks5");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 1080);
assert_eq!(proxy.username.as_deref(), Some("local-user"));
assert_eq!(proxy.password.as_deref(), Some("local-password"));
assert!(proxy.vless_uri.is_none());
}
#[test]
fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4());
let mut config = test_config(&id);
save_xray_worker_config(&config).unwrap();
assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user");
assert_eq!(
find_xray_worker_by_profile_id("profile").unwrap().id,
config.id
);
assert!(list_xray_worker_configs()
.iter()
.any(|candidate| candidate.id == id));
config.pid = Some(41);
config.xray_pid = Some(42);
config.browser_pid = Some(43);
assert!(update_xray_worker_config(&config));
let updated = get_xray_worker_config(&id).unwrap();
assert_eq!(updated.pid, Some(41));
assert_eq!(updated.xray_pid, Some(42));
assert_eq!(updated.browser_pid, Some(43));
let runtime_path = xray_runtime_config_path(&id);
write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap();
let log_path = xray_worker_log_path(&id);
drop(create_xray_worker_log(&id).unwrap());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(crate::proxy_storage::get_storage_dir())
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
std::fs::metadata(xray_worker_config_path(&id))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&runtime_path)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
assert!(delete_xray_worker_config(&id));
assert!(get_xray_worker_config(&id).is_none());
assert!(!runtime_path.exists());
assert!(!log_path.exists());
assert!(!update_xray_worker_config(&config));
assert!(write_xray_runtime_config(&id, b"{}").is_err());
assert!(create_xray_worker_log(&id).is_err());
}
#[test]
fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() {
let fresh = test_config("fresh");
assert!(!unstarted_worker_is_stale(&fresh));
let mut legacy = test_config("legacy");
legacy.created_at = 0;
assert!(unstarted_worker_is_stale(&legacy));
legacy.pid = Some(1);
assert!(!unstarted_worker_is_stale(&legacy));
}
#[test]
fn atomic_state_updates_never_expose_partial_json() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.json");
atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
for value in 1..=500 {
let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap();
atomic_write_owner_only(&writer_path, &content).unwrap();
}
});
// Bound the reader on the writer's own lifetime. A completion flag the
// writer sets last is never set when it panics, which strands this loop
// reading the last good file forever instead of failing.
while !writer.is_finished() {
let content = read_worker_state(&path).expect("state file stays readable while replaced");
let value: serde_json::Value = serde_json::from_slice(&content).unwrap();
assert!(value["value"].is_number());
std::thread::yield_now();
}
writer.join().unwrap();
}
#[cfg(unix)]
#[test]
fn atomic_state_write_replaces_a_symlink_without_touching_its_target() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let victim = temp.path().join("victim");
let state = temp.path().join("state.json");
std::fs::write(&victim, "untouched").unwrap();
symlink(&victim, &state).unwrap();
atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap();
assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&std::fs::read(state).unwrap()).unwrap()["safe"],
true
);
}
#[test]
fn legacy_worker_config_defaults_missing_browser_pid() {
let value = serde_json::json!({
"id": "legacy",
"profile_id": "profile",
"vless_uri": "vless://example",
"local_port": 1080,
"username": "user",
"password": "password",
"pid": 1,
"xray_pid": 2
});
let config: XrayWorkerConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.created_at, 0);
assert_eq!(config.pid_start_time, None);
assert_eq!(config.xray_pid_start_time, None);
assert!(!config.ready);
assert_eq!(config.browser_pid, None);
assert_eq!(config.browser_pid_start_time, None);
}
}
#[path = "xray_worker_storage_tests.rs"]
mod tests;
+177
View File
@@ -0,0 +1,177 @@
use super::*;
fn test_config(id: &str) -> XrayWorkerConfig {
XrayWorkerConfig::new(
id.to_string(),
Some("profile".to_string()),
"vless://example".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
)
}
#[test]
fn local_proxy_settings_use_authenticated_loopback_socks() {
let config = test_config("id");
let proxy = config.local_proxy_settings();
assert_eq!(proxy.proxy_type, "socks5");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 1080);
assert_eq!(proxy.username.as_deref(), Some("local-user"));
assert_eq!(proxy.password.as_deref(), Some("local-password"));
assert!(proxy.vless_uri.is_none());
}
#[test]
fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4());
let mut config = test_config(&id);
save_xray_worker_config(&config).unwrap();
assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user");
assert_eq!(
find_xray_worker_by_profile_id("profile").unwrap().id,
config.id
);
assert!(list_xray_worker_configs()
.iter()
.any(|candidate| candidate.id == id));
config.pid = Some(41);
config.xray_pid = Some(42);
config.browser_pid = Some(43);
assert!(update_xray_worker_config(&config));
let updated = get_xray_worker_config(&id).unwrap();
assert_eq!(updated.pid, Some(41));
assert_eq!(updated.xray_pid, Some(42));
assert_eq!(updated.browser_pid, Some(43));
let runtime_path = xray_runtime_config_path(&id);
write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap();
let log_path = xray_worker_log_path(&id);
drop(create_xray_worker_log(&id).unwrap());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(crate::proxy_storage::get_storage_dir())
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
std::fs::metadata(xray_worker_config_path(&id))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&runtime_path)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
assert!(delete_xray_worker_config(&id));
assert!(get_xray_worker_config(&id).is_none());
assert!(!runtime_path.exists());
assert!(!log_path.exists());
assert!(!update_xray_worker_config(&config));
assert!(write_xray_runtime_config(&id, b"{}").is_err());
assert!(create_xray_worker_log(&id).is_err());
}
#[test]
fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() {
let fresh = test_config("fresh");
assert!(!unstarted_worker_is_stale(&fresh));
let mut legacy = test_config("legacy");
legacy.created_at = 0;
assert!(unstarted_worker_is_stale(&legacy));
legacy.pid = Some(1);
assert!(!unstarted_worker_is_stale(&legacy));
}
#[test]
fn atomic_state_updates_never_expose_partial_json() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.json");
atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
for value in 1..=500 {
let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap();
atomic_write_owner_only(&writer_path, &content).unwrap();
}
});
// Bound the reader on the writer's own lifetime. A completion flag the
// writer sets last is never set when it panics, which strands this loop
// reading the last good file forever instead of failing.
while !writer.is_finished() {
let content = read_worker_state(&path).expect("state file stays readable while replaced");
let value: serde_json::Value = serde_json::from_slice(&content).unwrap();
assert!(value["value"].is_number());
std::thread::yield_now();
}
writer.join().unwrap();
}
#[cfg(unix)]
#[test]
fn atomic_state_write_replaces_a_symlink_without_touching_its_target() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let victim = temp.path().join("victim");
let state = temp.path().join("state.json");
std::fs::write(&victim, "untouched").unwrap();
symlink(&victim, &state).unwrap();
atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap();
assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&std::fs::read(state).unwrap()).unwrap()["safe"],
true
);
}
#[test]
fn legacy_worker_config_defaults_missing_browser_pid() {
let value = serde_json::json!({
"id": "legacy",
"profile_id": "profile",
"vless_uri": "vless://example",
"local_port": 1080,
"username": "user",
"password": "password",
"pid": 1,
"xray_pid": 2
});
let config: XrayWorkerConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.created_at, 0);
assert_eq!(config.pid_start_time, None);
assert_eq!(config.xray_pid_start_time, None);
assert!(!config.ready);
assert_eq!(config.browser_pid, None);
assert_eq!(config.browser_pid_start_time, None);
}