Skip to main content

matter_crypto/
checkin.rs

1//! Matter Check-In message codec (Matter Core §4.18.2) — the payload an ICD
2//! sends unsolicited to a registered client when it briefly wakes. Reuses the
3//! crate's AES-128-CCM AEAD and `ring` HMAC-SHA256; never implements primitives.
4//!
5//! Wire: `nonce(13) ‖ ciphertext ‖ mic(16)`, where `nonce = HMAC-SHA256(key,
6//! counter)[..13]` and `ciphertext‖mic = AES-CCM(key, nonce, counter ‖ app_data,
7//! aad = ∅)` (`counter` little-endian). A single 16-byte registration key is
8//! used for both the HMAC (nonce) and the AES-CCM (payload).
9
10#![forbid(unsafe_code)]
11
12use crate::aead;
13
14/// Length of the ICD registration / Check-In symmetric key.
15pub const CHECKIN_KEY_LEN: usize = 16;
16
17const NONCE_LEN: usize = 13; // AEAD nonce length
18const MIC_LEN: usize = 16;
19const COUNTER_LEN: usize = 4;
20const MIN_PAYLOAD: usize = NONCE_LEN + COUNTER_LEN + MIC_LEN; // 33
21
22/// Errors decoding a Check-In message.
23#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum CheckinError {
26    /// Payload shorter than the 33-byte minimum.
27    #[error("check-in payload too short")]
28    TooShort,
29    /// AEAD authentication failed (wrong key or tampered ciphertext).
30    #[error("check-in decryption/authentication failed")]
31    AuthFailed,
32    /// The counter-derived nonce did not match the payload nonce.
33    #[error("check-in nonce mismatch")]
34    NonceMismatch,
35    /// The AES-CCM encode produced no output (unreachable for fixed-length keys).
36    #[error("check-in encode failed")]
37    EncodeFailed,
38}
39
40/// The 13-byte Check-In nonce = first 13 bytes of `HMAC-SHA256(key, counter)`
41/// (with `counter` little-endian).
42fn checkin_nonce(key: &[u8; CHECKIN_KEY_LEN], counter: u32) -> [u8; NONCE_LEN] {
43    let hk = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, key);
44    let tag = ring::hmac::sign(&hk, &counter.to_le_bytes());
45    let mut nonce = [0u8; NONCE_LEN];
46    nonce.copy_from_slice(&tag.as_ref()[..NONCE_LEN]);
47    nonce
48}
49
50/// Encode a Check-In message payload for `counter` + `app_data` under `key`.
51///
52/// # Errors
53/// [`CheckinError::EncodeFailed`] only if the AES-CCM layer fails — impossible in
54/// practice for a fixed-length key and spec-bounded sizes.
55pub fn encode_checkin(
56    key: &[u8; CHECKIN_KEY_LEN],
57    counter: u32,
58    app_data: &[u8],
59) -> Result<Vec<u8>, CheckinError> {
60    let nonce = checkin_nonce(key, counter);
61    let mut plaintext = Vec::with_capacity(COUNTER_LEN + app_data.len());
62    plaintext.extend_from_slice(&counter.to_le_bytes());
63    plaintext.extend_from_slice(app_data);
64    let ct_tag =
65        aead::encrypt(key, &nonce, &[], &plaintext).map_err(|_| CheckinError::EncodeFailed)?;
66    let mut out = Vec::with_capacity(NONCE_LEN + ct_tag.len());
67    out.extend_from_slice(&nonce);
68    out.extend_from_slice(&ct_tag);
69    Ok(out)
70}
71
72/// Decode + verify a Check-In payload, returning `(counter, app_data)`.
73///
74/// # Errors
75/// [`CheckinError`] on a short payload, failed AEAD authentication, or a nonce
76/// that does not match the decrypted counter.
77pub fn decode_checkin(
78    key: &[u8; CHECKIN_KEY_LEN],
79    payload: &[u8],
80) -> Result<(u32, Vec<u8>), CheckinError> {
81    if payload.len() < MIN_PAYLOAD {
82        return Err(CheckinError::TooShort);
83    }
84    let (nonce_bytes, ct_tag) = payload.split_at(NONCE_LEN);
85    let mut nonce = [0u8; NONCE_LEN];
86    nonce.copy_from_slice(nonce_bytes);
87    let mut plaintext =
88        aead::decrypt(key, &nonce, &[], ct_tag).map_err(|_| CheckinError::AuthFailed)?;
89    if plaintext.len() < COUNTER_LEN {
90        return Err(CheckinError::TooShort);
91    }
92    let mut c = [0u8; COUNTER_LEN];
93    c.copy_from_slice(&plaintext[..COUNTER_LEN]);
94    let counter = u32::from_le_bytes(c);
95    // Verify the nonce is the one the counter derives (chip does this).
96    if checkin_nonce(key, counter) != nonce {
97        return Err(CheckinError::NonceMismatch);
98    }
99    plaintext.drain(..COUNTER_LEN);
100    Ok((counter, plaintext))
101}
102
103#[cfg(test)]
104mod tests {
105    #![allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md carve-out.
106    use super::*;
107
108    fn unhex(s: &str) -> Vec<u8> {
109        (0..s.len())
110            .step_by(2)
111            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
112            .collect()
113    }
114
115    // chip CheckIn_Message_test_vectors.h vector1: key, counter 12, empty appData.
116    const KEY1: &str = "d90e13180d00baadd20cf5ed4913d3ff";
117    const PAYLOAD1: &str = "4580d2c6f1310dc4eb64f1f8e8bdc21fb5195d747dd2879b2b0d43ce5b1c565078";
118
119    fn key16(hex: &str) -> [u8; 16] {
120        let mut k = [0u8; 16];
121        k.copy_from_slice(&unhex(hex));
122        k
123    }
124
125    #[test]
126    fn encode_matches_chip_vector1() {
127        assert_eq!(
128            encode_checkin(&key16(KEY1), 12, &[]).unwrap(),
129            unhex(PAYLOAD1)
130        );
131    }
132
133    #[test]
134    fn decode_matches_chip_vector1() {
135        let (counter, app) = decode_checkin(&key16(KEY1), &unhex(PAYLOAD1)).unwrap();
136        assert_eq!(counter, 12);
137        assert!(app.is_empty());
138    }
139
140    #[test]
141    fn decode_rejects_wrong_key() {
142        assert!(matches!(
143            decode_checkin(&[0u8; 16], &unhex(PAYLOAD1)),
144            Err(CheckinError::AuthFailed)
145        ));
146    }
147
148    #[test]
149    fn decode_rejects_too_short() {
150        assert!(matches!(
151            decode_checkin(&key16(KEY1), &[0u8; 10]),
152            Err(CheckinError::TooShort)
153        ));
154    }
155
156    #[test]
157    fn roundtrip_with_app_data() {
158        let key = [0x11u8; 16];
159        let payload = encode_checkin(&key, 0x0102_0304, b"This").unwrap();
160        let (c, app) = decode_checkin(&key, &payload).unwrap();
161        assert_eq!(c, 0x0102_0304);
162        assert_eq!(app, b"This");
163    }
164}