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 const PASSPHRASE_POLICY_HINT: &str =
72 "12+ characters, at least 3 of: lowercase, uppercase, digit, symbol";
73
74pub fn validate_passphrase(passphrase: &str) -> Result<(), AgentError> {
79 if passphrase.len() < 12 {
80 return Err(AgentError::WeakPassphrase(format!(
81 "Passphrase must be at least 12 characters (got {})",
82 passphrase.len()
83 )));
84 }
85
86 let has_lower = passphrase.chars().any(|c| c.is_ascii_lowercase());
87 let has_upper = passphrase.chars().any(|c| c.is_ascii_uppercase());
88 let has_digit = passphrase.chars().any(|c| c.is_ascii_digit());
89 let has_symbol = passphrase.chars().any(|c| {
90 c.is_ascii_punctuation() || (c.is_ascii() && !c.is_ascii_alphanumeric() && c != ' ')
91 });
92
93 let class_count = has_lower as u8 + has_upper as u8 + has_digit as u8 + has_symbol as u8;
94
95 if class_count < 3 {
96 return Err(AgentError::WeakPassphrase(format!(
97 "Passphrase must contain at least 3 of 4 character classes \
98 (lowercase, uppercase, digit, symbol); found {}",
99 class_count
100 )));
101 }
102
103 Ok(())
104}
105
106pub fn encrypt_bytes(
110 data: &[u8],
111 passphrase: &str,
112 algo: EncryptionAlgorithm,
113) -> Result<Vec<u8>, AgentError> {
114 validate_passphrase(passphrase)?;
115
116 let mut salt = [0u8; SALT_LEN];
117 rand::rngs::OsRng.fill_bytes(&mut salt);
118
119 let params = get_kdf_params()?;
120 let m_cost = params.m_cost();
121 let t_cost = params.t_cost();
122 let p_cost = params.p_cost();
123 let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
124 let mut key = Zeroizing::new([0u8; SYMMETRIC_KEY_LEN]);
125 argon2
126 .hash_password_into(passphrase.as_bytes(), &salt, &mut *key)
127 .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;
128
129 let mut nonce = [0u8; NONCE_LEN];
130 rand::rngs::OsRng.fill_bytes(&mut nonce);
131
132 let ciphertext = match algo {
133 EncryptionAlgorithm::AesGcm256 => {
134 let cipher = Aes256Gcm::new_from_slice(&*key)
135 .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?;
136 cipher
137 .encrypt(AesNonce::from_slice(&nonce), data)
138 .map_err(|_| AgentError::CryptoError("AES encryption failed".into()))?
139 }
140 EncryptionAlgorithm::ChaCha20Poly1305 => {
141 let cipher = ChaCha20Poly1305::new_from_slice(&*key)
142 .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?;
143 cipher
144 .encrypt(ChaChaNonce::from_slice(&nonce), data)
145 .map_err(|_| AgentError::CryptoError("ChaCha encryption failed".into()))?
146 }
147 };
148
149 let mut out = Vec::with_capacity(
150 TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN + ciphertext.len(),
151 );
152 out.push(ARGON2_TAG);
153 out.extend_from_slice(&salt);
154 out.extend_from_slice(&m_cost.to_le_bytes());
155 out.extend_from_slice(&t_cost.to_le_bytes());
156 out.extend_from_slice(&p_cost.to_le_bytes());
157 out.push(algo.tag());
158 out.extend_from_slice(&nonce);
159 out.extend_from_slice(&ciphertext);
160 Ok(out)
161}
162
163pub fn decrypt_bytes(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
170 if encrypted.is_empty() {
171 return Err(AgentError::CryptoError("Encrypted data too short".into()));
172 }
173
174 let tag = encrypted[0];
175
176 if tag == ARGON2_TAG {
177 return decrypt_bytes_argon2(encrypted, passphrase);
178 }
179
180 if tag == 1 || tag == 2 {
182 return Err(AgentError::CryptoError(
183 "This key was encrypted with a legacy format (HKDF). \
184 Re-encrypt it with: auths key migrate"
185 .into(),
186 ));
187 }
188
189 Err(AgentError::CryptoError(format!(
190 "Unknown encryption tag: {}",
191 tag
192 )))
193}
194
195fn decrypt_bytes_argon2(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
199 const MIN_LEN: usize = TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN;
200 if encrypted.len() < MIN_LEN {
201 return Err(AgentError::CryptoError(
202 "Argon2id encrypted data too short".into(),
203 ));
204 }
205
206 let mut offset = TAG_LEN; let salt = &encrypted[offset..offset + SALT_LEN];
209 offset += SALT_LEN;
210
211 let m_cost = u32::from_le_bytes(
212 encrypted[offset..offset + 4]
213 .try_into()
214 .map_err(|_| AgentError::CryptoError("invalid m_cost bytes".into()))?,
215 );
216 offset += 4;
217 let t_cost = u32::from_le_bytes(
218 encrypted[offset..offset + 4]
219 .try_into()
220 .map_err(|_| AgentError::CryptoError("invalid t_cost bytes".into()))?,
221 );
222 offset += 4;
223 let p_cost = u32::from_le_bytes(
224 encrypted[offset..offset + 4]
225 .try_into()
226 .map_err(|_| AgentError::CryptoError("invalid p_cost bytes".into()))?,
227 );
228 offset += 4;
229
230 let algo_tag = encrypted[offset];
231 offset += 1;
232 let algo = EncryptionAlgorithm::from_tag(algo_tag)
233 .ok_or_else(|| AgentError::CryptoError(format!("Unknown encryption tag: {}", algo_tag)))?;
234
235 let nonce = &encrypted[offset..offset + NONCE_LEN];
236 offset += NONCE_LEN;
237
238 let ciphertext = &encrypted[offset..];
239
240 let params = Params::new(m_cost, t_cost, p_cost, Some(SYMMETRIC_KEY_LEN))
241 .map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e)))?;
242 let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
243 let mut key = Zeroizing::new([0u8; SYMMETRIC_KEY_LEN]);
244 argon2
245 .hash_password_into(passphrase.as_bytes(), salt, &mut *key)
246 .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;
247
248 let result = match algo {
249 EncryptionAlgorithm::AesGcm256 => Aes256Gcm::new_from_slice(&*key)
250 .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?
251 .decrypt(AesNonce::from_slice(nonce), ciphertext),
252
253 EncryptionAlgorithm::ChaCha20Poly1305 => ChaCha20Poly1305::new_from_slice(&*key)
254 .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?
255 .decrypt(ChaChaNonce::from_slice(nonce), ciphertext),
256 };
257
258 match result {
259 Ok(plaintext) => Ok(plaintext),
260 Err(_) => Err(AgentError::IncorrectPassphrase),
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::crypto::EncryptionAlgorithm;
268
269 const STRONG_PASS: &str = "MyStr0ng!Pass";
270
271 #[test]
272 fn test_argon2_roundtrip_aes() {
273 let data = b"hello argon2 aes";
274 let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
275 let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
276 assert_eq!(data.as_slice(), decrypted.as_slice());
277 }
278
279 #[test]
280 fn test_argon2_roundtrip_chacha() {
281 let data = b"hello argon2 chacha";
282 let encrypted =
283 encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::ChaCha20Poly1305).unwrap();
284 let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
285 assert_eq!(data.as_slice(), decrypted.as_slice());
286 }
287
288 #[test]
289 fn test_legacy_hkdf_tag_returns_migration_error() {
290 let mut blob = vec![1u8];
292 blob.extend_from_slice(&[0u8; 64]);
293 let result = decrypt_bytes(&blob, "any-passphrase");
294 match result {
295 Err(AgentError::CryptoError(msg)) => {
296 assert!(msg.contains("legacy format"), "got: {}", msg);
297 assert!(msg.contains("auths key migrate"), "got: {}", msg);
298 }
299 other => panic!(
300 "expected CryptoError with migration message, got: {:?}",
301 other
302 ),
303 }
304 }
305
306 #[test]
307 fn test_legacy_hkdf_tag2_returns_migration_error() {
308 let mut blob = vec![2u8];
310 blob.extend_from_slice(&[0u8; 64]);
311 let result = decrypt_bytes(&blob, "any-passphrase");
312 assert!(matches!(result, Err(AgentError::CryptoError(_))));
313 }
314
315 #[test]
316 fn test_argon2_wrong_passphrase() {
317 let data = b"secret";
318 let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
319 let result = decrypt_bytes(&encrypted, "Wr0ng!Passphrase");
320 assert!(matches!(result, Err(AgentError::IncorrectPassphrase)));
321 }
322
323 #[test]
324 fn test_argon2_blob_starts_with_tag_3() {
325 let data = b"tag check";
326 let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
327 assert_eq!(encrypted[0], ARGON2_TAG);
328 }
329
330 #[test]
331 fn test_unknown_tag_returns_error() {
332 let blob = vec![0xFF; 64];
333 let result = decrypt_bytes(&blob, "irrelevant");
334 assert!(matches!(result, Err(AgentError::CryptoError(_))));
335 }
336
337 #[test]
338 fn test_validate_passphrase_too_short() {
339 let result = validate_passphrase("Short1!");
340 assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
341 }
342
343 #[test]
344 fn test_validate_passphrase_insufficient_classes() {
345 let result = validate_passphrase("abcdefABCDEFgh");
347 assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
348 }
349
350 #[test]
351 fn test_validate_passphrase_strong() {
352 assert!(validate_passphrase(STRONG_PASS).is_ok());
353 }
354
355 #[test]
356 fn policy_hint_matches_validator() {
357 assert!(
362 PASSPHRASE_POLICY_HINT.contains("12+"),
363 "hint must state the length floor"
364 );
365 assert!(validate_passphrase("Abcdefg1234!x").is_ok());
367 assert!(matches!(
369 validate_passphrase("abcdefgh1234"),
370 Err(AgentError::WeakPassphrase(_))
371 ));
372 assert!(matches!(
374 validate_passphrase("Ab1!"),
375 Err(AgentError::WeakPassphrase(_))
376 ));
377 }
378
379 #[test]
380 fn test_argon2_encrypt_rejects_weak() {
381 let result = encrypt_bytes(b"data", "weak", EncryptionAlgorithm::AesGcm256);
382 assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
383 }
384
385 #[test]
386 fn keychain_kdf_cost_is_strong() {
387 const {
392 assert!(
393 PRODUCTION_KDF_M_COST_KIB >= 19 * 1024,
394 "production Argon2 memory cost below the OWASP minimum (19 MiB)"
395 )
396 };
397 const {
398 assert!(
399 PRODUCTION_KDF_T_COST >= 2,
400 "production Argon2 time cost too low"
401 )
402 };
403 const { assert!(PRODUCTION_KDF_P_COST >= 1) };
404 }
405}