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