umsh_node/
identity.rs

1use alloc::string::String;
2use alloc::vec::Vec;
3
4use bitflags::bitflags;
5use umsh_core::options::{OptionDecoder, OptionEncoder, parse_be_i32, parse_be_u32};
6
7use crate::app_util::parse_utf8;
8use crate::location::NodeLocation;
9use crate::{AppEncodeError, AppParseError};
10
11mod opt {
12    pub const NAME: u16 = 0;
13    pub const LOCATION: u16 = 1;
14    pub const ALTITUDE: u16 = 2;
15    pub const TIMESTAMP: u16 = 3;
16    pub const SUPPORTED_REGIONS: u16 = 4;
17    pub const NONCE: u16 = 5;
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum NodeRole {
22    Unspecified,
23    Repeater,
24    Chat,
25    Tracker,
26    Sensor,
27    Bridge,
28    ChatRoom,
29    TemporarySession,
30    /// A role value not recognized by this implementation; preserved for round-tripping.
31    Unknown(u8),
32}
33
34impl NodeRole {
35    pub fn from_byte(value: u8) -> Self {
36        match value {
37            0 => Self::Unspecified,
38            1 => Self::Repeater,
39            2 => Self::Chat,
40            3 => Self::Tracker,
41            4 => Self::Sensor,
42            5 => Self::Bridge,
43            6 => Self::ChatRoom,
44            7 => Self::TemporarySession,
45            n => Self::Unknown(n),
46        }
47    }
48
49    pub fn as_byte(self) -> u8 {
50        match self {
51            Self::Unspecified => 0,
52            Self::Repeater => 1,
53            Self::Chat => 2,
54            Self::Tracker => 3,
55            Self::Sensor => 4,
56            Self::Bridge => 5,
57            Self::ChatRoom => 6,
58            Self::TemporarySession => 7,
59            Self::Unknown(n) => n,
60        }
61    }
62}
63
64bitflags! {
65    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
66    pub struct NodeCapabilities: u8 {
67        const REPEATER       = 0x01;
68        const MOBILE         = 0x02;
69        const TEXT_MESSAGES  = 0x04;
70        const TELEMETRY      = 0x08;
71        const CHAT_ROOM      = 0x10;
72        const COAP           = 0x20;
73    }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct NodeIdentityPayload {
78    pub role: NodeRole,
79    pub capabilities: NodeCapabilities,
80    /// Option 0 — display name (UTF-8).
81    pub name: Option<String>,
82    /// Option 1 — geographic position.
83    pub location: Option<NodeLocation>,
84    /// Option 2 — altitude above mean sea level, in meters.
85    pub altitude_m: Option<i32>,
86    /// Option 3 — seconds since the Unix epoch (freshness marker).
87    pub timestamp: Option<u32>,
88    /// Option 4 — concatenated 2-byte region codes this repeater serves.
89    pub supported_regions: Option<Vec<u8>>,
90    /// Option 5 — nonce echoed from a soliciting Advertisement Request.
91    /// Present only in solicited advertisements whose request carried one.
92    pub nonce: Option<u32>,
93    /// EdDSA signature over ROLE..=0xFF, present when the identity stands alone.
94    ///
95    /// TODO: signing and verification belong outside `NodeIdentityPayload`
96    /// (the way the source-address routing hint lives outside the payload).
97    /// The eventual rework should remove this field and move the
98    /// signed-identity wrapper to a separate type that owns both the encoded
99    /// payload bytes and the signature, so the signed-byte range is never
100    /// reconstructed at the call site.
101    pub signature: Option<[u8; 64]>,
102}
103
104impl NodeIdentityPayload {
105    pub fn from_bytes(payload: &[u8]) -> Result<NodeIdentityPayload, AppParseError> {
106        if payload.len() < 2 {
107            return Err(AppParseError::Core(umsh_core::ParseError::Truncated));
108        }
109
110        let role = NodeRole::from_byte(payload[0]);
111        let capabilities = NodeCapabilities::from_bits_truncate(payload[1]);
112        let remaining = &payload[2..];
113
114        let mut name = None;
115        let mut location = None;
116        let mut altitude_m = None;
117        let mut timestamp = None;
118        let mut supported_regions = None;
119        let mut nonce = None;
120
121        let mut decoder = OptionDecoder::new(remaining);
122        for result in decoder.by_ref() {
123            let (number, value) = result?;
124            match number {
125                opt::NAME => name = Some(String::from(parse_utf8(value)?)),
126                opt::LOCATION => {
127                    // Spec: MUST ignore bytes after the 7th
128                    location = Some(NodeLocation::from_bytes(value));
129                }
130                opt::ALTITUDE => altitude_m = Some(parse_be_i32(value)?),
131                opt::TIMESTAMP => timestamp = Some(parse_be_u32(value)?),
132                opt::SUPPORTED_REGIONS => {
133                    if value.len() % 2 != 0 {
134                        return Err(AppParseError::InvalidOptionValue);
135                    }
136                    supported_regions = Some(Vec::from(value));
137                }
138                opt::NONCE => {
139                    // A verbatim copy of the request's 4-byte field —
140                    // fixed-width, unlike the minimally encoded integers.
141                    let bytes: [u8; 4] = value
142                        .try_into()
143                        .map_err(|_| AppParseError::InvalidOptionValue)?;
144                    nonce = Some(u32::from_be_bytes(bytes));
145                }
146                _ => {} // unknown options are silently skipped
147            }
148        }
149
150        let sig_bytes = decoder.remainder();
151        let signature = match sig_bytes.len() {
152            0 => None,
153            64 => Some(
154                sig_bytes
155                    .try_into()
156                    .map_err(|_| AppParseError::InvalidLength {
157                        expected: 64,
158                        actual: sig_bytes.len(),
159                    })?,
160            ),
161            n => {
162                return Err(AppParseError::InvalidLength {
163                    expected: 64,
164                    actual: n,
165                });
166            }
167        };
168
169        Ok(NodeIdentityPayload {
170            role,
171            capabilities,
172            name,
173            location,
174            altitude_m,
175            timestamp,
176            supported_regions,
177            nonce,
178            signature,
179        })
180    }
181
182    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
183        if buf.len() < 2 {
184            return Err(AppEncodeError::BufferTooSmall);
185        }
186        buf[0] = self.role.as_byte();
187        buf[1] = self.capabilities.bits();
188        let mut pos = 2;
189
190        {
191            let mut enc = OptionEncoder::new(&mut buf[pos..]);
192            if let Some(name) = self.name.as_deref() {
193                enc.put(opt::NAME, name.as_bytes())?;
194            }
195            if let Some(loc) = self.location {
196                enc.put(opt::LOCATION, loc.as_bytes())?;
197            }
198            if let Some(alt) = self.altitude_m {
199                enc.put_i32(opt::ALTITUDE, alt)?;
200            }
201            if let Some(ts) = self.timestamp {
202                enc.put_u32(opt::TIMESTAMP, ts)?;
203            }
204            if let Some(regions) = self.supported_regions.as_deref() {
205                enc.put(opt::SUPPORTED_REGIONS, regions)?;
206            }
207            if let Some(nonce) = self.nonce {
208                enc.put(opt::NONCE, &nonce.to_be_bytes())?;
209            }
210            if self.signature.is_some() {
211                enc.end_marker()?;
212            }
213            pos += enc.finish();
214        }
215
216        if let Some(sig) = &self.signature {
217            if pos + 64 > buf.len() {
218                return Err(AppEncodeError::BufferTooSmall);
219            }
220            buf[pos..pos + 64].copy_from_slice(sig);
221            pos += 64;
222        }
223
224        Ok(pos)
225    }
226
227    /// Encode the signed byte range — `ROLE` through the `0xFF`
228    /// options terminator, inclusive — for a detached signing step.
229    /// `self.signature` is ignored; the caller signs exactly the
230    /// returned bytes and appends the 64-byte signature to produce the
231    /// standalone (signed) wire form:
232    ///
233    /// ```ignore
234    /// let len = payload.encode_for_signing(&mut buf)?;
235    /// let signature = identity.sign(&buf[..len]).await?;
236    /// buf[len..len + 64].copy_from_slice(&signature);
237    /// // buf[..len + 64] now parses with `signature: Some(..)`.
238    /// ```
239    pub fn encode_for_signing(&self, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
240        let unsigned = Self {
241            signature: None,
242            ..self.clone()
243        };
244        let mut pos = unsigned.encode(buf)?;
245        let mut enc = OptionEncoder::new(&mut buf[pos..]);
246        enc.end_marker()?;
247        pos += enc.finish();
248        Ok(pos)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn round_trip(id: &NodeIdentityPayload) -> bool {
257        let mut buf = [0u8; 256];
258        let len = id.encode(&mut buf).expect("encode failed");
259        let decoded = NodeIdentityPayload::from_bytes(&buf[..len]).expect("parse failed");
260        decoded == *id
261    }
262
263    #[test]
264    fn minimal_two_bytes() {
265        let id = NodeIdentityPayload {
266            role: NodeRole::Chat,
267            capabilities: NodeCapabilities::TEXT_MESSAGES,
268            name: None,
269            location: None,
270            altitude_m: None,
271            timestamp: None,
272            supported_regions: None,
273            nonce: None,
274            signature: None,
275        };
276        let mut buf = [0u8; 16];
277        let len = id.encode(&mut buf).unwrap();
278        assert_eq!(len, 2);
279        assert_eq!(buf[0], 2); // Chat role
280        assert_eq!(buf[1], NodeCapabilities::TEXT_MESSAGES.bits());
281        assert!(round_trip(&id));
282    }
283
284    #[test]
285    fn name_only() {
286        let id = NodeIdentityPayload {
287            role: NodeRole::Unspecified,
288            capabilities: NodeCapabilities::empty(),
289            name: Some("Alice".into()),
290            location: None,
291            altitude_m: None,
292            timestamp: None,
293            supported_regions: None,
294            nonce: None,
295            signature: None,
296        };
297        assert!(round_trip(&id));
298    }
299
300    #[test]
301    fn all_options() {
302        let loc = NodeLocation::from_bytes(&[0x2B, 0x95, 0x51]);
303        let id = NodeIdentityPayload {
304            role: NodeRole::Repeater,
305            capabilities: NodeCapabilities::REPEATER | NodeCapabilities::TEXT_MESSAGES,
306            name: Some("tower".into()),
307            location: Some(loc),
308            altitude_m: Some(1500),
309            timestamp: Some(1_700_000_000),
310            supported_regions: Some(vec![0x00, 0x01, 0x00, 0x02]),
311            nonce: None,
312            signature: None,
313        };
314        assert!(round_trip(&id));
315    }
316
317    #[test]
318    fn negative_altitude() {
319        let id = NodeIdentityPayload {
320            role: NodeRole::Sensor,
321            capabilities: NodeCapabilities::empty(),
322            name: None,
323            location: None,
324            altitude_m: Some(-430), // Dead Sea
325            timestamp: None,
326            supported_regions: None,
327            nonce: None,
328            signature: None,
329        };
330        assert!(round_trip(&id));
331    }
332
333    #[test]
334    fn altitude_zero() {
335        let id = NodeIdentityPayload {
336            role: NodeRole::Sensor,
337            capabilities: NodeCapabilities::empty(),
338            name: None,
339            location: None,
340            altitude_m: Some(0),
341            timestamp: None,
342            supported_regions: None,
343            nonce: None,
344            signature: None,
345        };
346        assert!(round_trip(&id));
347    }
348
349    #[test]
350    fn nonce_round_trips_as_fixed_four_bytes() {
351        let id = NodeIdentityPayload {
352            role: NodeRole::Tracker,
353            capabilities: NodeCapabilities::MOBILE,
354            name: Some("UMSH TRACKER 1".into()),
355            location: None,
356            altitude_m: None,
357            timestamp: None,
358            supported_regions: None,
359            nonce: Some(0x0000_0042), // leading zeros must survive
360            signature: None,
361        };
362        assert!(round_trip(&id));
363        // The wire form carries all four bytes even with leading zeros.
364        let mut buf = [0u8; 64];
365        let len = id.encode(&mut buf).unwrap();
366        let window = &buf[..len];
367        assert!(
368            window.windows(4).any(|w| w == [0x00, 0x00, 0x00, 0x42]),
369            "nonce not fixed-width on the wire"
370        );
371        // A truncated nonce option is rejected, not minimally decoded.
372        let mut manual = [0u8; 8];
373        manual[0] = 0; // role
374        manual[1] = 0; // caps
375        // option 5, length 2 (invalid): delta 5 -> nibble 0x5, len 0x2
376        manual[2] = 0x52;
377        manual[3] = 0xAA;
378        manual[4] = 0xBB;
379        assert!(NodeIdentityPayload::from_bytes(&manual[..5]).is_err());
380    }
381
382    #[test]
383    fn encode_for_signing_matches_signed_wire_form() {
384        let id = NodeIdentityPayload {
385            role: NodeRole::Tracker,
386            capabilities: NodeCapabilities::empty(),
387            name: Some("advert".into()),
388            location: None,
389            altitude_m: None,
390            timestamp: None,
391            supported_regions: None,
392            nonce: Some(0xDEAD_BEEF),
393            signature: None,
394        };
395        let mut buf = [0u8; 256];
396        let len = id.encode_for_signing(&mut buf).unwrap();
397        // The signed range ends with the options terminator.
398        assert_eq!(buf[len - 1], 0xFF);
399        // Appending a signature yields exactly the wire form `encode`
400        // produces for the same payload with `signature: Some(..)`.
401        buf[len..len + 64].copy_from_slice(&[0xA5; 64]);
402        let mut reference = [0u8; 256];
403        let mut signed = id.clone();
404        signed.signature = Some([0xA5; 64]);
405        let ref_len = signed.encode(&mut reference).unwrap();
406        assert_eq!(&buf[..len + 64], &reference[..ref_len]);
407        // And the composite parses back with the signature attached.
408        let parsed = NodeIdentityPayload::from_bytes(&buf[..len + 64]).unwrap();
409        assert_eq!(parsed, signed);
410    }
411
412    #[test]
413    fn with_signature() {
414        let id = NodeIdentityPayload {
415            role: NodeRole::Chat,
416            capabilities: NodeCapabilities::empty(),
417            name: Some("Bob".into()),
418            location: None,
419            altitude_m: None,
420            timestamp: Some(1_700_000_000),
421            supported_regions: None,
422            nonce: None,
423            signature: Some([0xAAu8; 64]),
424        };
425        assert!(round_trip(&id));
426    }
427}