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 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 clock
1235 // is supplied by the caller via the constructor; this crate never reads the
1236 // system clock.
1237 let chain_certs: Vec<MatterCertificate> = match &peer_tbe.peer_icac {
1238 Some(icac) => vec![peer_tbe.peer_noc.clone(), icac.clone()],
1239 None => vec![peer_tbe.peer_noc.clone()],
1240 };
1241 CertificateChain::new(&chain_certs)
1242 .validate(trusted_roots, now)
1243 .map_err(Error::InvalidPeerNocChain)?;
1244
1245 // Step 5: Extract initiator NodeId + FabricId from NOC subject.
1246 let peer_dn = peer_tbe.peer_noc.subject();
1247 let peer_node_id = peer_dn
1248 .node_id()
1249 .ok_or(Error::PeerNodeIdMismatch(0, credentials.node_id))?;
1250 let peer_fabric_id = peer_dn.fabric_id().ok_or(Error::FabricIdMismatch {
1251 peer: 0,
1252 local: credentials.fabric_id,
1253 })?;
1254
1255 // Step 6: Verify FabricId matches our credentials.
1256 if peer_fabric_id != credentials.fabric_id {
1257 return Err(Error::FabricIdMismatch {
1258 peer: peer_fabric_id,
1259 local: credentials.fabric_id,
1260 });
1261 }
1262
1263 // Step 7: Verify initiator's ECDSA signature over TBSData3.
1264 // In Sigma3, the initiator plays the "responder" role in TlvSignedData
1265 // (field names defined from Sigma2 perspective; re-used symmetrically).
1266 // Pinned from CaseServer.ts: initiatorEphPub → "responderPublicKey",
1267 // responderEphPub → "initiatorPublicKey".
1268 let peer_noc_tlv = peer_tbe
1269 .peer_noc
1270 .to_tlv()
1271 .map_err(Error::InvalidPeerNocChain)?;
1272 let peer_icac_tlv: Option<Vec<u8>> = match &peer_tbe.peer_icac {
1273 Some(icac) => Some(icac.to_tlv().map_err(Error::InvalidPeerNocChain)?),
1274 None => None,
1275 };
1276 let peer_signed_data = encode_tbs_data(
1277 &peer_noc_tlv,
1278 peer_icac_tlv.as_deref(),
1279 initiator_eph_pub, // initiator's eph pub = "responderPublicKey" in TBSData3
1280 eph_pub, // our eph pub = "initiatorPublicKey" in TBSData3
1281 )?;
1282 let peer_sig =
1283 Signature::from_slice(&peer_tbe.peer_signature).map_err(|_| Error::PeerSignatureInvalid)?;
1284 peer_tbe
1285 .peer_noc
1286 .public_key()
1287 .verify(&peer_signed_data, &peer_sig)
1288 .map_err(|_| Error::PeerSignatureInvalid)?;
1289
1290 // Step 8: Derive final session keys.
1291 // sessionSalt = IPK(16) || SHA-256(sigma1 || sigma2 || sigma3)
1292 let h_all = transcript_hash(&[sigma1_bytes, sigma2_bytes, sigma3_bytes]);
1293 let mut session_salt: Vec<u8> = Vec::with_capacity(16 + 32);
1294 session_salt.extend_from_slice(&credentials.ipk);
1295 session_salt.extend_from_slice(&h_all);
1296 // `keys_blob` holds the raw 48-byte session-key material; wrap in
1297 // `Zeroizing` so it is wiped once the per-direction keys are split out.
1298 let mut keys_blob = Zeroizing::new([0u8; 48]);
1299 hkdf_derive(
1300 shared_secret,
1301 &session_salt,
1302 HKDF_INFO_SESSION_KEYS,
1303 &mut *keys_blob,
1304 )?;
1305
1306 // Responder key assignment (NodeSession.ts, isInitiator=false):
1307 // decryptKey (i2r) = keys[0..16] — responder decrypts what initiator encrypts
1308 // encryptKey (r2i) = keys[16..32] — responder encrypts to initiator
1309 // attestationChallenge = keys[32..48]
1310 // The key bytes are identical to the initiator's derivation; only the
1311 // variable-name binding differs (swap which end "encrypts" and which "decrypts").
1312 let mut i2r_key = [0u8; 16];
1313 let mut r2i_key = [0u8; 16];
1314 let mut attestation_challenge = [0u8; 16];
1315 i2r_key.copy_from_slice(&keys_blob[0..16]);
1316 r2i_key.copy_from_slice(&keys_blob[16..32]);
1317 attestation_challenge.copy_from_slice(&keys_blob[32..48]);
1318
1319 let session_keys = CaseSessionKeys {
1320 i2r_key,
1321 r2i_key,
1322 attestation_challenge,
1323 };
1324
1325 let peer = PeerInfo {
1326 node_id: peer_node_id,
1327 fabric_id: peer_fabric_id,
1328 noc: peer_tbe.peer_noc,
1329 session_id: initiator_session_id,
1330 };
1331 let local = LocalInfo {
1332 node_id: credentials.node_id,
1333 fabric_id: credentials.fabric_id,
1334 session_id: responder_session_id,
1335 };
1336
1337 Ok((session_keys, peer, local))
1338}
1339
1340// ---------------------------------------------------------------------------
1341// Tests
1342// ---------------------------------------------------------------------------
1343
1344#[cfg(test)]
1345#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
1346mod tests {
1347 use super::*;
1348 use crate::case::signer::{CaseSigner, RingSigner};
1349 use matter_cert::test_support::{build_unsigned, TestCertFields};
1350 use matter_cert::{
1351 BasicConstraints, DistinguishedName, DnAttribute, Extensions, MatterTime, TrustAnchor,
1352 TrustedRoots,
1353 };
1354
1355 // ─── Test helpers ─────────────────────────────────────────────────────
1356
1357 /// Build a minimal `MatterCertificate` suitable for unit tests.
1358 ///
1359 /// The cert is not validly signed; its purpose is to let state-machine
1360 /// tests exercise paths that don't reach chain validation.
1361 fn make_test_cert(node_id: u64, fabric_id: u64) -> MatterCertificate {
1362 let (signer, _) = RingSigner::generate().unwrap();
1363 let pk_bytes = *signer.public_key().as_bytes();
1364 let pub_key = matter_cert::PublicKey::new(pk_bytes).unwrap();
1365 let subject = DistinguishedName::new(vec![
1366 DnAttribute::FabricId(fabric_id),
1367 DnAttribute::NodeId(node_id),
1368 ]);
1369 let issuer = DistinguishedName::new(vec![DnAttribute::RcacId(1)]);
1370 let extensions = Extensions::builder()
1371 .basic_constraints(Some(BasicConstraints::new(false, None)))
1372 .build();
1373 build_unsigned(TestCertFields {
1374 serial: vec![1],
1375 issuer,
1376 not_before: MatterTime::from_unix_secs(0),
1377 not_after: MatterTime::NO_EXPIRY,
1378 subject,
1379 public_key: pub_key,
1380 extensions,
1381 signature: matter_cert::Signature::new([0u8; 64]),
1382 })
1383 }
1384
1385 /// Build a `CaseCredentials` with a fresh `RingSigner` keypair.
1386 fn make_test_credentials(
1387 node_id: u64,
1388 fabric_id: u64,
1389 ipk: [u8; 16],
1390 rcac_public_key: [u8; 65],
1391 ) -> CaseCredentials {
1392 let (signer, _) = RingSigner::generate().unwrap();
1393 let noc = make_test_cert(node_id, fabric_id);
1394 CaseCredentials {
1395 noc,
1396 icac: None,
1397 signer: Box::new(signer),
1398 fabric_id,
1399 node_id,
1400 ipk,
1401 rcac_public_key,
1402 }
1403 }
1404
1405 /// Build an empty `TrustedRoots` set (used for tests that don't reach
1406 /// chain validation).
1407 fn empty_roots() -> TrustedRoots {
1408 TrustedRoots::new()
1409 }
1410
1411 /// A valid-looking RCAC public key (SEC1 uncompressed, prefix 0x04).
1412 fn dummy_rcac_pub() -> [u8; 65] {
1413 let mut k = [0u8; 65];
1414 k[0] = 0x04;
1415 k
1416 }
1417
1418 // ─── Construction ─────────────────────────────────────────────────────
1419
1420 /// `new()` must accept valid credentials without panicking.
1421 #[test]
1422 fn new_succeeds_with_valid_credentials() {
1423 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1424 let _responder = CaseResponder::new(
1425 creds,
1426 empty_roots(),
1427 0x0002,
1428 MatterTime::from_unix_secs(2_000_000_000),
1429 )
1430 .unwrap();
1431 }
1432
1433 // ─── expected_inbound() states ────────────────────────────────────────
1434
1435 /// Freshly constructed responder must be waiting for Sigma1.
1436 #[test]
1437 fn expected_inbound_initially_is_sigma1() {
1438 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1439 let responder = CaseResponder::new(
1440 creds,
1441 empty_roots(),
1442 0x0002,
1443 MatterTime::from_unix_secs(2_000_000_000),
1444 )
1445 .unwrap();
1446 assert_eq!(responder.expected_inbound(), Some(CaseMessageKind::Sigma1));
1447 }
1448
1449 /// After `handle_sigma1` and `next_message`, `expected_inbound` is `Sigma3`.
1450 #[test]
1451 fn expected_inbound_after_next_message_is_sigma3() {
1452 use crate::case::messages::Sigma1;
1453 let ipk = [0xAB; 16];
1454 let mut rcac_pub = [0u8; 65];
1455 rcac_pub[0] = 0x04;
1456
1457 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1458 let mut responder = CaseResponder::new(
1459 creds,
1460 empty_roots(),
1461 0x0002,
1462 MatterTime::from_unix_secs(2_000_000_000),
1463 )
1464 .unwrap();
1465
1466 // Compute the correct dest_id for this responder.
1467 let initiator_random = [0x42u8; 32];
1468 let dest_id = compute_dest_id(&ipk, &rcac_pub, 0x5678, 0x1234, &initiator_random);
1469
1470 // Build a Sigma1 that addresses this responder.
1471 let sigma1 = Sigma1 {
1472 initiator_random,
1473 initiator_session_id: 1,
1474 dest_id,
1475 initiator_eph_pub: {
1476 let rng = ring::rand::SystemRandom::new();
1477 let (_, pub_bytes) = generate_ephemeral_keypair(&rng).unwrap();
1478 pub_bytes
1479 },
1480 initiator_session_params: None,
1481 resumption_id: None,
1482 initiator_resume_mic: None,
1483 };
1484 let sigma1_bytes = sigma1.encode().unwrap();
1485
1486 let outcome = responder.handle_sigma1(&sigma1_bytes).unwrap();
1487 assert_eq!(outcome, Sigma1Outcome::NewSession);
1488
1489 let _ = responder.next_message().unwrap();
1490 assert_eq!(responder.expected_inbound(), Some(CaseMessageKind::Sigma3));
1491 }
1492
1493 // ─── handle_sigma1: dest_id mismatch ──────────────────────────────────
1494
1495 /// `handle_sigma1` with a wrong `dest_id` must return `InvalidParameter`.
1496 #[test]
1497 fn handle_sigma1_unknown_dest_id_returns_invalid_parameter() {
1498 use crate::case::messages::Sigma1;
1499 let ipk = [0xAB; 16];
1500 let rcac_pub = dummy_rcac_pub();
1501 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1502 let mut responder = CaseResponder::new(
1503 creds,
1504 empty_roots(),
1505 0x0002,
1506 MatterTime::from_unix_secs(2_000_000_000),
1507 )
1508 .unwrap();
1509
1510 // Build a Sigma1 with a garbage dest_id — it won't match our identity.
1511 let sigma1 = Sigma1 {
1512 initiator_random: [0x11; 32],
1513 initiator_session_id: 1,
1514 dest_id: [0xFF; 32], // clearly wrong
1515 initiator_eph_pub: {
1516 let rng = ring::rand::SystemRandom::new();
1517 let (_, pub_bytes) = generate_ephemeral_keypair(&rng).unwrap();
1518 pub_bytes
1519 },
1520 initiator_session_params: None,
1521 resumption_id: None,
1522 initiator_resume_mic: None,
1523 };
1524 let sigma1_bytes = sigma1.encode().unwrap();
1525
1526 assert!(matches!(
1527 responder.handle_sigma1(&sigma1_bytes),
1528 Err(Error::InvalidParameter)
1529 ));
1530 }
1531
1532 // ─── handle_sigma1: resumption path ──────────────────────────────────
1533
1534 /// Helper: build a Sigma1 that addresses the responder. Returns
1535 /// `(sigma1_bytes, initiator_random, noc_cert)` so callers can build a
1536 /// matching `ResumptionRecord`.
1537 ///
1538 /// When `resumption_id` and `resume_mic` are both `Some`, the Sigma1 carries
1539 /// resumption fields and `handle_sigma1` must return
1540 /// `Sigma1Outcome::ResumptionRequested`.
1541 fn build_sigma1_for_responder(
1542 ipk: &[u8; 16],
1543 rcac_pub: &[u8; 65],
1544 node_id: u64,
1545 fabric_id: u64,
1546 initiator_random: [u8; 32],
1547 resumption_id: Option<[u8; 16]>,
1548 resume_mic: Option<[u8; 16]>,
1549 ) -> Vec<u8> {
1550 let dest_id = compute_dest_id(ipk, rcac_pub, fabric_id, node_id, &initiator_random);
1551 let rng = ring::rand::SystemRandom::new();
1552 let (_, eph_pub) = generate_ephemeral_keypair(&rng).unwrap();
1553 let sigma1 = Sigma1 {
1554 initiator_random,
1555 initiator_session_id: 7,
1556 dest_id,
1557 initiator_eph_pub: eph_pub,
1558 initiator_session_params: None,
1559 resumption_id,
1560 initiator_resume_mic: resume_mic,
1561 };
1562 sigma1.encode().unwrap()
1563 }
1564
1565 /// `handle_sigma1` with both `resumption_id` AND `initiator_resume_mic` must
1566 /// return `Sigma1Outcome::ResumptionRequested` carrying the correct ID.
1567 #[test]
1568 fn handle_sigma1_with_resumption_fields_returns_resumption_requested() {
1569 let ipk = [0xAB; 16];
1570 let rcac_pub = dummy_rcac_pub();
1571 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1572 let mut responder = CaseResponder::new(
1573 creds,
1574 empty_roots(),
1575 0x0002,
1576 MatterTime::from_unix_secs(2_000_000_000),
1577 )
1578 .unwrap();
1579
1580 let initiator_random = [0x42u8; 32];
1581 let resumption_id = [0xCC; 16];
1582 // A plausible (but not verified-here) 16-byte MIC.
1583 let resume_mic = [0xDD; 16];
1584
1585 let sigma1_bytes = build_sigma1_for_responder(
1586 &ipk,
1587 &rcac_pub,
1588 0x1234,
1589 0x5678,
1590 initiator_random,
1591 Some(resumption_id),
1592 Some(resume_mic),
1593 );
1594
1595 let outcome = responder.handle_sigma1(&sigma1_bytes).unwrap();
1596 assert_eq!(
1597 outcome,
1598 Sigma1Outcome::ResumptionRequested {
1599 id: ResumptionId(resumption_id),
1600 }
1601 );
1602 }
1603
1604 /// `handle_sigma1` with only `resumption_id` (no MIC) must fall through to
1605 /// the new-session path since we require BOTH fields for resumption.
1606 #[test]
1607 fn handle_sigma1_with_only_resumption_id_takes_new_session_path() {
1608 let ipk = [0xAB; 16];
1609 let rcac_pub = dummy_rcac_pub();
1610 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1611 let mut responder = CaseResponder::new(
1612 creds,
1613 empty_roots(),
1614 0x0002,
1615 MatterTime::from_unix_secs(2_000_000_000),
1616 )
1617 .unwrap();
1618
1619 let initiator_random = [0x42u8; 32];
1620 let sigma1_bytes = build_sigma1_for_responder(
1621 &ipk,
1622 &rcac_pub,
1623 0x1234,
1624 0x5678,
1625 initiator_random,
1626 Some([0xCC; 16]), // resumption_id present
1627 None, // MIC absent — should NOT trigger resumption path
1628 );
1629
1630 let outcome = responder.handle_sigma1(&sigma1_bytes).unwrap();
1631 assert_eq!(outcome, Sigma1Outcome::NewSession);
1632 }
1633
1634 // ─── Out-of-order rejection ────────────────────────────────────────────
1635
1636 /// `next_message` before `handle_sigma1` must return `UnexpectedCaseMessage`.
1637 #[test]
1638 fn next_message_before_handle_sigma1_is_rejected() {
1639 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1640 let mut responder = CaseResponder::new(
1641 creds,
1642 empty_roots(),
1643 0x0002,
1644 MatterTime::from_unix_secs(2_000_000_000),
1645 )
1646 .unwrap();
1647 assert!(matches!(
1648 responder.next_message(),
1649 Err(Error::UnexpectedCaseMessage { .. })
1650 ));
1651 }
1652
1653 /// `handle_sigma3` before `handle_sigma1` must return `UnexpectedCaseMessage`.
1654 #[test]
1655 fn handle_sigma3_before_sigma1_is_rejected() {
1656 use crate::case::messages::Sigma3;
1657 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1658 let mut responder = CaseResponder::new(
1659 creds,
1660 empty_roots(),
1661 0x0002,
1662 MatterTime::from_unix_secs(2_000_000_000),
1663 )
1664 .unwrap();
1665
1666 let dummy_sigma3 = Sigma3 {
1667 encrypted: vec![0xAA; 80],
1668 };
1669 let bytes = dummy_sigma3.encode().unwrap();
1670 assert!(matches!(
1671 responder.handle_sigma3(&bytes),
1672 Err(Error::UnexpectedCaseMessage { .. })
1673 ));
1674 }
1675
1676 // ─── finish() before Complete ──────────────────────────────────────────
1677
1678 /// `finish()` before any handshake steps returns `HandshakeIncomplete`.
1679 #[test]
1680 fn finish_before_complete_returns_handshake_incomplete() {
1681 let creds = make_test_credentials(0x1234, 0x5678, [0xAB; 16], dummy_rcac_pub());
1682 let responder = CaseResponder::new(
1683 creds,
1684 empty_roots(),
1685 0x0002,
1686 MatterTime::from_unix_secs(2_000_000_000),
1687 )
1688 .unwrap();
1689 assert!(matches!(
1690 responder.finish(),
1691 Err(Error::HandshakeIncomplete)
1692 ));
1693 }
1694
1695 // ─── TrustedRoots helper verification ─────────────────────────────────
1696
1697 /// Ensures the `TrustedRoots` type accepts roots correctly (used by both
1698 /// initiator and responder tests).
1699 #[test]
1700 fn trusted_roots_with_anchor_is_non_empty() {
1701 let rcac = make_test_cert(0, 0x5678);
1702 let anchor = TrustAnchor::from_root_cert(&rcac);
1703 let mut roots = TrustedRoots::new();
1704 roots.add(anchor);
1705 assert!(!roots.is_empty());
1706 assert_eq!(roots.len(), 1);
1707 }
1708
1709 // ─── Resumption: accept_resumption / reject_resumption ────────────────
1710
1711 /// Build a valid `ResumptionRecord` and the matching Sigma1 bytes
1712 /// (i.e., the MIC was computed correctly and will verify successfully).
1713 fn build_valid_resumption_setup(
1714 ipk: &[u8; 16],
1715 rcac_pub: &[u8; 65],
1716 node_id: u64,
1717 fabric_id: u64,
1718 ) -> (Vec<u8>, ResumptionRecord, [u8; 32]) {
1719 use crate::case::sigma::compute_sigma1_resume_mic;
1720
1721 let shared_secret = [0x55u8; 32];
1722 let resumption_id = [0xAA; 16];
1723 let initiator_random = [0x11; 32];
1724
1725 // Compute the MIC as the initiator would.
1726 let mic =
1727 compute_sigma1_resume_mic(&shared_secret, &initiator_random, &resumption_id).unwrap();
1728
1729 let sigma1_bytes = build_sigma1_for_responder(
1730 ipk,
1731 rcac_pub,
1732 node_id,
1733 fabric_id,
1734 initiator_random,
1735 Some(resumption_id),
1736 Some(mic),
1737 );
1738
1739 // Build a synthetic NOC to embed in the record (resumption re-uses cached peer).
1740 let noc = make_test_cert(node_id + 1, fabric_id);
1741 let peer = PeerInfo {
1742 node_id: node_id + 1,
1743 fabric_id,
1744 noc,
1745 session_id: 99,
1746 };
1747 let record = ResumptionRecord {
1748 id: ResumptionId(resumption_id),
1749 shared_secret,
1750 peer,
1751 expires_at: None,
1752 };
1753
1754 (sigma1_bytes, record, initiator_random)
1755 }
1756
1757 /// `accept_resumption` rejects a record whose ID doesn't match the one the
1758 /// initiator presented.
1759 #[test]
1760 fn accept_resumption_rejects_wrong_id() {
1761 let ipk = [0xAB; 16];
1762 let rcac_pub = dummy_rcac_pub();
1763 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1764 let mut responder = CaseResponder::new(
1765 creds,
1766 empty_roots(),
1767 0x0002,
1768 MatterTime::from_unix_secs(2_000_000_000),
1769 )
1770 .unwrap();
1771
1772 let (sigma1_bytes, mut record, _) =
1773 build_valid_resumption_setup(&ipk, &rcac_pub, 0x1234, 0x5678);
1774
1775 let outcome = responder.handle_sigma1(&sigma1_bytes).unwrap();
1776 assert!(matches!(outcome, Sigma1Outcome::ResumptionRequested { .. }));
1777
1778 // Tamper with the record ID — it no longer matches the presented ID.
1779 record.id = ResumptionId([0xFF; 16]);
1780 assert!(matches!(
1781 responder.accept_resumption(record),
1782 Err(Error::InvalidParameter)
1783 ));
1784 }
1785
1786 /// `accept_resumption` rejects a record whose `shared_secret` produces a wrong MIC.
1787 #[test]
1788 fn accept_resumption_rejects_invalid_mic() {
1789 let ipk = [0xAB; 16];
1790 let rcac_pub = dummy_rcac_pub();
1791 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1792 let mut responder = CaseResponder::new(
1793 creds,
1794 empty_roots(),
1795 0x0002,
1796 MatterTime::from_unix_secs(2_000_000_000),
1797 )
1798 .unwrap();
1799
1800 let (sigma1_bytes, mut record, _) =
1801 build_valid_resumption_setup(&ipk, &rcac_pub, 0x1234, 0x5678);
1802
1803 let outcome = responder.handle_sigma1(&sigma1_bytes).unwrap();
1804 assert!(matches!(outcome, Sigma1Outcome::ResumptionRequested { .. }));
1805
1806 // Tamper with the shared secret — the MIC verification will fail.
1807 record.shared_secret = [0xFF; 32];
1808 assert!(matches!(
1809 responder.accept_resumption(record),
1810 Err(Error::ResumptionMacMismatch)
1811 ));
1812 }
1813
1814 /// After `handle_sigma1` (resumption) + `reject_resumption`, calling
1815 /// `next_message` returns a Sigma2 (new-session path) rather than `Sigma2_Resume`.
1816 #[test]
1817 fn reject_resumption_transitions_to_new_session_path() {
1818 use crate::case::messages::Sigma2;
1819
1820 let ipk = [0xAB; 16];
1821 let rcac_pub = dummy_rcac_pub();
1822 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1823 let mut responder = CaseResponder::new(
1824 creds,
1825 empty_roots(),
1826 0x0002,
1827 MatterTime::from_unix_secs(2_000_000_000),
1828 )
1829 .unwrap();
1830
1831 let (sigma1_bytes, _record, _) =
1832 build_valid_resumption_setup(&ipk, &rcac_pub, 0x1234, 0x5678);
1833
1834 let outcome = responder.handle_sigma1(&sigma1_bytes).unwrap();
1835 assert!(matches!(outcome, Sigma1Outcome::ResumptionRequested { .. }));
1836
1837 responder.reject_resumption().unwrap();
1838
1839 // next_message() must succeed and return some bytes.
1840 let outbound = responder.next_message().unwrap();
1841 assert!(
1842 !outbound.is_empty(),
1843 "next_message after reject_resumption must return Sigma2 bytes"
1844 );
1845
1846 // The returned bytes should decode as a valid Sigma2 (not Sigma2_Resume).
1847 // Sigma2 has tag 3 = responder_eph_pub (65 bytes); Sigma2_Resume has tag 1 = resumption_id (16 bytes).
1848 // A successful Sigma2::decode is sufficient confirmation.
1849 Sigma2::decode(&outbound).unwrap();
1850 }
1851
1852 /// `accept_resumption` + `next_message` returns `Sigma2_Resume` bytes and
1853 /// transitions to Complete; `finish` returns a resumption record.
1854 #[test]
1855 fn accept_resumption_then_next_message_returns_sigma2_resume() {
1856 use crate::case::messages::Sigma2Resume;
1857
1858 let ipk = [0xAB; 16];
1859 let rcac_pub = dummy_rcac_pub();
1860 let creds = make_test_credentials(0x1234, 0x5678, ipk, rcac_pub);
1861 let mut responder = CaseResponder::new(
1862 creds,
1863 empty_roots(),
1864 0x0002,
1865 MatterTime::from_unix_secs(2_000_000_000),
1866 )
1867 .unwrap();
1868
1869 let (sigma1_bytes, record, _) =
1870 build_valid_resumption_setup(&ipk, &rcac_pub, 0x1234, 0x5678);
1871 let old_id = record.id;
1872
1873 let outcome = responder.handle_sigma1(&sigma1_bytes).unwrap();
1874 assert!(matches!(outcome, Sigma1Outcome::ResumptionRequested { .. }));
1875
1876 responder.accept_resumption(record).unwrap();
1877
1878 // next_message() must return Sigma2_Resume bytes.
1879 let outbound = responder.next_message().unwrap();
1880 assert!(!outbound.is_empty());
1881
1882 // The bytes must decode as a Sigma2_Resume.
1883 let sigma2_resume = Sigma2Resume::decode(&outbound).unwrap();
1884
1885 // The new resumption_id must differ from the old one (it was freshly generated).
1886 assert_ne!(
1887 sigma2_resume.resumption_id, old_id.0,
1888 "Sigma2_Resume must carry a fresh resumption_id"
1889 );
1890
1891 // finish() must succeed and carry a resumption_record with the new id.
1892 let output = responder.finish().unwrap();
1893 let next_record = output.resumption_record.unwrap();
1894 assert_eq!(
1895 next_record.id.0, sigma2_resume.resumption_id,
1896 "CaseSessionOutput resumption_record.id must match Sigma2_Resume resumption_id"
1897 );
1898 }
1899}