Skip to main content

matter_crypto/case/
responder.rs

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