1use crate::tenant::current_tenant;
31use crate::types::TenantId;
32use aes_gcm::aead::{Aead, KeyInit, OsRng};
33use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
34use async_trait::async_trait;
35use std::collections::HashMap;
36use std::sync::Arc;
37use thiserror::Error;
38
39#[derive(Debug, Error)]
41#[non_exhaustive]
42pub enum CrypterError {
43 #[error("key destroyed or not configured for tenant {tenant:?}")]
45 KeyDestroyed {
46 tenant: Option<TenantId>,
48 },
49 #[error("aead failed: {0}")]
51 Aead(String),
52 #[error("aad mismatch: stored aad does not match current context")]
56 AadMismatch,
57 #[error("ciphertext too short: {0} bytes")]
59 Truncated(usize),
60 #[error("internal: {0}")]
62 Internal(String),
63}
64
65#[derive(Clone, Debug)]
67#[non_exhaustive]
68pub struct KeyMaterial {
69 pub key: [u8; 32],
71 pub version: u8,
74}
75
76impl KeyMaterial {
77 #[must_use]
79 pub fn new(key: [u8; 32], version: u8) -> Self {
80 Self { key, version }
81 }
82}
83
84pub trait KeyProvider: Send + Sync {
91 fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]>;
95
96 fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
103 self.lookup(tenant)
104 .map(|key| KeyMaterial { key, version: 0 })
105 }
106}
107
108#[non_exhaustive]
110pub struct StaticKeyProvider {
111 keys: HashMap<Option<TenantId>, [u8; 32]>,
112}
113
114impl StaticKeyProvider {
115 #[must_use]
118 pub fn from_map(keys: HashMap<Option<TenantId>, [u8; 32]>) -> Self {
119 Self { keys }
120 }
121}
122
123impl KeyProvider for StaticKeyProvider {
124 fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
125 self.keys.get(tenant).copied()
126 }
127}
128
129#[async_trait]
134pub trait Crypter: Send + Sync {
135 async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError>;
138
139 async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError>;
141}
142
143#[non_exhaustive]
157pub struct TenantKeyedCrypter {
158 keys: Arc<dyn KeyProvider>,
159}
160
161impl TenantKeyedCrypter {
162 #[must_use]
164 pub fn new(keys: Arc<dyn KeyProvider>) -> Self {
165 Self { keys }
166 }
167
168 fn resolve_key(&self, tenant: &Option<TenantId>) -> Result<KeyMaterial, CrypterError> {
169 self.keys
170 .lookup_versioned(tenant)
171 .ok_or_else(|| CrypterError::KeyDestroyed {
172 tenant: tenant.clone(),
173 })
174 }
175
176 fn build_aad(tenant: &Option<TenantId>, version: u8) -> Vec<u8> {
179 let mut aad = match tenant {
180 Some(t) => t.0.as_bytes().to_vec(),
181 None => Vec::new(),
182 };
183 aad.push(version);
184 aad
185 }
186}
187
188const NONCE_LEN: usize = 12;
192const AAD_LEN_FIELD: usize = 2; const MIN_HEADER: usize = NONCE_LEN + AAD_LEN_FIELD;
194
195#[async_trait]
196impl Crypter for TenantKeyedCrypter {
197 async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CrypterError> {
198 let tenant = current_tenant();
199 let km = self.resolve_key(&tenant)?;
200 let cipher = Aes256Gcm::new((&km.key).into());
201 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
202 let aad = Self::build_aad(&tenant, km.version);
203 let aad_len = aad.len() as u16;
204
205 let body = cipher
206 .encrypt(
207 &nonce,
208 aes_gcm::aead::Payload {
209 msg: plaintext,
210 aad: &aad,
211 },
212 )
213 .map_err(|e| CrypterError::Aead(format!("encrypt: {e}")))?;
214
215 let mut out = Vec::with_capacity(NONCE_LEN + AAD_LEN_FIELD + aad.len() + body.len());
218 out.extend_from_slice(nonce.as_ref());
219 out.extend_from_slice(&aad_len.to_le_bytes());
220 out.extend_from_slice(&aad);
221 out.extend_from_slice(&body);
222 Ok(out)
223 }
224
225 async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CrypterError> {
226 if ciphertext.len() < MIN_HEADER {
227 return Err(CrypterError::Truncated(ciphertext.len()));
228 }
229
230 let mut nonce_bytes = [0u8; NONCE_LEN];
234 nonce_bytes.copy_from_slice(&ciphertext[..NONCE_LEN]);
235 let nonce = Nonce::from(nonce_bytes);
236 let aad_len =
237 u16::from_le_bytes([ciphertext[NONCE_LEN], ciphertext[NONCE_LEN + 1]]) as usize;
238
239 let aad_start = MIN_HEADER;
240 let body_start = aad_start + aad_len;
241 if ciphertext.len() < body_start {
242 return Err(CrypterError::Truncated(ciphertext.len()));
243 }
244
245 let stored_aad = &ciphertext[aad_start..body_start];
246 let body = &ciphertext[body_start..];
247
248 let tenant = current_tenant();
249 let km = self.resolve_key(&tenant)?;
250
251 let expected_aad = Self::build_aad(&tenant, km.version);
259 if stored_aad != expected_aad.as_slice() {
260 return Err(CrypterError::AadMismatch);
261 }
262
263 let cipher = Aes256Gcm::new((&km.key).into());
264 cipher
265 .decrypt(
266 &nonce,
267 aes_gcm::aead::Payload {
268 msg: body,
269 aad: stored_aad,
270 },
271 )
272 .map_err(|e| CrypterError::Aead(format!("decrypt: {e}")))
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::tenant::scope_tenant;
280
281 fn provider_with(seed: u8, tenant: &str) -> StaticKeyProvider {
282 let mut keys = HashMap::new();
283 keys.insert(Some(TenantId(tenant.to_string())), [seed; 32]);
284 StaticKeyProvider::from_map(keys)
285 }
286
287 fn two_tenant_provider(
288 seed_a: u8,
289 tenant_a: &str,
290 seed_b: u8,
291 tenant_b: &str,
292 ) -> StaticKeyProvider {
293 let mut keys = HashMap::new();
294 keys.insert(Some(TenantId(tenant_a.to_string())), [seed_a; 32]);
295 keys.insert(Some(TenantId(tenant_b.to_string())), [seed_b; 32]);
296 StaticKeyProvider::from_map(keys)
297 }
298
299 #[tokio::test]
300 async fn encrypt_then_decrypt_round_trip() {
301 let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(1, "BL_auto")));
302 let plaintext = b"sensitive";
303 let result = scope_tenant(Some(TenantId("BL_auto".into())), async {
304 let ct = crypter.encrypt(plaintext).await.expect("encrypt");
305 crypter.decrypt(&ct).await.expect("decrypt")
306 })
307 .await;
308 assert_eq!(result, plaintext);
309 }
310
311 #[tokio::test]
312 async fn missing_tenant_key_returns_key_destroyed() {
313 let crypter =
314 TenantKeyedCrypter::new(Arc::new(StaticKeyProvider::from_map(HashMap::new())));
315 let err = scope_tenant(Some(TenantId("BL_motor".into())), async {
316 crypter.encrypt(b"x").await.unwrap_err()
317 })
318 .await;
319 assert!(matches!(err, CrypterError::KeyDestroyed { .. }));
320 }
321
322 #[tokio::test]
326 async fn cross_tenant_decrypt_fails_with_aad_mismatch() {
327 let crypter = TenantKeyedCrypter::new(Arc::new(two_tenant_provider(
329 42, "tenant_a", 42, "tenant_b",
330 )));
331 let plaintext = b"cross-tenant-secret";
332
333 let ciphertext = scope_tenant(Some(TenantId("tenant_a".into())), async {
335 crypter.encrypt(plaintext).await.expect("encrypt")
336 })
337 .await;
338
339 let err = scope_tenant(Some(TenantId("tenant_b".into())), async {
341 crypter.decrypt(&ciphertext).await.unwrap_err()
342 })
343 .await;
344
345 assert!(
346 matches!(err, CrypterError::AadMismatch),
347 "expected AadMismatch, got {err:?}"
348 );
349 }
350
351 struct VersionedProvider {
353 tenant: TenantId,
354 key: [u8; 32],
355 current_version: u8,
356 }
357
358 impl KeyProvider for VersionedProvider {
359 fn lookup(&self, tenant: &Option<TenantId>) -> Option<[u8; 32]> {
360 if tenant.as_ref() == Some(&self.tenant) {
361 Some(self.key)
362 } else {
363 None
364 }
365 }
366
367 fn lookup_versioned(&self, tenant: &Option<TenantId>) -> Option<KeyMaterial> {
368 self.lookup(tenant).map(|key| KeyMaterial {
369 key,
370 version: self.current_version,
371 })
372 }
373 }
374
375 #[tokio::test]
380 async fn ciphertext_length_matches_v0_6_format() {
381 let tenant = TenantId("BL_format".into());
382 let crypter = TenantKeyedCrypter::new(Arc::new(provider_with(3, "BL_format")));
383 let plaintext = b"hello";
384
385 let ciphertext = scope_tenant(Some(tenant.clone()), async {
386 crypter.encrypt(plaintext).await.expect("encrypt")
387 })
388 .await;
389
390 let aad_len = tenant.0.len() + 1; let expected_len = NONCE_LEN + AAD_LEN_FIELD + aad_len + plaintext.len() + 16;
393 assert_eq!(
394 ciphertext.len(),
395 expected_len,
396 "v0.6 wire format length mismatch: nonce({NONCE_LEN}) + aad_len_field({AAD_LEN_FIELD}) \
397 + aad({aad_len}) + plaintext({}) + tag(16) = {expected_len}",
398 plaintext.len()
399 );
400 }
401
402 #[tokio::test]
405 async fn old_key_version_decrypt_fails_clean() {
406 let tenant = TenantId("tenant_rotation".into());
407
408 let provider_v0 = Arc::new(VersionedProvider {
410 tenant: tenant.clone(),
411 key: [7u8; 32],
412 current_version: 0,
413 });
414 let crypter_v0 = TenantKeyedCrypter::new(provider_v0);
415 let ciphertext = scope_tenant(Some(tenant.clone()), async {
416 crypter_v0.encrypt(b"old-secret").await.expect("encrypt v0")
417 })
418 .await;
419
420 let provider_v1 = Arc::new(VersionedProvider {
422 tenant: tenant.clone(),
423 key: [7u8; 32],
424 current_version: 1,
425 });
426 let crypter_v1 = TenantKeyedCrypter::new(provider_v1);
427 let err = scope_tenant(Some(tenant.clone()), async {
428 crypter_v1.decrypt(&ciphertext).await.unwrap_err()
429 })
430 .await;
431
432 assert!(
433 matches!(err, CrypterError::AadMismatch | CrypterError::Aead(_)),
434 "expected AadMismatch or Aead on version mismatch, got {err:?}"
435 );
436 }
437}