Skip to content

Commit 4e6917b

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 4e6917b

8 files changed

Lines changed: 1479 additions & 0 deletions

File tree

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

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)