umsh_node/
identity.rs

1use alloc::string::String;
2use alloc::vec::Vec;
3
4use bitflags::bitflags;
5use umsh_core::REGION_NAME_MAX_LEN as REGION_STRING_MAX_LEN;
6use umsh_core::options::{OptionDecoder, OptionEncoder, parse_be_i32, parse_be_u32};
7
8use crate::app_util::parse_utf8;
9use crate::location::NodeLocation;
10use crate::{AppEncodeError, AppParseError};
11
12/// Most regions one identity payload names.
13///
14/// The list is a claim about where a repeater forwards, not an inventory, and
15/// ten of them is already more than a packet comfortably carries
16/// (node-identity.md § Supported Regions).
17pub const MAX_SUPPORTED_REGIONS: usize = 10;
18
19mod opt {
20    pub const NAME: u16 = 0;
21    pub const LOCATION: u16 = 1;
22    pub const ALTITUDE: u16 = 2;
23    pub const TIMESTAMP: u16 = 3;
24    pub const SUPPORTED_REGIONS: u16 = 4;
25    pub const NONCE: u16 = 5;
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum NodeRole {
30    Unspecified,
31    Repeater,
32    Chat,
33    Tracker,
34    Sensor,
35    Bridge,
36    ChatRoom,
37    TemporarySession,
38    /// A role value not recognized by this implementation; preserved for round-tripping.
39    Unknown(u8),
40}
41
42impl NodeRole {
43    pub fn from_byte(value: u8) -> Self {
44        match value {
45            0 => Self::Unspecified,
46            1 => Self::Repeater,
47            2 => Self::Chat,
48            3 => Self::Tracker,
49            4 => Self::Sensor,
50            5 => Self::Bridge,
51            6 => Self::ChatRoom,
52            7 => Self::TemporarySession,
53            n => Self::Unknown(n),
54        }
55    }
56
57    pub fn as_byte(self) -> u8 {
58        match self {
59            Self::Unspecified => 0,
60            Self::Repeater => 1,
61            Self::Chat => 2,
62            Self::Tracker => 3,
63            Self::Sensor => 4,
64            Self::Bridge => 5,
65            Self::ChatRoom => 6,
66            Self::TemporarySession => 7,
67            Self::Unknown(n) => n,
68        }
69    }
70}
71
72bitflags! {
73    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
74    pub struct NodeCapabilities: u8 {
75        const REPEATER       = 0x01;
76        const MOBILE         = 0x02;
77        const TEXT_MESSAGES  = 0x04;
78        const TELEMETRY      = 0x08;
79        const CHAT_ROOM      = 0x10;
80        const COAP           = 0x20;
81    }
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct NodeIdentityPayload {
86    pub role: NodeRole,
87    pub capabilities: NodeCapabilities,
88    /// Option 0 — display name (UTF-8).
89    pub name: Option<String>,
90    /// Option 1 — geographic position.
91    pub location: Option<NodeLocation>,
92    /// Option 2 — altitude above mean sea level, in meters.
93    pub altitude_m: Option<i32>,
94    /// Option 3 — seconds since the Unix epoch (freshness marker).
95    pub timestamp: Option<u32>,
96    /// Option 4 — the regions this repeater floods for, in their string form,
97    /// one repetition of the option per region.
98    ///
99    /// The string travels rather than the derived code because the code is
100    /// always recoverable from the string while a hash-derived code cannot be
101    /// turned back into a name. A node lists at most
102    /// [`MAX_SUPPORTED_REGIONS`], and a list that does not fit its packet is
103    /// legitimately shortened, so this names regions the node forwards for
104    /// without promising to name them all
105    /// (node-identity.md § Supported Regions).
106    pub supported_regions: Option<Vec<String>>,
107    /// Option 5 — nonce echoed from a soliciting Advertisement Request.
108    /// Present only in solicited advertisements whose request carried one.
109    pub nonce: Option<u32>,
110    /// EdDSA signature over ROLE..=0xFF, present when the identity stands alone.
111    ///
112    /// TODO: signing and verification belong outside `NodeIdentityPayload`
113    /// (the way the source-address routing hint lives outside the payload).
114    /// The eventual rework should remove this field and move the
115    /// signed-identity wrapper to a separate type that owns both the encoded
116    /// payload bytes and the signature, so the signed-byte range is never
117    /// reconstructed at the call site.
118    pub signature: Option<[u8; 64]>,
119}
120
121impl NodeIdentityPayload {
122    pub fn from_bytes(payload: &[u8]) -> Result<NodeIdentityPayload, AppParseError> {
123        if payload.len() < 2 {
124            return Err(AppParseError::Core(umsh_core::ParseError::Truncated));
125        }
126
127        let role = NodeRole::from_byte(payload[0]);
128        let capabilities = NodeCapabilities::from_bits_truncate(payload[1]);
129        let remaining = &payload[2..];
130
131        let mut name = None;
132        let mut location = None;
133        let mut altitude_m = None;
134        let mut timestamp = None;
135        let mut supported_regions = None;
136        let mut nonce = None;
137
138        let mut decoder = OptionDecoder::new(remaining);
139        for result in decoder.by_ref() {
140            let (number, value) = result?;
141            match number {
142                opt::NAME => name = Some(String::from(parse_utf8(value)?)),
143                opt::LOCATION => {
144                    // Spec: MUST ignore bytes after the 7th
145                    location = Some(NodeLocation::from_bytes(value));
146                }
147                opt::ALTITUDE => altitude_m = Some(parse_be_i32(value)?),
148                opt::TIMESTAMP => timestamp = Some(parse_be_u32(value)?),
149                opt::SUPPORTED_REGIONS => {
150                    // Repeated once per region. An entry that is empty,
151                    // over-long, or not UTF-8 is skipped rather than taken as
152                    // grounds to reject the identity, and so is anything past
153                    // the tenth: the list is advisory, and a reader that
154                    // throws away a whole advertisement over one bad region
155                    // learns nothing about the node instead of nearly
156                    // everything.
157                    let regions = supported_regions.get_or_insert_with(|| Vec::with_capacity(1));
158                    if regions.len() < MAX_SUPPORTED_REGIONS
159                        && (1..=REGION_STRING_MAX_LEN).contains(&value.len())
160                        && let Ok(text) = core::str::from_utf8(value)
161                    {
162                        regions.push(String::from(text));
163                    }
164                }
165                opt::NONCE => {
166                    // A verbatim copy of the request's 4-byte field —
167                    // fixed-width, unlike the minimally encoded integers.
168                    let bytes: [u8; 4] = value
169                        .try_into()
170                        .map_err(|_| AppParseError::InvalidOptionValue)?;
171                    nonce = Some(u32::from_be_bytes(bytes));
172                }
173                _ => {} // unknown options are silently skipped
174            }
175        }
176
177        let sig_bytes = decoder.remainder();
178        let signature = match sig_bytes.len() {
179            0 => None,
180            64 => Some(
181                sig_bytes
182                    .try_into()
183                    .map_err(|_| AppParseError::InvalidLength {
184                        expected: 64,
185                        actual: sig_bytes.len(),
186                    })?,
187            ),
188            n => {
189                return Err(AppParseError::InvalidLength {
190                    expected: 64,
191                    actual: n,
192                });
193            }
194        };
195
196        Ok(NodeIdentityPayload {
197            role,
198            capabilities,
199            name,
200            location,
201            altitude_m,
202            timestamp,
203            supported_regions,
204            nonce,
205            signature,
206        })
207    }
208
209    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
210        if buf.len() < 2 {
211            return Err(AppEncodeError::BufferTooSmall);
212        }
213        buf[0] = self.role.as_byte();
214        buf[1] = self.capabilities.bits();
215        let mut pos = 2;
216
217        {
218            let mut enc = OptionEncoder::new(&mut buf[pos..]);
219            if let Some(name) = self.name.as_deref() {
220                enc.put(opt::NAME, name.as_bytes())?;
221            }
222            if let Some(loc) = self.location {
223                enc.put(opt::LOCATION, loc.as_bytes())?;
224            }
225            if let Some(alt) = self.altitude_m {
226                enc.put_i32(opt::ALTITUDE, alt)?;
227            }
228            if let Some(ts) = self.timestamp {
229                enc.put_u32(opt::TIMESTAMP, ts)?;
230            }
231            if let Some(regions) = self.supported_regions.as_deref() {
232                for region in regions.iter().take(MAX_SUPPORTED_REGIONS) {
233                    if !(1..=REGION_STRING_MAX_LEN).contains(&region.len()) {
234                        return Err(AppEncodeError::InvalidField);
235                    }
236                    enc.put(opt::SUPPORTED_REGIONS, region.as_bytes())?;
237                }
238            }
239            if let Some(nonce) = self.nonce {
240                enc.put(opt::NONCE, &nonce.to_be_bytes())?;
241            }
242            if self.signature.is_some() {
243                enc.end_marker()?;
244            }
245            pos += enc.finish();
246        }
247
248        if let Some(sig) = &self.signature {
249            if pos + 64 > buf.len() {
250                return Err(AppEncodeError::BufferTooSmall);
251            }
252            buf[pos..pos + 64].copy_from_slice(sig);
253            pos += 64;
254        }
255
256        Ok(pos)
257    }
258
259    /// Encode, dropping regions from the end of the list until the result
260    /// fits.
261    ///
262    /// The region list is the one part of an identity that is allowed to
263    /// arrive incomplete: it names regions the node forwards for without
264    /// promising to name every one (node-identity.md § Supported Regions).
265    /// Everything else in the payload is a fact about the node, so a buffer
266    /// too small to hold it after the last region is gone is still an error.
267    pub fn encode_fitting(&self, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
268        fn out_of_room(error: &AppEncodeError) -> bool {
269            matches!(
270                error,
271                AppEncodeError::BufferTooSmall
272                    | AppEncodeError::Core(umsh_core::EncodeError::BufferTooSmall)
273            )
274        }
275
276        match self.encode(buf) {
277            Err(error) if out_of_room(&error) => {}
278            result => return result,
279        }
280
281        let Some(regions) = self.supported_regions.as_deref() else {
282            return Err(AppEncodeError::BufferTooSmall);
283        };
284        let mut shortened = self.clone();
285        for keep in (0..regions.len()).rev() {
286            shortened.supported_regions = (keep > 0).then(|| Vec::from(&regions[..keep]));
287            match shortened.encode(buf) {
288                Err(error) if out_of_room(&error) => continue,
289                result => return result,
290            }
291        }
292        Err(AppEncodeError::BufferTooSmall)
293    }
294
295    /// Encode the signed byte range — `ROLE` through the `0xFF`
296    /// options terminator, inclusive — for a detached signing step.
297    /// `self.signature` is ignored; the caller signs exactly the
298    /// returned bytes and appends the 64-byte signature to produce the
299    /// standalone (signed) wire form:
300    ///
301    /// ```ignore
302    /// let len = payload.encode_for_signing(&mut buf)?;
303    /// let signature = identity.sign(&buf[..len]).await?;
304    /// buf[len..len + 64].copy_from_slice(&signature);
305    /// // buf[..len + 64] now parses with `signature: Some(..)`.
306    /// ```
307    pub fn encode_for_signing(&self, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
308        let unsigned = Self {
309            signature: None,
310            ..self.clone()
311        };
312        // Fit-aware, because a standalone identity is framed into a fixed
313        // advertisement buffer and the region list is the part that is
314        // allowed to arrive short.
315        let mut pos = unsigned.encode_fitting(buf)?;
316        let mut enc = OptionEncoder::new(&mut buf[pos..]);
317        enc.end_marker()?;
318        pos += enc.finish();
319        Ok(pos)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn round_trip(id: &NodeIdentityPayload) -> bool {
328        let mut buf = [0u8; 256];
329        let len = id.encode(&mut buf).expect("encode failed");
330        let decoded = NodeIdentityPayload::from_bytes(&buf[..len]).expect("parse failed");
331        decoded == *id
332    }
333
334    #[test]
335    fn minimal_two_bytes() {
336        let id = NodeIdentityPayload {
337            role: NodeRole::Chat,
338            capabilities: NodeCapabilities::TEXT_MESSAGES,
339            name: None,
340            location: None,
341            altitude_m: None,
342            timestamp: None,
343            supported_regions: None,
344            nonce: None,
345            signature: None,
346        };
347        let mut buf = [0u8; 16];
348        let len = id.encode(&mut buf).unwrap();
349        assert_eq!(len, 2);
350        assert_eq!(buf[0], 2); // Chat role
351        assert_eq!(buf[1], NodeCapabilities::TEXT_MESSAGES.bits());
352        assert!(round_trip(&id));
353    }
354
355    #[test]
356    fn name_only() {
357        let id = NodeIdentityPayload {
358            role: NodeRole::Unspecified,
359            capabilities: NodeCapabilities::empty(),
360            name: Some("Alice".into()),
361            location: None,
362            altitude_m: None,
363            timestamp: None,
364            supported_regions: None,
365            nonce: None,
366            signature: None,
367        };
368        assert!(round_trip(&id));
369    }
370
371    #[test]
372    fn all_options() {
373        let loc = NodeLocation::from_bytes(&[0x2B, 0x95, 0x51]);
374        let id = NodeIdentityPayload {
375            role: NodeRole::Repeater,
376            capabilities: NodeCapabilities::REPEATER | NodeCapabilities::TEXT_MESSAGES,
377            name: Some("tower".into()),
378            location: Some(loc),
379            altitude_m: Some(1500),
380            timestamp: Some(1_700_000_000),
381            supported_regions: Some(vec!["SJC".into(), "Rogue Valley".into()]),
382            nonce: None,
383            signature: None,
384        };
385        assert!(round_trip(&id));
386    }
387
388    /// One option per region, so the wire carries the list rather than a
389    /// concatenation a reader would have to know how to split.
390    #[test]
391    fn each_region_is_its_own_option() {
392        let id = NodeIdentityPayload {
393            role: NodeRole::Repeater,
394            capabilities: NodeCapabilities::REPEATER,
395            name: None,
396            location: None,
397            altitude_m: None,
398            timestamp: None,
399            supported_regions: Some(vec!["SJC".into(), "MFR".into(), "0x31d9".into()]),
400            nonce: None,
401            signature: None,
402        };
403        let mut buf = [0u8; 64];
404        let len = id.encode(&mut buf).unwrap();
405
406        let mut seen = Vec::new();
407        for entry in OptionDecoder::new(&buf[2..len]) {
408            let (number, value) = entry.unwrap();
409            if number == opt::SUPPORTED_REGIONS {
410                seen.push(String::from(core::str::from_utf8(value).unwrap()));
411            }
412        }
413        assert_eq!(seen, ["SJC", "MFR", "0x31d9"]);
414        assert!(round_trip(&id));
415    }
416
417    /// The list is advisory. A reader keeps what it can use and drops the
418    /// rest rather than discarding everything else the identity says.
419    #[test]
420    fn skips_regions_it_cannot_use_and_keeps_the_identity() {
421        let mut buf = [0u8; 256];
422        buf[0] = NodeRole::Repeater.as_byte();
423        buf[1] = NodeCapabilities::REPEATER.bits();
424        let long = "R".repeat(REGION_STRING_MAX_LEN + 1);
425        let mut pos = 2;
426        {
427            let mut enc = OptionEncoder::new(&mut buf[pos..]);
428            enc.put(opt::NAME, b"tower").unwrap();
429            enc.put(opt::SUPPORTED_REGIONS, b"SJC").unwrap();
430            enc.put(opt::SUPPORTED_REGIONS, b"").unwrap();
431            enc.put(opt::SUPPORTED_REGIONS, long.as_bytes()).unwrap();
432            enc.put(opt::SUPPORTED_REGIONS, &[0xFF, 0xFE]).unwrap();
433            enc.put(opt::SUPPORTED_REGIONS, b"MFR").unwrap();
434            pos += enc.finish();
435        }
436
437        let decoded = NodeIdentityPayload::from_bytes(&buf[..pos]).unwrap();
438        assert_eq!(decoded.name.as_deref(), Some("tower"));
439        assert_eq!(decoded.supported_regions.unwrap(), ["SJC", "MFR"]);
440    }
441
442    #[test]
443    fn keeps_only_the_first_ten_regions() {
444        let mut buf = [0u8; 256];
445        buf[0] = NodeRole::Repeater.as_byte();
446        buf[1] = NodeCapabilities::REPEATER.bits();
447        let mut pos = 2;
448        {
449            let mut enc = OptionEncoder::new(&mut buf[pos..]);
450            for index in 0..MAX_SUPPORTED_REGIONS + 4 {
451                let name = alloc::format!("R{index:02}");
452                enc.put(opt::SUPPORTED_REGIONS, name.as_bytes()).unwrap();
453            }
454            pos += enc.finish();
455        }
456
457        let regions = NodeIdentityPayload::from_bytes(&buf[..pos])
458            .unwrap()
459            .supported_regions
460            .unwrap();
461        assert_eq!(regions.len(), MAX_SUPPORTED_REGIONS);
462        assert_eq!(regions[0], "R00");
463        assert_eq!(regions[MAX_SUPPORTED_REGIONS - 1], "R09");
464    }
465
466    /// A list that does not fit is shortened from the end; the rest of the
467    /// identity still goes out.
468    #[test]
469    fn drops_trailing_regions_to_fit_the_buffer() {
470        let id = NodeIdentityPayload {
471            role: NodeRole::Repeater,
472            capabilities: NodeCapabilities::REPEATER,
473            name: Some("tower".into()),
474            location: None,
475            altitude_m: None,
476            timestamp: None,
477            supported_regions: Some(vec![
478                "Rogue Valley".into(),
479                "SF Bay Area".into(),
480                "Southern Oregon".into(),
481            ]),
482            nonce: None,
483            signature: None,
484        };
485
486        let mut full = [0u8; 128];
487        let full_len = id.encode(&mut full).unwrap();
488
489        let mut clipped = [0u8; 128];
490        let clipped_len = id.encode_fitting(&mut clipped[..full_len - 8]).unwrap();
491        let decoded = NodeIdentityPayload::from_bytes(&clipped[..clipped_len]).unwrap();
492
493        assert_eq!(decoded.name.as_deref(), Some("tower"));
494        assert_eq!(
495            decoded.supported_regions.unwrap(),
496            ["Rogue Valley", "SF Bay Area"],
497            "only the entries that did not fit are dropped, and from the end"
498        );
499    }
500
501    /// Only the regions are optional. A buffer that cannot hold the identity
502    /// with every region gone is an encoding failure, not a shorter identity.
503    #[test]
504    fn refuses_to_shorten_anything_but_the_regions() {
505        let id = NodeIdentityPayload {
506            role: NodeRole::Repeater,
507            capabilities: NodeCapabilities::REPEATER,
508            name: Some("a rather long tower name".into()),
509            location: None,
510            altitude_m: None,
511            timestamp: None,
512            supported_regions: Some(vec!["SJC".into()]),
513            nonce: None,
514            signature: None,
515        };
516        let mut buf = [0u8; 12];
517        assert!(matches!(
518            id.encode_fitting(&mut buf),
519            Err(AppEncodeError::BufferTooSmall)
520        ));
521    }
522
523    #[test]
524    fn negative_altitude() {
525        let id = NodeIdentityPayload {
526            role: NodeRole::Sensor,
527            capabilities: NodeCapabilities::empty(),
528            name: None,
529            location: None,
530            altitude_m: Some(-430), // Dead Sea
531            timestamp: None,
532            supported_regions: None,
533            nonce: None,
534            signature: None,
535        };
536        assert!(round_trip(&id));
537    }
538
539    #[test]
540    fn altitude_zero() {
541        let id = NodeIdentityPayload {
542            role: NodeRole::Sensor,
543            capabilities: NodeCapabilities::empty(),
544            name: None,
545            location: None,
546            altitude_m: Some(0),
547            timestamp: None,
548            supported_regions: None,
549            nonce: None,
550            signature: None,
551        };
552        assert!(round_trip(&id));
553    }
554
555    #[test]
556    fn nonce_round_trips_as_fixed_four_bytes() {
557        let id = NodeIdentityPayload {
558            role: NodeRole::Tracker,
559            capabilities: NodeCapabilities::MOBILE,
560            name: Some("UMSH TRACKER 1".into()),
561            location: None,
562            altitude_m: None,
563            timestamp: None,
564            supported_regions: None,
565            nonce: Some(0x0000_0042), // leading zeros must survive
566            signature: None,
567        };
568        assert!(round_trip(&id));
569        // The wire form carries all four bytes even with leading zeros.
570        let mut buf = [0u8; 64];
571        let len = id.encode(&mut buf).unwrap();
572        let window = &buf[..len];
573        assert!(
574            window.windows(4).any(|w| w == [0x00, 0x00, 0x00, 0x42]),
575            "nonce not fixed-width on the wire"
576        );
577        // A truncated nonce option is rejected, not minimally decoded.
578        let mut manual = [0u8; 8];
579        manual[0] = 0; // role
580        manual[1] = 0; // caps
581        // option 5, length 2 (invalid): delta 5 -> nibble 0x5, len 0x2
582        manual[2] = 0x52;
583        manual[3] = 0xAA;
584        manual[4] = 0xBB;
585        assert!(NodeIdentityPayload::from_bytes(&manual[..5]).is_err());
586    }
587
588    #[test]
589    fn encode_for_signing_matches_signed_wire_form() {
590        let id = NodeIdentityPayload {
591            role: NodeRole::Tracker,
592            capabilities: NodeCapabilities::empty(),
593            name: Some("advert".into()),
594            location: None,
595            altitude_m: None,
596            timestamp: None,
597            supported_regions: None,
598            nonce: Some(0xDEAD_BEEF),
599            signature: None,
600        };
601        let mut buf = [0u8; 256];
602        let len = id.encode_for_signing(&mut buf).unwrap();
603        // The signed range ends with the options terminator.
604        assert_eq!(buf[len - 1], 0xFF);
605        // Appending a signature yields exactly the wire form `encode`
606        // produces for the same payload with `signature: Some(..)`.
607        buf[len..len + 64].copy_from_slice(&[0xA5; 64]);
608        let mut reference = [0u8; 256];
609        let mut signed = id.clone();
610        signed.signature = Some([0xA5; 64]);
611        let ref_len = signed.encode(&mut reference).unwrap();
612        assert_eq!(&buf[..len + 64], &reference[..ref_len]);
613        // And the composite parses back with the signature attached.
614        let parsed = NodeIdentityPayload::from_bytes(&buf[..len + 64]).unwrap();
615        assert_eq!(parsed, signed);
616    }
617
618    #[test]
619    fn with_signature() {
620        let id = NodeIdentityPayload {
621            role: NodeRole::Chat,
622            capabilities: NodeCapabilities::empty(),
623            name: Some("Bob".into()),
624            location: None,
625            altitude_m: None,
626            timestamp: Some(1_700_000_000),
627            supported_regions: None,
628            nonce: None,
629            signature: Some([0xAAu8; 64]),
630        };
631        assert!(round_trip(&id));
632    }
633}