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}