Skip to main content

mnemo_core/
encryption.rs

1//! AES-256-GCM encryption for memory content at rest.
2//!
3//! Provides encrypt/decrypt operations for memory content before storage.
4//! The encryption key is loaded from an environment variable or passed directly.
5
6use crate::error::{Error, Result};
7
8use aes_gcm::{
9    Aes256Gcm,
10    aead::{Aead, KeyInit, Nonce},
11};
12
13/// AES-256-GCM encryption provider for at-rest memory content.
14pub struct ContentEncryption {
15    key: [u8; 32],
16}
17
18impl ContentEncryption {
19    /// Create from a 32-byte key.
20    pub fn new(key: [u8; 32]) -> Self {
21        Self { key }
22    }
23
24    /// Create from a hex-encoded key string (64 hex chars = 32 bytes).
25    pub fn from_hex(hex_key: &str) -> Result<Self> {
26        let bytes =
27            hex::decode(hex_key).map_err(|e| Error::Validation(format!("invalid hex key: {e}")))?;
28        if bytes.len() != 32 {
29            return Err(Error::Validation(format!(
30                "key must be 32 bytes, got {}",
31                bytes.len()
32            )));
33        }
34        let mut key = [0u8; 32];
35        key.copy_from_slice(&bytes);
36        Ok(Self { key })
37    }
38
39    /// Create from the `MNEMO_ENCRYPTION_KEY` environment variable.
40    pub fn from_env() -> Result<Self> {
41        let hex_key = std::env::var("MNEMO_ENCRYPTION_KEY")
42            .map_err(|_| Error::Validation("MNEMO_ENCRYPTION_KEY not set".to_string()))?;
43        Self::from_hex(&hex_key)
44    }
45
46    /// Encrypt plaintext content. Returns `nonce(12) || ciphertext+tag` as bytes.
47    ///
48    /// Uses AES-256-GCM with a random 12-byte nonce.
49    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
50        let cipher = Aes256Gcm::new_from_slice(&self.key)
51            .map_err(|e| Error::Internal(format!("invalid AES-256 key: {e}")))?;
52
53        // Random 96-bit nonce straight from the OS CSPRNG. Using `getrandom`
54        // directly (rather than aes-gcm's re-exported RNG) keeps this stable
55        // across the aead/rand_core version churn that the 0.11 bump introduced.
56        let mut nonce_bytes = [0u8; 12];
57        getrandom::getrandom(&mut nonce_bytes)
58            .map_err(|e| Error::Internal(format!("nonce RNG failed: {e}")))?;
59        let nonce: Nonce<Aes256Gcm> = nonce_bytes.into();
60
61        let ciphertext = cipher
62            .encrypt(&nonce, plaintext)
63            .map_err(|e| Error::Internal(format!("encryption failed: {e}")))?;
64
65        let mut output = Vec::with_capacity(12 + ciphertext.len());
66        output.extend_from_slice(&nonce_bytes);
67        output.extend_from_slice(&ciphertext);
68        Ok(output)
69    }
70
71    /// Decrypt content encrypted by [`encrypt`].
72    pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
73        if data.len() < 28 {
74            // 12 nonce + 16 tag minimum
75            return Err(Error::Validation("encrypted data too short".to_string()));
76        }
77
78        let cipher = Aes256Gcm::new_from_slice(&self.key)
79            .map_err(|e| Error::Internal(format!("invalid AES-256 key: {e}")))?;
80
81        let nonce = <Nonce<Aes256Gcm>>::try_from(&data[..12])
82            .map_err(|_| Error::Validation("invalid nonce".to_string()))?;
83        let ciphertext = &data[12..];
84
85        cipher
86            .decrypt(&nonce, ciphertext)
87            .map_err(|_| Error::Validation("decryption tag mismatch".to_string()))
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_encryption_round_trip() {
97        let key = [0x42u8; 32];
98        let enc = ContentEncryption::new(key);
99
100        let plaintext = b"Hello, encrypted world!";
101        let encrypted = enc.encrypt(plaintext).unwrap();
102
103        assert_ne!(&encrypted[12..encrypted.len() - 16], plaintext);
104
105        let decrypted = enc.decrypt(&encrypted).unwrap();
106        assert_eq!(decrypted, plaintext);
107    }
108
109    #[test]
110    fn test_encryption_from_hex() {
111        let hex_key = "42".repeat(32);
112        let enc = ContentEncryption::from_hex(&hex_key).unwrap();
113
114        let plaintext = b"test";
115        let encrypted = enc.encrypt(plaintext).unwrap();
116        let decrypted = enc.decrypt(&encrypted).unwrap();
117        assert_eq!(decrypted, plaintext);
118    }
119
120    #[test]
121    fn test_invalid_hex_key_length() {
122        let result = ContentEncryption::from_hex("abcd");
123        assert!(result.is_err());
124    }
125
126    #[test]
127    fn test_tampered_ciphertext_fails() {
128        let key = [0x42u8; 32];
129        let enc = ContentEncryption::new(key);
130
131        let encrypted = enc.encrypt(b"secret data").unwrap();
132        let mut tampered = encrypted.clone();
133        tampered[15] ^= 0xff; // flip a byte in the ciphertext
134
135        let result = enc.decrypt(&tampered);
136        assert!(result.is_err());
137    }
138
139    #[test]
140    fn test_aes_gcm_round_trip() {
141        let key = [0xABu8; 32];
142        let enc = ContentEncryption::new(key);
143
144        // Test various sizes
145        for size in [0, 1, 16, 100, 1024, 65536] {
146            let plaintext: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
147            let encrypted = enc.encrypt(&plaintext).unwrap();
148            let decrypted = enc.decrypt(&encrypted).unwrap();
149            assert_eq!(decrypted, plaintext, "round-trip failed for size {size}");
150        }
151    }
152
153    #[test]
154    fn test_aes_gcm_tamper_detection() {
155        let key = [0xCDu8; 32];
156        let enc = ContentEncryption::new(key);
157        let encrypted = enc.encrypt(b"sensitive data").unwrap();
158
159        // Tamper with nonce
160        let mut tampered = encrypted.clone();
161        tampered[0] ^= 0x01;
162        assert!(enc.decrypt(&tampered).is_err());
163
164        // Tamper with ciphertext body
165        let mut tampered = encrypted.clone();
166        tampered[14] ^= 0x01;
167        assert!(enc.decrypt(&tampered).is_err());
168
169        // Tamper with last byte (tag)
170        let mut tampered = encrypted.clone();
171        let last = tampered.len() - 1;
172        tampered[last] ^= 0x01;
173        assert!(enc.decrypt(&tampered).is_err());
174    }
175}