Skip to main content

ados_protocol/
nonce_cache.rs

1//! A bounded single-use nonce cache for short-lived HMAC tickets.
2//!
3//! An HMAC ticket on its own is a bearer token: anyone who observes one can
4//! present it again until it expires. The relay lane is a broadcast every fleet
5//! member hears, and a WebSocket ticket travels in a subprotocol header that
6//! can surface in logs, so both need "this exact ticket has already been
7//! spent" as well as "this ticket is authentic".
8//!
9//! The cache remembers each admitted nonce until the ticket that carried it
10//! can no longer verify anyway, and never for less than a fixed retention. It
11//! is bounded: past its capacity the oldest entry is evicted. Only tickets that
12//! already passed their HMAC check reach the cache, so filling it requires the
13//! signing key, and an eviction cannot be forced by a stranger.
14
15use std::collections::{BTreeMap, VecDeque};
16use std::sync::Mutex;
17
18/// Remembers spent nonces. Cheap to share behind a `&'static` or an `Arc`.
19pub struct NonceCache {
20    capacity: usize,
21    retention_seconds: i64,
22    inner: Mutex<Inner>,
23}
24
25struct Inner {
26    /// nonce → unix second after which it may be forgotten. A `BTreeMap` so
27    /// the cache can be built in a `const` context and live in a `static`.
28    seen: BTreeMap<String, i64>,
29    /// Insertion order, for oldest-first eviction. May hold nonces already
30    /// purged from `seen`; those are skipped when evicting.
31    order: VecDeque<String>,
32}
33
34impl NonceCache {
35    /// A cache holding at most `capacity` nonces, each for at least
36    /// `retention_seconds`.
37    pub const fn new(capacity: usize, retention_seconds: i64) -> Self {
38        Self {
39            capacity,
40            retention_seconds,
41            inner: Mutex::new(Inner {
42                seen: BTreeMap::new(),
43                order: VecDeque::new(),
44            }),
45        }
46    }
47
48    /// Record `nonce` as spent at `now` (unix seconds). Returns `true` when it
49    /// was fresh and is now recorded, `false` when it was already spent.
50    ///
51    /// `valid_until` is the last unix second at which the ticket carrying the
52    /// nonce could still verify; the entry is kept at least that long, so a
53    /// ticket can never outlive its own replay record.
54    pub fn admit(&self, nonce: &str, now: i64, valid_until: i64) -> bool {
55        let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
56        if let Some(forget_at) = inner.seen.remove(nonce) {
57            if forget_at > now {
58                inner.seen.insert(nonce.to_owned(), forget_at);
59                return false;
60            }
61            // Expired: forget the old record entirely before re-admitting.
62            inner.order.retain(|n| n != nonce);
63        }
64        let forget_at = now
65            .saturating_add(self.retention_seconds)
66            .max(valid_until.saturating_add(1));
67        if inner.seen.len() >= self.capacity {
68            inner.seen.retain(|_, f| *f > now);
69            let Inner { seen, order } = &mut *inner;
70            order.retain(|n| seen.contains_key(n));
71        }
72        while inner.seen.len() >= self.capacity {
73            let Some(oldest) = inner.order.pop_front() else {
74                break;
75            };
76            inner.seen.remove(&oldest);
77        }
78        inner.seen.insert(nonce.to_owned(), forget_at);
79        inner.order.push_back(nonce.to_owned());
80        true
81    }
82
83    /// How many nonces are currently remembered.
84    pub fn len(&self) -> usize {
85        self.inner
86            .lock()
87            .unwrap_or_else(|p| p.into_inner())
88            .seen
89            .len()
90    }
91
92    /// Whether nothing is remembered.
93    pub fn is_empty(&self) -> bool {
94        self.len() == 0
95    }
96}
97
98/// Sixteen random bytes as lowercase hex, for a ticket nonce. `None` only when
99/// the operating system cannot supply randomness, in which case no ticket
100/// should be minted at all.
101pub fn random_nonce_hex() -> Option<String> {
102    let mut bytes = [0u8; 16];
103    getrandom::fill(&mut bytes).ok()?;
104    Some(hex::encode(bytes))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn a_nonce_is_admitted_once() {
113        let cache = NonceCache::new(8, 600);
114        assert!(cache.admit("aa", 1_000, 1_030));
115        assert!(!cache.admit("aa", 1_001, 1_030), "a second use is a replay");
116        assert!(cache.admit("bb", 1_001, 1_030));
117    }
118
119    #[test]
120    fn an_entry_outlives_the_ticket_that_carried_it() {
121        // Retention is the floor; a ticket that can still verify later than
122        // that keeps its record until it cannot.
123        let cache = NonceCache::new(8, 600);
124        assert!(cache.admit("aa", 1_000, 1_900));
125        assert!(!cache.admit("aa", 1_650, 1_900));
126        assert!(!cache.admit("aa", 1_900, 1_900));
127        assert!(cache.admit("aa", 1_901, 2_000), "forgotten once unusable");
128    }
129
130    #[test]
131    fn the_oldest_entry_is_evicted_at_capacity() {
132        let cache = NonceCache::new(2, 600);
133        assert!(cache.admit("a", 1_000, 1_030));
134        assert!(cache.admit("b", 1_000, 1_030));
135        assert!(cache.admit("c", 1_000, 1_030));
136        assert_eq!(cache.len(), 2);
137        assert!(cache.admit("a", 1_001, 1_030), "the oldest was evicted");
138        assert!(!cache.admit("c", 1_001, 1_030));
139    }
140
141    #[test]
142    fn expired_entries_are_purged_before_anything_live_is_evicted() {
143        let cache = NonceCache::new(2, 10);
144        assert!(cache.admit("old", 1_000, 1_000));
145        assert!(cache.admit("live", 1_050, 1_100));
146        // `old` is past its retention, so it goes and `live` stays.
147        assert!(cache.admit("new", 1_060, 1_100));
148        assert!(!cache.admit("live", 1_061, 1_100));
149    }
150
151    #[test]
152    fn random_nonces_are_sixteen_bytes_of_hex_and_differ() {
153        let a = random_nonce_hex().unwrap();
154        let b = random_nonce_hex().unwrap();
155        assert_eq!(a.len(), 32);
156        assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
157        assert_ne!(a, b);
158    }
159}