umsh_ulcp/
items.rs

1//! Item codecs for the full protocol's multi-value properties.
2//!
3//! Each multi-value property defines an **item form** (what the host
4//! writes) and a **digest form** (what the device reports). The two differ
5//! exactly where the item form carries symmetric key material: digest
6//! forms never contain secrets. See "Multi-Value Properties" in the full
7//! ULCP spec.
8//!
9//! Whole-table values concatenate items: fixed-size items back to back
10//! (see [`fixed_items`]), or PUI-length-prefixed items for properties
11//! documented with an item length prefix (see [`prefixed_items`] /
12//! [`encode_prefixed_item`]). Single items carried by
13//! `CMD_PROP_INSERT`/`CMD_PROP_REMOVE` are never length-prefixed; the
14//! framing layer bounds them.
15
16use crate::pui;
17
18/// Length of an Ed25519 public key (peer entries, `PROP_HOST_KEY`,
19/// `PROP_DEV_KEY`).
20pub const PUBLIC_KEY_LEN: usize = 32;
21/// Length of a channel key item (`PROP_HOST_CHANNEL_KEYS`,
22/// `PROP_DEV_CHANNEL_KEYS`).
23pub const CHANNEL_KEY_LEN: usize = 32;
24/// Length of a derived channel identifier (the digest form of a channel
25/// key).
26pub const CHANNEL_ID_LEN: usize = 2;
27/// Length of a routing-domain region code (`PROP_MAC_REPEATER_DEFAULT_REGION`,
28/// the Region Code packet option, and the region codes carried in
29/// peer-repeater listings).
30pub const REGION_CODE_LEN: usize = 2;
31/// Longest a region's string form may be, in UTF-8 bytes
32/// (`PROP_MAC_REPEATER_REGIONS`, and the Supported Flood Regions
33/// node-identity option). The device derives the code from the string.
34pub const REGION_STRING_MAX_LEN: usize = 24;
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum ItemError {
38    /// The input ended before the item was complete.
39    Truncated,
40    /// The item's length does not match its type.
41    BadLength,
42    /// A filter entry carried an unrecognized `FILTER_TYPE`.
43    UnknownFilterType,
44    /// The output buffer cannot hold the encoded item.
45    BufferTooSmall,
46    /// An item length prefix was malformed.
47    BadPrefix,
48}
49
50/// One `PROP_HOST_PEER_KEYS` entry in item form: the peer's public key
51/// and the pairwise keys derived by the host. **Secret-bearing** — the
52/// digest form is [`Self::public_key`] alone.
53///
54/// Inserting an entry whose public key matches an existing entry
55/// replaces that entry's key material (the spec's exception to the
56/// `STATUS_ALREADY` duplicate rule).
57#[derive(Clone, Copy, PartialEq, Eq)]
58pub struct PeerKeyEntry {
59    pub public_key: [u8; PUBLIC_KEY_LEN],
60    pub k_enc: [u8; 32],
61    pub k_mic: [u8; 32],
62}
63
64impl PeerKeyEntry {
65    pub const WIRE_LEN: usize = 96;
66
67    pub fn encode(&self, out: &mut [u8]) -> Result<usize, ItemError> {
68        if out.len() < Self::WIRE_LEN {
69            return Err(ItemError::BufferTooSmall);
70        }
71        out[..32].copy_from_slice(&self.public_key);
72        out[32..64].copy_from_slice(&self.k_enc);
73        out[64..96].copy_from_slice(&self.k_mic);
74        Ok(Self::WIRE_LEN)
75    }
76
77    /// Decode an item occupying the whole input.
78    pub fn decode(input: &[u8]) -> Result<Self, ItemError> {
79        if input.len() != Self::WIRE_LEN {
80            return Err(ItemError::BadLength);
81        }
82        let mut entry = Self {
83            public_key: [0; PUBLIC_KEY_LEN],
84            k_enc: [0; 32],
85            k_mic: [0; 32],
86        };
87        entry.public_key.copy_from_slice(&input[..32]);
88        entry.k_enc.copy_from_slice(&input[32..64]);
89        entry.k_mic.copy_from_slice(&input[64..96]);
90        Ok(entry)
91    }
92
93    /// The entry's digest form (and remove selector): the public key,
94    /// never the pairwise keys.
95    pub fn digest(&self) -> &[u8; PUBLIC_KEY_LEN] {
96        &self.public_key
97    }
98}
99
100/// Debug intentionally omits `k_enc`/`k_mic`: entries must never leak
101/// key material into logs or panic messages.
102impl core::fmt::Debug for PeerKeyEntry {
103    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104        formatter
105            .debug_struct("PeerKeyEntry")
106            .field("public_key", &self.public_key)
107            .finish_non_exhaustive()
108    }
109}
110
111/// `FILTER_TYPE` for a 3-octet destination-hint filter.
112pub const FILTER_DEST_HINT: u8 = 0;
113/// `FILTER_TYPE` for a 2-octet channel-identifier filter.
114pub const FILTER_CHANNEL_ID: u8 = 1;
115/// `FILTER_TYPE` for a 1-octet FCF packet-type filter.
116pub const FILTER_PKT_TYPE: u8 = 2;
117
118/// One `PROP_HOST_RX_FILTERS` entry. Item and digest forms are
119/// identical; the remove selector is the full item.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum Filter {
122    /// Frames whose destination hint equals the value.
123    DestHint([u8; 3]),
124    /// Channel-addressed frames whose channel identifier equals the
125    /// value.
126    ChannelId([u8; CHANNEL_ID_LEN]),
127    /// Frames whose FCF packet-type field equals the value.
128    PktType(u8),
129}
130
131impl Filter {
132    /// Largest encoded filter entry (type octet + 3-octet value).
133    pub const MAX_WIRE_LEN: usize = 4;
134
135    pub const fn wire_len(&self) -> usize {
136        match self {
137            Self::DestHint(_) => 4,
138            Self::ChannelId(_) => 3,
139            Self::PktType(_) => 2,
140        }
141    }
142
143    pub fn encode(&self, out: &mut [u8]) -> Result<usize, ItemError> {
144        let len = self.wire_len();
145        if out.len() < len {
146            return Err(ItemError::BufferTooSmall);
147        }
148        match self {
149            Self::DestHint(hint) => {
150                out[0] = FILTER_DEST_HINT;
151                out[1..4].copy_from_slice(hint);
152            }
153            Self::ChannelId(id) => {
154                out[0] = FILTER_CHANNEL_ID;
155                out[1..3].copy_from_slice(id);
156            }
157            Self::PktType(pkt_type) => {
158                out[0] = FILTER_PKT_TYPE;
159                out[1] = *pkt_type;
160            }
161        }
162        Ok(len)
163    }
164
165    /// Decode a filter entry occupying the whole input.
166    ///
167    /// Per the spec, an unrecognized `FILTER_TYPE` or a value length
168    /// that does not match the type is invalid
169    /// (`STATUS_INVALID_ARGUMENT`).
170    pub fn decode(input: &[u8]) -> Result<Self, ItemError> {
171        let [filter_type, value @ ..] = input else {
172            return Err(ItemError::Truncated);
173        };
174        match (*filter_type, value) {
175            (FILTER_DEST_HINT, &[a, b, c]) => Ok(Self::DestHint([a, b, c])),
176            (FILTER_CHANNEL_ID, &[a, b]) => Ok(Self::ChannelId([a, b])),
177            (FILTER_PKT_TYPE, &[pkt_type]) => Ok(Self::PktType(pkt_type)),
178            (FILTER_DEST_HINT | FILTER_CHANNEL_ID | FILTER_PKT_TYPE, _) => {
179                Err(ItemError::BadLength)
180            }
181            _ => Err(ItemError::UnknownFilterType),
182        }
183    }
184}
185
186/// Iterate the fixed-size items of a whole-table value with no item
187/// length prefix (channel keys, peer public keys, peer key entries).
188///
189/// Fails up front unless the value is an exact multiple of `N`, so
190/// callers can validate before mutating.
191pub fn fixed_items<const N: usize>(
192    value: &[u8],
193) -> Result<impl ExactSizeIterator<Item = &[u8; N]> + Clone, ItemError> {
194    const { assert!(N > 0) };
195    if value.len() % N != 0 {
196        return Err(ItemError::BadLength);
197    }
198    Ok(value
199        .chunks_exact(N)
200        .map(|chunk| chunk.try_into().expect("chunks_exact yields N-byte chunks")))
201}
202
203/// Iterate the PUI-length-prefixed items of a whole-table value
204/// (properties documented with an item length prefix, such as
205/// `PROP_HOST_RX_FILTERS`).
206///
207/// Yields an error item for a malformed prefix or truncated body and
208/// then ends; validate the whole table before applying any of it.
209pub fn prefixed_items(value: &[u8]) -> PrefixedItems<'_> {
210    PrefixedItems { rest: value }
211}
212
213#[derive(Clone)]
214pub struct PrefixedItems<'a> {
215    rest: &'a [u8],
216}
217
218impl<'a> Iterator for PrefixedItems<'a> {
219    type Item = Result<&'a [u8], ItemError>;
220
221    fn next(&mut self) -> Option<Self::Item> {
222        if self.rest.is_empty() {
223            return None;
224        }
225        let (len, consumed) = match pui::decode(self.rest) {
226            Ok(decoded) => decoded,
227            Err(_) => {
228                self.rest = &[];
229                return Some(Err(ItemError::BadPrefix));
230            }
231        };
232        let body = &self.rest[consumed..];
233        let Ok(len) = usize::try_from(len) else {
234            self.rest = &[];
235            return Some(Err(ItemError::BadPrefix));
236        };
237        if body.len() < len {
238            self.rest = &[];
239            return Some(Err(ItemError::Truncated));
240        }
241        let (item, rest) = body.split_at(len);
242        self.rest = rest;
243        Some(Ok(item))
244    }
245}
246
247/// Append one PUI-length-prefixed item to `out`, returning the number
248/// of bytes written.
249pub fn encode_prefixed_item(item: &[u8], out: &mut [u8]) -> Result<usize, ItemError> {
250    let len = u32::try_from(item.len()).map_err(|_| ItemError::BadLength)?;
251    let prefix = pui::encode(len, out).map_err(|error| match error {
252        pui::Error::BufferTooSmall => ItemError::BufferTooSmall,
253        _ => ItemError::BadLength,
254    })?;
255    let end = prefix + item.len();
256    if out.len() < end {
257        return Err(ItemError::BufferTooSmall);
258    }
259    out[prefix..end].copy_from_slice(item);
260    Ok(end)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn peer_key_entry_round_trip_and_digest_is_secret_free() {
269        let entry = PeerKeyEntry {
270            public_key: [0x11; 32],
271            k_enc: [0x22; 32],
272            k_mic: [0x33; 32],
273        };
274        let mut buf = [0u8; PeerKeyEntry::WIRE_LEN];
275        assert_eq!(entry.encode(&mut buf).unwrap(), PeerKeyEntry::WIRE_LEN);
276        assert_eq!(PeerKeyEntry::decode(&buf).unwrap(), entry);
277
278        // The digest form and remove selector carry only the public key.
279        assert_eq!(entry.digest(), &[0x11; 32]);
280        // Debug output must not leak key material.
281        let debug = std::format!("{entry:?}");
282        assert!(
283            !debug.contains("k_enc") && !debug.contains("k_mic"),
284            "{debug}"
285        );
286    }
287
288    #[test]
289    fn peer_key_entry_rejects_wrong_lengths() {
290        assert_eq!(PeerKeyEntry::decode(&[0; 95]), Err(ItemError::BadLength));
291        assert_eq!(PeerKeyEntry::decode(&[0; 97]), Err(ItemError::BadLength));
292        assert_eq!(PeerKeyEntry::decode(&[]), Err(ItemError::BadLength));
293    }
294
295    #[test]
296    fn filter_round_trip_every_type() {
297        let filters = [
298            Filter::DestHint([0xAA, 0xBB, 0xCC]),
299            Filter::ChannelId([0x12, 0x34]),
300            Filter::PktType(0),
301        ];
302        for filter in filters {
303            let mut buf = [0u8; Filter::MAX_WIRE_LEN];
304            let len = filter.encode(&mut buf).unwrap();
305            assert_eq!(len, filter.wire_len());
306            assert_eq!(Filter::decode(&buf[..len]).unwrap(), filter);
307        }
308    }
309
310    #[test]
311    fn filter_rejects_malformed_entries() {
312        assert_eq!(Filter::decode(&[]), Err(ItemError::Truncated));
313        // Wrong value lengths for each known type.
314        assert_eq!(
315            Filter::decode(&[FILTER_DEST_HINT, 1, 2]),
316            Err(ItemError::BadLength)
317        );
318        assert_eq!(
319            Filter::decode(&[FILTER_CHANNEL_ID, 1, 2, 3]),
320            Err(ItemError::BadLength)
321        );
322        assert_eq!(
323            Filter::decode(&[FILTER_PKT_TYPE]),
324            Err(ItemError::BadLength)
325        );
326        // Unknown filter type.
327        assert_eq!(Filter::decode(&[3, 0]), Err(ItemError::UnknownFilterType));
328    }
329
330    #[test]
331    fn fixed_items_iterates_and_validates_alignment() {
332        let value = [1u8, 1, 2, 2, 3, 3];
333        let items: Vec<[u8; 2]> = fixed_items::<2>(&value).unwrap().copied().collect();
334        assert_eq!(items, [[1, 1], [2, 2], [3, 3]]);
335
336        assert!(fixed_items::<2>(&[0; 5]).is_err());
337        assert_eq!(fixed_items::<32>(&[]).unwrap().len(), 0);
338    }
339
340    #[test]
341    fn prefixed_items_round_trip() {
342        let mut table = [0u8; 32];
343        let mut len = 0;
344        let items: [&[u8]; 3] = [&[0xAA, 0xBB], &[], &[0xCC; 5]];
345        for item in items {
346            len += encode_prefixed_item(item, &mut table[len..]).unwrap();
347        }
348        let decoded: Vec<&[u8]> = prefixed_items(&table[..len])
349            .collect::<Result<_, _>>()
350            .unwrap();
351        assert_eq!(decoded, items);
352        assert_eq!(prefixed_items(&[]).count(), 0);
353    }
354
355    #[test]
356    fn prefixed_items_reports_truncation_and_stops() {
357        // Prefix claims 4 bytes, only 2 present.
358        let mut iterator = prefixed_items(&[4, 0xAA, 0xBB]);
359        assert_eq!(iterator.next(), Some(Err(ItemError::Truncated)));
360        assert_eq!(iterator.next(), None);
361
362        // A malformed (over-long) PUI prefix.
363        let mut iterator = prefixed_items(&[0x80, 0x80, 0x80, 0x80]);
364        assert_eq!(iterator.next(), Some(Err(ItemError::BadPrefix)));
365        assert_eq!(iterator.next(), None);
366    }
367
368    #[test]
369    fn encode_prefixed_item_reports_overflow() {
370        let mut small = [0u8; 3];
371        assert_eq!(
372            encode_prefixed_item(&[0; 8], &mut small),
373            Err(ItemError::BufferTooSmall)
374        );
375    }
376}