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 plaintext 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        let exchange = Exchange::initiate_plaintext(self.matter, &self.crypto, peer_addr).await?;
351        CaseInitiator::perform(exchange, &self.crypto, fab_idx, phase1.device_node_id).await?;
352
353        // CommissioningComplete on the CASE session.
354        self.commissioning_complete(fab_idx, phase1.device_node_id)
355            .await
356    }
357
358    /// Phase 2, resolving the device's operational address via mDNS.
359    ///
360    /// Identical to [`Self::complete_via_case`] except that, instead of being
361    /// handed a fixed `peer_addr`, it looks up the device's operational endpoint
362    /// via `_matter._tcp` mDNS from `(fabric, device_node_id)` (using
363    /// [`Exchange::initiate_plaintext_operational`]).
364    ///
365    /// This is the production phase-2 path: after phase 1 the device may only be
366    /// reachable at a *different* address than PASE used - most notably when the
367    /// device was commissioned over BLE and has since joined its operational
368    /// (Wi-Fi / Thread) network, where its operational IP is not known until it
369    /// announces itself. It requires the mDNS backend to be running (so the
370    /// resolve request is answered), and the device to have joined the network
371    /// and started announcing operationally.
372    pub async fn complete_via_case_operational(
373        &mut self,
374        phase1: &CommissionResult,
375    ) -> Result<(), Error> {
376        let fab_idx = self.fab_idx;
377
378        let exchange = Exchange::initiate_plaintext_operational(
379            self.matter,
380            &self.crypto,
381            fab_idx,
382            phase1.device_node_id,
383        )
384        .await?;
385        CaseInitiator::perform(exchange, &self.crypto, fab_idx, phase1.device_node_id).await?;
386
387        // CommissioningComplete on the CASE session.
388        self.commissioning_complete(fab_idx, phase1.device_node_id)
389            .await
390    }
391
392    /// `GeneralCommissioning::ArmFailSafe(expiry, breadcrumb=0)`.
393    pub(crate) async fn arm_fail_safe(
394        &self,
395        peer_addr: Address,
396        passcode: u32,
397        expiry_seconds: u16,
398    ) -> Result<(), Error> {
399        let exchange =
400            Exchange::initiate_pase(self.matter, &self.crypto, peer_addr, passcode).await?;
401
402        let handle = exchange
403            .general_commissioning()
404            .arm_fail_safe(ROOT_ENDPOINT_ID, |req| {
405                req.expiry_length_seconds(expiry_seconds)?
406                    .breadcrumb(0)?
407                    .end()
408            })
409            .await?;
410
411        let code = handle.response()?.error_code()?;
412
413        handle.complete().await?;
414
415        if code != CommissioningErrorEnum::OK {
416            return Err(ErrorCode::Failure.into());
417        }
418
419        Ok(())
420    }
421
422    /// `OperationalCredentials::CSRRequest(nonce)` — hands the
423    /// DER-encoded PKCS#10 CSR pulled out of the NOCSRElements payload
424    /// to `use_csr` (with the nonce echo already validated).
425    ///
426    /// The CSR slice handed to the closure is borrowed *directly* from
427    /// the response RX buffer — no per-call staging copy. The buffer
428    /// stays alive for the duration of `use_csr`; the trailing
429    /// `StatusResponse(Success)` ACK is sent after it returns.
430    ///
431    /// Static-style (no `&self` receiver) so the caller can pass a
432    /// closure that re-borrows other `Commissioner` fields (e.g.
433    /// `noc_generator`, `crypto`) without conflicting with a `&self`
434    /// borrow on this method.
435    pub(crate) async fn csr_request<'m, F, R>(
436        matter: &'m Matter<'m>,
437        crypto: &C,
438        peer_addr: Address,
439        passcode: u32,
440        csr_nonce: &[u8; 32],
441        use_csr: F,
442    ) -> Result<R, Error>
443    where
444        F: FnOnce(&[u8]) -> Result<R, Error>,
445    {
446        let exchange = Exchange::initiate_pase(matter, crypto, peer_addr, passcode).await?;
447
448        let handle = exchange
449            .operational_credentials()
450            .csr_request(ROOT_ENDPOINT_ID, |req| {
451                req.csr_nonce(OctetStr::new(csr_nonce))?
452                    .is_for_update_noc(None)?
453                    .end()
454            })
455            .await?;
456
457        let result = {
458            let resp = handle.response()?;
459            let nocsr_bytes = resp.nocsr_elements()?;
460
461            // NOCSRElements is itself TLV — its `csr` and `CSRNonce`
462            // fields live at ctx(1) and ctx(2) of an anonymous struct.
463            let root = TLVElement::new(nocsr_bytes.0).structure()?;
464            let csr_tlv = OctetStr::from_tlv(&root.ctx(NOCSR_TAG_CSR)?)?;
465            let nonce_echo = OctetStr::from_tlv(&root.ctx(NOCSR_TAG_NONCE)?)?;
466
467            if nonce_echo.0 != csr_nonce {
468                // Replay / freshness failure — abort before minting a NOC.
469                return Err(ErrorCode::Failure.into());
470            }
471
472            use_csr(csr_tlv.0)?
473        };
474
475        handle.complete().await?;
476
477        Ok(result)
478    }
479
480    /// `OperationalCredentials::AddTrustedRootCertificate(rcac_tlv)`.
481    ///
482    /// Static-style for the same reason as [`Self::csr_request`] — the
483    /// caller in [`Self::commission`] is holding a `&mut self.noc_generator`
484    /// projection borrow when it invokes this.
485    pub(crate) async fn add_trusted_root_certificate<'m>(
486        matter: &'m Matter<'m>,
487        crypto: &C,
488        peer_addr: Address,
489        passcode: u32,
490        rcac_tlv: &[u8],
491    ) -> Result<(), Error> {
492        let exchange = Exchange::initiate_pase(matter, crypto, peer_addr, passcode).await?;
493
494        exchange
495            .operational_credentials()
496            .add_trusted_root_certificate(ROOT_ENDPOINT_ID, |req| {
497                req.root_ca_certificate(OctetStr::new(rcac_tlv))?.end()
498            })
499            .await
500    }
501
502    /// `OperationalCredentials::AddNOC(noc, icac?, ipk, admin_subject,
503    /// admin_vendor_id)` — returns the FabricIndex the device assigned.
504    ///
505    /// Pass an empty `icac` slice when the controller signs NOCs
506    /// directly off the RCAC (no ICAC tier).
507    ///
508    /// Static-style: see [`Self::add_trusted_root_certificate`].
509    #[allow(clippy::too_many_arguments)]
510    pub(crate) async fn add_noc<'m>(
511        matter: &'m Matter<'m>,
512        crypto: &C,
513        peer_addr: Address,
514        passcode: u32,
515        noc: &[u8],
516        icac: &[u8],
517        ipk: &[u8],
518        admin_case_subject: u64,
519        admin_vendor_id: u16,
520    ) -> Result<NonZeroU8, Error> {
521        let exchange = Exchange::initiate_pase(matter, crypto, peer_addr, passcode).await?;
522
523        let handle = exchange
524            .operational_credentials()
525            .add_noc(ROOT_ENDPOINT_ID, |req| {
526                req.noc_value(OctetStr::new(noc))?
527                    .icac_value(if icac.is_empty() {
528                        None
529                    } else {
530                        Some(OctetStr::new(icac))
531                    })?
532                    .ipk_value(OctetStr::new(ipk))?
533                    .case_admin_subject(admin_case_subject)?
534                    .admin_vendor_id(admin_vendor_id)?
535                    .end()
536            })
537            .await?;
538
539        let (status, fabric_index) = {
540            let resp = handle.response()?;
541            (resp.status_code()?, resp.fabric_index()?)
542        };
543
544        handle.complete().await?;
545
546        if status != NodeOperationalCertStatusEnum::OK {
547            return Err(ErrorCode::Failure.into());
548        }
549
550        // Spec reserves `fabric_index=0` for PASE / no-fabric; the
551        // device must assign a non-zero slot on a successful `AddNOC`.
552        // A missing field or a zero value is a peer-side bug — surface
553        // it as `InvalidData` rather than silently widening.
554        fabric_index
555            .and_then(NonZeroU8::new)
556            .ok_or_else(|| ErrorCode::InvalidData.into())
557    }
558
559    /// `GeneralCommissioning::CommissioningComplete()` over the CASE
560    /// session keyed by `(fab_idx, peer_node_id, secure=true)`.
561    ///
562    /// **Must be invoked over CASE**, not PASE — the device responder
563    /// rejects it over PASE (`Failsafe::disarm` requires a CASE
564    /// `fab_idx`). [`Self::complete_via_case`] is the only caller.
565    pub(crate) async fn commissioning_complete(
566        &self,
567        fab_idx: NonZeroU8,
568        peer_node_id: NodeId,
569    ) -> Result<(), Error> {
570        let exchange = Exchange::initiate(self.matter, &self.crypto, fab_idx, peer_node_id).await?;
571
572        let handle = exchange
573            .general_commissioning()
574            .commissioning_complete(ROOT_ENDPOINT_ID)
575            .await?;
576
577        let code = handle.response()?.error_code()?;
578
579        handle.complete().await?;
580
581        if code != CommissioningErrorEnum::OK {
582            return Err(ErrorCode::Failure.into());
583        }
584
585        Ok(())
586    }
587
588    /// DAC verification placeholder.
589    ///
590    /// Returns `Ok(())` iff `allow_test_attestation` is set; real
591    /// verification (RequestAttestation → DCL chain validation) lands
592    /// in a follow-up.
593    async fn verify_device_attestation(&self, opts: &CommissionOptions) -> Result<(), Error> {
594        if opts.allow_test_attestation {
595            return Ok(());
596        }
597
598        Err(ErrorCode::Failure.into())
599    }
600}