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 <noreply@anthropic.com>
This commit is contained in:
parent
9e8db9dc81
commit
f0358cfb13
@ -81,6 +81,59 @@ pub fn open_dev_db(path: &Path) -> anyhow::Result<Connection> {
|
|||||||
Ok(conn)
|
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<Connection> {
|
||||||
|
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<Connection> {
|
pub fn init_db(data_dir: &Path) -> anyhow::Result<Connection> {
|
||||||
std::fs::create_dir_all(data_dir)?;
|
std::fs::create_dir_all(data_dir)?;
|
||||||
let db_path = data_dir.join("trcaa.db");
|
let db_path = data_dir.join("trcaa.db");
|
||||||
@ -90,7 +143,20 @@ pub fn init_db(data_dir: &Path) -> anyhow::Result<Connection> {
|
|||||||
let conn = if cfg!(debug_assertions) {
|
let conn = if cfg!(debug_assertions) {
|
||||||
open_dev_db(&db_path)?
|
open_dev_db(&db_path)?
|
||||||
} else {
|
} 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)?;
|
crate::db::migrations::run_migrations(&conn)?;
|
||||||
@ -102,13 +168,22 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
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();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_db_key_uses_env_var_when_present() {
|
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");
|
let dir = temp_dir("env-var");
|
||||||
std::env::set_var("TFTSR_DB_KEY", "test-db-key");
|
std::env::set_var("TFTSR_DB_KEY", "test-db-key");
|
||||||
let key = get_db_key(&dir).unwrap();
|
let key = get_db_key(&dir).unwrap();
|
||||||
@ -118,10 +193,43 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_db_key_debug_fallback_for_empty_env() {
|
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");
|
let dir = temp_dir("empty-env");
|
||||||
std::env::set_var("TFTSR_DB_KEY", " ");
|
std::env::set_var("TFTSR_DB_KEY", " ");
|
||||||
let key = get_db_key(&dir).unwrap();
|
let key = get_db_key(&dir).unwrap();
|
||||||
assert_eq!(key, "dev-key-change-in-prod");
|
assert_eq!(key, "dev-key-change-in-prod");
|
||||||
std::env::remove_var("TFTSR_DB_KEY");
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,7 +179,60 @@ fn get_encryption_key_material() -> Result<String, String> {
|
|||||||
return Ok("dev-key-change-me-in-production-32b".to_string());
|
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> {
|
fn derive_aes_key() -> Result<[u8; 32], String> {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ pub fn run() {
|
|||||||
tracing::info!("Starting Troubleshooting and RCA Assistant application");
|
tracing::info!("Starting Troubleshooting and RCA Assistant application");
|
||||||
|
|
||||||
// Determine data directory
|
// 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
|
// Initialize database
|
||||||
let conn = db::connection::init_db(&data_dir).expect("Failed to 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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error running Troubleshooting and RCA Assistant application");
|
.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")
|
|
||||||
}
|
|
||||||
|
|||||||
@ -72,3 +72,49 @@ pub struct AppState {
|
|||||||
/// These windows stay open for the user to browse and for fresh cookie extraction
|
/// These windows stay open for the user to browse and for fresh cookie extraction
|
||||||
pub integration_webviews: Arc<Mutex<HashMap<String, String>>>,
|
pub integration_webviews: Arc<Mutex<HashMap<String, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determine the application data directory.
|
||||||
|
/// Returns None if the directory cannot be determined.
|
||||||
|
pub fn get_app_data_dir() -> Option<PathBuf> {
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user