auths-core 0.1.16

Core cryptography and keychain integration for Auths
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Symmetric encryption utilities.

use aes_gcm::{
    Aes256Gcm, Nonce as AesNonce,
    aead::{Aead, KeyInit},
};
use argon2::{Algorithm as Argon2Algorithm, Argon2, Params, Version};
use chacha20poly1305::{ChaCha20Poly1305, Nonce as ChaChaNonce};
use rand::RngCore;
use zeroize::Zeroizing;

use crate::crypto::EncryptionAlgorithm;
use crate::error::AgentError;

/// Byte size of the algorithm tag prefix.
pub const TAG_LEN: usize = 1;
/// Byte size of the nonce used in both AES-GCM and ChaCha20Poly1305.
pub const NONCE_LEN: usize = 12; // we're using 12-byte nonces for both
/// Byte size of the salt used in key derivation.
pub const SALT_LEN: usize = 16;
/// Length in bytes of a symmetric encryption key (256-bit).
pub const SYMMETRIC_KEY_LEN: usize = 32;

/// Tag byte for Argon2id-derived encryption blobs.
pub const ARGON2_TAG: u8 = 3;
/// Length of embedded Argon2 parameters: 3 × u32 LE = 12 bytes.
const ARGON2_PARAMS_LEN: usize = 12;

/// Production Argon2id memory cost in KiB (64 MiB). Exposed as a const so the
/// strength of the at-rest KDF can be asserted regardless of build profile (test
/// builds derive keys with deliberately weak params for speed).
pub const PRODUCTION_KDF_M_COST_KIB: u32 = 65536;
/// Production Argon2id time cost (iterations).
pub const PRODUCTION_KDF_T_COST: u32 = 3;
/// Production Argon2id parallelism.
pub const PRODUCTION_KDF_P_COST: u32 = 1;

/// Returns Argon2id parameters for key derivation.
///
/// Production builds use OWASP-recommended settings (m=64 MiB, t=3).
/// Test and `test-utils` builds use minimal settings (m=8 KiB, t=1) to keep
/// both unit and integration test suites fast. The parameters are embedded in
/// the encrypted blob, so decryption always uses the values from the blob
/// header rather than this getter.
///
/// Args:
/// * None
///
/// Usage:
/// ```ignore
/// let params = get_kdf_params()?;
/// let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
/// ```
pub fn get_kdf_params() -> Result<Params, AgentError> {
    #[cfg(not(any(test, feature = "test-utils")))]
    let params = Params::new(
        PRODUCTION_KDF_M_COST_KIB,
        PRODUCTION_KDF_T_COST,
        PRODUCTION_KDF_P_COST,
        Some(SYMMETRIC_KEY_LEN),
    );
    #[cfg(any(test, feature = "test-utils"))]
    let params = Params::new(8, 1, 1, Some(SYMMETRIC_KEY_LEN));
    params.map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e)))
}

/// Human-readable statement of the passphrase policy [`validate_passphrase`]
/// enforces. Shown before an interactive prompt so the rule is known up front,
/// not learned by violating it. Single source of truth: the prompt hint and the
/// validator read the same policy from here.
pub const PASSPHRASE_POLICY_HINT: &str =
    "12+ characters, at least 3 of: lowercase, uppercase, digit, symbol";

/// Validates that a passphrase meets minimum strength requirements.
///
/// Requires at least 12 characters and at least 3 of 4 character classes:
/// lowercase, uppercase, digit, symbol.
pub fn validate_passphrase(passphrase: &str) -> Result<(), AgentError> {
    if passphrase.len() < 12 {
        return Err(AgentError::WeakPassphrase(format!(
            "Passphrase must be at least 12 characters (got {})",
            passphrase.len()
        )));
    }

    let has_lower = passphrase.chars().any(|c| c.is_ascii_lowercase());
    let has_upper = passphrase.chars().any(|c| c.is_ascii_uppercase());
    let has_digit = passphrase.chars().any(|c| c.is_ascii_digit());
    let has_symbol = passphrase.chars().any(|c| {
        c.is_ascii_punctuation() || (c.is_ascii() && !c.is_ascii_alphanumeric() && c != ' ')
    });

    let class_count = has_lower as u8 + has_upper as u8 + has_digit as u8 + has_symbol as u8;

    if class_count < 3 {
        return Err(AgentError::WeakPassphrase(format!(
            "Passphrase must contain at least 3 of 4 character classes \
             (lowercase, uppercase, digit, symbol); found {}",
            class_count
        )));
    }

    Ok(())
}

