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 rand::RngCore;
10use zeroize::Zeroizing;
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 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/// Production Argon2id memory cost in KiB (64 MiB). Exposed as a const so the
30/// strength of the at-rest KDF can be asserted regardless of build profile (test
31/// builds derive keys with deliberately weak params for speed).
32pub const PRODUCTION_KDF_M_COST_KIB: u32 = 65536;
33/// Production Argon2id time cost (iterations).
34pub const PRODUCTION_KDF_T_COST: u32 = 3;
35/// Production Argon2id parallelism.
36pub const PRODUCTION_KDF_P_COST: u32 = 1;
37
38/// Returns Argon2id parameters for key derivation.
39///
40/// Production builds use OWASP-recommended settings (m=64 MiB, t=3).
41/// Test and `test-utils` builds use minimal settings (m=8 KiB, t=1) to keep
42/// both unit and integration test suites fast. The parameters are embedded in
43/// the encrypted blob, so decryption always uses the values from the blob
44/// header rather than this getter.
45///
46/// Args:
47/// * None
48///
49/// Usage:
50/// ```ignore
51/// let params = get_kdf_params()?;
52/// let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
53/// ```
54pub 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
67/// Validates that a passphrase meets minimum strength requirements.
68///
69/// Requires at least 12 characters and at least 3 of 4 character classes:
70/// lowercase, uppercase, digit, symbol.
71pub 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
99/// Encrypt data using Argon2id for key derivation, prepending tag 0x03.
100///
101/// Output format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
102pub 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
156/// Decrypts data using a tagged encryption format and a user-provided passphrase.
157///
158/// Only supports tag 3 (Argon2id): `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
159///
160/// If decryption fails (e.g. due to wrong passphrase), returns
161/// `AgentError::IncorrectPassphrase`.
162pub 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    // Legacy HKDF tags 1 (AES) and 2 (ChaCha) are no longer supported
174    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
188/// Decrypts an Argon2id-tagged blob.
189///
190/// Expected format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
191fn 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; // skip the 0x03 tag
200
201    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        // Tag 1 = legacy AES-GCM via HKDF
284        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        // Tag 2 = legacy ChaCha via HKDF
302        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        // 14 chars, only lowercase + uppercase = 2 classes
339        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        // The at-rest keychain KDF must make offline brute-force of a weak passphrase
357        // expensive. Assert the production Argon2id parameters meet OWASP minimums
358        // (m >= 19 MiB, t >= 2) regardless of build profile — test builds derive keys
359        // with weaker params for speed, so this checks the production constants.
360        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}