Skip to main content

ldp_protocol/
replay.rs

1//! Replay detection for LDP messages.
2
3use lru::LruCache;
4use std::num::NonZeroUsize;
5use std::time::Duration;
6
7/// Guards against replayed messages using a bounded LRU cache.
8///
9/// Must be wrapped in `Arc<Mutex<ReplayGuard>>` for concurrent use in servers.
10pub struct ReplayGuard {
11    seen: LruCache<String, ()>,
12    window: Duration,
13}
14
15impl ReplayGuard {
16    pub fn new(capacity: usize, window_secs: u64) -> Self {
17        Self {
18            seen: LruCache::new(NonZeroUsize::new(capacity.max(1)).unwrap()),
19            window: Duration::from_secs(window_secs),
20        }
21    }
22
23    /// Check if a message should be accepted. Returns Err with reason if rejected.
24    pub fn check(
25        &mut self,
26        message_id: &str,
27        nonce: Option<&str>,
28        timestamp: &str,
29    ) -> Result<(), String> {
30        // 1. Timestamp freshness check
31        if let Ok(msg_time) = chrono::DateTime::parse_from_rfc3339(timestamp) {
32            let now = chrono::Utc::now();
33            let diff = (now - msg_time.with_timezone(&chrono::Utc))
34                .num_seconds()
35                .unsigned_abs();
36            if diff > self.window.as_secs() {
37                return Err(format!(
38                    "Message timestamp too old: {}s > {}s window",
39                    diff,
40                    self.window.as_secs()
41                ));
42            }
43        }
44
45        // 2. Nonce deduplication (only when nonce is present)
46        if let Some(nonce) = nonce {
47            let key = format!("{}:{}", message_id, nonce);
48            if self.seen.contains(&key) {
49                return Err("Duplicate message_id + nonce pair".into());
50            }
51            self.seen.put(key, ());
52        }
53
54        Ok(())
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn accepts_fresh_message() {
64        let mut guard = ReplayGuard::new(100, 60);
65        let ts = chrono::Utc::now().to_rfc3339();
66        assert!(guard.check("m1", Some("nonce1"), &ts).is_ok());
67    }
68
69    #[test]
70    fn rejects_duplicate_nonce() {
71        let mut guard = ReplayGuard::new(100, 60);
72        let ts = chrono::Utc::now().to_rfc3339();
73        assert!(guard.check("m1", Some("nonce1"), &ts).is_ok());
74        assert!(guard.check("m1", Some("nonce1"), &ts).is_err());
75    }
76
77    #[test]
78    fn accepts_different_nonce_same_message_id() {
79        let mut guard = ReplayGuard::new(100, 60);
80        let ts = chrono::Utc::now().to_rfc3339();
81        assert!(guard.check("m1", Some("nonce1"), &ts).is_ok());
82        assert!(guard.check("m1", Some("nonce2"), &ts).is_ok());
83    }
84
85    #[test]
86    fn rejects_stale_timestamp() {
87        let mut guard = ReplayGuard::new(100, 60);
88        let old_ts = (chrono::Utc::now() - chrono::Duration::seconds(120)).to_rfc3339();
89        assert!(guard.check("m1", Some("nonce1"), &old_ts).is_err());
90    }
91
92    #[test]
93    fn skips_dedup_when_no_nonce() {
94        let mut guard = ReplayGuard::new(100, 60);
95        let ts = chrono::Utc::now().to_rfc3339();
96        assert!(guard.check("m1", None, &ts).is_ok());
97        assert!(guard.check("m1", None, &ts).is_ok());
98    }
99
100    #[test]
101    fn lru_evicts_oldest_at_capacity() {
102        let mut guard = ReplayGuard::new(2, 60);
103        let ts = chrono::Utc::now().to_rfc3339();
104        assert!(guard.check("m1", Some("n1"), &ts).is_ok());
105        assert!(guard.check("m2", Some("n2"), &ts).is_ok());
106        assert!(guard.check("m3", Some("n3"), &ts).is_ok());
107        // m1:n1 was evicted by m3:n3
108        assert!(guard.check("m1", Some("n1"), &ts).is_ok());
109        // m1:n1 re-insert evicted m2:n2, so m3:n3 is still in cache
110        assert!(guard.check("m3", Some("n3"), &ts).is_err());
111    }
112}