Skip to main content

cairn_mod/auth/
cache.rs

1//! TTL'd LRU caches for DID documents and jti replay detection (§5.2, §5.4).
2//!
3//! Both caches wrap `lru::LruCache` with expiration checks on access.
4//! Entries that are expired when looked up are treated as a miss and
5//! evicted, so stale data never influences a decision.
6//!
7//! Time is read through a `Clock` handle so tests can advance it
8//! deterministically — TTL semantics that depend on `Instant::now()`
9//! flake under CI scheduling jitter when tests rely on `thread::sleep`
10//! to cross small thresholds (§F4 #21). Production passes a
11//! `SystemClock`; tests pass `MockClock`.
12
13use std::num::NonZeroUsize;
14use std::sync::Arc;
15use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18use lru::LruCache;
19
20use super::did::DidDocument;
21
22/// Source of "now" for the cache TTL checks. Production wires
23/// [`SystemClock`]; tests substitute a mock clock with explicit
24/// `advance` so TTL behavior is deterministic — no `thread::sleep`,
25/// no scheduler-jitter flakes.
26pub(crate) trait Clock: Send + Sync {
27    /// Current monotonic instant. Implementations must return values
28    /// that respect `>=` ordering of real time (or simulated time, in
29    /// the mock case) — the cache's expiry check assumes monotonicity.
30    fn now(&self) -> Instant;
31}
32
33/// Production [`Clock`] backed by [`std::time::Instant::now`].
34pub(crate) struct SystemClock;
35
36impl Clock for SystemClock {
37    fn now(&self) -> Instant {
38        Instant::now()
39    }
40}
41
42/// Cached DID doc with its expiration. Separate positive/negative TTLs
43/// so we can match §5.4 (60s positive, 5s negative) with one structure.
44#[derive(Debug, Clone)]
45pub(crate) enum CachedResolve {
46    Ok(DidDocument),
47    /// String because `ResolveError` isn't Clone; we only need a marker
48    /// here to short-circuit repeated failures within the negative-TTL
49    /// window. The actual re-resolve would re-encounter the real error.
50    Err,
51}
52
53pub(crate) struct DidDocCache {
54    inner: Mutex<LruCache<String, (CachedResolve, Instant)>>,
55    positive_ttl: Duration,
56    negative_ttl: Duration,
57    clock: Arc<dyn Clock>,
58}
59
60impl DidDocCache {
61    pub fn new(
62        size: NonZeroUsize,
63        positive_ttl: Duration,
64        negative_ttl: Duration,
65        clock: Arc<dyn Clock>,
66    ) -> Self {
67        Self {
68            inner: Mutex::new(LruCache::new(size)),
69            positive_ttl,
70            negative_ttl,
71            clock,
72        }
73    }
74
75    pub fn get(&self, did: &str) -> Option<CachedResolve> {
76        let mut inner = self.inner.lock().expect("cache mutex poisoned");
77        let (cached, expires_at) = inner.get(did)?;
78        if self.clock.now() >= *expires_at {
79            let key = did.to_owned();
80            inner.pop(&key);
81            return None;
82        }
83        Some(cached.clone())
84    }
85
86    pub fn insert_ok(&self, did: String, doc: DidDocument) {
87        let exp = self.clock.now() + self.positive_ttl;
88        self.inner
89            .lock()
90            .expect("cache mutex poisoned")
91            .put(did, (CachedResolve::Ok(doc), exp));
92    }
93
94    pub fn insert_err(&self, did: String) {
95        let exp = self.clock.now() + self.negative_ttl;
96        self.inner
97            .lock()
98            .expect("cache mutex poisoned")
99            .put(did, (CachedResolve::Err, exp));
100    }
101}
102
103/// Replay cache: `(iss, jti)` → expiration time. On hit with a live
104/// entry, the token is a replay. On hit with an expired entry, treat as
105/// miss and re-insert.
106///
107/// Per §5.2: "If an eviction would drop a `jti` whose TTL has not yet
108/// expired, that `jti` loses replay protection for the remainder of
109/// its TTL" — intentional tradeoff. Call site documents this.
110pub(crate) struct JtiCache {
111    inner: Mutex<LruCache<(String, String), Instant>>,
112    clock: Arc<dyn Clock>,
113}
114
115impl JtiCache {
116    pub fn new(size: NonZeroUsize, clock: Arc<dyn Clock>) -> Self {
117        Self {
118            inner: Mutex::new(LruCache::new(size)),
119            clock,
120        }
121    }
122
123    /// Atomically: check whether `(iss, jti)` is in the cache with a
124    /// live entry. If so, return `Err(Replay)`. Otherwise insert the
125    /// entry with the given `expires_at` and return `Ok(())`.
126    ///
127    /// Doing check-and-insert in one locked section is load-bearing —
128    /// concurrent requests with the same token must both see the
129    /// replay on the second.
130    pub fn check_and_record(
131        &self,
132        iss: &str,
133        jti: &str,
134        expires_at: Instant,
135    ) -> Result<(), Replay> {
136        let mut inner = self.inner.lock().expect("cache mutex poisoned");
137        let key = (iss.to_owned(), jti.to_owned());
138        if let Some(existing_exp) = inner.get(&key)
139            && self.clock.now() < *existing_exp
140        {
141            return Err(Replay);
142        }
143        // Stale entries fall through and are overwritten below.
144        // Per-§5.2: LRU eviction may drop a still-live jti to make room;
145        // that jti loses replay protection for the remainder of its TTL.
146        // Bounded-memory replay protection is the stated tradeoff.
147        inner.put(key, expires_at);
148        Ok(())
149    }
150}
151
152/// Returned by the jti cache's `check_and_record` when the
153/// `(iss, jti)` pair was already observed inside its TTL (§5.2
154/// replay protection). Callers at the auth boundary map this to
155/// `AuthError::Replay`.
156#[derive(Debug, thiserror::Error)]
157#[error("replay detected")]
158pub struct Replay;
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use std::sync::atomic::{AtomicU64, Ordering};
164
165    fn nz(n: usize) -> NonZeroUsize {
166        NonZeroUsize::new(n).unwrap()
167    }
168
169    /// Test [`Clock`] with explicit, deterministic time control.
170    /// Internally tracks ms since `base` (the `Instant` captured at
171    /// construction) — `advance` adds to that offset; `now` returns
172    /// `base + Duration::from_millis(offset)`.
173    ///
174    /// AtomicU64 lets a `&MockClock` be advanced from any thread
175    /// without taking a lock; cache tests are single-threaded today,
176    /// but the pattern matches the production `Send + Sync` contract.
177    struct MockClock {
178        base: Instant,
179        offset_ms: AtomicU64,
180    }
181
182    impl MockClock {
183        fn new() -> Self {
184            Self {
185                base: Instant::now(),
186                offset_ms: AtomicU64::new(0),
187            }
188        }
189
190        fn advance(&self, by: Duration) {
191            self.offset_ms
192                .fetch_add(by.as_millis() as u64, Ordering::Relaxed);
193        }
194    }
195
196    impl Clock for MockClock {
197        fn now(&self) -> Instant {
198            self.base + Duration::from_millis(self.offset_ms.load(Ordering::Relaxed))
199        }
200    }
201
202    #[test]
203    fn doc_cache_returns_cached_then_expires() {
204        let clock = Arc::new(MockClock::new());
205        let cache = DidDocCache::new(
206            nz(10),
207            Duration::from_millis(50),
208            Duration::from_millis(5),
209            clock.clone(),
210        );
211        let doc = DidDocument {
212            id: "did:plc:a".into(),
213            verification_method: vec![],
214        };
215        cache.insert_ok("did:plc:a".into(), doc.clone());
216        match cache.get("did:plc:a").expect("hit") {
217            CachedResolve::Ok(got) => assert_eq!(got.id, "did:plc:a"),
218            _ => panic!("expected Ok"),
219        }
220        // Advance past the positive TTL boundary. Crossing the >=
221        // threshold is what the cache's expiry check tests — pick a
222        // value strictly greater so the assertion is unambiguous.
223        clock.advance(Duration::from_millis(60));
224        assert!(cache.get("did:plc:a").is_none(), "must expire");
225    }
226
227    #[test]
228    fn doc_cache_negative_has_shorter_ttl() {
229        let clock = Arc::new(MockClock::new());
230        let cache = DidDocCache::new(
231            nz(10),
232            Duration::from_secs(60),
233            Duration::from_millis(5),
234            clock.clone(),
235        );
236        cache.insert_err("did:plc:bad".into());
237        assert!(matches!(cache.get("did:plc:bad"), Some(CachedResolve::Err)));
238        // Past negative_ttl (5ms) but well under positive_ttl (60s).
239        // The mock clock makes the threshold deterministic — the
240        // pre-mockclock version slept 15ms, which flaked under CI
241        // scheduler jitter when the negative entry's expiry hadn't
242        // actually been crossed yet (#21).
243        clock.advance(Duration::from_millis(15));
244        assert!(cache.get("did:plc:bad").is_none(), "neg entry must expire");
245    }
246
247    #[test]
248    fn jti_cache_second_use_is_replay() {
249        let clock = Arc::new(MockClock::new());
250        let cache = JtiCache::new(nz(1000), clock.clone());
251        let exp = clock.now() + Duration::from_secs(60);
252        cache
253            .check_and_record("did:plc:a", "j1", exp)
254            .expect("first ok");
255        let second = cache.check_and_record("did:plc:a", "j1", exp);
256        assert!(second.is_err(), "second use must be replay");
257    }
258
259    #[test]
260    fn jti_cache_expiry_permits_reuse() {
261        let clock = Arc::new(MockClock::new());
262        let cache = JtiCache::new(nz(1000), clock.clone());
263        let exp = clock.now() + Duration::from_millis(5);
264        cache
265            .check_and_record("did:plc:a", "j1", exp)
266            .expect("first ok");
267        clock.advance(Duration::from_millis(20));
268        // Stale entry treated as miss — reinsert succeeds.
269        cache
270            .check_and_record("did:plc:a", "j1", exp)
271            .expect("reuse ok post-expiry");
272    }
273
274    #[test]
275    fn jti_cache_distinguishes_iss() {
276        // Same jti, different iss: NOT a replay (replay is per-issuer).
277        let clock = Arc::new(MockClock::new());
278        let cache = JtiCache::new(nz(1000), clock.clone());
279        let exp = clock.now() + Duration::from_secs(60);
280        cache.check_and_record("did:plc:a", "same", exp).unwrap();
281        cache
282            .check_and_record("did:plc:b", "same", exp)
283            .expect("different iss, same jti, not replay");
284    }
285}