Skip to main content

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}