mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-16 22:55:44 +02:00
refactor: cleanup
This commit is contained in:
+333
-355
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user