matter_crypto/case/mod.rs
1//! Matter CASE (Certificate Authenticated Session Establishment) via SIGMA-I.
2//!
3//! Implementation lands across phases:
4//! - M4.1 (current): math + Sigma1/2/3 state machines + new-session wire messages.
5//! - M4.2: session resumption (`Sigma2_Resume`, `Sigma3_Resume`).
6//! - M4.3: matter.js byte-parity verification + readiness markers.
7//!
8//! See Matter Core Specification §4.13 and
9//! `docs/superpowers/specs/2026-05-19-matter-crypto-case-design.md`.
10
11pub(crate) mod initiator;
12pub(crate) mod messages;
13pub(crate) mod responder;
14pub(crate) mod sigma;
15pub(crate) mod signer;
16
17use crate::case::signer::CaseSigner;
18use matter_cert::{MatterCertificate, MatterTime};
19
20/// Identifies one of the 5 CASE message types. Used by
21/// [`crate::Error::UnexpectedMessage`] and `expected_inbound()` accessors
22/// on the state machines.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum CaseMessageKind {
26 /// The first CASE message sent by the initiator (new-session path).
27 Sigma1,
28 /// The responder's reply to `Sigma1` (new-session path).
29 Sigma2,
30 /// The initiator's final message completing the handshake (new-session path).
31 Sigma3,
32 /// Resumption response (M4.2).
33 Sigma2Resume,
34 /// Resumption finish (M4.2).
35 Sigma3Resume,
36}
37
38/// Operational identity for a CASE session.
39///
40/// Packages the things that identify a participant on a fabric:
41/// NOC, optional ICAC, signer for the NOC's private key, the
42/// claimed `FabricId` + `NodeId`, the fabric-scoped IPK, and
43/// the RCAC's public key (needed for `DestinationId` computation).
44/// Consumed by both `CaseInitiator::new` and `CaseResponder::new`.
45///
46/// # Secret hygiene
47///
48/// Carries the fabric-scoped IPK (a 16-byte secret). The [`Debug`] impl
49/// redacts the IPK, and a manual [`Drop`] zeroizes the IPK bytes when the
50/// credentials are dropped. We cannot derive [`zeroize::ZeroizeOnDrop`] on the
51/// whole struct because several fields (`noc`, `icac`, the boxed `signer`) are
52/// not `Zeroize`; the NOC private key inside `signer` is owned and wiped by the
53/// signer implementation itself.
54pub struct CaseCredentials {
55 /// Node Operational Certificate. Issued by this fabric's CA chain.
56 pub noc: MatterCertificate,
57 /// Optional Intermediate CA Certificate, if NOC was issued by an
58 /// intermediate rather than directly by the RCAC.
59 pub icac: Option<MatterCertificate>,
60 /// Signer for the NOC's private key.
61 pub signer: Box<dyn CaseSigner>,
62 /// Fabric ID this identity is associated with. Cross-checked against
63 /// the `FabricId` attribute in the NOC's subject DN.
64 pub fabric_id: u64,
65 /// Node ID this identity is associated with. Cross-checked against
66 /// the `NodeId` attribute in the NOC's subject DN.
67 pub node_id: u64,
68 /// 16-byte fabric-scoped Identity Protection Key (IPK).
69 ///
70 /// Used as the HKDF salt in CASE key derivations (`DestinationId`, S2RK,
71 /// S3SK, and attestation-challenge). Provides cross-fabric domain
72 /// separation: two fabrics sharing a NOC but using different IPKs cannot
73 /// impersonate each other. The IPK is derived during commissioning (M6
74 /// fabric storage persists it alongside the NOC).
75 ///
76 /// Pinned from matter.js: `operationalIdentityProtectionKey` (16 bytes).
77 pub ipk: [u8; 16],
78 /// 65-byte SEC1-uncompressed public key of this fabric's Root CA (RCAC).
79 ///
80 /// Required for `DestinationId` computation (Matter Core Spec §4.13.2.4).
81 /// The `DestinationId` salt is
82 /// `HMAC-SHA256(IPK, initiatorRandom || rcacPublicKey || fabricId_le8 || nodeId_le8)`.
83 ///
84 /// Pinned from matter.js: `fabric.rootPublicKey` used in
85 /// `Fabric.#generateSalt(nodeId, random)`.
86 pub rcac_public_key: [u8; 65],
87}
88
89impl core::fmt::Debug for CaseCredentials {
90 /// Redacts the secret `ipk`; prints the remaining (non-secret) fields.
91 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
92 f.debug_struct("CaseCredentials")
93 .field("noc", &self.noc)
94 .field("icac", &self.icac)
95 .field("signer", &self.signer)
96 .field("fabric_id", &self.fabric_id)
97 .field("node_id", &self.node_id)
98 .field("ipk", &"<redacted>")
99 .field("rcac_public_key", &self.rcac_public_key)
100 .finish()
101 }
102}
103
104impl Drop for CaseCredentials {
105 /// Wipe the secret IPK from memory on drop. The other fields are either
106 /// non-secret or own their own secret material (the boxed signer wipes its
107 /// private key in its own `Drop`).
108 fn drop(&mut self) {
109 use zeroize::Zeroize;
110 self.ipk.zeroize();
111 }
112}
113
114/// Output of a successful CASE handshake.
115#[derive(Debug, Clone)]
116pub struct CaseSessionOutput {
117 /// Pure key material for the symmetric cipher (consumed by M5 transport).
118 pub keys: CaseSessionKeys,
119 /// Peer's identity discovered during the handshake.
120 pub peer: PeerInfo,
121 /// Our side's identity (mirror; included for symmetry).
122 pub local: LocalInfo,
123 /// Resumption record for next-time fast-path. Populated on every
124 /// completed handshake: on the full path from the `resumption_id`
125 /// exchanged in `TBEData2` plus the session's ECDH secret, and on the
126 /// resumption path with the fresh id from `Sigma2_Resume`. The caller
127 /// persists it (keyed by peer node id) so a later `Sigma1` carrying
128 /// resumption fields can be matched and accepted.
129 pub resumption_record: Option<ResumptionRecord>,
130}
131
132/// Symmetric session keys derived by a completed CASE handshake.
133///
134/// Consumed by M5 transport's AES-CCM cipher wrapper.
135///
136/// # Secret hygiene
137///
138/// This type carries live symmetric key material. It implements
139/// [`zeroize::ZeroizeOnDrop`] so the key bytes are wiped from memory when the
140/// value is dropped, and its [`Debug`] impl redacts every field (printing
141/// `CaseSessionKeys { .. }`) so key bytes never reach logs. Equality is
142/// intentionally *not* derived: comparing session keys with the variable-time
143/// `==` would be a timing side-channel, and no caller needs it (tests compare
144/// individual byte-array fields directly).
145#[derive(Clone, zeroize::ZeroizeOnDrop)]
146pub struct CaseSessionKeys {
147 /// Key for encrypting initiator → responder traffic.
148 pub i2r_key: [u8; 16],
149 /// Key for encrypting responder → initiator traffic.
150 pub r2i_key: [u8; 16],
151 /// Challenge used for the attestation step on the operational session.
152 pub attestation_challenge: [u8; 16],
153}
154
155impl core::fmt::Debug for CaseSessionKeys {
156 /// Redacts all key material; never prints key bytes.
157 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
158 f.debug_struct("CaseSessionKeys").finish_non_exhaustive()
159 }
160}
161
162/// Identity of the peer we just shook hands with.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct PeerInfo {
165 /// Peer's Node ID (extracted from peer's NOC subject DN).
166 pub node_id: u64,
167 /// Peer's Fabric ID (extracted from peer's NOC subject DN).
168 pub fabric_id: u64,
169 /// Peer's NOC verbatim. Available for cert-pinning callers (ACL
170 /// evaluation, fast-path re-binding, etc.).
171 pub noc: MatterCertificate,
172 /// Peer's session ID for messages sent BACK to it.
173 pub session_id: u16,
174}
175
176/// Our own identity on the session (mirror of `PeerInfo`, useful for symmetry).
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct LocalInfo {
179 /// Our Node ID.
180 pub node_id: u64,
181 /// Our Fabric ID.
182 pub fabric_id: u64,
183 /// Our session ID for messages sent TO us.
184 pub session_id: u16,
185}
186
187/// 16-byte resumption identifier.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
189pub struct ResumptionId(pub [u8; 16]);
190
191/// State persisted by the caller after a successful CASE handshake,
192/// allowing a future session to skip the full 3-message handshake via
193/// `Sigma2_Resume`. Resumption flow lands in M4.2.
194///
195/// # Secret hygiene
196///
197/// Carries the resumption `shared_secret`. Implements
198/// [`zeroize::ZeroizeOnDrop`] (only the secret is wiped — the non-secret
199/// metadata fields `id`, `peer`, and `expires_at` are `#[zeroize(skip)]`),
200/// redacts the secret in its [`Debug`] impl, and does not derive variable-time
201/// equality.
202#[derive(Clone, zeroize::ZeroizeOnDrop)]
203pub struct ResumptionRecord {
204 /// Identifier the peer sends back in Sigma1 to attempt resumption.
205 ///
206 /// Skipped from zeroization: a public, non-secret session identifier (it is
207 /// sent on the wire in Sigma1). Only `shared_secret` is sensitive.
208 #[zeroize(skip)]
209 pub id: ResumptionId,
210 /// The full 32-byte ECDH `SharedSecret` of the original session (Matter
211 /// Core §4.14.8). Both chip's `SessionResumptionStorage` and matter.js
212 /// store the raw ECDH output; it is the HKDF IKM for the resumption MICs
213 /// and the resumed session keys. Caller treats this as opaque.
214 pub shared_secret: [u8; 32],
215 /// Peer's identity at the time the record was created.
216 ///
217 /// Skipped from zeroization: non-secret identity/cert metadata, and
218 /// `PeerInfo` is not `Zeroize`.
219 #[zeroize(skip)]
220 pub peer: PeerInfo,
221 /// Optional expiry timestamp. Callers should reject resumption
222 /// attempts after this point.
223 ///
224 /// Skipped from zeroization: non-secret timestamp metadata.
225 #[zeroize(skip)]
226 pub expires_at: Option<MatterTime>,
227}
228
229impl core::fmt::Debug for ResumptionRecord {
230 /// Redacts `shared_secret`; the non-secret fields are printed verbatim.
231 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
232 f.debug_struct("ResumptionRecord")
233 .field("id", &self.id)
234 .field("shared_secret", &"<redacted>")
235 .field("peer", &self.peer)
236 .field("expires_at", &self.expires_at)
237 .finish()
238 }
239}
240
241/// Outcome of processing a Sigma1 message.
242///
243/// On `ResumptionRequested`, the caller looks up the corresponding
244/// `ResumptionRecord` in their session store and calls either
245/// `accept_resumption(record)` or `reject_resumption()` on the
246/// `CaseResponder`. (Resumption flow lands in M4.2.)
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum Sigma1Outcome {
249 /// Initiator wants a fresh CASE session.
250 NewSession,
251 /// Initiator wants to resume a previous session by ID.
252 ResumptionRequested {
253 /// The resumption ID the initiator presented.
254 id: ResumptionId,
255 },
256}
257
258#[cfg(test)]
259#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
260mod secret_hygiene_tests {
261 use super::*;
262 use matter_cert::test_support::{build_unsigned, TestCertFields};
263 use matter_cert::{
264 DistinguishedName, DnAttribute, Extensions, MatterTime, PublicKey, Signature,
265 };
266
267 /// Compile-time proof that `T: ZeroizeOnDrop`. Instantiating it for each
268 /// secret-bearing type fails to compile if the trait is ever removed,
269 /// which is the strongest guarantee we can give without observing the
270 /// (already-freed) memory at runtime.
271 fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
272
273 #[test]
274 fn secret_key_types_are_zeroize_on_drop() {
275 assert_zeroize_on_drop::<CaseSessionKeys>();
276 assert_zeroize_on_drop::<ResumptionRecord>();
277 assert_zeroize_on_drop::<crate::pase::PaseSessionKeys>();
278 }
279
280 /// A minimal `MatterCertificate` for building a `PeerInfo`.
281 fn dummy_cert() -> MatterCertificate {
282 let subject = DistinguishedName::new(vec![
283 DnAttribute::FabricId(0x5678),
284 DnAttribute::NodeId(0x1234),
285 ]);
286 let issuer = DistinguishedName::new(vec![DnAttribute::RcacId(1)]);
287 build_unsigned(TestCertFields {
288 serial: vec![1],
289 issuer,
290 not_before: MatterTime::from_unix_secs(0),
291 not_after: MatterTime::NO_EXPIRY,
292 subject,
293 public_key: PublicKey::new([0x04u8; 65]).unwrap(),
294 extensions: Extensions::default(),
295 signature: Signature::new([0u8; 64]),
296 })
297 }
298
299 #[test]
300 fn case_session_keys_debug_redacts_key_bytes() {
301 let keys = CaseSessionKeys {
302 i2r_key: [0xAA; 16],
303 r2i_key: [0xBB; 16],
304 attestation_challenge: [0xCC; 16],
305 };
306 let s = format!("{keys:?}");
307 // The redacting Debug must not leak any key field's bytes. Check for the
308 // exact way each `[u8; 16]` would render if it leaked via a derived Debug
309 // (decimal, e.g. `[170, 170, ...]`) — a precise, collision-free signature.
310 assert!(
311 !s.contains(&format!("{:?}", [0xAAu8; 16])),
312 "i2r_key leaked: {s}"
313 );
314 assert!(
315 !s.contains(&format!("{:?}", [0xBBu8; 16])),
316 "r2i_key leaked: {s}"
317 );
318 assert!(
319 !s.contains(&format!("{:?}", [0xCCu8; 16])),
320 "attestation_challenge leaked: {s}"
321 );
322 assert!(s.contains("CaseSessionKeys"));
323 }
324
325 #[test]
326 fn resumption_record_debug_redacts_shared_secret() {
327 let record = ResumptionRecord {
328 id: ResumptionId([0x11; 16]),
329 shared_secret: [0xDD; 32],
330 peer: PeerInfo {
331 node_id: 0x1234,
332 fabric_id: 0x5678,
333 noc: dummy_cert(),
334 session_id: 1,
335 },
336 expires_at: None,
337 };
338 let s = format!("{record:?}");
339 assert!(
340 !s.contains(&format!("{:?}", [0xDDu8; 32])),
341 "shared_secret leaked: {s}"
342 );
343 assert!(s.contains("<redacted>"));
344 assert!(s.contains("ResumptionRecord"));
345 }
346
347 #[test]
348 fn case_credentials_debug_redacts_ipk() {
349 use crate::case::signer::RingSigner;
350 let (signer, _) = RingSigner::generate().unwrap();
351 let creds = CaseCredentials {
352 noc: dummy_cert(),
353 icac: None,
354 signer: Box::new(signer),
355 fabric_id: 0x5678,
356 node_id: 0x1234,
357 ipk: [0xEE; 16],
358 rcac_public_key: [0x04; 65],
359 };
360 let s = format!("{creds:?}");
361 // Use the exact decimal rendering of the ipk array, not a 2-char hex
362 // substring: a random signer public key's Debug can incidentally contain
363 // short hex like "ee", which made the old assertion flaky.
364 assert!(
365 !s.contains(&format!("{:?}", [0xEEu8; 16])),
366 "ipk leaked: {s}"
367 );
368 assert!(s.contains("<redacted>"));
369 assert!(s.contains("CaseCredentials"));
370 }
371}