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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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}