/// Encrypt data using Argon2id for key derivation, prepending tag 0x03.
///
/// Output format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
pub fn encrypt_bytes(
    data: &[u8],
    passphrase: &str,
    algo: EncryptionAlgorithm,
) -> Result<Vec<u8>, AgentError> {
    validate_passphrase(passphrase)?;

    let mut salt = [0u8; SALT_LEN];
    rand::rngs::OsRng.fill_bytes(&mut salt);

    let params = get_kdf_params()?;
    let m_cost = params.m_cost();
    let t_cost = params.t_cost();
    let p_cost = params.p_cost();
    let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
    let mut key = Zeroizing::new([0u8; SYMMETRIC_KEY_LEN]);
    argon2
        .hash_password_into(passphrase.as_bytes(), &salt, &mut *key)
        .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;

    let mut nonce = [0u8; NONCE_LEN];
    rand::rngs::OsRng.fill_bytes(&mut nonce);

    let ciphertext = match algo {
        EncryptionAlgorithm::AesGcm256 => {
            let cipher = Aes256Gcm::new_from_slice(&*key)
                .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?;
            cipher
                .encrypt(AesNonce::from_slice(&nonce), data)
                .map_err(|_| AgentError::CryptoError("AES encryption failed".into()))?
        }
        EncryptionAlgorithm::ChaCha20Poly1305 => {
            let cipher = ChaCha20Poly1305::new_from_slice(&*key)
                .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?;
            cipher
                .encrypt(ChaChaNonce::from_slice(&nonce), data)
                .map_err(|_| AgentError::CryptoError("ChaCha encryption failed".into()))?
        }
    };

    let mut out = Vec::with_capacity(
        TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN + ciphertext.len(),
    );
    out.push(ARGON2_TAG);
    out.extend_from_slice(&salt);
    out.extend_from_slice(&m_cost.to_le_bytes());
    out.extend_from_slice(&t_cost.to_le_bytes());
    out.extend_from_slice(&p_cost.to_le_bytes());
    out.push(algo.tag());
    out.extend_from_slice(&nonce);
    out.extend_from_slice(&ciphertext);
    Ok(out)
}

/// Decrypts data using a tagged encryption format and a user-provided passphrase.
///
/// 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]`
///
/// If decryption fails (e.g. due to wrong passphrase), returns
/// `AgentError::IncorrectPassphrase`.
pub fn decrypt_bytes(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
    if encrypted.is_empty() {
        return Err(AgentError::CryptoError("Encrypted data too short".into()));
    }

    let tag = encrypted[0];

    if tag == ARGON2_TAG {
        return decrypt_bytes_argon2(encrypted, passphrase);
    }

    // Legacy HKDF tags 1 (AES) and 2 (ChaCha) are no longer supported
    if tag == 1 || tag == 2 {
        return Err(AgentError::CryptoError(
            "This key was encrypted with a legacy format (HKDF). \
             Re-encrypt it with: auths key migrate"
                .into(),
        ));
    }

    Err(AgentError::CryptoError(format!(
        "Unknown encryption tag: {}",
        tag
    )))
}

