matter-commissioning 0.4.0

Matter commissioning state machine: setup payload, attestation, NOC issuance, network commissioning.
Documentation

matter-commissioning

Matter commissioning: setup payloads, the ten-stage state machine, device attestation, NOC issuance, and network commissioning.

Part of matter-rust. Milestone 6.

Status: 0.2.0.

Milestone 6.4 (Commissioning State Machine): complete — the state machine drives end-to-end from SecurePairing through Action::Done(CommissionedFabric) on canned responses + a mock on_case_established callback. matter.js byte-parity gate infrastructure shipped (operator-touch wiring deferred — see TODO-1.0.md).

Phases available:

  • M6.1: the setup-payload codec (QR + manual pairing code).
  • M6.2.1: typed attestation cert wrappers (Dac / Pai / Paa), PaaTrustStore with bundled CSA test roots, VendorId / ProductId newtypes. Parsing only.
  • M6.2.2: verify_chainrustls-webpki 0.103 path validation with KeyUsage::client_auth() plus a Matter VID/PID equality overlay. Six granular AttestationError variants with a documented webpki::Error mapping. 8-row negative-fixture matrix.
  • M6.2.3 (M6.2 feature-complete): verify_attestation_response — pure ECDSA P-256/SHA-256 verification via ring over attestation_elements || attestation_challenge. Single coarse BadResponseSignature error variant; matter.js byte-parity for happy-path + four single-byte mutations.
  • M6.3 (feature-complete): NOC issuance — FabricRecord, verify_csr_response, issue_noc, OpCreds command codecs with matter.js byte-parity.
  • M6.4 (complete): commissioning state machine — all six sub-phases (M6.4.1 skeleton → M6.4.6 byte-parity gate infrastructure) shipped.

Next: M6.5 (Wi-Fi network commissioning subgraph) and M6.6 (Tokio driver + first real-device commission). With M6.6 lands the first public demo of the library commissioning a real Matter device.

Example: parse a QR code

use matter_commissioning::setup::parse_qr;

let payload = parse_qr("MT:Y.K90AFN00KA0648G00")?;
assert_eq!(payload.vendor_id, Some(0xFFF1));
assert_eq!(payload.passcode.as_u32(), 20_202_021);

(Replace the QR string with the actual captured value from test-vectors/commissioning/setup/qr-spec-example.json.)

Example: parse a manual pairing code

use matter_commissioning::setup::parse_manual_code;

let payload = parse_manual_code("11693312331")?;
assert_eq!(payload.discriminator.short(), 0x5);

Example: parse a DAC and reach for a trusted root (M6.2.1)

use matter_commissioning::{Dac, PaaTrustStore, VendorId};

# fn run(dac_der: &[u8]) -> Result<(), matter_commissioning::AttestationError> {
let dac = Dac::from_der(dac_der)?;
assert_eq!(dac.subject_vid(), VendorId::new(0xFFF1));

let trust_store = PaaTrustStore::with_example_device_roots();
assert!(trust_store.len() > 0);
# Ok(())
# }

Chain validation against the trust store is M6.2.2.

Example: validate an attestation chain (M6.2.2)

use matter_cert::time::MatterTime;
use matter_commissioning::{verify_chain, Dac, Pai, PaaTrustStore};

# fn run(dac_der: &[u8], pai_der: &[u8])
#   -> Result<(), matter_commissioning::AttestationError> {
let dac = Dac::from_der(dac_der)?;
let pai = Pai::from_der(pai_der)?;
let store = PaaTrustStore::with_example_device_roots();
let now = MatterTime::from_unix_secs(1_704_067_200);

let chain = verify_chain(&dac, &pai, &store, now)?;
println!("DAC verified for VID={} PID={}", chain.vendor_id, chain.product_id);
# Ok(())
# }

Production callers build their own PaaTrustStore from CSA-published production roots (M8 deliverable). The bundled with_example_device_roots() is for examples and integration tests only.

Example: verify an attestation response (M6.2.3)

