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