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        // getrandom 0.3+ renamed the free `getrandom()` function to `fill()`.
57        let mut nonce_bytes = [0u8; 12];
58        getrandom::fill(&mut nonce_bytes)
59            .map_err(|e| Error::Internal(format!("nonce RNG failed: {e}")))?;
60        let nonce: Nonce<Aes256Gcm> = nonce_bytes.into();
61
62        let ciphertext = cipher
63            .encrypt(&nonce, plaintext)
64            .map_err(|e| Error::Internal(format!("encryption failed: {e}")))?;
65
66        let mut output = Vec::with_capacity(12 + ciphertext.len());
67        output.extend_from_slice(&nonce_bytes);
68        output.extend_from_slice(&ciphertext);
69        Ok(output)
70    }
71
72    /// Decrypt content encrypted by [`encrypt`].
73    pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
74        if data.len() < 28 {
75            // 12 nonce + 16 tag minimum
76            return Err(Error::Validation("encrypted data too short".to_string()));
77        }
78
79        let cipher = Aes256Gcm::new_from_slice(&self.key)
80            .map_err(|e| Error::Internal(format!("invalid AES-256 key: {e}")))?;
81
82        let nonce = <Nonce<Aes256Gcm>>::try_from(&data[..12])
83            .map_err(|_| Error::Validation("invalid nonce".to_string()))?;
84        let ciphertext = &data[12..];
85
86        cipher
87            .decrypt(&nonce, ciphertext)
88            .map_err(|_| Error::Validation("decryption tag mismatch".to_string()))
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_encryption_round_trip() {
98        let key = [0x42u8; 32];
99        let enc = ContentEncryption::new(key);
100
101        let plaintext = b"Hello, encrypted world!";
102        let encrypted = enc.encrypt(plaintext).unwrap();
103
104        assert_ne!(&encrypted[12..encrypted.len() - 16], plaintext);
105
106        let decrypted = enc.decrypt(&encrypted).unwrap();
107        assert_eq!(decrypted, plaintext);
108    }
109
110    #[test]
111    fn test_encryption_from_hex() {
112        let hex_key = "42".repeat(32);
113        let enc = ContentEncryption::from_hex(&hex_key).unwrap();
114
115        let plaintext = b"test";
116        let encrypted = enc.encrypt(plaintext).unwrap();
117        let decrypted = enc.decrypt(&encrypted).unwrap();
118        assert_eq!(decrypted, plaintext);
119    }
120
121    #[test]
122    fn test_invalid_hex_key_length() {
123        let result = ContentEncryption::from_hex("abcd");
124        assert!(result.is_err());
125    }
126
127    #[test]
128    fn test_tampered_ciphertext_fails() {
129        let key = [0x42u8; 32];
130        let enc = ContentEncryption::new(key);
131
132        let encrypted = enc.encrypt(b"secret data").unwrap();
133        let mut tampered = encrypted.clone();
134        tampered[15] ^= 0xff; // flip a byte in the ciphertext
135
136        let result = enc.decrypt(&tampered);
137        assert!(result.is_err());
138    }
139
140    #[test]
141    fn test_aes_gcm_round_trip() {
142        let key = [0xABu8; 32];
143        let enc = ContentEncryption::new(key);
144
145        // Test various sizes
146        for size in [0, 1, 16, 100, 1024, 65536] {
147            let plaintext: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
148            let encrypted = enc.encrypt(&plaintext).unwrap();
149            let decrypted = enc.decrypt(&encrypted).unwrap();
150            assert_eq!(decrypted, plaintext, "round-trip failed for size {size}");
151        }
152    }
153
154    #[test]
155    fn test_aes_gcm_tamper_detection() {
156        let key = [0xCDu8; 32];
157        let enc = ContentEncryption::new(key);
158        let encrypted = enc.encrypt(b"sensitive data").unwrap();
159
160        // Tamper with nonce
161        let mut tampered = encrypted.clone();
162        tampered[0] ^= 0x01;
163        assert!(enc.decrypt(&tampered).is_err());
164
165        // Tamper with ciphertext body
166        let mut tampered = encrypted.clone();
167        tampered[14] ^= 0x01;
168        assert!(enc.decrypt(&tampered).is_err());
169
170        // Tamper with last byte (tag)
171        let mut tampered = encrypted.clone();
172        let last = tampered.len() - 1;
173        tampered[last] ^= 0x01;
174        assert!(enc.decrypt(&tampered).is_err());
175    }
176}