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 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    Ok((counter, plaintext[COUNTER_LEN..].to_vec()))
100}
101
102#[cfg(test)]
103mod tests {
104    #![allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md carve-out.
105    use super::*;
106
107    fn unhex(s: &str) -> Vec<u8> {
108        (0..s.len())
109            .step_by(2)
110            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
111            .collect()
112    }
113
114    // chip CheckIn_Message_test_vectors.h vector1: key, counter 12, empty appData.
115    const KEY1: &str = "d90e13180d00baadd20cf5ed4913d3ff";
116    const PAYLOAD1: &str = "4580d2c6f1310dc4eb64f1f8e8bdc21fb5195d747dd2879b2b0d43ce5b1c565078";
117
118    fn key16(hex: &str) -> [u8; 16] {
119        let mut k = [0u8; 16];
120        k.copy_from_slice(&unhex(hex));
121        k
122    }
123
124    #[test]
125    fn encode_matches_chip_vector1() {
126        assert_eq!(
127            encode_checkin(&key16(KEY1), 12, &[]).unwrap(),
128            unhex(PAYLOAD1)
129        );
130    }
131
132    #[test]
133    fn decode_matches_chip_vector1() {
134        let (counter, app) = decode_checkin(&key16(KEY1), &unhex(PAYLOAD1)).unwrap();
135        assert_eq!(counter, 12);
136        assert!(app.is_empty());
137    }
138
139    #[test]
140    fn decode_rejects_wrong_key() {
141        assert!(matches!(
142            decode_checkin(&[0u8; 16], &unhex(PAYLOAD1)),
143            Err(CheckinError::AuthFailed)
144        ));
145    }
146
147    #[test]
148    fn decode_rejects_too_short() {
149        assert!(matches!(
150            decode_checkin(&key16(KEY1), &[0u8; 10]),
151            Err(CheckinError::TooShort)
152        ));
153    }
154
155    #[test]
156    fn roundtrip_with_app_data() {
157        let key = [0x11u8; 16];
158        let payload = encode_checkin(&key, 0x0102_0304, b"This").unwrap();
159        let (c, app) = decode_checkin(&key, &payload).unwrap();
160        assert_eq!(c, 0x0102_0304);
161        assert_eq!(app, b"This");
162    }
163}