matter_crypto/pase/verifier.rs
1//! Device-side PASE state machine.
2//!
3//! Drives the 5-message PASE handshake (or 3-message known-params path)
4//! from the device's (verifier's) perspective. Sans-IO: the caller feeds raw
5//! TLV 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//! → PBKDFParamRequest ────────────────────────>
13//! handle_pbkdf_request()
14//! next_message() [PBKDFParamResponse]
15//! <──────── PBKDFParamResponse
16//! → Pake1 ────────────────────────>
17//! handle_pake1()
18//! next_message() [Pake2]
19//! <──────── Pake2
20//! → Pake3 ────────────────────────>
21//! handle_pake3()
22//! finish() → PaseSessionKeys
23//! ```
24//!
25//! # Known-params path
26//!
27//! When the commissioner already has the PBKDF params cached it skips
28//! `PBKDFParamRequest` and sends Pake1 directly. The verifier's
29//! `AwaitingFirstMessage` state accepts either message kind and branches
30//! automatically.
31//!
32//! ```text
33//! → Pake1 → handle_pake1() → next_message() [Pake2]
34//! → handle_pake3() → finish()
35//! ```
36//!
37//! # Verifier values
38//!
39//! The device stores `w0` (32-byte big-endian scalar) and `L` (65-byte
40//! uncompressed P-256 point) computed from the setup PIN at provisioning time.
41//! The PIN is **never** stored after provisioning. See [`PaseVerifier::new`] for
42//! the production constructor and [`PaseVerifier::new_from_pin`] for the
43//! test/convenience path.
44//!
45//! # Transcript context composition
46//!
47//! Same as the prover — `hash_context` in `spake2plus.rs`:
48//! `context = SHA-256("CHIP PAKE V1 Commissioning" || pbkdfReq || pbkdfResp)`.
49//! For the known-params path (no param exchange): `context = SHA-256("CHIP PAKE V1 Commissioning")`.
50//!
51//! # Session key assignment (device = responder)
52//!
53//! From matter.js `NodeSession.ts` with `isInitiator = false`:
54//! ```ts
55//! const decryptKey = isInitiator ? keys.slice(16, 32) : keys.slice(0, 16);
56//! const encryptKey = isInitiator ? keys.slice(0, 16) : keys.slice(16, 32);
57//! ```
58//! So for the device (responder):
59//! - `r2i_key` (device → commissioner, device encrypts) = `blob[16..32]`.
60//! - `i2r_key` (commissioner → device, device decrypts) = `blob[0..16]`.
61//!
62//! The prover (commissioner) uses the opposite assignment, so both sides
63//! produce the same `i2r_key` and `r2i_key` in `PaseSessionKeys` from
64//! their respective perspectives.
65
66use p256::elliptic_curve::group::ff::PrimeField; // PrimeField for Scalar::from_repr
67use ring::rand::{SecureRandom, SystemRandom};
68
69use crate::error::{Error, Result};
70use crate::pase::kdf::{derive_l, derive_w0_w1, validate_params};
71use crate::pase::messages::{
72 Pake1, Pake2, Pake3, PbkdfParamRequest, PbkdfParamResponse, PbkdfParamsInner,
73};
74use crate::pase::spake2plus::{
75 compute_ca, compute_cb, compute_y, compute_z_v_verifier, derive_confirmation_keys,
76 derive_session_keys, hash_context, ka_ke_from_transcript, sample_scalar, transcript_hash,
77 verify_tag,
78};
79use crate::pase::{PaseMessageKind, PasePbkdfParams, PaseSessionKeys};
80use zeroize::Zeroize;
81
82// =============================================================================
83// Internal state enum
84// =============================================================================
85
86/// Internal states of the device-side PASE handshake.
87///
88/// Each variant corresponds to a point in the protocol flow from Matter Core
89/// Spec §3.10.5 as seen by the responder. Named for the *next action* the
90/// state machine expects or is ready to perform.
91///
92/// The `AwaitingFirstMessage` state is a branch point: the verifier accepts
93/// either `PBKDFParamRequest` (negotiation path) or `Pake1` (known-params
94/// path) as its first inbound message. The caller does not need to know which
95/// path the commissioner will choose.
96#[derive(Debug)]
97enum State {
98 /// Waiting for the first inbound message.
99 ///
100 /// The verifier does not know yet whether the commissioner will send
101 /// `PBKDFParamRequest` (negotiation) or `Pake1` (known-params). Both
102 /// are valid here.
103 ///
104 /// `y_scalar` is pre-sampled so that `handle_pake1` is infallible for
105 /// randomness after construction succeeds.
106 AwaitingFirstMessage {
107 w0: p256::Scalar,
108 /// 65-byte uncompressed P-256 point `L = w1·P` stored by the device.
109 l: [u8; 65],
110 params: PasePbkdfParams,
111 y_scalar: p256::Scalar,
112 /// Pre-sampled 32-byte responder nonce, used if the commissioner sends
113 /// a `PBKDFParamRequest`.
114 responder_random: [u8; 32],
115 /// Session ID to include in `PBKDFParamResponse`. Set to 0 by the
116 /// production constructors; overrideable for testing.
117 responder_session_id: u16,
118 },
119
120 /// `PBKDFParamRequest` received; `next_message()` will emit the Response.
121 ///
122 /// Holds the verbatim bytes of the request (for transcript context), the
123 /// responder random, and enough to build the Response.
124 ReadyToSendPbkdfResponse {
125 w0: p256::Scalar,
126 l: [u8; 65],
127 params: PasePbkdfParams,
128 y_scalar: p256::Scalar,
129 /// Verbatim TLV bytes of the `PBKDFParamRequest`, saved for transcript context.
130 request_bytes: Vec<u8>,
131 responder_random: [u8; 32],
132 initiator_random: [u8; 32],
133 responder_session_id: u16,
134 },
135
136 /// `PBKDFParamResponse` sent; waiting for Pake1.
137 ///
138 /// `transcript_context` is already computed as
139 /// `SHA-256(SPAKE_CONTEXT || pbkdfReq || pbkdfResp)`.
140 AwaitingPake1 {
141 w0: p256::Scalar,
142 l: [u8; 65],
143 y_scalar: p256::Scalar,
144 /// SHA-256 context hash to pass into `transcript_hash`.
145 transcript_context: [u8; 32],
146 },
147
148 /// Pake1 received; `next_message()` will emit Pake2 (Y + cB).
149 ReadyToSendPake2 {
150 /// Our Y point (65 bytes, uncompressed P-256), to send in Pake2.
151 y_bytes: [u8; 65],
152 /// Our confirmation tag `cB = HMAC-SHA256(KcB, X)`, to send in Pake2.
153 cb: [u8; 32],
154 /// The expected `cA = HMAC-SHA256(KcA, Y)` that Pake3 must carry.
155 ca_expected: [u8; 32],
156 session_keys: PaseSessionKeys,
157 },
158
159 /// Pake3 received and `cA` verified; `finish()` may be called.
160 Complete { session_keys: PaseSessionKeys },
161
162 /// Sentinel used during `std::mem::replace` state transitions.
163 ///
164 /// This variant is **never observable** to callers: every `mem::replace`
165 /// immediately replaces `Poisoned` with the next real state, or returns
166 /// an error before storing it. If somehow reached, all methods return
167 /// [`Error::HandshakeIncomplete`].
168 Poisoned,
169}
170
171// =============================================================================
172// PaseVerifier
173// =============================================================================
174
175/// Device-side PASE state machine.
176///
177/// Drives the SPAKE2+ handshake from the device's (responder's) perspective.
178/// Sans-IO: the caller is responsible for transmitting and receiving bytes.
179///
180/// # Construction
181///
182/// - [`PaseVerifier::new`] — production path; device stores pre-computed `w0`
183/// and `L` (never the PIN after provisioning).
184/// - [`PaseVerifier::new_from_pin`] — test/convenience path; derives `w0` and
185/// `L` from the PIN via PBKDF2.
186///
187/// # Driving the handshake
188///
189/// 1. Feed the inbound `PBKDFParamRequest` bytes into
190/// [`handle_pbkdf_request`][Self::handle_pbkdf_request] (negotiation path),
191/// or skip to step 3 (known-params path).
192/// 2. Call [`next_message`][Self::next_message] to emit `PBKDFParamResponse`.
193/// 3. Feed the inbound `Pake1` bytes into
194/// [`handle_pake1`][Self::handle_pake1].
195/// 4. Call [`next_message`][Self::next_message] to emit Pake2.
196/// 5. Feed the inbound `Pake3` bytes into
197/// [`handle_pake3`][Self::handle_pake3].
198/// 6. Call [`finish`][Self::finish] to retrieve the [`PaseSessionKeys`].
199///
200/// Use [`expected_inbound`][Self::expected_inbound] at any point to query
201/// which message type the machine is currently waiting for.
202pub struct PaseVerifier {
203 state: State,
204}
205
206impl PaseVerifier {
207 // ─── Public constructors ──────────────────────────────────────────────
208
209 /// Production constructor: device stores pre-computed verification values.
210 ///
211 /// In production the PIN is hashed to `w0` and `L` once at provisioning
212 /// time and the raw PIN is discarded. Pass those stored values here.
213 ///
214 /// Validates `params` against Matter spec §3.10.3 bounds before accepting.
215 ///
216 /// # Parameters
217 ///
218 /// - `w0`: 32-byte big-endian P-256 scalar derived from the PIN.
219 /// - `l`: 65-byte uncompressed P-256 point `L = w1·P`.
220 /// - `params`: PBKDF2 parameters used when `w0`/`L` were derived.
221 /// - `responder_session_id`: the non-zero secured-session id this device
222 /// advertises (in `PBKDFParamResponse`) for the peer to address it by.
223 ///
224 /// # Errors
225 ///
226 /// - [`Error::PbkdfIterationsTooLow`] if `params.iterations < 1000`.
227 /// - [`Error::PbkdfSaltLengthInvalid`] if `params.salt.len()` ∉ \[16, 32\].
228 /// - [`Error::InvalidScalar`] if the CSPRNG is broken and a non-zero
229 /// scalar cannot be sampled after 16 attempts (practically impossible).
230 /// - [`Error::PinDerivationFailed`] if the nonce fill fails.
231 pub fn new(
232 w0: [u8; 32],
233 l: [u8; 65],
234 params: PasePbkdfParams,
235 responder_session_id: u16,
236 ) -> Result<Self> {
237 validate_params(params.iterations, ¶ms.salt)?;
238 let rng = SystemRandom::new();
239 Self::new_using_rng(w0, l, params, responder_session_id, &rng)
240 }
241
242 /// Deterministic constructor for testing — accepts an injectable RNG.
243 ///
244 /// Production code should always use [`new`][Self::new].
245 pub(crate) fn new_using_rng(
246 w0_bytes: [u8; 32],
247 l: [u8; 65],
248 params: PasePbkdfParams,
249 responder_session_id: u16,
250 rng: &dyn SecureRandom,
251 ) -> Result<Self> {
252 validate_params(params.iterations, ¶ms.salt)?;
253
254 // Decode `w0` from 32-byte big-endian representation.
255 // `Scalar::from_repr` returns `CtOption<Scalar>` — this is a direct
256 // conversion (no modular reduction needed because the stored value is
257 // already reduced at provisioning time).
258 let w0_opt: Option<p256::Scalar> =
259 p256::Scalar::from_repr(p256::FieldBytes::from(w0_bytes)).into();
260 let w0 = w0_opt.ok_or(Error::InvalidScalar)?;
261 // The zero scalar is not a valid w0 — it would collapse all SPAKE2+ math.
262 // `is_zero()` returns `subtle::Choice`; convert via `bool::from`.
263 if bool::from(p256::elliptic_curve::group::ff::Field::is_zero(&w0)) {
264 return Err(Error::InvalidScalar);
265 }
266
267 let y_scalar = sample_scalar(rng)?;
268 let mut responder_random = [0u8; 32];
269 rng.fill(&mut responder_random)
270 .map_err(|_| Error::PinDerivationFailed)?;
271
272 Ok(Self {
273 state: State::AwaitingFirstMessage {
274 w0,
275 l,
276 params,
277 y_scalar,
278 responder_random,
279 responder_session_id,
280 },
281 })
282 }
283
284 /// Test/convenience constructor: derive `w0` and `L` from the PIN.
285 ///
286 /// In production a device never stores the PIN after provisioning —
287 /// it stores `w0` and `L` instead. This constructor is provided for
288 /// tests and development use where deriving from a PIN is convenient.
289 ///
290 /// # Parameters
291 ///
292 /// - `responder_session_id`: the non-zero secured-session id this device
293 /// advertises (in `PBKDFParamResponse`) for the peer to address it by.
294 ///
295 /// # Errors
296 ///
297 /// - [`Error::PbkdfIterationsTooLow`] / [`Error::PbkdfSaltLengthInvalid`]
298 /// if `params` are out of spec.
299 /// - [`Error::PinDerivationFailed`] if PBKDF2 fails.
300 /// - [`Error::InvalidScalar`] if the CSPRNG is broken.
301 pub fn new_from_pin(
302 pin: u32,
303 params: PasePbkdfParams,
304 responder_session_id: u16,
305 ) -> Result<Self> {
306 let rng = SystemRandom::new();
307 Self::new_from_pin_using_rng(pin, params, responder_session_id, &rng)
308 }
309
310 /// Deterministic constructor for testing — derives `w0`/`L` from the PIN
311 /// and accepts an injectable RNG for the SPAKE2+ scalar and nonce.
312 ///
313 /// Production code should always use [`new_from_pin`][Self::new_from_pin].
314 pub(crate) fn new_from_pin_using_rng(
315 pin: u32,
316 params: PasePbkdfParams,
317 responder_session_id: u16,
318 rng: &dyn SecureRandom,
319 ) -> Result<Self> {
320 let (w0_scalar, w1_scalar) = derive_w0_w1(pin, ¶ms.salt, params.iterations)?;
321 let l = derive_l(&w1_scalar);
322 // Encode w0 as 32-byte big-endian for the `new_using_rng` constructor.
323 // `Scalar::to_bytes()` returns `FieldBytes` in big-endian order.
324 let w0_be: p256::FieldBytes = w0_scalar.to_bytes();
325 let mut w0_arr = [0u8; 32];
326 w0_arr.copy_from_slice(&w0_be);
327 Self::new_using_rng(w0_arr, l, params, responder_session_id, rng)
328 }
329
330 /// Deterministic constructor for testing — injects a fixed `y` scalar
331 /// directly, bypassing the RNG. Accepts pre-computed `w0` and `L`.
332 ///
333 /// Uses an all-zero `responder_random` and `responder_session_id = 0`.
334 /// For full matter.js byte-parity tests, use
335 /// [`new_with_scalar_and_random`][Self::new_with_scalar_and_random] instead.
336 ///
337 /// Production code should always use [`new`][Self::new].
338 ///
339 /// # Errors
340 ///
341 /// - [`Error::PbkdfIterationsTooLow`] / [`Error::PbkdfSaltLengthInvalid`]
342 /// if `params` are out of spec.
343 /// - [`Error::InvalidScalar`] if `w0_bytes` or `y_scalar_bytes` is zero or
344 /// not a valid P-256 scalar.
345 pub(crate) fn new_with_scalar(
346 w0_bytes: [u8; 32],
347 l: [u8; 65],
348 params: PasePbkdfParams,
349 y_scalar_bytes: [u8; 32],
350 ) -> Result<Self> {
351 Self::new_with_scalar_and_random(w0_bytes, l, params, y_scalar_bytes, [0u8; 32], 0)
352 }
353
354 /// Deterministic constructor for testing — injects a fixed `y` scalar,
355 /// `responder_random`, and `responder_session_id` directly.
356 ///
357 /// Used by `test_support` to reproduce matter.js fixture handshakes that
358 /// capture a specific responder nonce and session ID in
359 /// `PBKDFParamResponse`. Production code always uses [`new`][Self::new].
360 ///
361 /// # Errors
362 ///
363 /// - [`Error::PbkdfIterationsTooLow`] / [`Error::PbkdfSaltLengthInvalid`]
364 /// if `params` are out of spec.
365 /// - [`Error::InvalidScalar`] if `w0_bytes` or `y_scalar_bytes` is zero or
366 /// not a valid P-256 scalar.
367 pub(crate) fn new_with_scalar_and_random(
368 w0_bytes: [u8; 32],
369 l: [u8; 65],
370 params: PasePbkdfParams,
371 y_scalar_bytes: [u8; 32],
372 responder_random: [u8; 32],
373 responder_session_id: u16,
374 ) -> Result<Self> {
375 use p256::elliptic_curve::group::ff::Field;
376 validate_params(params.iterations, ¶ms.salt)?;
377
378 let w0_opt: Option<p256::Scalar> =
379 p256::Scalar::from_repr(p256::FieldBytes::from(w0_bytes)).into();
380 let w0 = w0_opt.ok_or(Error::InvalidScalar)?;
381 if bool::from(w0.is_zero()) {
382 return Err(Error::InvalidScalar);
383 }
384
385 let y_opt: Option<p256::Scalar> =
386 p256::Scalar::from_repr(p256::FieldBytes::from(y_scalar_bytes)).into();
387 let y_scalar = y_opt.ok_or(Error::InvalidScalar)?;
388 if bool::from(y_scalar.is_zero()) {
389 return Err(Error::InvalidScalar);
390 }
391
392 Ok(Self {
393 state: State::AwaitingFirstMessage {
394 w0,
395 l,
396 params,
397 y_scalar,
398 responder_random,
399 responder_session_id,
400 },
401 })
402 }
403
404 /// Deterministic constructor for testing — derives `w0`/`L` from the PIN
405 /// and injects a fixed `y` scalar directly, bypassing the RNG.
406 ///
407 /// Used by `test_support` to construct a verifier with a known scalar for
408 /// matter.js byte-parity tests.
409 ///
410 /// Production code should always use [`new_from_pin`][Self::new_from_pin].
411 ///
412 /// # Errors
413 ///
414 /// - [`Error::PbkdfIterationsTooLow`] / [`Error::PbkdfSaltLengthInvalid`]
415 /// if `params` are out of spec.
416 /// - [`Error::PinDerivationFailed`] if PBKDF2 fails.
417 /// - [`Error::InvalidScalar`] if `y_scalar_bytes` is zero or not a valid
418 /// P-256 scalar.
419 pub(crate) fn new_from_pin_with_scalar(
420 pin: u32,
421 params: PasePbkdfParams,
422 y_scalar_bytes: [u8; 32],
423 ) -> Result<Self> {
424 let (w0_scalar, w1_scalar) = derive_w0_w1(pin, ¶ms.salt, params.iterations)?;
425 let l = derive_l(&w1_scalar);
426 let w0_be: p256::FieldBytes = w0_scalar.to_bytes();
427 let mut w0_arr = [0u8; 32];
428 w0_arr.copy_from_slice(&w0_be);
429 Self::new_with_scalar(w0_arr, l, params, y_scalar_bytes)
430 }
431
432 // ─── State inspection ─────────────────────────────────────────────────
433
434 /// Returns the message kind the state machine is currently waiting to
435 /// receive, or `None` if the machine is in an outbound-only or completed
436 /// state.
437 ///
438 /// Useful for routing inbound messages in a dispatcher.
439 pub fn expected_inbound(&self) -> Option<PaseMessageKind> {
440 match &self.state {
441 State::AwaitingFirstMessage { .. } => {
442 // Either PbkdfParamRequest or Pake1 is valid; return the more
443 // common (negotiation-path) expectation. Callers that need
444 // strict routing should branch on `handle_pbkdf_request` vs
445 // `handle_pake1`.
446 Some(PaseMessageKind::PbkdfParamRequest)
447 }
448 State::AwaitingPake1 { .. } => Some(PaseMessageKind::Pake1),
449 State::ReadyToSendPake2 { .. } => Some(PaseMessageKind::Pake3),
450 _ => None,
451 }
452 }
453
454 // ─── Inbound handlers ─────────────────────────────────────────────────
455
456 /// Process an inbound `PBKDFParamRequest` message (negotiation path).
457 ///
458 /// Decodes the request, captures the raw bytes for transcript composition,
459 /// and transitions to `ReadyToSendPbkdfResponse`. After this call,
460 /// [`next_message`][Self::next_message] emits `PBKDFParamResponse`.
461 ///
462 /// # Errors
463 ///
464 /// - [`Error::UnexpectedMessage`] if called from any state other than
465 /// `AwaitingFirstMessage`.
466 /// - [`Error::Codec`] on TLV decoding failure.
467 /// - [`Error::InvalidParameter`] if the request is malformed.
468 pub fn handle_pbkdf_request(&mut self, bytes: &[u8]) -> Result<()> {
469 let prev = std::mem::replace(&mut self.state, State::Poisoned);
470 match prev {
471 State::AwaitingFirstMessage {
472 w0,
473 l,
474 params,
475 y_scalar,
476 responder_random,
477 responder_session_id,
478 } => {
479 // Decode the request so we can capture `initiator_random`.
480 // We also keep the verbatim bytes for transcript context.
481 let req = PbkdfParamRequest::decode(bytes)?;
482
483 self.state = State::ReadyToSendPbkdfResponse {
484 w0,
485 l,
486 params,
487 y_scalar,
488 request_bytes: bytes.to_vec(),
489 responder_random,
490 initiator_random: req.initiator_random,
491 responder_session_id,
492 };
493 Ok(())
494 }
495
496 other => {
497 self.state = other;
498 Err(Error::UnexpectedMessage {
499 expected: PaseMessageKind::PbkdfParamRequest,
500 got: PaseMessageKind::PbkdfParamRequest,
501 })
502 }
503 }
504 }
505
506 /// Process an inbound `Pake1` message.
507 ///
508 /// Valid in two states:
509 /// - `AwaitingFirstMessage` — commissioner skipped param negotiation
510 /// (known-params path); context = `SHA-256(SPAKE_CONTEXT)`.
511 /// - `AwaitingPake1` — negotiation complete; context already computed.
512 ///
513 /// After this call, [`next_message`][Self::next_message] emits Pake2.
514 ///
515 /// # Cryptography
516 ///
517 /// 1. Decode X from Pake1 TLV.
518 /// 2. Compute `Y = y·P + w0·N` (verifier's SPAKE2+ share).
519 /// 3. Compute `Z = y·(X − w0·M)` and `V = y·L` (shared secrets).
520 /// 4. Compute the SPAKE2+ transcript hash `TT_HASH`.
521 /// 5. Split `Ka` (first 16 bytes) and `Ke` (last 16 bytes) from `TT_HASH`.
522 /// 6. Derive confirmation keys `KcA`/`KcB` from `Ka`.
523 /// 7. Compute `cB = HMAC-SHA256(KcB, X)` (our confirmation tag to send).
524 /// 8. Compute `cA_expected = HMAC-SHA256(KcA, Y)` (to verify in Pake3).
525 /// 9. Derive session keys from `Ke`.
526 ///
527 /// # Errors
528 ///
529 /// - [`Error::UnexpectedMessage`] if called from the wrong state.
530 /// - [`Error::Codec`] on TLV decoding failure.
531 /// - [`Error::InvalidParameter`] if X is not a valid P-256 point.
532 /// - [`Error::PinDerivationFailed`] on HKDF failure.
533 pub fn handle_pake1(&mut self, bytes: &[u8]) -> Result<()> {
534 let prev = std::mem::replace(&mut self.state, State::Poisoned);
535 match prev {
536 // Known-params path: commissioner sent Pake1 as the first message.
537 State::AwaitingFirstMessage {
538 w0,
539 l,
540 y_scalar,
541 // params, responder_random, and responder_session_id are not used
542 // on the known-params path (no PbkdfParam exchange).
543 ..
544 } => {
545 // context = SHA-256("CHIP PAKE V1 Commissioning") — no param exchange.
546 let transcript_context = hash_context(&[]);
547 self.state = State::Poisoned; // keep Poisoned while we do crypto
548 self.compute_pake2(w0, l, y_scalar, transcript_context, bytes)
549 }
550
551 // Negotiation path: param exchange complete, now handle Pake1.
552 State::AwaitingPake1 {
553 w0,
554 l,
555 y_scalar,
556 transcript_context,
557 } => {
558 self.state = State::Poisoned; // keep Poisoned while we do crypto
559 self.compute_pake2(w0, l, y_scalar, transcript_context, bytes)
560 }
561
562 other => {
563 self.state = other;
564 Err(Error::UnexpectedMessage {
565 expected: PaseMessageKind::Pake1,
566 got: PaseMessageKind::Pake1,
567 })
568 }
569 }
570 }
571
572 /// Process an inbound `Pake3` message.
573 ///
574 /// Verifies the commissioner's confirmation tag `cA` using constant-time
575 /// comparison (`subtle::ConstantTimeEq`). If verification succeeds the
576 /// state machine transitions to `Complete` and [`finish`][Self::finish]
577 /// may be called.
578 ///
579 /// # Security
580 ///
581 /// Tag comparison MUST be constant-time. This is enforced by routing
582 /// through `verify_tag` (in `pase::spake2plus`) which uses
583 /// `subtle::ConstantTimeEq`.
584 ///
585 /// # Errors
586 ///
587 /// - [`Error::UnexpectedMessage`] if called before Pake2 was sent.
588 /// - [`Error::ConfirmationTagMismatch`] if `cA` fails constant-time
589 /// verification (wrong PIN on the commissioner's side).
590 /// - [`Error::Codec`] on TLV decoding failure.
591 pub fn handle_pake3(&mut self, bytes: &[u8]) -> Result<()> {
592 let prev = std::mem::replace(&mut self.state, State::Poisoned);
593 match prev {
594 State::ReadyToSendPake2 {
595 y_bytes: _,
596 cb: _,
597 ca_expected,
598 session_keys,
599 } => {
600 let pake3 = Pake3::decode(bytes)?;
601
602 // CT-eq — never use `==` on HMAC tags.
603 verify_tag(&ca_expected, &pake3.verifier)?;
604
605 self.state = State::Complete { session_keys };
606 Ok(())
607 }
608
609 other => {
610 self.state = other;
611 Err(Error::UnexpectedMessage {
612 expected: PaseMessageKind::Pake3,
613 got: PaseMessageKind::Pake3,
614 })
615 }
616 }
617 }
618
619 /// Produce the next outbound message.
620 ///
621 /// - After [`handle_pbkdf_request`][Self::handle_pbkdf_request]: emits
622 /// `PBKDFParamResponse` (TLV bytes).
623 /// - After [`handle_pake1`][Self::handle_pake1]: emits Pake2 (TLV bytes).
624 ///
625 /// Calling from any other state returns [`Error::UnexpectedMessage`].
626 ///
627 /// # Errors
628 ///
629 /// - [`Error::UnexpectedMessage`] if called from the wrong state.
630 /// - [`Error::Codec`] on TLV encoding failure.
631 pub fn next_message(&mut self) -> Result<Vec<u8>> {
632 let prev = std::mem::replace(&mut self.state, State::Poisoned);
633 match prev {
634 State::ReadyToSendPbkdfResponse {
635 w0,
636 l,
637 params,
638 y_scalar,
639 request_bytes,
640 responder_random,
641 initiator_random,
642 responder_session_id,
643 } => {
644 // §3.10.5 step 2: build and send PBKDFParamResponse.
645 // Include our PBKDF parameters (the commissioner set
646 // has_pbkdf_parameters=false, so we must include them).
647 let resp = PbkdfParamResponse {
648 initiator_random,
649 responder_random,
650 responder_session_id,
651 pbkdf_parameters: Some(PbkdfParamsInner {
652 iterations: params.iterations,
653 salt: params.salt.clone(),
654 }),
655 responder_session_params: None,
656 };
657 let resp_bytes = resp.encode()?;
658
659 // Compose transcript context: SHA-256(SPAKE_CONTEXT || req || resp).
660 let transcript_context = hash_context(&[&request_bytes, &resp_bytes]);
661
662 self.state = State::AwaitingPake1 {
663 w0,
664 l,
665 y_scalar,
666 transcript_context,
667 };
668 Ok(resp_bytes)
669 }
670
671 State::ReadyToSendPake2 {
672 y_bytes,
673 cb,
674 ca_expected,
675 session_keys,
676 } => {
677 // §3.10.5: send Y and cB in Pake2.
678 let pake2_bytes = Pake2 {
679 y: y_bytes,
680 verifier: cb,
681 }
682 .encode()?;
683 // Keep ca_expected and session_keys for when Pake3 arrives.
684 self.state = State::ReadyToSendPake2 {
685 y_bytes,
686 cb,
687 ca_expected,
688 session_keys,
689 };
690 Ok(pake2_bytes)
691 }
692
693 other => {
694 self.state = other;
695 Err(Error::UnexpectedMessage {
696 expected: PaseMessageKind::PbkdfParamResponse,
697 got: PaseMessageKind::PbkdfParamResponse,
698 })
699 }
700 }
701 }
702
703 /// Finalise the session and retrieve the derived session keys.
704 ///
705 /// May only be called after [`handle_pake3`][Self::handle_pake3] has
706 /// successfully verified `cA` (i.e., the state machine is in `Complete`).
707 ///
708 /// # Errors
709 ///
710 /// - [`Error::HandshakeIncomplete`] if called before the handshake has
711 /// completed all phases.
712 pub fn finish(self) -> Result<PaseSessionKeys> {
713 match self.state {
714 State::Complete { session_keys } => Ok(session_keys),
715 _ => Err(Error::HandshakeIncomplete),
716 }
717 }
718}
719
720// =============================================================================
721// Internal helpers
722// =============================================================================
723
724impl PaseVerifier {
725 /// Perform the verifier-side SPAKE2+ cryptography for Pake1 processing.
726 ///
727 /// This is a shared helper called from `handle_pake1` in both the
728 /// known-params and post-negotiation code paths. Sets `self.state` to
729 /// `ReadyToSendPake2` on success.
730 ///
731 /// The state machine must be in `Poisoned` before this call (the caller
732 /// has already `mem::replace`d it out). On error, the machine remains
733 /// `Poisoned` — the caller should not recover from a crypto failure.
734 // kca / kcb are the SPAKE2+ confirmation key names from Matter
735 // Core Spec §3.10 (RFC 9383 §3.3). Renaming would impair
736 // verification against the spec.
737 #[allow(clippy::similar_names)]
738 fn compute_pake2(
739 &mut self,
740 w0: p256::Scalar,
741 l: [u8; 65],
742 y_scalar: p256::Scalar,
743 transcript_context: [u8; 32],
744 pake1_bytes: &[u8],
745 ) -> Result<()> {
746 let pake1 = Pake1::decode(pake1_bytes)?;
747
748 // §3.10.5 verifier side:
749 // Y = y·P + w0·N
750 // Z = y·(X − w0·M)
751 // V = y·L
752 let y_bytes = compute_y(&y_scalar, &w0);
753 let (z_bytes, v_bytes) = compute_z_v_verifier(&y_scalar, &w0, &l, &pake1.x)?;
754
755 // Transcript hash: SHA-256 over all protocol elements.
756 let t_t = transcript_hash(
757 &transcript_context,
758 &pake1.x,
759 &y_bytes,
760 &z_bytes,
761 &v_bytes,
762 &w0,
763 );
764
765 // Split Ka (first 16 bytes) and Ke (last 16 bytes). Both are SPAKE2+
766 // secrets (`ke` is the root session secret); zeroize them after their
767 // last use so no copy lingers on the stack.
768 let (mut ka, mut ke) = ka_ke_from_transcript(&t_t);
769
770 // Derive KcA and KcB from Ka.
771 let (kca, kcb) = derive_confirmation_keys(&ka)?;
772 ka.zeroize();
773
774 // cB = HMAC-SHA256(KcB, X) — sent to the commissioner in Pake2.
775 let cb = compute_cb(&kcb, &pake1.x);
776
777 // cA_expected = HMAC-SHA256(KcA, Y) — verified when Pake3 arrives.
778 let ca_expected = compute_ca(&kca, &y_bytes);
779
780 // Derive 48-byte session key material from Ke.
781 // `build_session_keys` copies `ke` into the `ZeroizeOnDrop`
782 // `PaseSessionKeys`; wipe our local copy afterwards.
783 let session_keys_blob = derive_session_keys(&ke)?;
784 let session_keys = build_session_keys(ke, &session_keys_blob);
785 ke.zeroize();
786
787 self.state = State::ReadyToSendPake2 {
788 y_bytes,
789 cb,
790 ca_expected,
791 session_keys,
792 };
793 Ok(())
794 }
795}
796
797// =============================================================================
798// Session-key builder (device = responder)
799// =============================================================================
800
801/// Build a [`PaseSessionKeys`] from `Ke` and the 48-byte derived key blob.
802///
803/// Key assignment for the device (= responder), per matter.js `NodeSession.ts`
804/// with `isInitiator = false`:
805/// ```ts
806/// const decryptKey = isInitiator ? keys.slice(16, 32) : keys.slice(0, 16);
807/// const encryptKey = isInitiator ? keys.slice(0, 16) : keys.slice(16, 32);
808/// const attestationKey = keys.slice(32, 48);
809/// ```
810///
811/// - `i2r_key` = `decryptKey` (device decrypts incoming from commissioner).
812/// - `r2i_key` = `encryptKey` (device encrypts outgoing to commissioner).
813///
814/// By convention, `PaseSessionKeys::i2r_key` is the initiator→responder
815/// direction key. From the device's perspective:
816/// - Initiator→responder (i2r): the device *decrypts* → `blob[0..16]` (because
817/// `isInitiator=false` means `decryptKey = blob[0..16]`).
818/// - Responder→initiator (r2i): the device *encrypts* → `blob[16..32]`.
819///
820/// This is the mirror of `prover.rs`'s `build_session_keys`, which assigns:
821/// - `i2r_key` = `blob[0..16]` (commissioner encrypts).
822/// - `r2i_key` = `blob[16..32]` (commissioner decrypts).
823///
824/// Both sides end up with the same `i2r_key` and `r2i_key` values.
825fn build_session_keys(ke: [u8; 16], blob_48: &[u8; 48]) -> PaseSessionKeys {
826 let mut i2r_key = [0u8; 16];
827 let mut r2i_key = [0u8; 16];
828 let mut attestation_key = [0u8; 16];
829
830 // Device = responder (isInitiator = false):
831 // decryptKey (i2r) = blob[0..16]
832 // encryptKey (r2i) = blob[16..32]
833 i2r_key.copy_from_slice(&blob_48[0..16]);
834 r2i_key.copy_from_slice(&blob_48[16..32]);
835 attestation_key.copy_from_slice(&blob_48[32..48]);
836
837 PaseSessionKeys {
838 ke,
839 i2r_key,
840 r2i_key,
841 attestation_key,
842 }
843}
844
845// =============================================================================
846// Tests
847// =============================================================================
848
849#[cfg(test)]
850#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
851mod tests {
852 use super::*;
853 use crate::pase::messages::PbkdfParamResponse;
854
855 /// Shared PBKDF params used across tests: valid per spec §3.10.3.
856 fn test_params() -> PasePbkdfParams {
857 PasePbkdfParams {
858 iterations: 1_000,
859 salt: vec![0x42u8; 16],
860 }
861 }
862
863 /// PIN used in tests; matches the matter.js canonical test PIN.
864 const TEST_PIN: u32 = 20_202_021;
865
866 // ─── Construction ─────────────────────────────────────────────────────
867
868 #[test]
869 fn new_from_pin_accepts_valid_params() {
870 let _ = PaseVerifier::new_from_pin(TEST_PIN, test_params(), 0x0033).unwrap();
871 }
872
873 #[test]
874 fn new_from_pin_rejects_low_iterations() {
875 let params = PasePbkdfParams {
876 iterations: 999,
877 salt: vec![0u8; 16],
878 };
879 assert!(matches!(
880 PaseVerifier::new_from_pin(TEST_PIN, params, 0x0033),
881 Err(Error::PbkdfIterationsTooLow(999))
882 ));
883 }
884
885 #[test]
886 fn new_from_pin_rejects_short_salt() {
887 let params = PasePbkdfParams {
888 iterations: 1_000,
889 salt: vec![0u8; 15],
890 };
891 assert!(matches!(
892 PaseVerifier::new_from_pin(TEST_PIN, params, 0x0033),
893 Err(Error::PbkdfSaltLengthInvalid(15))
894 ));
895 }
896
897 #[test]
898 fn new_raw_rejects_invalid_w0_scalar() {
899 // All-zeros is not a valid P-256 scalar (zero scalar is the identity — rejected).
900 let w0_zero = [0u8; 32];
901 let l = [0x04u8; 65];
902 let params = test_params();
903 assert!(matches!(
904 PaseVerifier::new(w0_zero, l, params, 0x0033),
905 Err(Error::InvalidScalar)
906 ));
907 }
908
909 // ─── expected_inbound ─────────────────────────────────────────────────
910
911 #[test]
912 fn expected_inbound_after_construction_is_pbkdf_request() {
913 let v = PaseVerifier::new_from_pin(TEST_PIN, test_params(), 0x0033).unwrap();
914 assert_eq!(
915 v.expected_inbound(),
916 Some(PaseMessageKind::PbkdfParamRequest)
917 );
918 }
919
920 // ─── Negotiation path state transitions ───────────────────────────────
921
922 #[test]
923 fn handle_pbkdf_request_advances_state() {
924 let mut v = PaseVerifier::new_from_pin(TEST_PIN, test_params(), 0x0033).unwrap();
925
926 // Build a minimal valid PbkdfParamRequest.
927 let req = PbkdfParamRequest {
928 initiator_random: [0x11u8; 32],
929 initiator_session_id: 0,
930 passcode_id: 0,
931 has_pbkdf_parameters: false,
932 initiator_session_params: None,
933 };
934 let req_bytes = req.encode().unwrap();
935
936 v.handle_pbkdf_request(&req_bytes).unwrap();
937 // After request, ready to send response — no inbound expected.
938 assert_eq!(v.expected_inbound(), None);
939 }
940
941 #[test]
942 fn next_message_after_pbkdf_request_emits_response() {
943 let mut v = PaseVerifier::new_from_pin(TEST_PIN, test_params(), 0x0033).unwrap();
944
945 let req = PbkdfParamRequest {
946 initiator_random: [0x11u8; 32],
947 initiator_session_id: 0,
948 passcode_id: 0,
949 has_pbkdf_parameters: false,
950 initiator_session_params: None,
951 };
952 v.handle_pbkdf_request(&req.encode().unwrap()).unwrap();
953
954 let resp_bytes = v.next_message().unwrap();
955 // PbkdfParamResponse is an anonymous TLV structure.
956 assert_eq!(resp_bytes[0], 0x15, "first byte must be 0x15 (anon struct)");
957
958 // After sending response, waiting for Pake1.
959 assert_eq!(v.expected_inbound(), Some(PaseMessageKind::Pake1));
960
961 // Round-trip: the response must decode successfully.
962 let decoded = PbkdfParamResponse::decode(&resp_bytes).unwrap();
963 // PBKDF params must be present (commissioner said has_pbkdf_parameters=false).
964 assert!(decoded.pbkdf_parameters.is_some());
965 let inner = decoded.pbkdf_parameters.unwrap();
966 assert_eq!(inner.iterations, 1_000);
967 assert_eq!(inner.salt, vec![0x42u8; 16]);
968 }
969
970 // ─── Out-of-order rejection ───────────────────────────────────────────
971
972 #[test]
973 fn out_of_order_handle_pake3_returns_unexpected_message() {
974 // Verifier is AwaitingFirstMessage; Pake3 is wrong here.
975 let mut v = PaseVerifier::new_from_pin(TEST_PIN, test_params(), 0x0033).unwrap();
976 let dummy_pake3 = Pake3 {
977 verifier: [0x00u8; 32],
978 };
979 let pake3_bytes = dummy_pake3.encode().unwrap();
980 assert!(matches!(
981 v.handle_pake3(&pake3_bytes),
982 Err(Error::UnexpectedMessage { .. })
983 ));
984 }
985
986 #[test]
987 fn finish_before_complete_returns_handshake_incomplete() {
988 let v = PaseVerifier::new_from_pin(TEST_PIN, test_params(), 0x0033).unwrap();
989 assert!(matches!(v.finish(), Err(Error::HandshakeIncomplete)));
990 }
991
992 // ─── Tag mismatch rejection ───────────────────────────────────────────
993
994 #[test]
995 fn handle_pake3_rejects_wrong_ca_tag() {
996 use crate::pase::kdf::derive_w0_w1;
997 use crate::pase::spake2plus::{compute_x, sample_scalar};
998 use ring::rand::SystemRandom;
999
1000 let rng = SystemRandom::new();
1001 let params = test_params();
1002
1003 // Verifier side.
1004 let mut v = PaseVerifier::new_from_pin(TEST_PIN, params.clone(), 0x0033).unwrap();
1005
1006 // Derive prover-side values to build a plausible Pake1.
1007 let (w0_scalar, _w1_scalar) =
1008 derive_w0_w1(TEST_PIN, ¶ms.salt, params.iterations).unwrap();
1009 let x_scalar = sample_scalar(&rng).unwrap();
1010 let x_bytes = compute_x(&x_scalar, &w0_scalar);
1011 let pake1_bytes = Pake1 { x: x_bytes }.encode().unwrap();
1012
1013 // Drive verifier: known-params path (skip PbkdfParamRequest).
1014 v.handle_pake1(&pake1_bytes).unwrap();
1015 let _pake2_bytes = v.next_message().unwrap();
1016
1017 // Send Pake3 with a wrong cA tag (all zeros — almost certainly wrong).
1018 let wrong_pake3 = Pake3 {
1019 verifier: [0x00u8; 32],
1020 };
1021 let wrong_pake3_bytes = wrong_pake3.encode().unwrap();
1022 assert!(matches!(
1023 v.handle_pake3(&wrong_pake3_bytes),
1024 Err(Error::ConfirmationTagMismatch)
1025 ));
1026 }
1027
1028 // ─── responder_session_id propagation ────────────────────────────────
1029
1030 #[test]
1031 fn verifier_advertises_responder_session_id() {
1032 let params = test_params();
1033 let mut verifier = PaseVerifier::new_from_pin(TEST_PIN, params, 0x0033).unwrap();
1034 // Drive a PBKDFParamRequest in so next_message() emits the response.
1035 let mut prover =
1036 crate::pase::prover::PaseProver::new_with_negotiation(TEST_PIN, 0x0001).unwrap();
1037 let req = prover.start().unwrap();
1038 verifier.handle_pbkdf_request(&req).unwrap();
1039 let resp = verifier.next_message().unwrap();
1040 let decoded = crate::pase::messages::PbkdfParamResponse::decode(&resp).unwrap();
1041 assert_eq!(decoded.responder_session_id, 0x0033);
1042 }
1043
1044 // ─── Known-params path (Pake1 as first message) ───────────────────────
1045
1046 #[test]
1047 fn handle_pake1_as_first_message_succeeds() {
1048 use crate::pase::kdf::derive_w0_w1;
1049 use crate::pase::spake2plus::{compute_x, sample_scalar};
1050 use ring::rand::SystemRandom;
1051
1052 let rng = SystemRandom::new();
1053 let params = test_params();
1054 let mut v = PaseVerifier::new_from_pin(TEST_PIN, params.clone(), 0x0033).unwrap();
1055
1056 let (w0_scalar, _) = derive_w0_w1(TEST_PIN, ¶ms.salt, params.iterations).unwrap();
1057 let x_scalar = sample_scalar(&rng).unwrap();
1058 let x_bytes = compute_x(&x_scalar, &w0_scalar);
1059 let pake1_bytes = Pake1 { x: x_bytes }.encode().unwrap();
1060
1061 // Feed Pake1 directly — no PbkdfParamRequest.
1062 v.handle_pake1(&pake1_bytes).unwrap();
1063 // Ready to send Pake2.
1064 assert_eq!(v.expected_inbound(), Some(PaseMessageKind::Pake3));
1065
1066 let pake2_bytes = v.next_message().unwrap();
1067 assert_eq!(pake2_bytes[0], 0x15, "Pake2 must be anon TLV structure");
1068
1069 let decoded = Pake2::decode(&pake2_bytes).unwrap();
1070 assert_eq!(
1071 decoded.y[0], 0x04,
1072 "Y must have SEC1 uncompressed prefix 0x04"
1073 );
1074 assert_eq!(decoded.verifier.len(), 32);
1075 }
1076}