umsh_node/
identity_responder.rs

1//! Built-in responder for the [Identity Request](crate::mac_command) MAC command.
2//!
3//! When enabled on a [`LocalNode`](crate::LocalNode), the node answers a
4//! matching Identity Request with a **targeted authenticated unicast** carrying
5//! its own [`NodeIdentityPayload`], echoing any request `NONCE` into identity
6//! option 5. Responses are never signed and never fall back to broadcast: the
7//! pairwise MIC, frame counter, and echoed nonce already make the reply
8//! authentic and fresh, and a request whose source cannot be resolved to a key
9//! is simply dropped by the MAC before the responder ever runs.
10//!
11//! The application supplies a [`NodeIdentityProfile`] (its role, capabilities,
12//! and descriptive fields) and, optionally, a respond **policy** — a
13//! registerable discriminator that inspects the [`IdentityRequestContext`] and
14//! decides whether, and how, to answer (e.g. "known peers only", "only on a
15//! given channel", "repeater → always"). No signing key is required.
16
17use alloc::boxed::Box;
18use alloc::string::String;
19use alloc::vec::Vec;
20
21use heapless::Deque;
22use umsh_core::{ChannelId, NodeHint, PayloadType, PublicKey, RouterHint};
23use umsh_mac::{PacketFamily, RouteHops, Snr};
24
25use crate::identity::{NodeCapabilities, NodeIdentityPayload, NodeRole};
26use crate::location::NodeLocation;
27use crate::mac_command::IdentityRequestFilters;
28
29/// The narrowest and widest random hold applied to a reply to a
30/// broadcast/multicast solicitation, per the Identity Request
31/// flood-management rules. Every node the solicitation selected is answering
32/// the same frame, so the hold spreads the replies across the window instead
33/// of piling them onto one another.
34///
35/// A solicitation narrowed to a single node is exempt: there is no crowd to
36/// spread, and holding the one reply would only make the answer late. See
37/// [`IdentityRequestFilters::hint_names_one_node`].
38pub(crate) const RESPONSE_MIN_DELAY_MS: u16 = 500;
39pub(crate) const RESPONSE_MAX_DELAY_MS: u16 = 30_000;
40
41/// How long one answered solicitation suppresses further replies to it.
42///
43/// A solicitation reaches a node once per path it travels, and the copies do
44/// not arrive together: a repeater carrying the second copy holds it for its
45/// own contention delay first. The window must also outlive the reply's own
46/// random hold, since a duplicate landing while the first reply still sits in
47/// the transmit queue is the case that queues a second one — so it is derived
48/// from that hold rather than chosen independently.
49///
50/// An immediate reply is covered by the same window with room to spare, and
51/// keeping one window for both cases costs nothing: it suppresses repeats of a
52/// solicitation, and a requester wanting a fresh answer inside it asks with a
53/// fresh nonce.
54const SOLICITATION_SUPPRESSION_MS: u64 = RESPONSE_MAX_DELAY_MS as u64 * 2;
55
56/// How many recently answered solicitations are remembered at once.
57///
58/// One entry per distinct solicitation, so this is how many separate
59/// questions a node can be holding answers to at once. Overflow evicts the
60/// oldest, which can then be answered twice — the pre-existing behavior
61/// rather than a new failure.
62const ANSWERED_CAPACITY: usize = 8;
63
64/// One solicitation this node has already answered.
65struct AnsweredSolicitation {
66    /// The requester, by hint rather than full key: enough to tell two
67    /// askers apart, and a collision costs one suppressed reply rather than
68    /// anything worse.
69    requester: NodeHint,
70    /// The request's `NONCE`, when it carried one. This is what makes the
71    /// entry name a *solicitation* and not merely a peer — a requester that
72    /// wants another answer inside the window asks with a fresh nonce, which
73    /// is what the nonce is for. A request that carries none cannot be told
74    /// apart from a repeat of itself, so within the window it is treated as
75    /// one.
76    nonce: Option<u32>,
77    answered_at_ms: u64,
78}
79
80/// This node's own identity, used to answer Identity Requests.
81///
82/// Holds descriptive fields only — **no signing key**. Config-like fields
83/// (`role`, `capabilities`, `name`, `supported_regions`) are typically set once
84/// at bring-up; live fields (`location`, `altitude_m`) can be refreshed at any
85/// time via [`LocalNode::update_identity_profile`](crate::LocalNode::update_identity_profile),
86/// e.g. from a GPS task.
87#[derive(Clone, Debug)]
88pub struct NodeIdentityProfile {
89    /// This node's public key. Its [hint](PublicKey::hint) is matched against a
90    /// request's `FILTER_NODE_HINT`; the key itself reaches the requester via
91    /// the reply's MAC source address, not the identity payload.
92    pub public_key: PublicKey,
93    pub role: NodeRole,
94    pub capabilities: NodeCapabilities,
95    pub name: Option<String>,
96    pub location: Option<NodeLocation>,
97    pub altitude_m: Option<i32>,
98    /// The regions this node floods for, in their string form. Trailing
99    /// entries are dropped from an advertisement that would not otherwise
100    /// fit (node-identity.md § Supported Regions).
101    pub supported_regions: Option<Vec<String>>,
102    /// Where identity option 3 comes from: the current Unix time, or
103    /// `None` on a node that does not know what time it is.
104    ///
105    /// A source rather than a value, because option 3 dates the *payload*
106    /// and every payload is built fresh — a stored number would be the
107    /// time some earlier payload was built. It lives here so that the one
108    /// canonical builder stamps every framing identically; a node with no
109    /// clock keeps the default and simply omits the option.
110    pub clock: fn() -> Option<u32>,
111}
112
113impl NodeIdentityProfile {
114    /// Create a minimal profile (role + capabilities), no descriptive options.
115    pub fn new(public_key: PublicKey, role: NodeRole, capabilities: NodeCapabilities) -> Self {
116        Self {
117            public_key,
118            role,
119            capabilities,
120            name: None,
121            location: None,
122            altitude_m: None,
123            supported_regions: None,
124            clock: || None,
125        }
126    }
127
128    /// Set the display name (builder style).
129    pub fn with_name(mut self, name: impl Into<String>) -> Self {
130        self.name = Some(name.into());
131        self
132    }
133
134    /// Set the geographic location (builder style).
135    pub fn with_location(mut self, location: NodeLocation) -> Self {
136        self.location = Some(location);
137        self
138    }
139
140    /// This node's hint, derived from its public key.
141    pub fn hint(&self) -> NodeHint {
142        self.public_key.hint()
143    }
144
145    /// Build the (unsigned) identity payload this node advertises,
146    /// stamping `nonce` into option 5.
147    ///
148    /// The one canonical builder, deliberately shared by both framings of
149    /// a node identity: the Identity Request reply, which carries a
150    /// request nonce and is authenticated by the enclosing unicast, and
151    /// the standalone signed blob, which carries no nonce and is
152    /// authenticated by a detached signature. They are different objects
153    /// on the wire and the same statement about the node; the difference
154    /// must not be able to drift into the contents.
155    pub fn to_payload(&self, nonce: Option<u32>) -> NodeIdentityPayload {
156        NodeIdentityPayload {
157            role: self.role,
158            capabilities: self.capabilities,
159            name: self.name.clone(),
160            location: self.location,
161            altitude_m: self.altitude_m,
162            // Option 3 dates this payload, so it is read now rather than
163            // carried: the freshness marker exists to stop a captured
164            // identity being presented indefinitely, and a stamp copied
165            // from an earlier build would be exactly that capture.
166            timestamp: (self.clock)(),
167            supported_regions: self.supported_regions.clone(),
168            nonce,
169            signature: None,
170        }
171    }
172}
173
174/// Reception context for an incoming Identity Request, handed to the respond
175/// policy so it can decide whether — and how — to answer.
176///
177/// The request has already passed the filter gate (its `FILTER_*` options
178/// select this node) and its source has already been resolved to a key, so the
179/// policy only governs the "do I want to answer *this sender*?" decision.
180pub struct IdentityRequestContext<'a> {
181    /// Resolved sender key. Always present: an unresolvable source is dropped
182    /// before the policy runs.
183    pub from_key: PublicKey,
184    /// Sender hint, when the frame carried one.
185    pub from_hint: Option<NodeHint>,
186    /// Whether the request frame was authenticated (pairwise or channel MIC).
187    pub source_authenticated: bool,
188    /// Whether the request carried the sender's full 32-byte key.
189    pub has_full_source: bool,
190    /// The channel the request arrived on, if any (`None` for plain
191    /// broadcast/unicast).
192    pub channel: Option<ChannelId>,
193    /// Coarse packet family (Unicast / Broadcast / Multicast / BlindUnicast).
194    pub family: PacketFamily,
195    /// The request's filter/option block, for policies that inspect it further.
196    pub filters: IdentityRequestFilters<'a>,
197    /// Received signal strength of the request, if measured.
198    pub rssi: Option<i16>,
199    /// Signal-to-noise ratio of the request, if measured.
200    pub snr: Option<Snr>,
201    /// The request's accumulated trace route, as packed option bytes.
202    ///
203    /// Repeaters prepend their hint when forwarding, so this reads
204    /// front-to-back as the path *back* to the requester and needs no
205    /// reversal. It is the only route home a broadcast solicitation offers:
206    /// broadcast reception registers no route with the MAC.
207    pub trace_route: &'a [u8],
208}
209
210/// A respond policy's verdict for one Identity Request.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum RespondDecision {
213    /// Do not answer this request.
214    Ignore,
215    /// Answer with an authenticated unicast identity response.
216    Respond {
217        /// Include our full 32-byte key in the reply's source address, so a
218        /// requester that only had our hint can authenticate the reply without
219        /// a prior key exchange. Set `false` only when the requester already
220        /// holds our key.
221        full_source: bool,
222    },
223}
224
225/// A registerable respond policy: given the request context, decide the verdict.
226pub type RespondPolicy = dyn FnMut(&IdentityRequestContext<'_>) -> RespondDecision;
227
228/// The default respond policy: answer every request that reached the policy.
229///
230/// Includes our full source key unless the request was authenticated to us
231/// (an authenticated pairwise request implies the sender already holds our
232/// key). The blind-unicast-over-channel case, where a channel-authenticated
233/// sender may still lack our key, is a known edge a custom policy can override.
234pub fn default_respond_policy(ctx: &IdentityRequestContext<'_>) -> RespondDecision {
235    RespondDecision::Respond {
236        full_source: !ctx.source_authenticated,
237    }
238}
239
240/// A respond policy that answers nothing.
241///
242/// Lets a node keep a live profile while declining to be discovered. The
243/// profile is what unsolicited advertisements are built from, so silencing
244/// the responder this way — rather than by uninstalling it — is what keeps
245/// the two behaviors independent.
246pub fn never_respond_policy(_ctx: &IdentityRequestContext<'_>) -> RespondDecision {
247    RespondDecision::Ignore
248}
249
250/// Installed responder state: the profile, the active policy, and the record
251/// of what has already been answered.
252pub(crate) struct IdentityResponder {
253    pub(crate) profile: NodeIdentityProfile,
254    pub(crate) policy: Box<RespondPolicy>,
255    /// Insertion-ordered, so the oldest entry is always at the front and
256    /// expiry prunes from that end without a scan.
257    answered: Deque<AnsweredSolicitation, ANSWERED_CAPACITY>,
258}
259
260impl IdentityResponder {
261    pub(crate) fn new(profile: NodeIdentityProfile, policy: Box<RespondPolicy>) -> Self {
262        Self {
263            profile,
264            policy,
265            answered: Deque::new(),
266        }
267    }
268}
269
270/// A resolved plan to answer one Identity Request, produced synchronously while
271/// the node state is borrowed and executed later by the async pump.
272pub(crate) struct IdentityResponsePlan {
273    /// Destination (the requester).
274    pub(crate) to: PublicKey,
275    /// Whether the reply should carry our full source key.
276    pub(crate) full_source: bool,
277    /// Whether the reply must be held for a random delay before transmit.
278    ///
279    /// Set for a broadcast/multicast solicitation that more than one node may
280    /// satisfy, where every selected node answers the same frame and undelayed
281    /// replies would collide on the channel. A request narrowed to a single
282    /// node has no crowd to spread and is answered immediately.
283    pub(crate) delayed: bool,
284    /// Whether the reply must carry no flood hop count field at all.
285    /// Set for a solicitation that no `FILTER_NODE_HINT` narrowed: such a
286    /// request was confined to the requester's neighbours on the way in, and
287    /// its reply stays there too rather than being flooded back.
288    pub(crate) no_flood: bool,
289    /// Routers to steer the reply back through, in send order; empty when the
290    /// request arrived with no trace to follow.
291    ///
292    /// A steered solicitation is the case this exists for: the requester is
293    /// several hops away, the reply carries no flood budget, and broadcast
294    /// reception left the MAC with no cached route to them. The trace the
295    /// request accumulated on the way in is the path home.
296    pub(crate) route: Vec<RouterHint>,
297    /// The channel to answer on, set when the request arrived as a blind
298    /// unicast; `None` sends a plain unicast.
299    ///
300    /// A blind request hid both endpoints behind the channel, and a reply sent
301    /// in the clear would name them. A multicast or broadcast solicitation
302    /// carries a channel too but is answered by targeted unicast, which is what
303    /// keeps one solicitation from drawing a crowd of channel-wide replies.
304    pub(crate) channel: Option<ChannelId>,
305    /// The framed reply payload: `PayloadType::NodeIdentity` + encoded identity.
306    pub(crate) framed: Vec<u8>,
307}
308
309impl IdentityResponder {
310    /// Evaluate an incoming request against the profile and policy.
311    ///
312    /// Returns `Some(plan)` when the node should answer: the request's filters
313    /// select this node, the solicitation has not already been answered,
314    /// **and** the policy returns `Respond`. Returns `None` otherwise (not
315    /// selected, already answered, policy said `Ignore`, or the reply could
316    /// not be encoded).
317    pub(crate) fn evaluate(
318        &mut self,
319        ctx: &IdentityRequestContext<'_>,
320        now_ms: u64,
321    ) -> Option<IdentityResponsePlan> {
322        // Filter gate: does this request target a node like us?
323        let our_hint = self.profile.hint();
324        if !ctx
325            .filters
326            .selects(self.profile.role, self.profile.capabilities, &our_hint)
327            .unwrap_or(false)
328        {
329            return None;
330        }
331
332        // Freshness gate: one reply per solicitation, however many copies of
333        // it arrive. A plain broadcast request carries no frame counter, so
334        // nothing below the node layer can recognize a second copy of one;
335        // the echoed nonce is the only thing that names the question.
336        let nonce = ctx.filters.nonce().ok().flatten();
337        let requester = ctx.from_key.hint();
338        self.expire_answered(now_ms);
339        if self.already_answered(&requester, nonce) {
340            return None;
341        }
342
343        // Policy gate: do we want to answer this particular sender?
344        let full_source = match (self.policy)(ctx) {
345            RespondDecision::Ignore => return None,
346            RespondDecision::Respond { full_source } => full_source,
347        };
348
349        // Build the framed reply, echoing the request nonce into option 5.
350        let payload = self.profile.to_payload(nonce);
351        let mut buf = [0u8; 192];
352        buf[0] = PayloadType::NodeIdentity as u8;
353        let len = 1 + payload.encode_fitting(&mut buf[1..]).ok()?;
354        let solicitation = matches!(
355            ctx.family,
356            crate::PacketFamily::Broadcast | crate::PacketFamily::Multicast
357        );
358        // Recorded only now that there is a reply to send: a request the
359        // policy declined or the encoder could not frame was never answered,
360        // and must not suppress a later attempt that would succeed.
361        self.record_answered(requester, nonce, now_ms);
362        // Repeaters prepend as they forward, so the accumulated trace already
363        // reads as the path back and is copied verbatim rather than reversed.
364        let route = RouteHops::new(ctx.trace_route).collect::<Vec<_>>();
365        Some(IdentityResponsePlan {
366            to: ctx.from_key,
367            full_source,
368            delayed: solicitation && !ctx.filters.hint_names_one_node(),
369            no_flood: solicitation && !ctx.filters.hint_filtered(),
370            route,
371            channel: match ctx.family {
372                PacketFamily::BlindUnicast => ctx.channel,
373                _ => None,
374            },
375            framed: Vec::from(&buf[..len]),
376        })
377    }
378
379    fn already_answered(&self, requester: &NodeHint, nonce: Option<u32>) -> bool {
380        self.answered
381            .iter()
382            .any(|entry| entry.requester == *requester && entry.nonce == nonce)
383    }
384
385    /// Drop every entry older than the suppression window.
386    ///
387    /// A clock that has gone backwards leaves an entry looking younger than
388    /// it is, never older, so suppression can only be held slightly too long
389    /// — never released early, which is the direction that would let the
390    /// duplicate replies back.
391    fn expire_answered(&mut self, now_ms: u64) {
392        while let Some(entry) = self.answered.front() {
393            if now_ms.saturating_sub(entry.answered_at_ms) < SOLICITATION_SUPPRESSION_MS {
394                return;
395            }
396            let _ = self.answered.pop_front();
397        }
398    }
399
400    fn record_answered(&mut self, requester: NodeHint, nonce: Option<u32>, now_ms: u64) {
401        if self.answered.is_full() {
402            let _ = self.answered.pop_front();
403        }
404        let _ = self.answered.push_back(AnsweredSolicitation {
405            requester,
406            nonce,
407            answered_at_ms: now_ms,
408        });
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::location::NodeLocation;
416
417    /// A profile's position reaches the wire, and every payload built
418    /// from it is dated when it was built rather than carrying a stamp
419    /// from some earlier one.
420    #[test]
421    fn a_profile_position_reaches_the_wire_dated_at_build_time() {
422        let mut profile = NodeIdentityProfile::new(
423            PublicKey([9; 32]),
424            NodeRole::Tracker,
425            NodeCapabilities::empty(),
426        );
427        let here = NodeLocation::from_e7(377_749_000, -1_224_194_000, 5);
428        profile.location = Some(here);
429        profile.altitude_m = Some(-17);
430        profile.clock = || Some(1_785_000_000);
431
432        let payload = profile.to_payload(None);
433        let mut buf = [0u8; 192];
434        let len = payload.encode(&mut buf).expect("encode");
435        let decoded = NodeIdentityPayload::from_bytes(&buf[..len]).expect("decode");
436
437        assert_eq!(decoded.location, Some(here));
438        assert_eq!(decoded.altitude_m, Some(-17));
439        assert_eq!(decoded.timestamp, Some(1_785_000_000));
440    }
441
442    /// A node that does not know what time it is omits option 3 rather
443    /// than inventing a date for the payload.
444    #[test]
445    fn a_clockless_node_omits_the_timestamp() {
446        let profile = NodeIdentityProfile::new(
447            PublicKey([9; 32]),
448            NodeRole::Tracker,
449            NodeCapabilities::empty(),
450        );
451        assert_eq!(profile.to_payload(None).timestamp, None);
452    }
453}