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}