1use crate::crypto::dek_cache::{CacheKey, DekCache, DekMaterial};
2use crate::key_provider::KeyProvider;
3use crate::spec_compat::{
4 DecryptError, DecryptResult, EncryptionAlgorithm, Envelope, Error, Result, Scope, SecretMeta,
5 SecretRecord,
6};
7use base64::{Engine, engine::general_purpose::STANDARD};
8use hkdf::Hkdf;
9use rand::Rng;
10#[cfg(feature = "crypto-ring")]
11use ring::{
12 aead,
13 rand::{SecureRandom, SystemRandom},
14};
15use sha2::Sha256;
16use std::env;
17
18const DEFAULT_DEK_LEN: usize = 32;
19const HKDF_SALT_LEN: usize = 32;
20#[cfg(feature = "crypto-ring")]
21const NONCE_LEN: usize = 12;
22#[cfg(feature = "crypto-ring")]
23const TAG_LEN: usize = 16;
24const ENC_ALGO_ENV: &str = "SECRETS_ENC_ALGO";
25
26#[cfg(not(any(feature = "crypto-ring", feature = "crypto-none")))]
27compile_error!("Enable either the `crypto-ring` or `crypto-none` feature for envelope encryption");
28
29pub struct EnvelopeService<P>
31where
32 P: KeyProvider,
33{
34 provider: P,
35 cache: DekCache,
36 algorithm: EncryptionAlgorithm,
37}
38
39impl<P> EnvelopeService<P>
40where
41 P: KeyProvider,
42{
43 pub fn new(provider: P, cache: DekCache, algorithm: EncryptionAlgorithm) -> Self {
45 Self {
46 provider,
47 cache,
48 algorithm,
49 }
50 }
51
52 pub fn from_env(provider: P) -> Result<Self> {
54 let algorithm = env::var(ENC_ALGO_ENV)
55 .ok()
56 .filter(|s| !s.trim().is_empty())
57 .map(|value| value.parse())
58 .transpose()?
59 .unwrap_or_default();
60
61 Ok(Self::new(provider, DekCache::from_env(), algorithm))
62 }
63
64 pub fn algorithm(&self) -> EncryptionAlgorithm {
66 self.algorithm
67 }
68
69 pub fn cache(&self) -> &DekCache {
71 &self.cache
72 }
73
74 pub fn cache_mut(&mut self) -> &mut DekCache {
76 &mut self.cache
77 }
78
79 pub fn encrypt_record(&mut self, meta: SecretMeta, plaintext: &[u8]) -> Result<SecretRecord> {
81 let cache_key = CacheKey::from_meta(&meta);
82 let scope = meta.scope().clone();
83 let info = meta.uri.to_string();
84
85 let (dek, wrapped) = self.obtain_dek(&cache_key, &scope)?;
86
87 let salt = random_bytes(HKDF_SALT_LEN);
88 let key = derive_key(&dek, &salt, info.as_bytes())?;
89 let (nonce, ciphertext) = encrypt_with_algorithm(self.algorithm, &key, plaintext)?;
90
91 let envelope = Envelope {
92 algorithm: self.algorithm,
93 nonce,
94 hkdf_salt: salt,
95 wrapped_dek: wrapped.clone(),
96 };
97
98 Ok(SecretRecord::new(meta, ciphertext, envelope))
99 }
100
101 fn obtain_dek(&mut self, cache_key: &CacheKey, scope: &Scope) -> Result<(Vec<u8>, Vec<u8>)> {
102 if let Some(material) = self.cache.get(cache_key) {
103 return Ok((material.dek, material.wrapped));
104 }
105
106 let dek = generate_dek();
107 let wrapped = self.provider.wrap_dek(scope, &dek)?;
108 self.cache
109 .insert(cache_key.clone(), dek.clone(), wrapped.clone());
110 Ok((dek, wrapped))
111 }
112
113 pub fn decrypt_record(&mut self, record: &SecretRecord) -> DecryptResult<Vec<u8>> {
115 let cache_key = CacheKey::from_meta(&record.meta);
116 let scope = record.meta.scope();
117 let algorithm = record.envelope.algorithm;
118 let info = record.meta.uri.to_string();
119
120 let material = match self.cache.get(&cache_key) {
121 Some(material) => material,
122 None => {
123 let dek = self
124 .provider
125 .unwrap_dek(scope, &record.envelope.wrapped_dek)
126 .map_err(|err| DecryptError::Provider(err.to_string()))?;
127 let material = DekMaterial {
128 dek: dek.clone(),
129 wrapped: record.envelope.wrapped_dek.clone(),
130 };
131 self.cache.insert(
132 cache_key.clone(),
133 material.dek.clone(),
134 material.wrapped.clone(),
135 );
136 material
137 }
138 };
139
140 let key = derive_key(&material.dek, &record.envelope.hkdf_salt, info.as_bytes())
141 .map_err(|err| DecryptError::Crypto(err.to_string()))?;
142 let plaintext =
143 decrypt_with_algorithm(algorithm, &key, &record.envelope.nonce, &record.value)?;
144
145 Ok(plaintext)
146 }
147}
148
149fn encrypt_with_algorithm(
150 algorithm: EncryptionAlgorithm,
151 key: &[u8; 32],
152 plaintext: &[u8],
153) -> Result<(Vec<u8>, Vec<u8>)> {
154 match algorithm {
155 EncryptionAlgorithm::Aes256Gcm => {
156 let sealed = seal_aead(key, plaintext).map_err(|err| Error::Crypto(err.to_string()))?;
157 let data = STANDARD
158 .decode(sealed)
159 .map_err(|err| Error::Crypto(err.to_string()))?;
160 let nonce_len = EncryptionAlgorithm::Aes256Gcm.nonce_len();
161 if data.len() < nonce_len {
162 return Err(Error::Crypto("ciphertext too short".into()));
163 }
164 let (nonce, ciphertext) = data.split_at(nonce_len);
165 Ok((nonce.to_vec(), ciphertext.to_vec()))
166 }
167 EncryptionAlgorithm::XChaCha20Poly1305 => {
168 #[cfg(feature = "xchacha")]
169 {
170 let sealed =
175 seal_aead(key, plaintext).map_err(|err| Error::Crypto(err.to_string()))?;
176 let data = STANDARD
177 .decode(sealed)
178 .map_err(|err| Error::Crypto(err.to_string()))?;
179 let aes_nonce_len = EncryptionAlgorithm::Aes256Gcm.nonce_len();
180 if data.len() < aes_nonce_len {
181 return Err(Error::Crypto("ciphertext too short".into()));
182 }
183 let (aes_nonce, ciphertext) = data.split_at(aes_nonce_len);
184 let mut nonce = random_bytes(EncryptionAlgorithm::XChaCha20Poly1305.nonce_len());
185 nonce[..aes_nonce_len].copy_from_slice(aes_nonce);
186 Ok((nonce, ciphertext.to_vec()))
187 }
188 #[cfg(not(feature = "xchacha"))]
189 {
190 Err(Error::AlgorithmFeatureUnavailable(
191 algorithm.as_str().to_string(),
192 ))
193 }
194 }
195 }
196}
197
198fn decrypt_with_algorithm(
199 algorithm: EncryptionAlgorithm,
200 key: &[u8; 32],
201 nonce: &[u8],
202 ciphertext: &[u8],
203) -> DecryptResult<Vec<u8>> {
204 match algorithm {
205 EncryptionAlgorithm::Aes256Gcm => {
206 let mut combined = Vec::with_capacity(nonce.len() + ciphertext.len());
207 combined.extend_from_slice(nonce);
208 combined.extend_from_slice(ciphertext);
209 let encoded = STANDARD.encode(combined);
210 match open_aead(key, &encoded) {
211 Ok(bytes) => Ok(bytes),
212 Err(Error::Backend(message)) if message == "open failed" => {
213 Err(DecryptError::MacMismatch)
214 }
215 Err(err) => Err(DecryptError::Crypto(err.to_string())),
216 }
217 }
218 EncryptionAlgorithm::XChaCha20Poly1305 => {
219 #[cfg(feature = "xchacha")]
220 {
221 let aes_nonce_len = EncryptionAlgorithm::Aes256Gcm.nonce_len();
222 if nonce.len() < aes_nonce_len {
223 return Err(DecryptError::Crypto(
224 "invalid nonce length for compatibility mode".into(),
225 ));
226 }
227 let mut combined = Vec::with_capacity(aes_nonce_len + ciphertext.len());
229 combined.extend_from_slice(&nonce[..aes_nonce_len]);
230 combined.extend_from_slice(ciphertext);
231 let encoded = STANDARD.encode(combined);
232 match open_aead(key, &encoded) {
233 Ok(bytes) => Ok(bytes),
234 Err(Error::Backend(message)) if message == "open failed" => {
235 Err(DecryptError::MacMismatch)
236 }
237 Err(err) => Err(DecryptError::Crypto(err.to_string())),
238 }
239 }
240 #[cfg(not(feature = "xchacha"))]
241 {
242 Err(DecryptError::Crypto(format!(
243 "algorithm {algorithm} unavailable"
244 )))
245 }
246 }
247 }
248}
249
250#[cfg(feature = "crypto-ring")]
251fn seal_aead(key_bytes: &[u8], plaintext: &[u8]) -> Result<String> {
252 let rng = SystemRandom::new();
253 let mut nonce = [0u8; NONCE_LEN];
254 rng.fill(&mut nonce)
255 .map_err(|err| Error::Backend(format!("rng: {err:?}")))?;
256
257 let key = aead::UnboundKey::new(&aead::AES_256_GCM, key_bytes)
258 .map_err(|_| Error::Backend("invalid key".into()))?;
259 let key = aead::LessSafeKey::new(key);
260
261 let mut in_out = plaintext.to_vec();
262 in_out.reserve(TAG_LEN);
263 key.seal_in_place_append_tag(
264 aead::Nonce::assume_unique_for_key(nonce),
265 aead::Aad::empty(),
266 &mut in_out,
267 )
268 .map_err(|_| Error::Backend("seal failed".into()))?;
269
270 let mut out = Vec::with_capacity(NONCE_LEN + in_out.len());
271 out.extend_from_slice(&nonce);
272 out.extend_from_slice(&in_out);
273 Ok(STANDARD.encode(out))
274}
275
276#[cfg(feature = "crypto-ring")]
277fn open_aead(key_bytes: &[u8], b64: &str) -> Result<Vec<u8>> {
278 let data = STANDARD
279 .decode(b64)
280 .map_err(|_| Error::Invalid("ciphertext".into(), "b64".into()))?;
281 if data.len() < NONCE_LEN {
282 return Err(Error::Invalid("ciphertext".into(), "too short".into()));
283 }
284 let (nonce, ct) = data.split_at(NONCE_LEN);
285
286 let key = aead::UnboundKey::new(&aead::AES_256_GCM, key_bytes)
287 .map_err(|_| Error::Backend("invalid key".into()))?;
288 let key = aead::LessSafeKey::new(key);
289
290 let mut buffer = ct.to_vec();
291 let plaintext = key
292 .open_in_place(
293 aead::Nonce::try_assume_unique_for_key(nonce)
294 .map_err(|_| Error::Invalid("nonce".into(), "invalid length".into()))?,
295 aead::Aad::empty(),
296 &mut buffer,
297 )
298 .map_err(|_| Error::Backend("open failed".into()))?;
299
300 Ok(plaintext.to_vec())
301}
302
303#[cfg(all(feature = "crypto-none", not(feature = "crypto-ring")))]
304fn seal_aead(_key_bytes: &[u8], plaintext: &[u8]) -> Result<String> {
305 Ok(STANDARD.encode(plaintext))
306}
307
308#[cfg(all(feature = "crypto-none", not(feature = "crypto-ring")))]
309fn open_aead(_key_bytes: &[u8], b64: &str) -> Result<Vec<u8>> {
310 STANDARD
311 .decode(b64)
312 .map_err(|_| Error::Invalid("ciphertext".into(), "b64".into()))
313}
314
315fn derive_key(dek: &[u8], salt: &[u8], info: &[u8]) -> Result<[u8; 32]> {
316 let hkdf = Hkdf::<Sha256>::new(Some(salt), dek);
317 let mut okm = [0u8; 32];
318 hkdf.expand(info, &mut okm)
319 .map_err(|_| Error::Crypto("failed to derive key material".into()))?;
320 Ok(okm)
321}
322
323fn generate_dek() -> Vec<u8> {
324 random_bytes(DEFAULT_DEK_LEN)
325}
326
327fn random_bytes(len: usize) -> Vec<u8> {
328 let mut buffer = vec![0u8; len];
329 let mut rng = rand::rng();
330 rng.fill_bytes(&mut buffer);
331 buffer
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::crypto::dek_cache::DekCache;
338 use crate::key_provider::KeyProvider;
339 use crate::spec_compat::{ContentType, Scope, SecretMeta, Visibility};
340 use crate::uri::SecretUri;
341 use std::sync::{Arc, Mutex};
342 use std::time::Duration;
343
344 #[derive(Clone)]
345 struct DummyProvider {
346 wrap_calls: Arc<Mutex<usize>>,
347 unwrap_calls: Arc<Mutex<usize>>,
348 }
349
350 impl DummyProvider {
351 fn new() -> Self {
352 Self {
353 wrap_calls: Arc::new(Mutex::new(0)),
354 unwrap_calls: Arc::new(Mutex::new(0)),
355 }
356 }
357
358 fn calls(&self) -> (usize, usize) {
359 (
360 *self.wrap_calls.lock().unwrap(),
361 *self.unwrap_calls.lock().unwrap(),
362 )
363 }
364 }
365
366 impl KeyProvider for DummyProvider {
367 fn wrap_dek(&self, _scope: &Scope, dek: &[u8]) -> Result<Vec<u8>> {
368 *self.wrap_calls.lock().unwrap() += 1;
369 Ok(dek.iter().map(|b| b ^ 0xAA).collect())
370 }
371
372 fn unwrap_dek(&self, _scope: &Scope, wrapped: &[u8]) -> Result<Vec<u8>> {
373 *self.unwrap_calls.lock().unwrap() += 1;
374 Ok(wrapped.iter().map(|b| b ^ 0xAA).collect())
375 }
376 }
377
378 fn sample_meta(team: Option<&str>) -> SecretMeta {
379 let scope = Scope::new(
380 "prod".to_string(),
381 "acme".to_string(),
382 team.map(|t| t.to_string()),
383 )
384 .unwrap();
385 let uri = SecretUri::new(scope.clone(), "kv", "api")
386 .unwrap()
387 .with_version(Some("v1"))
388 .unwrap();
389 SecretMeta::new(uri, Visibility::Team, ContentType::Opaque)
390 }
391
392 #[test]
393 fn encrypt_decrypt_roundtrip() {
394 let provider = DummyProvider::new();
395 let cache = DekCache::new(8, Duration::from_secs(300));
396 let mut service = EnvelopeService::new(provider, cache, EncryptionAlgorithm::Aes256Gcm);
397
398 let meta = sample_meta(Some("payments"));
399 let plaintext = b"super-secret-data";
400 let record = service
401 .encrypt_record(meta.clone(), plaintext)
402 .expect("encrypt");
403
404 let recovered = service.decrypt_record(&record).expect("decrypt");
405 assert_eq!(plaintext.to_vec(), recovered);
406 assert_eq!(record.meta, meta);
407 }
408
409 #[test]
410 fn tamper_detection() {
411 let provider = DummyProvider::new();
412 let cache = DekCache::new(8, Duration::from_secs(300));
413 let mut service = EnvelopeService::new(provider, cache, EncryptionAlgorithm::Aes256Gcm);
414 let meta = sample_meta(Some("payments"));
415
416 let mut record = service.encrypt_record(meta, b"critical").expect("encrypt");
417 record.value[0] ^= 0xFF;
418
419 let err = service.decrypt_record(&record).unwrap_err();
420 assert!(matches!(err, DecryptError::MacMismatch));
421 }
422
423 #[test]
424 fn cache_hit_and_miss_behavior() {
425 let provider = DummyProvider::new();
426 let cache = DekCache::new(8, Duration::from_secs(300));
427 let mut service =
428 EnvelopeService::new(provider.clone(), cache, EncryptionAlgorithm::Aes256Gcm);
429 let meta = sample_meta(Some("payments"));
430 let plaintext = b"payload";
431
432 service
433 .encrypt_record(meta.clone(), plaintext)
434 .expect("encrypt");
435 let (wrap_calls, _) = provider.calls();
436 assert_eq!(wrap_calls, 1);
437
438 service
439 .encrypt_record(meta.clone(), plaintext)
440 .expect("encrypt again");
441 let (wrap_calls, _) = provider.calls();
442 assert_eq!(wrap_calls, 1, "expected cache hit to avoid wrapping");
443
444 let (wrap_calls_before, _) = provider.calls();
446 let mut service = EnvelopeService::new(
447 provider.clone(),
448 DekCache::new(8, Duration::from_secs(0)),
449 EncryptionAlgorithm::Aes256Gcm,
450 );
451 service
452 .encrypt_record(meta, plaintext)
453 .expect("encrypt with fresh cache");
454 let (wrap_calls, _) = provider.calls();
455 assert!(
456 wrap_calls > wrap_calls_before,
457 "expected miss to invoke wrap again"
458 );
459 }
460}