Skip to main content

symbi_runtime/
crypto.rs

1//! Cryptographic utilities for Symbiont
2//!
3//! This module provides encryption and decryption capabilities using industry-standard
4//! algorithms like AES-256-GCM for symmetric encryption and Argon2 for key derivation.
5
6use aes_gcm::{
7    aead::{Aead, AeadCore, KeyInit, OsRng},
8    Aes256Gcm, Key, Nonce,
9};
10use argon2::{
11    password_hash::{rand_core::RngCore, PasswordHasher, SaltString},
12    Argon2, Params,
13};
14use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use thiserror::Error;
18
19/// Errors that can occur during cryptographic operations
20#[derive(Debug, Error)]
21pub enum CryptoError {
22    /// Invalid key format or length
23    #[error("Invalid key: {message}")]
24    InvalidKey { message: String },
25
26    /// Encryption operation failed
27    #[error("Encryption failed: {message}")]
28    EncryptionFailed { message: String },
29
30    /// Decryption operation failed
31    #[error("Decryption failed: {message}")]
32    DecryptionFailed { message: String },
33
34    /// Key derivation failed
35    #[error("Key derivation failed: {message}")]
36    KeyDerivationFailed { message: String },
37
38    /// Invalid ciphertext format
39    #[error("Invalid ciphertext format: {message}")]
40    InvalidCiphertext { message: String },
41
42    /// Base64 encoding/decoding error
43    #[error("Base64 error: {message}")]
44    Base64Error { message: String },
45}
46
47/// Encrypted data container
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EncryptedData {
50    /// Base64-encoded ciphertext
51    pub ciphertext: String,
52    /// Base64-encoded nonce/IV
53    pub nonce: String,
54    /// Base64-encoded salt used for key derivation
55    pub salt: String,
56    /// Algorithm used for encryption
57    pub algorithm: String,
58    /// Key derivation function used
59    pub kdf: String,
60}
61
62impl fmt::Display for EncryptedData {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(f, "EncryptedData(algorithm={})", self.algorithm)
65    }
66}
67
68/// AES-256-GCM encryption/decryption utilities
69pub struct Aes256GcmCrypto;
70
71impl Default for Aes256GcmCrypto {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl Aes256GcmCrypto {
78    /// Create a new Aes256GcmCrypto instance
79    pub fn new() -> Self {
80        Self
81    }
82
83    /// Encrypt data using AES-256-GCM with a direct key (for CLI usage)
84    pub fn encrypt(&self, plaintext: &[u8], key: &str) -> Result<Vec<u8>, CryptoError> {
85        // Decode the base64 key
86        let key_bytes = BASE64.decode(key).map_err(|e| CryptoError::InvalidKey {
87            message: format!("Invalid base64 key: {}", e),
88        })?;
89
90        if key_bytes.len() != 32 {
91            return Err(CryptoError::InvalidKey {
92                message: "Key must be 32 bytes".to_string(),
93            });
94        }
95
96        let cipher_key = Key::<Aes256Gcm>::from_slice(&key_bytes);
97        let cipher = Aes256Gcm::new(cipher_key);
98
99        // Generate random nonce
100        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
101
102        // Encrypt
103        let ciphertext =
104            cipher
105                .encrypt(&nonce, plaintext)
106                .map_err(|e| CryptoError::EncryptionFailed {
107                    message: e.to_string(),
108                })?;
109
110        // Combine nonce + ciphertext
111        let mut result = Vec::with_capacity(12 + ciphertext.len());
112        result.extend_from_slice(&nonce);
113        result.extend_from_slice(&ciphertext);
114
115        Ok(result)
116    }
117
118    /// Decrypt data using AES-256-GCM with a direct key (for CLI usage)
119    pub fn decrypt(&self, encrypted_data: &[u8], key: &str) -> Result<Vec<u8>, CryptoError> {
120        if encrypted_data.len() < 12 {
121            return Err(CryptoError::InvalidCiphertext {
122                message: "Encrypted data too short".to_string(),
123            });
124        }
125
126        // Decode the base64 key
127        let key_bytes = BASE64.decode(key).map_err(|e| CryptoError::InvalidKey {
128            message: format!("Invalid base64 key: {}", e),
129        })?;
130
131        if key_bytes.len() != 32 {
132            return Err(CryptoError::InvalidKey {
133                message: "Key must be 32 bytes".to_string(),
134            });
135        }
136
137        let cipher_key = Key::<Aes256Gcm>::from_slice(&key_bytes);
138        let cipher = Aes256Gcm::new(cipher_key);
139
140        // Extract nonce and ciphertext
141        let (nonce_bytes, ciphertext) = encrypted_data.split_at(12);
142        let nonce = Nonce::from_slice(nonce_bytes);
143
144        // Decrypt
145        let plaintext =
146            cipher
147                .decrypt(nonce, ciphertext)
148                .map_err(|e| CryptoError::DecryptionFailed {
149                    message: e.to_string(),
150                })?;
151
152        Ok(plaintext)
153    }
154
155    /// Encrypt data using AES-256-GCM with Argon2 key derivation (original method)
156    pub fn encrypt_with_password(
157        plaintext: &[u8],
158        password: &str,
159    ) -> Result<EncryptedData, CryptoError> {
160        // Generate random salt
161        let mut salt = [0u8; 32];
162        OsRng.fill_bytes(&mut salt);
163        let salt_string =
164            SaltString::encode_b64(&salt).map_err(|e| CryptoError::KeyDerivationFailed {
165                message: e.to_string(),
166            })?;
167
168        // Derive key using Argon2id with explicit parameters:
169        // - 19 MiB memory cost (OWASP minimum recommendation)
170        // - 2 iterations
171        // - 1 degree of parallelism
172        let params = Params::new(19 * 1024, 2, 1, Some(32)).map_err(|e| {
173            CryptoError::KeyDerivationFailed {
174                message: format!("Invalid Argon2 parameters: {}", e),
175            }
176        })?;
177        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
178        let password_hash = argon2
179            .hash_password(password.as_bytes(), &salt_string)
180            .map_err(|e| CryptoError::KeyDerivationFailed {
181                message: e.to_string(),
182            })?;
183
184        // Extract the hash bytes for the encryption key
185        let hash_binding = password_hash
186            .hash
187            .ok_or_else(|| CryptoError::KeyDerivationFailed {
188                message: "Password hash generation returned None".to_string(),
189            })?;
190        let key_bytes = hash_binding.as_bytes();
191        if key_bytes.len() < 32 {
192            return Err(CryptoError::InvalidKey {
193                message: "Derived key too short".to_string(),
194            });
195        }
196
197        let key = Key::<Aes256Gcm>::from_slice(&key_bytes[..32]);
198        let cipher = Aes256Gcm::new(key);
199
200        // Generate random nonce
201        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
202
203        // Encrypt
204        let ciphertext =
205            cipher
206                .encrypt(&nonce, plaintext)
207                .map_err(|e| CryptoError::EncryptionFailed {
208                    message: e.to_string(),
209                })?;
210
211        Ok(EncryptedData {
212            ciphertext: BASE64.encode(&ciphertext),
213            nonce: BASE64.encode(nonce),
214            salt: BASE64.encode(salt),
215            algorithm: "AES-256-GCM".to_string(),
216            kdf: "Argon2".to_string(),
217        })
218    }
219
220    /// Decrypt data using AES-256-GCM with Argon2 key derivation (static method)
221    pub fn decrypt_with_password(
222        encrypted_data: &EncryptedData,
223        password: &str,
224    ) -> Result<Vec<u8>, CryptoError> {
225        // Decode base64 components
226        let ciphertext =
227            BASE64
228                .decode(&encrypted_data.ciphertext)
229                .map_err(|e| CryptoError::Base64Error {
230                    message: e.to_string(),
231                })?;
232
233        let nonce_bytes =
234            BASE64
235                .decode(&encrypted_data.nonce)
236                .map_err(|e| CryptoError::Base64Error {
237                    message: e.to_string(),
238                })?;
239
240        let salt = BASE64
241            .decode(&encrypted_data.salt)
242            .map_err(|e| CryptoError::Base64Error {
243                message: e.to_string(),
244            })?;
245
246        // Reconstruct salt string
247        let salt_string =
248            SaltString::encode_b64(&salt).map_err(|e| CryptoError::KeyDerivationFailed {
249                message: e.to_string(),
250            })?;
251
252        // Derive key using the same Argon2id parameters as encryption
253        let params = Params::new(19 * 1024, 2, 1, Some(32)).map_err(|e| {
254            CryptoError::KeyDerivationFailed {
255                message: format!("Invalid Argon2 parameters: {}", e),
256            }
257        })?;
258        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
259        let password_hash = argon2
260            .hash_password(password.as_bytes(), &salt_string)
261            .map_err(|e| CryptoError::KeyDerivationFailed {
262                message: e.to_string(),
263            })?;
264
265        let hash_binding = password_hash
266            .hash
267            .ok_or_else(|| CryptoError::KeyDerivationFailed {
268                message: "Password hash generation returned None".to_string(),
269            })?;
270        let key_bytes = hash_binding.as_bytes();
271        if key_bytes.len() < 32 {
272            return Err(CryptoError::InvalidKey {
273                message: "Derived key too short".to_string(),
274            });
275        }
276
277        let key = Key::<Aes256Gcm>::from_slice(&key_bytes[..32]);
278        let cipher = Aes256Gcm::new(key);
279
280        // Create nonce
281        if nonce_bytes.len() != 12 {
282            return Err(CryptoError::InvalidCiphertext {
283                message: "Invalid nonce length".to_string(),
284            });
285        }
286        let nonce = Nonce::from_slice(&nonce_bytes);
287
288        // Decrypt
289        let plaintext = cipher.decrypt(nonce, ciphertext.as_ref()).map_err(|e| {
290            CryptoError::DecryptionFailed {
291                message: e.to_string(),
292            }
293        })?;
294
295        Ok(plaintext)
296    }
297}
298
299/// Utilities for key management
300pub struct KeyUtils;
301
302impl Default for KeyUtils {
303    fn default() -> Self {
304        Self::new()
305    }
306}
307
308impl KeyUtils {
309    /// Create a new KeyUtils instance
310    pub fn new() -> Self {
311        Self
312    }
313
314    /// Get or create a key, prioritizing keychain, then environment, then generating new
315    ///
316    /// # Security Warning
317    ///
318    /// This method will generate a new encryption key if none is found. This can lead to
319    /// data loss if you have existing encrypted data that was encrypted with a different key.
320    ///
321    /// Key priority order:
322    /// 1. System keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
323    /// 2. Environment variable SYMBIONT_MASTER_KEY
324    /// 3. Generate new random key (⚠️ will make old encrypted data unrecoverable)
325    ///
326    /// # Recommendations
327    ///
328    /// For production deployments:
329    /// - Use a secrets management system (HashiCorp Vault, AWS Secrets Manager)
330    /// - Set SYMBIONT_MASTER_KEY environment variable with a secure key
331    /// - Ensure proper key backup and rotation procedures
332    pub fn get_or_create_key(&self) -> Result<String, CryptoError> {
333        // Try keychain first
334        if let Ok(key) = self.get_key_from_keychain("symbiont", "secrets") {
335            tracing::debug!("Using encryption key from system keychain");
336            return Ok(key);
337        }
338
339        // Try environment variable
340        if let Ok(key) = Self::get_key_from_env("SYMBIONT_MASTER_KEY") {
341            tracing::info!("Using encryption key from SYMBIONT_MASTER_KEY environment variable");
342            return Ok(key);
343        }
344
345        // ⚠️ SECURITY WARNING: No existing key found, generating new one
346        tracing::warn!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
347        tracing::warn!("⚠️  SECURITY WARNING: No encryption key found!");
348        tracing::warn!("⚠️  Generating a new random encryption key.");
349        tracing::warn!("⚠️  If you have existing encrypted data, it will be UNRECOVERABLE!");
350        tracing::warn!("⚠️  The new key will be stored in the system keychain.");
351        tracing::warn!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
352
353        eprintln!("\n⚠️  CRITICAL SECURITY WARNING:");
354        eprintln!("⚠️  No encryption key found in keychain or environment!");
355        eprintln!("⚠️  Generating new random key - existing encrypted data will be lost!");
356        eprintln!("⚠️  Set SYMBIONT_MASTER_KEY environment variable to use a specific key.\n");
357
358        // Generate a new key
359        let new_key = self.generate_key();
360
361        // Try to store in keychain with clear error handling
362        match self.store_key_in_keychain("symbiont", "secrets", &new_key) {
363            Ok(_) => {
364                tracing::info!("✓ New encryption key stored in system keychain");
365                eprintln!("✓ New encryption key stored in system keychain");
366            }
367            Err(e) => {
368                tracing::error!("✗ Failed to store key in keychain: {}", e);
369                eprintln!("✗ ERROR: Failed to store key in keychain: {}", e);
370                eprintln!("✗ You MUST set SYMBIONT_MASTER_KEY environment variable.");
371                eprintln!("✗ The generated key has been used but could not be persisted.");
372
373                // Return error in production-like scenarios
374                if std::env::var("SYMBIONT_ENV").unwrap_or_default() == "production" {
375                    return Err(CryptoError::InvalidKey {
376                        message: format!(
377                            "Failed to store encryption key in production mode: {}",
378                            e
379                        ),
380                    });
381                }
382            }
383        }
384
385        Ok(new_key)
386    }
387
388    /// Generate a new random key
389    pub fn generate_key(&self) -> String {
390        use base64::Engine;
391        let mut key_bytes = [0u8; 32];
392        OsRng.fill_bytes(&mut key_bytes);
393        BASE64.encode(key_bytes)
394    }
395
396    /// Store a key in the OS keychain
397    #[cfg(feature = "keychain")]
398    fn store_key_in_keychain(
399        &self,
400        service: &str,
401        account: &str,
402        key: &str,
403    ) -> Result<(), CryptoError> {
404        use keyring::Entry;
405
406        let entry = Entry::new(service, account).map_err(|e| CryptoError::InvalidKey {
407            message: format!("Failed to create keychain entry: {}", e),
408        })?;
409
410        entry
411            .set_password(key)
412            .map_err(|e| CryptoError::InvalidKey {
413                message: format!("Failed to store in keychain: {}", e),
414            })
415    }
416
417    #[cfg(not(feature = "keychain"))]
418    fn store_key_in_keychain(
419        &self,
420        _service: &str,
421        _account: &str,
422        _key: &str,
423    ) -> Result<(), CryptoError> {
424        Err(CryptoError::InvalidKey {
425            message: "Keychain support not enabled. Compile with 'keychain' feature.".to_string(),
426        })
427    }
428
429    /// Retrieve a key from environment variable
430    pub fn get_key_from_env(env_var: &str) -> Result<String, CryptoError> {
431        std::env::var(env_var).map_err(|_| CryptoError::InvalidKey {
432            message: format!("Environment variable {} not found", env_var),
433        })
434    }
435
436    /// Retrieve a key from OS keychain (cross-platform)
437    #[cfg(feature = "keychain")]
438    pub fn get_key_from_keychain(
439        &self,
440        service: &str,
441        account: &str,
442    ) -> Result<String, CryptoError> {
443        use keyring::Entry;
444
445        let entry = Entry::new(service, account).map_err(|e| CryptoError::InvalidKey {
446            message: format!("Failed to create keychain entry: {}", e),
447        })?;
448
449        entry.get_password().map_err(|e| CryptoError::InvalidKey {
450            message: format!("Failed to retrieve from keychain: {}", e),
451        })
452    }
453
454    #[cfg(not(feature = "keychain"))]
455    pub fn get_key_from_keychain(
456        &self,
457        _service: &str,
458        _account: &str,
459    ) -> Result<String, CryptoError> {
460        Err(CryptoError::InvalidKey {
461            message: "Keychain support not enabled. Compile with 'keychain' feature.".to_string(),
462        })
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_encrypt_decrypt_roundtrip() {
472        let plaintext = b"Hello, world!";
473        let password = "test1"; // Test password
474
475        let encrypted = Aes256GcmCrypto::encrypt_with_password(plaintext, password).unwrap();
476        let decrypted = Aes256GcmCrypto::decrypt_with_password(&encrypted, password).unwrap();
477
478        assert_eq!(plaintext, decrypted.as_slice());
479    }
480
481    #[test]
482    fn test_encrypt_decrypt_wrong_password() {
483        let plaintext = b"Hello, world!";
484        let password = "test1"; // Test password
485        let wrong_password = "wrong1"; // Wrong test password
486
487        let encrypted = Aes256GcmCrypto::encrypt_with_password(plaintext, password).unwrap();
488        let result = Aes256GcmCrypto::decrypt_with_password(&encrypted, wrong_password);
489
490        assert!(result.is_err());
491    }
492
493    #[test]
494    fn test_direct_encrypt_decrypt_roundtrip() {
495        let plaintext = b"Hello, world!";
496        let key_utils = KeyUtils::new();
497        let key = key_utils.generate_key();
498
499        let crypto = Aes256GcmCrypto::new();
500        let encrypted = crypto.encrypt(plaintext, &key).unwrap();
501        let decrypted = crypto.decrypt(&encrypted, &key).unwrap();
502
503        assert_eq!(plaintext, decrypted.as_slice());
504    }
505
506    #[test]
507    fn test_get_key_from_env() {
508        std::env::set_var("TEST_KEY", "test_value");
509        let result = KeyUtils::get_key_from_env("TEST_KEY").unwrap();
510        assert_eq!(result, "test_value");
511
512        let missing_result = KeyUtils::get_key_from_env("MISSING_KEY");
513        assert!(missing_result.is_err());
514    }
515}