Skip to main content

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}