1use crate::error::{CryptoError, Result};
11use crate::kdf::hkdf_sha512;
12use zeroize::{Zeroize, ZeroizeOnDrop};
13
14const BROADCAST_INFO: &[u8] = b"Broadcast-Encryption-Key";
15
16#[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 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 #[must_use]
41 pub fn from_bytes(key: [u8; 32]) -> Self {
42 Self(key)
43 }
44
45 #[must_use]
47 pub fn as_bytes(&self) -> &[u8; 32] {
48 &self.0
49 }
50
51 #[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 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 let mut poly_block = zeroize::Zeroizing::new([0u8; 64]);
73 cipher.apply_keystream(&mut *poly_block);
74
75 let mut ciphertext = plaintext.to_vec();
77 cipher.apply_keystream(&mut ciphertext);
78
79 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 ciphertext.extend_from_slice(&full_tag.as_slice()[..4]);
86 ciphertext
87 }
88
89 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 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 let mut cipher = chacha20::ChaCha20::new(
117 chacha20::Key::from_slice(&self.0),
118 chacha20::Nonce::from_slice(&nonce),
119 );
120 let mut poly_block = zeroize::Zeroizing::new([0u8; 64]);
123 cipher.apply_keystream(&mut *poly_block);
124
125 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 if full_tag.as_slice()[..4].ct_eq(tag4).unwrap_u8() != 1 {
133 return Err(CryptoError::Aead);
134 }
135
136 let mut plaintext = ciphertext.to_vec();
138 cipher.apply_keystream(&mut plaintext);
139 Ok(plaintext)
140 }
141}
142
143fn 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#[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]); 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}