use crate::tenant::current_tenant;
use crate::types::TenantId;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CrypterError {
#[error("key destroyed or not configured for tenant {tenant:?}")]
KeyDestroyed {
tenant: Option<TenantId>,
},
#[error("aead failed: {0}")]
Aead(String),
#[error("aad mismatch: stored aad does not match current context")]
AadMismatch,
#[error("ciphertext too short: {0} bytes")]
Truncated(usize),
#[error("internal: {0}")]
Internal(String),
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct KeyMaterial {
pub key: [u8; 32],
pub version: u8,
}
impl KeyMaterial {
#[must_use]
pub fn new(key: [u8; 32], version: u8) -> Self {
Self { key, version }
}
}
pub trait KeyProvider: Send + Sync {
fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]>;
fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
self.lookup(tenant)
.map(|key| KeyMaterial { key, version: 0 })
}
}
#[non_exhaustive]
pub struct StaticKeyProvider {
keys: HashMap<Option<TenantId>, [u8; 32]>,
}
impl StaticKeyProvider {
#[must_use]
pub fn from_map(keys: HashMap<Option<TenantId>, [u8; 32]>) -> Self {
Self { keys }
}
}
impl KeyProvider for StaticKeyProvider {
fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
self.keys.get(tenant).copied()
}
}
#[async_trait]
pub trait Crypter: Send + Sync {
async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError>;
async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError>;
}
#[non_exhaustive]
pub struct TenantKeyedCrypter {
keys: Arc<dyn KeyProvider>,
}
impl TenantKeyedCrypter {
#[must_use]
pub fn new(keys: Arc<dyn KeyProvider>) -> Self {
Self { keys }
}
fn resolve_key(&self, tenant: &Option<TenantId>) -> Result<KeyMaterial, CrypterError> {
self.keys
.lookup_versioned(tenant)
.ok_or_else(|| CrypterError::KeyDestroyed {
tenant: tenant.clone(),
})
}
fn build_aad(tenant: &Option<TenantId>, version: u8) -> Vec<u8> {
let mut aad = match tenant {
Some(t) => t.0.as_bytes().to_vec(),
None => Vec::new(),
};
aad.push(version);
aad
}
}
const NONCE_LEN: usize = 12;
const AAD_LEN_FIELD: usize = 2; const MIN_HEADER: usize = NONCE_LEN + AAD_LEN_FIELD;
#[async_trait]
impl Crypter for TenantKeyedCrypter {
async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError> {
let tenant = current_tenant();
let km = self.resolve_key(&tenant)?;
let cipher = Aes256Gcm::new((&km.key).into());
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let aad = Self::build_aad(&tenant, km.version);
let aad_len = aad.len() as u16;
let body = cipher
.encrypt(
&nonce,
aes_gcm::aead::Payload {
msg: plaintext,
aad: &aad,
},
)
.map_err(|e| CrypterError::Aead(format!("encrypt: {e}")))?;
let mut out = Vec::with_capacity(NONCE_LEN + AAD_LEN_FIELD + aad.len() + body.len());
out.extend_from_slice(nonce.as_ref());
out.extend_from_slice(&aad_len.to_le_bytes());
out.extend_from_slice(&aad);
out.extend_from_slice(&body);
Ok(out)
}
async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError> {
if ciphertext.len() < MIN_HEADER {
return Err(CrypterError::Truncated(ciphertext.len()));
}
let mut nonce_bytes = [0u8; NONCE_LEN];
nonce_bytes.copy_from_slice(&ciphertext[..NONCE_LEN]);
let nonce = Nonce::from(nonce_bytes);
let aad_len =
u16::from_le_bytes([ciphertext[NONCE_LEN], ciphertext[NONCE_LEN + 1]]) as usize;
let aad_start = MIN_HEADER;
let body_start = aad_start + aad_len;
if ciphertext.len() < body_start {
return Err(CrypterError::Truncated(ciphertext.len()));
}
let stored_aad = &ciphertext[aad_start..body_start];
let body = &ciphertext[body_start..];
let tenant = current_tenant();
let km = self.resolve_key(&tenant)?;
let expected_aad = Self::build_aad(&tenant, km.version);
if stored_aad != expected_aad.as_slice() {
return Err(CrypterError::AadMismatch);
}
let cipher = Aes256Gcm::new((&km.key).into());
cipher
.decrypt(
&nonce,
aes_gcm::aead::Payload {
msg: body,
aad: stored_aad,
},
)
.map_err(|e| CrypterError::Aead(format!("decrypt: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tenant::scope_tenant;
fn provider_with(seed: u8, tenant: &str) -> StaticKeyProvider {
let mut keys = HashMap::new();
keys.insert(Some(TenantId(tenant.to_string())), [seed; 32]);
StaticKeyProvider::from_map(keys)
}
fn two_tenant_provider(
seed_a: u8,
tenant_a: &str,
seed_b: u8,
tenant_b: &str,
) -> StaticKeyProvider {
let mut keys = HashMap::new();
keys.insert(Some(TenantId(tenant_a.to_string())), [seed_a; 32]);
keys.insert(Some(TenantId(tenant_b.to_string())), [seed_b; 32]);
StaticKeyProvider::from_map(keys)
}
#[tokio::test]
async fn encrypt_then_decrypt_round_trip() {
let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(1, "BL_auto")));
let plaintext = b"sensitive";
let result = scope_tenant(Some(TenantId("BL_auto".into())), async {
let ct = crypter.encrypt(plaintext).await.expect("encrypt");
crypter.decrypt(&ct).await.expect("decrypt")
})
.await;
assert_eq!(result, plaintext);
}
#[tokio::test]
async fn missing_tenant_key_returns_key_destroyed() {
let crypter =
TenantKeyedCrypter::new(Arc::new(StaticKeyProvider::from_map(HashMap::new())));
let err = scope_tenant(Some(TenantId("BL_motor".into())), async {
crypter.encrypt(b"x").await.unwrap_err()
})
.await;
assert!(matches!(err, CrypterError::KeyDestroyed { .. }));
}
#[tokio::test]
async fn cross_tenant_decrypt_fails_with_aad_mismatch() {
let crypter = TenantKeyedCrypter::new(Arc::new(two_tenant_provider(
42, "tenant_a", 42, "tenant_b",
)));
let plaintext = b"cross-tenant-secret";
let ciphertext = scope_tenant(Some(TenantId("tenant_a".into())), async {
crypter.encrypt(plaintext).await.expect("encrypt")
})
.await;
let err = scope_tenant(Some(TenantId("tenant_b".into())), async {
crypter.decrypt(&ciphertext).await.unwrap_err()
})
.await;
assert!(
matches!(err, CrypterError::AadMismatch),
"expected AadMismatch, got {err:?}"
);
}
struct VersionedProvider {
tenant: TenantId,
key: [u8; 32],
current_version: u8,
}
impl KeyProvider for VersionedProvider {
fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
if tenant.as_ref() == Some(&self.tenant) {
Some(self.key)
} else {
None
}
}
fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
self.lookup(tenant).map(|key| KeyMaterial {
key,
version: self.current_version,
})
}
}
#[tokio::test]
async fn ciphertext_length_matches_v0_6_format() {
let tenant = TenantId("BL_format".into());
let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(3, "BL_format")));
let plaintext = b"hello";
let ciphertext = scope_tenant(Some(tenant.clone()), async {
crypter.encrypt(plaintext).await.expect("encrypt")
})
.await;
let aad_len = tenant.0.len() + 1; let expected_len = NONCE_LEN + AAD_LEN_FIELD + aad_len + plaintext.len() + 16;
assert_eq!(
ciphertext.len(),
expected_len,
"v0.6 wire format length mismatch: nonce({NONCE_LEN}) + aad_len_field({AAD_LEN_FIELD}) \
+ aad({aad_len}) + plaintext({}) + tag(16) = {expected_len}",
plaintext.len()
);
}
#[tokio::test]
async fn old_key_version_decrypt_fails_clean() {
let tenant = TenantId("tenant_rotation".into());
let provider_v0 = Arc::new(VersionedProvider {
tenant: tenant.clone(),
key: [7u8; 32],
current_version: 0,
});
let crypter_v0 = TenantKeyedCrypter::new(provider_v0);
let ciphertext = scope_tenant(Some(tenant.clone()), async {
crypter_v0.encrypt(b"old-secret").await.expect("encrypt v0")
})
.await;
let provider_v1 = Arc::new(VersionedProvider {
tenant: tenant.clone(),
key: [7u8; 32],
current_version: 1,
});
let crypter_v1 = TenantKeyedCrypter::new(provider_v1);
let err = scope_tenant(Some(tenant.clone()), async {
crypter_v1.decrypt(&ciphertext).await.unwrap_err()
})
.await;
assert!(
matches!(err, CrypterError::AadMismatch | CrypterError::Aead(_)),
"expected AadMismatch or Aead on version mismatch, got {err:?}"
);
}
}