1use 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
22pub(crate) trait Clock: Send + Sync {
27 fn now(&self) -> Instant;
31}
32
33pub(crate) struct SystemClock;
35
36impl Clock for SystemClock {
37 fn now(&self) -> Instant {
38 Instant::now()
39 }
40}
41
42#[derive(Debug, Clone)]
45pub(crate) enum CachedResolve {
46 Ok(DidDocument),
47 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
103pub(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 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 inner.put(key, expires_at);
148 Ok(())
149 }
150}
151
152#[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 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 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 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 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 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}