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}