keyhog_verifier/cache.rs
1//! Verification cache: avoids re-verifying the same credential across scans.
2//!
3//! Stores `(credential_hash, detector_id) -> (result, expiry)` mappings.
4//! TTLs matter because live/dead status changes over time, and the cache stores
5//! only hashes so plaintext credentials are not retained in memory longer than needed.
6
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use dashmap::DashMap;
13use keyhog_core::VerificationResult;
14use sha2::{Digest, Sha256};
15
16/// Bounded in-memory cache for verification outcomes.
17///
18/// # Examples
19///
20/// ```rust
21/// use keyhog_verifier::cache::VerificationCache;
22/// use std::time::Duration;
23///
24/// let cache = VerificationCache::new(Duration::from_secs(60));
25/// assert!(cache.is_empty());
26/// ```
27pub struct VerificationCache {
28 /// Sharded concurrent map. DashMap (per-shard locking, default 64 shards
29 /// based on parallelism) replaces the previous single global RwLock so
30 /// concurrent `get`/`put` calls touch different shards and never block
31 /// each other on cacheline bouncing - see audits/legendary-2026-04-26.
32 entries: DashMap<CacheKey, CacheEntry>,
33 inserts: AtomicUsize,
34 max_entries: usize,
35 ttl: Duration,
36 /// Concurrent FIFO queue for fast eviction of the oldest entries
37 /// without locking all DashMap shards.
38 queue: parking_lot::Mutex<std::collections::VecDeque<CacheKey>>,
39}
40
41#[derive(Hash, Eq, PartialEq, Clone)]
42struct CacheKey {
43 credential_hash: [u8; VerificationCache::HASH_BYTES],
44 detector_id_hash: [u8; VerificationCache::HASH_BYTES],
45 detector_id: Arc<str>,
46}
47
48struct CacheEntry {
49 result: VerificationResult,
50 metadata: HashMap<String, String>,
51 expires_at: Instant,
52}
53
54impl VerificationCache {
55 const DEFAULT_TTL_SECS: u64 = 300;
56 const DEFAULT_MAX_ENTRIES: usize = 10_000;
57 const EVICTION_INTERVAL: usize = 64;
58 pub(crate) const HASH_BYTES: usize = 32;
59 const MAX_DETECTOR_ID_BYTES: usize = 128;
60 const MAX_METADATA_ENTRIES: usize = 16;
61 const MAX_METADATA_KEY_BYTES: usize = 64;
62 const MAX_METADATA_VALUE_BYTES: usize = 256;
63
64 /// Create a new cache with the given TTL.
65 ///
66 /// # Examples
67 ///
68 /// ```rust
69 /// use keyhog_verifier::cache::VerificationCache;
70 /// use std::time::Duration;
71 ///
72 /// let cache = VerificationCache::new(Duration::from_secs(60));
73 /// assert!(cache.is_empty());
74 /// ```
75 pub fn new(ttl: Duration) -> Self {
76 Self::with_max_entries(ttl, Self::DEFAULT_MAX_ENTRIES)
77 }
78
79 /// Create a new cache with the given TTL and an explicit size bound.
80 ///
81 /// # Examples
82 ///
83 /// ```rust
84 /// use keyhog_verifier::cache::VerificationCache;
85 /// use std::time::Duration;
86 ///
87 /// let cache = VerificationCache::with_max_entries(Duration::from_secs(60), 32);
88 /// assert!(cache.is_empty());
89 /// ```
90 pub fn with_max_entries(ttl: Duration, max_entries: usize) -> Self {
91 Self {
92 entries: DashMap::new(),
93 inserts: AtomicUsize::new(0),
94 max_entries: max_entries.max(1),
95 ttl,
96 queue: parking_lot::Mutex::new(std::collections::VecDeque::new()),
97 }
98 }
99
100 /// Default cache: 5 minute TTL.
101 ///
102 /// # Examples
103 ///
104 /// ```rust
105 /// use keyhog_verifier::cache::VerificationCache;
106 ///
107 /// let cache = VerificationCache::default_ttl();
108 /// assert!(cache.is_empty());
109 /// ```
110 pub fn default_ttl() -> Self {
111 Self::new(Duration::from_secs(Self::DEFAULT_TTL_SECS))
112 }
113
114 /// Look up a cached result.
115 ///
116 /// # Examples
117 ///
118 /// ```rust
119 /// use keyhog_core::VerificationResult;
120 /// use keyhog_verifier::cache::VerificationCache;
121 /// use std::collections::HashMap;
122 /// use std::time::Duration;
123 ///
124 /// let cache = VerificationCache::new(Duration::from_secs(60));
125 /// cache.put("secret", "detector", VerificationResult::Live, HashMap::new());
126 /// assert!(cache.get("secret", "detector").is_some());
127 /// ```
128 pub fn get(
129 &self,
130 credential: &str,
131 detector_id: &str,
132 ) -> Option<(VerificationResult, HashMap<String, String>)> {
133 let key = cache_key(credential, detector_id);
134 let now = Instant::now();
135
136 // Per-shard read: O(1) hash, lock just one shard. Hot path for
137 // unexpired entries. `?` on `Option` returns None for a miss; an
138 // expired hit falls through to the eviction path below.
139 let entry = self.entries.get(&key)?;
140 if now < entry.expires_at {
141 return Some((entry.result.clone(), entry.metadata.clone()));
142 }
143 drop(entry);
144
145 // Expired: lock the shard for removal. dashmap's Entry API gives us
146 // CAS-style replacement so concurrent writers don't double-evict.
147 if let dashmap::mapref::entry::Entry::Occupied(entry) = self.entries.entry(key) {
148 if now >= entry.get().expires_at {
149 entry.remove();
150 } else {
151 let entry = entry.get();
152 return Some((entry.result.clone(), entry.metadata.clone()));
153 }
154 }
155 None
156 }
157
158 /// Store a verification result.
159 ///
160 /// # Examples
161 ///
162 /// ```rust
163 /// use keyhog_core::VerificationResult;
164 /// use keyhog_verifier::cache::VerificationCache;
165 /// use std::collections::HashMap;
166 /// use std::time::Duration;
167 ///
168 /// let cache = VerificationCache::new(Duration::from_secs(60));
169 /// cache.put("secret", "detector", VerificationResult::Live, HashMap::new());
170 /// assert_eq!(cache.len(), 1);
171 /// ```
172 pub fn put(
173 &self,
174 credential: &str,
175 detector_id: &str,
176 result: VerificationResult,
177 metadata: HashMap<String, String>,
178 ) {
179 let key = cache_key(credential, detector_id);
180
181 let insert_count = self.inserts.fetch_add(1, Ordering::Relaxed) + 1;
182 if insert_count.is_multiple_of(Self::EVICTION_INTERVAL) {
183 // SAFETY: cache bounded by MAX_CACHE_ENTRIES, eviction runs on every 64th
184 // insert. In this implementation MAX_CACHE_ENTRIES is the configured
185 // max_entries bound, and we also trim back to that bound after each insert.
186 self.evict_expired();
187 }
188
189 let key_clone = key.clone();
190 self.entries.insert(
191 key,
192 CacheEntry {
193 result,
194 metadata: sanitize_metadata(metadata),
195 expires_at: Instant::now() + self.ttl,
196 },
197 );
198 self.queue.lock().push_back(key_clone);
199
200 if self.entries.len() > self.max_entries {
201 self.evict_one_oldest();
202 }
203 }
204
205 /// Number of cached entries.
206 ///
207 /// # Examples
208 ///
209 /// ```rust
210 /// use keyhog_verifier::cache::VerificationCache;
211 /// use std::time::Duration;
212 ///
213 /// let cache = VerificationCache::new(Duration::from_secs(60));
214 /// assert_eq!(cache.len(), 0);
215 /// ```
216 pub fn len(&self) -> usize {
217 self.entries.len()
218 }
219
220 /// Return `true` when the cache contains no live entries.
221 ///
222 /// # Examples
223 ///
224 /// ```rust
225 /// use keyhog_verifier::cache::VerificationCache;
226 /// use std::time::Duration;
227 ///
228 /// let cache = VerificationCache::new(Duration::from_secs(60));
229 /// assert!(cache.is_empty());
230 /// ```
231 pub fn is_empty(&self) -> bool {
232 self.entries.is_empty()
233 }
234
235 /// Evict expired entries.
236 ///
237 /// # Examples
238 ///
239 /// ```rust
240 /// use keyhog_verifier::cache::VerificationCache;
241 /// use std::time::Duration;
242 ///
243 /// let cache = VerificationCache::new(Duration::from_secs(60));
244 /// cache.evict_expired();
245 /// assert!(cache.is_empty());
246 /// ```
247 pub fn evict_expired(&self) {
248 let now = Instant::now();
249 self.entries.retain(|_, entry| now < entry.expires_at);
250 }
251
252 fn evict_one_oldest(&self) {
253 let mut queue = self.queue.lock();
254 while let Some(key) = queue.pop_front() {
255 if self.entries.remove(&key).is_some() {
256 break;
257 }
258 }
259 }
260}
261
262fn hash_credential(credential: &str) -> [u8; VerificationCache::HASH_BYTES] {
263 Sha256::digest(credential.as_bytes()).into()
264}
265
266fn cache_key(credential: &str, detector_id: &str) -> CacheKey {
267 CacheKey {
268 credential_hash: hash_credential(credential),
269 detector_id_hash: hash_credential(detector_id),
270 detector_id: Arc::<str>::from(truncate_to_char_boundary(
271 detector_id,
272 VerificationCache::MAX_DETECTOR_ID_BYTES,
273 )),
274 }
275}
276
277fn sanitize_metadata(metadata: HashMap<String, String>) -> HashMap<String, String> {
278 metadata
279 .into_iter()
280 .take(VerificationCache::MAX_METADATA_ENTRIES)
281 .map(|(key, value)| {
282 (
283 truncate_to_char_boundary(&key, VerificationCache::MAX_METADATA_KEY_BYTES),
284 truncate_to_char_boundary(&value, VerificationCache::MAX_METADATA_VALUE_BYTES),
285 )
286 })
287 .collect()
288}
289
290fn truncate_to_char_boundary(value: &str, max_bytes: usize) -> String {
291 if value.len() <= max_bytes {
292 return value.to_string();
293 }
294
295 let mut end = max_bytes;
296 while end > 0 && !value.is_char_boundary(end) {
297 end -= 1;
298 }
299 value[..end].to_string()
300}