Skip to main content

secrets_core/crypto/
envelope.rs

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
29/// High-level service responsible for encrypting and decrypting secret records.
30pub 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    /// Constructs a new service with the supplied components.
44    pub fn new(provider: P, cache: DekCache, algorithm: EncryptionAlgorithm) -> Self {
45        Self {
46            provider,
47            cache,
48            algorithm,
49        }
50    }
51
52    /// Builds a service using environment configuration and default cache parameters.
53    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    /// Currently configured algorithm.
65    pub fn algorithm(&self) -> EncryptionAlgorithm {
66        self.algorithm
67    }
68
69    /// Borrow the underlying DEK cache.
70    pub fn cache(&self) -> &DekCache {
71        &self.cache
72    }
73
74    /// Mutable access to the DEK cache.
75    pub fn cache_mut(&mut self) -> &mut DekCache {
76        &mut self.cache
77    }
78
79    /// Encrypts plaintext into a [`SecretRecord`] using envelope encryption.
80    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    /// Decrypts the ciphertext of a [`SecretRecord`].
114    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                // Fallback implementation that reuses AES-GCM under the hood while preserving
171                // the XChaCha nonce width (24 bytes). The first 12 bytes store the AES nonce; the
172                // remaining bytes are random padding so decryptions can reconstruct the original
173                // AES inputs deterministically.
174                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                // Reconstruct the AES-compatible ciphertext produced by the fallback encryptor.
228                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        // Force TTL expiry by rebuilding cache with zero TTL.
445        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}