Skip to main content

didbot_serve/
replay.rs

1//! The bounded "spent once" map both proof checks keep.
2//!
3//! A tree proof and a DPoP proof are each presented once, and each is
4//! refused the second time it arrives. What they share is the structure
5//! that decides it -- a map of key to expiry, bounded, refusing rather than
6//! evicting when it fills -- and not the policy around it: the two windows
7//! are different lengths, their keys are built from different claims, and
8//! one sweeps on every spend while the other sweeps on a schedule. Those
9//! stay with their callers.
10
11use std::collections::HashMap;
12
13use time::OffsetDateTime;
14
15/// How many spent keys one window holds at once.
16///
17/// An entry costs an authenticated request inside one window, so filling
18/// this takes that many of them. Sized well past what honest load reaches.
19/// Past it a proof is refused rather than admitted without a replay check,
20/// and rather than evicting an entry that still names a live proof: either
21/// of those turns a full cache into a way to get a replay accepted.
22pub const REPLAY_CAPACITY: usize = 100_000;
23
24/// The longest `jti` either window takes, in bytes.
25///
26/// A `jti` is the caller's own string, and a window keeps it for as long as
27/// its proof lives. Real clients send 16 to 36 bytes: jacquard's DPoP proofs
28/// carry 16 base64url characters, `didbot-agentd`'s tree proofs 32 hex
29/// digits, and a UUID is 36 characters. 512 is more than ten times the
30/// longest of those. A proof with a longer one is refused before it is kept.
31pub const MAX_JTI_BYTES: usize = 512;
32
33/// Why a key could not be spent.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Spent {
36    /// It was presented before.
37    Replayed,
38    /// The window holds [`REPLAY_CAPACITY`] live keys already.
39    WindowFull,
40}
41
42/// Keys already spent, each until its own expiry, bounded at a capacity.
43///
44/// Not itself synchronised: a caller holds it behind whatever lock its own
45/// read and write paths need.
46#[derive(Debug)]
47pub struct SpentKeys {
48    capacity: usize,
49    seen: HashMap<String, OffsetDateTime>,
50    last_swept: OffsetDateTime,
51}
52
53impl SpentKeys {
54    /// An empty window holding at most `capacity` keys, counting
55    /// [`sweep_if_due`](Self::sweep_if_due)'s interval from `swept_at`.
56    pub fn new(capacity: usize, swept_at: OffsetDateTime) -> Self {
57        Self {
58            capacity,
59            seen: HashMap::new(),
60            last_swept: swept_at,
61        }
62    }
63
64    /// Whether `key` is held.
65    ///
66    /// Held, not live: an elapsed entry reads as spent until a sweep drops
67    /// it, which is the safe direction -- refusing a key twice costs a
68    /// caller one retry, admitting one twice is the replay.
69    pub fn holds(&self, key: &str) -> bool {
70        self.seen.contains_key(key)
71    }
72
73    /// Drops every entry that has elapsed by `now`.
74    pub fn sweep(&mut self, now: OffsetDateTime) {
75        self.seen.retain(|_, expires| *expires > now);
76        self.last_swept = now;
77    }
78
79    /// [`sweep`](Self::sweep), if it has been at least `interval` since the
80    /// last one.
81    pub fn sweep_if_due(&mut self, now: OffsetDateTime, interval: time::Duration) {
82        if now - self.last_swept >= interval {
83            self.sweep(now);
84        }
85    }
86
87    /// Spends `key` until `until`.
88    ///
89    /// A full window sweeps once at `now` before refusing, so a window full
90    /// of elapsed keys is not a refusal.
91    pub fn spend(
92        &mut self,
93        key: String,
94        until: OffsetDateTime,
95        now: OffsetDateTime,
96    ) -> Result<(), Spent> {
97        if self.seen.contains_key(&key) {
98            return Err(Spent::Replayed);
99        }
100        if self.seen.len() >= self.capacity {
101            self.sweep(now);
102            if self.seen.len() >= self.capacity {
103                return Err(Spent::WindowFull);
104            }
105        }
106        self.seen.insert(key, until);
107        Ok(())
108    }
109
110    /// Every key held, with the moment it stops mattering.
111    ///
112    /// Held, not live, for the reason [`holds`](Self::holds) gives: a caller
113    /// writing these down sweeps first if it wants only the live ones.
114    pub fn held(&self) -> impl Iterator<Item = (&str, OffsetDateTime)> {
115        self.seen.iter().map(|(key, until)| (key.as_str(), *until))
116    }
117
118    /// How many keys are held.
119    pub fn len(&self) -> usize {
120        self.seen.len()
121    }
122
123    /// Whether nothing is held.
124    pub fn is_empty(&self) -> bool {
125        self.seen.is_empty()
126    }
127}