Skip to main content

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}