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}