feat: add tips

This commit is contained in:
zhom committed 2026-09-09 06:16:14 -07:00
1 parent 3a0e5a41c0
commit a01901ef07
83 files changed
+8032 -2732

No files matched your search

+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 (~80–150 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));
}
}