From f0358cfb13937688dd02d98481b3a3c3042693a4 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Mon, 6 Apr 2026 17:21:31 -0500 Subject: [PATCH] fix(db,auth): auto-generate encryption keys for release builds Fixes two critical issues preventing Mac release builds from working: 1. Database encryption key auto-generation: Release builds now auto-generate and persist the SQLCipher encryption key to ~/.../trcaa/.dbkey (mode 0600) instead of requiring the TFTSR_DB_KEY env var. This prevents 'file is not a database' errors when users don't set the env var. 2. Plain SQLite to encrypted migration: When a release build encounters a plain SQLite database (from a previous debug build), it now automatically migrates it to encrypted SQLCipher format using ATTACH DATABASE + sqlcipher_export. Creates a backup at .db.plain-backup before migration. 3. Credential encryption key auto-generation: Applied the same pattern to TFTSR_ENCRYPTION_KEY for encrypting AI provider API keys and integration tokens. Release builds now auto-generate and persist to ~/.../trcaa/.enckey (mode 0600) instead of failing with 'TFTSR_ENCRYPTION_KEY must be set'. 4. Refactored app data directory helper: Moved dirs_data_dir() from lib.rs to state.rs as get_app_data_dir() so it can be reused by both database and auth modules. Testing: - All unit tests pass (db::connection::tests + integrations::auth::tests) - Verified manual migration from plain to encrypted database - No clippy warnings Impact: Users installing the Mac release build will now have a working app out-of-the-box without needing to set environment variables. Developers switching from debug to release builds will have their databases automatically migrated. Co-Authored-By: Claude Sonnet 4.5 --- src-tauri/src/db/connection.rs | 112 ++++++++++++++++++++++++++++- src-tauri/src/integrations/auth.rs | 55 +++++++++++++- src-tauri/src/lib.rs | 43 +---------- src-tauri/src/state.rs | 46 ++++++++++++ 4 files changed, 211 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index d19bbc8f..3b6fe83b 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -81,6 +81,59 @@ pub fn open_dev_db(path: &Path) -> anyhow::Result { Ok(conn) } +/// Migrates a plain SQLite database to an encrypted SQLCipher database. +/// Creates a backup of the original file before migration. +fn migrate_plain_to_encrypted(db_path: &Path, key: &str) -> anyhow::Result { + tracing::warn!("Detected plain SQLite database in release build - migrating to encrypted"); + + // Create backup of plain database + let backup_path = db_path.with_extension("db.plain-backup"); + std::fs::copy(db_path, &backup_path)?; + tracing::info!("Backed up plain database to {:?}", backup_path); + + // Open the plain database + let plain_conn = Connection::open(db_path)?; + + // Create temporary encrypted database path + let temp_encrypted = db_path.with_extension("db.encrypted-temp"); + + // Attach and migrate to encrypted database using SQLCipher export + plain_conn.execute_batch(&format!( + "ATTACH DATABASE '{}' AS encrypted KEY '{}';\ + PRAGMA encrypted.cipher_page_size = 16384;\ + PRAGMA encrypted.kdf_iter = 256000;\ + PRAGMA encrypted.cipher_hmac_algorithm = HMAC_SHA512;\ + PRAGMA encrypted.cipher_kdf_algorithm = PBKDF2_HMAC_SHA512;", + temp_encrypted.display(), + key.replace('\'', "''") + ))?; + + // Export all data to encrypted database + plain_conn.execute_batch("SELECT sqlcipher_export('encrypted');")?; + plain_conn.execute_batch("DETACH DATABASE encrypted;")?; + drop(plain_conn); + + // Replace original with encrypted version + std::fs::rename(&temp_encrypted, db_path)?; + tracing::info!("Successfully migrated database to encrypted format"); + + // Open and return the encrypted database + open_encrypted_db(db_path, key) +} + +/// Checks if a database file is plain SQLite by reading its header. +fn is_plain_sqlite(path: &Path) -> bool { + if let Ok(mut file) = std::fs::File::open(path) { + use std::io::Read; + let mut header = [0u8; 16]; + if file.read_exact(&mut header).is_ok() { + // SQLite databases start with "SQLite format 3\0" + return &header == b"SQLite format 3\0"; + } + } + false +} + pub fn init_db(data_dir: &Path) -> anyhow::Result { std::fs::create_dir_all(data_dir)?; let db_path = data_dir.join("trcaa.db"); @@ -90,7 +143,20 @@ pub fn init_db(data_dir: &Path) -> anyhow::Result { let conn = if cfg!(debug_assertions) { open_dev_db(&db_path)? } else { - open_encrypted_db(&db_path, &key)? + // In release mode, try encrypted first + match open_encrypted_db(&db_path, &key) { + Ok(conn) => conn, + Err(e) => { + // Check if error is due to trying to decrypt a plain SQLite database + if db_path.exists() && is_plain_sqlite(&db_path) { + // Auto-migrate from plain to encrypted + migrate_plain_to_encrypted(&db_path, &key)? + } else { + // Different error - propagate it + return Err(e); + } + } + } }; crate::db::migrations::run_migrations(&conn)?; @@ -102,13 +168,22 @@ mod tests { use super::*; fn temp_dir(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("tftsr-test-{}", name)); + use std::time::SystemTime; + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("tftsr-test-{}-{}", name, timestamp)); + // Clean up if it exists + let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn test_get_db_key_uses_env_var_when_present() { + // Remove any existing env var first + std::env::remove_var("TFTSR_DB_KEY"); let dir = temp_dir("env-var"); std::env::set_var("TFTSR_DB_KEY", "test-db-key"); let key = get_db_key(&dir).unwrap(); @@ -118,10 +193,43 @@ mod tests { #[test] fn test_get_db_key_debug_fallback_for_empty_env() { + // Remove any existing env var first + std::env::remove_var("TFTSR_DB_KEY"); let dir = temp_dir("empty-env"); std::env::set_var("TFTSR_DB_KEY", " "); let key = get_db_key(&dir).unwrap(); assert_eq!(key, "dev-key-change-in-prod"); std::env::remove_var("TFTSR_DB_KEY"); } + + #[test] + fn test_is_plain_sqlite_detects_plain_database() { + let dir = temp_dir("plain-detect"); + let db_path = dir.join("test.db"); + + // Create a plain SQLite database + let conn = Connection::open(&db_path).unwrap(); + conn.execute("CREATE TABLE test (id INTEGER)", []).unwrap(); + drop(conn); + + assert!(is_plain_sqlite(&db_path)); + } + + #[test] + fn test_is_plain_sqlite_rejects_encrypted() { + let dir = temp_dir("encrypted-detect"); + let db_path = dir.join("test.db"); + + // Create an encrypted database + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch( + "PRAGMA key = 'test-key';\ + PRAGMA cipher_page_size = 16384;", + ) + .unwrap(); + conn.execute("CREATE TABLE test (id INTEGER)", []).unwrap(); + drop(conn); + + assert!(!is_plain_sqlite(&db_path)); + } } diff --git a/src-tauri/src/integrations/auth.rs b/src-tauri/src/integrations/auth.rs index 2634a37f..0d0c64ce 100644 --- a/src-tauri/src/integrations/auth.rs +++ b/src-tauri/src/integrations/auth.rs @@ -179,7 +179,60 @@ fn get_encryption_key_material() -> Result { return Ok("dev-key-change-me-in-production-32b".to_string()); } - Err("TFTSR_ENCRYPTION_KEY must be set in release builds".to_string()) + // Release: load or auto-generate a per-installation encryption key + // stored in the app data directory, similar to the database key. + if let Some(app_data_dir) = crate::state::get_app_data_dir() { + let key_path = app_data_dir.join(".enckey"); + + // Try to load existing key + if key_path.exists() { + if let Ok(key) = std::fs::read_to_string(&key_path) { + let key = key.trim().to_string(); + if !key.is_empty() { + return Ok(key); + } + } + } + + // Generate and store new key + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let key = hex::encode(bytes); + + // Ensure directory exists + if let Err(e) = std::fs::create_dir_all(&app_data_dir) { + tracing::warn!("Failed to create app data directory: {}", e); + return Err(format!("Failed to create app data directory: {}", e)); + } + + // Write key with restricted permissions + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&key_path) + .map_err(|e| format!("Failed to write encryption key: {}", e))?; + f.write_all(key.as_bytes()) + .map_err(|e| format!("Failed to write encryption key: {}", e))?; + } + + #[cfg(not(unix))] + { + std::fs::write(&key_path, &key) + .map_err(|e| format!("Failed to write encryption key: {}", e))?; + } + + tracing::info!("Generated new encryption key at {:?}", key_path); + return Ok(key); + } + + Err("Failed to determine app data directory for encryption key storage".to_string()) } fn derive_aes_key() -> Result<[u8; 32], String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2054bd68..b4751cd8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,7 +26,7 @@ pub fn run() { tracing::info!("Starting Troubleshooting and RCA Assistant application"); // Determine data directory - let data_dir = dirs_data_dir(); + let data_dir = state::get_app_data_dir().expect("Failed to determine app data directory"); // Initialize database let conn = db::connection::init_db(&data_dir).expect("Failed to initialize database"); @@ -147,44 +147,3 @@ pub fn run() { .run(tauri::generate_context!()) .expect("Error running Troubleshooting and RCA Assistant application"); } - -/// Determine the application data directory. -fn dirs_data_dir() -> std::path::PathBuf { - if let Ok(dir) = std::env::var("TFTSR_DATA_DIR") { - return std::path::PathBuf::from(dir); - } - - // Use platform-appropriate data directory - #[cfg(target_os = "linux")] - { - if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - return std::path::PathBuf::from(xdg).join("trcaa"); - } - if let Ok(home) = std::env::var("HOME") { - return std::path::PathBuf::from(home) - .join(".local") - .join("share") - .join("trcaa"); - } - } - - #[cfg(target_os = "macos")] - { - if let Ok(home) = std::env::var("HOME") { - return std::path::PathBuf::from(home) - .join("Library") - .join("Application Support") - .join("trcaa"); - } - } - - #[cfg(target_os = "windows")] - { - if let Ok(appdata) = std::env::var("APPDATA") { - return std::path::PathBuf::from(appdata).join("trcaa"); - } - } - - // Fallback - std::path::PathBuf::from("./trcaa-data") -} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 97b51736..a0d7d211 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -72,3 +72,49 @@ pub struct AppState { /// These windows stay open for the user to browse and for fresh cookie extraction pub integration_webviews: Arc>>, } + +/// Determine the application data directory. +/// Returns None if the directory cannot be determined. +pub fn get_app_data_dir() -> Option { + if let Ok(dir) = std::env::var("TFTSR_DATA_DIR") { + return Some(PathBuf::from(dir)); + } + + // Use platform-appropriate data directory + #[cfg(target_os = "linux")] + { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + return Some(PathBuf::from(xdg).join("trcaa")); + } + if let Ok(home) = std::env::var("HOME") { + return Some( + PathBuf::from(home) + .join(".local") + .join("share") + .join("trcaa"), + ); + } + } + + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + return Some( + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("trcaa"), + ); + } + } + + #[cfg(target_os = "windows")] + { + if let Ok(appdata) = std::env::var("APPDATA") { + return Some(PathBuf::from(appdata).join("trcaa")); + } + } + + // Fallback + Some(PathBuf::from("./trcaa-data")) +}