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}