1use 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; const ARGON2_TIME_COST: u32 = 3; const ARGON2_PARALLELISM: u32 = 4; const MAX_KEY_CACHE_SIZE: usize = 10_000;
46
47fn 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
56static KEY_CACHE: LazyLock<Mutex<HashMap<(String, String), [u8; 32]>>> =
60 LazyLock::new(|| Mutex::new(HashMap::new()));
61
62pub 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
107pub 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 {
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 if let Ok(mut cache) = KEY_CACHE.lock() {
194 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 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 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 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 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}