Skip to main content

hap_ble/
thread.rs

1//! Thread network commissioning for HAP-BLE accessories.
2//!
3//! A HomeKit **Thread** accessory does not join a Thread network through the
4//! standard external-commissioner / joiner flow; it receives its Thread
5//! *operational dataset* over HAP-BLE. The controller, over the established
6//! secure session, writes the dataset to the accessory's **Thread Control Point**
7//! characteristic (`0x0704`, in the Thread Transport service): first a small
8//! query, then the provision write carrying the network name, channel, PAN ID,
9//! Extended PAN ID, and network key.
10//!
11//! This module builds the TLV8 bodies for those two writes. Their byte layout is
12//! cross-verified against `aiohomekit`'s `thread_provision` in the tests.
13
14use hap_tlv8::Tlv8Writer;
15
16/// The Thread Control Point characteristic UUID (HAP type `0x0704`).
17pub(crate) const THREAD_CONTROL_POINT_UUID: &str = "00000704-0000-1000-8000-0026bb765291";
18
19/// A Thread operational dataset — the credentials an accessory needs to join a
20/// specific Thread network.
21///
22/// Obtain these from your Thread border router. With OpenThread's `ot-ctl`:
23/// `networkname`, `channel`, `panid`, `extpanid`, `networkkey`.
24#[derive(Clone)]
25pub struct ThreadDataset {
26    /// The Thread network name (e.g. `"OpenThread-89d7"`).
27    pub network_name: String,
28    /// The IEEE 802.15.4 channel.
29    pub channel: u8,
30    /// The 16-bit PAN ID.
31    pub pan_id: u16,
32    /// The 8-byte Extended PAN ID (big-endian, as `ot-ctl extpanid` prints it).
33    pub ext_pan_id: [u8; 8],
34    /// The 16-byte Thread network key. **Secret** — never log or persist it.
35    pub network_key: [u8; 16],
36}
37
38impl core::fmt::Debug for ThreadDataset {
39    /// Redacts the network key so a dataset is never accidentally logged.
40    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
41        f.debug_struct("ThreadDataset")
42            .field("network_name", &self.network_name)
43            .field("channel", &self.channel)
44            .field("pan_id", &format_args!("{:#06x}", self.pan_id))
45            .field(
46                "ext_pan_id",
47                &format_args!("{}", hex_lower(&self.ext_pan_id)),
48            )
49            .field("network_key", &"<redacted>")
50            .finish()
51    }
52}
53
54/// The Thread Control Point value that queries provisioning support
55/// (`kTLV(1) = 0x03`), written before the provision.
56pub(crate) fn encode_query() -> Vec<u8> {
57    let mut out = Vec::new();
58    let mut w = Tlv8Writer::new(&mut out);
59    w.push(1, &[0x03]);
60    out
61}
62
63/// The Thread Control Point value that provisions `dataset`.
64///
65/// Outer op TLV `{1 = 0x01 (provision), 2 = <dataset TLV>, 3 = 0x01}`, where the
66/// dataset TLV is `{1 = name, 2 = channel(u8), 3 = PAN ID(u16 LE),
67/// 4 = ext-PAN ID(8), 5 = network key(16)}` — byte-for-byte as `aiohomekit`
68/// sends it.
69pub(crate) fn encode_provision(dataset: &ThreadDataset) -> Vec<u8> {
70    let mut inner = Vec::new();
71    let mut iw = Tlv8Writer::new(&mut inner);
72    iw.push(1, dataset.network_name.as_bytes());
73    iw.push(2, &[dataset.channel]);
74    iw.push(3, &dataset.pan_id.to_le_bytes());
75    iw.push(4, &dataset.ext_pan_id);
76    iw.push(5, &dataset.network_key);
77
78    let mut out = Vec::new();
79    let mut w = Tlv8Writer::new(&mut out);
80    w.push(1, &[0x01]);
81    w.push(2, &inner);
82    w.push(3, &[0x01]);
83    out
84}
85
86/// Lower-case hex of `bytes` (for the redacting `Debug` and error context).
87fn hex_lower(bytes: &[u8]) -> String {
88    let mut s = String::with_capacity(bytes.len() * 2);
89    for b in bytes {
90        s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
91        s.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0'));
92    }
93    s
94}
95
96#[cfg(test)]
97#[allow(clippy::unwrap_used, clippy::expect_used)]
98mod tests {
99    use super::*;
100
101    /// The fixed test dataset the golden vectors were generated from (a fake key).
102    fn test_dataset() -> ThreadDataset {
103        ThreadDataset {
104            network_name: "OpenThread-test".into(),
105            channel: 15,
106            pan_id: 0x1234,
107            ext_pan_id: [0xde, 0xad, 0xbe, 0xef, 0x0b, 0xad, 0xf0, 0x0d],
108            network_key: [
109                0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
110                0xee, 0xff,
111            ],
112        }
113    }
114
115    // Golden bytes produced by aiohomekit's TLV encoder (see the Item 5
116    // commissioning scope doc) for `test_dataset()`.
117    const QUERY_HEX: &str = "010103";
118    const PROVISION_HEX: &str = "0101010234010f4f70656e5468726561642d7465737402010f030234120408deadbeef0badf00d051000112233445566778899aabbccddeeff030101";
119
120    #[test]
121    fn query_matches_aiohomekit() {
122        assert_eq!(hex_lower(&encode_query()), QUERY_HEX);
123    }
124
125    #[test]
126    fn provision_matches_aiohomekit_byte_for_byte() {
127        assert_eq!(hex_lower(&encode_provision(&test_dataset())), PROVISION_HEX);
128    }
129
130    #[test]
131    fn debug_redacts_the_network_key() {
132        let dbg = format!("{:?}", test_dataset());
133        assert!(dbg.contains("<redacted>"), "network key must be redacted");
134        assert!(
135            !dbg.contains("00112233"),
136            "the key bytes must not appear in Debug"
137        );
138    }
139}