use argon2::{Algorithm, Argon2, Params, Version};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
use chacha20poly1305::{AeadCore, XChaCha20Poly1305, XNonce};
use crate::error::{Result, ZeptoError};
const ARGON2_SALT_LEN: usize = 16;
const XCHACHA_NONCE_LEN: usize = 24;
const FORMAT_VERSION: &str = "1";
pub struct SecretEncryption {
key: [u8; 32],
}
impl SecretEncryption {
pub fn from_passphrase(passphrase: &str) -> Result<Self> {
let salt = Self::random_bytes::<ARGON2_SALT_LEN>();
let key = Self::derive_key(passphrase, &salt)?;
Ok(Self { key })
}
pub fn from_raw_key(key: &[u8; 32]) -> Self {
Self { key: *key }
}
pub fn is_encrypted(value: &str) -> bool {
value.starts_with("ENC[") && value.ends_with(']')
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
let salt = Self::random_bytes::<ARGON2_SALT_LEN>();
let cipher_key = self.key;
let cipher = XChaCha20Poly1305::new((&cipher_key).into());
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| ZeptoError::Config(format!("encryption failed: {e}")))?;
let salt_b64 = BASE64.encode(salt);
let nonce_b64 = BASE64.encode(nonce.as_slice());
let ct_b64 = BASE64.encode(&ciphertext);
Ok(format!(
"ENC[{FORMAT_VERSION}:{salt_b64}:{nonce_b64}:{ct_b64}]"
))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String> {
if !Self::is_encrypted(encrypted) {
return Err(ZeptoError::Config(
"value is not an encrypted envelope".into(),
));
}
let inner = &encrypted[4..encrypted.len() - 1];
let parts: Vec<&str> = inner.split(':').collect();
if parts.len() != 4 {
return Err(ZeptoError::Config(format!(
"invalid encrypted format: expected 4 parts, got {}",
parts.len()
)));
}
let version = parts[0];
if version != FORMAT_VERSION {
return Err(ZeptoError::Config(format!(
"unsupported encryption version: {version}"
)));
}
let _salt_bytes = BASE64
.decode(parts[1])
.map_err(|e| ZeptoError::Config(format!("invalid salt encoding: {e}")))?;
let nonce_bytes = BASE64
.decode(parts[2])
.map_err(|e| ZeptoError::Config(format!("invalid nonce encoding: {e}")))?;
if nonce_bytes.len() != XCHACHA_NONCE_LEN {
return Err(ZeptoError::Config(format!(
"invalid nonce length: expected {XCHACHA_NONCE_LEN}, got {}",
nonce_bytes.len()
)));
}
let ciphertext = BASE64
.decode(parts[3])
.map_err(|e| ZeptoError::Config(format!("invalid ciphertext encoding: {e}")))?;
let nonce = XNonce::from_slice(&nonce_bytes);
let cipher = XChaCha20Poly1305::new((&self.key).into());
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| ZeptoError::Config(format!("decryption failed: {e}")))?;
String::from_utf8(plaintext)
.map_err(|e| ZeptoError::Config(format!("decrypted value is not valid UTF-8: {e}")))
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32]> {
let params = Params::new(65536, 3, 1, Some(32))
.map_err(|e| ZeptoError::Config(format!("Argon2 params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| ZeptoError::Config(format!("key derivation failed: {e}")))?;
Ok(key)
}
fn random_bytes<const N: usize>() -> [u8; N] {
use chacha20poly1305::aead::rand_core::RngCore;
let mut buf = [0u8; N];
OsRng.fill_bytes(&mut buf);
buf
}
}
pub struct PassphraseEncryption {
passphrase: String,
}
impl PassphraseEncryption {
pub fn new(passphrase: &str) -> Self {
Self {
passphrase: passphrase.to_string(),
}
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
let salt = SecretEncryption::random_bytes::<ARGON2_SALT_LEN>();
let key = SecretEncryption::derive_key(&self.passphrase, &salt)?;
let cipher = XChaCha20Poly1305::new((&key).into());
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| ZeptoError::Config(format!("encryption failed: {e}")))?;
let salt_b64 = BASE64.encode(salt);
let nonce_b64 = BASE64.encode(nonce.as_slice());
let ct_b64 = BASE64.encode(&ciphertext);
Ok(format!(
"ENC[{FORMAT_VERSION}:{salt_b64}:{nonce_b64}:{ct_b64}]"
))
}
pub fn decrypt(&self, encrypted: &str) -> Result<String> {
if !SecretEncryption::is_encrypted(encrypted) {
return Err(ZeptoError::Config(
"value is not an encrypted envelope".into(),
));
}
let inner = &encrypted[4..encrypted.len() - 1];
let parts: Vec<&str> = inner.split(':').collect();
if parts.len() != 4 {
return Err(ZeptoError::Config(format!(
"invalid encrypted format: expected 4 parts, got {}",
parts.len()
)));
}
let version = parts[0];
if version != FORMAT_VERSION {
return Err(ZeptoError::Config(format!(
"unsupported encryption version: {version}"
)));
}
let salt_bytes = BASE64
.decode(parts[1])
.map_err(|e| ZeptoError::Config(format!("invalid salt encoding: {e}")))?;
let nonce_bytes = BASE64
.decode(parts[2])
.map_err(|e| ZeptoError::Config(format!("invalid nonce encoding: {e}")))?;
if nonce_bytes.len() != XCHACHA_NONCE_LEN {
return Err(ZeptoError::Config(format!(
"invalid nonce length: expected {XCHACHA_NONCE_LEN}, got {}",
nonce_bytes.len()
)));
}
let ciphertext = BASE64
.decode(parts[3])
.map_err(|e| ZeptoError::Config(format!("invalid ciphertext encoding: {e}")))?;
let key = SecretEncryption::derive_key(&self.passphrase, &salt_bytes)?;
let nonce = XNonce::from_slice(&nonce_bytes);
let cipher = XChaCha20Poly1305::new((&key).into());
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| ZeptoError::Config(format!("decryption failed: {e}")))?;
String::from_utf8(plaintext)
.map_err(|e| ZeptoError::Config(format!("decrypted value is not valid UTF-8: {e}")))
}
}
pub fn is_secret_field(field_name: &str) -> bool {
matches!(
field_name,
"api_key"
| "token"
| "bot_token"
| "app_token"
| "auth_token"
| "access_token"
| "app_secret"
| "client_secret"
| "encrypt_key"
| "verification_token"
| "service_account_base64"
| "webhook_verify_token"
)
}
pub fn resolve_master_key(interactive: bool) -> Result<SecretEncryption> {
if let Ok(hex_key) = std::env::var("ZEPTOCLAW_MASTER_KEY") {
let bytes = hex::decode(hex_key.trim()).map_err(|e| {
ZeptoError::Config(format!("ZEPTOCLAW_MASTER_KEY is not valid hex: {e}"))
})?;
if bytes.len() != 32 {
return Err(ZeptoError::Config(format!(
"ZEPTOCLAW_MASTER_KEY must be 64 hex chars (32 bytes), got {} bytes",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(SecretEncryption::from_raw_key(&key));
}
if interactive {
let passphrase = rpassword::prompt_password("Enter master passphrase: ")
.map_err(|e| ZeptoError::Config(format!("failed to read passphrase: {e}")))?;
if passphrase.is_empty() {
return Err(ZeptoError::Config("passphrase cannot be empty".into()));
}
return SecretEncryption::from_passphrase(&passphrase);
}
Err(ZeptoError::Config(
"no master key available: set ZEPTOCLAW_MASTER_KEY env var or use interactive mode".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_encrypted_true() {
assert!(SecretEncryption::is_encrypted("ENC[1:abc:def:ghi]"));
assert!(SecretEncryption::is_encrypted("ENC[anything]"));
}
#[test]
fn test_is_encrypted_false() {
assert!(!SecretEncryption::is_encrypted("sk-abc123"));
assert!(!SecretEncryption::is_encrypted(""));
assert!(!SecretEncryption::is_encrypted("ENC"));
assert!(!SecretEncryption::is_encrypted("ENC["));
assert!(!SecretEncryption::is_encrypted("ENC]"));
assert!(!SecretEncryption::is_encrypted("[ENC]"));
assert!(!SecretEncryption::is_encrypted("plain text"));
}
#[test]
fn test_round_trip_passphrase() {
let enc = PassphraseEncryption::new("test-passphrase-42");
let plaintext = "sk-secret-api-key-12345";
let encrypted = enc.encrypt(plaintext).unwrap();
assert!(SecretEncryption::is_encrypted(&encrypted));
let decrypted = enc.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_round_trip_raw_key() {
let key: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
];
let enc = SecretEncryption::from_raw_key(&key);
let plaintext = "super-secret-token";
let encrypted = enc.encrypt(plaintext).unwrap();
assert!(SecretEncryption::is_encrypted(&encrypted));
let decrypted = enc.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_wrong_passphrase_fails() {
let enc1 = PassphraseEncryption::new("correct-passphrase");
let enc2 = PassphraseEncryption::new("wrong-passphrase");
let encrypted = enc1.encrypt("secret-data").unwrap();
let result = enc2.decrypt(&encrypted);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("decryption failed"));
}
#[test]
fn test_corrupted_ciphertext_fails() {
let key = [0xABu8; 32];
let enc = SecretEncryption::from_raw_key(&key);
let encrypted = enc.encrypt("test-value").unwrap();
let inner = &encrypted[4..encrypted.len() - 1];
let parts: Vec<&str> = inner.split(':').collect();
let corrupted = format!(
"ENC[{}:{}:{}:{}]",
parts[0],
parts[1],
parts[2],
BASE64.encode(b"totally-corrupted-ciphertext-data")
);
let result = enc.decrypt(&corrupted);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("decryption failed"));
}
#[test]
fn test_invalid_format_fails() {
let key = [0x42u8; 32];
let enc = SecretEncryption::from_raw_key(&key);
assert!(enc.decrypt("not-encrypted").is_err());
assert!(enc.decrypt("ENC[1:two:parts]").is_err());
assert!(enc.decrypt("ENC[only-one]").is_err());
assert!(enc.decrypt("ENC[99:a:b:c]").is_err());
}
#[test]
fn test_empty_plaintext() {
let key = [0x55u8; 32];
let enc = SecretEncryption::from_raw_key(&key);
let encrypted = enc.encrypt("").unwrap();
assert!(SecretEncryption::is_encrypted(&encrypted));
let decrypted = enc.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, "");
}
#[test]
fn test_each_encrypt_produces_unique_output() {
let key = [0x77u8; 32];
let enc = SecretEncryption::from_raw_key(&key);
let plaintext = "same-secret";
let a = enc.encrypt(plaintext).unwrap();
let b = enc.encrypt(plaintext).unwrap();
assert_ne!(a, b);
assert_eq!(enc.decrypt(&a).unwrap(), plaintext);
assert_eq!(enc.decrypt(&b).unwrap(), plaintext);
}
#[test]
fn test_encrypted_format_has_four_parts() {
let key = [0x11u8; 32];
let enc = SecretEncryption::from_raw_key(&key);
let encrypted = enc.encrypt("test").unwrap();
assert!(encrypted.starts_with("ENC["));
assert!(encrypted.ends_with(']'));
let inner = &encrypted[4..encrypted.len() - 1];
let parts: Vec<&str> = inner.split(':').collect();
assert_eq!(parts.len(), 4, "envelope must have 4 colon-separated parts");
assert_eq!(parts[0], "1", "version must be '1'");
let salt = BASE64.decode(parts[1]).unwrap();
assert_eq!(salt.len(), ARGON2_SALT_LEN);
let nonce = BASE64.decode(parts[2]).unwrap();
assert_eq!(nonce.len(), XCHACHA_NONCE_LEN);
let ct = BASE64.decode(parts[3]).unwrap();
assert!(!ct.is_empty());
}
#[test]
fn test_secret_field_names() {
let secret_fields = [
"api_key",
"token",
"bot_token",
"app_token",
"auth_token",
"access_token",
"app_secret",
"client_secret",
"encrypt_key",
"verification_token",
"service_account_base64",
"webhook_verify_token",
];
for field in &secret_fields {
assert!(
is_secret_field(field),
"{field} should be detected as a secret field"
);
}
let non_secret_fields = [
"model",
"name",
"workspace",
"enabled",
"max_retries",
"base_url",
"provider",
"temperature",
];
for field in &non_secret_fields {
assert!(
!is_secret_field(field),
"{field} should NOT be detected as a secret field"
);
}
}
}