use matter_commissioning::{
    verify_attestation_response, AttestationResponse,
};

# fn run(
#     attestation_elements: Vec<u8>,
#     signature: [u8; 64],
#     dac_public_key: &[u8],
#     attestation_challenge: &[u8; 16],
# ) -> Result<(), matter_commissioning::AttestationError> {
let response = AttestationResponse {
    attestation_elements,
    signature,
};
verify_attestation_response(&response, attestation_challenge, dac_public_key)?;
# Ok(())
# }

The dac_public_key is exactly what Dac::public_key() returns (raw SEC1 uncompressed P-256, 65 bytes). The attestation_challenge is the 16-byte session value at [32..48] of the PASE/CASE session key blob (exposed as CaseSessionKeys::attestation_challenge or PaseSessionKeys::attestation_key). Any verification failure folds into the single coarse AttestationError::BadResponseSignature.

Example: drive the early commissioning stages (M6.4.1)

use std::sync::Arc;

use matter_cert::time::MatterTime;
use matter_commissioning::attestation::CdSigningRoots;
use matter_commissioning::noc::{FabricRecord, NocRng, SystemNocRng};
use matter_commissioning::{
    Action, Commissioner, CommissionerConfig, Expectation, PaaTrustStore, SetupPayload,
};
use matter_crypto::{RingSigner, Signer};

# fn run(
#     pase_attestation_challenge: [u8; 16],
#     setup: &SetupPayload,
# ) -> Result<(), Box<dyn std::error::Error>> {
let (signer, _pkcs8) = RingSigner::generate()?;
let signer: Arc<dyn Signer> = Arc::new(signer);
let rng_for_fabric = SystemNocRng;
let fabric = FabricRecord::new_root_only(
    /* fabric_id */ 0x0000_0000_0000_0001,
    signer,
    MatterTime::from_unix_secs(1_704_067_200),
    MatterTime::from_unix_secs(1_735_689_600),
    /* rcac_id */ 0xDEAD_BEEF_CAFE_F00D,
    &rng_for_fabric,
)?;

let paa = PaaTrustStore::with_example_device_roots();
let cd_signing_roots = CdSigningRoots::with_example_device_roots();
let rng: Arc<dyn NocRng> = Arc::new(SystemNocRng);
let cfg = CommissionerConfig {
    pase_attestation_challenge,
    fabric: &fabric,
    setup_payload: setup,
    paa_trust_store: &paa,
    cd_signing_roots: &cd_signing_roots,
    commissioner_node_id: 0x1,
    assigned_node_id: 0x2,
    ipk_epoch_key: [0x42_u8; 16],
    case_admin_subject: 0x1,
    admin_vendor_id: 0xFFF1,
    now: MatterTime::from_unix_secs(1_704_067_200),
    rng,
};
let mut sm = Commissioner::new(cfg)?;
loop {
    match sm.poll()? {
        Action::ReadAttribute { expect, .. } | Action::Invoke { expect, .. } => {
            // The caller (M6.6 driver) frames the request into an
            // Invoke/Read envelope, routes via matter-transport over
            // the PASE session, and feeds the decoded response back:
            let response_bytes: &[u8] = unimplemented!("driver supplies the bytes");
            sm.on_response(expect, response_bytes)?;
        }
        Action::Abort { send_disarm_failsafe, reason } => {
            eprintln!("commissioning aborted at {:?}: {reason}", sm.stage());
            if send_disarm_failsafe {
                // ... send DisarmFailsafe (ArmFailSafe with expiry=0) over PASE ...
            }
            break;
        }
        Action::Done(_) => break,
        other => unreachable!("M6.4.1 doesn't emit {other:?} yet"),
    }
}
# Ok(())
# }

M6.4.1 only drives SecurePairingReadCommissioningInfoArmFailsafeConfigRegulatory. M6.4.2 extends the flow through the attestation request/response stages, and M6.4.3 wires the CSA-signed Certification Declaration check into the off-wire AttestationVerification step so the cursor can advance past attestation into the (M6.4.4) CSR + NOC issuance stages.

