Skip to content

Commit dabfefc

Browse files
committed
controller: add commissioner-side commissioning building blocks
Introduces a new `controller` module that provides the IM-invoke and orchestration primitives a controller needs to drive a Matter accessory from a freshly-established PASE session through to CommissioningComplete. The accessory role is already well-covered in this crate. This module is the inverse role — the thing that *commissions* accessories — and adds a deliberately small, validated surface: Public API (controller::commissioner): - arm_fail_safe(matter, expiry_seconds, breadcrumb) GeneralCommissioning::ArmFailSafe over PASE, decodes ArmFailSafeResponse.errorCode → maps non-OK to FailSafeExpired. - csr_request(matter, &csr_nonce) -> CsrPayload OperationalCredentials::CSRRequest with full CSRResponse decode. Returns NOCSRElements (octstr<400>) + AttestationSignature (64 B). - decode_nocsr_elements(blob) -> DecodedNocsr<'_> TLV decoder for the NOCSRElements struct (§11.18.6.5.2) — pulls the PKCS#10 CSR DER and the device's nonce-echo so the controller can verify nonce freshness before issuing a NOC. - add_noc(matter, noc, icac, ipk, admin_subject, admin_vendor_id) OperationalCredentials::AddNOC with NOCResponse status + FabricIndex decode. Non-OK status → AddNocRejected. - commission_pase(matter, crypto, fabric_creds, admin_subject, admin_vendor_id, fail_safe_secs) -> PaseCommissionResult Orchestrator that chains the above plus the two unexposed steps (AddTrustedRootCertificate, CommissioningComplete) and calls FabricCredentials::generate_device_credentials to mint the NOC. Returns the device-assigned FabricIndex + NodeID + cert chain. Public API (controller::setup_code): - Manual pairing-code + QR-code (MT:...) parser per Matter spec §5.1.4, with version / vendor-id / product-id / discriminator / passcode / discovery-capabilities-bitmask decoding. Supporting additions to existing files: - commissioner::FabricCredentials gains `root_secret_key()`, `rcac_id()`, and `from_persisted(...)` so a controller can persist its CA material and reload it on next boot. - commissioner::NocGenerator gains the matching `root_secret_key()` accessor. What is intentionally *not* in this PR (planned follow-ups): - BLE central + BTP framing (the bootstrap transport). - DCL fetch + Device Attestation chain verification. - NetworkCommissioning cluster (Thread / Wi-Fi credential delivery). - Operational discovery (_matter._tcp mDNS-SD client) + CASE. - A higher-level Commissioner state machine wrapping all of the above. Each of those is its own design conversation and they don't belong in a single 3000-line PR. Validation ---------- End-to-end test: examples/src/bin/pase_smoke_test.rs. With this PR plus project-chip#454 (cert: NotBefore=0 sentinel fix — required for CHIP interop) applied, the smoke test commissions chip-tool's all-clusters-app end-to-end: ✓ PASE handshake ✓ ArmFailSafe(60s) ✓ CSRRequest → 243B NOCSRElements + 64B AttestationSignature ✓ NOCSRElements decoded; nonce verified ✓ AddTrustedRootCertificate ✓ AddNOC → device returns fabric_index=1 ✓ CommissioningComplete Responder log (chip-tool all-clusters-app): OpCreds: successfully created fabric index 0x1 via AddNOC `cargo test -p rs-matter --lib --features=os,zbus` passes (532 existing tests; no behavior change to existing crates). Without project-chip#454 the smoke test will still complete PASE/ArmFailSafe/ CSRRequest but AddTrustedRootCertificate is rejected by CHIP as 'Invalid signature' — see project-chip#454 for the root cause.
1 parent da2d79e commit dabfefc

8 files changed

