Skip to main content

xos_storage/
encryption.rs

1//! Client-side encryption using AES-256-GCM with Argon2 key derivation.
2//!
3//! - Password -> 32-byte key via Argon2id
4//! - Encrypt: AES-256-GCM with random 12-byte nonce (prepended to ciphertext)
5//! - Decrypt: split nonce, decrypt remaining ciphertext
6//! - Key is zeroized on drop
7
8use aes_gcm::aead::{Aead, KeyInit};
9use aes_gcm::Aes256Gcm;
10use rand::RngCore;
11use zeroize::Zeroize;
12
13use crate::{Result, StorageError};
14
15/// AES-256-GCM encryption with Argon2-derived keys.
16pub struct Encryption {
17    key: [u8; 32],
18}
19
20impl Encryption {
21    /// Derive a 256-bit key from a password and salt using Argon2id.
22    ///
23    /// Salt must be at least 8 bytes.
24    pub fn from_password(password: &str, salt: &[u8]) -> Result<Self> {
25        if salt.len() < 8 {
26            return Err(StorageError::Encryption(
27                "salt must be at least 8 bytes".into(),
28            ));
29        }
30
31        let argon2 = argon2::Argon2::default();
32        let mut key = [0u8; 32];
33
34        argon2
35            .hash_password_into(password.as_bytes(), salt, &mut key)
36            .map_err(|e| StorageError::Encryption(format!("key derivation failed: {e}")))?;
37
38        Ok(Self { key })
39    }
40
41    /// Create encryption from a raw 32-byte key (for testing or pre-derived keys).
42    pub fn from_raw_key(key: [u8; 32]) -> Self {
43        Self { key }
44    }
45
46    /// Encrypt plaintext. Returns nonce (12 bytes) || ciphertext || tag.
47    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
48        let cipher = Aes256Gcm::new(&self.key.into());
49
50        let mut nonce_bytes = [0u8; 12];
51        rand::thread_rng().fill_bytes(&mut nonce_bytes);
52        let nonce = aes_gcm::Nonce::from(nonce_bytes);
53
54        let ciphertext = cipher
55            .encrypt(&nonce, plaintext)
56            .map_err(|e| StorageError::Encryption(format!("encrypt failed: {e}")))?;
57
58        // Prepend nonce to ciphertext
59        let mut result = Vec::with_capacity(12 + ciphertext.len());
60        result.extend_from_slice(&nonce_bytes);
61        result.extend_from_slice(&ciphertext);
62
63        Ok(result)
64    }
65
66    /// Decrypt data produced by [`encrypt`]. Expects nonce || ciphertext || tag.
67    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>> {
68        if encrypted.len() < 12 + 16 {
69            // 12 nonce + 16 tag minimum
70            return Err(StorageError::Encryption("data too short".into()));
71        }
72
73        let (nonce_bytes, ciphertext) = encrypted.split_at(12);
74        let nonce_arr: [u8; 12] = nonce_bytes
75            .try_into()
76            .map_err(|_| StorageError::Encryption("invalid nonce length".into()))?;
77        let nonce = aes_gcm::Nonce::from(nonce_arr);
78        let cipher = Aes256Gcm::new(&self.key.into());
79
80        cipher
81            .decrypt(&nonce, ciphertext)
82            .map_err(|e| StorageError::Encryption(format!("decrypt failed: {e}")))
83    }
84}
85
86impl Drop for Encryption {
87    fn drop(&mut self) {
88        self.key.zeroize();
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    const TEST_SALT: &[u8] = b"xorion_test_salt_16bytes";
97    const TEST_PASSWORD: &str = "hunter2";
98
99    #[test]
100    fn roundtrip() {
101        let enc = Encryption::from_password(TEST_PASSWORD, TEST_SALT).unwrap();
102        let plaintext = b"Hello, Xorion!";
103        let encrypted = enc.encrypt(plaintext).unwrap();
104        let decrypted = enc.decrypt(&encrypted).unwrap();
105        assert_eq!(&decrypted, plaintext);
106    }
107
108    #[test]
109    fn empty_plaintext_roundtrip() {
110        let enc = Encryption::from_password(TEST_PASSWORD, TEST_SALT).unwrap();
111        let encrypted = enc.encrypt(b"").unwrap();
112        let decrypted = enc.decrypt(&encrypted).unwrap();
113        assert!(decrypted.is_empty());
114    }
115
116    #[test]
117    fn large_data_roundtrip() {
118        let enc = Encryption::from_password(TEST_PASSWORD, TEST_SALT).unwrap();
119        let data = vec![0xABu8; 1_000_000]; // 1 MB
120        let encrypted = enc.encrypt(&data).unwrap();
121        let decrypted = enc.decrypt(&encrypted).unwrap();
122        assert_eq!(decrypted, data);
123    }
124
125    #[test]
126    fn wrong_password_fails() {
127        let enc1 = Encryption::from_password("correct", TEST_SALT).unwrap();
128        let enc2 = Encryption::from_password("wrong", TEST_SALT).unwrap();
129        let encrypted = enc1.encrypt(b"secret").unwrap();
130        assert!(enc2.decrypt(&encrypted).is_err());
131    }
132
133    #[test]
134    fn nonce_prepended() {
135        let enc = Encryption::from_password(TEST_PASSWORD, TEST_SALT).unwrap();
136        let encrypted = enc.encrypt(b"data").unwrap();
137        // 12 nonce + 4 plaintext + 16 GCM tag = 32 bytes
138        assert_eq!(encrypted.len(), 12 + 4 + 16);
139    }
140
141    #[test]
142    fn different_encryptions_differ() {
143        let enc = Encryption::from_password(TEST_PASSWORD, TEST_SALT).unwrap();
144        let e1 = enc.encrypt(b"same").unwrap();
145        let e2 = enc.encrypt(b"same").unwrap();
146        // Random nonce means ciphertexts differ
147        assert_ne!(e1, e2);
148    }
149
150    #[test]
151    fn short_salt_rejected() {
152        let result = Encryption::from_password("pass", b"short");
153        assert!(result.is_err());
154    }
155
156    #[test]
157    fn truncated_ciphertext_rejected() {
158        let result = Encryption::from_raw_key([0u8; 32]).decrypt(&[0u8; 10]);
159        assert!(result.is_err());
160    }
161
162    #[test]
163    fn from_raw_key_works() {
164        let key = [42u8; 32];
165        let enc = Encryption::from_raw_key(key);
166        let encrypted = enc.encrypt(b"test").unwrap();
167        let enc2 = Encryption::from_raw_key(key);
168        let decrypted = enc2.decrypt(&encrypted).unwrap();
169        assert_eq!(&decrypted, b"test");
170    }
171}