Skip to main content

hap_crypto/
aead.rs

1//! ChaCha20-Poly1305 authenticated encryption for Pair Setup.
2//!
3//! HomeKit uses the IETF construction of ChaCha20-Poly1305 (RFC 8439): a
4//! 256-bit key, a 96-bit (12-byte) nonce, and a 128-bit (16-byte) Poly1305
5//! authentication tag appended to the ciphertext. The encrypted M5/M6 sub-TLVs
6//! of Pair Setup are sealed with this AEAD.
7//!
8//! The primitive is never reimplemented; it comes from the RustCrypto
9//! [`chacha20poly1305`] crate.
10//!
11//! # HAP nonce layout
12//!
13//! HAP builds the 12-byte nonce as four leading zero bytes followed by an
14//! 8-byte ASCII label (the 64-bit counter region is left-zero-padded and the
15//! label occupies its low bytes):
16//!
17//! ```text
18//! byte:  0 1 2 3 | 4 5 6 7 8 9 10 11
19//!        0 0 0 0 |    label[0..8]
20//! ```
21//!
22//! For example the M5 label `b"PS-Msg05"` (exactly 8 bytes) yields the nonce
23//! `[0, 0, 0, 0, b'P', b'S', b'-', b'M', b's', b'g', b'0', b'5']`. Labels
24//! shorter than 8 bytes occupy the low bytes of the 8-byte region, leaving the
25//! remaining high bytes zero. See the crate-internal `hap_nonce` helper.
26
27use chacha20poly1305::aead::{Aead, KeyInit, Payload};
28use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
29
30use crate::error::{CryptoError, Result};
31
32/// Build a 12-byte HAP nonce: four zero bytes followed by an 8-byte counter
33/// region whose low bytes hold `label` (left-zero-padded if `label` is shorter
34/// than 8 bytes). See the [module docs](self) for the exact layout.
35///
36/// Labels longer than 8 bytes are truncated to their first 8 bytes; HAP labels
37/// (e.g. `b"PS-Msg05"`) are always exactly 8 bytes.
38pub(crate) fn hap_nonce(label: &[u8]) -> [u8; 12] {
39    let mut nonce = [0u8; 12];
40    let n = label.len().min(8);
41    // Place the label in the low bytes of the 8-byte counter region (bytes
42    // 4..12), leaving any unused high bytes zero.
43    nonce[4..4 + n].copy_from_slice(&label[..n]);
44    nonce
45}
46
47/// Encrypt `plaintext` with ChaCha20-Poly1305 under `key`/`nonce`, binding
48/// `aad`, returning `ciphertext || tag` (the 16-byte Poly1305 tag is appended).
49///
50/// # Errors
51///
52/// Returns [`CryptoError::Aead`] only on an internal AEAD usage error; for
53/// well-formed in-memory inputs (as used by Pair Setup) encryption does not
54/// fail.
55pub(crate) fn encrypt(
56    key: &[u8; 32],
57    nonce: &[u8; 12],
58    aad: &[u8],
59    plaintext: &[u8],
60) -> Result<Vec<u8>> {
61    let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
62    cipher
63        .encrypt(
64            Nonce::from_slice(nonce),
65            Payload {
66                msg: plaintext,
67                aad,
68            },
69        )
70        .map_err(|_| CryptoError::Aead)
71}
72
73/// Decrypt `ciphertext_and_tag` (ciphertext with the 16-byte Poly1305 tag
74/// appended) with ChaCha20-Poly1305 under `key`/`nonce`, verifying `aad`,
75/// returning the recovered plaintext.
76///
77/// # Errors
78///
79/// Returns [`CryptoError::Aead`] if authentication fails — a wrong key, a
80/// tampered ciphertext or tag, mismatched `aad`, or input shorter than the
81/// 16-byte tag.
82pub(crate) fn decrypt(
83    key: &[u8; 32],
84    nonce: &[u8; 12],
85    aad: &[u8],
86    ciphertext_and_tag: &[u8],
87) -> Result<Vec<u8>> {
88    let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
89    cipher
90        .decrypt(
91            Nonce::from_slice(nonce),
92            Payload {
93                msg: ciphertext_and_tag,
94                aad,
95            },
96        )
97        .map_err(|_| CryptoError::Aead)
98}
99
100/// Seal `plaintext` with ChaCha20-Poly1305 under `key`/`nonce`, binding `aad`,
101/// returning `ciphertext || tag`. Thin public wrapper over the crate-internal
102/// `encrypt` helper, used by the `hap-transport` record layer.
103///
104/// # Errors
105///
106/// Returns [`crate::CryptoError::Aead`] only on an internal AEAD usage error.
107pub fn chacha20poly1305_seal(
108    key: &[u8; 32],
109    nonce: &[u8; 12],
110    aad: &[u8],
111    plaintext: &[u8],
112) -> crate::error::Result<Vec<u8>> {
113    encrypt(key, nonce, aad, plaintext)
114}
115
116/// Open `ciphertext_and_tag` (ciphertext with the 16-byte Poly1305 tag appended)
117/// with ChaCha20-Poly1305 under `key`/`nonce`, verifying `aad`, returning the
118/// recovered plaintext. Thin public wrapper over the crate-internal `decrypt`
119/// helper, used by the `hap-transport` record layer.
120///
121/// # Errors
122///
123/// Returns [`crate::CryptoError::Aead`] if authentication fails — a wrong key,
124/// tampered ciphertext or tag, or mismatched `aad`.
125pub fn chacha20poly1305_open(
126    key: &[u8; 32],
127    nonce: &[u8; 12],
128    aad: &[u8],
129    ciphertext_and_tag: &[u8],
130) -> crate::error::Result<Vec<u8>> {
131    decrypt(key, nonce, aad, ciphertext_and_tag)
132}
133
134#[cfg(test)]
135// Test code only: CLAUDE.md carves out `unwrap`/`expect` for tests with a
136// documented justification. A failed `unwrap` here is itself a test failure.
137#[allow(clippy::unwrap_used, clippy::expect_used)]
138mod tests {
139    use super::*;
140
141    fn h(s: &str) -> Vec<u8> {
142        hex::decode(s).unwrap()
143    }
144
145    // RFC 8439 §2.8.2 "Example and Test Vector for AEAD_CHACHA20_POLY1305".
146    // The plaintext is the ASCII sentence given in the RFC; key, nonce, AAD,
147    // ciphertext and tag are the published hex octet sequences.
148    const RFC8439_PLAINTEXT: &[u8] =
149        b"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.";
150    const RFC8439_KEY: &str = "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f";
151    const RFC8439_NONCE: &str = "070000004041424344454647";
152    const RFC8439_AAD: &str = "50515253c0c1c2c3c4c5c6c7";
153    const RFC8439_CIPHERTEXT: &str = "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d63dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b3692ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc3ff4def08e4b7a9de576d26586cec64b6116";
154    const RFC8439_TAG: &str = "1ae10b594f09e26a7e902ecbd0600691";
155
156    fn key() -> [u8; 32] {
157        h(RFC8439_KEY).try_into().unwrap()
158    }
159    fn nonce() -> [u8; 12] {
160        h(RFC8439_NONCE).try_into().unwrap()
161    }
162
163    #[test]
164    fn encrypt_matches_rfc8439_vector() {
165        let aad = h(RFC8439_AAD);
166        let mut expected = h(RFC8439_CIPHERTEXT);
167        expected.extend_from_slice(&h(RFC8439_TAG));
168
169        let out = encrypt(&key(), &nonce(), &aad, RFC8439_PLAINTEXT).unwrap();
170        assert_eq!(out, expected);
171    }
172
173    #[test]
174    fn decrypt_matches_rfc8439_vector() {
175        let aad = h(RFC8439_AAD);
176        let mut ct = h(RFC8439_CIPHERTEXT);
177        ct.extend_from_slice(&h(RFC8439_TAG));
178
179        let plain = decrypt(&key(), &nonce(), &aad, &ct).unwrap();
180        assert_eq!(plain, RFC8439_PLAINTEXT);
181    }
182
183    #[test]
184    fn round_trip() {
185        let k = [0x42u8; 32];
186        let n = hap_nonce(b"PS-Msg05");
187        let aad = b"aad bytes";
188        let msg = b"the M5 sub-TLV plaintext";
189        let sealed = encrypt(&k, &n, aad, msg).unwrap();
190        let opened = decrypt(&k, &n, aad, &sealed).unwrap();
191        assert_eq!(opened, msg);
192    }
193
194    #[test]
195    fn decrypt_rejects_tampered_tag() {
196        let aad = h(RFC8439_AAD);
197        let mut ct = h(RFC8439_CIPHERTEXT);
198        ct.extend_from_slice(&h(RFC8439_TAG));
199        // Flip the last bit of the tag.
200        let last = ct.len() - 1;
201        ct[last] ^= 0x01;
202        assert!(matches!(
203            decrypt(&key(), &nonce(), &aad, &ct),
204            Err(CryptoError::Aead)
205        ));
206    }
207
208    #[test]
209    fn decrypt_rejects_tampered_ciphertext() {
210        let aad = h(RFC8439_AAD);
211        let mut ct = h(RFC8439_CIPHERTEXT);
212        ct.extend_from_slice(&h(RFC8439_TAG));
213        // Flip the first ciphertext byte.
214        ct[0] ^= 0x01;
215        assert!(matches!(
216            decrypt(&key(), &nonce(), &aad, &ct),
217            Err(CryptoError::Aead)
218        ));
219    }
220
221    #[test]
222    fn decrypt_rejects_wrong_aad() {
223        let mut ct = h(RFC8439_CIPHERTEXT);
224        ct.extend_from_slice(&h(RFC8439_TAG));
225        assert!(matches!(
226            decrypt(&key(), &nonce(), b"wrong aad", &ct),
227            Err(CryptoError::Aead)
228        ));
229    }
230
231    #[test]
232    fn hap_nonce_layout() {
233        // 8-byte label fills the counter region's low bytes; first 4 are zero.
234        assert_eq!(
235            hap_nonce(b"PS-Msg05"),
236            [0, 0, 0, 0, b'P', b'S', b'-', b'M', b's', b'g', b'0', b'5']
237        );
238        // Short label is left-zero-padded within the 8-byte region.
239        assert_eq!(
240            hap_nonce(b"abc"),
241            [0, 0, 0, 0, b'a', b'b', b'c', 0, 0, 0, 0, 0]
242        );
243        // Empty label yields an all-zero nonce.
244        assert_eq!(hap_nonce(b""), [0u8; 12]);
245    }
246}