Example: attestation flow through CD verification (M6.4.3)

The same driver loop from the M6.4.1 example works unchanged — after ConfigRegulatory the state machine emits four more Action::Invoke calls (PAI cert, DAC cert, AttestationRequest) and one off-wire AttestationVerification step. M6.4.3 wires the CD-verify step in, so on a valid CD the cursor advances past attestation:

use matter_commissioning::{
    Action, Commissioner, CommissionerConfig, CommissioningError, Expectation,
};

# fn run(
#     sm: &mut Commissioner,
#     pai_response_tlv: &[u8],
#     dac_response_tlv: &[u8],
#     attestation_response_tlv: &[u8],
# ) -> Result<(), Box<dyn std::error::Error>> {
// After ConfigRegulatory, cursor reaches SendPaiCertRequest.

// Stage 4: PAI cert request.
let _ = sm.poll()?;
sm.on_response(Expectation::PaiCertChainResponse, pai_response_tlv)?;

// Stage 5: DAC cert request.
let _ = sm.poll()?;
sm.on_response(Expectation::DacCertChainResponse, dac_response_tlv)?;

// Stage 6: AttestationRequest with fresh 32-byte random nonce.
let _ = sm.poll()?;
sm.on_response(Expectation::AttestationResponse, attestation_response_tlv)?;

// Stage 7: AttestationVerification (off-wire). Runs the M6.2/M6.4.3
// verifier chain — chain validation, attestation signature, nonce
// echo, then CD verification — and advances past attestation on
// success. On failure, `poll()` returns a typed `CommissioningError`
// and the cursor transitions to `Failed`.
let _ = sm.poll()?;
# Ok(())
# }

M6.4.4 will land the CSR / NOC issuance stages that consume the advanced cursor.

Example: verify a Certification Declaration standalone (M6.4.3)

verify_certification_declaration can be called directly without involving the state machine — useful for offline analysis of captured CD blobs:

use matter_commissioning::{
    verify_certification_declaration, AttestationError, CdSigningRoots,
    ProductId, VendorId,
};

# fn run(cd_bytes: &[u8]) -> Result<(), AttestationError> {
let trust = CdSigningRoots::with_example_device_roots();
verify_certification_declaration(
    cd_bytes,
    VendorId::new(0xFFF1),
    ProductId::new(0x8001),
    &trust,
)?;
# Ok(())
# }

Production callers replace with_example_device_roots() with CdSigningRoots::from_pem(&[my_root_pem]) loading the CSA-published signing root(s) supplied by deployment.

The verifier performs five checks in order:

  1. Parse the CMS/PKCS#7 SignedData via the cms crate.
  2. Validate the CMS envelope shape (single signer, attached content, ecdsa-with-SHA256).
  3. Verify the ECDSA-P256/SHA-256 signature against each trusted root; accept on first match.
  4. Decode the inner Matter-TLV CD body to extract vendor_id + product_id_array.
  5. Cross-check the declared VID/PID against the expected_vid / expected_pid arguments.

Any failure surfaces as a specific AttestationError::CertificationDeclaration* variant.

Example: full commissioning driver loop reaching Action::Done (M6.4.5)

