offline_protocol_sealed/freshness.rs
1//! How old a signed control frame is allowed to be.
2//!
3//! [`control_signing_payload_v2`] puts the frame's timestamp inside the
4//! signature. This module is what reads it back: the windows the two ends
5//! allow, and the one comparison that turns a stamp and a clock into a
6//! verdict.
7//!
8//! # Why the windows live beside each other
9//!
10//! A phone and a leaf node allow *different* ages, and that difference is a
11//! shared fact rather than each end's private policy. A frame the phone is
12//! still willing to retransmit is a frame the leaf may be asked to verify, so
13//! a leaf window shorter than the phone's retransmission horizon is a pair
14//! that refuses its own traffic. Keeping both numbers in one file is what
15//! makes that relationship checkable; splitting them is how one end is later
16//! retuned alone and the refusals show up on a device.
17//!
18//! [`control_signing_payload_v2`]: crate::canonical::control_signing_payload_v2
19
20/// How far in the past a control frame's timestamp may sit before a phone
21/// refuses it.
22///
23/// Thirty days, which is not a comfort margin but the smallest number that
24/// clears every path on which this protocol delivers a *legitimately* old
25/// frame:
26///
27/// - **The outbox retransmits frozen signed bytes.** An entry is retried for
28/// `outbox_max_lifetime_ms` (7 days by default) and each probe refreshes its
29/// last-send stamp, so terminal failure moves out to the absolute cap of
30/// four lifetimes, about 28 days. A connection request riding that ladder is
31/// signed once, at the start.
32/// - **A published key package is not delivered, it is left to be found.** It
33/// sits on a relay for as long as the package is valid: 30 days for one this
34/// install minted, and up to [`crate::MAX_ACCEPTED_KEY_PACKAGE_LIFETIME`] for one
35/// minted elsewhere. Such a record is signed under the older payload on
36/// purpose, so this window is not what admits it; the numbers are here
37/// because they are what a window would have to clear if that ever changed.
38///
39/// A window shorter than either of those refuses frames that are late by
40/// design. This is therefore a bound on the replay of every other control
41/// frame rather than a closure of it: what closes the destructive case is the
42/// spent-frame record the engine keeps for session resets, which only has to
43/// reach back as far as this window for the two together to be complete.
44///
45/// An install that raises `outbox_max_lifetime_ms` past a quarter of this
46/// number pushes its own retransmissions outside the window; the delivery
47/// documentation carries that caveat.
48pub const CTRL_FRESHNESS_PAST_MS: u64 = 30 * 24 * 3600 * 1000;
49
50/// How far into the future a control frame's timestamp may sit before a
51/// verifier refuses it.
52///
53/// Two days. The field is the *sender's* clock reading, so the allowance is
54/// for honest disagreement between two devices, not for delivery: nothing
55/// legitimate arrives before it was sent. It is generous because the cost of
56/// being wrong is asymmetric. A window too small refuses a real peer whose
57/// clock is fast, and the refusal looks exactly like an attack; a window too
58/// large only lets an attacker who is *already holding a valid signature*
59/// choose when within two days to spend it, which the spent-frame record
60/// denies separately.
61///
62/// It is deliberately not zero-tolerance: a receiver that demands `stamp <=
63/// now` refuses its own peers over a second of NTP disagreement.
64pub const CTRL_FRESHNESS_FUTURE_MS: u64 = 2 * 24 * 3600 * 1000;
65
66/// How far in the past a control frame's timestamp may sit before a **leaf
67/// node** refuses it.
68///
69/// Two days, against the phone's thirty, because a leaf is on the other end of
70/// a different kind of link. The paths that make a phone accept a month-old
71/// frame do not reach a device: a leaf is paired with one phone over a direct
72/// radio link, it is not addressed through a relay, and nothing parks a frame
73/// bound for it in an outbox ladder measured in weeks. What a leaf does see
74/// late is a retransmission over a link that dropped, which is minutes.
75///
76/// The shorter window is what makes the device's bounded memory sufficient
77/// rather than merely helpful. A leaf remembers only the last few reset frames
78/// it acted on, because it has a few hundred kilobytes of flash and no room to
79/// grow that list with traffic; a two-day window means the frames that list
80/// has to cover are only the ones a peer could have minted in two days, and a
81/// phone's rekey cadence is far slower than that. Widening this window without
82/// widening that list reopens the gap between them.
83pub const LEAF_CTRL_FRESHNESS_PAST_MS: u64 = 2 * 24 * 3600 * 1000;
84
85/// What a verifier concluded about a control frame's age.
86///
87/// Deliberately three-valued rather than a boolean. The two refusals are the
88/// same decision and different diagnoses: one is a frame that waited too long,
89/// which is what a replay looks like, and the other is a frame from a clock
90/// that disagrees with ours, which is what a misconfigured device looks like.
91/// A caller that renders them identically leaves an integrator unable to tell
92/// a fleet with a broken time source from a fleet under attack.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Freshness {
95 /// Inside both windows.
96 Fresh,
97 /// Older than the past window allows.
98 Stale {
99 /// How far past the window the frame sits, in milliseconds.
100 age_ms: u64,
101 },
102 /// Stamped further ahead of the verifier's clock than the future window
103 /// allows.
104 FromTheFuture {
105 /// How far ahead of the verifier's clock the stamp sits, in
106 /// milliseconds.
107 skew_ms: u64,
108 },
109}
110
111impl Freshness {
112 /// Whether the frame may be acted on.
113 #[must_use]
114 pub fn is_fresh(&self) -> bool {
115 matches!(self, Freshness::Fresh)
116 }
117}
118
119/// Judges a control frame's timestamp against a verifier's clock.
120///
121/// `signed_ms` is the value that was inside the signature, so it is chosen by
122/// whoever produced the frame and is bounded by nothing: every arithmetic step
123/// here has to survive `i64::MIN` and `i64::MAX` being handed to it. That is
124/// why the difference saturates rather than wrapping, and why the magnitude is
125/// taken with `unsigned_abs`, which is defined at `i64::MIN` where negation is
126/// not.
127///
128/// Both windows are parameters rather than reads of the constants above,
129/// because the two ends allow different ages and a function that hard-coded
130/// one of them would need a second copy for the other. That is the failure
131/// this crate exists to prevent.
132#[must_use]
133pub fn control_frame_freshness(
134 signed_ms: i64,
135 now_ms: i64,
136 past_window_ms: u64,
137 future_window_ms: u64,
138) -> Freshness {
139 let delta = now_ms.saturating_sub(signed_ms);
140 if delta >= 0 {
141 let age_ms = delta.unsigned_abs();
142 if age_ms > past_window_ms {
143 Freshness::Stale { age_ms }
144 } else {
145 Freshness::Fresh
146 }
147 } else {
148 let skew_ms = delta.unsigned_abs();
149 if skew_ms > future_window_ms {
150 Freshness::FromTheFuture { skew_ms }
151 } else {
152 Freshness::Fresh
153 }
154 }
155}
156
157#[cfg(all(test, feature = "std"))]
158mod tests {
159 use super::*;
160
161 const NOW: i64 = 1_700_000_000_000;
162
163 #[test]
164 fn a_frame_stamped_now_is_fresh() {
165 assert_eq!(
166 control_frame_freshness(NOW, NOW, CTRL_FRESHNESS_PAST_MS, CTRL_FRESHNESS_FUTURE_MS),
167 Freshness::Fresh
168 );
169 }
170
171 /// The boundary is inclusive: a frame exactly at the window's edge is
172 /// still accepted, so a window of exactly the outbox's absolute cap does
173 /// not refuse the last retransmission it is supposed to cover.
174 #[test]
175 fn the_past_window_edge_is_inclusive() {
176 let at_edge = NOW - CTRL_FRESHNESS_PAST_MS as i64;
177 assert_eq!(
178 control_frame_freshness(
179 at_edge,
180 NOW,
181 CTRL_FRESHNESS_PAST_MS,
182 CTRL_FRESHNESS_FUTURE_MS
183 ),
184 Freshness::Fresh
185 );
186 assert_eq!(
187 control_frame_freshness(
188 at_edge - 1,
189 NOW,
190 CTRL_FRESHNESS_PAST_MS,
191 CTRL_FRESHNESS_FUTURE_MS
192 ),
193 Freshness::Stale {
194 age_ms: CTRL_FRESHNESS_PAST_MS + 1
195 }
196 );
197 }
198
199 #[test]
200 fn the_future_window_edge_is_inclusive() {
201 let at_edge = NOW + CTRL_FRESHNESS_FUTURE_MS as i64;
202 assert_eq!(
203 control_frame_freshness(
204 at_edge,
205 NOW,
206 CTRL_FRESHNESS_PAST_MS,
207 CTRL_FRESHNESS_FUTURE_MS
208 ),
209 Freshness::Fresh
210 );
211 assert_eq!(
212 control_frame_freshness(
213 at_edge + 1,
214 NOW,
215 CTRL_FRESHNESS_PAST_MS,
216 CTRL_FRESHNESS_FUTURE_MS
217 ),
218 Freshness::FromTheFuture {
219 skew_ms: CTRL_FRESHNESS_FUTURE_MS + 1
220 }
221 );
222 }
223
224 /// The whole point: a frame older than the window is refused however
225 /// valid its signature is.
226 #[test]
227 fn a_captured_frame_goes_stale() {
228 let a_year_ago = NOW - 365 * 24 * 3600 * 1000;
229 assert!(!control_frame_freshness(
230 a_year_ago,
231 NOW,
232 CTRL_FRESHNESS_PAST_MS,
233 CTRL_FRESHNESS_FUTURE_MS
234 )
235 .is_fresh());
236 }
237
238 /// A leaf refuses what a phone would still accept, which is the asymmetry
239 /// the two windows exist to express.
240 #[test]
241 fn a_leaf_window_refuses_what_a_phone_admits() {
242 let ten_days_ago = NOW - 10 * 24 * 3600 * 1000;
243 assert!(control_frame_freshness(
244 ten_days_ago,
245 NOW,
246 CTRL_FRESHNESS_PAST_MS,
247 CTRL_FRESHNESS_FUTURE_MS
248 )
249 .is_fresh());
250 assert!(!control_frame_freshness(
251 ten_days_ago,
252 NOW,
253 LEAF_CTRL_FRESHNESS_PAST_MS,
254 CTRL_FRESHNESS_FUTURE_MS
255 )
256 .is_fresh());
257 }
258
259 /// The stamp is attacker-chosen, so the extremes of the type must produce
260 /// a refusal rather than a panic or a wrapped comparison that reads as
261 /// fresh. A debug build would panic on overflow, which on a device is a
262 /// remote reset.
263 #[test]
264 fn the_extremes_of_the_type_refuse_rather_than_overflow() {
265 assert!(matches!(
266 control_frame_freshness(
267 i64::MIN,
268 NOW,
269 CTRL_FRESHNESS_PAST_MS,
270 CTRL_FRESHNESS_FUTURE_MS
271 ),
272 Freshness::Stale { .. }
273 ));
274 assert!(matches!(
275 control_frame_freshness(
276 i64::MAX,
277 NOW,
278 CTRL_FRESHNESS_PAST_MS,
279 CTRL_FRESHNESS_FUTURE_MS
280 ),
281 Freshness::FromTheFuture { .. }
282 ));
283 // And with the clock itself at an extreme, which a device with no
284 // time source can report.
285 assert!(matches!(
286 control_frame_freshness(
287 i64::MAX,
288 i64::MIN,
289 CTRL_FRESHNESS_PAST_MS,
290 CTRL_FRESHNESS_FUTURE_MS
291 ),
292 Freshness::FromTheFuture { .. }
293 ));
294 assert!(matches!(
295 control_frame_freshness(
296 i64::MIN,
297 i64::MAX,
298 CTRL_FRESHNESS_PAST_MS,
299 CTRL_FRESHNESS_FUTURE_MS
300 ),
301 Freshness::Stale { .. }
302 ));
303 }
304
305 /// A zero-length window admits only the present instant, which is what
306 /// makes the comparison a `>` rather than a `>=`.
307 #[test]
308 fn a_zero_window_admits_only_the_instant_itself() {
309 assert_eq!(control_frame_freshness(NOW, NOW, 0, 0), Freshness::Fresh);
310 assert!(!control_frame_freshness(NOW - 1, NOW, 0, 0).is_fresh());
311 assert!(!control_frame_freshness(NOW + 1, NOW, 0, 0).is_fresh());
312 }
313
314 /// The windows are the numbers the documentation states, and both ends
315 /// build against them.
316 #[test]
317 fn the_windows_have_their_published_values() {
318 assert_eq!(CTRL_FRESHNESS_PAST_MS, 2_592_000_000);
319 assert_eq!(CTRL_FRESHNESS_FUTURE_MS, 172_800_000);
320 assert_eq!(LEAF_CTRL_FRESHNESS_PAST_MS, 172_800_000);
321 }
322
323 /// The phone's window has to clear the longest legitimate delivery delay
324 /// the engine can produce: four times the default outbox lifetime.
325 #[test]
326 fn the_phone_window_covers_the_outbox_absolute_cap() {
327 const DEFAULT_OUTBOX_LIFETIME_MS: u64 = 7 * 24 * 3600 * 1000;
328 const ABSOLUTE_CAP_FACTOR: u64 = 4;
329 assert!(CTRL_FRESHNESS_PAST_MS >= DEFAULT_OUTBOX_LIFETIME_MS * ABSOLUTE_CAP_FACTOR);
330 }
331}