1use aes_gcm::{
4 Aes256Gcm, Nonce as AesNonce,
5 aead::{Aead, KeyInit},
6};
7use argon2::{Algorithm as Argon2Algorithm, Argon2, Params, Version};
8use chacha20poly1305::{ChaCha20Poly1305, Nonce as ChaChaNonce};
9use rand::RngCore;
10use zeroize::Zeroizing;
11
12use crate::crypto::EncryptionAlgorithm;
13use crate::error::AgentError;
14
15pub const TAG_LEN: usize = 1;
17pub const NONCE_LEN: usize = 12; pub const SALT_LEN: usize = 16;
21pub const SYMMETRIC_KEY_LEN: usize = 32;
23
24pub const ARGON2_TAG: u8 = 3;
26const ARGON2_PARAMS_LEN: usize = 12;
28
29pub const PRODUCTION_KDF_M_COST_KIB: u32 = 65536;
33pub const PRODUCTION_KDF_T_COST: u32 = 3;
35pub const PRODUCTION_KDF_P_COST: u32 = 1;
37
38pub fn get_kdf_params() -> Result<Params, AgentError> {
55 #[cfg(not(any(test, feature = "test-utils")))]
56 let params = Params::new(
57 PRODUCTION_KDF_M_COST_KIB,
58 PRODUCTION_KDF_T_COST,
59 PRODUCTION_KDF_P_COST,
60 Some(SYMMETRIC_KEY_LEN),
61 );
62 #[cfg(any(test, feature = "test-utils"))]
63 let params = Params::new(8, 1, 1, Some(SYMMETRIC_KEY_LEN));
64 params.map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e)))
65}
66
67pub fn validate_passphrase(passphrase: &str) -> Result<(), AgentError> {
72 if passphrase.len() < 12 {
73 return Err(AgentError::WeakPassphrase(format!(
74 "Passphrase must be at least 12 characters (got {})",
75 passphrase.len()
76 )));
77 }
78
79 let has_lower = passphrase.chars().any(|c| c.is_ascii_lowercase());
80 let has_upper = passphrase.chars().any(|c| c.is_ascii_uppercase());
81 let has_digit = passphrase.chars().any(|c| c.is_ascii_digit());
82 let has_symbol = passphrase.chars().any(|c| {
83 c.is_ascii_punctuation() || (c.is_ascii() && !c.is_ascii_alphanumeric() && c != ' ')
84 });
85
86 let class_count = has_lower as u8 + has_upper as u8 + has_digit as u8 + has_symbol as u8;
87
88 if class_count < 3 {
89 return Err(AgentError::WeakPassphrase(format!(
90 "Passphrase must contain at least 3 of 4 character classes \
91 (lowercase, uppercase, digit, symbol); found {}",
92 class_count
93 )));
94 }
95
96 Ok(())
97}
98
99pub fn encrypt_bytes(
103 data: &[u8],
104 passphrase: &str,
105 algo: EncryptionAlgorithm,
106) -> Result<Vec<u8>, AgentError> {
107 validate_passphrase(passphrase)?;
108
109 let mut salt = [0u8; SALT_LEN];
110 rand::rngs::OsRng.fill_bytes(&mut salt);
111
112 let params = get_kdf_params()?;
113 let m_cost = params.m_cost();
114 let t_cost = params.t_cost();
115 let p_cost = params.p_cost();
116 let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
117 let mut key = Zeroizing::new([0u8; SYMMETRIC_KEY_LEN]);
118 argon2
119 .hash_password_into(passphrase.as_bytes(), &salt, &mut *key)
120 .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;
121
122 let mut nonce = [0u8; NONCE_LEN];
123 rand::rngs::OsRng.fill_bytes(&mut nonce);
124
125 let ciphertext = match algo {
126 EncryptionAlgorithm::AesGcm256 => {
127 let cipher = Aes256Gcm::new_from_slice(&*key)
128 .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?;
129 cipher
130 .encrypt(AesNonce::from_slice(&nonce), data)
131 .map_err(|_| AgentError::CryptoError("AES encryption failed".into()))?
132 }
133 EncryptionAlgorithm::ChaCha20Poly1305 => {
134 let cipher = ChaCha20Poly1305::new_from_slice(&*key)
135 .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?;
136 cipher
137 .encrypt(ChaChaNonce::from_slice(&nonce), data)
138 .map_err(|_| AgentError::CryptoError("ChaCha encryption failed".into()))?
139 }
140 };
141
142 let mut out = Vec::with_capacity(
143 TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN + ciphertext.len(),
144 );
145 out.push(ARGON2_TAG);
146 out.extend_from_slice(&salt);
147 out.extend_from_slice(&m_cost.to_le_bytes());
148 out.extend_from_slice(&t_cost.to_le_bytes());
149 out.extend_from_slice(&p_cost.to_le_bytes());
150 out.push(algo.tag());
151 out.extend_from_slice(&nonce);
152 out.extend_from_slice(&ciphertext);
153 Ok(out)
154}
155
156pub fn decrypt_bytes(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
163 if encrypted.is_empty() {
164 return Err(AgentError::CryptoError("Encrypted data too short".into()));
165 }
166
167 let tag = encrypted[0];
168
169 if tag == ARGON2_TAG {
170 return decrypt_bytes_argon2(encrypted, passphrase);
171 }
172
173 if tag == 1 || tag == 2 {
175 return Err(AgentError::CryptoError(
176 "This key was encrypted with a legacy format (HKDF). \
177 Re-encrypt it with: auths key migrate"
178 .into(),
179 ));
180 }
181
182 Err(AgentError::CryptoError(format!(
183 "Unknown encryption tag: {}",
184 tag
185 )))
186}
187
188fn decrypt_bytes_argon2(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
192 const MIN_LEN: usize = TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN;
193 if encrypted.len() < MIN_LEN {
194 return Err(AgentError::CryptoError(
195 "Argon2id encrypted data too short".into(),
196 ));
197 }
198
199 let mut offset = TAG_LEN; let salt = &encrypted[offset..offset + SALT_LEN];
202 offset += SALT_LEN;
203
204 let m_cost = u32::from_le_bytes(
205 encrypted[offset..offset + 4]
206 .try_into()
207 .map_err(|_| AgentError::CryptoError("invalid m_cost bytes".into()))?,
208 );
209 offset += 4;
210 let t_cost = u32::from_le_bytes(
211 encrypted[offset..offset + 4]
212 .try_into()
213 .map_err(|_| AgentError::CryptoError("invalid t_cost bytes".into()))?,
214 );
215 offset += 4;
216 let p_cost = u32::from_le_bytes(
217 encrypted[offset..offset + 4]
218 .try_into()
219 .map_err(|_| AgentError::CryptoError("invalid p_cost bytes".into()))?,
220 );
221 offset += 4;
222
223 let algo_tag = encrypted[offset];
224 offset += 1;
225 let algo = EncryptionAlgorithm::from_tag(algo_tag)
226 .ok_or_else(|| AgentError::CryptoError(format!("Unknown encryption tag: {}", algo_tag)))?;
227
228 let nonce = &encrypted[offset..offset + NONCE_LEN];
229 offset += NONCE_LEN;
230
231 let ciphertext = &encrypted[offset..];
232
233 let params = Params::new(m_cost, t_cost, p_cost, Some(SYMMETRIC_KEY_LEN))
234 .map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e)))?;
235 let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
236 let mut key = Zeroizing::new([0u8; SYMMETRIC_KEY_LEN]);
237 argon2
238 .hash_password_into(passphrase.as_bytes(), salt, &mut *key)
239 .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;
240
241 let result = match algo {
242 EncryptionAlgorithm::AesGcm256 => Aes256Gcm::new_from_slice(&*key)
243 .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?
244 .decrypt(AesNonce::from_slice(nonce), ciphertext),
245
246 EncryptionAlgorithm::ChaCha20Poly1305 => ChaCha20Poly1305::new_from_slice(&*key)
247 .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?
248 .decrypt(ChaChaNonce::from_slice(nonce), ciphertext),
249 };
250
251 match result {
252 Ok(plaintext) => Ok(plaintext),
253 Err(_) => Err(AgentError::IncorrectPassphrase),
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::crypto::EncryptionAlgorithm;
261
262 const STRONG_PASS: &str = "MyStr0ng!Pass";
263
264 #[test]
265 fn test_argon2_roundtrip_aes() {
266 let data = b"hello argon2 aes";
267 let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
268 let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
269 assert_eq!(data.as_slice(), decrypted.as_slice());
270 }
271
272 #[test]
273 fn test_argon2_roundtrip_chacha() {
274 let data = b"hello argon2 chacha";
275 let encrypted =
276 encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::ChaCha20Poly1305).unwrap();
277 let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
278 assert_eq!(data.as_slice(), decrypted.as_slice());
279 }
280
281 #[test]
282 fn test_legacy_hkdf_tag_returns_migration_error() {
283 let mut blob = vec![1u8];
285 blob.extend_from_slice(&[0u8; 64]);
286 let result = decrypt_bytes(&blob, "any-passphrase");
287 match result {
288 Err(AgentError::CryptoError(msg)) => {
289 assert!(msg.contains("legacy format"), "got: {}", msg);
290 assert!(msg.contains("auths key migrate"), "got: {}", msg);
291 }
292 other => panic!(
293 "expected CryptoError with migration message, got: {:?}",
294 other
295 ),
296 }
297 }
298
299 #[test]
300 fn test_legacy_hkdf_tag2_returns_migration_error() {
301 let mut blob = vec![2u8];
303 blob.extend_from_slice(&[0u8; 64]);
304 let result = decrypt_bytes(&blob, "any-passphrase");
305 assert!(matches!(result, Err(AgentError::CryptoError(_))));
306 }
307
308 #[test]
309 fn test_argon2_wrong_passphrase() {
310 let data = b"secret";
311 let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
312 let result = decrypt_bytes(&encrypted, "Wr0ng!Passphrase");
313 assert!(matches!(result, Err(AgentError::IncorrectPassphrase)));
314 }
315
316 #[test]
317 fn test_argon2_blob_starts_with_tag_3() {
318 let data = b"tag check";
319 let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
320 assert_eq!(encrypted[0], ARGON2_TAG);
321 }
322
323 #[test]
324 fn test_unknown_tag_returns_error() {
325 let blob = vec![0xFF; 64];
326 let result = decrypt_bytes(&blob, "irrelevant");
327 assert!(matches!(result, Err(AgentError::CryptoError(_))));
328 }
329
330 #[test]
331 fn test_validate_passphrase_too_short() {
332 let result = validate_passphrase("Short1!");
333 assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
334 }
335
336 #[test]
337 fn test_validate_passphrase_insufficient_classes() {
338 let result = validate_passphrase("abcdefABCDEFgh");
340 assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
341 }
342
343 #[test]
344 fn test_validate_passphrase_strong() {
345 assert!(validate_passphrase(STRONG_PASS).is_ok());
346 }
347
348 #[test]
349 fn test_argon2_encrypt_rejects_weak() {
350 let result = encrypt_bytes(b"data", "weak", EncryptionAlgorithm::AesGcm256);
351 assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
352 }
353
354 #[test]
355 fn keychain_kdf_cost_is_strong() {
356 const {
361 assert!(
362 PRODUCTION_KDF_M_COST_KIB >= 19 * 1024,
363 "production Argon2 memory cost below the OWASP minimum (19 MiB)"
364 )
365 };
366 const {
367 assert!(
368 PRODUCTION_KDF_T_COST >= 2,
369 "production Argon2 time cost too low"
370 )
371 };
372 const { assert!(PRODUCTION_KDF_P_COST >= 1) };
373 }
374}