crypto_service.rs
142 lignes · 5453 octets
use crate::error::AppError; use aes_gcm::{ AeadCore, Aes256Gcm, Nonce, aead::{Aead, KeyInit, OsRng}, }; use base64::{Engine, engine::general_purpose::STANDARD as B64}; use std::env; const ENCRYPTED_PREFIX: &str = "enc:"; /// Service providing AES-256-GCM encryption and decryption for sensitive settings. pub struct CryptoService; impl CryptoService { fn get_key() -> Result<[u8; 32], AppError> { let key_hex = env::var("ENCRYPTION_KEY") .or_else(|_| env::var("OAUTH_ENCRYPTION_KEY")) .map_err(|_| { AppError::Internal( "ENCRYPTION_KEY (or OAUTH_ENCRYPTION_KEY) is not set. A 64-character hex key is required for encryption.".to_string(), ) })?; let bytes = hex::decode(key_hex.trim()) .map_err(|e| AppError::Internal(format!("ENCRYPTION_KEY is not valid hex: {}", e)))?; if bytes.len() != 32 { return Err(AppError::Internal(format!( "ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got {} bytes", bytes.len() ))); } let mut key = [0u8; 32]; key.copy_from_slice(&bytes); Ok(key) } /// Encrypt a plaintext string. Returns `enc:<base64(nonce || ciphertext)>`. pub fn encrypt(plaintext: &str) -> Result<String, AppError> { let key_bytes = Self::get_key()?; let cipher = Aes256Gcm::new_from_slice(&key_bytes) .map_err(|e| AppError::Internal(format!("Failed to create cipher: {}", e)))?; let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; let mut combined = nonce.to_vec(); combined.extend_from_slice(&ciphertext); Ok(format!("{}{}", ENCRYPTED_PREFIX, B64.encode(&combined))) } /// Decrypt a value previously encrypted by [`Self::encrypt`]. Handles both /// `enc:` prefixed (encrypted) and plain text (legacy/migration) values. pub fn decrypt(value: &str) -> Result<String, AppError> { let encoded = match value.strip_prefix(ENCRYPTED_PREFIX) { Some(s) => s, None => return Ok(value.to_string()), }; let key_bytes = Self::get_key()?; let cipher = Aes256Gcm::new_from_slice(&key_bytes) .map_err(|e| AppError::Internal(format!("Failed to create cipher: {}", e)))?; let combined = B64 .decode(encoded) .map_err(|e| AppError::Internal(format!("Failed to decode encrypted value: {}", e)))?; if combined.len() < 12 { return Err(AppError::Internal("Encrypted value too short".to_string())); } let (nonce_bytes, ciphertext) = combined.split_at(12); let nonce_array: [u8; 12] = nonce_bytes .try_into() .map_err(|_| AppError::Internal("Invalid nonce length".to_string()))?; let nonce = Nonce::from(nonce_array); let plaintext = cipher.decrypt(&nonce, ciphertext).map_err(|_| { AppError::Internal("Decryption failed – wrong key or corrupted data".to_string()) })?; String::from_utf8(plaintext) .map_err(|e| AppError::Internal(format!("Decrypted value is not valid UTF-8: {}", e))) } /// Returns true if an encryption key is configured. pub fn is_available() -> bool { env::var("ENCRYPTION_KEY").is_ok() || env::var("OAUTH_ENCRYPTION_KEY").is_ok() } /// Returns true if the value is already encrypted (has the `enc:` prefix). pub fn is_encrypted(value: &str) -> bool { value.starts_with(ENCRYPTED_PREFIX) } } #[cfg(test)] mod tests { use super::*; /// Roundtrip encryption / decryption with a fixed test key. /// /// Mutating `OAUTH_ENCRYPTION_KEY` via `env::set_var` is **process-wide**. /// Other tests in this binary (notably in `totp_service`) also write /// `ENCRYPTION_KEY`, and any concurrent reader of either variable can /// observe the wrong value mid-flight. The previous "race is benign" /// comment was incorrect: an intermittent failure was reported on /// 2026-05-18 when this test ran alongside the broader workspace suite. /// /// The `#[serial(encryption_key)]` attribute (from `serial_test`) /// serialises every test annotated with the same key, eliminating the /// race without requiring `--test-threads=1`. #[test] #[serial_test::serial(encryption_key)] fn test_roundtrip() { // SAFETY: env mutation is serialised across the suite by // `#[serial(encryption_key)]`, so no other thread reads the var // while this block runs. unsafe { env::set_var( "OAUTH_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); } let secret = "my-super-secret-client-key"; let encrypted = CryptoService::encrypt(secret).unwrap(); assert!(encrypted.starts_with("enc:")); let decrypted = CryptoService::decrypt(&encrypted).unwrap(); assert_eq!(decrypted, secret); } #[test] fn test_plaintext_passthrough() { let plain = "not-encrypted-value"; let result = CryptoService::decrypt(plain).unwrap(); assert_eq!(result, plain); } }
GitRust