use std::fs; use std::path::Path; use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use base64::{Engine, engine::general_purpose::STANDARD}; use rand::RngCore; use crate::config::AppConfig; #[derive(Clone)] pub struct SessionCrypto { key: [u8; 32], } impl SessionCrypto { pub fn load_or_create(config: &AppConfig) -> Result { if let Some(raw) = config .session_encryption_key .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) { let decoded = STANDARD .decode(raw) .map_err(|e| format!("SESSION_ENCRYPTION_KEY must be base64: {e}"))?; if decoded.len() != 32 { return Err("SESSION_ENCRYPTION_KEY must decode to exactly 32 bytes".to_string()); } let mut key = [0u8; 32]; key.copy_from_slice(&decoded); return Ok(Self { key }); } if config.cluster_mode || config.database_backend != "sqlite" || config.session_backend == "redis" { return Err( "SESSION_ENCRYPTION_KEY is required for shared/cluster session storage".to_string(), ); } fs::create_dir_all(&config.data_dir) .map_err(|e| format!("failed to create data_dir: {e}"))?; let path = Path::new(&config.data_dir).join("session.key"); if path.is_file() { let raw = fs::read(&path).map_err(|e| format!("failed to read session key: {e}"))?; if raw.len() != 32 { return Err(format!("invalid session key length in {}", path.display())); } let mut key = [0u8; 32]; key.copy_from_slice(&raw); return Ok(Self { key }); } let mut key = [0u8; 32]; rand::rng().fill_bytes(&mut key); #[cfg(unix)] { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut file = fs::OpenOptions::new() .create_new(true) .write(true) .mode(0o600) .open(&path) .map_err(|e| format!("failed to create session key: {e}"))?; file.write_all(&key) .map_err(|e| format!("failed to write session key: {e}"))?; } #[cfg(not(unix))] fs::write(&path, key).map_err(|e| format!("failed to write session key: {e}"))?; Ok(Self { key }) } pub fn encrypt(&self, plaintext: &str) -> Result { let cipher = Aes256Gcm::new_from_slice(&self.key).map_err(|_| "invalid session key".to_string())?; let mut nonce_bytes = [0u8; 12]; rand::rng().fill_bytes(&mut nonce_bytes); let ciphertext = cipher .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes()) .map_err(|_| "failed to encrypt session credential".to_string())?; let mut combined = nonce_bytes.to_vec(); combined.extend_from_slice(&ciphertext); Ok(STANDARD.encode(combined)) } pub fn decrypt(&self, encoded: &str) -> Result { let raw = STANDARD .decode(encoded) .map_err(|e| format!("invalid encrypted session credential: {e}"))?; if raw.len() < 13 { return Err("invalid encrypted session credential".to_string()); } let (nonce, ciphertext) = raw.split_at(12); let cipher = Aes256Gcm::new_from_slice(&self.key).map_err(|_| "invalid session key".to_string())?; let plaintext = cipher .decrypt(Nonce::from_slice(nonce), ciphertext) .map_err(|_| "failed to decrypt session credential".to_string())?; String::from_utf8(plaintext) .map_err(|e| format!("invalid UTF-8 in session credential: {e}")) } }