diff --git a/src/session/config.rs b/src/session/config.rs index db6ef6d..39623a2 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -1,7 +1,14 @@ use std::{fs, path::PathBuf}; use anyhow::{Context, Result}; +use argon2::Argon2; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit}, +}; use directories::BaseDirs; +use rand::{RngCore, rngs::OsRng}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -344,26 +351,33 @@ impl ConfigStore { } let mut cache = if path.exists() { - let raw = fs::read_to_string(&path) + let raw_bytes = fs::read(&path) .with_context(|| format!("failed to read {}", path.display()))?; - match serde_json::from_str::(&raw) { + let hardware_uuid = get_hardware_uuid(); + match decrypt_config(&raw_bytes, &hardware_uuid) { Ok(cache) => cache, - Err(err) => { - let backup_path = path.with_extension("json.bak"); - if let Err(backup_err) = fs::write(&backup_path, raw.as_bytes()) { - tracing::warn!( - "failed to parse config {}; backup to {} also failed: {backup_err:#}; parse error: {err:#}", - path.display(), - backup_path.display(), - ); - } else { - tracing::warn!( - "failed to parse config {}; backed up the original to {} and loaded defaults: {err:#}", - path.display(), - backup_path.display(), - ); + Err(decrypt_err) => { + // Fallback to plain text JSON if decryption/parsing failed + match serde_json::from_slice::(&raw_bytes) { + Ok(cache) => cache, + Err(json_err) => { + let backup_path = path.with_extension("json.bak"); + if let Err(backup_err) = fs::write(&backup_path, &raw_bytes) { + tracing::warn!( + "failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backup to {} also failed: {backup_err:#}", + path.display(), + backup_path.display(), + ); + } else { + tracing::warn!( + "failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backed up the original to {} and loaded defaults", + path.display(), + backup_path.display(), + ); + } + ConfigFile::default() + } } - ConfigFile::default() } } } else { @@ -758,8 +772,9 @@ impl ConfigStore { if self.path.as_os_str().is_empty() { return Ok(()); } - let raw = serde_json::to_string_pretty(&self.cache)?; - fs::write(&self.path, raw) + let hardware_uuid = get_hardware_uuid(); + let encrypted_bytes = encrypt_config(&self.cache, &hardware_uuid)?; + fs::write(&self.path, encrypted_bytes) .with_context(|| format!("failed to write {}", self.path.display()))?; #[cfg(unix)] @@ -959,3 +974,181 @@ pub fn active_proxy(session: &Session) -> Option<(String, String, Option)> None } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EncryptedConfigEnvelope { + format_version: u32, + kdf: String, + cipher: String, + salt: String, + nonce: String, + payload: String, +} + +fn get_hardware_uuid() -> String { + #[cfg(target_os = "macos")] + { + if let Ok(output) = std::process::Command::new("ioreg") + .args(&["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("IOPlatformUUID") { + if let Some(uuid) = line.split('"').nth(3) { + let uuid = uuid.trim().to_string(); + if !uuid.is_empty() { + return uuid; + } + } + } + } + } + } + + #[cfg(target_os = "linux")] + { + if let Ok(uuid) = std::fs::read_to_string("/sys/class/dmi/id/product_uuid") { + let uuid = uuid.trim().to_string(); + if !uuid.is_empty() { + return uuid; + } + } + if let Ok(id) = std::fs::read_to_string("/etc/machine-id") { + let id = id.trim().to_string(); + if !id.is_empty() { + return id; + } + } + if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") { + let id = id.trim().to_string(); + if !id.is_empty() { + return id; + } + } + } + + #[cfg(target_os = "windows")] + { + if let Ok(output) = std::process::Command::new("reg") + .args(&["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("MachineGuid") { + if let Some(guid) = line.split_whitespace().last() { + let guid = guid.trim().to_string(); + if !guid.is_empty() { + return guid; + } + } + } + } + } + if let Ok(output) = std::process::Command::new("wmic") + .args(&["csproduct", "get", "uuid"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + if lines.len() >= 2 { + let uuid = lines[1].trim().to_string(); + if !uuid.is_empty() { + return uuid; + } + } + } + } + + "ashell-default-hardware-uuid-fallback".to_string() +} + +fn encrypt_config(config: &ConfigFile, password: &str) -> Result> { + let mut salt = [0u8; 16]; + let mut nonce = [0u8; 24]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce); + + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(password.as_bytes(), &salt, &mut key) + .map_err(|err| anyhow::anyhow!("derive encryption key: {err}"))?; + + let plaintext = serde_json::to_vec(config).context("serialize config")?; + let ciphertext = XChaCha20Poly1305::new((&key).into()) + .encrypt(XNonce::from_slice(&nonce), plaintext.as_ref()) + .map_err(|_| anyhow::anyhow!("encrypt config payload"))?; + + serde_json::to_vec_pretty(&EncryptedConfigEnvelope { + format_version: 1, + kdf: "argon2id".to_string(), + cipher: "xchacha20poly1305".to_string(), + salt: STANDARD.encode(salt), + nonce: STANDARD.encode(nonce), + payload: STANDARD.encode(ciphertext), + }) + .context("serialize encrypted config envelope") +} + +fn decrypt_config(raw: &[u8], password: &str) -> Result { + let envelope: EncryptedConfigEnvelope = + serde_json::from_slice(raw).context("parse encrypted config envelope")?; + if envelope.format_version != 1 + || envelope.kdf != "argon2id" + || envelope.cipher != "xchacha20poly1305" + { + return Err(anyhow::anyhow!("unsupported encrypted config format")); + } + let salt = STANDARD.decode(envelope.salt).context("decode config salt")?; + let nonce = STANDARD + .decode(envelope.nonce) + .context("decode config nonce")?; + if nonce.len() != 24 { + return Err(anyhow::anyhow!("invalid config nonce")); + } + let ciphertext = STANDARD + .decode(envelope.payload) + .context("decode encrypted config payload")?; + + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(password.as_bytes(), &salt, &mut key) + .map_err(|err| anyhow::anyhow!("derive encryption key: {err}"))?; + + let plaintext = XChaCha20Poly1305::new((&key).into()) + .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref()) + .map_err(|_| anyhow::anyhow!("cannot decrypt config; hardware UUID mismatch or corrupted data"))?; + + serde_json::from_slice(&plaintext).context("parse decrypted config") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_hardware_uuid() { + let uuid = get_hardware_uuid(); + assert!(!uuid.is_empty()); + } + + #[test] + fn test_config_encryption_roundtrip() { + let config = ConfigFile::default(); + let password = "test-password-123"; + let encrypted = encrypt_config(&config, password).unwrap(); + + // Ensure it doesn't contain plain text fields of default config + let encrypted_str = String::from_utf8_lossy(&encrypted); + assert!(!encrypted_str.contains("Maple Mono NF CN")); + assert!(encrypted_str.contains("argon2id")); + + let decrypted = decrypt_config(&encrypted, password).unwrap(); + assert_eq!(decrypted.terminal_font_family, config.terminal_font_family); + + // Decrypt with wrong password should fail + assert!(decrypt_config(&encrypted, "wrong-password").is_err()); + } +} +