Skip to main content

secrets_core/crypto/
dek_cache.rs

1use crate::spec_compat::{Scope, SecretMeta};
2use lru::LruCache;
3use std::env;
4use std::num::NonZeroUsize;
5use std::time::{Duration, Instant};
6
7const DEFAULT_CACHE_CAPACITY: usize = 256;
8const DEFAULT_TTL_SECS: u64 = 300;
9const TTL_ENV: &str = "SECRETS_DEK_CACHE_TTL_SECS";
10
11/// Material returned from the cache containing both plaintext and wrapped DEKs.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct DekMaterial {
14    pub dek: Vec<u8>,
15    pub wrapped: Vec<u8>,
16}
17
18#[derive(Clone, Debug, PartialEq, Eq, Hash)]
19pub struct CacheKey {
20    env: String,
21    tenant: String,
22    team: Option<String>,
23    category: String,
24}
25
26impl CacheKey {
27    pub fn new(scope: &Scope, category: &str) -> Self {
28        Self {
29            env: scope.env().to_string(),
30            tenant: scope.tenant().to_string(),
31            team: scope.team().map(ToString::to_string),
32            category: category.to_string(),
33        }
34    }
35
36    pub fn from_meta(meta: &SecretMeta) -> Self {
37        Self::new(meta.scope(), meta.uri.category())
38    }
39}
40
41#[derive(Clone, Debug)]
42struct CacheValue {
43    dek: Vec<u8>,
44    wrapped: Vec<u8>,
45    expires_at: Instant,
46}
47
48/// In-memory LRU cache for data-encryption keys.
49pub struct DekCache {
50    ttl: Duration,
51    inner: LruCache<CacheKey, CacheValue>,
52}
53
54impl DekCache {
55    /// Construct a cache with the provided capacity and TTL.
56    pub fn new(capacity: usize, ttl: Duration) -> Self {
57        let size = NonZeroUsize::new(capacity.max(1)).unwrap();
58        Self {
59            ttl,
60            inner: LruCache::new(size),
61        }
62    }
63
64    /// Construct a cache using environment-driven defaults.
65    pub fn from_env() -> Self {
66        let ttl = env::var(TTL_ENV)
67            .ok()
68            .and_then(|v| v.parse::<u64>().ok())
69            .map(Duration::from_secs)
70            .unwrap_or_else(|| Duration::from_secs(DEFAULT_TTL_SECS));
71        Self::new(DEFAULT_CACHE_CAPACITY, ttl)
72    }
73
74    /// Cache TTL.
75    pub fn ttl(&self) -> Duration {
76        self.ttl
77    }
78
79    /// Fetch a DEK from the cache if present and not expired.
80    pub fn get(&mut self, key: &CacheKey) -> Option<DekMaterial> {
81        self.get_with_now(key, Instant::now())
82    }
83
84    /// Insert or update a cached DEK.
85    pub fn insert(&mut self, key: CacheKey, dek: Vec<u8>, wrapped: Vec<u8>) {
86        self.insert_with_now(key, dek, wrapped, Instant::now());
87    }
88
89    #[cfg(test)]
90    pub(crate) fn get_at(&mut self, key: &CacheKey, now: Instant) -> Option<DekMaterial> {
91        self.get_with_now(key, now)
92    }
93
94    #[cfg(test)]
95    pub(crate) fn insert_at(
96        &mut self,
97        key: CacheKey,
98        dek: Vec<u8>,
99        wrapped: Vec<u8>,
100        now: Instant,
101    ) {
102        self.insert_with_now(key, dek, wrapped, now);
103    }
104
105    fn insert_with_now(&mut self, key: CacheKey, dek: Vec<u8>, wrapped: Vec<u8>, now: Instant) {
106        let entry = CacheValue {
107            dek,
108            wrapped,
109            expires_at: now + self.ttl,
110        };
111        self.inner.put(key, entry);
112    }
113
114    fn get_with_now(&mut self, key: &CacheKey, now: Instant) -> Option<DekMaterial> {
115        self.purge_expired(now);
116        self.inner.get(key).map(|value| DekMaterial {
117            dek: value.dek.clone(),
118            wrapped: value.wrapped.clone(),
119        })
120    }
121
122    fn purge_expired(&mut self, now: Instant) {
123        let expired: Vec<CacheKey> = self
124            .inner
125            .iter()
126            .filter_map(|(key, value)| {
127                if value.expires_at <= now {
128                    Some(key.clone())
129                } else {
130                    None
131                }
132            })
133            .collect();
134
135        for key in expired {
136            self.inner.pop(&key);
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::spec_compat::{ContentType, SecretMeta, Visibility};
145    use crate::uri::SecretUri;
146
147    fn sample_meta(team: Option<&str>) -> SecretMeta {
148        let scope = Scope::new(
149            "prod".to_string(),
150            "acme".to_string(),
151            team.map(|t| t.to_string()),
152        )
153        .unwrap();
154        let uri = SecretUri::new(scope.clone(), "kv", "api")
155            .unwrap()
156            .with_version(Some("v1"))
157            .unwrap();
158        SecretMeta::new(uri, Visibility::Team, ContentType::Opaque)
159    }
160
161    #[test]
162    fn cache_hit_and_miss() {
163        let mut cache = DekCache::new(4, Duration::from_secs(5));
164        let meta = sample_meta(Some("payments"));
165        let key = CacheKey::from_meta(&meta);
166
167        assert!(cache.get(&key).is_none());
168        cache.insert(key.clone(), vec![1; 32], vec![2; 48]);
169        let material = cache.get(&key).expect("cache hit");
170        assert_eq!(material.dek, vec![1; 32]);
171        assert_eq!(material.wrapped, vec![2; 48]);
172    }
173
174    #[test]
175    fn cache_expiry() {
176        let mut cache = DekCache::new(4, Duration::from_millis(1));
177        let meta = sample_meta(Some("payments"));
178        let key = CacheKey::from_meta(&meta);
179        let now = Instant::now();
180        cache.insert_at(key.clone(), vec![3; 32], vec![4; 48], now);
181        assert!(cache.get_at(&key, now).is_some());
182        assert!(cache.get_at(&key, now + Duration::from_millis(2)).is_none());
183    }
184}