use base64::Engine as _;
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
use sha2::{Digest, Sha256};
const NONCE_LEN: usize = 24;
pub(crate) const KEY_VAR: &str = "RUSTANGO_SECRET_KEY";
pub(crate) const MIN_KEY_LEN: usize = 32;
#[cfg_attr(not(feature = "tenancy"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KeyStatus {
Missing,
Short,
Ok,
}
#[cfg_attr(not(feature = "tenancy"), allow(dead_code))]
pub(crate) fn key_status() -> KeyStatus {
match env_key() {
None => KeyStatus::Missing,
Some(k) if k.len() < MIN_KEY_LEN => KeyStatus::Short,
Some(_) => KeyStatus::Ok,
}
}
#[cfg(test)]
pub(crate) fn set_test_key() {
std::env::set_var(KEY_VAR, "unit-test-secret-key");
}
#[derive(Debug)]
pub(crate) enum AeadError {
MissingKey,
Encrypt(String),
Decode(String),
Short,
Decrypt,
}
fn env_key() -> Option<String> {
std::env::var(KEY_VAR).ok().filter(|k| !k.trim().is_empty())
}
#[derive(Clone, Copy)]
struct AeadKey<'a>(&'a str);
impl<'a> AeadKey<'a> {
fn new(secret: Option<&'a str>) -> Result<Self, AeadError> {
match secret {
Some(s) if !s.trim().is_empty() => Ok(Self(s)),
_ => Err(AeadError::MissingKey),
}
}
fn previous(secret: Option<&'a str>) -> Result<Self, AeadError> {
secret.map(Self).ok_or(AeadError::MissingKey)
}
fn cipher(self) -> XChaCha20Poly1305 {
let digest = Sha256::digest(self.0.as_bytes());
XChaCha20Poly1305::new(Key::from_slice(&digest))
}
}
pub(crate) fn encrypt(plaintext: &[u8], aad: &[u8]) -> Result<String, AeadError> {
warn_blank_previous(std::env::var(PREVIOUS_KEY_VAR).ok().as_deref());
encrypt_with(env_key().as_deref(), plaintext, aad)
}
fn warn_blank_previous(previous: Option<&str>) -> bool {
static WARNED: std::sync::Once = std::sync::Once::new();
let blank = previous.is_some_and(|p| p.trim().is_empty());
if blank {
WARNED.call_once(|| {
tracing::warn!(
target: "rustango::aead",
"{PREVIOUS_KEY_VAR} is set but blank; run `reseal-tenant-secrets --apply`, then unset it"
);
});
}
blank
}
pub(crate) const PREVIOUS_KEY_VAR: &str = "RUSTANGO_SECRET_KEY_PREVIOUS";
#[cfg_attr(not(feature = "casts"), allow(dead_code))]
pub(crate) fn decrypt(stored: &str, aad: &[u8]) -> Result<Vec<u8>, AeadError> {
let previous = std::env::var(PREVIOUS_KEY_VAR).ok();
warn_blank_previous(previous.as_deref());
decrypt_either(env_key().as_deref(), previous.as_deref(), stored, aad)
}
pub(crate) fn decrypt_either(
current: Option<&str>,
previous: Option<&str>,
stored: &str,
aad: &[u8],
) -> Result<Vec<u8>, AeadError> {
match AeadKey::new(current).and_then(|k| open(k, stored, aad)) {
Err(AeadError::Decrypt | AeadError::MissingKey) if previous.is_some() => {
open(AeadKey::previous(previous)?, stored, aad)
}
other => other,
}
}
pub(crate) fn encrypt_with(
secret: Option<&str>,
plaintext: &[u8],
aad: &[u8],
) -> Result<String, AeadError> {
let cipher = AeadKey::new(secret)?.cipher();
let mut nonce = [0u8; NONCE_LEN];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce);
let ciphertext = cipher
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|e| AeadError::Encrypt(e.to_string()))?;
let mut framed = Vec::with_capacity(NONCE_LEN + ciphertext.len());
framed.extend_from_slice(&nonce);
framed.extend_from_slice(&ciphertext);
Ok(base64::engine::general_purpose::STANDARD.encode(framed))
}
#[cfg(test)]
pub(crate) fn decrypt_with(
secret: Option<&str>,
stored: &str,
aad: &[u8],
) -> Result<Vec<u8>, AeadError> {
open(AeadKey::new(secret)?, stored, aad)
}
fn open(key: AeadKey<'_>, stored: &str, aad: &[u8]) -> Result<Vec<u8>, AeadError> {
let framed = base64::engine::general_purpose::STANDARD
.decode(stored)
.map_err(|e| AeadError::Decode(e.to_string()))?;
if framed.len() < NONCE_LEN {
return Err(AeadError::Short);
}
let (nonce, ciphertext) = framed.split_at(NONCE_LEN);
key.cipher()
.decrypt(
XNonce::from_slice(nonce),
Payload {
msg: ciphertext,
aad,
},
)
.map_err(|_| AeadError::Decrypt)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: Option<&str> = Some("aead-unit-test-key");
#[test]
fn empty_aad_matches_the_plain_slice_api() {
let digest = Sha256::digest(KEY.unwrap().as_bytes());
let cipher = XChaCha20Poly1305::new(Key::from_slice(&digest));
let nonce = [7u8; NONCE_LEN];
let ct = cipher
.encrypt(XNonce::from_slice(&nonce), b"legacy".as_slice())
.unwrap();
let framed = [nonce.as_slice(), &ct].concat();
let stored = base64::engine::general_purpose::STANDARD.encode(framed);
assert_eq!(decrypt_with(KEY, &stored, b"").unwrap(), b"legacy");
}
#[test]
fn only_a_blank_previous_key_warns() {
assert!(warn_blank_previous(Some(" ")));
assert!(!warn_blank_previous(Some("old-key")));
assert!(!warn_blank_previous(None));
}
#[test]
fn a_different_aad_does_not_open() {
let stored = encrypt_with(KEY, b"x", b"one").unwrap();
assert!(matches!(
decrypt_with(KEY, &stored, b"two"),
Err(AeadError::Decrypt)
));
assert_eq!(decrypt_with(KEY, &stored, b"one").unwrap(), b"x");
}
#[test]
fn a_blank_key_never_seals() {
for blank in ["", " "] {
assert!(matches!(
encrypt_with(Some(blank), b"x", b""),
Err(AeadError::MissingKey)
));
}
let digest = Sha256::digest(b"");
let nonce = [1u8; NONCE_LEN];
let ct = XChaCha20Poly1305::new(Key::from_slice(&digest))
.encrypt(XNonce::from_slice(&nonce), b"old".as_slice())
.unwrap();
let stored =
base64::engine::general_purpose::STANDARD.encode([nonce.as_slice(), &ct].concat());
assert!(decrypt_either(Some(""), None, &stored, b"").is_err());
assert_eq!(decrypt_either(KEY, Some(""), &stored, b"").unwrap(), b"old");
}
#[test]
fn a_missing_key_is_its_own_error() {
assert!(matches!(
encrypt_with(None, b"x", b""),
Err(AeadError::MissingKey)
));
let stored = encrypt_with(KEY, b"x", b"").unwrap();
assert!(matches!(
decrypt_with(None, &stored, b""),
Err(AeadError::MissingKey)
));
}
}