Skip to main content

matter_crypto/pase/
prover.rs

1//! Commissioner-side PASE state machine.
2//!
3//! Drives the 5-message PASE handshake (or 3-message known-params path)
4//! from the commissioner's perspective. Sans-IO: the caller feeds raw TLV
5//! bytes in and gets raw TLV bytes out; no sockets, no async, no I/O.
6//!
7//! # Protocol flow (negotiation path — Matter Core Spec §3.10.5)
8//!
9//! ```text
10//! Commissioner (Prover)          Device (Verifier)
11//! ───────────────────────────────────────────────
12//! start()
13//!   → PBKDFParamRequest  ────────────────────────>
14//!                        <──────── PBKDFParamResponse
15//! handle_pbkdf_response()
16//! next_message()  [Pake1]
17//!   → Pake1              ────────────────────────>
18//!                        <──────── Pake2
19//! handle_pake2()
20//! next_message()  [Pake3]
21//!   → Pake3              ────────────────────────>
22//!                        <──────── StatusReport: Success
23//! finish() → PaseSessionKeys
24//! ```
25//!
26//! # Known-params path
27//!
28//! When the commissioner already has the PBKDF params cached it uses
29//! `new_with_known_params`, which skips to Pake1 directly:
30//!
31//! ```text
32//! start()  → Pake1  →  handle_pake2()  →  next_message() [Pake3]
33//! ```
34//!
35//! # Transcript context composition (matter.js pin)
36//!
37//! `PaseClient.ts` line 89:
38//! ```ts
39//! context = await crypto.computeHash([SPAKE_CONTEXT, requestPayload, responsePayload])
40//! ```
41//! where `SPAKE_CONTEXT = Bytes.fromString("CHIP PAKE V1 Commissioning")`.
42//! `computeHash([...])` concatenates the arrays and SHA-256-hashes the result,
43//! so `context = SHA-256("CHIP PAKE V1 Commissioning" || pbkdfReq_bytes || pbkdfResp_bytes)`.
44//!
45//! For the known-params path there is no `PBKDFParam` exchange; matter.js does not
46//! implement this path in `PaseClient.ts` (it always negotiates). We follow the
47//! same SHA-256 construction but with zero extra bytes:
48//! `context = SHA-256("CHIP PAKE V1 Commissioning")`.
49//! This will be verified in M3.3 if/when the known-params path is tested against
50//! matter.js.
51
52use ring::rand::{SecureRandom, SystemRandom};
53
54use crate::error::{Error, Result};
55use crate::pase::kdf::{derive_w0_w1, validate_params};
56use crate::pase::messages::{Pake1, Pake2, Pake3, PbkdfParamRequest, PbkdfParamResponse};
57use crate::pase::spake2plus::{
58    compute_ca, compute_cb, compute_x, compute_z_v_prover, derive_confirmation_keys,
59    derive_session_keys, hash_context, ka_ke_from_transcript, sample_scalar, transcript_hash,
60    verify_tag,
61};
62use crate::pase::{PaseMessageKind, PasePbkdfParams, PaseSessionKeys};
63use zeroize::Zeroize;
64
65// =============================================================================
66// Internal state enum
67// =============================================================================
68
69/// Internal states of the commissioner-side PASE handshake.
70///
71/// Each variant corresponds to a point in the protocol flow defined in
72/// Matter Core Spec §3.10.5. Named for the *next action* the state machine
73/// expects or is ready to perform.
74#[derive(Debug)]
75enum State {
76    /// `start()` has not been called yet — negotiation path.
77    ///
78    /// Holds the pre-sampled x scalar and nonce so that `start()` is
79    /// infallible after construction succeeds.
80    AwaitingStartNegotiation {
81        pin: u32,
82        x_scalar: p256::Scalar,
83        initiator_random: [u8; 32],
84        /// Session ID to include in the `PBKDFParamRequest`. Caller-supplied,
85        /// passed through from `new_with_negotiation` (or a fixture value via
86        /// the test-only `new_with_negotiation_with_scalar_and_session_id`).
87        initiator_session_id: u16,
88    },
89
90    /// `start()` has not been called yet — known-params path.
91    AwaitingStartKnownParams {
92        pin: u32,
93        params: PasePbkdfParams,
94        x_scalar: p256::Scalar,
95    },
96
97    /// `start()` sent `PBKDFParamRequest`; waiting for `PBKDFParamResponse`.
98    ///
99    /// `sent_request_bytes` is the verbatim TLV bytes of the request we sent,
100    /// needed to compose the transcript context hash.
101    AwaitingPbkdfResponse {
102        pin: u32,
103        x_scalar: p256::Scalar,
104        sent_request_bytes: Vec<u8>,
105    },
106
107    /// `handle_pbkdf_response()` has processed the response; `next_message()`
108    /// will derive w0/w1, compute X, and emit Pake1.
109    ReadyToSendPake1 {
110        pin: u32,
111        params: PasePbkdfParams,
112        x_scalar: p256::Scalar,
113        /// SHA-256 context hash: SHA-256(SPAKE_CONTEXT || pbkdfReq || pbkdfResp).
114        transcript_context: [u8; 32],
115    },
116
117    /// Pake1 sent; waiting for Pake2.
118    AwaitingPake2 {
119        w0: p256::Scalar,
120        w1: p256::Scalar,
121        x_scalar: p256::Scalar,
122        x_bytes: [u8; 65],
123        /// SHA-256 context hash to pass into `transcript_hash`.
124        transcript_context: [u8; 32],
125    },
126
127    /// Pake2 verified; `next_message()` will emit Pake3.
128    ReadyToSendPake3 {
129        /// Our cA confirmation tag, already computed.
130        ca: [u8; 32],
131        session_keys: PaseSessionKeys,
132    },
133
134    /// Pake3 sent; `finish()` may be called.
135    Complete { session_keys: PaseSessionKeys },
136
137    /// Sentinel used during `std::mem::replace` state transitions.
138    ///
139    /// This variant is **never observable** to callers: every `mem::replace`
140    /// immediately replaces `Poisoned` with the next real state, or returns
141    /// an error before storing it. If somehow reached, all methods return
142    /// `Error::HandshakeIncomplete`.
143    Poisoned,
144}
145
146// =============================================================================
147// PaseProver
148// =============================================================================
149
150/// Commissioner-side PASE state machine.
151///
152/// Drives the SPAKE2+ handshake from the commissioner's (initiator's)
153/// perspective. Sans-IO: the caller is responsible for transmitting and
154/// receiving bytes.
155///
156/// # Construction
157///
158/// - [`PaseProver::new_with_negotiation`] — sends `PBKDFParamRequest` first
159///   (the normal path when PBKDF params are not cached).
160/// - [`PaseProver::new_with_known_params`] — skips negotiation; first message
161///   is Pake1 (when PBKDF params are already known from a prior session).
162///
163/// # Driving the handshake
164///
165/// 1. Call [`start`][Self::start] to get the first outbound message bytes.
166/// 2. Feed inbound bytes into [`handle_pbkdf_response`][Self::handle_pbkdf_response]
167///    (negotiation path) or skip to step 3 (known-params path).
168/// 3. Call [`next_message`][Self::next_message] to get Pake1 bytes.
169/// 4. Feed inbound Pake2 bytes into [`handle_pake2`][Self::handle_pake2].
170/// 5. Call [`next_message`][Self::next_message] to get Pake3 bytes.
171/// 6. After the peer confirms success, call [`finish`][Self::finish] to
172///    retrieve the [`PaseSessionKeys`].
173///
174/// Use [`expected_inbound`][Self::expected_inbound] at any point to query
175/// which message type the machine is currently waiting for.
176pub struct PaseProver {
177    state: State,
178    /// The responder's advertised session id, captured from
179    /// `PBKDFParamResponse`. `None` until [`Self::handle_pbkdf_response`]
180    /// is called (and always `None` on the known-params path, which
181    /// exchanges no PBKDF messages).
182    responder_session_id: Option<u16>,
183}
184
185impl PaseProver {
186    // ─── Public constructors ──────────────────────────────────────────────
187
188    /// Construct a prover that negotiates PBKDF parameters (sends
189    /// `PBKDFParamRequest` first).
190    ///
191    /// `initiator_session_id` is the non-zero secured-session id this
192    /// commissioner advertises for the peer to address us by. It is included
193    /// in the `PBKDFParamRequest` wire message and hashed into the SPAKE2+
194    /// transcript, so it must be fixed before [`start`][Self::start] is called.
195    ///
196    /// Pre-samples the SPAKE2+ `x` scalar and the 32-byte initiator nonce
197    /// so that [`start`][Self::start] cannot fail due to randomness.
198    ///
199    /// # Errors
200    ///
201    /// - [`Error::InvalidScalar`] if the CSPRNG is broken and a non-zero
202    ///   scalar cannot be sampled after 16 attempts (practically impossible).
203    /// - [`Error::PinDerivationFailed`] if the nonce fill fails.
204    pub fn new_with_negotiation(pin: u32, initiator_session_id: u16) -> Result<Self> {
205        let rng = SystemRandom::new();
206        Self::new_with_negotiation_using_rng(pin, initiator_session_id, &rng)
207    }
208
209    /// Deterministic constructor for testing — accepts an injectable RNG.
210    ///
211    /// Production code should always use [`new_with_negotiation`][Self::new_with_negotiation].
212    pub(crate) fn new_with_negotiation_using_rng(
213        pin: u32,
214        initiator_session_id: u16,
215        rng: &dyn SecureRandom,
216    ) -> Result<Self> {
217        let x_scalar = sample_scalar(rng)?;
218        let mut initiator_random = [0u8; 32];
219        rng.fill(&mut initiator_random)
220            .map_err(|_| Error::PinDerivationFailed)?;
221        Ok(Self {
222            state: State::AwaitingStartNegotiation {
223                pin,
224                x_scalar,
225                initiator_random,
226                initiator_session_id,
227            },
228            responder_session_id: None,
229        })
230    }
231
232    /// Deterministic constructor for testing — injects fixed `x` scalar and
233    /// `initiator_random` bytes directly, bypassing the RNG.
234    ///
235    /// Used by `test_support` to construct a prover with known values for
236    /// matter.js byte-parity tests. `x_scalar_bytes` must be a valid non-zero
237    /// P-256 scalar in big-endian representation.
238    ///
239    /// Production code should always use [`new_with_negotiation`][Self::new_with_negotiation].
240    ///
241    /// # Errors
242    ///
243    /// - [`Error::InvalidScalar`] if `x_scalar_bytes` is zero or not a valid
244    ///   P-256 scalar (i.e., ≥ curve order).
245    pub(crate) fn new_with_negotiation_with_scalar(
246        pin: u32,
247        x_scalar_bytes: [u8; 32],
248        initiator_random: [u8; 32],
249    ) -> Result<Self> {
250        Self::new_with_negotiation_with_scalar_and_session_id(
251            pin,
252            x_scalar_bytes,
253            initiator_random,
254            0,
255        )
256    }
257
258    /// Deterministic constructor for testing — injects fixed `x` scalar,
259    /// `initiator_random`, and `initiator_session_id` directly.
260    ///
261    /// Used by `test_support` to reproduce matter.js fixture handshakes that
262    /// use a specific session ID in `PBKDFParamRequest`. Production code always
263    /// uses [`new_with_negotiation`][Self::new_with_negotiation].
264    ///
265    /// # Errors
266    ///
267    /// - [`Error::InvalidScalar`] if `x_scalar_bytes` is zero or not a valid
268    ///   P-256 scalar (i.e., ≥ curve order).
269    pub(crate) fn new_with_negotiation_with_scalar_and_session_id(
270        pin: u32,
271        x_scalar_bytes: [u8; 32],
272        initiator_random: [u8; 32],
273        initiator_session_id: u16,
274    ) -> Result<Self> {
275        use p256::elliptic_curve::group::ff::{Field, PrimeField};
276        let x_scalar_opt: Option<p256::Scalar> =
277            p256::Scalar::from_repr(p256::FieldBytes::from(x_scalar_bytes)).into();
278        let x_scalar = x_scalar_opt.ok_or(Error::InvalidScalar)?;
279        if bool::from(x_scalar.is_zero()) {
280            return Err(Error::InvalidScalar);
281        }
282        Ok(Self {
283            state: State::AwaitingStartNegotiation {
284                pin,
285                x_scalar,
286                initiator_random,
287                initiator_session_id,
288            },
289            responder_session_id: None,
290        })
291    }
292
293    /// Construct a prover with PBKDF parameters already known (skips negotiation;
294    /// first message is Pake1).
295    ///
296    /// Validates `params` against Matter spec §3.10.3 bounds before accepting.
297    ///
298    /// `initiator_session_id` is accepted for API symmetry with
299    /// [`new_with_negotiation`][Self::new_with_negotiation] but is **unused** on
300    /// this path: the known-params flow sends no `PBKDFParamRequest`, so the id
301    /// never reaches the wire or the transcript, and
302    /// [`responder_session_id`][Self::responder_session_id] will always be
303    /// `None`. (Secured-session-id negotiation requires the negotiation path,
304    /// which the commissioning driver uses.)
305    ///
306    /// # Errors
307    ///
308    /// - [`Error::PbkdfIterationsTooLow`] if `params.iterations < 1000`.
309    /// - [`Error::PbkdfSaltLengthInvalid`] if `params.salt.len()` ∉ \[16, 32\].
310    /// - [`Error::InvalidScalar`] if the CSPRNG is broken.
311    pub fn new_with_known_params(
312        pin: u32,
313        params: PasePbkdfParams,
314        initiator_session_id: u16,
315    ) -> Result<Self> {
316        validate_params(params.iterations, &params.salt)?;
317        let rng = SystemRandom::new();
318        Self::new_with_known_params_using_rng(pin, params, initiator_session_id, &rng)
319    }
320
321    /// Deterministic constructor for testing — accepts an injectable RNG.
322    ///
323    /// Production code should always use
324    /// [`new_with_known_params`][Self::new_with_known_params].
325    pub(crate) fn new_with_known_params_using_rng(
326        pin: u32,
327        params: PasePbkdfParams,
328        _initiator_session_id: u16,
329        rng: &dyn SecureRandom,
330    ) -> Result<Self> {
331        validate_params(params.iterations, &params.salt)?;
332        let x_scalar = sample_scalar(rng)?;
333        // _initiator_session_id is not used on the known-params path (no
334        // PBKDFParamRequest is sent), but we accept it for API symmetry.
335        Ok(Self {
336            state: State::AwaitingStartKnownParams {
337                pin,
338                params,
339                x_scalar,
340            },
341            responder_session_id: None,
342        })
343    }
344
345    /// Deterministic constructor for testing — injects a fixed `x` scalar
346    /// directly, bypassing the RNG.
347    ///
348    /// Used by `test_support` to construct a prover with a known scalar for
349    /// matter.js byte-parity tests. `x_scalar_bytes` must be a valid non-zero
350    /// P-256 scalar in big-endian representation.
351    ///
352    /// Production code should always use
353    /// [`new_with_known_params`][Self::new_with_known_params].
354    ///
355    /// # Errors
356    ///
357    /// - [`Error::PbkdfIterationsTooLow`] if `params.iterations < 1000`.
358    /// - [`Error::PbkdfSaltLengthInvalid`] if `params.salt.len()` ∉ \[16, 32\].
359    /// - [`Error::InvalidScalar`] if `x_scalar_bytes` is zero or not a valid
360    ///   P-256 scalar.
361    pub(crate) fn new_with_known_params_with_scalar(
362        pin: u32,
363        params: PasePbkdfParams,
364        x_scalar_bytes: [u8; 32],
365    ) -> Result<Self> {
366        use p256::elliptic_curve::group::ff::{Field, PrimeField};
367        validate_params(params.iterations, &params.salt)?;
368        let x_scalar_opt: Option<p256::Scalar> =
369            p256::Scalar::from_repr(p256::FieldBytes::from(x_scalar_bytes)).into();
370        let x_scalar = x_scalar_opt.ok_or(Error::InvalidScalar)?;
371        if bool::from(x_scalar.is_zero()) {
372            return Err(Error::InvalidScalar);
373        }
374        Ok(Self {
375            state: State::AwaitingStartKnownParams {
376                pin,
377                params,
378                x_scalar,
379            },
380            responder_session_id: None,
381        })
382    }
383
384    // ─── State inspection ─────────────────────────────────────────────────
385
386    /// Returns the message kind the state machine is currently waiting to
387    /// receive, or `None` if the machine is in an outbound-only state
388    /// (waiting to emit a message) or has completed / been poisoned.
389    pub fn expected_inbound(&self) -> Option<PaseMessageKind> {
390        match &self.state {
391            State::AwaitingPbkdfResponse { .. } => Some(PaseMessageKind::PbkdfParamResponse),
392            State::AwaitingPake2 { .. } => Some(PaseMessageKind::Pake2),
393            _ => None,
394        }
395    }
396
397    /// The responder's advertised secured-session id, captured from
398    /// `PBKDFParamResponse`. `None` before [`Self::handle_pbkdf_response`]
399    /// (and on the known-params path, which exchanges no PBKDF messages).
400    #[must_use]
401    pub fn responder_session_id(&self) -> Option<u16> {
402        self.responder_session_id
403    }
404
405    // ─── Handshake methods ────────────────────────────────────────────────
406
407    /// Produce the first outbound message.
408    ///
409    /// - Negotiation path: emits `PBKDFParamRequest` TLV bytes.
410    /// - Known-params path: derives w0/w1, computes X, emits Pake1 TLV bytes.
411    ///
412    /// May only be called once, from the initial state. Repeated calls or
413    /// calls from any later state return [`Error::UnexpectedMessage`].
414    ///
415    /// # Errors
416    ///
417    /// - [`Error::UnexpectedMessage`] if called from the wrong state.
418    /// - [`Error::Codec`] on TLV encoding failure.
419    /// - [`Error::PinDerivationFailed`] / [`Error::PbkdfIterationsTooLow`] /
420    ///   [`Error::PbkdfSaltLengthInvalid`] on KDF failure (known-params path).
421    pub fn start(&mut self) -> Result<Vec<u8>> {
422        let prev = std::mem::replace(&mut self.state, State::Poisoned);
423        match prev {
424            State::AwaitingStartNegotiation {
425                pin,
426                x_scalar,
427                initiator_random,
428                initiator_session_id,
429            } => {
430                // §3.10.5 step 1: commissioner sends PBKDFParamRequest.
431                // passcode_id=0 per spec defaults for the negotiation path.
432                // initiator_session_id is the caller-supplied value (the
433                // secured-session id we advertise for the peer to address us by).
434                let req = PbkdfParamRequest {
435                    initiator_random,
436                    initiator_session_id,
437                    passcode_id: 0,
438                    has_pbkdf_parameters: false,
439                    initiator_session_params: None,
440                };
441                let bytes = req.encode()?;
442                self.state = State::AwaitingPbkdfResponse {
443                    pin,
444                    x_scalar,
445                    sent_request_bytes: bytes.clone(),
446                };
447                Ok(bytes)
448            }
449
450            State::AwaitingStartKnownParams {
451                pin,
452                params,
453                x_scalar,
454            } => {
455                // §3.10.5 known-params shortcut: skip param negotiation.
456                // context = SHA-256("CHIP PAKE V1 Commissioning") — no pbkdfReq/Resp to fold in.
457                let transcript_context = hash_context(&[]);
458                let (w0, w1) = derive_w0_w1(pin, &params.salt, params.iterations)?;
459                let x_bytes = compute_x(&x_scalar, &w0);
460                let pake1_bytes = Pake1 { x: x_bytes }.encode()?;
461                self.state = State::AwaitingPake2 {
462                    w0,
463                    w1,
464                    x_scalar,
465                    x_bytes,
466                    transcript_context,
467                };
468                Ok(pake1_bytes)
469            }
470
471            other => {
472                self.state = other;
473                Err(Error::UnexpectedMessage {
474                    expected: PaseMessageKind::PbkdfParamRequest,
475                    got: PaseMessageKind::PbkdfParamRequest,
476                })
477            }
478        }
479    }
480
481    /// Process an inbound `PBKDFParamResponse` message.
482    ///
483    /// Decodes the response, validates the PBKDF parameters, and composes
484    /// the transcript context as `SHA-256(SPAKE_CONTEXT || pbkdfReq || pbkdfResp)`.
485    ///
486    /// After this call, [`next_message`][Self::next_message] emits Pake1.
487    ///
488    /// # Errors
489    ///
490    /// - [`Error::UnexpectedMessage`] if called from the wrong state.
491    /// - [`Error::InvalidParameter`] if the response is malformed or missing
492    ///   the required `pbkdf_parameters` field.
493    /// - [`Error::PbkdfIterationsTooLow`] / [`Error::PbkdfIterationsTooHigh`] /
494    ///   [`Error::PbkdfSaltLengthInvalid`] if the responder's parameters are
495    ///   out of spec. The too-high case caps a peer-supplied iteration count
496    ///   to prevent a commissioner CPU denial-of-service.
497    /// - [`Error::Codec`] on TLV decoding failure.
498    pub fn handle_pbkdf_response(&mut self, bytes: &[u8]) -> Result<()> {
499        let prev = std::mem::replace(&mut self.state, State::Poisoned);
500        match prev {
501            State::AwaitingPbkdfResponse {
502                pin,
503                x_scalar,
504                sent_request_bytes,
505            } => {
506                // §3.10.5 step 2: decode and validate the response.
507                let resp = PbkdfParamResponse::decode(bytes)?;
508
509                // Capture the responder's advertised session id so callers can
510                // retrieve it via `responder_session_id()`.
511                self.responder_session_id = Some(resp.responder_session_id);
512
513                // The responder MUST include pbkdf_parameters when we set
514                // has_pbkdf_parameters=false (§3.10.5). If absent, abort.
515                let params_inner = resp.pbkdf_parameters.ok_or(Error::InvalidParameter)?;
516                let params = PasePbkdfParams {
517                    iterations: params_inner.iterations,
518                    salt: params_inner.salt,
519                };
520                validate_params(params.iterations, &params.salt)?;
521
522                // §3.10.5 — compose transcript context.
523                // matter.js PaseClient.ts line 89:
524                //   context = SHA-256("CHIP PAKE V1 Commissioning" || pbkdfReq_bytes || pbkdfResp_bytes)
525                let transcript_context = hash_context(&[&sent_request_bytes, bytes]);
526
527                self.state = State::ReadyToSendPake1 {
528                    pin,
529                    params,
530                    x_scalar,
531                    transcript_context,
532                };
533                Ok(())
534            }
535
536            other => {
537                self.state = other;
538                Err(Error::UnexpectedMessage {
539                    expected: PaseMessageKind::PbkdfParamResponse,
540                    got: PaseMessageKind::PbkdfParamResponse,
541                })
542            }
543        }
544    }
545
546    /// Process an inbound `Pake2` message.
547    ///
548    /// Performs the SPAKE2+ cryptographic operations:
549    /// 1. Decode Y from the Pake2 TLV.
550    /// 2. Compute Z and V (the shared point values).
551    /// 3. Compute the transcript hash `TT_HASH`.
552    /// 4. Derive confirmation keys `KcA`, `KcB`.
553    /// 5. Verify the device's confirmation tag `cB` in constant time via
554    ///    `verify_tag` (subtle CT-EQ, never `==`).
555    /// 6. Compute our confirmation tag `cA`.
556    /// 7. Derive session keys.
557    ///
558    /// After this call, [`next_message`][Self::next_message] emits Pake3.
559    ///
560    /// # Security
561    ///
562    /// Tag comparison at step 5 MUST be constant-time. This is enforced by
563    /// routing through `verify_tag` (in `pase::spake2plus`) which uses `subtle::ConstantTimeEq`.
564    ///
565    /// # Errors
566    ///
567    /// - [`Error::UnexpectedMessage`] if called from the wrong state.
568    /// - [`Error::InvalidParameter`] if Y is not a valid P-256 point.
569    /// - [`Error::ConfirmationTagMismatch`] if the device's `cB` tag fails
570    ///   constant-time verification (wrong PIN or compromised peer).
571    /// - [`Error::Codec`] on TLV decoding failure.
572    /// - [`Error::PinDerivationFailed`] on HKDF failure.
573    // kca / kcb are the SPAKE2+ confirmation key names from Matter
574    // Core Spec §3.10 (RFC 9383 §3.3). Renaming would impair
575    // verification against the spec.
576    #[allow(clippy::similar_names)]
577    pub fn handle_pake2(&mut self, bytes: &[u8]) -> Result<()> {
578        let prev = std::mem::replace(&mut self.state, State::Poisoned);
579        match prev {
580            State::AwaitingPake2 {
581                w0,
582                w1,
583                x_scalar,
584                x_bytes,
585                transcript_context,
586            } => {
587                let pake2 = Pake2::decode(bytes)?;
588
589                // §3.10.5 — commissioner side:
590                //   Z = x · (Y − w0·N)
591                //   V = w1 · (Y − w0·N)
592                let (z_bytes, v_bytes) = compute_z_v_prover(&x_scalar, &w0, &w1, &pake2.y)?;
593
594                // Transcript hash: SHA-256 over all protocol elements.
595                // context is already SHA-256(SPAKE_CONTEXT || pbkdfReq || pbkdfResp).
596                let t_t = transcript_hash(
597                    &transcript_context,
598                    &x_bytes,
599                    &pake2.y,
600                    &z_bytes,
601                    &v_bytes,
602                    &w0,
603                );
604
605                // Split Ka (first 16) and Ke (last 16). Both are SPAKE2+ secrets
606                // (`ke` is the root session secret); zeroize them after their last
607                // use so no copy lingers on the stack.
608                let (mut ka, mut ke) = ka_ke_from_transcript(&t_t);
609
610                // Derive KcA and KcB from Ka.
611                let (kca, kcb) = derive_confirmation_keys(&ka)?;
612                ka.zeroize();
613
614                // Verify the device's confirmation tag cB = HMAC-SHA256(KcB, X).
615                // MUST use constant-time comparison.
616                let cb_expected = compute_cb(&kcb, &x_bytes);
617                verify_tag(&cb_expected, &pake2.verifier)?;
618
619                // Compute our confirmation tag cA = HMAC-SHA256(KcA, Y).
620                let ca = compute_ca(&kca, &pake2.y);
621
622                // Derive 48-byte session key material from Ke.
623                // `build_session_keys` copies `ke` into the `ZeroizeOnDrop`
624                // `PaseSessionKeys`; wipe our local copy afterwards.
625                let session_keys_blob = derive_session_keys(&ke)?;
626                let session_keys = build_session_keys(ke, &session_keys_blob);
627                ke.zeroize();
628
629                self.state = State::ReadyToSendPake3 { ca, session_keys };
630                Ok(())
631            }
632
633            other => {
634                self.state = other;
635                Err(Error::UnexpectedMessage {
636                    expected: PaseMessageKind::Pake2,
637                    got: PaseMessageKind::Pake2,
638                })
639            }
640        }
641    }
642
643    /// Produce the next outbound message.
644    ///
645    /// - After [`handle_pbkdf_response`][Self::handle_pbkdf_response]: emits Pake1.
646    /// - After [`handle_pake2`][Self::handle_pake2]: emits Pake3.
647    ///
648    /// # Errors
649    ///
650    /// - [`Error::UnexpectedMessage`] if called from the wrong state.
651    /// - [`Error::Codec`] on TLV encoding failure.
652    /// - [`Error::PinDerivationFailed`] / [`Error::PbkdfIterationsTooLow`] /
653    ///   [`Error::PbkdfSaltLengthInvalid`] on KDF failure (Pake1 path only).
654    pub fn next_message(&mut self) -> Result<Vec<u8>> {
655        let prev = std::mem::replace(&mut self.state, State::Poisoned);
656        match prev {
657            State::ReadyToSendPake1 {
658                pin,
659                params,
660                x_scalar,
661                transcript_context,
662            } => {
663                // §3.10.5 step 3: derive w0/w1 from PIN, compute X, send Pake1.
664                let (w0, w1) = derive_w0_w1(pin, &params.salt, params.iterations)?;
665                let x_bytes = compute_x(&x_scalar, &w0);
666                let pake1_bytes = Pake1 { x: x_bytes }.encode()?;
667                self.state = State::AwaitingPake2 {
668                    w0,
669                    w1,
670                    x_scalar,
671                    x_bytes,
672                    transcript_context,
673                };
674                Ok(pake1_bytes)
675            }
676
677            State::ReadyToSendPake3 { ca, session_keys } => {
678                // §3.10.5 step 5: send our confirmation tag cA in Pake3.
679                let pake3_bytes = Pake3 { verifier: ca }.encode()?;
680                self.state = State::Complete { session_keys };
681                Ok(pake3_bytes)
682            }
683
684            other => {
685                self.state = other;
686                Err(Error::UnexpectedMessage {
687                    expected: PaseMessageKind::Pake1,
688                    got: PaseMessageKind::Pake1,
689                })
690            }
691        }
692    }
693
694    /// Finalise the session and retrieve the derived session keys.
695    ///
696    /// May only be called after [`next_message`][Self::next_message] has
697    /// emitted Pake3 (i.e., the state machine is in the `Complete` state).
698    ///
699    /// # Errors
700    ///
701    /// - [`Error::HandshakeIncomplete`] if called before the handshake has
702    ///   completed all phases.
703    pub fn finish(self) -> Result<PaseSessionKeys> {
704        match self.state {
705            State::Complete { session_keys } => Ok(session_keys),
706            _ => Err(Error::HandshakeIncomplete),
707        }
708    }
709}
710
711// =============================================================================
712// Internal helpers
713// =============================================================================
714
715/// Build a [`PaseSessionKeys`] from `Ke` and the 48-byte derived key blob.
716///
717/// Key assignment for commissioner (= initiator), per matter.js `NodeSession.ts`:
718/// ```ts
719/// const decryptKey = isInitiator ? keys.slice(16, 32) : keys.slice(0, 16);
720/// const encryptKey = isInitiator ? keys.slice(0, 16)  : keys.slice(16, 32);
721/// const attestationKey = keys.slice(32, 48);
722/// ```
723///
724/// - `i2r_key` = `encryptKey` (commissioner encrypts for the device).
725/// - `r2i_key` = `decryptKey` (commissioner decrypts incoming from device).
726fn build_session_keys(ke: [u8; 16], blob_48: &[u8; 48]) -> PaseSessionKeys {
727    let mut i2r_key = [0u8; 16];
728    let mut r2i_key = [0u8; 16];
729    let mut attestation_key = [0u8; 16];
730
731    // Commissioner = initiator:
732    //   encryptKey (i2r) = blob[0..16]
733    //   decryptKey (r2i) = blob[16..32]
734    i2r_key.copy_from_slice(&blob_48[0..16]);
735    r2i_key.copy_from_slice(&blob_48[16..32]);
736    attestation_key.copy_from_slice(&blob_48[32..48]);
737
738    PaseSessionKeys {
739        ke,
740        i2r_key,
741        r2i_key,
742        attestation_key,
743    }
744}
745
746// =============================================================================
747// Tests
748// =============================================================================
749
750#[cfg(test)]
751#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
752mod tests {
753    use super::*;
754    use crate::pase::messages::{PbkdfParamResponse, PbkdfParamsInner};
755
756    // ─── Construction ─────────────────────────────────────────────────────
757
758    #[test]
759    fn new_with_negotiation_accepts_any_pin() {
760        let _ = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
761        let _ = PaseProver::new_with_negotiation(0, 0x0001).unwrap();
762        let _ = PaseProver::new_with_negotiation(u32::MAX, 0x0001).unwrap();
763    }
764
765    #[test]
766    fn new_with_known_params_rejects_low_iterations() {
767        let params = PasePbkdfParams {
768            iterations: 999,
769            salt: vec![0u8; 16],
770        };
771        assert!(matches!(
772            PaseProver::new_with_known_params(20_202_021, params, 0x0001),
773            Err(Error::PbkdfIterationsTooLow(999))
774        ));
775    }
776
777    #[test]
778    fn new_with_known_params_rejects_short_salt() {
779        let params = PasePbkdfParams {
780            iterations: 1_000,
781            salt: vec![0u8; 15],
782        };
783        assert!(matches!(
784            PaseProver::new_with_known_params(20_202_021, params, 0x0001),
785            Err(Error::PbkdfSaltLengthInvalid(15))
786        ));
787    }
788
789    #[test]
790    fn new_with_known_params_accepts_valid_params() {
791        let params = PasePbkdfParams {
792            iterations: 1_000,
793            salt: vec![0x42u8; 16],
794        };
795        let _ = PaseProver::new_with_known_params(20_202_021, params, 0x0001).unwrap();
796    }
797
798    // ─── Negotiation path state transitions ───────────────────────────────
799
800    #[test]
801    fn start_negotiation_emits_tlv_structure() {
802        let mut prover = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
803        let bytes = prover.start().unwrap();
804        // An anonymous TLV structure starts with 0x15 (type=Structure, tag=Anonymous).
805        assert_eq!(
806            bytes[0], 0x15,
807            "first byte must be anonymous structure tag 0x15"
808        );
809        assert!(!bytes.is_empty());
810    }
811
812    #[test]
813    fn expected_inbound_after_start_negotiation_is_pbkdf_response() {
814        let mut prover = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
815        let _ = prover.start().unwrap();
816        assert_eq!(
817            prover.expected_inbound(),
818            Some(PaseMessageKind::PbkdfParamResponse)
819        );
820    }
821
822    #[test]
823    fn handle_pbkdf_response_advances_to_ready_to_send_pake1() {
824        let mut prover = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
825        let _req_bytes = prover.start().unwrap();
826
827        // Build a minimal valid PBKDFParamResponse with pbkdf_parameters.
828        let resp = PbkdfParamResponse {
829            initiator_random: [0x42u8; 32],
830            responder_random: [0x11u8; 32],
831            responder_session_id: 1,
832            pbkdf_parameters: Some(PbkdfParamsInner {
833                iterations: 1_000,
834                salt: vec![0xABu8; 16],
835            }),
836            responder_session_params: None,
837        };
838        let resp_bytes = resp.encode().unwrap();
839
840        // The function succeeds.
841        prover.handle_pbkdf_response(&resp_bytes).unwrap();
842        // Now the prover is ready to send Pake1 — no inbound expected yet.
843        assert_eq!(prover.expected_inbound(), None);
844    }
845
846    #[test]
847    fn handle_pbkdf_response_rejects_missing_pbkdf_params() {
848        let mut prover = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
849        let _ = prover.start().unwrap();
850
851        // Response without pbkdf_parameters should fail.
852        let resp = PbkdfParamResponse {
853            initiator_random: [0x42u8; 32],
854            responder_random: [0x11u8; 32],
855            responder_session_id: 1,
856            pbkdf_parameters: None, // missing!
857            responder_session_params: None,
858        };
859        let resp_bytes = resp.encode().unwrap();
860        assert!(matches!(
861            prover.handle_pbkdf_response(&resp_bytes),
862            Err(Error::InvalidParameter)
863        ));
864    }
865
866    #[test]
867    fn next_message_after_pbkdf_response_emits_pake1() {
868        let mut prover = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
869        let _ = prover.start().unwrap();
870
871        let resp = PbkdfParamResponse {
872            initiator_random: [0x42u8; 32],
873            responder_random: [0x11u8; 32],
874            responder_session_id: 1,
875            pbkdf_parameters: Some(PbkdfParamsInner {
876                iterations: 1_000,
877                salt: vec![0xABu8; 16],
878            }),
879            responder_session_params: None,
880        };
881        prover
882            .handle_pbkdf_response(&resp.encode().unwrap())
883            .unwrap();
884
885        let pake1_bytes = prover.next_message().unwrap();
886        // Pake1 is an anonymous structure starting with 0x15.
887        assert_eq!(pake1_bytes[0], 0x15);
888        // After Pake1, we're awaiting Pake2.
889        assert_eq!(prover.expected_inbound(), Some(PaseMessageKind::Pake2));
890    }
891
892    // ─── Terminal state guards ────────────────────────────────────────────
893
894    #[test]
895    fn finish_before_complete_returns_handshake_incomplete() {
896        let prover = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
897        assert!(matches!(prover.finish(), Err(Error::HandshakeIncomplete)));
898    }
899
900    #[test]
901    fn out_of_order_handle_pake2_returns_unexpected_message() {
902        // The prover is in AwaitingStartNegotiation — handle_pake2 is wrong here.
903        let mut prover = PaseProver::new_with_negotiation(20_202_021, 0x0001).unwrap();
904        // Build plausible Pake2 bytes (will be rejected at state check, not decoding).
905        let dummy_pake2 = Pake2 {
906            y: [0x04u8; 65],
907            verifier: [0x00u8; 32],
908        };
909        let pake2_bytes = dummy_pake2.encode().unwrap();
910        assert!(matches!(
911            prover.handle_pake2(&pake2_bytes),
912            Err(Error::UnexpectedMessage { .. })
913        ));
914    }
915
916    // ─── hash_context (now lives in spake2plus; tested there) ────────────
917    //
918    // The three hash_context tests have moved to `spake2plus::tests` since
919    // the function itself moved there. We reference it through the import at
920    // the top of this file (`use crate::pase::spake2plus::hash_context`) only
921    // in the production call sites; tests live with the implementation.
922
923    // ─── Session ID plumbing ──────────────────────────────────────────────
924
925    #[test]
926    fn prover_advertises_initiator_session_id_and_starts_unknowing_responder_id() {
927        let mut prover = PaseProver::new_with_negotiation(20_202_021, 0x0011).unwrap();
928        assert_eq!(prover.responder_session_id(), None);
929
930        let req = prover.start().unwrap();
931        let decoded_req = crate::pase::messages::PbkdfParamRequest::decode(&req).unwrap();
932        assert_eq!(decoded_req.initiator_session_id, 0x0011);
933
934        assert_eq!(prover.responder_session_id(), None);
935    }
936}