Lines changed: 1496 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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+
//! Minimal end-to-end PASE smoke test.
19+
//!
20+
//! Usage:
21+
//! 1. In one terminal: `cargo run --release --bin onoff_light`
22+
//! (or any rs-matter responder that opens a Basic Commissioning Window).
23+
//! 2. In another terminal: `cargo run --release --bin pase_smoke_test`
24+
//!
25+
//! What it does:
26+
//! - Constructs a controller-side `Matter` bound to port 5541 (so it
27+
//! doesn't fight the responder for the standard 5540).
28+
//! - Opens an unsecured exchange to `127.0.0.1:5540` (the responder).
29+
//! - Drives `PaseInitiator::initiate` with passcode 20202021 (the
30+
//! canonical test passcode that onoff_light + chip_tool_tests use).
31+
//! - Reports whether PASE completed.
32+
//!
33+
//! Success = the responder's "PASE Basic Commissioning Window" picks up,
34+
//! Spake2+ messages exchange, and `PaseInitiator::initiate` returns `Ok(())`.
35+
//! Failure = first stage where the handshake broke down. The output of
36+
//! both processes together identifies the offending step.
37+
//!
38+
//! This is the test the controller-side commissioner work has been
39+
//! building toward — proves PASE *actually negotiates over the wire*
40+
//! between rs-matter's responder and our `PaseInitiator` driver.
41+
42+
use std::net::{SocketAddr, UdpSocket};
43+
44+
use async_io::Async;
45+
use log::{error, info};
46+
47+
use rs_matter::commissioner::FabricCredentials;
48+
use rs_matter::controller::commissioner::{arm_fail_safe, commission_pase, csr_request};
49+
use rs_matter::crypto::default_crypto;
50+
use rs_matter::dm::devices::test::{DAC_PRIVKEY, TEST_DEV_ATT, TEST_DEV_COMM, TEST_DEV_DET};
51+
use rs_matter::sc::pase::PaseInitiator;
52+
use rs_matter::transport::exchange::Exchange;
53+
use rs_matter::transport::network::Address;
54+
use rs_matter::{Matter, MATTER_PORT};
55+
56+
const SMOKE_TEST_PORT: u16 = 5541;
57+
const RESPONDER_PORT: u16 = MATTER_PORT; // 5540
58+
// IPv6 localhost — matches the bind on [::]:SMOKE_TEST_PORT. Mixing v4
59+
// peer + v6 socket fails with EINVAL on macOS (and is iffy on Linux too
60+
// without IPV6_V6ONLY=0). rs-matter's whole transport is IPv6-native.
61+
const RESPONDER_ADDR: &str = "[::1]";
62+
const PASSCODE: u32 = 20202021;
63+
64+
fn main() {
65+
env_logger::init();
66+
info!("PASE smoke test starting (controller side)");
67+
68+
// Run the whole thing on a stack-size-bumped thread — `Matter`'s
69+
// futures want ~550 KB of stack (matches what onoff_light does).
70+
let thread = std::thread::Builder::new()
71+
.stack_size(550 * 1024)
72+
.spawn(|| {
73+
if let Err(e) = run() {
74+
error!("smoke test thread error: {}", e);
75+
std::process::exit(1);
76+
}
77+
})
78+
.expect("spawn");
79+
thread.join().expect("join");
80+
}
81+
82+
fn run() -> Result<(), String> {
83+
// 1. Controller-side Matter runtime. We use the same TEST_DEV_*
84+
// fixtures the device side uses — the controller doesn't actually
85+
// serve attestation to peers, but the constructor needs the refs.
86+
let matter = Box::leak(Box::new(Matter::new_default(
87+
&TEST_DEV_DET,
88+
TEST_DEV_COMM.clone(),
89+
&TEST_DEV_ATT,
90+
SMOKE_TEST_PORT,
91+
)));
92+
93+
let bind_addr: SocketAddr = format!("[::]:{}", SMOKE_TEST_PORT)
94+
.parse()
95+
.map_err(|e: std::net::AddrParseError| e.to_string())?;
96+
let socket = Async::<UdpSocket>::bind(bind_addr).map_err(|e| e.to_string())?;
97+
info!(
98+
"controller bound on UDP {} (responder at {}:{})",
99+
SMOKE_TEST_PORT, RESPONDER_ADDR, RESPONDER_PORT
100+
);
101+
102+
let crypto = default_crypto(rand::thread_rng(), DAC_PRIVKEY);
103+
104+
let main = async move {
105+
let peer_addr: SocketAddr = format!("{}:{}", RESPONDER_ADDR, RESPONDER_PORT)
106+
.parse()
107+
.unwrap();
108+
let peer = Address::Udp(peer_addr);
109+
110+
// Run the matter transport in parallel with the PASE handshake.
111+
// PASE needs the transport pump alive to send/receive.
112+
let transport_fut = matter.run(&crypto, &socket, &socket, &socket);
113+
let pase_fut = async {
114+
info!("opening unsecured exchange to {}", peer_addr);
115+
let mut exchange = Exchange::initiate_unsecured(matter, &crypto, peer).await?;
116+
info!(
117+
"unsecured exchange open — driving PASE with passcode {}",
118+
PASSCODE
119+
);
120+
PaseInitiator::initiate(&mut exchange, &crypto, PASSCODE).await?;
121+
info!("✓ PASE handshake completed");
122+
drop(exchange); // PASE session now cached; subsequent opens are secured
123+
124+
// Stage 2: ArmFailSafe over the PASE-secured channel.
125+
// - Opens a fresh exchange (fab=0, peer=0, secure=true)
126+
// - Sends GeneralCommissioning::ArmFailSafe(60, 0)
127+
// - Waits for the device's ArmFailSafeResponse
128+
info!("calling ArmFailSafe(60s, breadcrumb=0) over PASE...");
129+
arm_fail_safe(matter, 60, 0).await.map_err(|e| {
130+
error!("arm_fail_safe error: {:?}", e);
131+
rs_matter::error::Error::new(rs_matter::error::ErrorCode::NoExchange)
132+
})?;
133+
info!("✓ ArmFailSafe completed");
134+
135+
// Stage 3: CSRRequest over PASE — exercises response-bearing
136+
// IM invoke (decode NOCSRElements from the device's reply).
137+
use rand::RngCore;
138+
let mut nonce = [0u8; 32];
139+
rand::thread_rng().fill_bytes(&mut nonce);
140+
info!("calling CSRRequest(random 32B nonce) over PASE...");
141+
let csr = csr_request(matter, &nonce).await.map_err(|e| {
142+
error!("csr_request error: {:?}", e);
143+
rs_matter::error::Error::new(rs_matter::error::ErrorCode::NoExchange)
144+
})?;
145+
info!(
146+
"✓✓✓ CSRRequest completed — got {}B NOCSRElements + {}B AttestationSignature",
147+
csr.nocsr_elements.len(),
148+
csr.attestation_signature.len()
149+
);
150+
151+
// Stage 4: full end-to-end commissioning. NOCSR decode →
152+
// controller issues a NOC against its own fabric → installs
153+
// RCAC → AddNOC → CommissioningComplete. After this the
154+
// device is part of our fabric and should respond on its
155+
// operational identity.
156+
info!("building controller-side FabricCredentials (fabric_id=1)...");
157+
let mut fabric_creds = FabricCredentials::new(&crypto, 1).map_err(|e| {
158+
error!("FabricCredentials::new error: {:?}", e);
159+
rs_matter::error::Error::new(rs_matter::error::ErrorCode::Invalid)
160+
})?;
161+
info!(
162+
"calling commission_pase(admin_subject=112233, admin_vendor_id=0xFFF1, fs=60s)..."
163+
);
164+
let result = commission_pase(matter, &crypto, &mut fabric_creds, 112233, 0xFFF1, 60)
165+
.await
166+
.map_err(|e| {
167+
error!("commission_pase error: {:?}", e);
168+
rs_matter::error::Error::new(rs_matter::error::ErrorCode::NoExchange)
169+
})?;
170+
info!(
171+
"✓✓✓✓ commission_pase done — fabric_index={} device_node_id=0x{:016x} \
172+
noc={}B icac={}B",
173+
result.fabric_index,
174+
result.device_node_id,
175+
result.noc_der.len(),
176+
result.icac_der.len()
177+
);
178+
Ok::<(), rs_matter::error::Error>(())
179+
};
180+
181+
match futures_lite::future::or(
182+
async {
183+
let r = pase_fut.await;
184+
match &r {
185+
Ok(()) => info!("controller-side PASE flow returned Ok"),
186+
Err(e) => error!("controller-side PASE flow returned Err: {:?}", e),
187+
}
188+
r
189+
},
190+
async {
191+
transport_fut.await.unwrap_or_else(|e| {
192+
error!("transport future ended: {:?}", e);
193+
});
194+
Err(rs_matter::error::Error::new(
195+
rs_matter::error::ErrorCode::NoExchange,
196+
))
197+
},
198+
)
199+
.await
200+
{
201+
Ok(()) => info!("smoke test PASS"),
202+
Err(e) => error!("smoke test FAIL: {:?}", e),
203+
}
204+
};
205+
206+
async_io::block_on(main);
207+
Ok(())
208+
}

