umsh_mac/cache.rs
1use heapless::Deque;
2
3// Replay detection is a Security-chapter concept and lives in
4// umsh-crypto so the device can share it without depending on
5// the MAC; re-exported here so this crate's public API is unchanged.
6pub use umsh_crypto::replay::{RecentMic, ReplayVerdict, ReplayWindow};
7
8/// Duplicate-suppression key derived from an accepted packet.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum DupCacheKey {
11 /// Authenticated routable packet keyed by its MIC bytes.
12 Mic {
13 bytes: [u8; 16],
14 len: u8,
15 route_retry: bool,
16 },
17 /// MIC-less routable packet keyed by a stable local hash over non-dynamic
18 /// fields.
19 Hash32(u32),
20}
21
22/// How long a duplicate key stays suppressed.
23///
24/// Capacity alone is not an expiry policy. A packet whose identity is
25/// derived from its contents rather than a MIC — a beacon, whose body is
26/// empty and whose non-dynamic options never change — hashes to the same key
27/// on every repetition, so a quiet mesh that never pushes 64 further keys
28/// through the ring would suppress that node's beacons forever. An hour is
29/// long enough that a burst of retransmissions of one packet is still
30/// collapsed, and short enough that a node re-announcing itself is heard
31/// again well within the time anyone would wait for it.
32pub const DUP_CACHE_TTL_MS: u64 = 60 * 60 * 1000;
33
34/// Fixed-capacity cache of recently observed duplicate keys.
35///
36/// Entries leave either by age ([`DUP_CACHE_TTL_MS`]) or by eviction of the
37/// oldest when the ring is full, whichever comes first.
38#[derive(Clone, Debug)]
39pub struct DuplicateCache<const N: usize = 64> {
40 /// Insertion-ordered, so the oldest entry is always at the front and
41 /// expiry can be pruned from that end without a scan.
42 entries: Deque<(DupCacheKey, u64), N>,
43}
44
45impl<const N: usize> Default for DuplicateCache<N> {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl<const N: usize> DuplicateCache<N> {
52 /// Create an empty duplicate cache.
53 pub fn new() -> Self {
54 Self {
55 entries: Deque::new(),
56 }
57 }
58
59 /// Return whether `key` is present and has not aged out.
60 pub fn contains(&self, key: &DupCacheKey, now_ms: u64) -> bool {
61 self.entries
62 .iter()
63 .any(|(entry, inserted_ms)| entry == key && !Self::is_expired(*inserted_ms, now_ms))
64 }
65
66 /// Insert `key`, dropping aged-out entries and evicting the oldest
67 /// survivor if the ring is still full.
68 ///
69 /// A repeat of a key already held does not refresh its timestamp: the
70 /// entry ages from when the packet was *first* seen, so a node repeating
71 /// itself inside the window cannot hold its own suppression open.
72 pub fn insert(&mut self, key: DupCacheKey, now_ms: u64) {
73 self.expire(now_ms);
74 if self.contains(&key, now_ms) {
75 return;
76 }
77 if self.entries.is_full() {
78 let _ = self.entries.pop_front();
79 }
80 let _ = self.entries.push_back((key, now_ms));
81 }
82
83 /// Drop every entry older than [`DUP_CACHE_TTL_MS`].
84 pub fn expire(&mut self, now_ms: u64) {
85 while let Some((_, inserted_ms)) = self.entries.front() {
86 if !Self::is_expired(*inserted_ms, now_ms) {
87 return;
88 }
89 let _ = self.entries.pop_front();
90 }
91 }
92
93 /// A clock that has gone backwards (a resynchronized monotonic source)
94 /// leaves the entry looking younger than it is, never older, so
95 /// suppression can only be held slightly too long — never released early.
96 fn is_expired(inserted_ms: u64, now_ms: u64) -> bool {
97 now_ms.saturating_sub(inserted_ms) >= DUP_CACHE_TTL_MS
98 }
99
100 /// Return the number of tracked entries.
101 pub fn len(&self) -> usize {
102 self.entries.len()
103 }
104
105 /// Return whether the cache is empty.
106 pub fn is_empty(&self) -> bool {
107 self.entries.is_empty()
108 }
109}