Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/guard/nonce.rs

8.7 KiB, 1 run

created by r1870400018:61150, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Moved here from `fe2o3_steel`'s signed admin login on 2026-09-23, so that
2//! any protocol whose signers choose their own nonces refuses a replay the same
3//! way. A verifier that issues its nonces itself, as `presentation::verify`
4//! does, marks the issued challenge spent instead, which needs no clock.
5//!
6//! 2026-09-23, from the presentation audit: a pair was held for the window
7//! after its first showing, while a freshness check of the same window accepts
8//! a command stamped up to the window ahead, so a command stamped 110 s ahead
9//! replayed 121 s after it was first shown. A pair is now held until the window
10//! after the later of its stamp and its first showing.
11
12use oxedyne_fe2o3_core::prelude::*;
13
14use std::{
15 collections::{
16 BTreeSet,
17 HashMap,
18 },
19 time::Duration,
20};
21
22
23type Pair = (Vec<u8>, [u8; 32]); // (scope, nonce)
24
25/// Refuses a second showing of the same `(scope, nonce)` pair while a freshness
26/// check with the same window could still accept the command that carries it.
27///
28/// A scope is whatever the caller keys replays by, such as a signer's id. Each
29/// pair is held until `max(stamp, now) + window`, where `stamp` is the time the
30/// signer put on the command and `now` the time it was first shown, both unix
31/// seconds: a check that accepts stamps within the window either side of now
32/// accepts this command until `stamp + window`, and no later. Pairs are let go
33/// in the order they lapse as later pairs arrive, so no thread of its own is
34/// needed.
35///
36/// Two limits make it fail closed. Once the caller's clock has read later than
37/// a pair's `stamp + window`, the tracker cannot say whether it has already let
38/// that pair go, so after a clock steps back it refuses such a pair until the
39/// clock catches up. And it holds at most `max_pairs` pairs, refusing the next
40/// until some lapse.
41#[derive(Debug)]
42pub struct NonceTracker {
43 held: HashMap<Pair, u64>, // pair -> unix second it is held until
44 by_lapse: BTreeSet<(u64, Pair)>, // the same pairs, soonest to lapse first
45 window: Duration,
46 max_pairs: usize,
47 latest: u64, // the latest `now` the caller has shown
48}
49
50impl NonceTracker {
51
52 pub const MAX_PAIRS: usize = 1 << 16;
53
54 pub fn new(window: Duration) -> Self {
55 Self {
56 held: HashMap::new(),
57 by_lapse: BTreeSet::new(),
58 window,
59 max_pairs: Self::MAX_PAIRS,
60 latest: 0,
61 }
62 }
63
64 /// Caps the pairs held at once, at least one.
65 pub fn with_max_pairs(mut self, max_pairs: usize) -> Self {
66 self.max_pairs = max_pairs.max(1);
67 self
68 }
69
70 /// Records the pair, or refuses it if it is still held, if the tracker can
71 /// no longer vouch for its stamp, or if the tracker is full.
72 ///
73 /// # Arguments
74 ///
75 /// * `stamp` - the unix second the signer put on the command.
76 /// * `now` - the caller's clock, the one its freshness check read.
77 pub fn record(
78 &mut self,
79 scope: &[u8],
80 nonce: &[u8; 32],
81 stamp: u64,
82 now: u64,
83 )
84 -> Outcome<()>
85 {
86 let window = self.window.as_secs();
87 self.latest = self.latest.max(now);
88 self.let_go(self.latest);
89 let pair = (scope.to_vec(), *nonce);
90 if self.held.contains_key(&pair) {
91 return Err(err!(
92 "Nonce already seen in this scope while a command carrying it could still \
93 pass a {} s freshness check.", window;
94 Invalid, Security, Duplicate));
95 }
96 // A pair with this stamp could have been held and let go already, if
97 // the clock has read past its window before stepping back.
98 if stamp.saturating_add(window) < self.latest {
99 return Err(err!(
100 "A command stamped {} is refused, since the clock has read {}, past the {} s \
101 window in which the tracker vouches for a stamp.", stamp, self.latest, window;
102 Invalid, Security, Order));
103 }
104 if self.held.len() >= self.max_pairs {
105 return Err(err!(
106 "The nonce tracker holds {} pairs, its most, so no more are recorded until \
107 some lapse.", self.held.len();
108 Excessive, Size));
109 }
110 let until = stamp.max(now).saturating_add(window);
111 self.by_lapse.insert((until, pair.clone()));
112 self.held.insert(pair, until);
113 Ok(())
114 }
115
116 pub fn len(&self) -> usize {
117 self.held.len()
118 }
119
120 pub fn is_empty(&self) -> bool {
121 self.held.is_empty()
122 }
123
124 /// Lets go of every pair held until before `now`, soonest first.
125 fn let_go(&mut self, now: u64) {
126 while let Some((until, _)) = self.by_lapse.first() {
127 if *until >= now {
128 break;
129 }
130 if let Some((_, pair)) = self.by_lapse.pop_first() {
131 self.held.remove(&pair);
132 }
133 }
134 }
135}
136
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 fn tracker() -> NonceTracker {
143 NonceTracker::new(Duration::from_secs(60))
144 }
145
146 #[test]
147 fn nonce_tracker_accepts_distinct_and_rejects_repeat() -> Outcome<()> {
148 let mut t = tracker();
149 let scope = b"alice".to_vec();
150 let n1 = [0x11u8; 32];
151 let n2 = [0x22u8; 32];
152 res!(t.record(&scope, &n1, 1000, 1000));
153 res!(t.record(&scope, &n2, 1000, 1000));
154 assert!(t.record(&scope, &n1, 1000, 1000).is_err(),
155 "re-presenting the same nonce inside the window must fail");
156 Ok(())
157 }
158
159 #[test]
160 fn nonce_tracker_evicts_after_window() -> Outcome<()> {
161 let mut t = tracker();
162 let scope = b"alice".to_vec();
163 let n = [0x33u8; 32];
164 res!(t.record(&scope, &n, 1000, 1000));
165 // Same scope and nonce, freshly stamped 61 seconds later: the first
166 // record has lapsed, and this one is recorded.
167 res!(t.record(&scope, &n, 1061, 1061));
168 Ok(())
169 }
170
171 #[test]
172 fn nonce_tracker_keeps_scopes_apart() -> Outcome<()> {
173 let mut t = tracker();
174 let a = b"alice".to_vec();
175 let b = b"bob".to_vec();
176 let n = [0x44u8; 32];
177 res!(t.record(&a, &n, 1000, 1000));
178 // Different scope, same nonce: allowed.
179 res!(t.record(&b, &n, 1000, 1000));
180 Ok(())
181 }
182
183 /// A command stamped ahead of the clock is fresh until its stamp plus the
184 /// window, so its pair is held that long, not the window from its first
185 /// showing.
186 #[test]
187 fn nonce_tracker_holds_a_future_stamp_to_its_own_window() -> Outcome<()> {
188 let mut t = tracker();
189 let n = [0x55u8; 32];
190 res!(t.record(b"ops", &n, 1055, 1000));
191 assert!(t.record(b"ops", &n, 1055, 1061).is_err(), "held past the window from its showing");
192 assert!(t.record(b"ops", &n, 1055, 1115).is_err(), "held to its stamp plus the window");
193 res!(t.record(b"ops", &n, 1116, 1116));
194 Ok(())
195 }
196
197 /// A command stamped behind the clock is held for the window from its first
198 /// showing, the later of the two.
199 #[test]
200 fn nonce_tracker_holds_a_past_stamp_from_its_showing() -> Outcome<()> {
201 let mut t = tracker();
202 let n = [0x66u8; 32];
203 res!(t.record(b"ops", &n, 950, 1000));
204 assert!(t.record(b"ops", &n, 950, 1060).is_err(), "held for the window from its showing");
205 Ok(())
206 }
207
208 /// Once the clock has read past a stamp's window, a pair with that stamp is
209 /// refused even after the clock steps back, since it may have been let go.
210 #[test]
211 fn nonce_tracker_refuses_what_it_can_no_longer_vouch_for() -> Outcome<()> {
212 let mut t = tracker();
213 let n = [0x77u8; 32];
214 res!(t.record(b"ops", &n, 1000, 1000));
215 res!(t.record(b"ops", &[0x78u8; 32], 2000, 2000)); // clock runs ahead; the first lapses
216 assert!(t.record(b"ops", &n, 1000, 1010).is_err(), "replayed after the clock stepped back");
217 assert!(t.record(b"ops", &[0x79u8; 32], 1010, 1010).is_err(),
218 "a new stamp the tracker cannot vouch for, until the clock catches up");
219 res!(t.record(b"ops", &[0x79u8; 32], 1950, 1950));
220 Ok(())
221 }
222
223 /// At its cap the tracker refuses rather than let go of a pair still held,
224 /// and records again once pairs lapse.
225 #[test]
226 fn nonce_tracker_refuses_at_its_cap() -> Outcome<()> {
227 let mut t = tracker().with_max_pairs(2);
228 res!(t.record(b"ops", &[1u8; 32], 1000, 1000));
229 res!(t.record(b"ops", &[2u8; 32], 1000, 1000));
230 assert!(t.record(b"ops", &[3u8; 32], 1000, 1000).is_err(), "full");
231 req!(t.len(), 2);
232 res!(t.record(b"ops", &[3u8; 32], 1061, 1061));
233 req!(t.len(), 1);
234 Ok(())
235 }
236}