Skip to main content

matter_controller/
admin.rs

1//! `AdministratorCommissioning` (0x003C) controller support: types and the pure
2//! request/response codecs the `Node` verbs compose. See M9-D1 plan.
3
4use matter_codec::{Tag, Value};
5use matter_interaction::AttributePath;
6
7use crate::error::Error;
8
9/// `AdministratorCommissioning` cluster id.
10pub(crate) const ADMIN_COMMISSIONING_CLUSTER: u32 = 0x003C;
11/// Command id for `OpenCommissioningWindow`.
12pub(crate) const CMD_OPEN_COMMISSIONING_WINDOW: u32 = 0x00;
13/// Command id for `OpenBasicCommissioningWindow`.
14pub(crate) const CMD_OPEN_BASIC_COMMISSIONING_WINDOW: u32 = 0x01;
15/// Command id for `RevokeCommissioning`.
16pub(crate) const CMD_REVOKE_COMMISSIONING: u32 = 0x02;
17/// Attribute id for `WindowStatus`.
18pub(crate) const ATTR_WINDOW_STATUS: u32 = 0x0000;
19/// Attribute id for `AdminFabricIndex`.
20pub(crate) const ATTR_ADMIN_FABRIC_INDEX: u32 = 0x0001;
21/// Attribute id for `AdminVendorId`.
22pub(crate) const ATTR_ADMIN_VENDOR_ID: u32 = 0x0002;
23/// Spec default/floor PBKDF iterations for an opened window.
24pub const DEFAULT_WINDOW_ITERATIONS: u32 = 1000;
25/// Spec-recommended commissioning-window timeout (seconds).
26pub const DEFAULT_WINDOW_TIMEOUT_S: u16 = 180;
27
28/// Options for `Node::open_commissioning_window` (Task 3).
29#[derive(Clone, Debug)]
30#[non_exhaustive]
31pub struct OpenWindowOpts {
32    /// How long the window stays open, in seconds.
33    pub timeout_s: u16,
34    /// PBKDF2 iteration count for the generated verifier (≥ 1000).
35    pub iterations: u32,
36    /// Device Vendor ID — required to emit a QR code (read it from Basic
37    /// Information, or leave `None` to get only the manual pairing code).
38    pub vendor_id: Option<u16>,
39    /// Device Product ID — pair with `vendor_id`.
40    pub product_id: Option<u16>,
41}
42
43impl Default for OpenWindowOpts {
44    fn default() -> Self {
45        Self {
46            timeout_s: DEFAULT_WINDOW_TIMEOUT_S,
47            iterations: DEFAULT_WINDOW_ITERATIONS,
48            vendor_id: None,
49            product_id: None,
50        }
51    }
52}
53
54/// The result of opening an enhanced commissioning window — everything a second
55/// commissioner needs to onboard the device onto its own fabric.
56#[derive(Clone, Debug)]
57#[non_exhaustive]
58pub struct CommissioningWindow {
59    /// The freshly generated 27-bit setup passcode.
60    pub passcode: u32,
61    /// The 12-bit discriminator advertised while the window is open.
62    pub discriminator: u16,
63    /// PBKDF2 iterations used.
64    pub iterations: u32,
65    /// PBKDF2 salt used.
66    pub salt: Vec<u8>,
67    /// 11-digit manual pairing code (always present).
68    pub manual_code: String,
69    /// `MT:` QR string — `Some` only when `vendor_id`/`product_id` were supplied.
70    pub qr_code: Option<String>,
71}
72
73/// Decoded `WindowStatus` enum8.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum CommissioningWindowStatus {
77    /// 0 — no window open.
78    WindowNotOpen,
79    /// 1 — enhanced window open.
80    EnhancedWindowOpen,
81    /// 2 — basic window open.
82    BasicWindowOpen,
83    /// Any other (future) value.
84    Unknown(u8),
85}
86
87impl CommissioningWindowStatus {
88    fn from_u8(v: u8) -> Self {
89        match v {
90            0 => Self::WindowNotOpen,
91            1 => Self::EnhancedWindowOpen,
92            2 => Self::BasicWindowOpen,
93            other => Self::Unknown(other),
94        }
95    }
96}
97
98/// Snapshot of the `AdministratorCommissioning` status attributes.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100#[non_exhaustive]
101pub struct WindowStatus {
102    /// Current window status.
103    pub status: CommissioningWindowStatus,
104    /// Fabric index of the admin that opened the window, if any.
105    pub admin_fabric_index: Option<u8>,
106    /// Vendor id of the admin that opened the window, if any.
107    pub admin_vendor_id: Option<u16>,
108}
109
110/// Build the `OpenCommissioningWindow` command fields struct (spec field tags).
111///
112/// Fields per Matter Core Spec §11.18.8.1:
113/// - tag 0: `CommissioningTimeout` (uint16)
114/// - tag 1: `PAKEPasscodeVerifier` (bytes, 97 octets)
115/// - tag 2: `Discriminator` (uint16, 12-bit)
116/// - tag 3: `Iterations` (uint32)
117/// - tag 4: `Salt` (bytes, 16–32 octets)
118pub(crate) fn open_window_fields(
119    timeout_s: u16,
120    verifier: &[u8],
121    discriminator: u16,
122    iterations: u32,
123    salt: &[u8],
124) -> Value {
125    Value::Structure(vec![
126        (Tag::Context(0), Value::Uint(u64::from(timeout_s))),
127        (Tag::Context(1), Value::Bytes(verifier.to_vec())),
128        (Tag::Context(2), Value::Uint(u64::from(discriminator))),
129        (Tag::Context(3), Value::Uint(u64::from(iterations))),
130        (Tag::Context(4), Value::Bytes(salt.to_vec())),
131    ])
132}
133
134/// Build the manual code (always) and QR (`Some` iff vid+pid given) for a window.
135///
136/// # Errors
137///
138/// Returns [`Error::SetupCode`] if `passcode` or `discriminator` are out of
139/// their valid ranges, or if QR encoding fails.
140pub(crate) fn onboarding_payload(
141    passcode: u32,
142    discriminator: u16,
143    vendor_id: Option<u16>,
144    product_id: Option<u16>,
145) -> Result<(String, Option<String>), Error> {
146    use matter_commissioning::setup::{
147        encode_manual_code, encode_qr, CommissioningFlow, DiscoveryCapabilities, Discriminator,
148        Passcode, SetupPayload,
149    };
150    let map = |e: matter_commissioning::setup::Error| Error::SetupCode(e.to_string());
151    let base = SetupPayload {
152        version: 0,
153        vendor_id: None,
154        product_id: None,
155        commissioning_flow: CommissioningFlow::Standard,
156        discovery_capabilities: DiscoveryCapabilities::ON_NETWORK,
157        discriminator: Discriminator::new(discriminator).map_err(map)?,
158        passcode: Passcode::new(passcode).map_err(map)?,
159    };
160    // Build the manual code (borrows base) before consuming base into the QR payload.
161    let manual_code = encode_manual_code(&base);
162    let qr_code = match (vendor_id, product_id) {
163        (Some(v), Some(p)) => {
164            let qr = SetupPayload {
165                vendor_id: Some(v),
166                product_id: Some(p),
167                ..base
168            };
169            Some(encode_qr(&qr).map_err(map)?)
170        }
171        _ => None,
172    };
173    Ok((manual_code, qr_code))
174}
175
176/// Generate a valid `(passcode, salt, discriminator)` for an enhanced window.
177///
178/// Passcode is a fresh 27-bit value with the spec's trivial values excluded;
179/// salt is 32 random bytes; discriminator is a random 12-bit value.
180///
181/// # Errors
182/// Returns [`Error::Operational`] if the system RNG fails or no valid passcode
183/// is found within the retry budget (practically never — ~12 values excluded).
184pub(crate) fn random_window_secrets() -> Result<(u32, [u8; 32], u16), Error> {
185    use matter_commissioning::setup::Passcode;
186    let rng = |buf: &mut [u8]| {
187        matter_crypto::random_bytes(buf).map_err(|e| Error::Operational(format!("rng: {e}")))
188    };
189    let mut salt = [0u8; 32];
190    rng(&mut salt)?;
191    let mut db = [0u8; 2];
192    rng(&mut db)?;
193    let discriminator = u16::from_le_bytes(db) & 0x0FFF;
194    // Passcode: draw 27-bit values until one is spec-valid (Passcode::new rejects
195    // out-of-range and the disallowed-trivial set).
196    for _ in 0..64 {
197        let mut pb = [0u8; 4];
198        rng(&mut pb)?;
199        let candidate = u32::from_le_bytes(pb) & 0x07FF_FFFF; // 27-bit
200        if Passcode::new(candidate).is_ok() {
201            return Ok((candidate, salt, discriminator));
202        }
203    }
204    Err(Error::Operational(
205        "could not generate a valid passcode".into(),
206    ))
207}
208
209/// Parse the three status attributes from a `read` result.
210pub(crate) fn parse_window_status(reports: &[(AttributePath, Value)]) -> WindowStatus {
211    let mut status = CommissioningWindowStatus::WindowNotOpen;
212    let mut admin_fabric_index: Option<u8> = None;
213    let mut admin_vendor_id: Option<u16> = None;
214    for (path, value) in reports {
215        match path.attribute {
216            ATTR_WINDOW_STATUS => {
217                if let Value::Uint(v) = value {
218                    #[allow(clippy::cast_possible_truncation)]
219                    // The spec defines WindowStatus as enum8; truncation to u8 is correct.
220                    {
221                        status = CommissioningWindowStatus::from_u8(*v as u8);
222                    }
223                }
224            }
225            ATTR_ADMIN_FABRIC_INDEX => {
226                if let Value::Uint(v) = value {
227                    #[allow(clippy::cast_possible_truncation)]
228                    // The spec defines AdminFabricIndex as fabric-idx (uint8); truncation correct.
229                    {
230                        admin_fabric_index = Some(*v as u8);
231                    }
232                }
233            }
234            ATTR_ADMIN_VENDOR_ID => {
235                if let Value::Uint(v) = value {
236                    #[allow(clippy::cast_possible_truncation)]
237                    // The spec defines AdminVendorId as vendor-id (uint16); truncation correct.
238                    {
239                        admin_vendor_id = Some(*v as u16);
240                    }
241                }
242            }
243            _ => {}
244        }
245    }
246    WindowStatus {
247        status,
248        admin_fabric_index,
249        admin_vendor_id,
250    }
251}
252
253#[cfg(test)]
254#[allow(clippy::unwrap_used)] // Test code: CLAUDE.md test-code carve-out.
255mod tests {
256    use super::*;
257
258    #[test]
259    fn open_window_fields_uses_spec_tags() {
260        let v = open_window_fields(180, &[0xAA; 97], 0xABC, 1000, &[0x01; 32]);
261        let Value::Structure(members) = v else {
262            panic!("expected struct")
263        };
264        assert_eq!(members.len(), 5);
265        assert_eq!(members[0].0, Tag::Context(0));
266        assert_eq!(members[0].1, Value::Uint(180));
267        assert_eq!(members[2].1, Value::Uint(0xABC));
268        assert_eq!(members[3].1, Value::Uint(1000));
269        assert!(matches!(&members[1].1, Value::Bytes(b) if b.len() == 97));
270        assert!(matches!(&members[4].1, Value::Bytes(b) if b.len() == 32));
271    }
272
273    #[test]
274    fn onboarding_payload_manual_always_qr_only_with_vidpid() {
275        let (manual, qr) = onboarding_payload(20_202_021, 3840, None, None).unwrap();
276        assert_eq!(manual.len(), 11);
277        assert!(qr.is_none());
278        let (_m, qr2) = onboarding_payload(20_202_021, 3840, Some(0xFFF1), Some(0x8000)).unwrap();
279        assert!(qr2.unwrap().starts_with("MT:"));
280    }
281
282    #[test]
283    fn random_window_secrets_are_valid_and_vary() {
284        use matter_commissioning::setup::{Discriminator, Passcode};
285        let (p1, s1, d1) = random_window_secrets().unwrap();
286        let (p2, _s2, _d2) = random_window_secrets().unwrap();
287        // Passcode is constructible (27-bit, non-trivial) and discriminator ≤ 0x0FFF.
288        Passcode::new(p1).unwrap();
289        Discriminator::new(d1).unwrap();
290        assert_eq!(s1.len(), 32);
291        assert_ne!(s1, [0u8; 32]);
292        assert_ne!(p1, p2); // overwhelmingly likely to differ
293    }
294
295    #[test]
296    fn parse_window_status_reads_three_attrs() {
297        let ap = |a: u32| AttributePath {
298            endpoint: 0,
299            cluster: ADMIN_COMMISSIONING_CLUSTER,
300            attribute: a,
301        };
302        let reports = vec![
303            (ap(ATTR_WINDOW_STATUS), Value::Uint(1)),
304            (ap(ATTR_ADMIN_FABRIC_INDEX), Value::Uint(2)),
305            (ap(ATTR_ADMIN_VENDOR_ID), Value::Null),
306        ];
307        let ws = parse_window_status(&reports);
308        assert_eq!(ws.status, CommissioningWindowStatus::EnhancedWindowOpen);
309        assert_eq!(ws.admin_fabric_index, Some(2));
310        assert_eq!(ws.admin_vendor_id, None);
311    }
312}