Skip to main content

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}