/// Decrypts an Argon2id-tagged blob.
///
/// Expected format: `[0x03][salt:16][m_cost:4 LE][t_cost:4 LE][p_cost:4 LE][algo_tag:1][nonce:12][ciphertext]`
fn decrypt_bytes_argon2(encrypted: &[u8], passphrase: &str) -> Result<Vec<u8>, AgentError> {
    const MIN_LEN: usize = TAG_LEN + SALT_LEN + ARGON2_PARAMS_LEN + TAG_LEN + NONCE_LEN;
    if encrypted.len() < MIN_LEN {
        return Err(AgentError::CryptoError(
            "Argon2id encrypted data too short".into(),
        ));
    }

    let mut offset = TAG_LEN; // skip the 0x03 tag

    let salt = &encrypted[offset..offset + SALT_LEN];
    offset += SALT_LEN;

    let m_cost = u32::from_le_bytes(
        encrypted[offset..offset + 4]
            .try_into()
            .map_err(|_| AgentError::CryptoError("invalid m_cost bytes".into()))?,
    );
    offset += 4;
    let t_cost = u32::from_le_bytes(
        encrypted[offset..offset + 4]
            .try_into()
            .map_err(|_| AgentError::CryptoError("invalid t_cost bytes".into()))?,
    );
    offset += 4;
    let p_cost = u32::from_le_bytes(
        encrypted[offset..offset + 4]
            .try_into()
            .map_err(|_| AgentError::CryptoError("invalid p_cost bytes".into()))?,
    );
    offset += 4;

    let algo_tag = encrypted[offset];
    offset += 1;
    let algo = EncryptionAlgorithm::from_tag(algo_tag)
        .ok_or_else(|| AgentError::CryptoError(format!("Unknown encryption tag: {}", algo_tag)))?;

    let nonce = &encrypted[offset..offset + NONCE_LEN];
    offset += NONCE_LEN;

    let ciphertext = &encrypted[offset..];

    let params = Params::new(m_cost, t_cost, p_cost, Some(SYMMETRIC_KEY_LEN))
        .map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e)))?;
    let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
    let mut key = Zeroizing::new([0u8; SYMMETRIC_KEY_LEN]);
    argon2
        .hash_password_into(passphrase.as_bytes(), salt, &mut *key)
        .map_err(|e| AgentError::CryptoError(format!("Argon2 key derivation failed: {}", e)))?;

    let result = match algo {
        EncryptionAlgorithm::AesGcm256 => Aes256Gcm::new_from_slice(&*key)
            .map_err(|_| AgentError::CryptoError("Invalid AES key".into()))?
            .decrypt(AesNonce::from_slice(nonce), ciphertext),

        EncryptionAlgorithm::ChaCha20Poly1305 => ChaCha20Poly1305::new_from_slice(&*key)
            .map_err(|_| AgentError::CryptoError("Invalid ChaCha key".into()))?
            .decrypt(ChaChaNonce::from_slice(nonce), ciphertext),
    };

    match result {
        Ok(plaintext) => Ok(plaintext),
        Err(_) => Err(AgentError::IncorrectPassphrase),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::crypto::EncryptionAlgorithm;

    const STRONG_PASS: &str = "MyStr0ng!Pass";

    #[test]
    fn test_argon2_roundtrip_aes() {
        let data = b"hello argon2 aes";
        let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
        let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
        assert_eq!(data.as_slice(), decrypted.as_slice());
    }

    #[test]
    fn test_argon2_roundtrip_chacha() {
        let data = b"hello argon2 chacha";
        let encrypted =
            encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::ChaCha20Poly1305).unwrap();
        let decrypted = decrypt_bytes(&encrypted, STRONG_PASS).unwrap();
        assert_eq!(data.as_slice(), decrypted.as_slice());
    }

    #[test]
    fn test_legacy_hkdf_tag_returns_migration_error() {
        // Tag 1 = legacy AES-GCM via HKDF
        let mut blob = vec![1u8];
        blob.extend_from_slice(&[0u8; 64]);
        let result = decrypt_bytes(&blob, "any-passphrase");
        match result {
            Err(AgentError::CryptoError(msg)) => {
                assert!(msg.contains("legacy format"), "got: {}", msg);
                assert!(msg.contains("auths key migrate"), "got: {}", msg);
            }
            other => panic!(
                "expected CryptoError with migration message, got: {:?}",
                other
            ),
        }
    }

    #[test]
    fn test_legacy_hkdf_tag2_returns_migration_error() {
        // Tag 2 = legacy ChaCha via HKDF
        let mut blob = vec![2u8];
        blob.extend_from_slice(&[0u8; 64]);
        let result = decrypt_bytes(&blob, "any-passphrase");
        assert!(matches!(result, Err(AgentError::CryptoError(_))));
    }

    #[test]
    fn test_argon2_wrong_passphrase() {
        let data = b"secret";
        let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
        let result = decrypt_bytes(&encrypted, "Wr0ng!Passphrase");
        assert!(matches!(result, Err(AgentError::IncorrectPassphrase)));
    }

    #[test]
    fn test_argon2_blob_starts_with_tag_3() {
        let data = b"tag check";
        let encrypted = encrypt_bytes(data, STRONG_PASS, EncryptionAlgorithm::AesGcm256).unwrap();
        assert_eq!(encrypted[0], ARGON2_TAG);
    }

    #[test]
    fn test_unknown_tag_returns_error() {
        let blob = vec![0xFF; 64];
        let result = decrypt_bytes(&blob, "irrelevant");
        assert!(matches!(result, Err(AgentError::CryptoError(_))));
    }

    #[test]
    fn test_validate_passphrase_too_short() {
        let result = validate_passphrase("Short1!");
        assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
    }

    #[test]
    fn test_validate_passphrase_insufficient_classes() {
        // 14 chars, only lowercase + uppercase = 2 classes
        let result = validate_passphrase("abcdefABCDEFgh");
        assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
    }

    #[test]
    fn test_validate_passphrase_strong() {
        assert!(validate_passphrase(STRONG_PASS).is_ok());
    }

    #[test]
    fn policy_hint_matches_validator() {
        // The shown policy and the enforced policy must never drift: a passphrase
        // that honors the hint (12+ chars, 3+ classes) passes, and ones that break
        // either half fail — so the hint can never promise a rule the validator
        // does not enforce.
        assert!(
            PASSPHRASE_POLICY_HINT.contains("12+"),
            "hint must state the length floor"
        );
        // Honors the hint: 13 chars across lowercase, uppercase, digit, symbol.
        assert!(validate_passphrase("Abcdefg1234!x").is_ok());
        // Long enough but only two classes (lowercase + digit) — must fail.
        assert!(matches!(
            validate_passphrase("abcdefgh1234"),
            Err(AgentError::WeakPassphrase(_))
        ));
        // Enough classes but too short — must fail.
        assert!(matches!(
            validate_passphrase("Ab1!"),
            Err(AgentError::WeakPassphrase(_))
        ));
    }

    #[test]
    fn test_argon2_encrypt_rejects_weak() {
        let result = encrypt_bytes(b"data", "weak", EncryptionAlgorithm::AesGcm256);
        assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
    }

    #[test]
    fn keychain_kdf_cost_is_strong() {
        // The at-rest keychain KDF must make offline brute-force of a weak passphrase
        // expensive. Assert the production Argon2id parameters meet OWASP minimums
        // (m >= 19 MiB, t >= 2) regardless of build profile — test builds derive keys
        // with weaker params for speed, so this checks the production constants.
        const {
            assert!(
                PRODUCTION_KDF_M_COST_KIB >= 19 * 1024,
                "production Argon2 memory cost below the OWASP minimum (19 MiB)"
            )
        };
        const {
            assert!(
                PRODUCTION_KDF_T_COST >= 2,
                "production Argon2 time cost too low"
            )
        };
        const { assert!(PRODUCTION_KDF_P_COST >= 1) };
    }
}