rs-matter/src/commissioner/fabric_credentials.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,49 @@ impl FabricCredentials {
261261
pub fn set_ipk(&mut self, ipk: CanonAeadKeyRef<'_>) {
262262
self.ipk.load(ipk);
263263
}
264+
265+
/// Get a reference to the Root CA private key for persistence.
266+
///
267+
/// Forwards to [`crate::commissioner::NocGenerator::root_secret_key`].
268+
/// Pair with [`Self::from_persisted`] (below) on the reload side
269+
/// to recreate the fabric credentials with identical signing
270+
/// capability — i.e., devices commissioned before reload remain
271+
/// valid because their NOCs are signed by the same key.
272+
pub fn root_secret_key(&self) -> crate::crypto::CanonPkcSecretKeyRef<'_> {
273+
self.noc_generator.root_secret_key()
274+
}
275+
276+
/// Get the RCAC ID for persistence.
277+
pub fn rcac_id(&self) -> u64 {
278+
self.noc_generator.rcac_id()
279+
}
280+
281+
/// Restore fabric credentials from previously-persisted bytes.
282+
///
283+
/// `root_privkey`, `root_cert`, `fabric_id`, `rcac_id`, `ipk_bytes`,
284+
/// and `next_node_id` all come from a prior [`Self::root_secret_key`]
285+
/// / [`Self::root_cert`] / [`Self::fabric_id`] / [`Self::rcac_id`] /
286+
/// [`Self::ipk`] / [`Self::peek_next_node_id`] snapshot saved to
287+
/// stable storage. This is the inverse of `new()`.
288+
pub fn from_persisted<C: Crypto>(
289+
crypto: &C,
290+
root_privkey: crate::crypto::CanonPkcSecretKey,
291+
root_cert: &[u8],
292+
fabric_id: u64,
293+
rcac_id: u64,
294+
ipk_bytes: CanonAeadKeyRef<'_>,
295+
next_node_id: u64,
296+
) -> Result<Self, Error> {
297+
let noc_generator =
298+
NocGenerator::from_root_ca(crypto, root_privkey, root_cert, fabric_id, rcac_id)?;
299+
let mut ipk = CanonAeadKey::new();
300+
ipk.load(ipk_bytes);
301+
Ok(Self {
302+
noc_generator,
303+
ipk,
304+
next_node_id,
305+
})
306+
}
264307
}
265308

266309
#[cfg(test)]

rs-matter/src/commissioner/noc_generator.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,16 @@ impl NocGenerator {
373373
self.rcac_id
374374
}
375375

376+
/// Get a reference to the Root CA private key, for persistence.
377+
///
378+
/// Pair with [`NocGenerator::from_root_ca`] on the reload side to
379+
/// restore a generator that signs new NOCs identically to before.
380+
/// Treat the returned bytes as highly sensitive — they're the
381+
/// trust anchor for the entire fabric.
382+
pub fn root_secret_key(&self) -> crate::crypto::CanonPkcSecretKeyRef<'_> {
383+
self.root_privkey.reference()
384+
}
385+
376386
/// Get the ICAC ID (if ICAC was generated).
377387
pub fn icac_id(&self) -> Option<u64> {
378388
self.icac_id

0 commit comments

Comments
 (0)