Skip to main content

rs_matter/
onboard.rs

1/*
2 *
3 *    Copyright (c) 2026 Project CHIP Authors
4 *
5 *    Licensed under the Apache License, Version 2.0 (the "License");
6 *    you may not use this file except in compliance with the License.
7 *    You may obtain a copy of the License at
8 *
9 *        http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *    Unless required by applicable law or agreed to in writing, software
12 *    distributed under the License is distributed on an "AS IS" BASIS,
13 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *    See the License for the specific language governing permissions and
15 *    limitations under the License.
16 */
17
18//! Matter Commissioner support.
19//!
20//! Building blocks for the **controller / commissioner** role — driving
21//! a freshly-paired accessory through the standard commissioning
22//! sequence and onto a fabric.
23//!
24//! # Scope and non-scope
25//!
26//! The types here orchestrate the *on-wire* commissioning flow only:
27//! post-PASE invokes ([`Commissioner::commission`]) and post-AddNOC
28//! CASE + `CommissioningComplete` ([`Commissioner::complete_via_case`]).
29//!
30//! Everything *off* the wire is the caller's responsibility:
31//!
32//!   - CA chain (RCAC, optional ICAC) generation — use [`cac::RcacGenerator`]
33//!     / [`cac::IcacGenerator`]. In a real deployment the RCAC is
34//!     minted once offline (typically on an HSM) and the ICAC at
35//!     factory provisioning time. The commissioner only needs the ICAC
36//!     private key + the RCAC and ICAC TLV certs at runtime.
37//!   - Fabric install — i.e. [`crate::fabric::Fabrics::add`] with the
38//!     controller's NOC, the RCAC/ICAC chain and an IPK. The caller
39//!     does this once before running any commissioning, then reuses
40//!     the resulting `fab_idx` for every device.
41//!   - NodeID allocation — devices get NodeIDs the caller picks (whatever
42//!     scheme they prefer: counter, hash, configuration). Same for the
43//!     NOC's ASN.1 serial number; the caller can simply pass
44//!     `serial == node_id` if they have no other constraint.
45//!   - Persistence — everything the caller wants to survive a restart
46//!     (ICAC private key, `fab_idx`, NOC-serial / next-NodeID counters,
47//!     the fabric itself) is theirs to write and read back.
48//!
49//! See `tests/commissioning.rs` and `examples/src/bin/commissioner_tests.rs`
50//! for a fully-worked example wiring of all of the above against a
51//! single in-process fabric.
52//!
53//! # Phase split
54//!
55//! [`Commissioner::commission`] (over PASE) and
56//! [`Commissioner::complete_via_case`] (over CASE) are split because
57//! the rs-matter device responder requires `CommissioningComplete` to
58//! arrive over a CASE session (Matter Core spec — and
59//! enforced by `Failsafe::disarm` which calls `get_case_fab_idx`).
60
61use core::num::NonZeroU8;
62
63use crate::cert::gen::Validity;
64use crate::crypto::{Crypto, RngCore, AEAD_CANON_KEY_LEN};
65use crate::dm::clusters::gen_comm::{CommissioningErrorEnum, GeneralCommissioningClient};
66use crate::dm::clusters::noc::{NodeOperationalCertStatusEnum, OperationalCredentialsClient};
67use crate::dm::endpoints::ROOT_ENDPOINT_ID;
68use crate::dm::NodeId;
69use crate::error::{Error, ErrorCode};
70use crate::onboard::noc::NocGenerator;
71use crate::sc::case::CaseInitiator;
72use crate::tlv::{FromTLV, OctetStr, TLVElement};
73use crate::transport::exchange::Exchange;
74use crate::transport::network::Address;
75use crate::Matter;
76
77pub mod cac;
78pub mod noc;
79
80/// NOCSRElements ([Matter Core spec]) is a struct with:
81///   ctx(0) = `csr` (PKCS#10 CertificationRequest, DER-encoded)
82///   ctx(1) = `CSRNonce` (32 bytes — must echo what we sent)
83///   ctx(2..4) = vendor-reserved (ignored here)
84const NOCSR_TAG_CSR: u8 = 1;
85const NOCSR_TAG_NONCE: u8 = 2;
86
87/// Knobs for [`Commissioner::commission`].
88///
89/// Per-device material (NodeID, validity) is passed as separate
90/// arguments to [`Commissioner::commission`] — it's expected to vary
91/// every call. This struct carries only the per-flow tunables.
92#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
93#[cfg_attr(feature = "defmt", derive(defmt::Format))]
94pub struct CommissionOptions {
95    /// `ExpiryLengthSeconds` for `ArmFailSafe`. The whole commissioning
96    /// flow must complete before this expires, otherwise the device
97    /// rolls back any partial state.
98    pub fail_safe_secs: u16,
99    /// Skip Device Attestation verification.
100    ///
101    /// Real DCL fetch + cert-chain validation is deferred to a follow-up.
102    /// Until then the only supported mode is `true` (accept the device's
103    /// attestation unconditionally) — suitable only for test devices like
104    /// `chip-all-clusters-app`. Setting `false` causes commissioning to
105    /// fail with [`ErrorCode::Failure`] (no verification path exists yet).
106    pub allow_test_attestation: bool,
107}
108
109impl CommissionOptions {
110    pub const fn new() -> Self {
111        Self {
112            fail_safe_secs: 60,
113            allow_test_attestation: false,
114        }
115    }
116}
117
118impl Default for CommissionOptions {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124/// What [`Commissioner::commission`] returns on success.
125///
126/// Also the handoff between phase 1 (`commission`) and phase 2
127/// ([`Commissioner::complete_via_case`]).
128#[derive(Debug, Clone, Copy, Eq, PartialEq)]
129#[cfg_attr(feature = "defmt", derive(defmt::Format))]
130pub struct CommissionResult {
131    /// Fabric slot the **device** assigned to us. Needed for subsequent
132    /// `UpdateNOC` / `RemoveFabric` / `UpdateFabricLabel` invocations.
133    /// (Independent of whatever local fabric index the **controller**
134    /// recorded for the same fabric — see [`Commissioner::fab_idx`].)
135    ///
136    /// `NonZeroU8` because the Matter Core spec reserves `fabric_index=0`
137    /// for "no fabric" / PASE — a successful `NOCResponse` carrying a
138    /// device-side fabric slot is, by definition, non-zero.
139    pub fabric_index: NonZeroU8,
140    /// Echo of the NodeID the caller supplied to
141    /// [`Commissioner::commission`] — kept here so the same struct can
142    /// be threaded into [`Commissioner::complete_via_case`] without the
143    /// caller having to plumb it separately.
144    pub device_node_id: NodeId,
145}
146
147/// Stateful commissioner.
148///
149/// Holds the references needed for the whole flow so individual steps
150/// don't have to take them. `&mut NocGenerator` because each
151/// `commission()` call mutably borrows the generator's scratch buffer
152/// to write the device NOC into; `&mut [u8] buf` is a caller-owned
153/// scratch slice used to stage the fabric's RCAC and ICAC bytes
154/// across the on-wire async calls (the fabric record itself can only
155/// be borrowed inside [`Matter::with_state`], which doesn't compose
156/// with `await`).
157///
158/// **The controller's fabric is expected to already be in
159/// `matter.state.fabrics`** at the given `fab_idx` — the caller installs
160/// it once via [`crate::fabric::Fabrics::add`] before constructing any
161/// commissioner. A single `Commissioner` instance can then be reused to
162/// commission any number of devices onto that fabric.
163pub struct Commissioner<'a, C: Crypto> {
164    matter: &'a Matter<'a>,
165    crypto: C,
166    fab_idx: NonZeroU8,
167    noc_generator: &'a mut NocGenerator<'a>,
168    buf: &'a mut [u8],
169}
170
171impl<'a, C: Crypto> Commissioner<'a, C> {
172    /// Create a commissioner bound to a Matter stack, crypto backend,
173    /// an already-installed fabric (`fab_idx`), an already-constructed
174    /// NOC generator that signs against the chain stored on that
175    /// fabric, and a scratch buffer.
176    ///
177    /// `buf` is used to copy the fabric's RCAC and (optionally) ICAC
178    /// bytes out of the locked fabric table so they can be passed to
179    /// the asynchronous `AddTrustedRootCertificate` / `AddNOC` invokes.
180    /// It must be at least [`crate::cert::MAX_CERT_TLV_LEN`] bytes; the
181    /// commissioner sequences the two transfers (RCAC first, then ICAC
182    /// re-uses the same slot) so a single-cert worth of memory is
183    /// enough.
184    pub const fn new(
185        matter: &'a Matter<'a>,
186        crypto: C,
187        fab_idx: NonZeroU8,
188        noc_generator: &'a mut NocGenerator<'a>,
189        buf: &'a mut [u8],
190    ) -> Self {
191        Self {
192            matter,
193            crypto,
194            fab_idx,
195            noc_generator,
196            buf,
197        }
198    }
199
200    /// Index of the controller's fabric in `matter.state.fabrics`. The
201    /// caller picked this when installing the fabric; the commissioner
202    /// simply propagates it (e.g. into [`CommissionResult`] callers
203    /// build on top).
204    pub const fn fab_idx(&self) -> NonZeroU8 {
205        self.fab_idx
206    }
207
208    /// Phase 1 — drive `ArmFailSafe` through `AddNOC` over PASE.
209    ///
210    /// Pre-condition: PASE handshake against the device has completed
211    /// successfully on `matter`'s transport. The function locates that
212    /// PASE session by the `(fab=0, peer=0, secure=true)` lookup tuple
213    /// every step uses — it implicitly assumes a single in-flight PASE
214    /// session, which is the case in practice for a controller driving
215    /// one device at a time.
216    ///
217    /// `device_node_id` is the NodeID the caller wishes to assign to
218    /// the device on the controller's fabric. `validity` is the NOC's
219    /// validity window — typically [`crate::cert::gen::VALID_FOREVER`]
220    /// for long-lived deployments, or a bounded window for short-lived
221    /// re-issuance. The NOC's ASN.1 serial number is derived from the
222    /// NodeID (see [`NocGenerator::generate`]).
223    ///
224    /// On success the device has accepted our RCAC + NOC and assigned
225    /// us a [`CommissionResult::fabric_index`], but its fail-safe is
226    /// still armed and PASE is still live. Phase 2
227    /// ([`Self::complete_via_case`]) finalises commissioning over
228    /// CASE; if the caller doesn't run it before the fail-safe expires
229    /// the device rolls back.
230    pub async fn commission(
231        &mut self,
232        peer_addr: Address,
233        passcode: u32,
234        opts: &CommissionOptions,
235        device_node_id: NodeId,
236        validity: Validity,
237    ) -> Result<CommissionResult, Error> {
238        // The first PASE step (ArmFailSafe) establishes the PASE session via
239        // `initiate_pase`; the rest reuse it.
240        self.arm_fail_safe(peer_addr, passcode, opts.fail_safe_secs)
241            .await?;
242
243        // Device Attestation — structural hook. See [`CommissionOptions::allow_test_attestation`].
244        self.verify_device_attestation(opts).await?;
245
246        // CSRRequest: random 32B nonce, then validate the device's echo
247        // and mint the operational NOC in the same scope where the CSR
248        // is borrowed from the response RX buffer — no per-call staging
249        // copy of the (up to ~400-byte) DER blob on our stack.
250        let mut csr_nonce = [0u8; 32];
251        self.crypto.rand()?.fill_bytes(&mut csr_nonce);
252
253        // Field-projection borrows so the closure passed to
254        // `csr_request` (and the buf-staged AddTrustedRoot / AddNOC
255        // calls below) don't conflict with a `&self` borrow.
256        let matter = self.matter;
257        let crypto = &self.crypto;
258        let fab_idx = self.fab_idx;
259        let noc_generator = &mut *self.noc_generator;
260        let buf = &mut *self.buf;
261
262        // Sign the device NOC. The returned slice lives in
263        // `noc_generator.buf` — independent of `buf`, so we can use
264        // both side-by-side below.
265        let noc = Self::csr_request(matter, crypto, peer_addr, passcode, &csr_nonce, |csr_der| {
266            noc_generator.generate(crypto, csr_der, device_node_id, &[], validity)
267        })
268        .await?;
269
270        // Stage the RCAC in `buf`, send `AddTrustedRootCertificate`,
271        // then re-use the same slot for the ICAC + grab IPK and admin
272        // scalars on the way. Two `with_state` passes (cheap — mutex
273        // + table lookup) keep the staging buffer to a single
274        // `MAX_CERT_TLV_LEN` slot. IPK is a 16-byte fixed-size stack
275        // array — trivial.
276        let rcac_len = matter.with_state(|state| {
277            let fabric = state.fabrics.fabric(fab_idx)?;
278            let rcac = fabric.root_ca();
279            if rcac.len() > buf.len() {
280                return Err(Error::from(ErrorCode::BufferTooSmall));
281            }
282            buf[..rcac.len()].copy_from_slice(rcac);
283            Ok::<_, Error>(rcac.len())
284        })?;
285        Self::add_trusted_root_certificate(matter, crypto, peer_addr, passcode, &buf[..rcac_len])
286            .await?;
287
288        // IPK as sent on the wire is the **epoch key** (the raw
289        // 16-byte input to the group-key derivation), not the
290        // per-fabric derived `op_key`. `KeySet` stores both;
291        // `.epoch_key()` is the right one.
292        let mut ipk_bytes = [0u8; AEAD_CANON_KEY_LEN];
293        let (icac_len, admin_node_id, admin_vendor_id) = matter.with_state(|state| {
294            let fabric = state.fabrics.fabric(fab_idx)?;
295            let icac = fabric.icac();
296            if icac.len() > buf.len() {
297                return Err(Error::from(ErrorCode::BufferTooSmall));
298            }
299            buf[..icac.len()].copy_from_slice(icac);
300            ipk_bytes.copy_from_slice(fabric.ipk().epoch_key().access());
301            Ok::<_, Error>((icac.len(), fabric.node_id(), fabric.vendor_id()))
302        })?;
303
304        // AddNOC. `&buf[..icac_len]` is empty for RCAC-direct fabrics
305        // (the codegen builder skips the field entirely); non-empty ⇒
306        // the full `[RCAC, ICAC, NOC]` chain is shipped.
307        let fabric_index = Self::add_noc(
308            matter,
309            crypto,
310            peer_addr,
311            passcode,
312            noc,
313            &buf[..icac_len],
314            &ipk_bytes,
315            admin_node_id,
316            admin_vendor_id,
317        )
318        .await?;
319
320        Ok(CommissionResult {
321            fabric_index,
322            device_node_id,
323        })
324    }
325
326    /// Phase 2 — establish CASE against the device's freshly-installed
327    /// operational identity and invoke `CommissioningComplete` over it.
328    ///
329    /// `peer_addr` is the device's operational endpoint. In production
330    /// it's discovered via `_matter._tcp` mDNS; in tests / examples it
331    /// can be the same address PASE used, since the device announces on
332    /// the same UDP port post-AddNOC.
333    ///
334    /// Steps:
335    ///   1. Open a fresh **unsecured** exchange to `peer_addr` and run
336    ///      [`CaseInitiator::initiate`] (Sigma1 → Sigma2 → Sigma3 →
337    ///      StatusReport). On success the new CASE session is keyed in
338    ///      `matter.state.sessions` at `(fab_idx, device_node_id,
339    ///      secure=true)`.
340    ///   2. Open a CASE-secured exchange on that session and invoke
341    ///      `GeneralCommissioning::CommissioningComplete`. The device
342    ///      disarms its fail-safe and persists the new fabric.
343    pub async fn complete_via_case(
344        &mut self,
345        peer_addr: Address,
346        phase1: &CommissionResult,
347    ) -> Result<(), Error> {
348        let fab_idx = self.fab_idx;
349
350        // CASE handshake over a fresh unsecured exchange.
351        {
352            let mut exchange =
353                Exchange::initiate_unsecured(self.matter, &self.crypto, peer_addr).await?;
354
355            CaseInitiator::initiate(&mut exchange, &self.crypto, fab_idx, phase1.device_node_id)
356                .await?;
357
358            // The CASE-establishment exchange is one-shot; drop it
359            // here so we open a fresh one on the new CASE session.
360        }
361
362        // CommissioningComplete on the CASE session.
363        self.commissioning_complete(fab_idx, phase1.device_node_id)
364            .await
365    }
366
367    /// `GeneralCommissioning::ArmFailSafe(expiry, breadcrumb=0)`.
368    pub(crate) async fn arm_fail_safe(
369        &self,
370        peer_addr: Address,
371        passcode: u32,
372        expiry_seconds: u16,
373    ) -> Result<(), Error> {
374        let exchange =
375            Exchange::initiate_pase(self.matter, &self.crypto, peer_addr, passcode).await?;
376
377        let handle = exchange
378            .general_commissioning()
379            .arm_fail_safe(ROOT_ENDPOINT_ID, |req| {
380                req.expiry_length_seconds(expiry_seconds)?
381                    .breadcrumb(0)?
382                    .end()
383            })
384            .await?;
385
386        let code = handle.response()?.error_code()?;
387
388        handle.complete().await?;
389
390        if code != CommissioningErrorEnum::OK {
391            return Err(ErrorCode::Failure.into());
392        }
393
394        Ok(())
395    }
396
397    /// `OperationalCredentials::CSRRequest(nonce)` — hands the
398    /// DER-encoded PKCS#10 CSR pulled out of the NOCSRElements payload
399    /// to `use_csr` (with the nonce echo already validated).
400    ///
401    /// The CSR slice handed to the closure is borrowed *directly* from
402    /// the response RX buffer — no per-call staging copy. The buffer
403    /// stays alive for the duration of `use_csr`; the trailing
404    /// `StatusResponse(Success)` ACK is sent after it returns.
405    ///
406    /// Static-style (no `&self` receiver) so the caller can pass a
407    /// closure that re-borrows other `Commissioner` fields (e.g.
408    /// `noc_generator`, `crypto`) without conflicting with a `&self`
409    /// borrow on this method.
410    pub(crate) async fn csr_request<'m, F, R>(
411        matter: &'m Matter<'m>,
412        crypto: &C,
413        peer_addr: Address,
414        passcode: u32,
415        csr_nonce: &[u8; 32],
416        use_csr: F,
417    ) -> Result<R, Error>
418    where
419        F: FnOnce(&[u8]) -> Result<R, Error>,
420    {
421        let exchange = Exchange::initiate_pase(matter, crypto, peer_addr, passcode).await?;
422
423        let handle = exchange
424            .operational_credentials()
425            .csr_request(ROOT_ENDPOINT_ID, |req| {
426                req.csr_nonce(OctetStr::new(csr_nonce))?
427                    .is_for_update_noc(None)?
428                    .end()
429            })
430            .await?;
431
432        let result = {
433            let resp = handle.response()?;
434            let nocsr_bytes = resp.nocsr_elements()?;
435
436            // NOCSRElements is itself TLV — its `csr` and `CSRNonce`
437            // fields live at ctx(1) and ctx(2) of an anonymous struct.
438            let root = TLVElement::new(nocsr_bytes.0).structure()?;
439            let csr_tlv = OctetStr::from_tlv(&root.ctx(NOCSR_TAG_CSR)?)?;
440            let nonce_echo = OctetStr::from_tlv(&root.ctx(NOCSR_TAG_NONCE)?)?;
441
442            if nonce_echo.0 != csr_nonce {
443                // Replay / freshness failure — abort before minting a NOC.
444                return Err(ErrorCode::Failure.into());
445            }
446
447            use_csr(csr_tlv.0)?
448        };
449
450        handle.complete().await?;
451
452        Ok(result)
453    }
454
455    /// `OperationalCredentials::AddTrustedRootCertificate(rcac_tlv)`.
456    ///
457    /// Static-style for the same reason as [`Self::csr_request`] — the
458    /// caller in [`Self::commission`] is holding a `&mut self.noc_generator`
459    /// projection borrow when it invokes this.
460    pub(crate) async fn add_trusted_root_certificate<'m>(
461        matter: &'m Matter<'m>,
462        crypto: &C,
463        peer_addr: Address,
464        passcode: u32,
465        rcac_tlv: &[u8],
466    ) -> Result<(), Error> {
467        let exchange = Exchange::initiate_pase(matter, crypto, peer_addr, passcode).await?;
468
469        exchange
470            .operational_credentials()
471            .add_trusted_root_certificate(ROOT_ENDPOINT_ID, |req| {
472                req.root_ca_certificate(OctetStr::new(rcac_tlv))?.end()
473            })
474            .await
475    }
476
477    /// `OperationalCredentials::AddNOC(noc, icac?, ipk, admin_subject,
478    /// admin_vendor_id)` — returns the FabricIndex the device assigned.
479    ///
480    /// Pass an empty `icac` slice when the controller signs NOCs
481    /// directly off the RCAC (no ICAC tier).
482    ///
483    /// Static-style: see [`Self::add_trusted_root_certificate`].
484    #[allow(clippy::too_many_arguments)]
485    pub(crate) async fn add_noc<'m>(
486        matter: &'m Matter<'m>,
487        crypto: &C,
488        peer_addr: Address,
489        passcode: u32,
490        noc: &[u8],
491        icac: &[u8],
492        ipk: &[u8],
493        admin_case_subject: u64,
494        admin_vendor_id: u16,
495    ) -> Result<NonZeroU8, Error> {
496        let exchange = Exchange::initiate_pase(matter, crypto, peer_addr, passcode).await?;
497
498        let handle = exchange
499            .operational_credentials()
500            .add_noc(ROOT_ENDPOINT_ID, |req| {
501                req.noc_value(OctetStr::new(noc))?
502                    .icac_value(if icac.is_empty() {
503                        None
504                    } else {
505                        Some(OctetStr::new(icac))
506                    })?
507                    .ipk_value(OctetStr::new(ipk))?
508                    .case_admin_subject(admin_case_subject)?
509                    .admin_vendor_id(admin_vendor_id)?
510                    .end()
511            })
512            .await?;
513
514        let (status, fabric_index) = {
515            let resp = handle.response()?;
516            (resp.status_code()?, resp.fabric_index()?)
517        };
518
519        handle.complete().await?;
520
521        if status != NodeOperationalCertStatusEnum::OK {
522            return Err(ErrorCode::Failure.into());
523        }
524
525        // Spec reserves `fabric_index=0` for PASE / no-fabric; the
526        // device must assign a non-zero slot on a successful `AddNOC`.
527        // A missing field or a zero value is a peer-side bug — surface
528        // it as `InvalidData` rather than silently widening.
529        fabric_index
530            .and_then(NonZeroU8::new)
531            .ok_or_else(|| ErrorCode::InvalidData.into())
532    }
533
534    /// `GeneralCommissioning::CommissioningComplete()` over the CASE
535    /// session keyed by `(fab_idx, peer_node_id, secure=true)`.
536    ///
537    /// **Must be invoked over CASE**, not PASE — the device responder
538    /// rejects it over PASE (`Failsafe::disarm` requires a CASE
539    /// `fab_idx`). [`Self::complete_via_case`] is the only caller.
540    pub(crate) async fn commissioning_complete(
541        &self,
542        fab_idx: NonZeroU8,
543        peer_node_id: NodeId,
544    ) -> Result<(), Error> {
545        let exchange = Exchange::initiate(self.matter, &self.crypto, fab_idx, peer_node_id).await?;
546
547        let handle = exchange
548            .general_commissioning()
549            .commissioning_complete(ROOT_ENDPOINT_ID)
550            .await?;
551
552        let code = handle.response()?.error_code()?;
553
554        handle.complete().await?;
555
556        if code != CommissioningErrorEnum::OK {
557            return Err(ErrorCode::Failure.into());
558        }
559
560        Ok(())
561    }
562
563    /// DAC verification placeholder.
564    ///
565    /// Returns `Ok(())` iff `allow_test_attestation` is set; real
566    /// verification (RequestAttestation → DCL chain validation) lands
567    /// in a follow-up.
568    async fn verify_device_attestation(&self, opts: &CommissionOptions) -> Result<(), Error> {
569        if opts.allow_test_attestation {
570            return Ok(());
571        }
572
573        Err(ErrorCode::Failure.into())
574    }
575}