matter_crypto/case/initiator.rs
1//! Initiator-side CASE state machine.
2//!
3//! Drives the 3-message Sigma1 / Sigma2 / Sigma3 handshake from the
4//! initiator's perspective. Sans-IO: the caller is responsible for
5//! transmitting and receiving bytes; this module only handles the
6//! cryptographic state transitions.
7//!
8//! # Protocol flow (new-session path — Matter Core Spec §4.13.2.4)
9//!
10//! ```text
11//! Initiator (us) Responder
12//! ─────────────────────────────────────────────────────────
13//! new() / new_using_rng()
14//! start()
15//! → Sigma1 ──────────────────────────────────────────>
16//! <──────────────────────────────── Sigma2
17//! handle_sigma2()
18//! next_message()
19//! → Sigma3 ──────────────────────────────────────────>
20//! <──────────────────────────────── StatusReport: Success
21//! finish() → CaseSessionOutput
22//! ```
23//!
24//! # Protocol flow (resumption path — Matter Core Spec §4.13.2.4)
25//!
26//! ```text
27//! Initiator (us) Responder
28//! ─────────────────────────────────────────────────────────
29//! new_with_resumption() / new_with_resumption_using_rng()
30//! start()
31//! → Sigma1 (with resumption_id + initiator_resume_mic) ──>
32//!
33//! Case A — responder accepts resumption:
34//! <──────────────── Sigma2_Resume
35//! handle_sigma2_resume()
36//! finish() → CaseSessionOutput (NO Sigma3 or Sigma3_Resume to send)
37//!
38//! Case B — responder declines (no matching record): sends normal Sigma2
39//! <──────────────── Sigma2
40//! handle_sigma2() (falls back to the new-session path)
41//! next_message()
42//! → Sigma3 ──────────────────────────────────────────>
43//! finish() → CaseSessionOutput
44//! ```
45//!
46//! **Note:** `Sigma3_Resume` does NOT exist as a wire message. After
47//! `handle_sigma2_resume` the initiator transitions directly to `Complete`;
48//! the implicit mutual-key-confirmation is the first encrypted M5 message.
49//!
50//! # KDF inputs (pinned from matter.js `CaseClient.ts` + `NodeSession.ts`)
51//!
52//! ## `DestinationId` (§4.13.2.4 step 1)
53//!
54//! ```text
55//! salt = initiatorRandom(32) || rcacPublicKey(65) || fabricId_le8 || nodeId_le8
56//! DestinationId = HMAC-SHA256(IPK, salt)
57//! ```
58//! Pinned from `Fabric.ts#generateSalt` + `signHmac(IPK, salt)`.
59//!
60//! ## S2K — Sigma2 TBE decryption key
61//!
62//! ```text
63//! sigma2Salt = IPK(16) || responderRandom(32) || responderEphPub(65) || SHA-256(s1_bytes)
64//! S2K = HKDF(secret=sharedSecret, salt=sigma2Salt, info="Sigma2", len=16)
65//! ```
66//! Pinned from `CaseClient.ts` lines 193–199.
67//!
68//! ## S3K — Sigma3 TBE encryption key
69//!
70//! ```text
71//! sigma3Salt = IPK(16) || SHA-256(s1_bytes || s2_bytes)
72//! S3K = HKDF(secret=sharedSecret, salt=sigma3Salt, info="Sigma3", len=16)
73//! ```
74//! Pinned from `CaseClient.ts` lines 244–248.
75//!
76//! ## Session keys
77//!
78//! ```text
79//! sessionSalt = IPK(16) || SHA-256(s1_bytes || s2_bytes || s3_bytes)
80//! keys(48) = HKDF(secret=sharedSecret, salt=sessionSalt, info="SessionKeys", len=48)
81//! i2r_key = keys[0..16] (initiator to responder encrypt key)
82//! r2i_key = keys[16..32] (responder to initiator decrypt key)
83//! attestation_challenge = keys[32..48]
84//! ```
85//! Pinned from `NodeSession.ts` lines 61–82 (`isInitiator=true` branch).
86//!
87//! ## `TBEData2` layout (plaintext after S2K decrypt)
88//!
89//! ```text
90//! TlvEncryptedDataSigma2 = {
91//! 1: responderNoc (bytes),
92//! 2: responderIcac (bytes, optional),
93//! 3: signature (64 bytes),
94//! 4: resumptionId (16 bytes),
95//! }
96//! ```
97//!
98//! ## `TBSData2` (signed payload verified from peer's NOC key)
99//!
100//! ```text
101//! TlvSignedData = {
102//! 1: responderNoc (bytes),
103//! 2: responderIcac (bytes, optional),
104//! 3: responderPublicKey (65 bytes) = responderEphPub,
105//! 4: initiatorPublicKey (65 bytes) = initiatorEphPub,
106//! }
107//! ```
108//! Pinned from `CaseMessages.ts` (`TlvSignedData`).
109//!
110//! ## `TBEData3` layout (plaintext before S3K encrypt)
111//!
112//! ```text
113//! TlvEncryptedDataSigma3 = {
114//! 1: responderNoc (bytes) = our NOC,
115//! 2: responderIcac (bytes, optional) = our ICAC,
116//! 3: signature (64 bytes),
117//! }
118//! ```
119//!
120//! ## `TBSData3` (what we sign with our NOC key)
121//!
122//! ```text
123//! TlvSignedData = {
124//! 1: responderNoc (bytes) = our NOC,
125//! 2: responderIcac (bytes, optional) = our ICAC,
126//! 3: responderPublicKey (65 bytes) = our ephemeral pub,
127//! 4: initiatorPublicKey (65 bytes) = peer's ephemeral pub,
128//! }
129//! ```
130//! Note: in Sigma3 the initiator plays the role of "responder" in `TlvSignedData`
131//! because the field names in matter.js's `TlvSignedData` were defined from the
132//! SIGMA-I Sigma2 perspective; `TlvSignedData` is re-used symmetrically in Sigma3.
133//! Pinned from `CaseClient.ts` lines 249–254.
134
135use p256::SecretKey;
136use ring::rand::{SecureRandom, SystemRandom};
137use zeroize::Zeroizing;
138
139use matter_cert::{CertificateChain, MatterCertificate, MatterTime, Signature, TrustedRoots};
140
141use crate::case::messages::{Sigma1, Sigma2, Sigma2Resume, Sigma3};
142use crate::case::sigma::{
143 aead_decrypt, aead_encrypt, compute_dest_id, compute_sigma1_resume_mic, decode_tbedata2,
144 derive_resume_session_keys, ecdh_shared_secret, encode_tbedata3, encode_tbs_data,
145 generate_ephemeral_keypair, hkdf_derive, transcript_hash, verify_sigma2_resume_mic,
146 AEAD_KEY_LEN, HKDF_INFO_SIGMA2, HKDF_INFO_SIGMA3, NONCE_TBE_DATA2, NONCE_TBE_DATA3,
147};
148use crate::case::{
149 CaseCredentials, CaseMessageKind, CaseSessionKeys, CaseSessionOutput, LocalInfo, PeerInfo,
150 ResumptionId, ResumptionRecord,
151};
152use crate::error::{Error, Result};
153
154// ---------------------------------------------------------------------------
155// HKDF info for session key derivation.
156// Pinned from matter.js NodeSession.ts line 41:
157// const SESSION_KEYS_INFO = Bytes.fromString("SessionKeys")
158// ---------------------------------------------------------------------------
159const HKDF_INFO_SESSION_KEYS: &[u8] = b"SessionKeys";
160
161// ---------------------------------------------------------------------------
162// State enum
163// ---------------------------------------------------------------------------
164
165/// Internal states of the initiator-side CASE handshake.
166///
167/// Named for the *next expected* action at each point.
168/// `Poisoned` is a sentinel used during `std::mem::replace` transitions;
169/// it is never observable to callers (all methods replace it immediately
170/// with either the next real state or an error return).
171#[derive(Debug)]
172enum State {
173 /// Initial state: `start()` has not been called yet.
174 ///
175 /// The ephemeral keypair and initiator random are pre-sampled here so
176 /// that `start()` cannot fail due to randomness.
177 AwaitingStart {
178 credentials: CaseCredentials,
179 trusted_roots: TrustedRoots,
180 peer_node_id: u64,
181 peer_fabric_id: u64,
182 eph_secret: SecretKey,
183 eph_pub: [u8; 65],
184 initiator_random: [u8; 32],
185 initiator_session_id: u16,
186 /// When `Some`, the caller supplied a prior-session record and `start()`
187 /// will populate Sigma1's resumption fields from it.
188 resumption_record: Option<ResumptionRecord>,
189 },
190
191 /// `start()` emitted Sigma1; waiting for the responder's Sigma2 (or
192 /// `Sigma2_Resume` if `resumption_attempt` is `Some`).
193 AwaitingSigma2 {
194 credentials: CaseCredentials,
195 trusted_roots: TrustedRoots,
196 peer_node_id: u64,
197 peer_fabric_id: u64,
198 eph_secret: SecretKey,
199 eph_pub: [u8; 65],
200 /// Used for resumption MIC computation and for the fallback new-session
201 /// `Sigma2` path. Always present on the wire in `sigma1.initiator_random`.
202 initiator_random: [u8; 32],
203 initiator_session_id: u16,
204 sigma1_bytes: Vec<u8>,
205 /// When `Some`, the Sigma1 we sent included resumption fields.
206 /// `handle_sigma2_resume` consumes this; `handle_sigma2` discards it.
207 resumption_attempt: Option<ResumptionRecord>,
208 },
209
210 /// `handle_sigma2()` has processed Sigma2 and produced Sigma3 + session
211 /// keys; `next_message()` will hand off the Sigma3 bytes and move to
212 /// `Complete`.
213 ReadyToSendSigma3 {
214 sigma3_bytes: Vec<u8>,
215 session_keys: CaseSessionKeys,
216 peer: PeerInfo,
217 local: LocalInfo,
218 /// Record built in `process_sigma2` from the responder's fresh
219 /// `resumption_id` (`TBEData2`) + this session's ECDH secret; carried
220 /// through to `Complete` for the caller to persist.
221 resumption_record: Option<ResumptionRecord>,
222 },
223
224 /// `next_message()` has emitted Sigma3 (or `handle_sigma2_resume` completed
225 /// the resumption path); `finish()` may be called.
226 Complete {
227 session_keys: CaseSessionKeys,
228 peer: PeerInfo,
229 local: LocalInfo,
230 /// Resumption record for the caller to persist. `None` when the responder
231 /// did not supply resumption-supporting session parameters. On the resumption
232 /// path this carries the updated record with the new ID from `Sigma2_Resume`.
233 resumption_record: Option<ResumptionRecord>,
234 },
235
236 /// Sentinel during `std::mem::replace` transitions.
237 Poisoned,
238}
239
240// ---------------------------------------------------------------------------
241// CaseInitiator
242// ---------------------------------------------------------------------------
243
244/// Initiator-side CASE state machine (new-session and resumption paths).
245///
246/// Drives the Sigma1 / Sigma2 / Sigma3 handshake (or the faster
247/// Sigma1 / `Sigma2_Resume` resumption path) from the initiator's perspective.
248/// Sans-IO: the caller feeds raw bytes in via [`handle_sigma2`][Self::handle_sigma2]
249/// or [`handle_sigma2_resume`][Self::handle_sigma2_resume] and reads raw bytes
250/// out via [`start`][Self::start] and [`next_message`][Self::next_message].
251///
252/// # Construction
253///
254/// New-session path:
255/// - [`CaseInitiator::new`] — production constructor; uses the OS CSPRNG.
256/// - `new_using_rng` (crate-internal) — deterministic constructor for tests.
257///
258/// Resumption path (M4.2):
259/// - [`CaseInitiator::new_with_resumption`] — production constructor with a
260/// prior-session [`ResumptionRecord`].
261/// - `new_with_resumption_using_rng` (crate-internal) — deterministic variant.
262///
263/// # Driving the new-session handshake
264///
265/// 1. Call [`start`][Self::start] → get Sigma1 bytes; send them.
266/// 2. Receive Sigma2 bytes from the peer.
267/// 3. Call [`handle_sigma2`][Self::handle_sigma2] with those bytes.
268/// 4. Call [`next_message`][Self::next_message] → get Sigma3 bytes; send them.
269/// 5. After the peer confirms with a `StatusReport: Success`, call
270/// [`finish`][Self::finish] to retrieve [`CaseSessionOutput`].
271///
272/// # Driving the resumption handshake
273///
274/// 1. Call [`start`][Self::start] → get Sigma1 bytes (with resumption fields); send them.
275/// 2. Receive the response from the peer:
276/// - If the peer accepts resumption: call [`handle_sigma2_resume`][Self::handle_sigma2_resume].
277/// Then call [`finish`][Self::finish] directly (no Sigma3 to send).
278/// - If the peer declines (sends a regular Sigma2): call [`handle_sigma2`][Self::handle_sigma2]
279/// normally, then [`next_message`][Self::next_message] and [`finish`][Self::finish].
280///
281/// Use [`expected_inbound`][Self::expected_inbound] at any point to query
282/// which message the machine is currently waiting to receive.
283pub struct CaseInitiator {
284 state: State,
285 /// Wall-clock instant at which inbound peer certificate chains are checked
286 /// for temporal validity (`not_before <= now <= not_after`). Injected at
287 /// construction so this crate never reads the system clock itself — the
288 /// controller layer supplies the real time. See `process_sigma2`.
289 validation_time: MatterTime,
290}
291
292impl CaseInitiator {
293 // ─── Public constructors ──────────────────────────────────────────────
294
295 /// Construct an initiator using the OS CSPRNG (new-session path).
296 ///
297 /// Pre-samples the ephemeral keypair and 32-byte initiator random so that
298 /// [`start`][Self::start] cannot fail due to randomness.
299 ///
300 /// `initiator_session_id` is the non-zero secured-session id this initiator
301 /// advertises in Sigma1 (tag 2) for the peer to address us by; it is recorded
302 /// as `CaseSessionOutput.local.session_id` once the handshake completes.
303 ///
304 /// For the resumption path, use [`new_with_resumption`][Self::new_with_resumption].
305 ///
306 /// `now` is the wall-clock instant against which the peer's operational
307 /// certificate chain is checked for temporal validity during Sigma2. This
308 /// crate never reads the system clock; the caller (controller layer) must
309 /// supply the real time.
310 ///
311 /// # Errors
312 ///
313 /// Returns [`Error::EphemeralKeyGenerationFailed`] if the OS RNG fails
314 /// (extremely unlikely in practice).
315 pub fn new(
316 credentials: CaseCredentials,
317 trusted_roots: TrustedRoots,
318 peer_node_id: u64,
319 peer_fabric_id: u64,
320 initiator_session_id: u16,
321 now: MatterTime,
322 ) -> Result<Self> {
323 let rng = SystemRandom::new();
324 Self::new_inner(
325 credentials,
326 trusted_roots,
327 peer_node_id,
328 peer_fabric_id,
329 initiator_session_id,
330 None,
331 now,
332 &rng,
333 )
334 }
335
336 /// Deterministic constructor for testing — accepts an injectable RNG.
337 ///
338 /// Production code should always use [`new`][Self::new].
339 ///
340 /// # Errors
341 ///
342 /// Returns [`Error::EphemeralKeyGenerationFailed`] if the RNG fails.
343 // Used in the case roundtrip integration test (tests/case_roundtrip.rs).
344 #[allow(dead_code)]
345 pub(crate) fn new_using_rng(
346 credentials: CaseCredentials,
347 trusted_roots: TrustedRoots,
348 peer_node_id: u64,
349 peer_fabric_id: u64,
350 now: MatterTime,
351 rng: &dyn SecureRandom,
352 ) -> Result<Self> {
353 Self::new_inner(
354 credentials,
355 trusted_roots,
356 peer_node_id,
357 peer_fabric_id,
358 0,
359 None,
360 now,
361 rng,
362 )
363 }
364
365 /// Construct an initiator with a prior-session [`ResumptionRecord`], using
366 /// the OS CSPRNG.
367 ///
368 /// When [`start`][Self::start] is called, the Sigma1 message will include
369 /// `resumption_id` (tag 6) and `initiator_resume_mic` (tag 7). The
370 /// responder may reply with `Sigma2_Resume` (call
371 /// [`handle_sigma2_resume`][Self::handle_sigma2_resume]) or fall back to a
372 /// regular `Sigma2` (call [`handle_sigma2`][Self::handle_sigma2]).
373 ///
374 /// `initiator_session_id` is the non-zero secured-session id we advertise
375 /// in Sigma1 (tag 2) for the peer to address us by, exactly as in
376 /// [`new`][Self::new].
377 ///
378 /// `now` is the wall-clock instant against which the peer's operational
379 /// certificate chain is checked for temporal validity during Sigma2 (used
380 /// only on the non-resumption fallback path). See [`new`][Self::new].
381 ///
382 /// # Errors
383 ///
384 /// Returns [`Error::EphemeralKeyGenerationFailed`] if the OS RNG fails.
385 pub fn new_with_resumption(
386 credentials: CaseCredentials,
387 trusted_roots: TrustedRoots,
388 peer_node_id: u64,
389 peer_fabric_id: u64,
390 record: ResumptionRecord,
391 initiator_session_id: u16,
392 now: MatterTime,
393 ) -> Result<Self> {
394 let rng = SystemRandom::new();
395 Self::new_with_resumption_using_rng(
396 credentials,
397 trusted_roots,
398 peer_node_id,
399 peer_fabric_id,
400 record,
401 initiator_session_id,
402 now,
403 &rng,
404 )
405 }
406
407 /// Deterministic resumption constructor for testing — accepts an injectable
408 /// RNG.
409 ///
410 /// Production code should always use
411 /// [`new_with_resumption`][Self::new_with_resumption].
412 ///
413 /// # Errors
414 ///
415 /// Returns [`Error::EphemeralKeyGenerationFailed`] if the RNG fails.
416 #[allow(clippy::too_many_arguments)]
417 pub(crate) fn new_with_resumption_using_rng(
418 credentials: CaseCredentials,
419 trusted_roots: TrustedRoots,
420 peer_node_id: u64,
421 peer_fabric_id: u64,
422 record: ResumptionRecord,
423 initiator_session_id: u16,
424 now: MatterTime,
425 rng: &dyn SecureRandom,
426 ) -> Result<Self> {
427 Self::new_inner(
428 credentials,
429 trusted_roots,
430 peer_node_id,
431 peer_fabric_id,
432 initiator_session_id,
433 Some(record),
434 now,
435 rng,
436 )
437 }
438
439 /// Deterministic new-session constructor for byte-parity testing — injects
440 /// a pre-computed ephemeral private key and initiator random, bypassing
441 /// the RNG entirely.
442 ///
443 /// This mirrors `new_using_rng` but derives the ephemeral public key
444 /// from the supplied private key bytes rather than sampling from an RNG.
445 /// The only valid caller is `test_support::case_initiator_with_eph_key`.
446 ///
447 /// # Errors
448 ///
449 /// Returns [`Error::EphemeralKeyGenerationFailed`] if `eph_private_key`
450 /// is zero, >= the P-256 curve order, or otherwise not a valid scalar.
451 pub(crate) fn new_with_eph_and_random(
452 credentials: CaseCredentials,
453 trusted_roots: TrustedRoots,
454 peer_node_id: u64,
455 peer_fabric_id: u64,
456 eph_private_key: [u8; 32],
457 initiator_random: [u8; 32],
458 now: MatterTime,
459 ) -> Result<Self> {
460 use p256::elliptic_curve::sec1::ToEncodedPoint;
461 use p256::NonZeroScalar;
462 let scalar_opt = NonZeroScalar::from_repr(eph_private_key.into());
463 let scalar =
464 Option::<NonZeroScalar>::from(scalar_opt).ok_or(Error::EphemeralKeyGenerationFailed)?;
465 let eph_secret = SecretKey::new(scalar.into());
466 let encoded = eph_secret.public_key().to_encoded_point(false);
467 let mut eph_pub = [0u8; 65];
468 eph_pub.copy_from_slice(encoded.as_bytes());
469 Ok(Self {
470 state: State::AwaitingStart {
471 credentials,
472 trusted_roots,
473 peer_node_id,
474 peer_fabric_id,
475 eph_secret,
476 eph_pub,
477 initiator_random,
478 initiator_session_id: 0,
479 resumption_record: None,
480 },
481 validation_time: now,
482 })
483 }
484
485 /// Deterministic resumption constructor for byte-parity testing — injects
486 /// a pre-computed ephemeral private key and initiator random, bypassing
487 /// the RNG entirely.
488 ///
489 /// Same as `new_with_eph_and_random` but includes a prior-session
490 /// `ResumptionRecord` so that Sigma1 carries resumption fields.
491 /// The only valid caller is `test_support::case_initiator_with_resumption_eph_key`.
492 ///
493 /// # Errors
494 ///
495 /// Returns [`Error::EphemeralKeyGenerationFailed`] if `eph_private_key`
496 /// is zero, >= the P-256 curve order, or otherwise not a valid scalar.
497 #[allow(clippy::too_many_arguments)]
498 pub(crate) fn new_with_resumption_eph_and_random(
499 credentials: CaseCredentials,
500 trusted_roots: TrustedRoots,
501 peer_node_id: u64,
502 peer_fabric_id: u64,
503 record: ResumptionRecord,
504 eph_private_key: [u8; 32],
505 initiator_random: [u8; 32],
506 now: MatterTime,
507 ) -> Result<Self> {
508 use p256::elliptic_curve::sec1::ToEncodedPoint;
509 use p256::NonZeroScalar;
510 let scalar_opt = NonZeroScalar::from_repr(eph_private_key.into());
511 let scalar =
512 Option::<NonZeroScalar>::from(scalar_opt).ok_or(Error::EphemeralKeyGenerationFailed)?;
513 let eph_secret = SecretKey::new(scalar.into());
514 let encoded = eph_secret.public_key().to_encoded_point(false);
515 let mut eph_pub = [0u8; 65];
516 eph_pub.copy_from_slice(encoded.as_bytes());
517 Ok(Self {
518 state: State::AwaitingStart {
519 credentials,
520 trusted_roots,
521 peer_node_id,
522 peer_fabric_id,
523 eph_secret,
524 eph_pub,
525 initiator_random,
526 initiator_session_id: 0,
527 resumption_record: Some(record),
528 },
529 validation_time: now,
530 })
531 }
532
533 /// Internal shared constructor: produces an `AwaitingStart` state with
534 /// an optional resumption record baked in.
535 ///
536 /// Called by all four public/crate-internal constructors.
537 #[allow(clippy::too_many_arguments)]
538 fn new_inner(
539 credentials: CaseCredentials,
540 trusted_roots: TrustedRoots,
541 peer_node_id: u64,
542 peer_fabric_id: u64,
543 initiator_session_id: u16,
544 resumption_record: Option<ResumptionRecord>,
545 now: MatterTime,
546 rng: &dyn SecureRandom,
547 ) -> Result<Self> {
548 let (eph_secret, eph_pub) = generate_ephemeral_keypair(rng)?;
549 let mut initiator_random = [0u8; 32];
550 rng.fill(&mut initiator_random)
551 .map_err(|_| Error::EphemeralKeyGenerationFailed)?;
552 Ok(Self {
553 state: State::AwaitingStart {
554 credentials,
555 trusted_roots,
556 peer_node_id,
557 peer_fabric_id,
558 eph_secret,
559 eph_pub,
560 initiator_random,
561 initiator_session_id,
562 resumption_record,
563 },
564 validation_time: now,
565 })
566 }
567
568 // ─── State inspection ─────────────────────────────────────────────────
569
570 /// Returns the CASE message kind the machine is currently waiting to
571 /// receive, or `None` if the machine is in an outbound-only state,
572 /// has completed, or has been poisoned.
573 ///
574 /// On the resumption path (after `start()` was called with a resumption
575 /// record), returns `Sigma2Resume` to indicate that the peer may send either
576 /// `Sigma2_Resume` (accepted) or a plain `Sigma2` (declined fallback). The
577 /// returned value is advisory — the caller must inspect the actual inbound
578 /// message type and route to the appropriate `handle_*` method.
579 pub fn expected_inbound(&self) -> Option<CaseMessageKind> {
580 match &self.state {
581 State::AwaitingSigma2 {
582 resumption_attempt: Some(_),
583 ..
584 } => Some(CaseMessageKind::Sigma2Resume),
585 State::AwaitingSigma2 {
586 resumption_attempt: None,
587 ..
588 } => Some(CaseMessageKind::Sigma2),
589 _ => None,
590 }
591 }
592
593 // ─── Handshake methods ────────────────────────────────────────────────
594
595 /// Produce the Sigma1 message bytes and advance to `AwaitingSigma2`.
596 ///
597 /// On the resumption path (constructed with
598 /// [`new_with_resumption`][Self::new_with_resumption]), the emitted Sigma1
599 /// will include `resumption_id` (tag 6) and `initiator_resume_mic` (tag 7),
600 /// signalling to the responder that it may send `Sigma2_Resume` instead of
601 /// `Sigma2`.
602 ///
603 /// # Errors
604 ///
605 /// - [`Error::UnexpectedCaseMessage`] if called from the wrong state.
606 /// - [`Error::Codec`] on TLV encoding failure.
607 /// - [`Error::EphemeralKeyGenerationFailed`] if MIC computation fails
608 /// (only possible if AES-CCM internal state is inconsistent — not expected
609 /// in practice).
610 pub fn start(&mut self) -> Result<Vec<u8>> {
611 let prev = std::mem::replace(&mut self.state, State::Poisoned);
612 match prev {
613 State::AwaitingStart {
614 credentials,
615 trusted_roots,
616 peer_node_id,
617 peer_fabric_id,
618 eph_secret,
619 eph_pub,
620 initiator_random,
621 initiator_session_id,
622 resumption_record,
623 } => {
624 let dest_id = compute_dest_id(
625 &credentials.ipk,
626 &credentials.rcac_public_key,
627 credentials.fabric_id,
628 peer_node_id,
629 &initiator_random,
630 );
631
632 // Populate resumption fields when we have a prior-session record.
633 // `sigma1_resume_mic` is derived from the shared_secret and the OLD
634 // resumption_id (the one already stored in the record), using the
635 // freshly-sampled initiator_random as part of the HKDF salt.
636 // This lets the responder verify we hold the correct shared secret
637 // without exposing the secret itself.
638 let (resumption_id_field, initiator_resume_mic_field) = match &resumption_record {
639 Some(record) => {
640 let mic = compute_sigma1_resume_mic(
641 &record.shared_secret,
642 &initiator_random,
643 &record.id.0,
644 )?;
645 (Some(record.id.0), Some(mic))
646 }
647 None => (None, None),
648 };
649
650 let sigma1 = Sigma1 {
651 initiator_random,
652 initiator_session_id,
653 dest_id,
654 initiator_eph_pub: eph_pub,
655 initiator_session_params: None,
656 resumption_id: resumption_id_field,
657 initiator_resume_mic: initiator_resume_mic_field,
658 };
659 let sigma1_bytes = sigma1.encode()?;
660
661 self.state = State::AwaitingSigma2 {
662 credentials,
663 trusted_roots,
664 peer_node_id,
665 peer_fabric_id,
666 eph_secret,
667 eph_pub,
668 initiator_random,
669 initiator_session_id,
670 sigma1_bytes: sigma1_bytes.clone(),
671 resumption_attempt: resumption_record,
672 };
673 Ok(sigma1_bytes)
674 }
675 other => {
676 self.state = other;
677 Err(Error::UnexpectedCaseMessage {
678 expected: CaseMessageKind::Sigma1,
679 got: CaseMessageKind::Sigma2,
680 })
681 }
682 }
683 }
684
685 /// Process the inbound Sigma2 message, verify the peer's credentials,
686 /// and produce Sigma3.
687 ///
688 /// After this call succeeds, call [`next_message`][Self::next_message] to
689 /// retrieve the Sigma3 bytes that must be sent to the responder.
690 ///
691 /// # Sigma2 processing steps
692 ///
693 /// 1. Parse Sigma2 TLV.
694 /// 2. ECDH shared secret from our ephemeral secret + peer's ephemeral
695 /// public key.
696 /// 3. Derive S2K via HKDF (see module doc for salt composition).
697 /// 4. AES-128-CCM decrypt the encrypted blob using S2K and the
698 /// `NCASE_Sigma2N` nonce.
699 /// 5. Parse `TBEData2` = `{ responderNoc, responderIcac?, signature,
700 /// resumptionId }`.
701 /// 6. Validate the peer's NOC chain against `trusted_roots`.
702 /// 7. Check that the NOC's `NodeId` and `FabricId` match expectations.
703 /// 8. Verify the peer's ECDSA signature over `TBSData2`.
704 /// 9. Build `TBSData3`, sign with our NOC's private key.
705 /// 10. Encode `TBEData3`, encrypt with S3K and `NCASE_Sigma3N` nonce.
706 /// 11. Derive the final session keys.
707 ///
708 /// # Errors
709 ///
710 /// - [`Error::UnexpectedCaseMessage`] if called from the wrong state.
711 /// - [`Error::Codec`] on TLV decode / encode failure.
712 /// - [`Error::InvalidParameter`] if the peer's ephemeral public key is
713 /// not a valid P-256 point.
714 /// - [`Error::EncryptedBlobDecryptionFailed`] if the encrypted blob
715 /// fails AEAD verification.
716 /// - [`Error::InvalidPeerNocChain`] if chain validation fails.
717 /// - [`Error::FabricIdMismatch`] / [`Error::PeerNodeIdMismatch`] if the
718 /// peer's identity doesn't match expectations.
719 /// - [`Error::PeerSignatureInvalid`] if the peer's ECDSA signature fails.
720 /// - [`Error::SigningFailed`] if our own signing operation fails.
721 /// - [`Error::EphemeralKeyGenerationFailed`] on HKDF failure.
722 pub fn handle_sigma2(&mut self, bytes: &[u8]) -> Result<()> {
723 let now = self.validation_time;
724 let prev = std::mem::replace(&mut self.state, State::Poisoned);
725 match prev {
726 State::AwaitingSigma2 {
727 credentials,
728 trusted_roots,
729 peer_node_id,
730 peer_fabric_id,
731 eph_secret,
732 eph_pub,
733 initiator_random: _,
734 initiator_session_id,
735 sigma1_bytes,
736 resumption_attempt: _, // Responder declined (or never attempted) — discard.
737 } => {
738 let (sigma3_bytes, session_keys, peer, local, resumption_record) = process_sigma2(
739 bytes,
740 &credentials,
741 &trusted_roots,
742 peer_node_id,
743 peer_fabric_id,
744 &eph_secret,
745 &eph_pub,
746 initiator_session_id,
747 &sigma1_bytes,
748 now,
749 )?;
750 self.state = State::ReadyToSendSigma3 {
751 sigma3_bytes,
752 session_keys,
753 peer,
754 local,
755 resumption_record: Some(resumption_record),
756 };
757 Ok(())
758 }
759 other => {
760 self.state = other;
761 Err(Error::UnexpectedCaseMessage {
762 expected: CaseMessageKind::Sigma2,
763 got: CaseMessageKind::Sigma3,
764 })
765 }
766 }
767 }
768
769 /// Process the inbound `Sigma2_Resume` message and complete the resumption
770 /// handshake.
771 ///
772 /// May only be called after [`start`][Self::start] when the initiator was
773 /// constructed with [`new_with_resumption`][Self::new_with_resumption] (i.e.,
774 /// the sent Sigma1 carried resumption fields).
775 ///
776 /// **No `Sigma3_Resume` or `Sigma3` to send.** After this call succeeds the
777 /// handshake is complete from the initiator's side. Call [`finish`][Self::finish]
778 /// directly to obtain the [`CaseSessionOutput`].
779 ///
780 /// # Resumption session-key layout
781 ///
782 /// Pinned from matter.js `NodeSession.create` (`isResumption = true` branch):
783 /// ```text
784 /// keys = HKDF(ikm = shared_secret,
785 /// salt = initiatorRandom || OLD_resumption_id,
786 /// info = "SessionResumptionKeys",
787 /// len = 48)
788 /// keys[0..16] → r2i_key (responder-to-initiator)
789 /// keys[16..32] → i2r_key (initiator-to-responder)
790 /// keys[32..48] → attestation_challenge
791 /// ```
792 /// Note the **reversed** byte assignment vs the new-session path
793 /// (where `keys[0..16]` is `i2r` and `keys[16..32]` is `r2i`).
794 ///
795 /// # Errors
796 ///
797 /// - [`Error::UnexpectedCaseMessage`] if called from the wrong state or if
798 /// the initiator never attempted resumption.
799 /// - [`Error::Codec`] on TLV decode failure.
800 /// - [`Error::ResumptionMacMismatch`] if the `sigma2_resume_mic` in the
801 /// message does not verify.
802 /// - [`Error::EphemeralKeyGenerationFailed`] on HKDF failure.
803 pub fn handle_sigma2_resume(&mut self, bytes: &[u8]) -> Result<()> {
804 let prev = std::mem::replace(&mut self.state, State::Poisoned);
805 match prev {
806 State::AwaitingSigma2 {
807 credentials,
808 initiator_random,
809 initiator_session_id,
810 resumption_attempt: Some(record),
811 // The new-session fields below are not needed for the resumption
812 // path but must be destructured to satisfy exhaustiveness.
813 trusted_roots: _,
814 peer_node_id: _,
815 peer_fabric_id: _,
816 eph_secret: _,
817 eph_pub: _,
818 sigma1_bytes: _,
819 } => {
820 let sigma2_resume = Sigma2Resume::decode(bytes)?;
821 let new_resumption_id = sigma2_resume.resumption_id;
822
823 // Step 1: Verify sigma2_resume_mic.
824 // The MIC is computed over the NEW resumption_id (generated by
825 // the responder for this session), using initiatorRandom as part
826 // of the HKDF salt. This proves the responder holds the same
827 // shared_secret as the record.
828 verify_sigma2_resume_mic(
829 &record.shared_secret,
830 &initiator_random,
831 &new_resumption_id,
832 &sigma2_resume.resume_mic,
833 )?;
834
835 // Step 2: Derive resumed session keys.
836 // Salt uses initiatorRandom || OLD resumptionId (record.id.0).
837 // info = "SessionResumptionKeys", len = 48.
838 // Key layout: [0..16]=i2r, [16..32]=r2i, [32..48]=attestation —
839 // the SAME as the new-session layout (chip's
840 // CryptoContext::InitFromSecret splits I2RKey || R2IKey ||
841 // AttestationChallenge for kSessionResumption too; live-verified
842 // against chip's OTA requestor).
843 let blob = derive_resume_session_keys(
844 &record.shared_secret,
845 &initiator_random,
846 &record.id.0,
847 )?;
848 let mut i2r_key = [0u8; 16];
849 let mut r2i_key = [0u8; 16];
850 let mut attestation_challenge = [0u8; 16];
851 i2r_key.copy_from_slice(&blob[0..16]);
852 r2i_key.copy_from_slice(&blob[16..32]);
853 attestation_challenge.copy_from_slice(&blob[32..48]);
854 let session_keys = CaseSessionKeys {
855 i2r_key,
856 r2i_key,
857 attestation_challenge,
858 };
859
860 // Step 3: Build the next resumption record.
861 // The responder supplied a fresh resumption_id (new_resumption_id)
862 // for use in the next resumption attempt. The shared_secret is
863 // re-used unchanged — matter.js does not re-derive it on resumption.
864 // (If this turns out to be wrong, M4.3 byte-parity testing will
865 // surface it; setting it to None is the safe conservative fallback,
866 // but re-using is the observed matter.js behaviour.)
867 let next_record = ResumptionRecord {
868 id: ResumptionId(new_resumption_id),
869 shared_secret: record.shared_secret, // re-use unchanged
870 peer: record.peer.clone(),
871 expires_at: None, // M6 commissioning sets a real expiry.
872 };
873
874 // Step 4: Build peer / local identity structs.
875 // The resumption path re-uses the cached peer identity from the
876 // record (we didn't verify a fresh NOC chain — that's the point of
877 // resumption). The peer's session ID comes from Sigma2_Resume.
878 let peer = PeerInfo {
879 session_id: sigma2_resume.responder_session_id,
880 // `record` is `Drop` (ZeroizeOnDrop), so its non-`Copy`
881 // fields cannot be moved out — clone the peer identity.
882 ..record.peer.clone()
883 };
884 let local = LocalInfo {
885 node_id: credentials.node_id,
886 fabric_id: credentials.fabric_id,
887 session_id: initiator_session_id,
888 };
889
890 // No Sigma3_Resume — transition directly to Complete.
891 self.state = State::Complete {
892 session_keys,
893 peer,
894 local,
895 resumption_record: Some(next_record),
896 };
897 Ok(())
898 }
899
900 // Initiator never attempted resumption; receiving Sigma2_Resume is a
901 // protocol violation. State is left Poisoned (unrecoverable).
902 State::AwaitingSigma2 {
903 resumption_attempt: None,
904 ..
905 } => Err(Error::UnexpectedCaseMessage {
906 expected: CaseMessageKind::Sigma2,
907 got: CaseMessageKind::Sigma2Resume,
908 }),
909
910 // Any other state is also invalid.
911 other => {
912 self.state = other;
913 Err(Error::UnexpectedCaseMessage {
914 expected: CaseMessageKind::Sigma2Resume,
915 got: CaseMessageKind::Sigma2Resume,
916 })
917 }
918 }
919 }
920
921 /// Retrieve the next outbound message (Sigma3) and advance to `Complete`.
922 ///
923 /// Must be called after a successful [`handle_sigma2`][Self::handle_sigma2].
924 ///
925 /// # Errors
926 ///
927 /// - [`Error::UnexpectedCaseMessage`] if called from the wrong state.
928 pub fn next_message(&mut self) -> Result<Vec<u8>> {
929 let prev = std::mem::replace(&mut self.state, State::Poisoned);
930 match prev {
931 State::ReadyToSendSigma3 {
932 sigma3_bytes,
933 session_keys,
934 peer,
935 local,
936 resumption_record,
937 } => {
938 self.state = State::Complete {
939 session_keys,
940 peer,
941 local,
942 resumption_record,
943 };
944 Ok(sigma3_bytes)
945 }
946 other => {
947 self.state = other;
948 Err(Error::UnexpectedCaseMessage {
949 expected: CaseMessageKind::Sigma3,
950 got: CaseMessageKind::Sigma1,
951 })
952 }
953 }
954 }
955
956 /// Finalise the session and retrieve the derived [`CaseSessionOutput`].
957 ///
958 /// May only be called after [`next_message`][Self::next_message] has
959 /// emitted Sigma3 (i.e., the state machine is in the `Complete` state).
960 ///
961 /// # Errors
962 ///
963 /// - [`Error::HandshakeIncomplete`] if called before all handshake phases
964 /// have completed.
965 pub fn finish(self) -> Result<CaseSessionOutput> {
966 match self.state {
967 State::Complete {
968 session_keys,
969 peer,
970 local,
971 resumption_record,
972 } => Ok(CaseSessionOutput {
973 keys: session_keys,
974 peer,
975 local,
976 resumption_record,
977 }),
978 _ => Err(Error::HandshakeIncomplete),
979 }
980 }
981}
982
983// ---------------------------------------------------------------------------
984// Helper: Sigma2 processing inner logic
985// ---------------------------------------------------------------------------
986
987/// Execute the full Sigma2 verification + Sigma3 construction logic.
988///
989/// Extracted from `CaseInitiator::handle_sigma2` to keep that method's
990/// line count within the `clippy::too_many_lines` limit.
991///
992/// Returns `(sigma3_bytes, session_keys, peer, local, resumption_record)` on
993/// success. The `resumption_record` pairs the responder's fresh
994/// `resumption_id` (from `TBEData2`) with this session's ECDH `SharedSecret`;
995/// the caller persists it for a future `Sigma1` resumption fast-path.
996///
997/// # Errors
998///
999/// See `CaseInitiator::handle_sigma2` for the full error taxonomy.
1000// The 10-step SIGMA-I protocol is intentionally kept as one function for
1001// auditability: a reviewer must be able to trace every step in sequence
1002// without jumping across files. The 100-line limit is relaxed here.
1003#[allow(clippy::too_many_lines)]
1004#[allow(clippy::too_many_arguments)]
1005fn process_sigma2(
1006 sigma2_bytes: &[u8],
1007 credentials: &CaseCredentials,
1008 trusted_roots: &TrustedRoots,
1009 peer_node_id: u64,
1010 peer_fabric_id: u64,
1011 eph_secret: &SecretKey,
1012 eph_pub: &[u8; 65],
1013 initiator_session_id: u16,
1014 sigma1_bytes: &[u8],
1015 now: MatterTime,
1016) -> Result<(
1017 Vec<u8>,
1018 CaseSessionKeys,
1019 PeerInfo,
1020 LocalInfo,
1021 ResumptionRecord,
1022)> {
1023 let sigma2 = Sigma2::decode(sigma2_bytes)?;
1024
1025 // Step 1: ECDH shared secret from our eph secret + peer's eph pub.
1026 // Wrap in `Zeroizing` so the raw ECDH output is wiped when this function
1027 // returns; it is the root secret all Sigma2/Sigma3/session keys derive from.
1028 let shared_secret = Zeroizing::new(ecdh_shared_secret(eph_secret, &sigma2.responder_eph_pub)?);
1029
1030 // Step 2: Derive S2K.
1031 // sigma2Salt = IPK(16) || responderRandom(32) || responderEphPub(65) || SHA-256(sigma1)
1032 let h_sigma1 = transcript_hash(&[sigma1_bytes]);
1033 let mut sigma2_salt: Vec<u8> = Vec::with_capacity(16 + 32 + 65 + 32);
1034 sigma2_salt.extend_from_slice(&credentials.ipk);
1035 sigma2_salt.extend_from_slice(&sigma2.responder_random);
1036 sigma2_salt.extend_from_slice(&sigma2.responder_eph_pub);
1037 sigma2_salt.extend_from_slice(&h_sigma1);
1038 // `s2k` is a derived secret key; wrap in `Zeroizing` so it is wiped on return.
1039 let mut s2k = Zeroizing::new([0u8; AEAD_KEY_LEN]);
1040 hkdf_derive(
1041 shared_secret.as_slice(),
1042 &sigma2_salt,
1043 HKDF_INFO_SIGMA2,
1044 &mut *s2k,
1045 )?;
1046
1047 // Step 3: AES-128-CCM decrypt.
1048 let sigma2_decrypted = aead_decrypt(&s2k, NONCE_TBE_DATA2, b"", &sigma2.encrypted)?;
1049
1050 // Step 4: Parse TBEData2.
1051 let peer_tbe = decode_tbedata2(&sigma2_decrypted)?;
1052
1053 // Step 5: Validate peer NOC chain against trusted roots at the injected
1054 // wall-clock instant (`not_before <= now <= not_after`). The clock is
1055 // supplied by the caller via the constructor; this crate never reads the
1056 // system clock.
1057 let chain_certs: Vec<MatterCertificate> = match &peer_tbe.peer_icac {
1058 Some(icac) => vec![peer_tbe.peer_noc.clone(), icac.clone()],
1059 None => vec![peer_tbe.peer_noc.clone()],
1060 };
1061 CertificateChain::new(&chain_certs)
1062 .validate(trusted_roots, now)
1063 .map_err(Error::InvalidPeerNocChain)?;
1064
1065 // Step 6: Check peer NodeId + FabricId match expectations.
1066 let peer_dn = peer_tbe.peer_noc.subject();
1067 let verified_node_id = peer_dn
1068 .node_id()
1069 .ok_or(Error::PeerNodeIdMismatch(0, peer_node_id))?;
1070 let verified_fabric_id = peer_dn.fabric_id().ok_or(Error::FabricIdMismatch {
1071 peer: 0,
1072 local: peer_fabric_id,
1073 })?;
1074 if verified_node_id != peer_node_id {
1075 return Err(Error::PeerNodeIdMismatch(verified_node_id, peer_node_id));
1076 }
1077 if verified_fabric_id != peer_fabric_id {
1078 return Err(Error::FabricIdMismatch {
1079 peer: verified_fabric_id,
1080 local: peer_fabric_id,
1081 });
1082 }
1083
1084 // Step 7: Verify peer's ECDSA signature over TBSData2.
1085 // TBSData2 = TlvSignedData { responderNoc, responderIcac?, responderEphPub, initiatorEphPub }
1086 let peer_noc_tlv = peer_tbe
1087 .peer_noc
1088 .to_tlv()
1089 .map_err(Error::InvalidPeerNocChain)?;
1090 let peer_icac_tlv: Option<Vec<u8>> = match &peer_tbe.peer_icac {
1091 Some(icac) => Some(icac.to_tlv().map_err(Error::InvalidPeerNocChain)?),
1092 None => None,
1093 };
1094 let peer_signed_data = encode_tbs_data(
1095 &peer_noc_tlv,
1096 peer_icac_tlv.as_deref(),
1097 &sigma2.responder_eph_pub,
1098 eph_pub,
1099 )?;
1100 let peer_sig =
1101 Signature::from_slice(&peer_tbe.peer_signature).map_err(|_| Error::PeerSignatureInvalid)?;
1102 peer_tbe
1103 .peer_noc
1104 .public_key()
1105 .verify(&peer_signed_data, &peer_sig)
1106 .map_err(|_| Error::PeerSignatureInvalid)?;
1107
1108 // Step 8: Build TBSData3 and sign with our NOC key.
1109 // The initiator plays the "responder" role in TlvSignedData because the
1110 // field names were defined from Sigma2's perspective. (CaseClient.ts lines 249–254)
1111 let our_noc_tlv = credentials
1112 .noc
1113 .to_tlv()
1114 .map_err(|_| Error::SigningFailed(crate::case::signer::SignerError::Internal))?;
1115 let our_icac_tlv: Option<Vec<u8>> = match &credentials.icac {
1116 Some(icac) => Some(
1117 icac.to_tlv()
1118 .map_err(|_| Error::SigningFailed(crate::case::signer::SignerError::Internal))?,
1119 ),
1120 None => None,
1121 };
1122 let our_signed_data = encode_tbs_data(
1123 &our_noc_tlv,
1124 our_icac_tlv.as_deref(),
1125 eph_pub, // our eph pub = "responderPublicKey"
1126 &sigma2.responder_eph_pub, // peer's eph pub = "initiatorPublicKey"
1127 )?;
1128 let our_signature = credentials
1129 .signer
1130 .sign_p256_sha256(&our_signed_data)
1131 .map_err(Error::SigningFailed)?;
1132
1133 // Step 9: Encode TBEData3 and encrypt with S3K.
1134 // sigma3Salt = IPK(16) || SHA-256(sigma1 || sigma2)
1135 let h_s1_s2 = transcript_hash(&[sigma1_bytes, sigma2_bytes]);
1136 let mut sigma3_salt: Vec<u8> = Vec::with_capacity(16 + 32);
1137 sigma3_salt.extend_from_slice(&credentials.ipk);
1138 sigma3_salt.extend_from_slice(&h_s1_s2);
1139 // `s3k` is a derived secret key; wrap in `Zeroizing` so it is wiped on return.
1140 let mut s3k = Zeroizing::new([0u8; AEAD_KEY_LEN]);
1141 hkdf_derive(
1142 shared_secret.as_slice(),
1143 &sigma3_salt,
1144 HKDF_INFO_SIGMA3,
1145 &mut *s3k,
1146 )?;
1147
1148 let sigma3_plaintext = encode_tbedata3(&our_noc_tlv, our_icac_tlv.as_deref(), &our_signature)?;
1149 let encrypted3 = aead_encrypt(&s3k, NONCE_TBE_DATA3, b"", &sigma3_plaintext)?;
1150 let sigma3_bytes = Sigma3 {
1151 encrypted: encrypted3,
1152 }
1153 .encode()?;
1154
1155 // Step 10: Derive final session keys.
1156 // sessionSalt = IPK(16) || SHA-256(sigma1 || sigma2 || sigma3)
1157 let h_all = transcript_hash(&[sigma1_bytes, sigma2_bytes, &sigma3_bytes]);
1158 let mut session_salt: Vec<u8> = Vec::with_capacity(16 + 32);
1159 session_salt.extend_from_slice(&credentials.ipk);
1160 session_salt.extend_from_slice(&h_all);
1161 // `keys_blob` holds the raw 48-byte session-key material; wrap in
1162 // `Zeroizing` so it is wiped once the per-direction keys are split out.
1163 let mut keys_blob = Zeroizing::new([0u8; 48]);
1164 hkdf_derive(
1165 shared_secret.as_slice(),
1166 &session_salt,
1167 HKDF_INFO_SESSION_KEYS,
1168 &mut *keys_blob,
1169 )?;
1170
1171 // Initiator key assignment (NodeSession.ts lines 75–77, isInitiator=true):
1172 // encryptKey (i2r) = keys[0..16]
1173 // decryptKey (r2i) = keys[16..32]
1174 // attestationChallenge = keys[32..48]
1175 let mut i2r_key = [0u8; 16];
1176 let mut r2i_key = [0u8; 16];
1177 let mut attestation_challenge = [0u8; 16];
1178 i2r_key.copy_from_slice(&keys_blob[0..16]);
1179 r2i_key.copy_from_slice(&keys_blob[16..32]);
1180 attestation_challenge.copy_from_slice(&keys_blob[32..48]);
1181
1182 let session_keys = CaseSessionKeys {
1183 i2r_key,
1184 r2i_key,
1185 attestation_challenge,
1186 };
1187
1188 let peer = PeerInfo {
1189 node_id: verified_node_id,
1190 fabric_id: verified_fabric_id,
1191 noc: peer_tbe.peer_noc,
1192 session_id: sigma2.responder_session_id,
1193 };
1194 let local = LocalInfo {
1195 node_id: credentials.node_id,
1196 fabric_id: credentials.fabric_id,
1197 session_id: initiator_session_id,
1198 };
1199
1200 // Step 11: Build the resumption record for the caller to persist.
1201 // The responder's fresh resumption id arrived (encrypted) in TBEData2;
1202 // the record's IKM is this session's raw ECDH SharedSecret — the same
1203 // (id, secret) pair chip's SessionResumptionStorage and matter.js store,
1204 // so either peer can later initiate resumption against the other.
1205 let resumption_record = ResumptionRecord {
1206 id: ResumptionId(peer_tbe.resumption_id),
1207 shared_secret: *shared_secret,
1208 peer: peer.clone(),
1209 expires_at: None,
1210 };
1211
1212 Ok((sigma3_bytes, session_keys, peer, local, resumption_record))
1213}
1214
1215// ---------------------------------------------------------------------------
1216// Tests
1217// ---------------------------------------------------------------------------
1218
1219#[cfg(test)]
1220#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
1221mod tests {
1222 use super::*;
1223 use crate::case::signer::{CaseSigner, RingSigner};
1224 use matter_cert::test_support::{build_unsigned, TestCertFields};
1225 use matter_cert::{
1226 BasicConstraints, DistinguishedName, DnAttribute, Extensions, MatterTime, TrustAnchor,
1227 TrustedRoots,
1228 };
1229
1230 // ─── Test helpers ─────────────────────────────────────────────────────
1231
1232 /// Build a minimal `MatterCertificate` suitable for unit tests.
1233 ///
1234 /// The cert is not validly signed; its purpose is to let state-machine
1235 /// tests exercise paths that don't reach chain validation.
1236 fn make_test_cert(node_id: u64, fabric_id: u64) -> MatterCertificate {
1237 let (signer, _) = RingSigner::generate().unwrap();
1238 let pk_bytes = *signer.public_key().as_bytes();
1239 let pub_key = matter_cert::PublicKey::new(pk_bytes).unwrap();
1240 let subject = DistinguishedName::new(vec![
1241 DnAttribute::FabricId(fabric_id),
1242 DnAttribute::NodeId(node_id),
1243 ]);
1244 let issuer = DistinguishedName::new(vec![DnAttribute::RcacId(1)]);
1245 let extensions = Extensions::builder()
1246 .basic_constraints(Some(BasicConstraints::new(false, None)))
1247 .build();
1248 build_unsigned(TestCertFields {
1249 serial: vec![1],
1250 issuer,
1251 not_before: MatterTime::from_unix_secs(0),
1252 not_after: MatterTime::NO_EXPIRY,
1253 subject,
1254 public_key: pub_key,
1255 extensions,
1256 signature: matter_cert::Signature::new([0u8; 64]),
1257 })
1258 }
1259
1260 /// Build a `CaseCredentials` with a fresh `RingSigner` keypair.
1261 fn make_test_credentials(
1262 node_id: u64,
1263 fabric_id: u64,
1264 ipk: [u8; 16],
1265 rcac_public_key: [u8; 65],
1266 ) -> CaseCredentials {
1267 let (signer, _) = RingSigner::generate().unwrap();
1268 let noc = make_test_cert(node_id, fabric_id);
1269 CaseCredentials {
1270 noc,
1271 icac: None,
1272 signer: Box::new(signer),
1273 fabric_id,
1274 node_id,
1275 ipk,
1276 rcac_public_key,
1277 }
1278 }
1279
1280 /// Build an empty `TrustedRoots` set (used for tests that don't reach
1281 /// chain validation).
1282 fn empty_roots() -> TrustedRoots {
1283 TrustedRoots::new()
1284 }
1285
1286 /// A valid-looking RCAC public key (SEC1 uncompressed, prefix 0x04).
1287 fn dummy_rcac_pub() -> [u8; 65] {
1288 let mut k = [0u8; 65];
1289 k[0] = 0x04;
1290 k
1291 }
1292
1293 // ─── Construction ─────────────────────────────────────────────────────
1294
1295 /// `new()` must accept valid credentials.
1296 #[test]
1297 fn new_succeeds_with_valid_credentials() {
1298 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1299 let _initiator = CaseInitiator::new(
1300 creds,
1301 empty_roots(),
1302 0x1234,
1303 0x5678,
1304 0x0001,
1305 MatterTime::from_unix_secs(2_000_000_000),
1306 )
1307 .unwrap();
1308 }
1309
1310 // ─── start() ──────────────────────────────────────────────────────────
1311
1312 /// `start()` must return a non-empty byte slice that starts with the
1313 /// anonymous TLV structure byte (0x15).
1314 #[test]
1315 fn start_returns_sigma1_bytes() {
1316 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1317 let mut initiator = CaseInitiator::new(
1318 creds,
1319 empty_roots(),
1320 0x1234,
1321 0x5678,
1322 0x0001,
1323 MatterTime::from_unix_secs(2_000_000_000),
1324 )
1325 .unwrap();
1326 let bytes = initiator.start().unwrap();
1327 assert!(!bytes.is_empty(), "Sigma1 bytes must be non-empty");
1328 assert_eq!(bytes[0], 0x15, "anonymous structure must start with 0x15");
1329 }
1330
1331 /// After `start()`, `expected_inbound()` must report `Sigma2`.
1332 #[test]
1333 fn expected_inbound_after_start_is_sigma2() {
1334 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1335 let mut initiator = CaseInitiator::new(
1336 creds,
1337 empty_roots(),
1338 0x1234,
1339 0x5678,
1340 0x0001,
1341 MatterTime::from_unix_secs(2_000_000_000),
1342 )
1343 .unwrap();
1344 let _ = initiator.start().unwrap();
1345 assert_eq!(initiator.expected_inbound(), Some(CaseMessageKind::Sigma2));
1346 }
1347
1348 /// `start()` must encode a Sigma1 that round-trips through the decoder.
1349 #[test]
1350 fn start_produces_valid_sigma1() {
1351 use crate::case::messages::Sigma1;
1352 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1353 let mut initiator = CaseInitiator::new(
1354 creds,
1355 empty_roots(),
1356 0x1234,
1357 0x5678,
1358 0x0001,
1359 MatterTime::from_unix_secs(2_000_000_000),
1360 )
1361 .unwrap();
1362 let bytes = initiator.start().unwrap();
1363 // Must decode without error.
1364 let decoded = Sigma1::decode(&bytes).unwrap();
1365 // dest_id is 32 bytes.
1366 assert_eq!(decoded.dest_id.len(), 32);
1367 // initiator_eph_pub starts with 0x04 (SEC1 uncompressed).
1368 assert_eq!(
1369 decoded.initiator_eph_pub[0], 0x04,
1370 "ephemeral pub key must be SEC1 uncompressed"
1371 );
1372 }
1373
1374 // ─── terminal-state guards ────────────────────────────────────────────
1375
1376 /// `finish()` before the handshake is complete returns `HandshakeIncomplete`.
1377 #[test]
1378 fn finish_before_complete_returns_handshake_incomplete() {
1379 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1380 let initiator = CaseInitiator::new(
1381 creds,
1382 empty_roots(),
1383 0x1234,
1384 0x5678,
1385 0x0001,
1386 MatterTime::from_unix_secs(2_000_000_000),
1387 )
1388 .unwrap();
1389 assert!(matches!(
1390 initiator.finish(),
1391 Err(Error::HandshakeIncomplete)
1392 ));
1393 }
1394
1395 /// `finish()` called immediately after `start()` returns `HandshakeIncomplete`.
1396 #[test]
1397 fn finish_after_start_returns_handshake_incomplete() {
1398 let creds2 = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1399 let fresh = CaseInitiator::new(
1400 creds2,
1401 empty_roots(),
1402 0x1234,
1403 0x5678,
1404 0x0001,
1405 MatterTime::from_unix_secs(2_000_000_000),
1406 )
1407 .unwrap();
1408 // The initiator is in AwaitingStart — finish() must fail.
1409 assert!(matches!(fresh.finish(), Err(Error::HandshakeIncomplete)));
1410 }
1411
1412 // ─── out-of-order rejection ────────────────────────────────────────────
1413
1414 /// Calling `handle_sigma2` before `start` (still in `AwaitingStart`)
1415 /// must return `UnexpectedCaseMessage`.
1416 #[test]
1417 fn handle_sigma2_before_start_is_rejected() {
1418 use crate::case::messages::Sigma2;
1419 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1420 let mut initiator = CaseInitiator::new(
1421 creds,
1422 empty_roots(),
1423 0x1234,
1424 0x5678,
1425 0x0001,
1426 MatterTime::from_unix_secs(2_000_000_000),
1427 )
1428 .unwrap();
1429 let dummy_sigma2 = Sigma2 {
1430 responder_random: [0u8; 32],
1431 responder_session_id: 1,
1432 responder_eph_pub: [0x04; 65],
1433 encrypted: vec![0xAA; 80],
1434 responder_session_params: None,
1435 };
1436 let bytes = dummy_sigma2.encode().unwrap();
1437 assert!(matches!(
1438 initiator.handle_sigma2(&bytes),
1439 Err(Error::UnexpectedCaseMessage { .. })
1440 ));
1441 }
1442
1443 /// Calling `next_message` before `handle_sigma2` must return
1444 /// `UnexpectedCaseMessage`.
1445 #[test]
1446 fn next_message_before_handle_sigma2_is_rejected() {
1447 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1448 let mut initiator = CaseInitiator::new(
1449 creds,
1450 empty_roots(),
1451 0x1234,
1452 0x5678,
1453 0x0001,
1454 MatterTime::from_unix_secs(2_000_000_000),
1455 )
1456 .unwrap();
1457 let _ = initiator.start().unwrap();
1458 // Still in AwaitingSigma2; next_message is not valid here.
1459 assert!(matches!(
1460 initiator.next_message(),
1461 Err(Error::UnexpectedCaseMessage { .. })
1462 ));
1463 }
1464
1465 /// Calling `start()` twice returns `UnexpectedCaseMessage` on the second call.
1466 #[test]
1467 fn double_start_is_rejected() {
1468 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1469 let mut initiator = CaseInitiator::new(
1470 creds,
1471 empty_roots(),
1472 0x1234,
1473 0x5678,
1474 0x0001,
1475 MatterTime::from_unix_secs(2_000_000_000),
1476 )
1477 .unwrap();
1478 let _ = initiator.start().unwrap();
1479 assert!(matches!(
1480 initiator.start(),
1481 Err(Error::UnexpectedCaseMessage { .. })
1482 ));
1483 }
1484
1485 // ─── expected_inbound() states ────────────────────────────────────────
1486
1487 /// Before `start()`, `expected_inbound()` returns `None`.
1488 #[test]
1489 fn expected_inbound_before_start_is_none() {
1490 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1491 let initiator = CaseInitiator::new(
1492 creds,
1493 empty_roots(),
1494 0x1234,
1495 0x5678,
1496 0x0001,
1497 MatterTime::from_unix_secs(2_000_000_000),
1498 )
1499 .unwrap();
1500 assert_eq!(initiator.expected_inbound(), None);
1501 }
1502
1503 // ─── TrustedRoots with an anchor ──────────────────────────────────────
1504
1505 /// `TrustedRoots::add` works and produces a non-empty set.
1506 #[test]
1507 fn trusted_roots_with_anchor_is_non_empty() {
1508 let rcac = make_test_cert(0, 0x5678);
1509 let anchor = TrustAnchor::from_root_cert(&rcac);
1510 let mut roots = TrustedRoots::new();
1511 roots.add(anchor);
1512 assert!(!roots.is_empty());
1513 assert_eq!(roots.len(), 1);
1514 }
1515
1516 // ─── M4.2: Resumption constructors ────────────────────────────────────
1517
1518 /// Helper: build a minimal `PeerInfo` for use in `ResumptionRecord`.
1519 fn make_test_peer_info(node_id: u64, fabric_id: u64) -> PeerInfo {
1520 PeerInfo {
1521 node_id,
1522 fabric_id,
1523 noc: make_test_cert(node_id, fabric_id),
1524 session_id: 1,
1525 }
1526 }
1527
1528 /// Helper: build a `ResumptionRecord` with given `shared_secret` and id.
1529 fn make_resumption_record(
1530 secret: [u8; 32],
1531 id: [u8; 16],
1532 node_id: u64,
1533 fabric_id: u64,
1534 ) -> ResumptionRecord {
1535 ResumptionRecord {
1536 id: ResumptionId(id),
1537 shared_secret: secret,
1538 peer: make_test_peer_info(node_id, fabric_id),
1539 expires_at: None,
1540 }
1541 }
1542
1543 /// `new_with_resumption` must succeed with valid inputs.
1544 #[test]
1545 fn new_with_resumption_succeeds() {
1546 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1547 let record = make_resumption_record([0x01u8; 32], [0x02u8; 16], 0x1234, 0x5678);
1548 let _initiator = CaseInitiator::new_with_resumption(
1549 creds,
1550 empty_roots(),
1551 0x1234,
1552 0x5678,
1553 record,
1554 0x0001,
1555 MatterTime::from_unix_secs(2_000_000_000),
1556 )
1557 .unwrap();
1558 }
1559
1560 /// When started with a resumption record, `start()` must produce a Sigma1
1561 /// that round-trips through the decoder and includes non-`None` resumption
1562 /// fields (`resumption_id` and `initiator_resume_mic`).
1563 #[test]
1564 fn start_with_resumption_populates_sigma1_resume_fields() {
1565 use crate::case::messages::Sigma1;
1566 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1567 let record = make_resumption_record([0x01u8; 32], [0x02u8; 16], 0x1234, 0x5678);
1568 let mut initiator = CaseInitiator::new_with_resumption(
1569 creds,
1570 empty_roots(),
1571 0x1234,
1572 0x5678,
1573 record,
1574 0x0001,
1575 MatterTime::from_unix_secs(2_000_000_000),
1576 )
1577 .unwrap();
1578 let bytes = initiator.start().unwrap();
1579 let decoded = Sigma1::decode(&bytes).unwrap();
1580 assert!(
1581 decoded.resumption_id.is_some(),
1582 "Sigma1 must carry resumption_id when constructed with a record"
1583 );
1584 assert_eq!(
1585 decoded.resumption_id.unwrap(),
1586 [0x02u8; 16],
1587 "resumption_id in Sigma1 must match the record's id"
1588 );
1589 assert!(
1590 decoded.initiator_resume_mic.is_some(),
1591 "Sigma1 must carry initiator_resume_mic when constructed with a record"
1592 );
1593 // After start(), expected_inbound should be Sigma2Resume (resumption path).
1594 assert_eq!(
1595 initiator.expected_inbound(),
1596 Some(CaseMessageKind::Sigma2Resume)
1597 );
1598 }
1599
1600 /// When started WITHOUT a resumption record, `start()` must produce a Sigma1
1601 /// with `None` resumption fields (the new-session path is unchanged).
1602 #[test]
1603 fn start_without_resumption_omits_sigma1_resume_fields() {
1604 use crate::case::messages::Sigma1;
1605 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1606 let mut initiator = CaseInitiator::new(
1607 creds,
1608 empty_roots(),
1609 0x1234,
1610 0x5678,
1611 0x0001,
1612 MatterTime::from_unix_secs(2_000_000_000),
1613 )
1614 .unwrap();
1615 let bytes = initiator.start().unwrap();
1616 let decoded = Sigma1::decode(&bytes).unwrap();
1617 assert!(
1618 decoded.resumption_id.is_none(),
1619 "Sigma1 must NOT carry resumption_id on the new-session path"
1620 );
1621 assert!(
1622 decoded.initiator_resume_mic.is_none(),
1623 "Sigma1 must NOT carry initiator_resume_mic on the new-session path"
1624 );
1625 // expected_inbound for non-resumption path must be Sigma2.
1626 assert_eq!(initiator.expected_inbound(), Some(CaseMessageKind::Sigma2));
1627 }
1628
1629 /// `handle_sigma2_resume` must succeed when given a correctly-computed
1630 /// `Sigma2_Resume` message (valid MIC).
1631 #[test]
1632 fn handle_sigma2_resume_after_resumption_attempt_succeeds_with_valid_mic() {
1633 use crate::case::messages::Sigma2Resume;
1634 use crate::case::sigma::compute_sigma2_resume_mic;
1635 use ring::rand::SystemRandom;
1636
1637 let shared_secret = [0x42u8; 32];
1638 let old_id = [0x11u8; 16];
1639 let new_id = [0x22u8; 16];
1640
1641 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1642 let record = make_resumption_record(shared_secret, old_id, 0x1234, 0x5678);
1643
1644 // We need to know the initiator_random that will be sampled.
1645 // Use new_with_resumption_using_rng with a deterministic RNG.
1646 // ring's SystemRandom is not deterministic, so we use the production path
1647 // and extract the random from the emitted Sigma1 to compute the expected MIC.
1648 let rng = SystemRandom::new();
1649 let mut initiator = CaseInitiator::new_with_resumption_using_rng(
1650 creds,
1651 empty_roots(),
1652 0x1234,
1653 0x5678,
1654 record,
1655 0x0001,
1656 MatterTime::from_unix_secs(2_000_000_000),
1657 &rng,
1658 )
1659 .unwrap();
1660
1661 // Start to emit Sigma1 (which contains the sampled initiator_random).
1662 let sigma1_bytes = initiator.start().unwrap();
1663 let sigma1 = crate::case::messages::Sigma1::decode(&sigma1_bytes).unwrap();
1664 let initiator_random = sigma1.initiator_random;
1665
1666 // Compute the MIC as the responder would.
1667 let mic = compute_sigma2_resume_mic(&shared_secret, &initiator_random, &new_id).unwrap();
1668
1669 // Build Sigma2_Resume.
1670 let sigma2_resume = Sigma2Resume {
1671 resumption_id: new_id,
1672 resume_mic: mic,
1673 responder_session_id: 0xBEEF,
1674 responder_session_params: None,
1675 };
1676 let sigma2_resume_bytes = sigma2_resume.encode().unwrap();
1677
1678 // This must succeed.
1679 initiator
1680 .handle_sigma2_resume(&sigma2_resume_bytes)
1681 .unwrap();
1682
1683 // finish() must succeed and carry the updated resumption record.
1684 let output = initiator.finish().unwrap();
1685 assert!(
1686 output.resumption_record.is_some(),
1687 "output must carry a resumption record after successful resumption"
1688 );
1689 assert_eq!(
1690 output.resumption_record.as_ref().unwrap().id.0,
1691 new_id,
1692 "resumption record id must be the NEW id from Sigma2_Resume"
1693 );
1694 // Verify resumed key derivation produces 16-byte keys.
1695 assert_ne!(
1696 output.keys.r2i_key, output.keys.i2r_key,
1697 "r2i and i2r keys must differ"
1698 );
1699 }
1700
1701 /// `handle_sigma2_resume` must return `ResumptionMacMismatch` when the MIC
1702 /// in the `Sigma2_Resume` message is corrupted.
1703 #[test]
1704 fn handle_sigma2_resume_rejects_invalid_mic() {
1705 use crate::case::messages::Sigma2Resume;
1706 use crate::case::sigma::compute_sigma2_resume_mic;
1707 use ring::rand::SystemRandom;
1708
1709 let shared_secret = [0x42u8; 32];
1710 let old_id = [0x11u8; 16];
1711 let new_id = [0x22u8; 16];
1712
1713 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1714 let record = make_resumption_record(shared_secret, old_id, 0x1234, 0x5678);
1715
1716 let rng = SystemRandom::new();
1717 let mut initiator = CaseInitiator::new_with_resumption_using_rng(
1718 creds,
1719 empty_roots(),
1720 0x1234,
1721 0x5678,
1722 record,
1723 0x0001,
1724 MatterTime::from_unix_secs(2_000_000_000),
1725 &rng,
1726 )
1727 .unwrap();
1728
1729 let sigma1_bytes = initiator.start().unwrap();
1730 let sigma1 = crate::case::messages::Sigma1::decode(&sigma1_bytes).unwrap();
1731 let initiator_random = sigma1.initiator_random;
1732
1733 let mut mic =
1734 compute_sigma2_resume_mic(&shared_secret, &initiator_random, &new_id).unwrap();
1735 // Corrupt one byte to make MIC invalid.
1736 mic[0] ^= 0xFF;
1737
1738 let sigma2_resume = Sigma2Resume {
1739 resumption_id: new_id,
1740 resume_mic: mic,
1741 responder_session_id: 0xBEEF,
1742 responder_session_params: None,
1743 };
1744 let sigma2_resume_bytes = sigma2_resume.encode().unwrap();
1745
1746 assert!(
1747 matches!(
1748 initiator.handle_sigma2_resume(&sigma2_resume_bytes),
1749 Err(Error::ResumptionMacMismatch)
1750 ),
1751 "Corrupted MIC must be rejected with ResumptionMacMismatch"
1752 );
1753 }
1754
1755 /// `handle_sigma2_resume` must return `UnexpectedCaseMessage` when the
1756 /// initiator was constructed WITHOUT a resumption record (no attempt was made).
1757 #[test]
1758 fn handle_sigma2_resume_without_resumption_attempt_returns_unexpected_message() {
1759 use crate::case::messages::Sigma2Resume;
1760
1761 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1762 let mut initiator = CaseInitiator::new(
1763 creds,
1764 empty_roots(),
1765 0x1234,
1766 0x5678,
1767 0x0001,
1768 MatterTime::from_unix_secs(2_000_000_000),
1769 )
1770 .unwrap();
1771 let _ = initiator.start().unwrap();
1772
1773 // Build any syntactically valid Sigma2_Resume.
1774 let sigma2_resume = Sigma2Resume {
1775 resumption_id: [0xAAu8; 16],
1776 resume_mic: [0xBBu8; 16],
1777 responder_session_id: 1,
1778 responder_session_params: None,
1779 };
1780 let bytes = sigma2_resume.encode().unwrap();
1781
1782 assert!(
1783 matches!(
1784 initiator.handle_sigma2_resume(&bytes),
1785 Err(Error::UnexpectedCaseMessage { .. })
1786 ),
1787 "Receiving Sigma2_Resume without having attempted resumption must fail"
1788 );
1789 }
1790}