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}