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