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}
47
48/// Result of checking a packet against a replay window.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum ReplayVerdict {
51    /// The packet is acceptable.
52    Accept,
53    /// The exact counter/MIC pair was already accepted.
54    Replay,
55    /// The counter is too far behind the tracked window.
56    OutOfWindow,
57    /// The replay state is too stale to safely accept backward-window traffic.
58    Stale,
59}
60
61impl Default for ReplayWindow {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl ReplayWindow {
68    /// Create a fresh replay window.
69    pub fn new() -> Self {
70        Self {
71            last_accepted: 0,
72            last_accepted_time_ms: 0,
73            backward_bitmap: 0,
74            recent_mics: Deque::new(),
75        }
76    }
77
78    /// Evaluate whether `counter` and `mic` are acceptable at `now_ms`.
79    pub fn check(&self, counter: u32, mic: &[u8], now_ms: u64) -> ReplayVerdict {
80        if self.last_accepted_time_ms == 0 && self.recent_mics.is_empty() {
81            return ReplayVerdict::Accept;
82        }
83
84        if counter > self.last_accepted {
85            return ReplayVerdict::Accept;
86        }
87
88        if now_ms.saturating_sub(self.last_accepted_time_ms) > REPLAY_STALE_MS {
89            return ReplayVerdict::Stale;
90        }
91
92        let delta = self.last_accepted - counter;
93        if delta > REPLAY_BACKTRACK_SLOTS {
94            return ReplayVerdict::OutOfWindow;
95        }
96
97        let slot_occupied = if delta == 0 {
98            true
99        } else {
100            self.backward_bitmap & (1u8 << (delta - 1)) != 0
101        };
102
103        if !slot_occupied {
104            return ReplayVerdict::Accept;
105        }
106
107        let _ = self.has_matching_recent_mic(counter, mic, now_ms);
108        ReplayVerdict::Replay
109    }
110
111    /// Return whether this is an exact, recently accepted packet eligible for
112    /// an idempotent duplicate acknowledgement.
113    ///
114    /// This deliberately requires both the bounded counter distance and a
115    /// matching retained MIC. Merely reusing an occupied counter does not prove
116    /// that the receiver previously accepted this logical packet.
117    pub fn is_acknowledgeable_duplicate(&self, counter: u32, mic: &[u8], now_ms: u64) -> bool {
118        if self.last_accepted_time_ms == 0 && self.recent_mics.is_empty() {
119            return false;
120        }
121
122        let ack_distance = self.last_accepted.wrapping_sub(counter);
123        ack_distance <= REPLAY_BACKTRACK_SLOTS && self.has_matching_recent_mic(counter, mic, now_ms)
124    }
125
126    /// Record an accepted `counter` and `mic` at `now_ms`.
127    pub fn accept(&mut self, counter: u32, mic: &[u8], now_ms: u64) {
128        self.prune_recent_mics(now_ms);
129
130        if self.last_accepted_time_ms == 0 && self.recent_mics.is_empty() {
131            self.last_accepted = counter;
132            self.last_accepted_time_ms = now_ms;
133        } else if counter > self.last_accepted {
134            let shift = (counter - self.last_accepted) as usize;
135            self.backward_bitmap = if shift > REPLAY_BACKTRACK_SLOTS as usize {
136                0
137            } else {
138                let shifted = if shift >= u8::BITS as usize {
139                    0
140                } else {
141                    self.backward_bitmap << shift
142                };
143                shifted | (1u8 << (shift - 1))
144            };
145            self.last_accepted = counter;
146            self.last_accepted_time_ms = now_ms;
147        } else if counter < self.last_accepted {
148            let delta = self.last_accepted - counter;
149            if (1..=REPLAY_BACKTRACK_SLOTS).contains(&delta) {
150                self.backward_bitmap |= 1u8 << (delta - 1);
151            }
152        } else {
153            self.last_accepted_time_ms = now_ms;
154        }
155
156        if let Some((normalized_mic, mic_len)) = normalize_mic(mic) {
157            if self.recent_mics.is_full() {
158                let _ = self.recent_mics.pop_front();
159            }
160            let _ = self.recent_mics.push_back(RecentMic {
161                counter,
162                mic: normalized_mic,
163                mic_len,
164                accepted_ms: now_ms,
165            });
166        }
167    }
168
169    /// Reset the replay window to a known baseline.
170    pub fn reset(&mut self, baseline: u32, now_ms: u64) {
171        self.last_accepted = baseline;
172        self.last_accepted_time_ms = now_ms;
173        self.backward_bitmap = 0;
174        self.recent_mics.clear();
175    }
176
177    fn has_matching_recent_mic(&self, counter: u32, mic: &[u8], now_ms: u64) -> bool {
178        let Some((normalized_mic, mic_len)) = normalize_mic(mic) else {
179            return false;
180        };
181
182        self.recent_mics.iter().any(|entry| {
183            entry.counter == counter
184                && now_ms.saturating_sub(entry.accepted_ms) <= REPLAY_STALE_MS
185                && entry.mic_len == mic_len
186                && entry.mic[..mic_len as usize] == normalized_mic[..mic_len as usize]
187        })
188    }
189
190    fn prune_recent_mics(&mut self, now_ms: u64) {
191        while let Some(front) = self.recent_mics.front() {
192            if now_ms.saturating_sub(front.accepted_ms) <= REPLAY_STALE_MS {
193                break;
194            }
195            let _ = self.recent_mics.pop_front();
196        }
197    }
198}
199
200fn normalize_mic(mic: &[u8]) -> Option<([u8; 16], u8)> {
201    if mic.len() > 16 {
202        return None;
203    }
204    let mut out = [0u8; 16];
205    out[..mic.len()].copy_from_slice(mic);
206    Some((out, mic.len() as u8))
207}