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/// Human-readable statement of the passphrase policy [`validate_passphrase`]
68/// enforces. Shown before an interactive prompt so the rule is known up front,
69/// not learned by violating it. Single source of truth: the prompt hint and the
70/// validator read the same policy from here.
71pub const PASSPHRASE_POLICY_HINT: &str =
72    "12+ characters, at least 3 of: lowercase, uppercase, digit, symbol";
73
74/// Validates that a passphrase meets minimum strength requirements.
75///
76/// Requires at least 12 characters and at least 3 of 4 character classes:
77/// lowercase, uppercase, digit, symbol.
78pub 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
106/// Encrypt data using Argon2id for key derivation, prepending tag 0x03.
107///
108/// Output format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
109pub 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
163/// Decrypts data using a tagged encryption format and a user-provided passphrase.
164///
165/// 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]`
166///
167/// If decryption fails (e.g. due to wrong passphrase), returns
168/// `AgentError::IncorrectPassphrase`.
169pub 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    // Legacy HKDF tags 1 (AES) and 2 (ChaCha) are no longer supported
181    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
195/// Decrypts an Argon2id-tagged blob.
196///
197/// Expected format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
198fn 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; // skip the 0x03 tag
207
208    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        // Tag 1 = legacy AES-GCM via HKDF
291        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        // Tag 2 = legacy ChaCha via HKDF
309        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        // 14 chars, only lowercase + uppercase = 2 classes
346        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        // The shown policy and the enforced policy must never drift: a passphrase
358        // that honors the hint (12+ chars, 3+ classes) passes, and ones that break
359        // either half fail — so the hint can never promise a rule the validator
360        // does not enforce.
361        assert!(
362            PASSPHRASE_POLICY_HINT.contains("12+"),
363            "hint must state the length floor"
364        );
365        // Honors the hint: 13 chars across lowercase, uppercase, digit, symbol.
366        assert!(validate_passphrase("Abcdefg1234!x").is_ok());
367        // Long enough but only two classes (lowercase + digit) — must fail.
368        assert!(matches!(
369            validate_passphrase("abcdefgh1234"),
370            Err(AgentError::WeakPassphrase(_))
371        ));
372        // Enough classes but too short — must fail.
373        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        // The at-rest keychain KDF must make offline brute-force of a weak passphrase
388        // expensive. Assert the production Argon2id parameters meet OWASP minimums
389        // (m >= 19 MiB, t >= 2) regardless of build profile — test builds derive keys
390        // with weaker params for speed, so this checks the production constants.
391        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}