Skip to main content

snip_it/
encryption.rs

1//! Encryption utilities for secure sync.
2//!
3//! Provides end-to-end encryption for snippet data using AES-256-GCM with
4//! Argon2 key derivation. All snippets are encrypted before transmission
5//! and decrypted upon receipt.
6//!
7//! # Security Model
8//!
9//! - **Encryption**: AES-256-GCM (authenticated encryption)
10//! - **Key Derivation**: Argon2id from API key + random salt
11//! - **Nonce**: Random 12-byte nonce per encryption (stored with ciphertext)
12//!
13//! # Example
14//!
15//! ```no_run
16//! use snip_it::encryption::{decrypt, encrypt};
17//!
18//! let api_key = "your-api-key";
19//! let encrypted = encrypt(api_key, "sensitive data").unwrap();
20//! let decrypted = decrypt(api_key, &encrypted).unwrap();
21//! assert_eq!(decrypted, "sensitive data");
22//! ```
23
24use aes_gcm::{
25    Aes256Gcm, Nonce,
26    aead::{Aead, KeyInit, OsRng, rand_core::RngCore},
27};
28use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
29use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
30use sha2::{Digest, Sha256};
31use std::collections::HashMap;
32use std::sync::{LazyLock, Mutex};
33use thiserror::Error;
34use zeroize::{Zeroize, ZeroizeOnDrop};
35
36#[cfg(test)]
37use subtle::ConstantTimeEq;
38
39const ARGON2_MEMORY_COST_KIB: u32 = 1 << 14; // 16 MiB — OWASP minimum for Argon2id
40const ARGON2_TIME_COST: u32 = 3; // 3 iterations — OWASP minimum recommendation
41const ARGON2_PARALLELISM: u32 = 4; // 4 threads — matches typical desktop CPU core count
42
43/// Maximum number of derived keys to cache. Each entry is ~100 bytes (32-byte key
44/// + string keys + HashMap overhead), so 10K entries ≈ 1 MB.
45const MAX_KEY_CACHE_SIZE: usize = 10_000;
46
47/// Cryptographic hash for API key cache keys.
48///
49/// Uses SHA-256 to avoid cache-key collisions that could cause one user's
50/// derived key to be served from another user's cache entry.
51fn hash_api_key(api_key: &str) -> String {
52    let hash = Sha256::digest(api_key.as_bytes());
53    format!("{:016x}", u64::from_le_bytes(hash[..8].try_into().unwrap()))
54}
55
56/// Session-local cache for derived keys to avoid re-running Argon2id
57/// for the same (api_key, salt) pair during a sync operation.
58/// Key: (hashed_api_key, base64(salt)), Value: derived key bytes
59static KEY_CACHE: LazyLock<Mutex<HashMap<(String, String), [u8; 32]>>> =
60    LazyLock::new(|| Mutex::new(HashMap::new()));
61
62/// Clear the session key cache. Should be called at the end of a sync operation.
63pub fn clear_key_cache() {
64    if let Ok(mut cache) = KEY_CACHE.lock() {
65        for mut key in cache.drain().map(|(_, v)| v) {
66            key.zeroize();
67        }
68    }
69}
70
71#[derive(Zeroize, ZeroizeOnDrop, Default)]
72struct DerivedKey([u8; 32]);
73
74impl DerivedKey {
75    fn new(key: [u8; 32]) -> Self {
76        Self(key)
77    }
78
79    fn as_slice(&self) -> &[u8] {
80        &self.0
81    }
82}
83
84#[derive(Error, Debug)]
85#[non_exhaustive]
86pub enum CryptoError {
87    #[error("Encryption failed: {0}")]
88    EncryptionFailed(String),
89    #[error("Decryption failed: {0}")]
90    DecryptionFailed(String),
91    #[error("Key derivation failed: {0}")]
92    KeyDerivationFailed(String),
93    #[error("Invalid data: {0}")]
94    InvalidData(String),
95}
96
97pub type CryptoResult<T> = Result<T, CryptoError>;
98
99const NONCE_SIZE: usize = 12;
100const SALT_SIZE: usize = 16;
101
102#[cfg(test)]
103pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
104    a.ct_eq(b).into()
105}
106
107/// Encrypted data container with salt, nonce, and ciphertext.
108///
109/// The salt and nonce are stored alongside the ciphertext to enable
110/// decryption without separate key exchange.
111pub struct EncryptedPayload {
112    pub salt: Vec<u8>,
113    pub nonce: Vec<u8>,
114    pub ciphertext: Vec<u8>,
115}
116
117impl EncryptedPayload {
118    pub fn to_base64(&self) -> String {
119        let mut combined = Vec::with_capacity(SALT_SIZE + NONCE_SIZE + self.ciphertext.len());
120        combined.extend_from_slice(&self.salt);
121        combined.extend_from_slice(&self.nonce);
122        combined.extend_from_slice(&self.ciphertext);
123        BASE64.encode(&combined)
124    }
125
126    pub fn from_base64(data: &str) -> CryptoResult<Self> {
127        let combined = BASE64
128            .decode(data)
129            .map_err(|e| CryptoError::InvalidData(format!("Failed to decode base64: {e}")))?;
130
131        if combined.len() < SALT_SIZE + NONCE_SIZE {
132            return Err(CryptoError::InvalidData("Data too short".to_string()));
133        }
134
135        let salt = combined[..SALT_SIZE].to_vec();
136        let nonce = combined[SALT_SIZE..SALT_SIZE + NONCE_SIZE].to_vec();
137        let ciphertext = combined[SALT_SIZE + NONCE_SIZE..].to_vec();
138
139        Ok(Self {
140            salt,
141            nonce,
142            ciphertext,
143        })
144    }
145}
146
147fn derive_key(api_key: &str, salt: &[u8]) -> CryptoResult<DerivedKey> {
148    let salt_b64 = BASE64.encode(salt);
149    let cache_key = (hash_api_key(api_key), salt_b64);
150
151    // Check cache first
152    {
153        if let Ok(cache) = KEY_CACHE.lock()
154            && let Some(cached) = cache.get(&cache_key)
155        {
156            return Ok(DerivedKey::new(*cached));
157        }
158    }
159
160    let salt_string = SaltString::encode_b64(salt)
161        .map_err(|e| CryptoError::KeyDerivationFailed(format!("Salt encoding failed: {e}")))?;
162
163    let argon2 = Argon2::new(
164        argon2::Algorithm::Argon2id,
165        argon2::Version::V0x13,
166        argon2::Params::new(
167            ARGON2_MEMORY_COST_KIB,
168            ARGON2_TIME_COST,
169            ARGON2_PARALLELISM,
170            Some(32),
171        )
172        .map_err(|e| CryptoError::KeyDerivationFailed(format!("Invalid Argon2 params: {e}")))?,
173    );
174
175    let hash = argon2
176        .hash_password(api_key.as_bytes(), &salt_string)
177        .map_err(|e| CryptoError::KeyDerivationFailed(format!("Hashing failed: {e}")))?;
178
179    let hash_output = hash
180        .hash
181        .ok_or_else(|| CryptoError::KeyDerivationFailed("No hash output".to_string()))?;
182
183    let hash_bytes = hash_output.as_bytes();
184    if hash_bytes.len() < 32 {
185        return Err(CryptoError::KeyDerivationFailed(
186            "Argon2 output too short for AES-256 key".to_string(),
187        ));
188    }
189    let mut key_bytes = [0u8; 32];
190    key_bytes.copy_from_slice(&hash_bytes[..32]);
191
192    // Cache the derived key for future use with the same (api_key, salt)
193    if let Ok(mut cache) = KEY_CACHE.lock() {
194        // Evict half the entries when cache is full. HashMap iteration order is
195        // arbitrary, but this is acceptable for a session-local cache — re-deriving
196        // a key costs less than the initial Argon2id computation.
197        if cache.len() >= MAX_KEY_CACHE_SIZE {
198            let keys_to_remove: Vec<_> =
199                cache.keys().take(MAX_KEY_CACHE_SIZE / 2).cloned().collect();
200            for key in keys_to_remove {
201                if let Some(mut old_key) = cache.remove(&key) {
202                    old_key.zeroize();
203                }
204            }
205        }
206        cache.insert(cache_key, key_bytes);
207    }
208
209    Ok(DerivedKey::new(key_bytes))
210}
211
212pub fn encrypt(api_key: &str, plaintext: &str) -> CryptoResult<String> {
213    let mut salt = [0u8; SALT_SIZE];
214    OsRng.fill_bytes(&mut salt);
215
216    let mut key = derive_key(api_key, &salt)?;
217
218    let cipher = Aes256Gcm::new_from_slice(key.as_slice())
219        .map_err(|e| CryptoError::EncryptionFailed(format!("Key init failed: {e}")))?;
220
221    let mut nonce_bytes = [0u8; NONCE_SIZE];
222    OsRng.fill_bytes(&mut nonce_bytes);
223    let nonce = Nonce::from_slice(&nonce_bytes);
224
225    let ciphertext = cipher
226        .encrypt(nonce, plaintext.as_bytes())
227        .map_err(|e| CryptoError::EncryptionFailed(format!("Encryption failed: {e}")))?;
228
229    drop(std::mem::take(&mut key));
230
231    let payload = EncryptedPayload {
232        salt: salt.to_vec(),
233        nonce: nonce_bytes.to_vec(),
234        ciphertext,
235    };
236
237    Ok(payload.to_base64())
238}
239
240pub fn decrypt(api_key: &str, encrypted_data: &str) -> CryptoResult<String> {
241    let payload = EncryptedPayload::from_base64(encrypted_data)?;
242
243    let mut key = derive_key(api_key, &payload.salt)?;
244
245    let cipher = Aes256Gcm::new_from_slice(key.as_slice())
246        .map_err(|e| CryptoError::DecryptionFailed(format!("Key init failed: {e}")))?;
247
248    let nonce = Nonce::from_slice(&payload.nonce);
249
250    let plaintext = cipher
251        .decrypt(nonce, payload.ciphertext.as_ref())
252        .map_err(|e| CryptoError::DecryptionFailed(format!("Decryption failed: {e}")))?;
253
254    drop(std::mem::take(&mut key));
255
256    String::from_utf8(plaintext)
257        .map_err(|e| CryptoError::DecryptionFailed(format!("UTF-8 conversion failed: {e}")))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_encrypt_decrypt_roundtrip() {
266        let api_key = "test-api-key-12345";
267        let plaintext = "echo 'hello world'";
268
269        let encrypted = encrypt(api_key, plaintext).unwrap();
270        let decrypted = decrypt(api_key, &encrypted).unwrap();
271
272        assert_eq!(plaintext, decrypted);
273    }
274
275    #[test]
276    fn test_different_encryptions_produce_different_output() {
277        let api_key = "test-api-key-12345";
278        let plaintext = "echo 'hello world'";
279
280        let encrypted1 = encrypt(api_key, plaintext).unwrap();
281        let encrypted2 = encrypt(api_key, plaintext).unwrap();
282
283        assert_ne!(encrypted1, encrypted2);
284    }
285
286    #[test]
287    fn test_wrong_key_fails() {
288        let api_key = "test-api-key-12345";
289        let wrong_key = "wrong-key-67890";
290        let plaintext = "echo 'hello world'";
291
292        let encrypted = encrypt(api_key, plaintext).unwrap();
293        let result = decrypt(wrong_key, &encrypted);
294
295        assert!(result.is_err());
296    }
297
298    #[test]
299    fn test_encrypt_empty_string() {
300        let api_key = "test-key";
301        let encrypted = encrypt(api_key, "").unwrap();
302        let decrypted = decrypt(api_key, &encrypted).unwrap();
303        assert_eq!(decrypted, "");
304    }
305
306    #[test]
307    fn test_encrypt_unicode() {
308        let api_key = "test-key";
309        let plaintext = "echo 'héllo wörld'";
310        let encrypted = encrypt(api_key, plaintext).unwrap();
311        let decrypted = decrypt(api_key, &encrypted).unwrap();
312        assert_eq!(decrypted, plaintext);
313    }
314
315    #[test]
316    fn test_encrypt_large_payload() {
317        let api_key = "test-key";
318        let plaintext = "x".repeat(10000);
319        let encrypted = encrypt(api_key, &plaintext).unwrap();
320        let decrypted = decrypt(api_key, &encrypted).unwrap();
321        assert_eq!(decrypted, plaintext);
322    }
323
324    #[test]
325    fn test_invalid_base64_decrypt() {
326        let api_key = "test-key";
327        let result = decrypt(api_key, "not-valid-base64!!!@#");
328        assert!(result.is_err());
329    }
330
331    #[test]
332    fn test_truncated_payload_decrypt() {
333        let api_key = "test-key";
334        let encrypted = encrypt(api_key, "test").unwrap();
335        // Truncate the encrypted data
336        let truncated = &encrypted[..10];
337        let result = decrypt(api_key, truncated);
338        assert!(result.is_err());
339    }
340
341    #[test]
342    fn test_tampered_ciphertext_detected() {
343        let api_key = "test-key";
344        let plaintext = "sensitive data";
345        let encrypted = encrypt(api_key, plaintext).unwrap();
346
347        let mut payload = EncryptedPayload::from_base64(&encrypted).unwrap();
348        // Flip a byte in the ciphertext (AES-GCM should detect this)
349        if payload.ciphertext.len() > 10 {
350            payload.ciphertext[10] ^= 0xFF;
351        }
352        let tampered = payload.to_base64();
353        let result = decrypt(api_key, &tampered);
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_tampered_nonce_detected() {
359        let api_key = "test-key";
360        let plaintext = "sensitive data";
361        let encrypted = encrypt(api_key, plaintext).unwrap();
362
363        let mut payload = EncryptedPayload::from_base64(&encrypted).unwrap();
364        // Flip a byte in the nonce
365        payload.nonce[0] ^= 0xFF;
366        let tampered = payload.to_base64();
367        let result = decrypt(api_key, &tampered);
368        assert!(result.is_err());
369    }
370
371    #[test]
372    fn test_tampered_salt_detected() {
373        let api_key = "test-key";
374        let plaintext = "sensitive data";
375        let encrypted = encrypt(api_key, plaintext).unwrap();
376
377        let mut payload = EncryptedPayload::from_base64(&encrypted).unwrap();
378        // Flip a byte in the salt (different key derivation => decryption fails)
379        payload.salt[0] ^= 0xFF;
380        let tampered = payload.to_base64();
381        let result = decrypt(api_key, &tampered);
382        assert!(result.is_err());
383    }
384
385    #[test]
386    fn test_ct_eq() {
387        let a = b"hello world";
388        let b = b"hello world";
389        let c = b"hello worlx";
390        assert!(ct_eq(a, b));
391        assert!(!ct_eq(a, c));
392        assert!(!ct_eq(a, b""));
393    }
394
395    #[test]
396    fn test_cache_keys_unique() {
397        let key1 = hash_api_key("test-api-key-12345");
398        let key2 = hash_api_key("test-api-key-12346");
399        let key3 = hash_api_key("test-api-key-12345");
400        assert_ne!(key1, key2, "different keys must produce different hashes");
401        assert_eq!(key1, key3, "same key must produce same hash");
402    }
403}