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 umsh_core::{ChannelId, NodeHint, PayloadType, PublicKey};
22use umsh_mac::{PacketFamily, Snr};
23
24use crate::identity::{NodeCapabilities, NodeIdentityPayload, NodeRole};
25use crate::location::NodeLocation;
26use crate::mac_command::IdentityRequestFilters;
27
28/// This node's own identity, used to answer Identity Requests.
29///
30/// Holds descriptive fields only — **no signing key**. Config-like fields
31/// (`role`, `capabilities`, `name`, `supported_regions`) are typically set once
32/// at bring-up; live fields (`location`, `altitude_m`) can be refreshed at any
33/// time via [`LocalNode::update_identity_profile`](crate::LocalNode::update_identity_profile),
34/// e.g. from a GPS task.
35#[derive(Clone, Debug)]
36pub struct NodeIdentityProfile {
37    /// This node's public key. Its [hint](PublicKey::hint) is matched against a
38    /// request's `FILTER_NODE_HINT`; the key itself reaches the requester via
39    /// the reply's MAC source address, not the identity payload.
40    pub public_key: PublicKey,
41    pub role: NodeRole,
42    pub capabilities: NodeCapabilities,
43    pub name: Option<String>,
44    pub location: Option<NodeLocation>,
45    pub altitude_m: Option<i32>,
46    pub supported_regions: Option<Vec<u8>>,
47    /// Where identity option 3 comes from: the current Unix time, or
48    /// `None` on a node that does not know what time it is.
49    ///
50    /// A source rather than a value, because option 3 dates the *payload*
51    /// and every payload is built fresh — a stored number would be the
52    /// time some earlier payload was built. It lives here so that the one
53    /// canonical builder stamps every framing identically; a node with no
54    /// clock keeps the default and simply omits the option.
55    pub clock: fn() -> Option<u32>,
56}
57
58impl NodeIdentityProfile {
59    /// Create a minimal profile (role + capabilities), no descriptive options.
60    pub fn new(public_key: PublicKey, role: NodeRole, capabilities: NodeCapabilities) -> Self {
61        Self {
62            public_key,
63            role,
64            capabilities,
65            name: None,
66            location: None,
67            altitude_m: None,
68            supported_regions: None,
69            clock: || None,
70        }
71    }
72
73    /// Set the display name (builder style).
74    pub fn with_name(mut self, name: impl Into<String>) -> Self {
75        self.name = Some(name.into());
76        self
77    }
78
79    /// Set the geographic location (builder style).
80    pub fn with_location(mut self, location: NodeLocation) -> Self {
81        self.location = Some(location);
82        self
83    }
84
85    /// This node's hint, derived from its public key.
86    pub fn hint(&self) -> NodeHint {
87        self.public_key.hint()
88    }
89
90    /// Build the (unsigned) identity payload this node advertises,
91    /// stamping `nonce` into option 5.
92    ///
93    /// The one canonical builder, deliberately shared by both framings of
94    /// a node identity: the Identity Request reply, which carries a
95    /// request nonce and is authenticated by the enclosing unicast, and
96    /// the standalone signed blob, which carries no nonce and is
97    /// authenticated by a detached signature. They are different objects
98    /// on the wire and the same statement about the node; the difference
99    /// must not be able to drift into the contents.
100    pub fn to_payload(&self, nonce: Option<u32>) -> NodeIdentityPayload {
101        NodeIdentityPayload {
102            role: self.role,
103            capabilities: self.capabilities,
104            name: self.name.clone(),
105            location: self.location,
106            altitude_m: self.altitude_m,
107            // Option 3 dates this payload, so it is read now rather than
108            // carried: the freshness marker exists to stop a captured
109            // identity being presented indefinitely, and a stamp copied
110            // from an earlier build would be exactly that capture.
111            timestamp: (self.clock)(),
112            supported_regions: self.supported_regions.clone(),
113            nonce,
114            signature: None,
115        }
116    }
117}
118
119/// Reception context for an incoming Identity Request, handed to the respond
120/// policy so it can decide whether — and how — to answer.
121///
122/// The request has already passed the filter gate (its `FILTER_*` options
123/// select this node) and its source has already been resolved to a key, so the
124/// policy only governs the "do I want to answer *this sender*?" decision.
125pub struct IdentityRequestContext<'a> {
126    /// Resolved sender key. Always present: an unresolvable source is dropped
127    /// before the policy runs.
128    pub from_key: PublicKey,
129    /// Sender hint, when the frame carried one.
130    pub from_hint: Option<NodeHint>,
131    /// Whether the request frame was authenticated (pairwise or channel MIC).
132    pub source_authenticated: bool,
133    /// Whether the request carried the sender's full 32-byte key.
134    pub has_full_source: bool,
135    /// The channel the request arrived on, if any (`None` for plain
136    /// broadcast/unicast).
137    pub channel: Option<ChannelId>,
138    /// Coarse packet family (Unicast / Broadcast / Multicast / BlindUnicast).
139    pub family: PacketFamily,
140    /// The request's filter/option block, for policies that inspect it further.
141    pub filters: IdentityRequestFilters<'a>,
142    /// Received signal strength of the request, if measured.
143    pub rssi: Option<i16>,
144    /// Signal-to-noise ratio of the request, if measured.
145    pub snr: Option<Snr>,
146}
147
148/// A respond policy's verdict for one Identity Request.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum RespondDecision {
151    /// Do not answer this request.
152    Ignore,
153    /// Answer with an authenticated unicast identity response.
154    Respond {
155        /// Include our full 32-byte key in the reply's source address, so a
156        /// requester that only had our hint can authenticate the reply without
157        /// a prior key exchange. Set `false` only when the requester already
158        /// holds our key.
159        full_source: bool,
160    },
161}
162
163/// A registerable respond policy: given the request context, decide the verdict.
164pub type RespondPolicy = dyn FnMut(&IdentityRequestContext<'_>) -> RespondDecision;
165
166/// The default respond policy: answer every request that reached the policy.
167///
168/// Includes our full source key unless the request was authenticated to us
169/// (an authenticated pairwise request implies the sender already holds our
170/// key). The blind-unicast-over-channel case, where a channel-authenticated
171/// sender may still lack our key, is a known edge a custom policy can override.
172pub fn default_respond_policy(ctx: &IdentityRequestContext<'_>) -> RespondDecision {
173    RespondDecision::Respond {
174        full_source: !ctx.source_authenticated,
175    }
176}
177
178/// A respond policy that answers nothing.
179///
180/// Lets a node keep a live profile while declining to be discovered. The
181/// profile is what unsolicited advertisements are built from, so silencing
182/// the responder this way — rather than by uninstalling it — is what keeps
183/// the two behaviours independent.
184pub fn never_respond_policy(_ctx: &IdentityRequestContext<'_>) -> RespondDecision {
185    RespondDecision::Ignore
186}
187
188/// Installed responder state: the profile plus the active policy.
189pub(crate) struct IdentityResponder {
190    pub(crate) profile: NodeIdentityProfile,
191    pub(crate) policy: Box<RespondPolicy>,
192}
193
194/// A resolved plan to answer one Identity Request, produced synchronously while
195/// the node state is borrowed and executed later by the async pump.
196pub(crate) struct IdentityResponsePlan {
197    /// Destination (the requester).
198    pub(crate) to: PublicKey,
199    /// Whether the reply should carry our full source key.
200    pub(crate) full_source: bool,
201    /// Whether the reply must be held for a random delay before transmit.
202    /// Set for broadcast/multicast solicitations, where every selected node
203    /// answers at once and undelayed replies would collide on the channel.
204    pub(crate) delayed: bool,
205    /// Whether the reply must carry no flood hop count field at all.
206    /// Set for a solicitation that no `FILTER_NODE_HINT` narrowed: such a
207    /// request was confined to the requester's neighbours on the way in, and
208    /// its reply stays there too rather than being flooded back.
209    pub(crate) no_flood: bool,
210    /// The framed reply payload: `PayloadType::NodeIdentity` + encoded identity.
211    pub(crate) framed: Vec<u8>,
212}
213
214impl IdentityResponder {
215    /// Evaluate an incoming request against the profile and policy.
216    ///
217    /// Returns `Some(plan)` when the node should answer: the request's filters
218    /// select this node **and** the policy returns `Respond`. Returns `None`
219    /// otherwise (not selected, policy said `Ignore`, or the reply could not be
220    /// encoded).
221    pub(crate) fn evaluate(
222        &mut self,
223        ctx: &IdentityRequestContext<'_>,
224    ) -> Option<IdentityResponsePlan> {
225        // Filter gate: does this request target a node like us?
226        let our_hint = self.profile.hint();
227        if !ctx
228            .filters
229            .selects(self.profile.role, self.profile.capabilities, &our_hint)
230            .unwrap_or(false)
231        {
232            return None;
233        }
234
235        // Policy gate: do we want to answer this particular sender?
236        let full_source = match (self.policy)(ctx) {
237            RespondDecision::Ignore => return None,
238            RespondDecision::Respond { full_source } => full_source,
239        };
240
241        // Build the framed reply, echoing the request nonce into option 5.
242        let nonce = ctx.filters.nonce().ok().flatten();
243        let payload = self.profile.to_payload(nonce);
244        let mut buf = [0u8; 192];
245        buf[0] = PayloadType::NodeIdentity as u8;
246        let len = 1 + payload.encode(&mut buf[1..]).ok()?;
247        let solicitation = matches!(
248            ctx.family,
249            crate::PacketFamily::Broadcast | crate::PacketFamily::Multicast
250        );
251        Some(IdentityResponsePlan {
252            to: ctx.from_key,
253            full_source,
254            delayed: solicitation,
255            no_flood: solicitation && !ctx.filters.hint_filtered(),
256            framed: Vec::from(&buf[..len]),
257        })
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::location::NodeLocation;
265
266    /// A profile's position reaches the wire, and every payload built
267    /// from it is dated when it was built rather than carrying a stamp
268    /// from some earlier one.
269    #[test]
270    fn a_profile_position_reaches_the_wire_dated_at_build_time() {
271        let mut profile = NodeIdentityProfile::new(
272            PublicKey([9; 32]),
273            NodeRole::Tracker,
274            NodeCapabilities::empty(),
275        );
276        let here = NodeLocation::from_e7(377_749_000, -1_224_194_000, 5);
277        profile.location = Some(here);
278        profile.altitude_m = Some(-17);
279        profile.clock = || Some(1_785_000_000);
280
281        let payload = profile.to_payload(None);
282        let mut buf = [0u8; 192];
283        let len = payload.encode(&mut buf).expect("encode");
284        let decoded = NodeIdentityPayload::from_bytes(&buf[..len]).expect("decode");
285
286        assert_eq!(decoded.location, Some(here));
287        assert_eq!(decoded.altitude_m, Some(-17));
288        assert_eq!(decoded.timestamp, Some(1_785_000_000));
289    }
290
291    /// A node that does not know what time it is omits option 3 rather
292    /// than inventing a date for the payload.
293    #[test]
294    fn a_clockless_node_omits_the_timestamp() {
295        let profile = NodeIdentityProfile::new(
296            PublicKey([9; 32]),
297            NodeRole::Tracker,
298            NodeCapabilities::empty(),
299        );
300        assert_eq!(profile.to_payload(None).timestamp, None);
301    }
302}