Skip to main content

subcog/security/
encryption.rs

1//! Encryption at rest for filesystem storage (CRIT-005).
2//!
3//! Provides AES-256-GCM authenticated encryption for memory files.
4//! Encryption is opt-in via the `encryption` feature flag and requires
5//! setting the `SUBCOG_ENCRYPTION_KEY` environment variable.
6//!
7//! # Security Properties
8//!
9//! - **Algorithm**: AES-256-GCM (authenticated encryption)
10//! - **Key**: 32 bytes (256 bits) from base64-encoded env var
11//! - **Nonce**: 12 bytes, randomly generated per encryption
12//! - **Format**: `SUBCOG_ENC_V1` magic + nonce + ciphertext + auth tag
13//!
14//! # Usage
15//!
16//! ```bash
17//! # Generate a key (32 random bytes, base64 encoded)
18//! openssl rand -base64 32
19//!
20//! # Set the environment variable
21//! export SUBCOG_ENCRYPTION_KEY="your-base64-encoded-key"
22//! ```
23//!
24//! # Example
25//!
26//! ```rust,ignore
27//! use subcog::security::encryption::{Encryptor, EncryptionConfig};
28//!
29//! let config = EncryptionConfig::from_env()?;
30//! let encryptor = Encryptor::new(config)?;
31//!
32//! let plaintext = b"sensitive data";
33//! let encrypted = encryptor.encrypt(plaintext)?;
34//! let decrypted = encryptor.decrypt(&encrypted)?;
35//! assert_eq!(plaintext, &decrypted[..]);
36//! ```
37
38#[cfg(feature = "encryption")]
39mod implementation {
40    use crate::{Error, Result};
41
42    use aes_gcm::{
43        Aes256Gcm, Key, Nonce,
44        aead::{Aead, KeyInit},
45    };
46    use base64::Engine;
47    use rand::RngCore;
48
49    /// Magic bytes to identify encrypted files.
50    /// Format: `SUBCOG_ENC_V1\0` (14 bytes)
51    pub const MAGIC_HEADER: &[u8] = b"SUBCOG_ENC_V1\0";
52
53    /// Nonce size for AES-256-GCM (12 bytes / 96 bits).
54    const NONCE_SIZE: usize = 12;
55
56    /// Key size for AES-256 (32 bytes / 256 bits).
57    const KEY_SIZE: usize = 32;
58
59    /// Environment variable for encryption key.
60    const ENV_ENCRYPTION_KEY: &str = "SUBCOG_ENCRYPTION_KEY";
61
62    /// Encryption configuration.
63    #[derive(Debug, Clone)]
64    pub struct EncryptionConfig {
65        /// Raw 32-byte encryption key.
66        key: [u8; KEY_SIZE],
67    }
68
69    impl EncryptionConfig {
70        /// Creates configuration from a base64-encoded key.
71        ///
72        /// # Errors
73        ///
74        /// Returns an error if the key is invalid or wrong size.
75        pub fn from_base64(key_b64: &str) -> Result<Self> {
76            let key_bytes = base64::engine::general_purpose::STANDARD
77                .decode(key_b64.trim())
78                .map_err(|e| Error::InvalidInput(format!("Invalid base64 encryption key: {e}")))?;
79
80            if key_bytes.len() != KEY_SIZE {
81                return Err(Error::InvalidInput(format!(
82                    "Encryption key must be {} bytes, got {}",
83                    KEY_SIZE,
84                    key_bytes.len()
85                )));
86            }
87
88            let mut key = [0u8; KEY_SIZE];
89            key.copy_from_slice(&key_bytes);
90
91            Ok(Self { key })
92        }
93
94        /// Loads configuration from environment variable.
95        ///
96        /// # Errors
97        ///
98        /// Returns an error if `SUBCOG_ENCRYPTION_KEY` is not set or invalid.
99        pub fn from_env() -> Result<Self> {
100            let key_b64 = std::env::var(ENV_ENCRYPTION_KEY).map_err(|_| {
101                Error::InvalidInput(format!(
102                    "Encryption enabled but {ENV_ENCRYPTION_KEY} not set. \
103                     Generate a key with: openssl rand -base64 32"
104                ))
105            })?;
106
107            Self::from_base64(&key_b64)
108        }
109
110        /// Tries to load configuration from environment, returns None if not configured.
111        #[must_use]
112        pub fn try_from_env() -> Option<Self> {
113            Self::from_env().ok()
114        }
115    }
116
117    /// AES-256-GCM encryptor.
118    pub struct Encryptor {
119        cipher: Aes256Gcm,
120    }
121
122    impl Encryptor {
123        /// Creates a new encryptor from configuration.
124        ///
125        /// # Errors
126        ///
127        /// Returns an error if the key is invalid.
128        pub fn new(config: EncryptionConfig) -> Result<Self> {
129            let key = Key::<Aes256Gcm>::from(config.key);
130            let cipher = Aes256Gcm::new(&key);
131            Ok(Self { cipher })
132        }
133
134        /// Creates an encryptor from environment configuration.
135        ///
136        /// # Errors
137        ///
138        /// Returns an error if configuration is missing or invalid.
139        pub fn from_env() -> Result<Self> {
140            let config = EncryptionConfig::from_env()?;
141            Self::new(config)
142        }
143
144        /// Encrypts plaintext data.
145        ///
146        /// Returns: magic header + nonce + ciphertext (includes auth tag)
147        ///
148        /// # Errors
149        ///
150        /// Returns an error if encryption fails.
151        pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
152            // Generate random nonce
153            let mut nonce_bytes = [0u8; NONCE_SIZE];
154            rand::rng().fill_bytes(&mut nonce_bytes);
155            let nonce = Nonce::from(nonce_bytes);
156
157            // Encrypt
158            let ciphertext =
159                self.cipher
160                    .encrypt(&nonce, plaintext)
161                    .map_err(|e| Error::OperationFailed {
162                        operation: "encrypt".to_string(),
163                        cause: format!("AES-256-GCM encryption failed: {e}"),
164                    })?;
165
166            // Build output: magic + nonce + ciphertext
167            let mut output = Vec::with_capacity(MAGIC_HEADER.len() + NONCE_SIZE + ciphertext.len());
168            output.extend_from_slice(MAGIC_HEADER);
169            output.extend_from_slice(&nonce_bytes);
170            output.extend_from_slice(&ciphertext);
171
172            tracing::debug!(
173                plaintext_len = plaintext.len(),
174                encrypted_len = output.len(),
175                "Encrypted data"
176            );
177
178            Ok(output)
179        }
180
181        /// Decrypts encrypted data.
182        ///
183        /// # Errors
184        ///
185        /// Returns an error if decryption fails or data is invalid.
186        pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>> {
187            let min_size = MAGIC_HEADER.len() + NONCE_SIZE + 16; // 16 = auth tag
188            if encrypted.len() < min_size {
189                return Err(Error::InvalidInput(format!(
190                    "Encrypted data too short: {} bytes, minimum {}",
191                    encrypted.len(),
192                    min_size
193                )));
194            }
195
196            // Verify magic header
197            if !encrypted.starts_with(MAGIC_HEADER) {
198                return Err(Error::InvalidInput(
199                    "Invalid encrypted file: missing magic header".to_string(),
200                ));
201            }
202
203            // Extract nonce and ciphertext
204            let nonce_start = MAGIC_HEADER.len();
205            let nonce_end = nonce_start + NONCE_SIZE;
206            let nonce_array: [u8; NONCE_SIZE] = encrypted[nonce_start..nonce_end]
207                .try_into()
208                .map_err(|_| Error::InvalidInput("Invalid nonce length".to_string()))?;
209            let nonce = Nonce::from(nonce_array);
210            let ciphertext = &encrypted[nonce_end..];
211
212            // Decrypt
213            let plaintext =
214                self.cipher
215                    .decrypt(&nonce, ciphertext)
216                    .map_err(|e| Error::OperationFailed {
217                        operation: "decrypt".to_string(),
218                        cause: format!(
219                            "AES-256-GCM decryption failed (wrong key or corrupted data): {e}"
220                        ),
221                    })?;
222
223            tracing::debug!(
224                encrypted_len = encrypted.len(),
225                plaintext_len = plaintext.len(),
226                "Decrypted data"
227            );
228
229            Ok(plaintext)
230        }
231    }
232
233    /// Checks if data appears to be encrypted (has magic header).
234    #[must_use]
235    pub fn is_encrypted(data: &[u8]) -> bool {
236        data.starts_with(MAGIC_HEADER)
237    }
238
239    #[cfg(test)]
240    mod tests {
241        use super::*;
242
243        fn test_config() -> EncryptionConfig {
244            // 32 bytes of test key
245            EncryptionConfig {
246                key: [
247                    0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
248                    0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19,
249                    0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
250                ],
251            }
252        }
253
254        #[test]
255        fn test_encrypt_decrypt_roundtrip() {
256            let encryptor = Encryptor::new(test_config()).unwrap();
257            let plaintext = b"Hello, World! This is a test of AES-256-GCM encryption.";
258
259            let encrypted = encryptor.encrypt(plaintext).unwrap();
260            assert!(is_encrypted(&encrypted));
261            assert_ne!(encrypted, plaintext);
262
263            let decrypted = encryptor.decrypt(&encrypted).unwrap();
264            assert_eq!(decrypted, plaintext);
265        }
266
267        #[test]
268        fn test_encrypt_decrypt_empty() {
269            let encryptor = Encryptor::new(test_config()).unwrap();
270            let plaintext = b"";
271
272            let encrypted = encryptor.encrypt(plaintext).unwrap();
273            let decrypted = encryptor.decrypt(&encrypted).unwrap();
274            assert_eq!(decrypted, plaintext);
275        }
276
277        #[test]
278        fn test_encrypt_decrypt_large() {
279            let encryptor = Encryptor::new(test_config()).unwrap();
280            let plaintext: Vec<u8> = (0u32..10000).map(|i| (i % 256) as u8).collect();
281
282            let encrypted = encryptor.encrypt(&plaintext).unwrap();
283            let decrypted = encryptor.decrypt(&encrypted).unwrap();
284            assert_eq!(decrypted, plaintext);
285        }
286
287        #[test]
288        fn test_different_nonces_produce_different_ciphertext() {
289            let encryptor = Encryptor::new(test_config()).unwrap();
290            let plaintext = b"Same plaintext";
291
292            let encrypted1 = encryptor.encrypt(plaintext).unwrap();
293            let encrypted2 = encryptor.encrypt(plaintext).unwrap();
294
295            // Same plaintext should produce different ciphertext due to random nonce
296            assert_ne!(encrypted1, encrypted2);
297
298            // Both should decrypt to same plaintext
299            let decrypted1 = encryptor.decrypt(&encrypted1).unwrap();
300            let decrypted2 = encryptor.decrypt(&encrypted2).unwrap();
301            assert_eq!(decrypted1, decrypted2);
302        }
303
304        #[test]
305        fn test_wrong_key_fails() {
306            let encryptor1 = Encryptor::new(test_config()).unwrap();
307            let mut wrong_config = test_config();
308            wrong_config.key[0] ^= 0xff; // Flip a bit
309            let encryptor2 = Encryptor::new(wrong_config).unwrap();
310
311            let plaintext = b"Secret data";
312            let encrypted = encryptor1.encrypt(plaintext).unwrap();
313
314            // Decryption with wrong key should fail
315            let result = encryptor2.decrypt(&encrypted);
316            assert!(result.is_err());
317        }
318
319        #[test]
320        fn test_tampered_ciphertext_fails() {
321            let encryptor = Encryptor::new(test_config()).unwrap();
322            let plaintext = b"Secret data";
323
324            let mut encrypted = encryptor.encrypt(plaintext).unwrap();
325            // Tamper with the ciphertext
326            let last = encrypted.len() - 1;
327            encrypted[last] ^= 0xff;
328
329            let result = encryptor.decrypt(&encrypted);
330            assert!(result.is_err());
331        }
332
333        #[test]
334        fn test_is_encrypted() {
335            assert!(is_encrypted(MAGIC_HEADER));
336            assert!(is_encrypted(b"SUBCOG_ENC_V1\0some_data"));
337            assert!(!is_encrypted(b"plain text"));
338            assert!(!is_encrypted(b"{}"));
339            assert!(!is_encrypted(b""));
340        }
341
342        #[test]
343        fn test_config_from_base64() {
344            // Valid 32-byte key in base64
345            let key_b64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
346            let config = EncryptionConfig::from_base64(key_b64).unwrap();
347            assert_eq!(config.key.len(), 32);
348        }
349
350        #[test]
351        fn test_config_from_base64_invalid() {
352            // Too short
353            let result = EncryptionConfig::from_base64("AAEC");
354            assert!(result.is_err());
355
356            // Invalid base64
357            let result = EncryptionConfig::from_base64("not-valid-base64!!!");
358            assert!(result.is_err());
359        }
360
361        #[test]
362        fn test_too_short_encrypted_data() {
363            let encryptor = Encryptor::new(test_config()).unwrap();
364            let result = encryptor.decrypt(b"too short");
365            assert!(result.is_err());
366        }
367
368        #[test]
369        fn test_missing_magic_header() {
370            let encryptor = Encryptor::new(test_config()).unwrap();
371            // Long enough but wrong header
372            let fake_data = vec![0u8; 100];
373            let result = encryptor.decrypt(&fake_data);
374            assert!(result.is_err());
375        }
376    }
377}
378
379#[cfg(feature = "encryption")]
380pub use implementation::{EncryptionConfig, Encryptor, MAGIC_HEADER, is_encrypted};
381
382// Stub implementations when encryption feature is disabled
383#[cfg(not(feature = "encryption"))]
384mod stub {
385    use crate::{Error, Result};
386
387    /// Encryption configuration (stub).
388    #[derive(Debug, Clone)]
389    pub struct EncryptionConfig;
390
391    impl EncryptionConfig {
392        /// Returns an error indicating encryption is not available.
393        ///
394        /// # Errors
395        ///
396        /// Always returns an error.
397        pub fn from_env() -> Result<Self> {
398            Err(Error::FeatureNotEnabled(
399                "encryption feature not compiled".to_string(),
400            ))
401        }
402
403        /// Always returns None.
404        #[must_use]
405        pub const fn try_from_env() -> Option<Self> {
406            None
407        }
408    }
409
410    /// Encryptor (stub).
411    pub struct Encryptor;
412
413    impl Encryptor {
414        /// Returns an error indicating encryption is not available.
415        ///
416        /// # Errors
417        ///
418        /// Always returns an error.
419        pub fn from_env() -> Result<Self> {
420            Err(Error::FeatureNotEnabled(
421                "encryption feature not compiled".to_string(),
422            ))
423        }
424    }
425
426    /// Checks if data appears to be encrypted.
427    #[must_use]
428    pub fn is_encrypted(data: &[u8]) -> bool {
429        data.starts_with(b"SUBCOG_ENC_V1\0")
430    }
431}
432
433#[cfg(not(feature = "encryption"))]
434pub use stub::{EncryptionConfig, Encryptor, is_encrypted};