umsh_crypto/
replay.rs

1//! Replay detection for secure traffic, as specified in the protocol's
2//! Security chapter (§Replay Detection, §Duplicate Acknowledgement
3//! Window).
4//!
5//! A [`ReplayWindow`] tracks one sender's frame counters at a final
6//! destination: a monotonic baseline, a small backward bitmap for
7//! out-of-order delivery, and a bounded cache of recently accepted MICs
8//! used both to reject backward-window replays and to recognize
9//! duplicates eligible for an idempotent re-acknowledgement. It is used
10//! by the host MAC per peer and per identity, and by the device
11//! per provisioned host peer for detached acknowledgement delegation.
12
13use heapless::Deque;
14
15/// Retained accepted-MIC entries per window (backward window + 1).
16pub const RECENT_MIC_CAPACITY: usize = 9;
17/// Backward-window size in counter slots (spec suggested default).
18pub const REPLAY_BACKTRACK_SLOTS: u32 = 8;
19/// Out-of-order acceptance time bound (spec: 5 minutes).
20pub const REPLAY_STALE_MS: u64 = 5 * 60 * 1000;
21
22/// Recently accepted MIC tracked for backward-window replay handling.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct RecentMic {
25    /// Accepted frame counter.
26    pub counter: u32,
27    /// Normalized MIC bytes.
28    pub mic: [u8; 16],
29    /// Number of valid bytes in [`mic`](Self::mic).
30    pub mic_len: u8,
31    /// Acceptance timestamp in milliseconds.
32    pub accepted_ms: u64,
33}
34
35/// Replay-detection window for secure traffic from one sender.
36#[derive(Clone, Debug)]
37pub struct ReplayWindow {
38    /// Highest accepted frame counter.
39    pub last_accepted: u32,
40    /// Timestamp of the highest accepted frame.
41    pub last_accepted_time_ms: u64,
42    /// Occupancy bitmap for the backward counter window.
43    pub backward_bitmap: u8,
44    /// Accepted MICs retained for duplicate late-arrival checks.
45    pub recent_mics: Deque<RecentMic, RECENT_MIC_CAPACITY>,
46    /// Counter of the last duplicate re-acknowledged, paired with
47    /// [`last_dup_ack_ms`](Self::last_dup_ack_ms).
48    ///
49    /// One pair for the whole window, not a stamp per retained MIC:
50    /// duplicate re-acks pace one packet's copies, and windows are
51    /// replicated widely enough (per channel, per tracked sender) that
52    /// per-entry state is real RAM on the embedded targets — spent, for
53    /// channel traffic, on packets that are never acknowledged at all.
54    pub last_dup_ack_counter: u32,
55    /// When the duplicate carrying
56    /// [`last_dup_ack_counter`](Self::last_dup_ack_counter) was last
57    /// re-acknowledged, in milliseconds.
58    pub last_dup_ack_ms: u64,
59}
60
61/// Result of checking a packet against a replay window.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum ReplayVerdict {
64    /// The packet is acceptable.
65    Accept,
66    /// The exact counter/MIC pair was already accepted.
67    Replay,
68    /// The counter is too far behind the tracked window.
69    OutOfWindow,
70    /// The replay state is too stale to safely accept backward-window traffic.
71    Stale,
72}
73
74impl Default for ReplayWindow {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl ReplayWindow {
81    /// Create a fresh replay window.
82    pub fn new() -> Self {
83        Self {
84            last_accepted: 0,
85            last_accepted_time_ms: 0,
86            backward_bitmap: 0,
87            recent_mics: Deque::new(),
88            last_dup_ack_counter: 0,
89            last_dup_ack_ms: 0,
90        }
91    }
92
93    /// Evaluate whether `counter` and `mic` are acceptable at `now_ms`.
94    pub fn check(&self, counter: u32, mic: &[u8], now_ms: u64) -> ReplayVerdict {
95        if self.last_accepted_time_ms == 0 && self.recent_mics.is_empty() {
96            return ReplayVerdict::Accept;
97        }
98
99        if counter > self.last_accepted {
100            return ReplayVerdict::Accept;
101        }
102
103        if now_ms.saturating_sub(self.last_accepted_time_ms) > REPLAY_STALE_MS {
104            return ReplayVerdict::Stale;
105        }
106
107        let delta = self.last_accepted - counter;
108        if delta > REPLAY_BACKTRACK_SLOTS {
109            return ReplayVerdict::OutOfWindow;
110        }
111
112        let slot_occupied = if delta == 0 {
113            true
114        } else {
115            self.backward_bitmap & (1u8 << (delta - 1)) != 0
116        };
117
118        if !slot_occupied {
119            return ReplayVerdict::Accept;
120        }
121
122        let _ = self.has_matching_recent_mic(counter, mic, now_ms);
123        ReplayVerdict::Replay
124    }
125
126    /// Return whether this is an exact, recently accepted packet eligible for
127    /// an idempotent duplicate acknowledgement, and record the
128    /// acknowledgement when it is.
129    ///
130    /// This deliberately requires both the bounded counter distance and a
131    /// matching retained MIC. Merely reusing an occupied counter does not prove
132    /// that the receiver previously accepted this logical packet.
133    ///
134    /// A duplicate is re-acknowledged at most once per `holdoff_ms`. The
135    /// duplicate-acknowledgement window exists so a sender whose ack was
136    /// lost can recover by retransmitting — but most duplicates are not
137    /// retransmissions, they are flood copies of a single transmission
138    /// arriving over different paths, and each already-sent ack covers
139    /// all of them. A sender cannot retransmit before its confirmation
140    /// window lapses, so a duplicate arriving inside that window proves
141    /// nothing was lost yet and earns no fresh ack. Callers pass their
142    /// forwarding-confirmation window (or the closest equivalent their
143    /// radio timing offers) as `holdoff_ms`.
144    ///
145    /// Two clocks pace this. Copies of the *accepted* transmission are
146    /// caught by the entry's acceptance time — the acceptance already
147    /// queued their ack. Copies of a *retransmission* are caught by the
148    /// re-ack stamp the first copy leaves behind. A `true` return
149    /// stamps: the caller is expected to queue the acknowledgement it
150    /// just asked about.
151    pub fn note_acknowledgeable_duplicate(
152        &mut self,
153        counter: u32,
154        mic: &[u8],
155        now_ms: u64,
156        holdoff_ms: u64,
157    ) -> bool {
158        if self.last_accepted_time_ms == 0 && self.recent_mics.is_empty() {
159            return false;
160        }
161
162        let ack_distance = self.last_accepted.wrapping_sub(counter);
163        if ack_distance > REPLAY_BACKTRACK_SLOTS {
164            return false;
165        }
166        let Some(entry) = self.find_recent_mic(counter, mic, now_ms) else {
167            return false;
168        };
169        if now_ms.saturating_sub(entry.accepted_ms) < holdoff_ms {
170            return false;
171        }
172        if self.last_dup_ack_counter == counter
173            && now_ms.saturating_sub(self.last_dup_ack_ms) < holdoff_ms
174        {
175            return false;
176        }
177        self.last_dup_ack_counter = counter;
178        self.last_dup_ack_ms = now_ms;
179        true
180    }
181
182    /// Record an accepted `counter` and `mic` at `now_ms`.
183    pub fn accept(&mut self, counter: u32, mic: &[u8], now_ms: u64) {
184        self.prune_recent_mics(now_ms);
185
186        if self.last_accepted_time_ms == 0 && self.recent_mics.is_empty() {
187            self.last_accepted = counter;
188            self.last_accepted_time_ms = now_ms;
189        } else if counter > self.last_accepted {
190            let shift = (counter - self.last_accepted) as usize;
191            self.backward_bitmap = if shift > REPLAY_BACKTRACK_SLOTS as usize {
192                0
193            } else {
194                let shifted = if shift >= u8::BITS as usize {
195                    0
196                } else {
197                    self.backward_bitmap << shift
198                };
199                shifted | (1u8 << (shift - 1))
200            };
201            self.last_accepted = counter;
202            self.last_accepted_time_ms = now_ms;
203        } else if counter < self.last_accepted {
204            let delta = self.last_accepted - counter;
205            if (1..=REPLAY_BACKTRACK_SLOTS).contains(&delta) {
206                self.backward_bitmap |= 1u8 << (delta - 1);
207            }
208        } else {
209            self.last_accepted_time_ms = now_ms;
210        }
211
212        if let Some((normalized_mic, mic_len)) = normalize_mic(mic) {
213            if self.recent_mics.is_full() {
214                let _ = self.recent_mics.pop_front();
215            }
216            let _ = self.recent_mics.push_back(RecentMic {
217                counter,
218                mic: normalized_mic,
219                mic_len,
220                accepted_ms: now_ms,
221            });
222        }
223    }
224
225    /// Reset the replay window to a known baseline.
226    pub fn reset(&mut self, baseline: u32, now_ms: u64) {
227        self.last_accepted = baseline;
228        self.last_accepted_time_ms = now_ms;
229        self.backward_bitmap = 0;
230        self.recent_mics.clear();
231        self.last_dup_ack_counter = 0;
232        self.last_dup_ack_ms = 0;
233    }
234
235    fn has_matching_recent_mic(&self, counter: u32, mic: &[u8], now_ms: u64) -> bool {
236        let Some((normalized_mic, mic_len)) = normalize_mic(mic) else {
237            return false;
238        };
239
240        self.recent_mics.iter().any(|entry| {
241            entry.counter == counter
242                && now_ms.saturating_sub(entry.accepted_ms) <= REPLAY_STALE_MS
243                && entry.mic_len == mic_len
244                && entry.mic[..mic_len as usize] == normalized_mic[..mic_len as usize]
245        })
246    }
247
248    fn find_recent_mic(&self, counter: u32, mic: &[u8], now_ms: u64) -> Option<&RecentMic> {
249        let (normalized_mic, mic_len) = normalize_mic(mic)?;
250
251        self.recent_mics.iter().find(|entry| {
252            entry.counter == counter
253                && now_ms.saturating_sub(entry.accepted_ms) <= REPLAY_STALE_MS
254                && entry.mic_len == mic_len
255                && entry.mic[..mic_len as usize] == normalized_mic[..mic_len as usize]
256        })
257    }
258
259    fn prune_recent_mics(&mut self, now_ms: u64) {
260        while let Some(front) = self.recent_mics.front() {
261            if now_ms.saturating_sub(front.accepted_ms) <= REPLAY_STALE_MS {
262                break;
263            }
264            let _ = self.recent_mics.pop_front();
265        }
266    }
267}
268
269fn normalize_mic(mic: &[u8]) -> Option<([u8; 16], u8)> {
270    if mic.len() > 16 {
271        return None;
272    }
273    let mut out = [0u8; 16];
274    out[..mic.len()].copy_from_slice(mic);
275    Some((out, mic.len() as u8))
276}