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}