refactor: cleanup

This commit is contained in:
zhom
2026-09-09 10:09:14 +04:00
parent 598d3bd513
commit dd42d46753
249 changed files with 67417 additions and 6659 deletions
+333 -355
View File
@@ -6,7 +6,6 @@ use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use rand::RngExt;
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -50,6 +49,29 @@ pub struct AppSettings {
pub mcp_port: Option<u16>, // Port for MCP server (default 51080)
#[serde(default)]
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
/// Let Donut cloud drive this installation's MCP tools over an outbound
/// bridge, so an agent on the website can control this browser.
///
/// Defaults to OFF and stays off until the user says otherwise. It opens a
/// long-lived socket to Donut cloud and hands the far end the ability to
/// launch and drive profiles, which is not something to switch on for
/// somebody by default because their plan happens to include it.
#[serde(default)]
pub mcp_remote_enabled: bool,
/// The durable `dmk_` credential agents present to the remote MCP endpoint.
///
/// Plaintext, kept in an encrypted file with the same posture as
/// `mcp_token`: loaded into the struct for a frontend settings read (the fx
/// client cannot take the credential from its config file, so the page
/// offers the export line), and stripped by `save_settings` so the settings
/// JSON never carries it. Absent from the wire when there is none, so a
/// settings file written by an older build stays byte-for-byte unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_remote_key: Option<String>,
/// The server-side id of `mcp_remote_key`, so a rotation can revoke exactly
/// the key it replaces. Not a secret; lives in the settings JSON.
#[serde(default)]
pub mcp_remote_key_id: Option<String>,
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
@@ -71,6 +93,11 @@ pub struct AppSettings {
/// copy is always re-encrypted regardless of this flag.
#[serde(default)]
pub keep_decrypted_profiles_in_ram: bool,
/// How long a deleted profile stays in the trash before it is purged.
/// Clamped to 1..=365 on save; the sweeper reads it through
/// `profile::trash::configured_retention_days`.
#[serde(default = "default_trash_retention_days")]
pub trash_retention_days: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -87,6 +114,10 @@ fn default_api_port() -> u16 {
10108
}
fn default_trash_retention_days() -> u32 {
crate::profile::trash::DEFAULT_RETENTION_DAYS
}
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -102,6 +133,9 @@ impl Default for AppSettings {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
mcp_remote_enabled: false,
mcp_remote_key: None,
mcp_remote_key_id: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
@@ -109,10 +143,20 @@ impl Default for AppSettings {
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS,
}
}
}
/// The remote MCP credential as it is kept on this machine.
#[derive(Debug, Clone)]
pub struct StoredMcpRemoteKey {
/// The plaintext `dmk_` key.
pub key: String,
/// The server-side id, when the store that wrote the key also recorded it.
pub id: Option<String>,
}
pub struct SettingsManager;
impl SettingsManager {
@@ -160,8 +204,15 @@ impl SettingsManager {
let settings_dir = self.get_settings_dir();
create_dir_all(&settings_dir)?;
// The remote MCP credential works from anywhere on the internet and has
// its own encrypted file; a struct loaded for the frontend carries it, so
// it is dropped at the one place the JSON gets written rather than at
// every caller that happens to hold such a struct.
let mut on_disk = settings.clone();
on_disk.mcp_remote_key = None;
let settings_file = self.get_settings_file();
let json = serde_json::to_string_pretty(settings)?;
let json = serde_json::to_string_pretty(&on_disk)?;
fs::write(settings_file, json)?;
Ok(())
@@ -198,121 +249,74 @@ impl SettingsManager {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
pub async fn generate_api_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
// Generate a secure random token (base64 encoded for URL safety)
let token_bytes: [u8; 32] = {
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
bytes
};
use base64::{engine::general_purpose, Engine as _};
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
// Store token securely
self.store_api_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_api_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
/// Encrypt `secret` into `file` under the vault password.
///
/// 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.
fn encrypt_to_file(
file: &std::path::Path,
header: &[u8; 5],
secret: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Store token in an encrypted file using Argon2 + AES-GCM
let token_file = self.get_settings_dir().join("api_token.dat");
// Create directory if it doesn't exist
if let Some(parent) = token_file.parent() {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
// Generate a random salt for Argon2
let salt_bytes: [u8; 16] = rand::rng().random();
let salt =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
// Use Argon2 to derive a 32-byte key from the vault password
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
// Take first 32 bytes for AES-256 key
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
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);
// Generate a random nonce
let nonce_bytes: [u8; 12] = rand::rng().random();
let nonce = Nonce::from(nonce_bytes);
// Encrypt the token
let ciphertext = cipher
.encrypt(&nonce, token.as_bytes())
.encrypt(&nonce, secret.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
// Create file data with header, salt, nonce, and encrypted data
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBAPI"); // 5-byte header
file_data.extend_from_slice(header);
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
// Store salt length and salt
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
// Store nonce (12 bytes for AES-GCM)
file_data.extend_from_slice(&nonce);
// Store ciphertext length and ciphertext
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(&token_file, file_data)?;
crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file));
std::fs::write(file, file_data)?;
crate::app_dirs::restrict_to_owner(file);
Ok(())
}
pub async fn get_api_token(
&self,
_app_handle: &tauri::AppHandle,
/// Read back a secret written by `encrypt_to_file`.
///
/// A missing file, a foreign header or a layout this version does not know
/// all read as "no secret" rather than an error, so a stale or damaged file
/// never blocks the feature it belongs to; the caller simply mints again.
fn decrypt_from_file(
file: &std::path::Path,
header: &[u8; 5],
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if !token_file.exists() {
if !file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
let file_data = std::fs::read(file)?;
// Validate header
if file_data.len() < 6 || &file_data[0..5] != b"DBAPI" {
if file_data.len() < 6 || &file_data[0..5] != header {
return Ok(None);
}
let version = file_data[5];
// Only support Argon2 + AES-GCM (version 2)
if version != 2 {
return Ok(None);
}
// Argon2 + AES-GCM decryption
let mut offset = 6;
// Read salt
if offset >= file_data.len() {
return Ok(None);
}
@@ -324,10 +328,9 @@ impl SettingsManager {
}
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 = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
offset += salt_len;
// Read nonce (12 bytes)
if offset + 12 > file_data.len() {
return Ok(None);
}
@@ -337,7 +340,6 @@ impl SettingsManager {
let nonce = Nonce::from(nonce_bytes);
offset += 12;
// Read ciphertext
if offset + 4 > file_data.len() {
return Ok(None);
}
@@ -354,22 +356,11 @@ impl SettingsManager {
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
// Derive key using Argon2
let vault_password = Self::get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
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);
// Decrypt the token
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
@@ -380,23 +371,15 @@ impl SettingsManager {
}
}
pub async fn remove_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
fn remove_secret_file(file: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
if file.exists() {
std::fs::remove_file(file)?;
}
Ok(())
}
pub async fn generate_mcp_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
/// A fresh 256-bit token, base64url so it is safe in a URL path.
fn random_token() -> String {
let token_bytes: [u8; 32] = {
use rand::Rng;
let mut rng = rand::rng();
@@ -405,7 +388,61 @@ impl SettingsManager {
bytes
};
use base64::{engine::general_purpose, Engine as _};
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
general_purpose::URL_SAFE_NO_PAD.encode(token_bytes)
}
fn api_token_file(&self) -> PathBuf {
self.get_settings_dir().join("api_token.dat")
}
fn mcp_token_file(&self) -> PathBuf {
self.get_settings_dir().join("mcp_token.dat")
}
fn sync_token_file(&self) -> PathBuf {
self.get_settings_dir().join("sync_token.dat")
}
fn mcp_remote_key_file(&self) -> PathBuf {
self.get_settings_dir().join("mcp_remote_key.dat")
}
pub async fn generate_api_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token = Self::random_token();
self.store_api_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_api_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
Self::encrypt_to_file(&self.api_token_file(), b"DBAPI", token)
}
pub async fn get_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
Self::decrypt_from_file(&self.api_token_file(), b"DBAPI")
}
pub async fn remove_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
Self::remove_secret_file(&self.api_token_file())
}
pub async fn generate_mcp_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token = Self::random_token();
self.store_mcp_token(app_handle, &token).await?;
Ok(token)
}
@@ -415,142 +452,21 @@ impl SettingsManager {
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if let Some(parent) = token_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 =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
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, token.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBMCP"); // 5-byte header for MCP token
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(&token_file, file_data)?;
crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file));
Ok(())
Self::encrypt_to_file(&self.mcp_token_file(), b"DBMCP", token)
}
pub async fn get_mcp_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
if file_data.len() < 6 || &file_data[0..5] != b"DBMCP" {
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 = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
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 argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
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),
}
Self::decrypt_from_file(&self.mcp_token_file(), b"DBMCP")
}
pub async fn remove_mcp_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
}
Ok(())
Self::remove_secret_file(&self.mcp_token_file())
}
pub async fn store_sync_token(
@@ -558,141 +474,66 @@ impl SettingsManager {
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("sync_token.dat");
if let Some(parent) = token_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 =
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
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, token.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBSYN"); // 5-byte header for sync
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(&token_file, file_data)?;
crate::app_dirs::restrict_to_owner(std::path::Path::new(&token_file));
Ok(())
Self::encrypt_to_file(&self.sync_token_file(), b"DBSYN", token)
}
pub async fn get_sync_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("sync_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
if file_data.len() < 6 || &file_data[0..5] != b"DBSYN" {
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 = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
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 argon2 = Argon2::default();
let password_hash = argon2
.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key_bytes: [u8; 32] = hash_bytes[..32]
.try_into()
.map_err(|_| "Invalid key length")?;
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),
}
Self::decrypt_from_file(&self.sync_token_file(), b"DBSYN")
}
pub async fn remove_sync_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("sync_token.dat");
Self::remove_secret_file(&self.sync_token_file())
}
if token_file.exists() {
std::fs::remove_file(token_file)?;
/// Keep the remote MCP credential: the `dmk_` key in its own encrypted file
/// and the server-side key id in the settings JSON, so a later rotation can
/// name the key it is retiring.
///
/// The plaintext is deliberately NOT part of the settings JSON:
/// `save_settings` strips it, and `get_app_settings` is the one reader that
/// loads it back for the frontend, the way the local display tokens are.
pub fn store_mcp_remote_key(
&self,
key: &str,
key_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
Self::encrypt_to_file(&self.mcp_remote_key_file(), b"DBMRK", key)?;
let mut settings = self.load_settings()?;
settings.mcp_remote_key_id = Some(key_id.to_string());
self.save_settings(&settings)
}
/// The stored remote MCP credential, if any: the plaintext key and the id
/// the server knows it by.
///
/// Read with the id from the JSON and the key from its file, so the two
/// cannot disagree: a key file without an id (an interrupted store) still
/// yields the key, and an id without a key file yields nothing at all.
pub fn get_mcp_remote_key(
&self,
) -> Result<Option<StoredMcpRemoteKey>, Box<dyn std::error::Error>> {
let Some(key) = Self::decrypt_from_file(&self.mcp_remote_key_file(), b"DBMRK")? else {
return Ok(None);
};
let id = self.load_settings()?.mcp_remote_key_id;
Ok(Some(StoredMcpRemoteKey { key, id }))
}
/// Drop the remote MCP credential from this machine. Does not revoke it:
/// that is the caller's job, because only the caller knows whether it still
/// has a session to revoke with.
pub fn remove_mcp_remote_key(&self) -> Result<(), Box<dyn std::error::Error>> {
Self::remove_secret_file(&self.mcp_remote_key_file())?;
let mut settings = self.load_settings()?;
if settings.mcp_remote_key_id.take().is_some() {
self.save_settings(&settings)?;
}
Ok(())
}
@@ -732,6 +573,13 @@ pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result<AppSetting
.await
.map_err(|e| format!("Failed to load MCP token: {e}"))?;
// Same posture as the local tokens: shown so the fx export line can be
// copied, never persisted (see `SettingsManager::save_settings`).
settings.mcp_remote_key = manager
.get_mcp_remote_key()
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.map(|stored| stored.key);
Ok(settings)
}
@@ -742,6 +590,13 @@ pub async fn save_app_settings(
) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
// The remote MCP credential is minted by `rotate_mcp_remote_credential` and
// by nothing else. A settings read hands the frontend the plaintext (for the
// fx export line) and the frontend echoes the whole struct back, so the
// field is simply not the frontend's to write: whatever arrived is dropped
// here and the stored key is what the answer below carries.
settings.mcp_remote_key = None;
// Handle API token
if settings.api_enabled {
if let Some(ref token) = settings.api_token {
@@ -790,6 +645,23 @@ pub async fn save_app_settings(
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
// A running local server now answers on a URL the installed clients
// do not know, so they are rewritten. With the server off there is no
// URL to write yet; `McpServer::start` does this when it comes up.
if crate::mcp_server::McpServer::instance()
.get_port()
.is_some()
{
let failed =
crate::reinstall_mcp_agents(&app_handle, crate::mcp_integrations::McpEndpoint::Local)
.await;
if !failed.is_empty() {
log::warn!(
"[settings] Could not refresh the clients pointing at the local server: {}",
failed.join(", ")
);
}
}
}
}
}
@@ -802,14 +674,30 @@ pub async fn save_app_settings(
settings.mcp_token = None;
}
// Preserve server-managed flags that the frontend may not have up-to-date.
// Read directly from file to avoid load_settings' save-on-load behavior.
// Preserve the fields the frontend does not own. Read directly from the
// file to avoid load_settings' save-on-load behavior.
//
// `mcp_remote_enabled` is flipped ONLY by `start_mcp_remote_bridge` and
// `stop_mcp_remote_bridge`, which also start and stop the bridge task. A
// settings save that carried the flag could switch the internet-facing
// bridge on for the next launch without ever going through the sign-in and
// terms gates those commands enforce, or switch it off on disk while the
// task kept running. The key id is bookkeeping for the rotation path and is
// never the frontend's to write.
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.mcp_remote_enabled = current.mcp_remote_enabled;
settings.mcp_remote_key_id = current.mcp_remote_key_id;
}
} else {
settings.mcp_remote_enabled = false;
settings.mcp_remote_key_id = None;
}
settings.trash_retention_days =
crate::profile::trash::clamp_retention_days(settings.trash_retention_days);
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
persist_settings.mcp_token = None;
@@ -828,6 +716,14 @@ pub async fn save_app_settings(
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
// Answer with what a fresh read would show, the stored credential included,
// so a page that keeps the answer as its settings does not lose the fx
// export line on every save.
settings.mcp_remote_key = manager
.get_mcp_remote_key()
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.map(|stored| stored.key);
Ok(settings)
}
@@ -1198,6 +1094,9 @@ mod tests {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
mcp_remote_enabled: false,
mcp_remote_key: None,
mcp_remote_key_id: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
@@ -1205,6 +1104,7 @@ mod tests {
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
trash_retention_days: 14,
};
let save_result = manager.save_settings(&test_settings);
@@ -1222,6 +1122,84 @@ mod tests {
loaded_settings.theme, "dark",
"Loaded theme should match saved"
);
assert_eq!(loaded_settings.trash_retention_days, 14);
}
#[test]
fn trash_retention_defaults_when_the_settings_file_predates_it() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings_dir = manager.get_settings_dir();
create_dir_all(&settings_dir).unwrap();
fs::write(manager.get_settings_file(), r#"{"theme":"light"}"#).unwrap();
let loaded = manager.load_settings().unwrap();
assert_eq!(loaded.theme, "light");
assert_eq!(
loaded.trash_retention_days,
crate::profile::trash::DEFAULT_RETENTION_DAYS
);
}
#[test]
fn the_remote_key_round_trips_and_never_reaches_the_settings_json() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
assert!(manager.get_mcp_remote_key().unwrap().is_none());
manager
.store_mcp_remote_key("dmk_abcdefghijklmnop", "key-1")
.unwrap();
let stored = manager.get_mcp_remote_key().unwrap().expect("stored");
assert_eq!(stored.key, "dmk_abcdefghijklmnop");
assert_eq!(stored.id.as_deref(), Some("key-1"));
// The id is bookkeeping and belongs in the JSON; the key is a credential
// that works from anywhere on the internet and must not.
let json = std::fs::read_to_string(manager.get_settings_file()).unwrap();
assert!(json.contains("\"mcp_remote_key_id\": \"key-1\""), "{json}");
assert!(!json.contains("dmk_abcdefghijklmnop"), "{json}");
assert!(!json.contains("\"mcp_remote_key\""), "{json}");
// A struct loaded for the frontend carries the plaintext (the fx export
// line needs it), so the write path is what keeps it off the disk: saving
// such a struct must not plant the key in the JSON.
let mut settings = manager.load_settings().unwrap();
settings.mcp_remote_key = Some("dmk_abcdefghijklmnop".to_string());
manager.save_settings(&settings).unwrap();
let json = std::fs::read_to_string(manager.get_settings_file()).unwrap();
assert!(!json.contains("dmk_"), "{json}");
assert!(!json.contains("\"mcp_remote_key\""), "{json}");
assert_eq!(
manager
.load_settings()
.unwrap()
.mcp_remote_key_id
.as_deref(),
Some("key-1")
);
manager.remove_mcp_remote_key().unwrap();
assert!(manager.get_mcp_remote_key().unwrap().is_none());
assert!(manager.load_settings().unwrap().mcp_remote_key_id.is_none());
}
#[test]
fn a_key_file_without_an_id_still_yields_the_key() {
// An interrupted store, or a settings file rewritten by an older build
// that did not know the field: the credential is still on disk and still
// valid, so it must still be usable. Only the id is missing, and the
// rotation path treats a missing id as "nothing to revoke".
let (manager, _temp_dir, _guard) = create_test_settings_manager();
manager
.store_mcp_remote_key("dmk_zzzzzzzzzzzz", "key-2")
.unwrap();
let mut settings = manager.load_settings().unwrap();
settings.mcp_remote_key_id = None;
manager.save_settings(&settings).unwrap();
let stored = manager.get_mcp_remote_key().unwrap().expect("stored");
assert_eq!(stored.key, "dmk_zzzzzzzzzzzz");
assert!(stored.id.is_none());
}
#[test]