Skip to main content

rs_matter/sc/
pase.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//! PASE (Passcode-Authenticated Session Establishment) protocol implementation.
19//!
20//! This module provides both the initiator (commissioner) and responder (device) sides
21//! of the PASE protocol for establishing secure sessions using a shared passcode.
22
23use core::num::NonZeroU8;
24
25use embassy_time::{Duration, Instant};
26
27use crate::dm::clusters::adm_comm::{self};
28use crate::dm::endpoints::ROOT_ENDPOINT_ID;
29use crate::error::{Error, ErrorCode};
30use crate::im::{ClusterId, EndptId};
31use crate::sc::pase::spake2p::{
32    Spake2pVerifierData, Spake2pVerifierStrRef, SPAKE2P_VERIFIER_SALT_LEN,
33    SPAKE2P_VERIFIER_SALT_MIN_LEN,
34};
35use crate::sc::SessionParameters;
36use crate::tlv::{FromTLV, OctetStr, ToTLV};
37use crate::transport::exchange::{Exchange, ExchangeId};
38use crate::utils::init::{init, Init};
39use crate::utils::maybe::Maybe;
40use crate::MatterLocalService;
41
42pub use initiator::PaseInitiator;
43pub use responder::PaseResponder;
44pub use spake2p::{
45    Spake2pVerifierPassword, Spake2pVerifierPasswordRef, SPAKE2P_VERIFIER_PASSWORD_LEN,
46    SPAKE2P_VERIFIER_PASSWORD_ZEROED,
47};
48
49mod initiator;
50mod responder;
51pub(crate) mod spake2p;
52
53/// Minimal commissioning window timeout in seconds, as per the Matter Core Spec
54pub const MIN_COMM_WINDOW_TIMEOUT_SECS: u16 = 3 * 60;
55/// Maximal commissioning window timeout in seconds, as per the Matter Core Spec
56pub const MAX_COMM_WINDOW_TIMEOUT_SECS: u16 = 15 * 60;
57
58/// Notify that the externally-visible attributes of the Administrator
59/// Commissioning cluster may have changed. Called whenever the commissioning
60/// window is opened or closed: `WindowStatus`, `AdminFabricIndex` and
61/// `AdminVendorId` all transition together, so we mark the entire cluster
62/// dirty with a single call.
63fn notify_adm_comm_window_attrs_changed(notify_change: &mut impl FnMut(EndptId, ClusterId)) {
64    notify_change(ROOT_ENDPOINT_ID, adm_comm::FULL_CLUSTER.id);
65}
66
67/// The type of commissioning window
68#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
69#[cfg_attr(feature = "defmt", derive(defmt::Format))]
70pub enum CommWindowType {
71    /// Basic commissioning window (using passcode)
72    Basic,
73    /// Enhanced commissioning window (using verifier)
74    Enhanced,
75}
76
77/// The fabric index of the fabric administrator that opened the commissioning window
78#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
79#[cfg_attr(feature = "defmt", derive(defmt::Format))]
80pub struct CommWindowOpener {
81    /// The fabric index
82    pub fab_idx: NonZeroU8,
83    /// The vendor ID
84    pub vendor_id: u16,
85}
86
87/// Whether a PASE commissioning window is open, and - if so - who opened it
88#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
89#[cfg_attr(feature = "defmt", derive(defmt::Format))]
90pub enum CommWindowState {
91    /// No commissioning window is open
92    Closed,
93    /// A commissioning window is open
94    Open {
95        /// The administrator that opened the window over a CASE session, or `None` when the
96        /// device opened it itself, which is the case for initial commissioning
97        opener: Option<CommWindowOpener>,
98    },
99}
100
101impl CommWindowState {
102    /// Return `true` if a commissioning window is open.
103    ///
104    /// This is the condition under which the device is discoverable as commissionable and will
105    /// accept PASE.
106    pub const fn is_open(&self) -> bool {
107        matches!(self, Self::Open { .. })
108    }
109
110    /// Return `true` if a commissioning window is open and should be advertised on every
111    /// supported transport, rather than on the operational IP network alone.
112    pub const fn is_open_on_all_transports(&self) -> bool {
113        matches!(self, Self::Open { opener: None })
114    }
115}
116
117/// A PASE commissioning window
118pub struct CommWindow {
119    /// The mDNS identifier
120    mdns_id: u64,
121    /// The discriminator
122    discriminator: u16,
123    /// The verifier data
124    pub(crate) verifier: Spake2pVerifierData,
125    /// The opener info
126    opener: Option<CommWindowOpener>,
127    /// The window expiry instant
128    window_expiry: Instant,
129    /// Number of failed PAKE handshake attempts within this window.
130    /// Per Matter Core spec, the window SHALL be
131    /// revoked after 20 unsuccessful handshakes.
132    pake_failures: u8,
133}
134
135impl CommWindow {
136    /// Initialize a commissioning window with a passcode
137    ///
138    /// # Arguments
139    /// - `mdns_id` - The mDNS identifier
140    /// - `password` - The passcode
141    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
142    /// - `discriminator` - The discriminator
143    /// - `opener` - The opener info
144    /// - `window_expiry` - The window expiry instant
145    /// - `rand` - The random number generator
146    fn init_with_pw<'a>(
147        mdns_id: u64,
148        password: Spake2pVerifierPasswordRef<'a>,
149        salt: &'a [u8],
150        discriminator: u16,
151        opener: Option<CommWindowOpener>,
152        window_expiry: Instant,
153    ) -> impl Init<Self> + 'a {
154        init!(Self {
155            mdns_id,
156            discriminator,
157            verifier <- Spake2pVerifierData::init_with_pw(password, salt),
158            opener,
159            window_expiry,
160            pake_failures: 0,
161        })
162    }
163
164    /// Initialize a commissioning window with a verifier
165    ///
166    /// # Arguments
167    /// - `verifier` - The verifier bytes
168    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
169    /// - `count` - The iteration count
170    /// - `discriminator` - The discriminator
171    /// - `opener` - The opener info
172    /// - `window_expiry` - The window expiry instant
173    fn init<'a>(
174        mdns_id: u64,
175        verifier: Spake2pVerifierStrRef<'a>,
176        salt: &'a [u8],
177        count: u32,
178        discriminator: u16,
179        opener: Option<CommWindowOpener>,
180        window_expiry: Instant,
181    ) -> impl Init<Self> + 'a {
182        init!(Self {
183            mdns_id,
184            discriminator,
185            verifier <- Spake2pVerifierData::init(verifier, salt, count),
186            opener,
187            window_expiry,
188            pake_failures: 0,
189        })
190    }
191
192    /// Get the type of commissioning window
193    pub fn comm_window_type(&self) -> CommWindowType {
194        if self.verifier.password.is_some() {
195            CommWindowType::Basic
196        } else {
197            CommWindowType::Enhanced
198        }
199    }
200
201    /// Get the opener info, if any
202    pub fn opener(&self) -> Option<CommWindowOpener> {
203        self.opener
204    }
205
206    /// Get the mDNS service info
207    pub fn mdns_service(&self) -> MatterLocalService {
208        MatterLocalService::Commissionable {
209            id: self.mdns_id,
210            discriminator: self.discriminator,
211            enhanced: matches!(self.comm_window_type(), CommWindowType::Enhanced),
212        }
213    }
214}
215
216/// The PASE state
217pub struct Pase {
218    /// The opened commissioning window, if any
219    comm_window: Maybe<CommWindow>,
220    /// The (one and only) PASE session timeout tracker
221    /// If there is no active PASE session, this is `None`
222    pub(crate) session_timeout: Option<SessionEstTimeout>,
223}
224
225impl Pase {
226    /// Create a new PASE state
227    #[inline(always)]
228    pub const fn new() -> Self {
229        Self {
230            comm_window: Maybe::none(),
231            session_timeout: None,
232        }
233    }
234
235    /// Return an in-place initializer for the PASE manager
236    pub fn init() -> impl Init<Self> {
237        init!(Self {
238            comm_window <- Maybe::init_none(),
239            session_timeout: None,
240        })
241    }
242
243    /// Check if the opened commissioning window has expired, and close it if so.
244    ///
245    /// This should be called periodically to ensure that the commissioning window state is updated in a timely manner.
246    /// Ideally, it should also be called at the beginning of any API that requires the commissioning window to be opened to ensure that the state is up to date.
247    pub fn check_comm_window_timeout(
248        &mut self,
249        notify_mdns: impl FnMut(),
250        notify_change: impl FnMut(EndptId, ClusterId),
251    ) -> Result<bool, Error> {
252        let expired = self
253            .comm_window
254            .as_opt_ref()
255            .map(|comm_window| Instant::now() > comm_window.window_expiry)
256            .unwrap_or(false);
257
258        if expired {
259            warn!("PASE Commissioning Window expired, closing");
260
261            self.close_comm_window(notify_mdns, notify_change)?;
262
263            Ok(true)
264        } else {
265            Ok(false)
266        }
267    }
268
269    /// Get the opened commissioning window, if any
270    pub fn comm_window(&self) -> Option<&CommWindow> {
271        self.comm_window.as_opt_ref()
272    }
273
274    /// Reject a salt that's outside the spec's 16..=32 B range
275    /// (Matter Core spec, Cryptographic Building Blocks).
276    fn validate_salt_len(salt: &[u8]) -> Result<(), Error> {
277        if !(SPAKE2P_VERIFIER_SALT_MIN_LEN..=SPAKE2P_VERIFIER_SALT_LEN).contains(&salt.len()) {
278            Err(ErrorCode::ConstraintError)?;
279        }
280
281        Ok(())
282    }
283
284    /// Return the state of the commissioning window - whether one is open, and if so who
285    /// opened it. See [`CommWindowState`] for what the answer is good for.
286    pub fn comm_window_state(&self) -> CommWindowState {
287        match self.comm_window() {
288            Some(comm_window) => CommWindowState::Open {
289                opener: comm_window.opener(),
290            },
291            None => CommWindowState::Closed,
292        }
293    }
294
295    /// Open a basic commissioning window using a passcode
296    ///
297    /// # Arguments
298    /// - `mdns_id` - The mDNS identifier
299    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
300    /// - `password` - The passcode
301    /// - `discriminator` - The discriminator
302    /// - `timeout_secs` - The timeout in seconds of the validity of the window
303    /// - `opener` - The opener info
304    /// - `mdns_notif` - The mDNS notification callback
305    ///
306    /// # Returns
307    /// - `Ok(())` if the window was opened successfully
308    /// - `Err(Error)` if an error occurred
309    ///   (i.e. there is another non-expired commissioning window already opened
310    ///   or the timeout is invalid)
311    #[allow(clippy::too_many_arguments)]
312    pub fn open_basic_comm_window(
313        &mut self,
314        mdns_id: u64,
315        salt: &[u8],
316        password: Spake2pVerifierPasswordRef<'_>,
317        discriminator: u16,
318        timeout_secs: u16,
319        opener: Option<CommWindowOpener>,
320        mut notify_mdns: impl FnMut(),
321        mut notify_change: impl FnMut(EndptId, ClusterId),
322    ) -> Result<(), Error> {
323        if self.comm_window.is_some() {
324            Err(ErrorCode::Busy)?;
325        }
326
327        if !(MIN_COMM_WINDOW_TIMEOUT_SECS..=MAX_COMM_WINDOW_TIMEOUT_SECS).contains(&timeout_secs) {
328            Err(ErrorCode::InvalidCommand)?;
329        }
330
331        Self::validate_salt_len(salt)?;
332
333        let window_expiry = Instant::now().saturating_add(Duration::from_secs(timeout_secs as _));
334
335        self.comm_window
336            .reinit(Maybe::init_some(CommWindow::init_with_pw(
337                mdns_id,
338                password,
339                salt,
340                discriminator,
341                opener,
342                window_expiry,
343            )));
344
345        notify_mdns();
346        notify_adm_comm_window_attrs_changed(&mut notify_change);
347
348        info!("PASE Basic Commissioning Window opened");
349
350        Ok(())
351    }
352
353    /// Open an enhanced commissioning window using a verifier
354    ///
355    /// # Arguments
356    /// - `mdns_id` - The mDNS identifier
357    /// - `verifier` - The verifier bytes
358    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
359    /// - `count` - The iteration count
360    /// - `discriminator` - The discriminator
361    /// - `timeout_secs` - The timeout in seconds of the validity of the window
362    /// - `opener` - The opener info
363    /// - `mdns_notif` - The mDNS notification callback
364    ///
365    /// # Returns
366    /// - `Ok(())` if the window was opened successfully
367    /// - `Err(Error)` if an error occurred
368    ///   (i.e. there is another non-expired commissioning window already opened
369    ///   or the timeout is invalid)
370    #[allow(clippy::too_many_arguments)]
371    pub fn open_comm_window(
372        &mut self,
373        mdns_id: u64,
374        verifier: Spake2pVerifierStrRef<'_>,
375        salt: &[u8],
376        count: u32,
377        discriminator: u16,
378        timeout_secs: u16,
379        opener: Option<CommWindowOpener>,
380        mut notify_mdns: impl FnMut(),
381        mut notify_change: impl FnMut(EndptId, ClusterId),
382    ) -> Result<(), Error> {
383        if self.comm_window.is_some() {
384            Err(ErrorCode::Busy)?;
385        }
386
387        if !(MIN_COMM_WINDOW_TIMEOUT_SECS..=MAX_COMM_WINDOW_TIMEOUT_SECS).contains(&timeout_secs) {
388            Err(ErrorCode::InvalidCommand)?;
389        }
390
391        Self::validate_salt_len(salt)?;
392
393        let window_expiry = Instant::now().saturating_add(Duration::from_secs(timeout_secs as _));
394
395        self.comm_window.reinit(Maybe::init_some(CommWindow::init(
396            mdns_id,
397            verifier,
398            salt,
399            count,
400            discriminator,
401            opener,
402            window_expiry,
403        )));
404
405        notify_mdns();
406        notify_adm_comm_window_attrs_changed(&mut notify_change);
407
408        info!("PASE Commissioning Window opened");
409
410        Ok(())
411    }
412
413    /// Record a failed PAKE handshake against the currently-open
414    /// commissioning window, and revoke the window if the limit is reached.
415    ///
416    /// Per Matter Core spec, after 20 unsuccessful PAKE
417    /// attempts the device SHALL revoke the open commissioning window.
418    ///
419    /// Also clears the in-progress PASE establishment timeout so that the
420    /// next handshake attempt is not rejected as "another session in
421    /// progress" — without this, only the first wrong-passcode attempt would
422    /// be counted because subsequent attempts would be short-circuited at the
423    /// session-timeout gate.
424    ///
425    /// Has no effect on the failure counter if no window is open.
426    pub fn record_pake_failure(
427        &mut self,
428        notify_mdns: impl FnMut(),
429        notify_change: impl FnMut(EndptId, ClusterId),
430    ) -> Result<(), Error> {
431        const MAX_PAKE_FAILURES: u8 = 20;
432
433        self.session_timeout = None;
434
435        let revoke = if let Some(window) = self.comm_window.as_opt_mut() {
436            window.pake_failures = window.pake_failures.saturating_add(1);
437            warn!(
438                "PASE Commissioning Window: PAKE failure {} of {}",
439                window.pake_failures, MAX_PAKE_FAILURES
440            );
441            window.pake_failures >= MAX_PAKE_FAILURES
442        } else {
443            false
444        };
445
446        if revoke {
447            warn!("PASE Commissioning Window revoked after too many failed PAKE attempts");
448            self.close_comm_window(notify_mdns, notify_change)?;
449        }
450
451        Ok(())
452    }
453
454    /// Close the opened commissioning window, if any
455    ///
456    /// # Arguments
457    /// - `ctx` - The handler context
458    ///
459    /// # Returns
460    /// - `Ok(true)` if a commissioning window was closed
461    /// - `Ok(false)` if there was no commissioning window to close
462    pub fn close_comm_window(
463        &mut self,
464        mut notify_mdns: impl FnMut(),
465        mut notify_change: impl FnMut(EndptId, ClusterId),
466    ) -> Result<bool, Error> {
467        if self.comm_window.is_some() {
468            self.comm_window.clear();
469
470            notify_mdns();
471            notify_adm_comm_window_attrs_changed(&mut notify_change);
472
473            info!("PASE Commissioning Window closed");
474
475            Ok(true)
476        } else {
477            warn!("No PASE Commissioning Window to close");
478
479            Ok(false)
480        }
481    }
482}
483
484impl Default for Pase {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490/// The timeout tracker for a PASE session establishment
491const PASE_SESSION_EST_TIMEOUT_SECS: Duration = Duration::from_secs(60);
492
493/// The info string for SPAKE2 session key derivation
494pub(crate) const SPAKE2_SESSION_KEYS_INFO: &[u8] = b"SessionKeys";
495
496/// The PASE session establishment timeout tracker
497pub(crate) struct SessionEstTimeout {
498    /// The session expiry instant
499    session_est_expiry: Instant,
500    /// The exchange identifier
501    pub(crate) exch_id: ExchangeId,
502}
503
504impl SessionEstTimeout {
505    /// Create a new session establishment timeout tracker.
506    pub(crate) fn new(exchange: &Exchange) -> Self {
507        Self {
508            session_est_expiry: Instant::now().saturating_add(PASE_SESSION_EST_TIMEOUT_SECS),
509            exch_id: exchange.id(),
510        }
511    }
512
513    /// Check if the session establishment has expired.
514    pub(crate) fn is_sess_expired(&self) -> bool {
515        Instant::now() > self.session_est_expiry
516    }
517}
518
519/// The PBKDFParamRequest structure
520#[derive(FromTLV, ToTLV, Debug)]
521#[cfg_attr(feature = "defmt", derive(defmt::Format))]
522#[tlvargs(lifetime = "'a", start = 1)]
523pub(crate) struct PBKDFParamReq<'a> {
524    /// The initiator random bytes
525    pub initiator_random: OctetStr<'a>,
526    /// The initiator session identifier
527    pub initiator_ssid: u16,
528    /// The passcode identifier
529    pub passcode_id: u16,
530    /// Whether parameters are included
531    pub has_params: bool,
532    /// The session parameters, if any
533    pub session_parameters: Option<SessionParameters>,
534}
535
536/// The PBKDFParamResponse structure
537#[derive(FromTLV, ToTLV, Debug)]
538#[cfg_attr(feature = "defmt", derive(defmt::Format))]
539#[tlvargs(lifetime = "'a", start = 1)]
540pub(crate) struct PBKDFParamResp<'a> {
541    /// The initiator random bytes (echoed back)
542    pub initiator_random: OctetStr<'a>,
543    /// The responder random bytes
544    pub responder_random: OctetStr<'a>,
545    /// The responder session identifier
546    pub responder_ssid: u16,
547    /// The PBKDF2 parameters, if any
548    pub params: Option<PBKDFParamRespParams<'a>>,
549    /// The responder session parameters, if any
550    pub session_parameters: Option<crate::sc::SessionParameters>,
551}
552
553/// The PBKDFParamResponse parameters structure
554#[derive(FromTLV, ToTLV, Debug)]
555#[cfg_attr(feature = "defmt", derive(defmt::Format))]
556#[tlvargs(lifetime = "'a", start = 1)]
557pub(crate) struct PBKDFParamRespParams<'a> {
558    /// The iteration count
559    pub iterations: u32,
560    /// The salt bytes
561    pub salt: OctetStr<'a>,
562}
563
564/// TLV structure for Pake1 (sent by initiator)
565#[derive(FromTLV, ToTLV, Debug)]
566#[cfg_attr(feature = "defmt", derive(defmt::Format))]
567#[tlvargs(lifetime = "'a", start = 1)]
568pub(crate) struct Pake1<'a> {
569    /// The pA point (65 bytes, uncompressed P-256)
570    pub pa: OctetStr<'a>,
571}
572
573/// The Pake1Resp structure (Pake2 message from responder)
574#[derive(FromTLV, ToTLV, Debug)]
575#[cfg_attr(feature = "defmt", derive(defmt::Format))]
576#[tlvargs(lifetime = "'a", start = 1)]
577pub(crate) struct Pake2<'a> {
578    /// The pB bytes
579    pub pb: OctetStr<'a>,
580    /// The cB bytes
581    pub cb: OctetStr<'a>,
582}
583
584/// TLV structure for Pake3 (sent by initiator)
585#[derive(FromTLV, ToTLV, Debug)]
586#[cfg_attr(feature = "defmt", derive(defmt::Format))]
587#[tlvargs(lifetime = "'a", start = 1)]
588pub(crate) struct Pake3<'a> {
589    /// The cA confirmation (32 bytes HMAC)
590    pub ca: OctetStr<'a>,
591}