Skip to main content

hap_crypto/
pair_verify.rs

1//! HomeKit **Pair Verify** (M3) — establish a fresh session from an existing
2//! pairing.
3//!
4//! Once Pair Setup ([`crate::pair_setup`]) has stored an accessory's pairing
5//! identifier and Ed25519 long-term public key (an [`AccessoryPairing`]), every
6//! subsequent connection runs Pair Verify to establish a fresh, mutually
7//! authenticated session. The exchange is a four-message TLV8 flow over an
8//! ephemeral X25519 Diffie-Hellman key exchange plus Ed25519 signatures:
9//!
10//! | Step | Direction | Contents |
11//! | ---- | --------- | -------- |
12//! | M1   | controller → accessory | `State=1`, `PublicKey` = controller ephemeral X25519 public key |
13//! | M2   | accessory → controller | `State=2`, `PublicKey` = accessory ephemeral X25519 public key, `EncryptedData` over `{ Identifier, Signature }` |
14//! | M3   | controller → accessory | `State=3`, `EncryptedData` over `{ Identifier, Signature }` |
15//! | M4   | accessory → controller | `State=4` on success, or `State=4` + `Error` |
16//!
17//! From the X25519 shared secret three keys are derived with HKDF-SHA512:
18//!
19//! - the **Pair-Verify encryption key** (decrypts M2, encrypts M3) — salt
20//!   `"Pair-Verify-Encrypt-Salt"`, info `"Pair-Verify-Encrypt-Info"`;
21//! - the **read key** (accessory→controller session traffic) — salt
22//!   `"Control-Salt"`, info `"Control-Read-Encryption-Key"`;
23//! - the **write key** (controller→accessory session traffic) — salt
24//!   `"Control-Salt"`, info `"Control-Write-Encryption-Key"`.
25//!
26//! The accessory's M2 signature, verified against the stored
27//! [`AccessoryPairing::ltpk`], is what authenticates the accessory; a mismatch
28//! (or any malformed/forged accessory input) is a [`CryptoError`], never a
29//! panic. On success [`PairVerifyClient::handle`] yields [`SessionKeys`], which
30//! the transport record layer (M4) uses to encrypt and decrypt session traffic.
31//!
32//! Every byte produced and consumed here is cross-verified against a real
33//! captured Pair Verify trace (see the integration tests).
34
35use ed25519_dalek::{Signer, SigningKey};
36use hap_tlv8::{Tlv8Map, Tlv8Writer};
37
38use crate::aead::{decrypt, encrypt, hap_nonce};
39use crate::error::{CryptoError, Result};
40use crate::kdf::hkdf_sha512;
41use crate::keys::{verify_ed25519, ControllerKeypair};
42use crate::pair_setup::AccessoryPairing;
43use crate::tlv_types as tlv;
44use crate::x25519::EphemeralKeypair;
45
46// HKDF salt/info constants (HAP, chapter 5 "Pair Verify").
47/// HKDF salt for the Pair-Verify encryption key (decrypts M2, encrypts M3).
48const PV_ENCRYPT_SALT: &[u8] = b"Pair-Verify-Encrypt-Salt";
49/// HKDF info for the Pair-Verify encryption key.
50const PV_ENCRYPT_INFO: &[u8] = b"Pair-Verify-Encrypt-Info";
51/// HKDF salt shared by both directional session keys.
52const CONTROL_SALT: &[u8] = b"Control-Salt";
53/// HKDF info for the accessory→controller (read) session key.
54const CONTROL_READ_INFO: &[u8] = b"Control-Read-Encryption-Key";
55/// HKDF info for the controller→accessory (write) session key.
56const CONTROL_WRITE_INFO: &[u8] = b"Control-Write-Encryption-Key";
57
58/// ChaCha20-Poly1305 nonce label for the encrypted M2 sub-TLV.
59const NONCE_M2: &[u8] = b"PV-Msg02";
60/// ChaCha20-Poly1305 nonce label for the encrypted M3 sub-TLV.
61const NONCE_M3: &[u8] = b"PV-Msg03";
62
63/// The two directional session keys produced by a successful Pair Verify.
64///
65/// The transport record layer encrypts controller→accessory traffic with
66/// [`write_key`](SessionKeys::write_key) and decrypts accessory→controller
67/// traffic with [`read_key`](SessionKeys::read_key).
68#[derive(Clone, PartialEq, Eq)]
69pub struct SessionKeys {
70    /// Accessory→controller key (`Control-Read-Encryption-Key`).
71    pub read_key: [u8; 32],
72    /// Controller→accessory key (`Control-Write-Encryption-Key`).
73    pub write_key: [u8; 32],
74}
75
76// Avoid leaking key material through the default derived `Debug`.
77impl core::fmt::Debug for SessionKeys {
78    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
79        f.debug_struct("SessionKeys").finish_non_exhaustive()
80    }
81}
82
83/// The result of feeding one accessory response to [`PairVerifyClient::handle`].
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum PairVerifyStep {
86    /// The next controller message (M3) to transmit to the accessory.
87    Send(Vec<u8>),
88    /// Pair Verify completed; the [`SessionKeys`] are ready for the record layer.
89    Done(SessionKeys),
90}
91
92/// Internal progress of the [`PairVerifyClient`] exchange.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum State {
95    /// Created; [`PairVerifyClient::start`] not yet called.
96    Init,
97    /// M1 sent; awaiting M2.
98    AwaitM2,
99    /// M3 sent; awaiting M4.
100    AwaitM4,
101    /// Finished (success or terminal error); no further input accepted.
102    Done,
103}
104
105/// Drives the controller side of HomeKit Pair Verify (M1–M4).
106///
107/// Construct with [`new`](PairVerifyClient::new), call
108/// [`start`](PairVerifyClient::start) to obtain the M1 payload, then feed each
109/// accessory response to [`handle`](PairVerifyClient::handle) and transmit the
110/// [`PairVerifyStep::Send`] payload it yields, until
111/// [`PairVerifyStep::Done`] returns the [`SessionKeys`].
112pub struct PairVerifyClient {
113    controller_id: String,
114    signing: SigningKey,
115    accessory: AccessoryPairing,
116    ephemeral: EphemeralKeypair,
117    shared_secret: Option<[u8; 32]>,
118    state: State,
119}
120
121impl PairVerifyClient {
122    /// Create a client that verifies against `accessory` using `controller`'s
123    /// long-term identity. A fresh random ephemeral X25519 keypair is generated.
124    #[must_use]
125    pub fn new(controller: &ControllerKeypair, accessory: &AccessoryPairing) -> Self {
126        Self::build(controller, accessory, EphemeralKeypair::generate())
127    }
128
129    /// Test/replay constructor that injects a fixed ephemeral X25519 secret so a
130    /// captured trace can be reproduced deterministically.
131    ///
132    /// Production code calls [`PairVerifyClient::new`], which generates a fresh
133    /// random ephemeral keypair. Mirrors `PairSetupClient::new_with_private`.
134    #[must_use]
135    pub fn new_with_ephemeral(
136        controller: &ControllerKeypair,
137        accessory: &AccessoryPairing,
138        ephemeral_secret: [u8; 32],
139    ) -> Self {
140        Self::build(
141            controller,
142            accessory,
143            EphemeralKeypair::from_secret(ephemeral_secret),
144        )
145    }
146
147    fn build(
148        controller: &ControllerKeypair,
149        accessory: &AccessoryPairing,
150        ephemeral: EphemeralKeypair,
151    ) -> Self {
152        Self {
153            controller_id: controller.id.clone(),
154            signing: controller.signing_key(),
155            accessory: accessory.clone(),
156            ephemeral,
157            shared_secret: None,
158            state: State::Init,
159        }
160    }
161
162    /// Produce the M1 payload (`State=1`, `PublicKey`) and advance the state
163    /// machine to await M2.
164    pub fn start(&mut self) -> Vec<u8> {
165        let mut out = Vec::new();
166        let mut w = Tlv8Writer::new(&mut out);
167        w.push_u8(tlv::STATE, tlv::STATE_M1);
168        w.push(tlv::PUBLIC_KEY, &self.ephemeral.public());
169        self.state = State::AwaitM2;
170        out
171    }
172
173    /// Feed the accessory's next response. Returns [`PairVerifyStep::Send`] with
174    /// the M3 payload after consuming M2, then [`PairVerifyStep::Done`] with the
175    /// [`SessionKeys`] after consuming M4.
176    ///
177    /// # Errors
178    ///
179    /// Returns a [`CryptoError`] if `handle` is called before
180    /// [`start`](PairVerifyClient::start) or after completion, if the accessory
181    /// response is malformed or carries an error code, if M2 decryption or the
182    /// accessory's Ed25519 signature fails to verify, or if the accessory's
183    /// identifier does not match the stored pairing.
184    pub fn handle(&mut self, response: &[u8]) -> Result<PairVerifyStep> {
185        match self.state {
186            State::Init => Err(CryptoError::Encoding(
187                "Pair Verify handle called before start",
188            )),
189            State::AwaitM2 => self.handle_m2(response),
190            State::AwaitM4 => self.handle_m4(response),
191            State::Done => Err(CryptoError::Encoding(
192                "Pair Verify handle called after completion",
193            )),
194        }
195    }
196
197    /// Handle M2: parse the accessory ephemeral key, derive the shared secret and
198    /// the Pair-Verify encryption key, decrypt the sub-TLV, verify the accessory
199    /// signature against the stored LTPK, and build M3.
200    fn handle_m2(&mut self, response: &[u8]) -> Result<PairVerifyStep> {
201        let map = Tlv8Map::parse(response)?;
202        check_error(&map)?;
203        expect_state(&map, tlv::STATE_M2)?;
204
205        let accessory_eph_pub: [u8; 32] = map
206            .get(tlv::PUBLIC_KEY)
207            .ok_or(CryptoError::Encoding("M2 missing accessory ephemeral key"))?
208            .try_into()
209            .map_err(|_| CryptoError::Encoding("M2 accessory ephemeral key not 32 bytes"))?;
210        let encrypted = map
211            .get(tlv::ENCRYPTED_DATA)
212            .ok_or(CryptoError::Encoding("M2 missing encrypted data"))?;
213
214        let controller_eph_pub = self.ephemeral.public();
215        let shared = self.ephemeral.diffie_hellman(&accessory_eph_pub);
216
217        let pv_key = derive_key(&shared, PV_ENCRYPT_SALT, PV_ENCRYPT_INFO)?;
218        let nonce = hap_nonce(NONCE_M2);
219        let plaintext = decrypt(&pv_key, &nonce, b"", encrypted)?;
220
221        // Decrypted sub-TLV: { Identifier, Signature }.
222        let sub = Tlv8Map::parse(&plaintext)?;
223        let identifier = sub
224            .get(tlv::IDENTIFIER)
225            .ok_or(CryptoError::Encoding("M2 sub-TLV missing identifier"))?;
226        let signature: [u8; 64] = sub
227            .get(tlv::SIGNATURE)
228            .ok_or(CryptoError::Encoding("M2 sub-TLV missing signature"))?
229            .try_into()
230            .map_err(|_| CryptoError::Encoding("M2 signature not 64 bytes"))?;
231
232        // The accessory id in the sub-TLV must match the stored pairing.
233        if identifier != self.accessory.pairing_id.as_bytes() {
234            return Err(CryptoError::Encoding(
235                "M2 accessory identifier does not match stored pairing",
236            ));
237        }
238
239        // Verify Ed25519(AccessoryLTPK,
240        //   accessoryEph ‖ AccessoryPairingID ‖ controllerEph).
241        let mut signed = Vec::with_capacity(32 + identifier.len() + 32);
242        signed.extend_from_slice(&accessory_eph_pub);
243        signed.extend_from_slice(identifier);
244        signed.extend_from_slice(&controller_eph_pub);
245        verify_ed25519(&self.accessory.ltpk, &signed, &signature)?;
246
247        // Build M3: encrypt { Identifier=controller_id, Signature } and frame.
248        let m3 = self.build_m3(&pv_key, &controller_eph_pub, &accessory_eph_pub)?;
249
250        self.shared_secret = Some(shared);
251        self.state = State::AwaitM4;
252        Ok(PairVerifyStep::Send(m3))
253    }
254
255    /// Build the controller's M3 payload: sign
256    /// `controllerEph ‖ ControllerID ‖ accessoryEph`, wrap it in the
257    /// `{ Identifier, Signature }` sub-TLV, encrypt under `pv_key` with the M3
258    /// nonce, and frame as `State=3`, `EncryptedData`.
259    fn build_m3(
260        &self,
261        pv_key: &[u8; 32],
262        controller_eph_pub: &[u8; 32],
263        accessory_eph_pub: &[u8; 32],
264    ) -> Result<Vec<u8>> {
265        let id = self.controller_id.as_bytes();
266
267        let mut signed = Vec::with_capacity(32 + id.len() + 32);
268        signed.extend_from_slice(controller_eph_pub);
269        signed.extend_from_slice(id);
270        signed.extend_from_slice(accessory_eph_pub);
271        let signature: [u8; 64] = self.signing.sign(&signed).to_bytes();
272
273        let mut sub = Vec::new();
274        let mut sw = Tlv8Writer::new(&mut sub);
275        sw.push(tlv::IDENTIFIER, id);
276        sw.push(tlv::SIGNATURE, &signature);
277
278        let nonce = hap_nonce(NONCE_M3);
279        let sealed = encrypt(pv_key, &nonce, b"", &sub)?;
280
281        let mut out = Vec::new();
282        let mut w = Tlv8Writer::new(&mut out);
283        w.push_u8(tlv::STATE, tlv::STATE_M3);
284        w.push(tlv::ENCRYPTED_DATA, &sealed);
285        Ok(out)
286    }
287
288    /// Derive the HAP-BLE broadcast-notification key after Pair Verify completes:
289    /// HKDF-SHA512 over the Pair-Verify shared secret (ikm), salted with the
290    /// controller's long-term public key (LTPK), info `"Broadcast-Encryption-Key"`.
291    /// Call after [`PairVerifyStep::Done`].
292    ///
293    /// # Errors
294    /// [`CryptoError`] if called before the shared secret is established (i.e.
295    /// before Pair Verify reached M2), or on HKDF failure.
296    pub fn broadcast_key(&self, controller_ltpk: &[u8]) -> Result<crate::BroadcastKey> {
297        let shared = self
298            .shared_secret
299            .ok_or(CryptoError::Encoding("Pair Verify shared secret missing"))?;
300        crate::BroadcastKey::derive(&shared, controller_ltpk)
301    }
302
303    /// Handle M4: accept `State=4` (surfacing an accessory error code) and emit
304    /// the derived [`SessionKeys`].
305    fn handle_m4(&mut self, response: &[u8]) -> Result<PairVerifyStep> {
306        let map = Tlv8Map::parse(response)?;
307        check_error(&map)?;
308        expect_state(&map, tlv::STATE_M4)?;
309
310        let shared = self
311            .shared_secret
312            .ok_or(CryptoError::Encoding("Pair Verify shared secret missing"))?;
313        let read_key = derive_key(&shared, CONTROL_SALT, CONTROL_READ_INFO)?;
314        let write_key = derive_key(&shared, CONTROL_SALT, CONTROL_WRITE_INFO)?;
315
316        self.state = State::Done;
317        Ok(PairVerifyStep::Done(SessionKeys {
318            read_key,
319            write_key,
320        }))
321    }
322}
323
324/// Derive a 32-byte key with HKDF-SHA512 over the X25519 `shared` secret.
325fn derive_key(shared: &[u8; 32], salt: &[u8], info: &[u8]) -> Result<[u8; 32]> {
326    let mut out = [0u8; 32];
327    hkdf_sha512(shared, salt, info, &mut out)?;
328    Ok(out)
329}
330
331/// Map an accessory `Error` TLV to a [`CryptoError`], if present.
332fn check_error(map: &Tlv8Map) -> Result<()> {
333    match map.get(tlv::ERROR) {
334        None | Some([]) => Ok(()),
335        Some(_) => Err(CryptoError::Encoding(
336            "accessory returned a Pair Verify error",
337        )),
338    }
339}
340
341/// Require the response to carry the expected `State` value.
342fn expect_state(map: &Tlv8Map, expected: u8) -> Result<()> {
343    match map.get_u8(tlv::STATE)? {
344        // Some accessories omit State (a known quirk); tolerate that.
345        None => Ok(()),
346        Some(s) if s == expected => Ok(()),
347        Some(_) => Err(CryptoError::Encoding("unexpected Pair Verify state")),
348    }
349}
350
351#[cfg(test)]
352// Test code only: CLAUDE.md carves out `unwrap`/`expect` and indexing for tests
353// with a documented justification. Fixtures are fixed captured/known values, so
354// a failing `unwrap`/index here is itself a test failure, which is intended.
355#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
356mod tests {
357    use super::*;
358    use std::fs;
359    use std::path::PathBuf;
360
361    /// Load a committed fixture from the workspace `test-vectors/pair-verify/`
362    /// tree, returning `None` when the directory/file is absent so CI without
363    /// the captured trace still passes (mirrors the M2 fixture tests).
364    fn vec_dir() -> PathBuf {
365        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
366            .join("../..")
367            .join("test-vectors/pair-verify")
368    }
369
370    fn load(name: &str) -> Option<Vec<u8>> {
371        fs::read(vec_dir().join(name)).ok()
372    }
373
374    fn load32(name: &str) -> Option<[u8; 32]> {
375        load(name).and_then(|v| v.try_into().ok())
376    }
377
378    /// A throwaway controller identity for signing M3 (the real controller LTSK
379    /// is not committed, so M3 cannot be compared byte-for-byte).
380    fn test_controller() -> ControllerKeypair {
381        ControllerKeypair::from_seed("ABCDEF01-2345-6789".to_string(), [7u8; 32])
382    }
383
384    fn accessory_from_fixtures() -> Option<AccessoryPairing> {
385        let id = String::from_utf8(load("accessory_id.txt")?)
386            .ok()?
387            .trim()
388            .to_string();
389        let ltpk = load32("accessory_ltpk.bin")?;
390        Some(AccessoryPairing {
391            pairing_id: id,
392            ltpk,
393        })
394    }
395
396    // --- Test 1: M1 reproduces the captured m1.bin byte-for-byte. ---
397    #[test]
398    fn m1_reproduces_captured() {
399        let (Some(accessory), Some(eph_priv), Some(m1)) = (
400            accessory_from_fixtures(),
401            load32("ios_eph_priv.bin"),
402            load("m1.bin"),
403        ) else {
404            eprintln!("skipping m1_reproduces_captured: fixtures absent");
405            return;
406        };
407        let mut client =
408            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
409        assert_eq!(client.start(), m1);
410    }
411
412    // --- Test 2: X25519 shared secret matches the captured value. ---
413    #[test]
414    fn x25519_matches_captured_shared_secret() {
415        let (Some(eph_priv), Some(m2), Some(shared)) = (
416            load32("ios_eph_priv.bin"),
417            load("m2.bin"),
418            load32("shared_secret.bin"),
419        ) else {
420            eprintln!("skipping x25519_matches_captured_shared_secret: fixtures absent");
421            return;
422        };
423        let map = Tlv8Map::parse(&m2).unwrap();
424        let accessory_eph: [u8; 32] = map.get(tlv::PUBLIC_KEY).unwrap().try_into().unwrap();
425        let kp = EphemeralKeypair::from_secret(eph_priv);
426        assert_eq!(kp.diffie_hellman(&accessory_eph), shared);
427        // Free-function path agrees too.
428        assert_eq!(
429            crate::x25519::x25519_shared(&eph_priv, &accessory_eph),
430            shared
431        );
432    }
433
434    // --- Test 3: session-key derivation matches the captured control keys. ---
435    #[test]
436    fn session_keys_match_captured() {
437        let (Some(shared), Some(read), Some(write)) = (
438            load32("shared_secret.bin"),
439            load32("control_read_encryption_key.bin"),
440            load32("control_write_encryption_key.bin"),
441        ) else {
442            eprintln!("skipping session_keys_match_captured: fixtures absent");
443            return;
444        };
445        assert_eq!(
446            derive_key(&shared, CONTROL_SALT, CONTROL_READ_INFO).unwrap(),
447            read
448        );
449        assert_eq!(
450            derive_key(&shared, CONTROL_SALT, CONTROL_WRITE_INFO).unwrap(),
451            write
452        );
453    }
454
455    // --- Test 4: full handle replay against the real trace. This is the
456    // high-value cross-check: it exercises HKDF + ChaCha + PV-Msg02 nonce +
457    // Ed25519 verification of the accessory signature over real bytes. ---
458    #[test]
459    fn full_replay_reaches_done_with_matching_keys() {
460        let (Some(accessory), Some(eph_priv), Some(m2), Some(m4), Some(read), Some(write)) = (
461            accessory_from_fixtures(),
462            load32("ios_eph_priv.bin"),
463            load("m2.bin"),
464            load("m4.bin"),
465            load32("control_read_encryption_key.bin"),
466            load32("control_write_encryption_key.bin"),
467        ) else {
468            eprintln!("skipping full_replay_reaches_done_with_matching_keys: fixtures absent");
469            return;
470        };
471
472        let mut client =
473            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
474        let _m1 = client.start();
475
476        // handle(M2) must DECRYPT and VERIFY the accessory signature, then emit M3.
477        let step = client.handle(&m2).unwrap();
478        let PairVerifyStep::Send(m3) = step else {
479            panic!("expected Send(m3) after M2, got {step:?}");
480        };
481        // M3 is a well-formed State=3 + EncryptedData payload (not byte-compared:
482        // the real controller LTSK is not committed).
483        let m3map = Tlv8Map::parse(&m3).unwrap();
484        assert_eq!(m3map.get_u8(tlv::STATE).unwrap(), Some(tlv::STATE_M3));
485        assert!(m3map.get(tlv::ENCRYPTED_DATA).is_some());
486
487        // handle(M4) yields the session keys matching the captured control keys.
488        let done = client.handle(&m4).unwrap();
489        let PairVerifyStep::Done(keys) = done else {
490            panic!("expected Done(SessionKeys) after M4, got {done:?}");
491        };
492        assert_eq!(keys.read_key, read);
493        assert_eq!(keys.write_key, write);
494    }
495
496    // --- Test 5 (negative): a corrupted M2 EncryptedData yields a CryptoError,
497    // not a panic. ---
498    #[test]
499    fn corrupt_m2_encrypted_data_errors() {
500        let (Some(accessory), Some(eph_priv), Some(m2)) = (
501            accessory_from_fixtures(),
502            load32("ios_eph_priv.bin"),
503            load("m2.bin"),
504        ) else {
505            eprintln!("skipping corrupt_m2_encrypted_data_errors: fixtures absent");
506            return;
507        };
508
509        // Flip a bit inside the EncryptedData item. Rebuild M2 with the tampered
510        // ciphertext so the TLV framing stays valid but the AEAD tag fails.
511        let map = Tlv8Map::parse(&m2).unwrap();
512        let accessory_eph = map.get(tlv::PUBLIC_KEY).unwrap().to_vec();
513        let mut enc = map.get(tlv::ENCRYPTED_DATA).unwrap().to_vec();
514        enc[0] ^= 0x01;
515
516        let mut tampered = Vec::new();
517        let mut w = Tlv8Writer::new(&mut tampered);
518        w.push_u8(tlv::STATE, tlv::STATE_M2);
519        w.push(tlv::PUBLIC_KEY, &accessory_eph);
520        w.push(tlv::ENCRYPTED_DATA, &enc);
521
522        let mut client =
523            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
524        let _m1 = client.start();
525        let err = client.handle(&tampered);
526        assert!(
527            matches!(err, Err(CryptoError::Aead | CryptoError::Signature)),
528            "expected Aead/Signature error, got {err:?}"
529        );
530    }
531
532    // --- Out-of-order: handle before start is rejected, not a panic. ---
533    #[test]
534    fn handle_before_start_errors() {
535        let accessory = AccessoryPairing {
536            pairing_id: "AA:BB:CC:DD:EE:FF".to_string(),
537            ltpk: [0u8; 32],
538        };
539        let mut client = PairVerifyClient::new(&test_controller(), &accessory);
540        assert!(client.handle(b"\x06\x01\x02").is_err());
541    }
542
543    // --- Accessory Error TLV in M4 is surfaced as an error. ---
544    #[test]
545    fn accessory_error_in_m4_errors() {
546        let (Some(accessory), Some(eph_priv), Some(m2)) = (
547            accessory_from_fixtures(),
548            load32("ios_eph_priv.bin"),
549            load("m2.bin"),
550        ) else {
551            eprintln!("skipping accessory_error_in_m4_errors: fixtures absent");
552            return;
553        };
554        let mut client =
555            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
556        let _m1 = client.start();
557        client.handle(&m2).unwrap();
558        // M4 with State=4 + Error=2 (authentication).
559        let mut m4err = Vec::new();
560        let mut w = Tlv8Writer::new(&mut m4err);
561        w.push_u8(tlv::STATE, tlv::STATE_M4);
562        w.push_u8(tlv::ERROR, 2);
563        assert!(client.handle(&m4err).is_err());
564    }
565
566    // --- Test 6: broadcast_key returns Ok after a completed Pair Verify, and
567    // the bytes match a direct BroadcastKey::derive call with the same inputs. ---
568    #[test]
569    fn broadcast_key_matches_direct_derive_after_done() {
570        let (Some(accessory), Some(eph_priv), Some(m2), Some(m4)) = (
571            accessory_from_fixtures(),
572            load32("ios_eph_priv.bin"),
573            load("m2.bin"),
574            load("m4.bin"),
575        ) else {
576            eprintln!("skipping broadcast_key_matches_direct_derive_after_done: fixtures absent");
577            return;
578        };
579
580        let controller = test_controller();
581        let mut client = PairVerifyClient::new_with_ephemeral(&controller, &accessory, eph_priv);
582        let _m1 = client.start();
583        client.handle(&m2).unwrap();
584        client.handle(&m4).unwrap();
585
586        // Use a fixed controller LTPK as salt (real value does not matter for
587        // the glue test; what matters is that both paths produce the same bytes).
588        let fake_ltpk = [0xABu8; 32];
589        let bk = client.broadcast_key(&fake_ltpk).unwrap();
590
591        // The method must be exactly equivalent to the free-function path.
592        // Capture the shared secret via the same ephemeral to verify round-trip.
593        let shared = {
594            let map = hap_tlv8::Tlv8Map::parse(&m2).unwrap();
595            let accessory_eph: [u8; 32] = map
596                .get(crate::tlv_types::PUBLIC_KEY)
597                .unwrap()
598                .try_into()
599                .unwrap();
600            crate::x25519::EphemeralKeypair::from_secret(eph_priv).diffie_hellman(&accessory_eph)
601        };
602        let direct = crate::BroadcastKey::derive(&shared, &fake_ltpk).unwrap();
603        assert_eq!(bk.as_bytes(), direct.as_bytes());
604    }
605
606    // --- Test 7 (negative): broadcast_key before M2 (no shared secret) errors. ---
607    #[test]
608    fn broadcast_key_before_m2_errors() {
609        let accessory = AccessoryPairing {
610            pairing_id: "AA:BB:CC:DD:EE:FF".to_string(),
611            ltpk: [0u8; 32],
612        };
613        let mut client = PairVerifyClient::new(&test_controller(), &accessory);
614        let _m1 = client.start();
615        // shared_secret is still None at this point.
616        assert!(client.broadcast_key(&[0u8; 32]).is_err());
617    }
618}