The complete cursor walks from SecurePairing through Action::Done(CommissionedFabric). The caller (M6.6's Tokio driver in the next major milestone) frames Invoke envelopes + routes via matter-transport, then performs mDNS find-operational + the SIGMA handshake when the state machine signals Action::EstablishCase:

use matter_commissioning::{
    Action, CommissionedFabric, Commissioner, CommissionerConfig,
    CommissioningError, Expectation,
};

# fn run(mut sm: Commissioner) -> Result<CommissionedFabric, CommissioningError> {
loop {
    match sm.poll()? {
        Action::Invoke { expect, .. } | Action::ReadAttribute { expect, .. } => {
            // Caller frames the request into Invoke/Read envelope and
            // routes via matter-transport. The session is PASE for all
            // pre-NOC stages and CASE after EstablishCase succeeds.
            let response_bytes: &[u8] = unimplemented!("driver supplies the bytes");
            sm.on_response(expect, response_bytes)?;
        }
        Action::EstablishCase { fabric_id, peer_node_id } => {
            // M6.6 driver: mDNS find-operational for the operational
            // service name keyed off (compressed_fabric_id, peer_node_id),
            // then run the SIGMA-I handshake from matter-crypto.
            // Pretend success here:
            let _ = (fabric_id, peer_node_id);
            sm.on_case_established()?;

            // On failure instead:
            //   sm.on_response(Expectation::CaseFailed, &[])?;
        }
        Action::EvictCase { .. } => {
            // Reserved for M8 multi-fabric work; never emitted by
            // M6.4's new-fabric flow.
        }
        Action::Done(commissioned_fabric) => {
            return Ok(commissioned_fabric);
        }
        Action::Abort { send_disarm_failsafe, reason } => {
            eprintln!("commissioning aborted: {reason}");
            if send_disarm_failsafe {
                // ... send ArmFailSafe(expiry=0) over PASE ...
            }
            return Err(CommissioningError::CaseEstablishmentFailed); // pick a representative error
        }
    }
}
# }

The returned CommissionedFabric carries the long-lived fabric record (RCAC + IPK + fabric ID), the peer's operational node ID, the device's NOC public key, and the terminal stage cursor (always Stage::Cleanup).

Wi-Fi commissioning configuration (M6.5+)

use matter_commissioning::{CommissionerConfig, NetworkCredentials, WiFiCredentials};

let config = CommissionerConfig {
    pase_attestation_challenge,
    fabric: &fabric,
    setup_payload: &setup,
    paa_trust_store: &paa,
    cd_signing_roots: &cd_signing_roots,
    commissioner_node_id: 0x1,
    assigned_node_id: 0x2,
    ipk_epoch_key: [0x42_u8; 16],
    case_admin_subject: 0x1,
    admin_vendor_id: 0xFFF1,
    now: MatterTime::from_unix_secs(1_704_067_200),
    rng,
    network: NetworkCredentials::WiFi(WiFiCredentials {
        ssid: b"matter".to_vec(),
        credentials: b"hunter22".to_vec(),
    }),
};
let mut sm = Commissioner::new(config)?;

For Ethernet-only devices (or devices already on their operational network), set network: NetworkCredentials::AlreadyOnNetwork — the state machine detects the network shape at Stage::ReadNetworkCommissioningInfo and skips the Wi-Fi sub-cursor.

Thread commissioning is supported: set network: NetworkCredentials::Thread(dataset) with a ThreadDataset built from an operational dataset (e.g. ot-ctl dataset active -x, hex-decoded). If the supplied credential type doesn't match what the device actually offers — e.g. Thread credentials against a device whose NetworkCommissioning::FeatureMap lacks the Thread bit — commissioning fails fast with CommissioningError::NetworkFeatureUnsupported { needed }, naming the network type the device is missing.

Optional tracing feature

Enable per-method spans for observability:

matter-commissioning = { version = "...", features = ["tracing"] }

Span field names (stage, expectation) align best-effort with matter.js's log-event format so operators can grep across both implementations.

Byte parity

Every fixture in test-vectors/commissioning/setup/ is captured from matter.js by cargo xtask capture-setup. The integration test in tests/setup_byte_parity.rs asserts that encode_qr / encode_manual_code produce byte-identical output and that parse_qr / parse_manual_code recover the same SetupPayload.

For attestation-response verification, test-vectors/attestation/response/ is captured by cargo xtask capture-attestation. The integration test in tests/attestation_response_byte_parity.rs asserts that Rust and matter.js's NodeJsStyleCrypto.verifyEcdsa produce the same accept/reject verdict for a happy-path tuple plus four single-byte mutations. (Byte-parity is on verdicts, not raw bytes — ECDSA's k is randomized per signing call, so the captured signature varies across script runs while the test assertions remain stable.)