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}