Skip to main content

matter_crypto/
aead.rs

1//! AES-128-CCM-128 (16-byte key, 13-byte nonce, 16-byte tag) AEAD helpers.
2//!
3//! Used internally by [`crate::case`] for SIGMA-I encrypted blobs and
4//! externally by `matter-transport` for the Matter secured-message
5//! framing layer (Matter Core Spec §4.5). The cipher itself comes from
6//! the `aes` + `ccm` crates; this module is a thin, typed adapter that
7//! matches matter.js's `crypto.encrypt`/`decrypt` byte layout (`ciphertext
8//! || tag`).
9//!
10//! We never implement primitives here — only the type-safe wrapper.
11
12use aes::Aes128;
13use ccm::{
14    aead::{Aead, AeadInPlace, KeyInit, Payload},
15    consts::{U13, U16},
16    Ccm, Key, Nonce,
17};
18
19use crate::error::{Error, Result};
20
21/// AES-128-CCM with a 16-byte tag and a 13-byte nonce — the Matter cipher.
22type Aes128Ccm = Ccm<Aes128, U16, U13>;
23
24/// AES-128 key length in bytes.
25pub const AEAD_KEY_LEN: usize = 16;
26
27/// AES-CCM nonce length in bytes (Matter uses 13-byte nonces).
28pub const AEAD_NONCE_LEN: usize = 13;
29
30/// AEAD authentication tag length in bytes.
31pub const AEAD_TAG_LEN: usize = 16;
32
33/// AES-128-CCM-128 encrypt: returns `ciphertext || tag` (so
34/// `output.len() == plaintext.len() + AEAD_TAG_LEN`).
35///
36/// `aad` may be empty. Matches matter.js's `crypto.encrypt(key, plaintext,
37/// nonce, aad?)` byte-for-byte.
38///
39/// This builds a fresh key schedule on every call. Prefer [`SessionAead`]
40/// for any path that encrypts more than once per key (e.g. every outgoing
41/// message on a session) to avoid repeating AES key expansion.
42///
43/// # Errors
44///
45/// Returns [`Error::EncryptionFailed`] on encryption failure (not
46/// expected in practice for the spec-bounded message sizes).
47pub fn encrypt(
48    key: &[u8; AEAD_KEY_LEN],
49    nonce: &[u8; AEAD_NONCE_LEN],
50    aad: &[u8],
51    plaintext: &[u8],
52) -> Result<Vec<u8>> {
53    SessionAead::new(key).encrypt(nonce, aad, plaintext)
54}
55
56/// AES-128-CCM-128 decrypt: input is `ciphertext || tag` (so
57/// `ciphertext.len() >= AEAD_TAG_LEN`). Returns the plaintext if the tag
58/// verifies.
59///
60/// `aad` may be empty. The `ccm` crate verifies the tag in constant time
61/// internally via `subtle`.
62///
63/// This builds a fresh key schedule on every call. Prefer [`SessionAead`]
64/// for any path that decrypts more than once per key (e.g. every inbound
65/// message on a session) to avoid repeating AES key expansion.
66///
67/// # Errors
68///
69/// Returns [`Error::EncryptedBlobDecryptionFailed`] on any authentication
70/// or decryption failure. The error is intentionally not specific —
71/// distinguishing "wrong key" from "tampered ciphertext" is a spec-level
72/// design choice that prevents oracle attacks.
73pub fn decrypt(
74    key: &[u8; AEAD_KEY_LEN],
75    nonce: &[u8; AEAD_NONCE_LEN],
76    aad: &[u8],
77    ciphertext: &[u8],
78) -> Result<Vec<u8>> {
79    SessionAead::new(key).decrypt(nonce, aad, ciphertext)
80}
81
82/// AES-128-CTR keystream application (encrypt == decrypt), using the CCM
83/// counter-block convention for a 13-byte nonce — the construction chip's
84/// `AES_CTR_crypt` uses for group-message **privacy** obfuscation (Matter
85/// Core Spec §4.8.3).
86///
87/// Implemented as AES-CCM **encryption with empty AAD, discarding the tag**:
88/// CCM's payload keystream IS CTR mode with counter blocks
89/// `flags(L=2) || nonce || counter` starting at 1, which is byte-identical to
90/// chip's CTR construction for the same nonce (verified end-to-end by the
91/// full-frame privacy KAT in `matter-transport`, from connectedhomeip
92/// `TestSessionManagerDispatch.cpp`). This stays composition — the cipher
93/// itself remains the `aes`+`ccm` crates.
94///
95/// Applying the function twice with the same key + nonce returns the input
96/// (XOR keystream), so one function serves obfuscation and de-obfuscation.
97///
98/// This builds a fresh key schedule on every call. Prefer [`SessionAead`]
99/// for any path that applies the keystream more than once per key to avoid
100/// repeating AES key expansion.
101///
102/// # Errors
103///
104/// Returns [`Error::EncryptionFailed`] if the underlying cipher fails (not
105/// expected in practice for spec-bounded sizes).
106pub fn ctr_apply(
107    key: &[u8; AEAD_KEY_LEN],
108    nonce: &[u8; AEAD_NONCE_LEN],
109    data: &[u8],
110) -> Result<Vec<u8>> {
111    SessionAead::new(key).ctr_apply(nonce, data)
112}
113
114/// A session-scoped AES-128-CCM-128 cipher with the key schedule computed
115/// once at construction.
116///
117/// The free functions [`encrypt`], [`decrypt`], and [`ctr_apply`] each
118/// build a fresh `Aes128Ccm` cipher — including running AES-128 key
119/// expansion — on every call. That is the right trade-off for a one-shot
120/// use (a single CASE handshake blob, say), but it repeats the same
121/// key-schedule computation on every packet for any path that
122/// encrypts/decrypts more than once per key — most notably encrypting
123/// every outgoing Matter message and decrypting every inbound one on a
124/// live session. `SessionAead` runs AES-128 key expansion once, at
125/// construction, and reuses the expanded schedule (via the `ccm`/`aes`
126/// crates' own internal caching) for every subsequent call.
127///
128/// This is purely a performance optimisation: for identical
129/// key/nonce/aad/input, every method here produces output byte-identical
130/// to the corresponding free function. It composes the same `aes` +
131/// `ccm` crates the free functions use — no cryptographic primitive is
132/// reimplemented here.
133///
134/// Holds the expanded AES-128 key schedule for its lifetime; there is no
135/// `Debug` derive because printing that schedule would leak key material
136/// (see the manual [`Debug`] impl below).
137///
138/// **Secret hygiene:** the expanded key schedule is NOT zeroized on drop —
139/// the `ccm`/`aes` types we compose do not implement `ZeroizeOnDrop`, and we
140/// do not reimplement them. That is acceptable here because a `SessionAead`
141/// is always constructed from key material that is itself already resident
142/// unzeroized for the whole session (the session keys it is derived from),
143/// so dropping the handle removes no guarantee the caller had. `SessionAead`
144/// is therefore NOT a secret-erasure boundary: a caller that needs key
145/// material scrubbed must scrub the source key bytes (see
146/// [`crate::pase::PaseSessionKeys`], which is `ZeroizeOnDrop`) and drop every
147/// derived handle, and must not treat this type as providing erasure.
148pub struct SessionAead(Aes128Ccm);
149
150impl SessionAead {
151    /// Construct a cipher handle with the AES-128 key schedule computed
152    /// once, from a fixed-length key.
153    ///
154    /// Infallible: unlike `Aes128Ccm::new_from_slice` (which the free
155    /// functions used to call directly, and which validates a runtime
156    /// slice length), a `&[u8; AEAD_KEY_LEN]` is always a valid key length
157    /// by construction, so key initialisation cannot fail.
158    pub fn new(key: &[u8; AEAD_KEY_LEN]) -> Self {
159        let key_arr: Key<Aes128Ccm> = (*key).into();
160        Self(Aes128Ccm::new(&key_arr))
161    }
162
163    /// AES-128-CCM-128 encrypt using the cached key schedule. See
164    /// [`encrypt`] for the exact byte layout (`ciphertext || tag`) and
165    /// matter.js compatibility notes.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`Error::EncryptionFailed`] on encryption failure (not
170    /// expected in practice for the spec-bounded message sizes).
171    pub fn encrypt(
172        &self,
173        nonce: &[u8; AEAD_NONCE_LEN],
174        aad: &[u8],
175        plaintext: &[u8],
176    ) -> Result<Vec<u8>> {
177        let nonce_arr: Nonce<U13> = (*nonce).into();
178        self.0
179            .encrypt(
180                &nonce_arr,
181                Payload {
182                    msg: plaintext,
183                    aad,
184                },
185            )
186            .map_err(|_| Error::EncryptionFailed)
187    }
188
189    /// AES-128-CCM-128 decrypt using the cached key schedule. See
190    /// [`decrypt`] for the exact byte layout and error semantics.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`Error::EncryptedBlobDecryptionFailed`] on any
195    /// authentication or decryption failure.
196    pub fn decrypt(
197        &self,
198        nonce: &[u8; AEAD_NONCE_LEN],
199        aad: &[u8],
200        ciphertext: &[u8],
201    ) -> Result<Vec<u8>> {
202        let nonce_arr: Nonce<U13> = (*nonce).into();
203        self.0
204            .decrypt(
205                &nonce_arr,
206                Payload {
207                    msg: ciphertext,
208                    aad,
209                },
210            )
211            .map_err(|_| Error::EncryptedBlobDecryptionFailed)
212    }
213
214    /// In-place seal: encrypts `buf` in place and appends the 16-byte tag
215    /// (so `buf.len()` grows by [`AEAD_TAG_LEN`]), using the cached key
216    /// schedule. Avoids the extra allocation-and-copy [`encrypt`] pays for
217    /// callers that already own a mutable buffer to encrypt into (e.g. a
218    /// pre-assembled outgoing packet).
219    ///
220    /// Produces the same bytes `encrypt(...)` would for the same
221    /// key/nonce/aad/plaintext.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`Error::EncryptionFailed`] on encryption failure. On
226    /// error, `buf`'s contents are unspecified — the underlying
227    /// `ccm`/`aead` crates make no guarantee it is restored to its input
228    /// state, so callers must not read `buf` after an error.
229    pub fn encrypt_in_place(
230        &self,
231        nonce: &[u8; AEAD_NONCE_LEN],
232        aad: &[u8],
233        buf: &mut Vec<u8>,
234    ) -> Result<()> {
235        let nonce_arr: Nonce<U13> = (*nonce).into();
236        self.0
237            .encrypt_in_place(&nonce_arr, aad, buf)
238            .map_err(|_| Error::EncryptionFailed)
239    }
240
241    /// In-place open: verifies and strips the 16-byte tag, truncating
242    /// `buf` to the plaintext on success, using the cached key schedule.
243    /// Avoids the extra allocation-and-copy [`decrypt`] pays for callers
244    /// that already own the ciphertext in a mutable buffer (e.g. a
245    /// received packet being decrypted in place).
246    ///
247    /// # Errors
248    ///
249    /// Returns [`Error::EncryptedBlobDecryptionFailed`] on any
250    /// authentication or decryption failure. On error, `buf`'s contents
251    /// are unspecified — the underlying `ccm`/`aead` crates make no
252    /// guarantee it is restored to its input state, so callers must not
253    /// read `buf` after an error.
254    pub fn decrypt_in_place(
255        &self,
256        nonce: &[u8; AEAD_NONCE_LEN],
257        aad: &[u8],
258        buf: &mut Vec<u8>,
259    ) -> Result<()> {
260        let nonce_arr: Nonce<U13> = (*nonce).into();
261        self.0
262            .decrypt_in_place(&nonce_arr, aad, buf)
263            .map_err(|_| Error::EncryptedBlobDecryptionFailed)
264    }
265
266    /// CTR keystream application (see [`ctr_apply`]) using the cached key
267    /// schedule.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`Error::EncryptionFailed`] if the underlying cipher fails
272    /// (not expected in practice for spec-bounded sizes).
273    pub fn ctr_apply(&self, nonce: &[u8; AEAD_NONCE_LEN], data: &[u8]) -> Result<Vec<u8>> {
274        let mut out = self.encrypt(nonce, &[], data)?;
275        out.truncate(data.len()); // drop the CCM tag — only the keystream XOR remains
276        Ok(out)
277    }
278}
279
280impl core::fmt::Debug for SessionAead {
281    /// Prints a fixed opaque placeholder — never the expanded key
282    /// schedule. `Ccm<Aes128, U16, U13>` does not implement `Debug`
283    /// itself, and even if it did, printing key material would be a
284    /// hygiene bug (see [`crate::pase::PaseSessionKeys`]'s redacted
285    /// `Debug` for the same discipline applied to raw key bytes).
286    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
287        f.write_str("SessionAead(<aes-128-ccm>)")
288    }
289}
290
291#[cfg(test)]
292#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
293mod tests {
294    use super::*;
295
296    #[test]
297    fn ctr_apply_is_an_involution() {
298        let key = [0x42u8; AEAD_KEY_LEN];
299        let nonce = [0x17u8; AEAD_NONCE_LEN];
300        let data = b"obfuscate me please";
301        let once = ctr_apply(&key, &nonce, data).unwrap();
302        assert_ne!(&once[..], &data[..]);
303        let twice = ctr_apply(&key, &nonce, &once).unwrap();
304        assert_eq!(&twice[..], &data[..]);
305    }
306
307    #[test]
308    fn encrypt_decrypt_roundtrip() {
309        let key = [0x42u8; AEAD_KEY_LEN];
310        let nonce = [0x17u8; AEAD_NONCE_LEN];
311        let aad = b"matter aad";
312        let plaintext = b"the quick brown fox jumps over the lazy dog";
313
314        let ciphertext = encrypt(&key, &nonce, aad, plaintext).unwrap();
315        assert_eq!(ciphertext.len(), plaintext.len() + AEAD_TAG_LEN);
316
317        let decrypted = decrypt(&key, &nonce, aad, &ciphertext).unwrap();
318        assert_eq!(decrypted, plaintext);
319    }
320
321    #[test]
322    fn tampered_ciphertext_rejected() {
323        let key = [0x42u8; AEAD_KEY_LEN];
324        let nonce = [0x17u8; AEAD_NONCE_LEN];
325        let mut ciphertext = encrypt(&key, &nonce, b"", b"payload").unwrap();
326        ciphertext[0] ^= 1;
327        assert!(decrypt(&key, &nonce, b"", &ciphertext).is_err());
328    }
329
330    #[test]
331    fn wrong_key_rejected() {
332        let key = [0x42u8; AEAD_KEY_LEN];
333        let bad_key = [0x43u8; AEAD_KEY_LEN];
334        let nonce = [0x17u8; AEAD_NONCE_LEN];
335        let ciphertext = encrypt(&key, &nonce, b"", b"payload").unwrap();
336        assert!(decrypt(&bad_key, &nonce, b"", &ciphertext).is_err());
337    }
338
339    #[test]
340    fn wrong_aad_rejected() {
341        let key = [0x42u8; AEAD_KEY_LEN];
342        let nonce = [0x17u8; AEAD_NONCE_LEN];
343        let ciphertext = encrypt(&key, &nonce, b"good aad", b"payload").unwrap();
344        assert!(decrypt(&key, &nonce, b"bad aad", &ciphertext).is_err());
345    }
346
347    #[test]
348    fn session_aead_matches_free_functions() {
349        let key = [0x42u8; AEAD_KEY_LEN];
350        let nonce = [0x17u8; AEAD_NONCE_LEN];
351        let aad = b"matter aad";
352        let plaintext = b"the quick brown fox jumps over the lazy dog";
353
354        let handle = SessionAead::new(&key);
355
356        let via_handle = handle.encrypt(&nonce, aad, plaintext).unwrap();
357        let via_free_fn = encrypt(&key, &nonce, aad, plaintext).unwrap();
358        assert_eq!(via_handle, via_free_fn);
359
360        let decrypted_by_handle = handle.decrypt(&nonce, aad, &via_free_fn).unwrap();
361        let decrypted_by_free_fn = decrypt(&key, &nonce, aad, &via_handle).unwrap();
362        assert_eq!(decrypted_by_handle, plaintext);
363        assert_eq!(decrypted_by_free_fn, plaintext);
364
365        let keystream_by_handle = handle.ctr_apply(&nonce, plaintext).unwrap();
366        let keystream_by_free_fn = ctr_apply(&key, &nonce, plaintext).unwrap();
367        assert_eq!(keystream_by_handle, keystream_by_free_fn);
368    }
369
370    #[test]
371    fn in_place_matches_vec_api() {
372        let key = [0x42u8; AEAD_KEY_LEN];
373        let nonce = [0x17u8; AEAD_NONCE_LEN];
374        let aad = b"matter aad";
375        let plaintext = b"the quick brown fox jumps over the lazy dog".to_vec();
376
377        let session = SessionAead::new(&key);
378
379        let expected_ct = session.encrypt(&nonce, aad, &plaintext).unwrap();
380
381        let mut buf = plaintext.clone();
382        session.encrypt_in_place(&nonce, aad, &mut buf).unwrap();
383        assert_eq!(buf, expected_ct);
384
385        session.decrypt_in_place(&nonce, aad, &mut buf).unwrap();
386        assert_eq!(buf, plaintext);
387
388        // Tampered buffer: decrypt_in_place errors. Buffer contents after
389        // failure are unspecified (not asserted) — callers must not read
390        // `buf` after a decryption error.
391        let mut tampered = expected_ct.clone();
392        tampered[0] ^= 1;
393        assert!(session
394            .decrypt_in_place(&nonce, aad, &mut tampered)
395            .is_err());
396    }
397
398    /// A buffer too short to even hold the 16-byte tag must be rejected, not
399    /// mis-read. `decrypt_in_place` is on the inbound path (attacker-supplied
400    /// bytes), so this pins the `aead` crate's length guard: a truncated
401    /// datagram returns `Err` rather than underflowing the tag split.
402    #[test]
403    fn decrypt_in_place_rejects_buffer_shorter_than_tag() {
404        let key = [0x42u8; AEAD_KEY_LEN];
405        let nonce = [0x17u8; AEAD_NONCE_LEN];
406        let session = SessionAead::new(&key);
407
408        let mut too_short = vec![0xAAu8; 4]; // < AEAD_TAG_LEN
409        assert!(session
410            .decrypt_in_place(&nonce, b"aad", &mut too_short)
411            .is_err());
412
413        // Boundary: exactly one byte short of a bare tag is still rejected.
414        let mut one_short = vec![0xAAu8; AEAD_TAG_LEN - 1];
415        assert!(session
416            .decrypt_in_place(&nonce, b"aad", &mut one_short)
417            .is_err());
418    }
419
420    /// Compile-time proof that a `SessionAead` can be cached inside a
421    /// `Session` that is moved across threads / held in a `tokio` task —
422    /// the whole point of caching it per session. Mirrors the static-assert
423    /// pattern in `crate::pase`'s secret-hygiene tests.
424    fn assert_send_sync<T: Send + Sync>() {}
425
426    #[test]
427    fn session_aead_is_send_and_sync() {
428        assert_send_sync::<SessionAead>();
429    }
430
431    #[test]
432    fn session_aead_debug_is_opaque() {
433        let key = [0x42u8; AEAD_KEY_LEN];
434        let session = SessionAead::new(&key);
435        assert_eq!(format!("{session:?}"), "SessionAead(<aes-128-ccm>)");
436    }
437}