Skip to main content

zlayer_secrets/
encryption.rs

1//! `XChaCha20-Poly1305` encryption for secrets.
2//!
3//! Provides authenticated encryption using `XChaCha20-Poly1305` with
4//! Argon2id key derivation for password-based encryption.
5
6use chacha20poly1305::{
7    aead::{Aead, KeyInit},
8    XChaCha20Poly1305, XNonce,
9};
10use rand::rngs::OsRng;
11use rand::TryRngCore;
12use zeroize::Zeroizing;
13
14use crate::{Result, SecretsError};
15
16/// Size of the XChaCha20-Poly1305 nonce in bytes.
17pub const NONCE_SIZE: usize = 24;
18
19/// Size of the encryption key in bytes.
20pub const KEY_SIZE: usize = 32;
21
22/// Encryption key with secure memory handling.
23///
24/// The key bytes are wrapped in [`Zeroizing`] to ensure they are
25/// zeroed from memory when dropped.
26#[derive(Clone)]
27pub struct EncryptionKey {
28    key: Zeroizing<[u8; KEY_SIZE]>,
29}
30
31impl EncryptionKey {
32    /// Derives an encryption key from a password using Argon2id.
33    ///
34    /// # Arguments
35    /// * `password` - The password to derive the key from
36    /// * `salt` - Salt bytes (should be at least 16 bytes, randomly generated per-key)
37    ///
38    /// # Errors
39    /// Returns `SecretsError::Encryption` if key derivation fails.
40    pub fn derive_from_password(password: &str, salt: &[u8]) -> Result<Self> {
41        use argon2::{Algorithm, Argon2, Params, Version};
42
43        // Argon2id parameters - balanced security/performance
44        // Using OWASP recommended minimums for interactive scenarios
45        let params = Params::new(
46            19 * 1024, // 19 MiB memory cost (OWASP recommendation)
47            2,         // 2 iterations
48            1,         // 1 degree of parallelism
49            Some(KEY_SIZE),
50        )
51        .map_err(|e| SecretsError::Encryption(format!("Invalid Argon2 params: {e}")))?;
52
53        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
54
55        let mut key_bytes = Zeroizing::new([0u8; KEY_SIZE]);
56        argon2
57            .hash_password_into(password.as_bytes(), salt, key_bytes.as_mut())
58            .map_err(|e| SecretsError::Encryption(format!("Key derivation failed: {e}")))?;
59
60        Ok(Self { key: key_bytes })
61    }
62
63    /// Generates a random 32-byte encryption key.
64    ///
65    /// Uses the operating system's cryptographically secure random number generator.
66    ///
67    /// # Panics
68    /// Panics if the OS random number generator fails.
69    #[must_use]
70    pub fn generate() -> Self {
71        let mut key_bytes = Zeroizing::new([0u8; KEY_SIZE]);
72        OsRng
73            .try_fill_bytes(key_bytes.as_mut())
74            .expect("OS RNG failed");
75        Self { key: key_bytes }
76    }
77
78    /// Creates an encryption key from raw bytes.
79    ///
80    /// # Arguments
81    /// * `bytes` - The key bytes (must be exactly 32 bytes)
82    ///
83    /// # Errors
84    /// Returns `SecretsError::Encryption` if the byte slice is not exactly 32 bytes.
85    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
86        if bytes.len() != KEY_SIZE {
87            return Err(SecretsError::Encryption(format!(
88                "Invalid key length: expected {KEY_SIZE} bytes, got {}",
89                bytes.len()
90            )));
91        }
92
93        let mut key_bytes = Zeroizing::new([0u8; KEY_SIZE]);
94        key_bytes.copy_from_slice(bytes);
95        Ok(Self { key: key_bytes })
96    }
97
98    /// Returns the raw key bytes.
99    ///
100    /// Use with caution - only for persisting the key securely.
101    #[inline]
102    #[must_use]
103    pub fn as_bytes(&self) -> &[u8] {
104        self.key.as_ref()
105    }
106
107    /// Encrypts plaintext using XChaCha20-Poly1305.
108    ///
109    /// The returned ciphertext has the 24-byte nonce prepended:
110    /// `[nonce (24 bytes)][ciphertext + auth tag]`
111    ///
112    /// # Arguments
113    /// * `plaintext` - The data to encrypt
114    ///
115    /// # Errors
116    /// Returns `SecretsError::Encryption` if encryption fails.
117    ///
118    /// # Panics
119    /// Panics if the OS random number generator fails to produce nonce bytes.
120    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
121        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_ref())
122            .map_err(|e| SecretsError::Encryption(format!("Failed to create cipher: {e}")))?;
123
124        // Generate random nonce
125        let mut nonce_bytes = [0u8; NONCE_SIZE];
126        OsRng
127            .try_fill_bytes(&mut nonce_bytes)
128            .expect("OS RNG failed");
129        let nonce = XNonce::from_slice(&nonce_bytes);
130
131        // Encrypt
132        let ciphertext = cipher
133            .encrypt(nonce, plaintext)
134            .map_err(|e| SecretsError::Encryption(format!("Encryption failed: {e}")))?;
135
136        // Prepend nonce to ciphertext
137        let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
138        result.extend_from_slice(&nonce_bytes);
139        result.extend_from_slice(&ciphertext);
140
141        Ok(result)
142    }
143
144    /// Decrypts data that was encrypted with [`Self::encrypt`].
145    ///
146    /// Expects the input format: `[nonce (24 bytes)][ciphertext + auth tag]`
147    ///
148    /// # Arguments
149    /// * `data` - The encrypted data with prepended nonce
150    ///
151    /// # Errors
152    /// Returns `SecretsError::Decryption` if:
153    /// - The data is too short (less than nonce size)
154    /// - Decryption or authentication fails
155    pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
156        if data.len() < NONCE_SIZE {
157            return Err(SecretsError::Decryption(format!(
158                "Data too short: expected at least {NONCE_SIZE} bytes for nonce, got {}",
159                data.len()
160            )));
161        }
162
163        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_ref())
164            .map_err(|e| SecretsError::Decryption(format!("Failed to create cipher: {e}")))?;
165
166        // Extract nonce and ciphertext
167        let (nonce_bytes, ciphertext) = data.split_at(NONCE_SIZE);
168        let nonce = XNonce::from_slice(nonce_bytes);
169
170        // Decrypt and verify
171        cipher
172            .decrypt(nonce, ciphertext)
173            .map_err(|e| SecretsError::Decryption(format!("Decryption failed: {e}")))
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_generate_key() {
183        let key = EncryptionKey::generate();
184        assert_eq!(key.as_bytes().len(), KEY_SIZE);
185
186        // Generated keys should be different
187        let key2 = EncryptionKey::generate();
188        assert_ne!(key.as_bytes(), key2.as_bytes());
189    }
190
191    #[test]
192    fn test_from_bytes_valid() {
193        let bytes = [42u8; KEY_SIZE];
194        let key = EncryptionKey::from_bytes(&bytes).unwrap();
195        assert_eq!(key.as_bytes(), &bytes);
196    }
197
198    #[test]
199    fn test_from_bytes_invalid_length() {
200        let bytes = [0u8; 16]; // Too short
201        let result = EncryptionKey::from_bytes(&bytes);
202        assert!(result.is_err());
203
204        let bytes = [0u8; 64]; // Too long
205        let result = EncryptionKey::from_bytes(&bytes);
206        assert!(result.is_err());
207    }
208
209    #[test]
210    fn test_derive_from_password() {
211        let salt = b"unique_salt_1234";
212        let key = EncryptionKey::derive_from_password("my_secure_password", salt).unwrap();
213        assert_eq!(key.as_bytes().len(), KEY_SIZE);
214
215        // Same password + salt should produce same key
216        let key2 = EncryptionKey::derive_from_password("my_secure_password", salt).unwrap();
217        assert_eq!(key.as_bytes(), key2.as_bytes());
218
219        // Different password should produce different key
220        let key3 = EncryptionKey::derive_from_password("different_password", salt).unwrap();
221        assert_ne!(key.as_bytes(), key3.as_bytes());
222
223        // Different salt should produce different key
224        let key4 =
225            EncryptionKey::derive_from_password("my_secure_password", b"different_salt__").unwrap();
226        assert_ne!(key.as_bytes(), key4.as_bytes());
227    }
228
229    #[test]
230    fn test_encrypt_decrypt_roundtrip() {
231        let key = EncryptionKey::generate();
232        let plaintext = b"Hello, World! This is a secret message.";
233
234        let encrypted = key.encrypt(plaintext).unwrap();
235
236        // Encrypted should be longer (nonce + auth tag)
237        assert!(encrypted.len() > plaintext.len());
238
239        let decrypted = key.decrypt(&encrypted).unwrap();
240        assert_eq!(decrypted, plaintext);
241    }
242
243    #[test]
244    fn test_encrypt_produces_different_ciphertext() {
245        let key = EncryptionKey::generate();
246        let plaintext = b"Same message";
247
248        let encrypted1 = key.encrypt(plaintext).unwrap();
249        let encrypted2 = key.encrypt(plaintext).unwrap();
250
251        // Different nonces should produce different ciphertext
252        assert_ne!(encrypted1, encrypted2);
253
254        // But both should decrypt to the same plaintext
255        assert_eq!(key.decrypt(&encrypted1).unwrap(), plaintext);
256        assert_eq!(key.decrypt(&encrypted2).unwrap(), plaintext);
257    }
258
259    #[test]
260    fn test_decrypt_with_wrong_key_fails() {
261        let key1 = EncryptionKey::generate();
262        let key2 = EncryptionKey::generate();
263        let plaintext = b"Secret data";
264
265        let encrypted = key1.encrypt(plaintext).unwrap();
266        let result = key2.decrypt(&encrypted);
267
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn test_decrypt_tampered_data_fails() {
273        let key = EncryptionKey::generate();
274        let plaintext = b"Important data";
275
276        let mut encrypted = key.encrypt(plaintext).unwrap();
277
278        // Tamper with the ciphertext (not the nonce)
279        if let Some(byte) = encrypted.get_mut(NONCE_SIZE + 5) {
280            *byte ^= 0xFF;
281        }
282
283        let result = key.decrypt(&encrypted);
284        assert!(result.is_err());
285    }
286
287    #[test]
288    fn test_decrypt_too_short_data() {
289        let key = EncryptionKey::generate();
290        let short_data = [0u8; 10]; // Less than NONCE_SIZE
291
292        let result = key.decrypt(&short_data);
293        assert!(result.is_err());
294    }
295
296    #[test]
297    fn test_encrypt_empty_plaintext() {
298        let key = EncryptionKey::generate();
299        let plaintext = b"";
300
301        let encrypted = key.encrypt(plaintext).unwrap();
302        let decrypted = key.decrypt(&encrypted).unwrap();
303
304        assert_eq!(decrypted, plaintext);
305    }
306
307    #[test]
308    fn test_password_derived_key_encrypt_decrypt() {
309        let salt = b"random_salt_here";
310        let key = EncryptionKey::derive_from_password("password123", salt).unwrap();
311        let plaintext = b"Sensitive information";
312
313        let encrypted = key.encrypt(plaintext).unwrap();
314        let decrypted = key.decrypt(&encrypted).unwrap();
315
316        assert_eq!(decrypted, plaintext);
317    }
318}