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;
pub const TAG_LEN: usize = 1;
pub const NONCE_LEN: usize = 12; pub const SALT_LEN: usize = 16;
pub const SYMMETRIC_KEY_LEN: usize = 32;
pub const ARGON2_TAG: u8 = 3;
const ARGON2_PARAMS_LEN: usize = 12;
pub fn get_kdf_params() -> Result<Params, AgentError> {
#[cfg(not(any(test, feature = "test-utils")))]
let params = Params::new(65536, 3, 1, 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)))
}
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(())
}
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)
}
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);
}
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
)))
}
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;
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() {
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() {
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() {
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 test_argon2_encrypt_rejects_weak() {
let result = encrypt_bytes(b"data", "weak", EncryptionAlgorithm::AesGcm256);
assert!(matches!(result, Err(AgentError::WeakPassphrase(_))));
}
}