Skip to main content

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}