secrets_core/crypto/
dek_cache.rs1use 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#[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
48pub struct DekCache {
50 ttl: Duration,
51 inner: LruCache<CacheKey, CacheValue>,
52}
53
54impl DekCache {
55 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 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 pub fn ttl(&self) -> Duration {
76 self.ttl
77 }
78
79 pub fn get(&mut self, key: &CacheKey) -> Option<DekMaterial> {
81 self.get_with_now(key, Instant::now())
82 }
83
84 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}