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 /// MAC ack keyed the same way as [`Hash32`](Self::Hash32), carried as its
21 /// own variant because acks age out of the cache on a much shorter clock
22 /// — see [`ACK_DUP_CACHE_TTL_MS`].
23 AckHash32(u32),
24}
25
26/// How long a duplicate key stays suppressed.
27///
28/// Capacity alone is not an expiry policy. A packet whose identity is
29/// derived from its contents rather than a MIC — a beacon, whose body is
30/// empty and whose non-dynamic options never change — hashes to the same key
31/// on every repetition, so a quiet mesh that never pushes 64 further keys
32/// through the ring would suppress that node's beacons forever. An hour is
33/// long enough that a burst of retransmissions of one packet is still
34/// collapsed, and short enough that a node re-announcing itself is heard
35/// again well within the time anyone would wait for it.
36pub const DUP_CACHE_TTL_MS: u64 = 60 * 60 * 1000;
37
38/// How long a MAC-ack duplicate key stays suppressed.
39///
40/// An identical re-acknowledgement is the one duplicate a *correct* node
41/// emits on purpose: the duplicate-acknowledgement window exists so a
42/// destination can re-ack when the sender retransmits, and that re-ack
43/// only helps if repeaters carry it. An ack held under the general
44/// hour-long TTL would be forwarded once and then silently absorbed for
45/// the rest of the hour, killing the recovery path at the first hop.
46/// Ten seconds still collapses the burst of copies from a single
47/// exchange — flood copies and retransmission ladders both play out
48/// within a few confirmation windows — while a re-ack provoked by a
49/// sender's route retry, arriving tens of seconds later, is carried.
50pub const ACK_DUP_CACHE_TTL_MS: u64 = 10 * 1000;
51
52/// Fixed-capacity cache of recently observed duplicate keys.
53///
54/// Entries leave either by age ([`DUP_CACHE_TTL_MS`]) or by eviction of the
55/// oldest when the ring is full, whichever comes first.
56#[derive(Clone, Debug)]
57pub struct DuplicateCache<const N: usize = 64> {
58 /// Insertion-ordered, so the oldest entry is always at the front and
59 /// expiry can be pruned from that end without a scan.
60 entries: Deque<(DupCacheKey, u64), N>,
61}
62
63impl<const N: usize> Default for DuplicateCache<N> {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl<const N: usize> DuplicateCache<N> {
70 /// Create an empty duplicate cache.
71 pub fn new() -> Self {
72 Self {
73 entries: Deque::new(),
74 }
75 }
76
77 /// Return whether `key` is present and has not aged out.
78 pub fn contains(&self, key: &DupCacheKey, now_ms: u64) -> bool {
79 self.entries.iter().any(|(entry, inserted_ms)| {
80 entry == key && !Self::is_expired(entry, *inserted_ms, now_ms)
81 })
82 }
83
84 /// Insert `key`, dropping aged-out entries and evicting the oldest
85 /// survivor if the ring is still full.
86 ///
87 /// A repeat of a key already held does not refresh its timestamp: the
88 /// entry ages from when the packet was *first* seen, so a node repeating
89 /// itself inside the window cannot hold its own suppression open.
90 pub fn insert(&mut self, key: DupCacheKey, now_ms: u64) {
91 self.expire(now_ms);
92 if self.contains(&key, now_ms) {
93 return;
94 }
95 if self.entries.is_full() {
96 let _ = self.entries.pop_front();
97 }
98 let _ = self.entries.push_back((key, now_ms));
99 }
100
101 /// Drop aged-out entries from the front of the ring.
102 ///
103 /// Insertion order only bounds age from one side, and TTLs differ by
104 /// key kind, so an expired short-TTL entry can sit behind a live
105 /// long-TTL one; it stops answering [`contains`](Self::contains)
106 /// immediately and is reclaimed when it reaches the front.
107 pub fn expire(&mut self, now_ms: u64) {
108 while let Some((entry, inserted_ms)) = self.entries.front() {
109 if !Self::is_expired(entry, *inserted_ms, now_ms) {
110 return;
111 }
112 let _ = self.entries.pop_front();
113 }
114 }
115
116 /// A clock that has gone backwards (a resynchronized monotonic source)
117 /// leaves the entry looking younger than it is, never older, so
118 /// suppression can only be held slightly too long — never released early.
119 fn is_expired(key: &DupCacheKey, inserted_ms: u64, now_ms: u64) -> bool {
120 let ttl_ms = match key {
121 DupCacheKey::AckHash32(_) => ACK_DUP_CACHE_TTL_MS,
122 DupCacheKey::Mic { .. } | DupCacheKey::Hash32(_) => DUP_CACHE_TTL_MS,
123 };
124 now_ms.saturating_sub(inserted_ms) >= ttl_ms
125 }
126
127 /// Return the number of tracked entries.
128 pub fn len(&self) -> usize {
129 self.entries.len()
130 }
131
132 /// Return whether the cache is empty.
133 pub fn is_empty(&self) -> bool {
134 self.entries.is_empty()
135 }
136}