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/// A PASE commissioning window
88pub struct CommWindow {
89    /// The mDNS identifier
90    mdns_id: u64,
91    /// The discriminator
92    discriminator: u16,
93    /// The verifier data
94    pub(crate) verifier: Spake2pVerifierData,
95    /// The opener info
96    opener: Option<CommWindowOpener>,
97    /// The window expiry instant
98    window_expiry: Instant,
99    /// Number of failed PAKE handshake attempts within this window.
100    /// Per Matter Core spec, the window SHALL be
101    /// revoked after 20 unsuccessful handshakes.
102    pake_failures: u8,
103}
104
105impl CommWindow {
106    /// Initialize a commissioning window with a passcode
107    ///
108    /// # Arguments
109    /// - `mdns_id` - The mDNS identifier
110    /// - `password` - The passcode
111    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
112    /// - `discriminator` - The discriminator
113    /// - `opener` - The opener info
114    /// - `window_expiry` - The window expiry instant
115    /// - `rand` - The random number generator
116    fn init_with_pw<'a>(
117        mdns_id: u64,
118        password: Spake2pVerifierPasswordRef<'a>,
119        salt: &'a [u8],
120        discriminator: u16,
121        opener: Option<CommWindowOpener>,
122        window_expiry: Instant,
123    ) -> impl Init<Self> + 'a {
124        init!(Self {
125            mdns_id,
126            discriminator,
127            verifier <- Spake2pVerifierData::init_with_pw(password, salt),
128            opener,
129            window_expiry,
130            pake_failures: 0,
131        })
132    }
133
134    /// Initialize a commissioning window with a verifier
135    ///
136    /// # Arguments
137    /// - `verifier` - The verifier bytes
138    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
139    /// - `count` - The iteration count
140    /// - `discriminator` - The discriminator
141    /// - `opener` - The opener info
142    /// - `window_expiry` - The window expiry instant
143    fn init<'a>(
144        mdns_id: u64,
145        verifier: Spake2pVerifierStrRef<'a>,
146        salt: &'a [u8],
147        count: u32,
148        discriminator: u16,
149        opener: Option<CommWindowOpener>,
150        window_expiry: Instant,
151    ) -> impl Init<Self> + 'a {
152        init!(Self {
153            mdns_id,
154            discriminator,
155            verifier <- Spake2pVerifierData::init(verifier, salt, count),
156            opener,
157            window_expiry,
158            pake_failures: 0,
159        })
160    }
161
162    /// Get the type of commissioning window
163    pub fn comm_window_type(&self) -> CommWindowType {
164        if self.verifier.password.is_some() {
165            CommWindowType::Basic
166        } else {
167            CommWindowType::Enhanced
168        }
169    }
170
171    /// Get the opener info, if any
172    pub fn opener(&self) -> Option<CommWindowOpener> {
173        self.opener
174    }
175
176    /// Get the mDNS service info
177    pub fn mdns_service(&self) -> MatterLocalService {
178        MatterLocalService::Commissionable {
179            id: self.mdns_id,
180            discriminator: self.discriminator,
181            enhanced: matches!(self.comm_window_type(), CommWindowType::Enhanced),
182        }
183    }
184}
185
186/// The PASE state
187pub struct Pase {
188    /// The opened commissioning window, if any
189    comm_window: Maybe<CommWindow>,
190    /// The (one and only) PASE session timeout tracker
191    /// If there is no active PASE session, this is `None`
192    pub(crate) session_timeout: Option<SessionEstTimeout>,
193}
194
195impl Pase {
196    /// Create a new PASE state
197    #[inline(always)]
198    pub const fn new() -> Self {
199        Self {
200            comm_window: Maybe::none(),
201            session_timeout: None,
202        }
203    }
204
205    /// Return an in-place initializer for the PASE manager
206    pub fn init() -> impl Init<Self> {
207        init!(Self {
208            comm_window <- Maybe::init_none(),
209            session_timeout: None,
210        })
211    }
212
213    /// Check if the opened commissioning window has expired, and close it if so.
214    ///
215    /// This should be called periodically to ensure that the commissioning window state is updated in a timely manner.
216    /// 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.
217    pub fn check_comm_window_timeout(
218        &mut self,
219        notify_mdns: impl FnMut(),
220        notify_change: impl FnMut(EndptId, ClusterId),
221    ) -> Result<bool, Error> {
222        let expired = self
223            .comm_window
224            .as_opt_ref()
225            .map(|comm_window| Instant::now() > comm_window.window_expiry)
226            .unwrap_or(false);
227
228        if expired {
229            warn!("PASE Commissioning Window expired, closing");
230
231            self.close_comm_window(notify_mdns, notify_change)?;
232
233            Ok(true)
234        } else {
235            Ok(false)
236        }
237    }
238
239    /// Get the opened commissioning window, if any
240    pub fn comm_window(&self) -> Option<&CommWindow> {
241        self.comm_window.as_opt_ref()
242    }
243
244    /// Reject a salt that's outside the spec's 16..=32 B range
245    /// (Matter Core spec, Cryptographic Building Blocks).
246    fn validate_salt_len(salt: &[u8]) -> Result<(), Error> {
247        if !(SPAKE2P_VERIFIER_SALT_MIN_LEN..=SPAKE2P_VERIFIER_SALT_LEN).contains(&salt.len()) {
248            Err(ErrorCode::ConstraintError)?;
249        }
250
251        Ok(())
252    }
253
254    /// Open a basic commissioning window using a passcode
255    ///
256    /// # Arguments
257    /// - `mdns_id` - The mDNS identifier
258    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
259    /// - `password` - The passcode
260    /// - `discriminator` - The discriminator
261    /// - `timeout_secs` - The timeout in seconds of the validity of the window
262    /// - `opener` - The opener info
263    /// - `mdns_notif` - The mDNS notification callback
264    ///
265    /// # Returns
266    /// - `Ok(())` if the window was opened successfully
267    /// - `Err(Error)` if an error occurred
268    ///   (i.e. there is another non-expired commissioning window already opened
269    ///   or the timeout is invalid)
270    #[allow(clippy::too_many_arguments)]
271    pub fn open_basic_comm_window(
272        &mut self,
273        mdns_id: u64,
274        salt: &[u8],
275        password: Spake2pVerifierPasswordRef<'_>,
276        discriminator: u16,
277        timeout_secs: u16,
278        opener: Option<CommWindowOpener>,
279        mut notify_mdns: impl FnMut(),
280        mut notify_change: impl FnMut(EndptId, ClusterId),
281    ) -> Result<(), Error> {
282        if self.comm_window.is_some() {
283            Err(ErrorCode::Busy)?;
284        }
285
286        if !(MIN_COMM_WINDOW_TIMEOUT_SECS..=MAX_COMM_WINDOW_TIMEOUT_SECS).contains(&timeout_secs) {
287            Err(ErrorCode::InvalidCommand)?;
288        }
289
290        Self::validate_salt_len(salt)?;
291
292        let window_expiry = Instant::now().saturating_add(Duration::from_secs(timeout_secs as _));
293
294        self.comm_window
295            .reinit(Maybe::init_some(CommWindow::init_with_pw(
296                mdns_id,
297                password,
298                salt,
299                discriminator,
300                opener,
301                window_expiry,
302            )));
303
304        notify_mdns();
305        notify_adm_comm_window_attrs_changed(&mut notify_change);
306
307        info!("PASE Basic Commissioning Window opened");
308
309        Ok(())
310    }
311
312    /// Open an enhanced commissioning window using a verifier
313    ///
314    /// # Arguments
315    /// - `mdns_id` - The mDNS identifier
316    /// - `verifier` - The verifier bytes
317    /// - `salt` - The salt bytes (16..=32 bytes, validated upstream)
318    /// - `count` - The iteration count
319    /// - `discriminator` - The discriminator
320    /// - `timeout_secs` - The timeout in seconds of the validity of the window
321    /// - `opener` - The opener info
322    /// - `mdns_notif` - The mDNS notification callback
323    ///
324    /// # Returns
325    /// - `Ok(())` if the window was opened successfully
326    /// - `Err(Error)` if an error occurred
327    ///   (i.e. there is another non-expired commissioning window already opened
328    ///   or the timeout is invalid)
329    #[allow(clippy::too_many_arguments)]
330    pub fn open_comm_window(
331        &mut self,
332        mdns_id: u64,
333        verifier: Spake2pVerifierStrRef<'_>,
334        salt: &[u8],
335        count: u32,
336        discriminator: u16,
337        timeout_secs: u16,
338        opener: Option<CommWindowOpener>,
339        mut notify_mdns: impl FnMut(),
340        mut notify_change: impl FnMut(EndptId, ClusterId),
341    ) -> Result<(), Error> {
342        if self.comm_window.is_some() {
343            Err(ErrorCode::Busy)?;
344        }
345
346        if !(MIN_COMM_WINDOW_TIMEOUT_SECS..=MAX_COMM_WINDOW_TIMEOUT_SECS).contains(&timeout_secs) {
347            Err(ErrorCode::InvalidCommand)?;
348        }
349
350        Self::validate_salt_len(salt)?;
351
352        let window_expiry = Instant::now().saturating_add(Duration::from_secs(timeout_secs as _));
353
354        self.comm_window.reinit(Maybe::init_some(CommWindow::init(
355            mdns_id,
356            verifier,
357            salt,
358            count,
359            discriminator,
360            opener,
361            window_expiry,
362        )));
363
364        notify_mdns();
365        notify_adm_comm_window_attrs_changed(&mut notify_change);
366
367        info!("PASE Commissioning Window opened");
368
369        Ok(())
370    }
371
372    /// Record a failed PAKE handshake against the currently-open
373    /// commissioning window, and revoke the window if the limit is reached.
374    ///
375    /// Per Matter Core spec, after 20 unsuccessful PAKE
376    /// attempts the device SHALL revoke the open commissioning window.
377    ///
378    /// Also clears the in-progress PASE establishment timeout so that the
379    /// next handshake attempt is not rejected as "another session in
380    /// progress" — without this, only the first wrong-passcode attempt would
381    /// be counted because subsequent attempts would be short-circuited at the
382    /// session-timeout gate.
383    ///
384    /// Has no effect on the failure counter if no window is open.
385    pub fn record_pake_failure(
386        &mut self,
387        notify_mdns: impl FnMut(),
388        notify_change: impl FnMut(EndptId, ClusterId),
389    ) -> Result<(), Error> {
390        const MAX_PAKE_FAILURES: u8 = 20;
391
392        self.session_timeout = None;
393
394        let revoke = if let Some(window) = self.comm_window.as_opt_mut() {
395            window.pake_failures = window.pake_failures.saturating_add(1);
396            warn!(
397                "PASE Commissioning Window: PAKE failure {} of {}",
398                window.pake_failures, MAX_PAKE_FAILURES
399            );
400            window.pake_failures >= MAX_PAKE_FAILURES
401        } else {
402            false
403        };
404
405        if revoke {
406            warn!("PASE Commissioning Window revoked after too many failed PAKE attempts");
407            self.close_comm_window(notify_mdns, notify_change)?;
408        }
409
410        Ok(())
411    }
412
413    /// Close the opened commissioning window, if any
414    ///
415    /// # Arguments
416    /// - `ctx` - The handler context
417    ///
418    /// # Returns
419    /// - `Ok(true)` if a commissioning window was closed
420    /// - `Ok(false)` if there was no commissioning window to close
421    pub fn close_comm_window(
422        &mut self,
423        mut notify_mdns: impl FnMut(),
424        mut notify_change: impl FnMut(EndptId, ClusterId),
425    ) -> Result<bool, Error> {
426        if self.comm_window.is_some() {
427            self.comm_window.clear();
428
429            notify_mdns();
430            notify_adm_comm_window_attrs_changed(&mut notify_change);
431
432            info!("PASE Commissioning Window closed");
433
434            Ok(true)
435        } else {
436            warn!("No PASE Commissioning Window to close");
437
438            Ok(false)
439        }
440    }
441}
442
443impl Default for Pase {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449/// The timeout tracker for a PASE session establishment
450const PASE_SESSION_EST_TIMEOUT_SECS: Duration = Duration::from_secs(60);
451
452/// The info string for SPAKE2 session key derivation
453pub(crate) const SPAKE2_SESSION_KEYS_INFO: &[u8] = b"SessionKeys";
454
455/// The PASE session establishment timeout tracker
456pub(crate) struct SessionEstTimeout {
457    /// The session expiry instant
458    session_est_expiry: Instant,
459    /// The exchange identifier
460    pub(crate) exch_id: ExchangeId,
461}
462
463impl SessionEstTimeout {
464    /// Create a new session establishment timeout tracker.
465    pub(crate) fn new(exchange: &Exchange) -> Self {
466        Self {
467            session_est_expiry: Instant::now().saturating_add(PASE_SESSION_EST_TIMEOUT_SECS),
468            exch_id: exchange.id(),
469        }
470    }
471
472    /// Check if the session establishment has expired.
473    pub(crate) fn is_sess_expired(&self) -> bool {
474        Instant::now() > self.session_est_expiry
475    }
476}
477
478/// The PBKDFParamRequest structure
479#[derive(FromTLV, ToTLV, Debug)]
480#[cfg_attr(feature = "defmt", derive(defmt::Format))]
481#[tlvargs(lifetime = "'a", start = 1)]
482pub(crate) struct PBKDFParamReq<'a> {
483    /// The initiator random bytes
484    pub initiator_random: OctetStr<'a>,
485    /// The initiator session identifier
486    pub initiator_ssid: u16,
487    /// The passcode identifier
488    pub passcode_id: u16,
489    /// Whether parameters are included
490    pub has_params: bool,
491    /// The session parameters, if any
492    pub session_parameters: Option<SessionParameters>,
493}
494
495/// The PBKDFParamResponse structure
496#[derive(FromTLV, ToTLV, Debug)]
497#[cfg_attr(feature = "defmt", derive(defmt::Format))]
498#[tlvargs(lifetime = "'a", start = 1)]
499pub(crate) struct PBKDFParamResp<'a> {
500    /// The initiator random bytes (echoed back)
501    pub initiator_random: OctetStr<'a>,
502    /// The responder random bytes
503    pub responder_random: OctetStr<'a>,
504    /// The responder session identifier
505    pub responder_ssid: u16,
506    /// The PBKDF2 parameters, if any
507    pub params: Option<PBKDFParamRespParams<'a>>,
508    /// The responder session parameters, if any
509    pub session_parameters: Option<crate::sc::SessionParameters>,
510}
511
512/// The PBKDFParamResponse parameters structure
513#[derive(FromTLV, ToTLV, Debug)]
514#[cfg_attr(feature = "defmt", derive(defmt::Format))]
515#[tlvargs(lifetime = "'a", start = 1)]
516pub(crate) struct PBKDFParamRespParams<'a> {
517    /// The iteration count
518    pub iterations: u32,
519    /// The salt bytes
520    pub salt: OctetStr<'a>,
521}
522
523/// TLV structure for Pake1 (sent by initiator)
524#[derive(FromTLV, ToTLV, Debug)]
525#[cfg_attr(feature = "defmt", derive(defmt::Format))]
526#[tlvargs(lifetime = "'a", start = 1)]
527pub(crate) struct Pake1<'a> {
528    /// The pA point (65 bytes, uncompressed P-256)
529    pub pa: OctetStr<'a>,
530}
531
532/// The Pake1Resp structure (Pake2 message from responder)
533#[derive(FromTLV, ToTLV, Debug)]
534#[cfg_attr(feature = "defmt", derive(defmt::Format))]
535#[tlvargs(lifetime = "'a", start = 1)]
536pub(crate) struct Pake2<'a> {
537    /// The pB bytes
538    pub pb: OctetStr<'a>,
539    /// The cB bytes
540    pub cb: OctetStr<'a>,
541}
542
543/// TLV structure for Pake3 (sent by initiator)
544#[derive(FromTLV, ToTLV, Debug)]
545#[cfg_attr(feature = "defmt", derive(defmt::Format))]
546#[tlvargs(lifetime = "'a", start = 1)]
547pub(crate) struct Pake3<'a> {
548    /// The cA confirmation (32 bytes HMAC)
549    pub ca: OctetStr<'a>,
550}