use confers::secret::{XChaCha20Crypto, derive_field_key};
const ENCRYPTION_KEY_ENV: &str = "VECBOOST_ENCRYPTION_KEY";
pub fn validate_encryption_key() -> Result<(), String> {
match read_master_key() {
Some(_) => Ok(()),
None => {
if std::env::var(ENCRYPTION_KEY_ENV).is_err() {
Err(crate::i18n::tr_with_args(
"config-encryption-missing",
crate::i18n::tr_args(&[("key", ENCRYPTION_KEY_ENV)]),
))
} else {
Err(crate::i18n::tr_with_args(
"config-encryption-length",
crate::i18n::tr_args(&[("key", ENCRYPTION_KEY_ENV)]),
))
}
}
}
}
const FIELD_PATH: &str = "vecboost.config.sensitive";
const KEY_VERSION: &str = "v1";
fn read_master_key() -> Option<[u8; 32]> {
let key = match std::env::var(ENCRYPTION_KEY_ENV) {
Ok(k) => k,
Err(_) => {
log::warn!(
"{ENCRYPTION_KEY_ENV} not set — sensitive config fields stored as plaintext. \
Production deployments MUST set this to a 32-byte key \
(e.g. `openssl rand -hex 32`) and enable VECBOOST_REQUIRE_ENCRYPTION=1."
);
return None;
}
};
if key.len() != 32 {
log::warn!(
"{ENCRYPTION_KEY_ENV} must be exactly 32 bytes for XChaCha20-Poly1305, \
got {} bytes — encryption disabled",
key.len()
);
return None;
}
let mut buf = [0u8; 32];
buf.copy_from_slice(key.as_bytes());
Some(buf)
}
fn derive_key(master: &[u8; 32]) -> Result<[u8; 32], String> {
derive_field_key(master, FIELD_PATH, KEY_VERSION)
.map_err(|e| format!("key derivation failed: {e}"))
}
fn encrypt_to_hex(plaintext: &[u8], master: &[u8; 32]) -> Result<String, String> {
let field_key = derive_key(master)?;
let crypto = XChaCha20Crypto::new();
let (nonce, ciphertext) = crypto
.encrypt(plaintext, &field_key)
.map_err(|e| format!("encryption failed: {e}"))?;
let mut combined = nonce;
combined.extend_from_slice(&ciphertext);
Ok(hex::encode(combined))
}
fn decrypt_from_hex(encoded: &str, master: &[u8; 32]) -> Result<Vec<u8>, String> {
let combined = hex::decode(encoded).map_err(|e| format!("invalid hex: {e}"))?;
if combined.len() < confers::secret::NONCE_SIZE {
return Err("encrypted value too short".to_string());
}
let (nonce_bytes, ciphertext) = combined.split_at(confers::secret::NONCE_SIZE);
let field_key = derive_key(master)?;
let crypto = XChaCha20Crypto::new();
crypto
.decrypt(nonce_bytes, ciphertext, &field_key)
.map_err(|e| format!("decryption failed: {e}"))
}
pub mod encrypted_option {
use super::*;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(
value: &Option<String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match value {
None => serializer.serialize_none(),
Some(plaintext) => {
let encrypted = match read_master_key() {
Some(master) => encrypt_to_hex(plaintext.as_bytes(), &master)
.map_err(serde::ser::Error::custom)?,
None => {
plaintext.clone()
}
};
serializer.serialize_str(&encrypted)
}
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
let opt = Option::<String>::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(encoded) => {
let decrypted = match read_master_key() {
Some(master) => match decrypt_from_hex(&encoded, &master) {
Ok(bytes) => String::from_utf8(bytes).map_err(serde::de::Error::custom)?,
Err(_) => {
encoded
}
},
None => encoded,
};
Ok(Some(decrypted))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::test_env_lock::ENV_LOCK;
use serde::{Deserialize, Serialize};
const TEST_KEY: [u8; 32] = *b"vecboost-test-encryption-key-32b";
#[test]
fn test_encrypt_decrypt_hex_roundtrip() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let plaintext = b"super-secret-jwt-token";
let encrypted = encrypt_to_hex(plaintext, &TEST_KEY).expect("encrypt");
let decrypted = decrypt_from_hex(&encrypted, &TEST_KEY).expect("decrypt");
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_decrypt_hex_empty_plaintext() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let plaintext = b"";
let encrypted = encrypt_to_hex(plaintext, &TEST_KEY).expect("encrypt");
let decrypted = decrypt_from_hex(&encrypted, &TEST_KEY).expect("decrypt");
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_decrypt_hex_unicode() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let plaintext = "你好世界🌍".as_bytes();
let encrypted = encrypt_to_hex(plaintext, &TEST_KEY).expect("encrypt");
let decrypted = decrypt_from_hex(&encrypted, &TEST_KEY).expect("decrypt");
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_decrypt_with_wrong_key_fails() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let plaintext = b"secret-data";
let encrypted = encrypt_to_hex(plaintext, &TEST_KEY).expect("encrypt");
let wrong_key = *b"vecboost-wrong-encryption-key32b"; let result = decrypt_from_hex(&encrypted, &wrong_key);
assert!(result.is_err());
}
#[test]
fn test_decrypt_invalid_hex_fails() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let result = decrypt_from_hex("not-valid-hex!", &TEST_KEY);
assert!(result.is_err());
}
#[test]
fn test_decrypt_too_short_value_fails() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let result = decrypt_from_hex("aabbccddee", &TEST_KEY);
assert!(result.is_err());
}
#[test]
fn test_validate_encryption_key_missing() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
let result = validate_encryption_key();
assert!(result.is_err());
}
#[test]
fn test_validate_encryption_key_wrong_length() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("VECBOOST_ENCRYPTION_KEY", "tooshort") };
let result = validate_encryption_key();
assert!(result.is_err());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
}
#[test]
fn test_validate_encryption_key_valid() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(
"VECBOOST_ENCRYPTION_KEY",
"vecboost-test-encryption-key-32b",
)
};
let result = validate_encryption_key();
assert!(result.is_ok());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
}
#[test]
fn test_read_master_key_missing() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
assert!(read_master_key().is_none());
}
#[test]
fn test_read_master_key_wrong_length() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("VECBOOST_ENCRYPTION_KEY", "short") };
assert!(read_master_key().is_none());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
}
#[test]
fn test_read_master_key_valid() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(
"VECBOOST_ENCRYPTION_KEY",
"vecboost-test-encryption-key-32b",
)
};
let key = read_master_key();
assert!(key.is_some());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
}
#[test]
fn test_derive_key_produces_32_bytes() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let derived = derive_key(&TEST_KEY);
assert!(derived.is_ok());
assert_eq!(derived.unwrap().len(), 32);
}
#[test]
fn test_encrypted_option_serde_none() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
#[derive(Serialize, Deserialize)]
struct Cfg {
#[serde(
default,
serialize_with = "encrypted_option::serialize",
deserialize_with = "encrypted_option::deserialize"
)]
val: Option<String>,
}
let cfg = Cfg { val: None };
let json = serde_json::to_string(&cfg).unwrap();
let deserialized: Cfg = serde_json::from_str(&json).unwrap();
assert!(deserialized.val.is_none());
}
#[test]
fn test_encrypted_option_serde_some_no_key() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
#[derive(Serialize, Deserialize)]
struct Cfg {
#[serde(
default,
serialize_with = "encrypted_option::serialize",
deserialize_with = "encrypted_option::deserialize"
)]
val: Option<String>,
}
let cfg = Cfg {
val: Some("plaintext".to_string()),
};
let json = serde_json::to_string(&cfg).unwrap();
let deserialized: Cfg = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.val, Some("plaintext".to_string()));
}
#[test]
fn test_encrypted_option_serde_some_with_key() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(
"VECBOOST_ENCRYPTION_KEY",
"vecboost-test-encryption-key-32b",
)
};
#[derive(Serialize, Deserialize)]
struct Cfg {
#[serde(
default,
serialize_with = "encrypted_option::serialize",
deserialize_with = "encrypted_option::deserialize"
)]
val: Option<String>,
}
let cfg = Cfg {
val: Some("secret".to_string()),
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(!json.contains("\"secret\""));
let deserialized: Cfg = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.val, Some("secret".to_string()));
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
}
#[test]
fn test_encrypted_option_deserialize_none() {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("VECBOOST_ENCRYPTION_KEY") };
#[derive(Serialize, Deserialize)]
struct Cfg {
#[serde(
default,
serialize_with = "encrypted_option::serialize",
deserialize_with = "encrypted_option::deserialize"
)]
val: Option<String>,
}
let json = r#"{}"#;
let deserialized: Cfg = serde_json::from_str(json).unwrap();
assert!(deserialized.val.is_none());
}
}