Skip to main content

hap_crypto/
broadcast.rs

1//! HAP-BLE broadcast-notification encryption key. The accessory broadcasts
2//! encrypted value changes in its advertisements while disconnected; the
3//! controller decrypts them with this key, derived once per broadcast-key
4//! generation and persisted as pairing material.
5//!
6//! Broadcasts use a ChaCha20-Poly1305 construction with a **4-byte truncated
7//! Poly1305 tag** (HomeKit-specific), so `open` composes the `chacha20` and
8//! `poly1305` component crates rather than the 16-byte `aead`.
9
10use crate::error::{CryptoError, Result};
11use crate::kdf::hkdf_sha512;
12use zeroize::{Zeroize, ZeroizeOnDrop};
13
14const BROADCAST_INFO: &[u8] = b"Broadcast-Encryption-Key";
15
16/// A 32-byte ChaCha20-Poly1305 key for decrypting encrypted broadcast
17/// notifications. Zeroized on drop; its `Debug` is redacted.
18#[derive(Clone, Zeroize, ZeroizeOnDrop)]
19pub struct BroadcastKey([u8; 32]);
20
21impl core::fmt::Debug for BroadcastKey {
22    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
23        f.write_str("BroadcastKey(<redacted>)")
24    }
25}
26
27impl BroadcastKey {
28    /// Derive the broadcast key via HKDF-SHA512, info `"Broadcast-Encryption-Key"`.
29    /// `ikm` is the Pair-Verify shared secret; `salt` the controller LTPK.
30    ///
31    /// # Errors
32    /// [`CryptoError`] on an HKDF length error (never for 32-byte output).
33    pub fn derive(ikm: &[u8], salt: &[u8]) -> Result<Self> {
34        let mut out = [0u8; 32];
35        hkdf_sha512(ikm, salt, BROADCAST_INFO, &mut out)?;
36        Ok(Self(out))
37    }
38
39    /// Wrap raw key bytes (restored from persisted broadcast state).
40    #[must_use]
41    pub fn from_bytes(key: [u8; 32]) -> Self {
42        Self(key)
43    }
44
45    /// The raw key bytes, for caller persistence. Handle as a secret.
46    #[must_use]
47    pub fn as_bytes(&self) -> &[u8; 32] {
48        &self.0
49    }
50
51    /// Encrypt `plaintext` into a broadcast payload `ciphertext || tag[:4]` for
52    /// `gsn`, binding the 6-byte `advertising_id` as AAD. Uses the HAP 4-byte
53    /// partial Poly1305 tag — the symmetric counterpart of [`BroadcastKey::open`].
54    ///
55    /// The returned `Vec<u8>` is `ciphertext || 4-byte-tag`, ready to append to a
56    /// `0x11` advertisement after the advertising id. Primarily useful for tests
57    /// and tooling (an accessory seals; a controller opens).
58    #[must_use]
59    pub fn seal(&self, gsn: u16, plaintext: &[u8], advertising_id: &[u8; 6]) -> Vec<u8> {
60        use chacha20::cipher::{KeyIvInit, StreamCipher};
61        use poly1305::universal_hash::{KeyInit, UniversalHash};
62
63        // Same 12-byte IETF nonce as `open`.
64        let mut nonce = [0u8; 12];
65        nonce[4..].copy_from_slice(&u64::from(gsn).to_le_bytes());
66
67        let mut cipher = chacha20::ChaCha20::new(
68            chacha20::Key::from_slice(&self.0),
69            chacha20::Nonce::from_slice(&nonce),
70        );
71        // Block 0: derive the Poly1305 one-time key.
72        let mut poly_block = zeroize::Zeroizing::new([0u8; 64]);
73        cipher.apply_keystream(&mut *poly_block);
74
75        // Encrypt at block 1 (cipher already positioned there).
76        let mut ciphertext = plaintext.to_vec();
77        cipher.apply_keystream(&mut ciphertext);
78
79        // Compute the RFC 8439 MAC over the ciphertext.
80        let mut mac = poly1305::Poly1305::new(poly1305::Key::from_slice(&poly_block[..32]));
81        mac.update_padded(&mac_data(advertising_id, &ciphertext));
82        let full_tag = mac.finalize();
83
84        // Append the first 4 bytes of the Poly1305 tag (HAP broadcast convention).
85        ciphertext.extend_from_slice(&full_tag.as_slice()[..4]);
86        ciphertext
87    }
88
89    /// Decrypt one encrypted broadcast payload `combined_text` (= `ciphertext ||
90    /// tag[:4]`) for `gsn`, binding the 6-byte `advertising_id` as AAD. Uses the
91    /// HAP 4-byte partial Poly1305 tag.
92    ///
93    /// # Errors
94    /// [`CryptoError::Aead`] if the partial tag does not match (wrong
95    /// key/gsn/aad or tampered payload) or the input is too short.
96    pub fn open(
97        &self,
98        gsn: u16,
99        combined_text: &[u8],
100        advertising_id: &[u8; 6],
101    ) -> Result<Vec<u8>> {
102        use chacha20::cipher::{KeyIvInit, StreamCipher};
103        use poly1305::universal_hash::{KeyInit, UniversalHash};
104        use subtle::ConstantTimeEq;
105
106        if combined_text.len() < 4 {
107            return Err(CryptoError::Aead);
108        }
109        // Build the 12-byte IETF nonce: 4 zero bytes then gsn as u64 little-endian.
110        let mut nonce = [0u8; 12];
111        nonce[4..].copy_from_slice(&u64::from(gsn).to_le_bytes());
112
113        let (ciphertext, tag4) = combined_text.split_at(combined_text.len() - 4);
114
115        // Derive the Poly1305 one-time key from ChaCha20 block 0 (64 bytes).
116        let mut cipher = chacha20::ChaCha20::new(
117            chacha20::Key::from_slice(&self.0),
118            chacha20::Nonce::from_slice(&nonce),
119        );
120        // Zeroized on drop: the block-0 keystream contains the Poly1305 one-time
121        // key (secret-derived material).
122        let mut poly_block = zeroize::Zeroizing::new([0u8; 64]);
123        cipher.apply_keystream(&mut *poly_block);
124
125        // Compute the RFC 8439 MAC over (aad || pad16 || ciphertext || pad16 ||
126        // le64(aad_len) || le64(ciphertext_len)).
127        let mut mac = poly1305::Poly1305::new(poly1305::Key::from_slice(&poly_block[..32]));
128        mac.update_padded(&mac_data(advertising_id, ciphertext));
129        let full_tag = mac.finalize();
130
131        // Constant-time compare only the first 4 bytes of the Poly1305 tag.
132        if full_tag.as_slice()[..4].ct_eq(tag4).unwrap_u8() != 1 {
133            return Err(CryptoError::Aead);
134        }
135
136        // Decrypt: cipher is already positioned at block 1 after the key-gen block.
137        let mut plaintext = ciphertext.to_vec();
138        cipher.apply_keystream(&mut plaintext);
139        Ok(plaintext)
140    }
141}
142
143/// RFC 8439 AEAD MAC input: `aad || pad16 || ciphertext || pad16 ||
144/// le64(aad_len) || le64(ciphertext_len)`, block-aligned.
145fn mac_data(aad: &[u8], ciphertext: &[u8]) -> Vec<u8> {
146    let mut d = Vec::new();
147    d.extend_from_slice(aad);
148    while d.len() % 16 != 0 {
149        d.push(0);
150    }
151    d.extend_from_slice(ciphertext);
152    while d.len() % 16 != 0 {
153        d.push(0);
154    }
155    d.extend_from_slice(&(aad.len() as u64).to_le_bytes());
156    d.extend_from_slice(&(ciphertext.len() as u64).to_le_bytes());
157    d
158}
159
160#[cfg(test)]
161// Test code only: CLAUDE.md carves out `unwrap`/`expect` for tests.
162// A failed `unwrap` here is itself a test failure.
163#[allow(clippy::unwrap_used)]
164mod tests {
165    use super::*;
166
167    fn hex(s: &str) -> Vec<u8> {
168        (0..s.len() / 2)
169            .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
170            .collect()
171    }
172
173    #[test]
174    #[allow(clippy::unwrap_used)]
175    fn derive_matches_aiohomekit_vector() {
176        let v: serde_json::Value = serde_json::from_str(include_str!(
177            "../../../test-vectors/ble-broadcast/derive.json"
178        ))
179        .unwrap();
180        let key = BroadcastKey::derive(
181            &hex(v["ikm_hex"].as_str().unwrap()),
182            &hex(v["salt_hex"].as_str().unwrap()),
183        )
184        .unwrap();
185        assert_eq!(key.as_bytes(), &hex(v["key_hex"].as_str().unwrap())[..]);
186    }
187
188    #[test]
189    #[allow(clippy::unwrap_used)]
190    fn open_matches_aiohomekit_partial_tag_vector() {
191        let v: serde_json::Value = serde_json::from_str(include_str!(
192            "../../../test-vectors/ble-broadcast/open.json"
193        ))
194        .unwrap();
195        let mut k = [0u8; 32];
196        k.copy_from_slice(&hex(v["key_hex"].as_str().unwrap()));
197        let mut aid = [0u8; 6];
198        aid.copy_from_slice(&hex(v["advertising_id_hex"].as_str().unwrap()));
199        let key = BroadcastKey::from_bytes(k);
200        let pt = key
201            .open(
202                u16::try_from(v["gsn"].as_u64().unwrap()).unwrap(),
203                &hex(v["combined_text_hex"].as_str().unwrap()),
204                &aid,
205            )
206            .unwrap();
207        assert_eq!(pt, hex(v["plaintext_hex"].as_str().unwrap()));
208    }
209
210    #[test]
211    #[allow(clippy::unwrap_used)]
212    fn seal_then_open_round_trip() {
213        let key = BroadcastKey::from_bytes([0xABu8; 32]);
214        let aid: [u8; 6] = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
215        let plaintext = b"hello world!";
216        let sealed = key.seal(42, plaintext, &aid);
217        let recovered = key.open(42, &sealed, &aid).unwrap();
218        assert_eq!(recovered, plaintext);
219    }
220
221    #[test]
222    #[allow(clippy::unwrap_used)]
223    fn open_rejects_wrong_key() {
224        let v: serde_json::Value = serde_json::from_str(include_str!(
225            "../../../test-vectors/ble-broadcast/open.json"
226        ))
227        .unwrap();
228        let mut aid = [0u8; 6];
229        aid.copy_from_slice(&hex(v["advertising_id_hex"].as_str().unwrap()));
230        let key = BroadcastKey::from_bytes([0u8; 32]); // wrong key
231        assert!(key
232            .open(
233                u16::try_from(v["gsn"].as_u64().unwrap()).unwrap(),
234                &hex(v["combined_text_hex"].as_str().unwrap()),
235                &aid,
236            )
237            .is_err());
238    }
239}