use crate::error::{CryptoError, Result};
use crate::kdf::hkdf_sha512;
use zeroize::{Zeroize, ZeroizeOnDrop};
const BROADCAST_INFO: &[u8] = b"Broadcast-Encryption-Key";
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct BroadcastKey([u8; 32]);
impl core::fmt::Debug for BroadcastKey {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("BroadcastKey(<redacted>)")
}
}
impl BroadcastKey {
pub fn derive(ikm: &[u8], salt: &[u8]) -> Result<Self> {
let mut out = [0u8; 32];
hkdf_sha512(ikm, salt, BROADCAST_INFO, &mut out)?;
Ok(Self(out))
}
#[must_use]
pub fn from_bytes(key: [u8; 32]) -> Self {
Self(key)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[must_use]
pub fn seal(&self, gsn: u16, plaintext: &[u8], advertising_id: &[u8; 6]) -> Vec<u8> {
use chacha20::cipher::{KeyIvInit, StreamCipher};
use poly1305::universal_hash::{KeyInit, UniversalHash};
let mut nonce = [0u8; 12];
nonce[4..].copy_from_slice(&u64::from(gsn).to_le_bytes());
let mut cipher = chacha20::ChaCha20::new(
chacha20::Key::from_slice(&self.0),
chacha20::Nonce::from_slice(&nonce),
);
let mut poly_block = zeroize::Zeroizing::new([0u8; 64]);
cipher.apply_keystream(&mut *poly_block);
let mut ciphertext = plaintext.to_vec();
cipher.apply_keystream(&mut ciphertext);
let mut mac = poly1305::Poly1305::new(poly1305::Key::from_slice(&poly_block[..32]));
mac.update_padded(&mac_data(advertising_id, &ciphertext));
let full_tag = mac.finalize();
ciphertext.extend_from_slice(&full_tag.as_slice()[..4]);
ciphertext
}
pub fn open(
&self,
gsn: u16,
combined_text: &[u8],
advertising_id: &[u8; 6],
) -> Result<Vec<u8>> {
use chacha20::cipher::{KeyIvInit, StreamCipher};
use poly1305::universal_hash::{KeyInit, UniversalHash};
use subtle::ConstantTimeEq;
if combined_text.len() < 4 {
return Err(CryptoError::Aead);
}
let mut nonce = [0u8; 12];
nonce[4..].copy_from_slice(&u64::from(gsn).to_le_bytes());
let (ciphertext, tag4) = combined_text.split_at(combined_text.len() - 4);
let mut cipher = chacha20::ChaCha20::new(
chacha20::Key::from_slice(&self.0),
chacha20::Nonce::from_slice(&nonce),
);
let mut poly_block = zeroize::Zeroizing::new([0u8; 64]);
cipher.apply_keystream(&mut *poly_block);
let mut mac = poly1305::Poly1305::new(poly1305::Key::from_slice(&poly_block[..32]));
mac.update_padded(&mac_data(advertising_id, ciphertext));
let full_tag = mac.finalize();
if full_tag.as_slice()[..4].ct_eq(tag4).unwrap_u8() != 1 {
return Err(CryptoError::Aead);
}
let mut plaintext = ciphertext.to_vec();
cipher.apply_keystream(&mut plaintext);
Ok(plaintext)
}
}
fn mac_data(aad: &[u8], ciphertext: &[u8]) -> Vec<u8> {
let mut d = Vec::new();
d.extend_from_slice(aad);
while d.len() % 16 != 0 {
d.push(0);
}
d.extend_from_slice(ciphertext);
while d.len() % 16 != 0 {
d.push(0);
}
d.extend_from_slice(&(aad.len() as u64).to_le_bytes());
d.extend_from_slice(&(ciphertext.len() as u64).to_le_bytes());
d
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn hex(s: &str) -> Vec<u8> {
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
.collect()
}
#[test]
#[allow(clippy::unwrap_used)]
fn derive_matches_aiohomekit_vector() {
let v: serde_json::Value = serde_json::from_str(include_str!(
"../../../test-vectors/ble-broadcast/derive.json"
))
.unwrap();
let key = BroadcastKey::derive(
&hex(v["ikm_hex"].as_str().unwrap()),
&hex(v["salt_hex"].as_str().unwrap()),
)
.unwrap();
assert_eq!(key.as_bytes(), &hex(v["key_hex"].as_str().unwrap())[..]);
}
#[test]
#[allow(clippy::unwrap_used)]
fn open_matches_aiohomekit_partial_tag_vector() {
let v: serde_json::Value = serde_json::from_str(include_str!(
"../../../test-vectors/ble-broadcast/open.json"
))
.unwrap();
let mut k = [0u8; 32];
k.copy_from_slice(&hex(v["key_hex"].as_str().unwrap()));
let mut aid = [0u8; 6];
aid.copy_from_slice(&hex(v["advertising_id_hex"].as_str().unwrap()));
let key = BroadcastKey::from_bytes(k);
let pt = key
.open(
u16::try_from(v["gsn"].as_u64().unwrap()).unwrap(),
&hex(v["combined_text_hex"].as_str().unwrap()),
&aid,
)
.unwrap();
assert_eq!(pt, hex(v["plaintext_hex"].as_str().unwrap()));
}
#[test]
#[allow(clippy::unwrap_used)]
fn seal_then_open_round_trip() {
let key = BroadcastKey::from_bytes([0xABu8; 32]);
let aid: [u8; 6] = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
let plaintext = b"hello world!";
let sealed = key.seal(42, plaintext, &aid);
let recovered = key.open(42, &sealed, &aid).unwrap();
assert_eq!(recovered, plaintext);
}
#[test]
#[allow(clippy::unwrap_used)]
fn open_rejects_wrong_key() {
let v: serde_json::Value = serde_json::from_str(include_str!(
"../../../test-vectors/ble-broadcast/open.json"
))
.unwrap();
let mut aid = [0u8; 6];
aid.copy_from_slice(&hex(v["advertising_id_hex"].as_str().unwrap()));
let key = BroadcastKey::from_bytes([0u8; 32]); assert!(key
.open(
u16::try_from(v["gsn"].as_u64().unwrap()).unwrap(),
&hex(v["combined_text_hex"].as_str().unwrap()),
&aid,
)
.is_err());
}
}