Skip to main content

auths_core/crypto/
encryption.rs

1//! Symmetric encryption utilities.
2
3use 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 hkdf::Hkdf;
10use sha2::Sha256;
11
12use crate::crypto::EncryptionAlgorithm;
13use crate::error::AgentError;
14
15/// Byte size of the algorithm tag prefix.
16pub const TAG_LEN: usize = 1;
17/// Byte size of the nonce used in both AES-GCM and ChaCha20Poly1305.
18pub const NONCE_LEN: usize = 12; // we're using 12-byte nonces for both
19/// Byte size of the salt used in HKDF key derivation.
20pub const SALT_LEN: usize = 16;
21/// Length in bytes of a symmetric encryption key (256-bit).
22pub const SYMMETRIC_KEY_LEN: usize = 32;
23
24/// Tag byte for Argon2id-derived encryption blobs.
25pub const ARGON2_TAG: u8 = 3;
26/// Length of embedded Argon2 parameters: 3 × u32 LE = 12 bytes.
27const ARGON2_PARAMS_LEN: usize = 12;
28
29/// Returns Argon2id parameters for key derivation.
30///
31/// Production builds use OWASP-recommended settings (m=64 MiB, t=3).
32/// Test and `test-utils` builds use minimal settings (m=8 KiB, t=1) to keep
33/// both unit and integration test suites fast. The parameters are embedded in
34/// the encrypted blob, so decryption always uses the values from the blob
35/// header rather than this getter.
36///
37/// Args:
38/// * None
39///
40/// Usage:
41/// ```ignore
42/// let params = get_kdf_params()?;
43/// let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
44/// ```
45pub fn get_kdf_params() -> Result<Params, AgentError> {
46    #[cfg(not(any(test, feature = "test-utils")))]
47    let params = Params::new(65536, 3, 1, Some(SYMMETRIC_KEY_LEN));
48    #[cfg(any(test, feature = "test-utils"))]
49    let params = Params::new(8, 1, 1, Some(SYMMETRIC_KEY_LEN));
50    params.map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e)))
51}
52
53/// Encrypt data, prepending a tag to identify algorithm during decryption.
54pub fn encrypt_bytes(
55    data: &[u8],
56    passphrase: &str,
57    algo: EncryptionAlgorithm,
58) -> Result<Vec<u8>, AgentError> {
59    let salt: [u8; SALT_LEN] = rand::random();
60    let hk = Hkdf::<Sha256>::new(Some(&salt), passphrase.as_bytes());
61    let mut key = [0u8; SYMMETRIC_KEY_LEN];
62    hk.expand(&[], &mut key)
63        .map_err(|_| AgentError::CryptoError("HKDF expand failed".into()))?;
64
65    let nonce: [u8; NONCE_LEN] = rand::random();
66
67    match algo {
68        EncryptionAlgorithm::AesGcm256 => {
69            let cipher = Aes256Gcm::new_from_slice(&key)
70                .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?;
71
72            let ciphertext = cipher
73                .encrypt(AesNonce::from_slice(&nonce), data)
74                .map_err(|_| AgentError::CryptoError("AES encryption failed".into()))?;
75
76            let mut out = vec![algo.tag()];
77            out.extend_from_slice(&salt);
78            out.extend_from_slice(&nonce);
79            out.extend_from_slice(&ciphertext);
80            Ok(out)
81        }
82
83        EncryptionAlgorithm::ChaCha20Poly1305 => {
84            let cipher = ChaCha20Poly1305::new_from_slice(&key)
85                .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?;
86
87            let ciphertext = cipher
88                .encrypt(ChaChaNonce::from_slice(&nonce), data)
89                .map_err(|_| AgentError::CryptoError("ChaCha encryption failed".into()))?;
90
91            let mut out = vec![algo.tag()];
92            out.extend_from_slice(&salt);
93            out.extend_from_slice(&nonce);
94            out.extend_from_slice(&ciphertext);
95            Ok(out)
96        }
97    }
98}
99
100/// Validates that a passphrase meets minimum strength requirements.
101///
102/// Requires at least 12 characters and at least 3 of 4 character classes:
103/// lowercase, uppercase, digit, symbol.
104pub fn validate_passphrase(passphrase: &str) -> Result<(), AgentError> {
105    if passphrase.len() < 12 {
106        return Err(AgentError::WeakPassphrase(format!(
107            "Passphrase must be at least 12 characters (got {})",
108            passphrase.len()
109        )));
110    }
111
112    let has_lower = passphrase.chars().any(|c| c.is_ascii_lowercase());
113    let has_upper = passphrase.chars().any(|c| c.is_ascii_uppercase());
114    let has_digit = passphrase.chars().any(|c| c.is_ascii_digit());
115    let has_symbol = passphrase.chars().any(|c| {
116        c.is_ascii_punctuation() || (c.is_ascii() && !c.is_ascii_alphanumeric() && c != ' ')
117    });
118
119    let class_count = has_lower as u8 + has_upper as u8 + has_digit as u8 + has_symbol as u8;
120
121    if class_count < 3 {
122        return Err(AgentError::WeakPassphrase(format!(
123            "Passphrase must contain at least 3 of 4 character classes \
124             (lowercase, uppercase, digit, symbol); found {}",
125            class_count
126        )));
127    }
128
129    Ok(())
130}
131
132/// Encrypt data using Argon2id for key derivation, prepending tag 0x03.
133///
134/// Output format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
135pub fn encrypt_bytes_argon2(
136    data: &[u8],
137    passphrase: &str,
138    algo: EncryptionAlgorithm,
139) -> Result<Vec<u8>, AgentError> {
140    validate_passphrase(passphrase)?;
141
142    let salt: [u8; SALT_LEN] = rand::random();
143
144    // Derive key with Argon2id
145    let params = get_kdf_params()?;
146    let m_cost = params.m_cost();
147    let t_cost = params.t_cost();
148    let p_cost = params.p_cost();
149    let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
150    let mut key = [0u8; SYMMETRIC_KEY_LEN];
151    argon2
152        .hash_password_into(passphrase.as_bytes(), &salt, &mut key)
153        .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;
154
155    let nonce: [u8; NONCE_LEN] = rand::random();
156
157    let ciphertext = match algo {
158        EncryptionAlgorithm::AesGcm256 => {
159            let cipher = Aes256Gcm::new_from_slice(&key)
160                .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?;
161            cipher
162                .encrypt(AesNonce::from_slice(&nonce), data)
163                .map_err(|_| AgentError::CryptoError("AES encryption failed".into()))?
164        }
165        EncryptionAlgorithm::ChaCha20Poly1305 => {
166            let cipher = ChaCha20Poly1305::new_from_slice(&key)
167                .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?;
168            cipher
169                .encrypt(ChaChaNonce::from_slice(&nonce), data)
170                .map_err(|_| AgentError::CryptoError("ChaCha encryption failed".into()))?
171        }
172    };
173
174    // Build output: [tag][salt][m_cost LE][t_cost LE][p_cost LE][algo_tag][nonce][ciphertext]
175    let mut out = Vec::with_capacity(
176        TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN + ciphertext.len(),
177    );
178    out.push(ARGON2_TAG);
179    out.extend_from_slice(&salt);
180    out.extend_from_slice(&m_cost.to_le_bytes());
181    out.extend_from_slice(&t_cost.to_le_bytes());
182    out.extend_from_slice(&p_cost.to_le_bytes());
183    out.push(algo.tag());
184    out.extend_from_slice(&nonce);
185    out.extend_from_slice(&ciphertext);
186    Ok(out)
187}
188
189/// Decrypts data using a tagged encryption format and a user-provided passphrase.
190///
191/// Supports three tag formats:
192/// - Tag 1 (AES-GCM) / Tag 2 (ChaCha20): Legacy HKDF path `[tag][salt:16][nonce:12][ciphertext]`
193/// - Tag 3 (Argon2id): `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
194///
195/// If decryption fails (e.g. due to wrong passphrase), returns
196/// `AgentError::IncorrectPassphrase`.
197pub fn decrypt_bytes(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
198    if encrypted.is_empty() {
199        return Err(AgentError::CryptoError("Encrypted data too short".into()));
200    }
201
202    let tag = encrypted[0];
203
204    if tag == ARGON2_TAG {
205        return decrypt_bytes_argon2(encrypted, passphrase);
206    }
207
208    // Legacy HKDF path (tags 1, 2)
209    if encrypted.len() < TAG_LEN + SALT_LEN + NONCE_LEN {
210        return Err(AgentError::CryptoError("Encrypted data too short".into()));
211    }
212
213    let algo = EncryptionAlgorithm::from_tag(tag)
214        .ok_or_else(|| AgentError::CryptoError(format!("Unknown encryption tag: {}", tag)))?;
215
216    let rest = &encrypted[TAG_LEN..];
217    let (salt, remaining) = rest.split_at(SALT_LEN);
218    let (nonce, ciphertext) = remaining.split_at(NONCE_LEN);
219
220    let hkdf = Hkdf::<Sha256>::new(Some(salt), passphrase.as_bytes());
221    let mut key = [0u8; SYMMETRIC_KEY_LEN];
222    hkdf.expand(&[], &mut key)
223        .map_err(|_| AgentError::CryptoError("HKDF expand failed".into()))?;
224
225    let result = match algo {
226        EncryptionAlgorithm::AesGcm256 => Aes256Gcm::new_from_slice(&key)
227            .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?
228            .decrypt(AesNonce::from_slice(nonce), ciphertext),
229
230        EncryptionAlgorithm::ChaCha20Poly1305 => ChaCha20Poly1305::new_from_slice(&key)
231            .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?
232            .decrypt(ChaChaNonce::from_slice(nonce), ciphertext),
233    };
234
235    match result {
236        Ok(plaintext) => Ok(plaintext),
237        Err(_) => Err(AgentError::IncorrectPassphrase),
238    }
239}
240
241/// Decrypts an Argon2id-tagged blob.
242///
243/// Expected format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
244fn decrypt_bytes_argon2(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
245    const MIN_LEN: usize = TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN;
246    if encrypted.len() < MIN_LEN {
247        return Err(AgentError::CryptoError(
248            "Argon2id encrypted data too short".into(),
249        ));
250    }
251
252    let mut offset = TAG_LEN; // skip the 0x03 tag
253
254    let salt = &encrypted[offset..offset + SALT_LEN];
255    offset += SALT_LEN;
256
257    let m_cost = u32::from_le_bytes(
258        encrypted[offset..offset + 4]
259            .try_into()
260            .map_err(|_| AgentError::CryptoError("invalid m_cost bytes".into()))?,
261    );
262    offset += 4;
263    let t_cost = u32::from_le_bytes(
264        encrypted[offset..offset + 4]
265            .try_into()
266            .map_err(|_| AgentError::CryptoError("invalid t_cost bytes".into()))?,
267    );
268    offset += 4;
269    let p_cost = u32::from_le_bytes(
270        encrypted[offset..offset + 4]
271            .try_into()
272            .map_err(|_| AgentError::CryptoError("invalid p_cost bytes".into()))?,
273    );
274    offset += 4;
275
276    let algo_tag = encrypted[offset];
277    offset += 1;
278    let algo = EncryptionAlgorithm::from_tag(algo_tag)
279        .ok_or_else(|| AgentError::CryptoError(format!("Unknown encryption tag: {}", algo_tag)))?;
280
281    let nonce = &encrypted[offset..offset + NONCE_LEN];
282    offset += NONCE_LEN;
283
284    let ciphertext = &encrypted[offset..];
285
286    // Derive key with Argon2id using embedded params
287    let params = Params::new(m_cost, t_cost, p_cost, Some(SYMMETRIC_KEY_LEN))
288        .map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e)))?;
289    let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
290    let mut key = [0u8; SYMMETRIC_KEY_LEN];
291    argon2
292        .hash_password_into(passphrase.as_bytes(), salt, &mut key)
293        .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;
294
295    let result = match algo {
296        EncryptionAlgorithm::AesGcm256 => Aes256Gcm::new_from_slice(&key)
297            .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?
298            .decrypt(AesNonce::from_slice(nonce), ciphertext),
299
300        EncryptionAlgorithm::ChaCha20Poly1305 => ChaCha20Poly1305::new_from_slice(&key)
301            .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?
302            .decrypt(ChaChaNonce::from_slice(nonce), ciphertext),
303    };
304
305    match result {
306        Ok(plaintext) => Ok(plaintext),
307        Err(_) => Err(AgentError::IncorrectPassphrase),
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::crypto::EncryptionAlgorithm;
315
316    const STRONG_PASS: &str = "MyStr0ng!Pass";
317
318    #[test]
319    fn test_argon2_roundtrip_aes() {
320        let data = b"hello argon2 aes";
321        let encrypted =
322            encrypt_bytes_argon2(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
323        let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
324        assert_eq!(data.as_slice(), decrypted.as_slice());
325    }
326
327    #[test]
328    fn test_argon2_roundtrip_chacha() {
329        let data = b"hello argon2 chacha";
330        let encrypted =
331            encrypt_bytes_argon2(data, STRONG_PASS, EncryptionAlgorithm::ChaCha20Poly1305).unwrap();
332        let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
333        assert_eq!(data.as_slice(), decrypted.as_slice());
334    }
335
336    #[test]
337    fn test_legacy_hkdf_decrypt_still_works() {
338        let data = b"legacy data";
339        let encrypted =
340            encrypt_bytes(data, "any-passphrase", EncryptionAlgorithm::AesGcm256).unwrap();
341        let decrypted = decrypt_bytes(&encrypted, "any-passphrase").unwrap();
342        assert_eq!(data.as_slice(), decrypted.as_slice());
343    }
344
345    #[test]
346    fn test_argon2_wrong_passphrase() {
347        let data = b"secret";
348        let encrypted =
349            encrypt_bytes_argon2(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
350        let result = decrypt_bytes(&encrypted, "Wr0ng!Passphrase");
351        assert!(matches!(result, Err(AgentError::IncorrectPassphrase)));
352    }
353
354    #[test]
355    fn test_argon2_blob_starts_with_tag_3() {
356        let data = b"tag check";
357        let encrypted =
358            encrypt_bytes_argon2(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
359        assert_eq!(encrypted[0], ARGON2_TAG);
360    }
361
362    #[test]
363    fn test_unknown_tag_returns_error() {
364        let blob = vec![0xFF; 64];
365        let result = decrypt_bytes(&blob, "irrelevant");
366        assert!(matches!(result, Err(AgentError::CryptoError(_))));
367    }
368
369    #[test]
370    fn test_validate_passphrase_too_short() {
371        let result = validate_passphrase("Short1!");
372        assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
373    }
374
375    #[test]
376    fn test_validate_passphrase_insufficient_classes() {
377        // 14 chars, only lowercase + uppercase = 2 classes
378        let result = validate_passphrase("abcdefABCDEFgh");
379        assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
380    }
381
382    #[test]
383    fn test_validate_passphrase_strong() {
384        assert!(validate_passphrase(STRONG_PASS).is_ok());
385    }
386
387    #[test]
388    fn test_argon2_encrypt_rejects_weak() {
389        let result = encrypt_bytes_argon2(b"data", "weak", EncryptionAlgorithm::AesGcm256);
390        assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
391    }
392}