Skip to main content

hap_crypto/
pair_setup.rs

1//! HomeKit Accessory Protocol **Pair Setup** controller state machine.
2//!
3//! Pair Setup (HAP specification chapter 5.6) is the six-message SRP-6a
4//! exchange by which a controller, knowing only the accessory's 8-digit setup
5//! code, establishes a mutually authenticated long-term pairing: it learns the
6//! accessory's Ed25519 long-term public key (LTPK) and the accessory learns the
7//! controller's. The messages are TLV8:
8//!
9//! | Msg | Direction      | Contents                                              |
10//! |-----|----------------|-------------------------------------------------------|
11//! | M1  | controller →   | `State=1`, `Method=PairSetup`                         |
12//! | M2  | → controller   | `State=2`, `Salt`, `PublicKey=B`                      |
13//! | M3  | controller →   | `State=3`, `PublicKey=A`, `Proof=M1`                  |
14//! | M4  | → controller   | `State=4`, `Proof=M2` (or `Error`)                    |
15//! | M5  | controller →   | `State=5`, `EncryptedData{ Id, LTPK, Signature }`     |
16//! | M6  | → controller   | `State=6`, `EncryptedData{ Id, LTPK, Signature }`     |
17//!
18//! The session encryption key for M5/M6 is
19//! `HKDF-SHA512(ikm = K, salt = "Pair-Setup-Encrypt-Salt",
20//! info = "Pair-Setup-Encrypt-Info", 32)`, where `K = H(S)` is the SRP session
21//! key derived from the premaster secret `S`. The controller signs
22//! `iOSDeviceX ‖ iOSPairingID ‖ iOS_LTPK` (with `iOSDeviceX` an HKDF of `K`
23//! under the controller-sign salt/info) and verifies the accessory's analogous
24//! signature in M6.
25//!
26//! The exact salt/info/nonce strings and concatenation order are cross-verified
27//! byte-for-byte against a captured `aiohomekit` Pair Setup trace (a real LIFX
28//! accessory) in this module's tests.
29//!
30//! # Usage
31//!
32//! Drive the machine by transport-agnostic message passing: send [`start`], then
33//! feed each accessory response to [`handle`] and send back whatever
34//! [`PairSetupStep::Send`] yields, until [`PairSetupStep::Done`] returns the
35//! established [`AccessoryPairing`].
36//!
37//! [`start`]: PairSetupClient::start
38//! [`handle`]: PairSetupClient::handle
39
40use hap_tlv8::{Tlv8Map, Tlv8Writer};
41use num_bigint::BigUint;
42use sha2::Sha512;
43
44use crate::aead::{decrypt, encrypt, hap_nonce};
45use crate::error::{CryptoError, Result};
46use crate::kdf::hkdf_sha512;
47use crate::keys::{verify_ed25519, ControllerKeypair};
48use crate::srp::{hap_group, SrpClient};
49use crate::tlv_types as tlv;
50
51/// The SRP-6a username HAP fixes for Pair Setup (`I` in RFC 5054 notation).
52const PAIR_SETUP_USERNAME: &[u8] = b"Pair-Setup";
53
54/// HKDF salt/info deriving the M5/M6 ChaCha20-Poly1305 session key from `K`.
55const ENCRYPT_SALT: &[u8] = b"Pair-Setup-Encrypt-Salt";
56const ENCRYPT_INFO: &[u8] = b"Pair-Setup-Encrypt-Info";
57/// HKDF salt/info deriving `iOSDeviceX`, the controller signing material.
58const CONTROLLER_SIGN_SALT: &[u8] = b"Pair-Setup-Controller-Sign-Salt";
59const CONTROLLER_SIGN_INFO: &[u8] = b"Pair-Setup-Controller-Sign-Info";
60/// HKDF salt/info deriving `AccessoryX`, the accessory signing material.
61const ACCESSORY_SIGN_SALT: &[u8] = b"Pair-Setup-Accessory-Sign-Salt";
62const ACCESSORY_SIGN_INFO: &[u8] = b"Pair-Setup-Accessory-Sign-Info";
63
64/// ChaCha20-Poly1305 nonce labels for the M5 and M6 encrypted sub-TLVs.
65const NONCE_M5: &[u8] = b"PS-Msg05";
66const NONCE_M6: &[u8] = b"PS-Msg06";
67
68/// The pairing material a successful Pair Setup yields about the accessory.
69///
70/// The controller stores this and uses it during every later Pair Verify to
71/// authenticate the accessory.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct AccessoryPairing {
74    /// The accessory's pairing identifier (`AccessoryPairingID`), a UTF-8 string
75    /// (typically a MAC-address-like value such as `AE:EC:86:C0:BF:D7`).
76    pub pairing_id: String,
77    /// The accessory's 32-byte Ed25519 long-term public key (`AccessoryLTPK`).
78    pub ltpk: [u8; 32],
79}
80
81/// The result of feeding one accessory response to [`PairSetupClient::handle`].
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum PairSetupStep {
84    /// The next controller message to transmit to the accessory (a TLV8 body).
85    Send(Vec<u8>),
86    /// Pair Setup completed; the accessory pairing was established and verified.
87    Done(AccessoryPairing),
88}
89
90/// Internal progress of the [`PairSetupClient`] exchange.
91enum State {
92    /// Before [`PairSetupClient::start`]; the M1 request has not been emitted.
93    Initial,
94    /// M1 sent; awaiting the accessory's M2 (salt + `B`).
95    AwaitingM2,
96    /// M3 sent; awaiting the accessory's M4 (`M2` proof). Holds the SRP session
97    /// key `K` and the controller proof `M1` needed to verify `M2`.
98    AwaitingM4 { session_key: Vec<u8>, m1: Vec<u8> },
99    /// M5 sent; awaiting the accessory's M6 (encrypted accessory sub-TLV). Holds
100    /// the SRP session key `K`.
101    AwaitingM6 { session_key: Vec<u8> },
102    /// The exchange finished (success or failure); no further input accepted.
103    Done,
104}
105
106/// A controller-side Pair Setup state machine over a single SRP-6a exchange.
107///
108/// Construct with [`new`](PairSetupClient::new), drive with
109/// [`start`](PairSetupClient::start) then [`handle`](PairSetupClient::handle).
110/// The machine is transport-agnostic: it consumes and produces raw TLV8 bodies.
111pub struct PairSetupClient {
112    /// The setup code as the SRP password `P` (e.g. `"123-45-678"`).
113    password: String,
114    controller: ControllerKeypair,
115    srp: SrpClient<Sha512>,
116    state: State,
117}
118
119impl PairSetupClient {
120    /// Create a Pair Setup client for `setup_code`, signing with `controller`.
121    ///
122    /// `setup_code` is the accessory's 8-digit setup code. It is accepted either
123    /// already hyphenated (`"123-45-678"`) or as bare digits (`"12345678"`); the
124    /// digits are re-grouped into the canonical `XXX-XX-XXX` form HAP hashes as
125    /// the SRP password. Any other input is used verbatim (the accessory will
126    /// then reject the proof, which surfaces as a setup-code error in M4).
127    ///
128    /// The SRP client ephemeral `a` is drawn from the OS CSPRNG; use
129    /// [`new_with_private`](PairSetupClient::new_with_private) for a
130    /// deterministic exchange in tests.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`CryptoError::SrpBadParameters`] if the freshly generated SRP
135    /// public ephemeral `A` is zero mod `N` (vanishingly unlikely).
136    pub fn new(setup_code: &str, controller: ControllerKeypair) -> Result<Self> {
137        let srp = SrpClient::<Sha512>::new(hap_group()?, PAIR_SETUP_USERNAME)?;
138        Ok(Self {
139            password: normalize_setup_code(setup_code),
140            controller,
141            srp,
142            state: State::Initial,
143        })
144    }
145
146    /// Create a Pair Setup client with a caller-supplied SRP private ephemeral
147    /// `a` (the deterministic test seam).
148    ///
149    /// Mirrors the crate-internal `srp` module's `with_private` so a replay
150    /// harness can reproduce an exchange exactly. `a` is the big-endian bytes of
151    /// the SRP exponent.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`CryptoError::SrpBadParameters`] if the resulting SRP public
156    /// ephemeral `A` is zero mod `N`.
157    pub fn new_with_private(
158        setup_code: &str,
159        controller: ControllerKeypair,
160        a: &[u8],
161    ) -> Result<Self> {
162        let srp = SrpClient::<Sha512>::with_private(
163            hap_group()?,
164            PAIR_SETUP_USERNAME,
165            BigUint::from_bytes_be(a),
166        )?;
167        Ok(Self {
168            password: normalize_setup_code(setup_code),
169            controller,
170            srp,
171            state: State::Initial,
172        })
173    }
174
175    /// Produce the M1 request that starts Pair Setup.
176    ///
177    /// The body is `State=1, Method=PairSetup`. Calling `start` advances the
178    /// machine to await M2; calling it again re-emits M1 but does not reset any
179    /// state already established by [`Self::handle`].
180    #[must_use]
181    pub fn start(&mut self) -> Vec<u8> {
182        self.state = State::AwaitingM2;
183        let mut out = Vec::new();
184        let mut w = Tlv8Writer::new(&mut out);
185        w.push_u8(tlv::STATE, tlv::STATE_M1);
186        w.push_u8(tlv::METHOD, tlv::METHOD_PAIR_SETUP);
187        out
188    }
189
190    /// Consume an accessory response and advance the exchange.
191    ///
192    /// Feed M2, then M4, then M6 in order; the return value is the next message
193    /// to send ([`PairSetupStep::Send`]) until the final
194    /// [`PairSetupStep::Done`] yields the [`AccessoryPairing`].
195    ///
196    /// # Errors
197    ///
198    /// Returns a [`CryptoError`] if the response is malformed, omits a required
199    /// field, carries an accessory `Error` TLV, fails SRP proof verification,
200    /// fails AEAD authentication, or carries an accessory signature that does
201    /// not verify. The machine then refuses further input.
202    pub fn handle(&mut self, response: &[u8]) -> Result<PairSetupStep> {
203        let map = Tlv8Map::parse(response)?;
204        check_error(&map)?;
205        match &self.state {
206            State::Initial => Err(CryptoError::Encoding("Pair Setup not started")),
207            State::AwaitingM2 => self.handle_m2(&map),
208            State::AwaitingM4 { .. } => self.handle_m4(&map),
209            State::AwaitingM6 { .. } => self.handle_m6(&map),
210            State::Done => Err(CryptoError::Encoding("Pair Setup already finished")),
211        }
212    }
213
214    /// M2 → produce M3. Consumes salt + `B`, derives `S`/`K`, builds `A`+`M1`.
215    fn handle_m2(&mut self, map: &Tlv8Map) -> Result<PairSetupStep> {
216        expect_state(map, tlv::STATE_M2)?;
217        let salt = map
218            .get(tlv::SALT)
219            .ok_or(CryptoError::Encoding("M2 missing salt"))?
220            .to_vec();
221        let b_bytes = map
222            .get(tlv::PUBLIC_KEY)
223            .ok_or(CryptoError::Encoding("M2 missing accessory public key B"))?;
224        let b_pub = BigUint::from_bytes_be(b_bytes);
225
226        let premaster = self
227            .srp
228            .premaster(&salt, self.password.as_bytes(), &b_pub)?;
229        let session_key = self.srp.session_key(&premaster);
230        let m1 = self.srp.proof_m1(&salt, &b_pub, &session_key);
231
232        let mut out = Vec::new();
233        let mut w = Tlv8Writer::new(&mut out);
234        w.push_u8(tlv::STATE, tlv::STATE_M3);
235        w.push(tlv::PUBLIC_KEY, &self.srp.a_pub_bytes());
236        w.push(tlv::PROOF, &m1);
237
238        self.state = State::AwaitingM4 { session_key, m1 };
239        Ok(PairSetupStep::Send(out))
240    }
241
242    /// M4 → produce M5. Verifies the accessory `M2` proof, then builds and seals
243    /// the controller sub-TLV `{ Identifier, PublicKey=LTPK, Signature }`.
244    fn handle_m4(&mut self, map: &Tlv8Map) -> Result<PairSetupStep> {
245        expect_state(map, tlv::STATE_M4)?;
246        let State::AwaitingM4 { session_key, m1 } = &self.state else {
247            return Err(CryptoError::Encoding("Pair Setup state corrupted"));
248        };
249        let session_key = session_key.clone();
250        let m1 = m1.clone();
251
252        let proof = map
253            .get(tlv::PROOF)
254            .ok_or(CryptoError::Encoding("M4 missing accessory proof M2"))?;
255        self.srp.verify_m2(&m1, &session_key, proof)?;
256
257        // Derive the M5/M6 encryption key and the controller signing material.
258        let mut enc_key = [0u8; 32];
259        hkdf_sha512(&session_key, ENCRYPT_SALT, ENCRYPT_INFO, &mut enc_key)?;
260        let mut ios_device_x = [0u8; 32];
261        hkdf_sha512(
262            &session_key,
263            CONTROLLER_SIGN_SALT,
264            CONTROLLER_SIGN_INFO,
265            &mut ios_device_x,
266        )?;
267
268        let id = self.controller.id.as_bytes();
269        let ltpk = self.controller.ltpk();
270
271        // sig = Ed25519(LTSK, iOSDeviceX ‖ iOSPairingID ‖ iOS_LTPK)
272        let mut signed = Vec::with_capacity(ios_device_x.len() + id.len() + ltpk.len());
273        signed.extend_from_slice(&ios_device_x);
274        signed.extend_from_slice(id);
275        signed.extend_from_slice(&ltpk);
276        let signature = self.controller.sign(&signed);
277
278        let mut sub = Vec::new();
279        let mut sw = Tlv8Writer::new(&mut sub);
280        sw.push(tlv::IDENTIFIER, id);
281        sw.push(tlv::PUBLIC_KEY, &ltpk);
282        sw.push(tlv::SIGNATURE, &signature);
283
284        let nonce = hap_nonce(NONCE_M5);
285        let sealed = encrypt(&enc_key, &nonce, b"", &sub)?;
286
287        let mut out = Vec::new();
288        let mut w = Tlv8Writer::new(&mut out);
289        w.push_u8(tlv::STATE, tlv::STATE_M5);
290        w.push(tlv::ENCRYPTED_DATA, &sealed);
291
292        self.state = State::AwaitingM6 { session_key };
293        Ok(PairSetupStep::Send(out))
294    }
295
296    /// M6 → finish. Decrypts the accessory sub-TLV, recomputes `AccessoryX`,
297    /// verifies the accessory signature, and yields the [`AccessoryPairing`].
298    fn handle_m6(&mut self, map: &Tlv8Map) -> Result<PairSetupStep> {
299        expect_state(map, tlv::STATE_M6)?;
300        let State::AwaitingM6 { session_key } = &self.state else {
301            return Err(CryptoError::Encoding("Pair Setup state corrupted"));
302        };
303        let session_key = session_key.clone();
304        self.state = State::Done;
305
306        let encrypted = map
307            .get(tlv::ENCRYPTED_DATA)
308            .ok_or(CryptoError::Encoding("M6 missing encrypted data"))?;
309
310        let mut enc_key = [0u8; 32];
311        hkdf_sha512(&session_key, ENCRYPT_SALT, ENCRYPT_INFO, &mut enc_key)?;
312        let nonce = hap_nonce(NONCE_M6);
313        let plaintext = decrypt(&enc_key, &nonce, b"", encrypted)?;
314
315        let sub = Tlv8Map::parse(&plaintext)?;
316        let id_bytes = sub
317            .get(tlv::IDENTIFIER)
318            .ok_or(CryptoError::Encoding("M6 sub-TLV missing identifier"))?;
319        let ltpk_bytes = sub
320            .get(tlv::PUBLIC_KEY)
321            .ok_or(CryptoError::Encoding("M6 sub-TLV missing public key"))?;
322        let signature = sub
323            .get(tlv::SIGNATURE)
324            .ok_or(CryptoError::Encoding("M6 sub-TLV missing signature"))?;
325
326        let ltpk: [u8; 32] = ltpk_bytes
327            .try_into()
328            .map_err(|_| CryptoError::Encoding("accessory LTPK is not 32 bytes"))?;
329        let signature: [u8; 64] = signature
330            .try_into()
331            .map_err(|_| CryptoError::Encoding("accessory signature is not 64 bytes"))?;
332        let pairing_id = String::from_utf8(id_bytes.to_vec())
333            .map_err(|_| CryptoError::Encoding("accessory pairing id is not valid UTF-8"))?;
334
335        // AccessoryX = HKDF(K, accessory-sign salt/info)
336        let mut accessory_x = [0u8; 32];
337        hkdf_sha512(
338            &session_key,
339            ACCESSORY_SIGN_SALT,
340            ACCESSORY_SIGN_INFO,
341            &mut accessory_x,
342        )?;
343
344        // Verify Ed25519(LTPK, AccessoryX ‖ AccessoryPairingID ‖ AccessoryLTPK).
345        let mut signed = Vec::with_capacity(accessory_x.len() + id_bytes.len() + ltpk.len());
346        signed.extend_from_slice(&accessory_x);
347        signed.extend_from_slice(id_bytes);
348        signed.extend_from_slice(&ltpk);
349        verify_ed25519(&ltpk, &signed, &signature)?;
350
351        Ok(PairSetupStep::Done(AccessoryPairing { pairing_id, ltpk }))
352    }
353}
354
355/// Normalise a setup code to the canonical `XXX-XX-XXX` SRP password form.
356///
357/// Exactly eight digits (with any non-digit characters stripped) are re-grouped;
358/// anything else is returned unchanged so an already-formatted or unexpected
359/// code still reaches SRP verbatim.
360fn normalize_setup_code(code: &str) -> String {
361    let digits: String = code.chars().filter(char::is_ascii_digit).collect();
362    if digits.len() == 8 {
363        format!("{}-{}-{}", &digits[0..3], &digits[3..5], &digits[5..8])
364    } else {
365        code.to_string()
366    }
367}
368
369/// Map an accessory `Error` TLV to a [`CryptoError`], if present.
370fn check_error(map: &Tlv8Map) -> Result<()> {
371    match map.get(tlv::ERROR) {
372        None | Some([]) => Ok(()),
373        // Any non-empty error code aborts the exchange. SRP-credential failures
374        // (kTLVError_Authentication = 2) are the common case (wrong setup code).
375        Some([2]) => Err(CryptoError::SrpProofMismatch),
376        Some(_) => Err(CryptoError::Encoding("accessory returned a pairing error")),
377    }
378}
379
380/// Require the response to carry the expected `State` value.
381fn expect_state(map: &Tlv8Map, expected: u8) -> Result<()> {
382    match map.get_u8(tlv::STATE)? {
383        // Some accessories omit State (a known quirk); tolerate that.
384        None => Ok(()),
385        Some(s) if s == expected => Ok(()),
386        Some(_) => Err(CryptoError::Encoding("unexpected Pair Setup state")),
387    }
388}
389
390#[cfg(test)]
391// Test code only: CLAUDE.md carves out `unwrap`/`expect` for tests with a
392// documented justification. Fixtures are fixed captured/known values, so a
393// failing `unwrap` here is itself a test failure, which is intended.
394#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
395mod tests {
396    use super::*;
397    use crate::srp::{compute_b, compute_k, compute_u, compute_v, compute_x, SrpGroup};
398    use ed25519_dalek::Signer;
399    use sha2::{Digest, Sha512};
400
401    /// Load a committed fixture from the workspace `test-vectors/` tree.
402    fn fixture(rel: &str) -> Option<Vec<u8>> {
403        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
404            .join("../../test-vectors")
405            .join(rel);
406        std::fs::read(p).ok()
407    }
408
409    fn test_controller() -> ControllerKeypair {
410        // RFC 8032 TEST 2 seed — any fixed seed gives a deterministic LTPK.
411        let seed = [
412            0x4c, 0xcd, 0x08, 0x9b, 0x28, 0xff, 0x96, 0xda, 0x9d, 0xb6, 0xc3, 0x46, 0xec, 0x11,
413            0x4e, 0x0f, 0x5b, 0x8a, 0x31, 0x9f, 0x35, 0xab, 0xa6, 0x24, 0xda, 0x8c, 0xf6, 0xed,
414            0x4f, 0xb8, 0xa6, 0xfb,
415        ];
416        ControllerKeypair::from_seed("test-controller".to_string(), seed)
417    }
418
419    // ---- M1 reproduction against the real captured trace ----
420
421    #[test]
422    fn m1_reproduces_captured_trace_byte_for_byte() {
423        let Some(expected) = fixture("pair-setup/m1.bin") else {
424            eprintln!("skipping: no test-vectors/pair-setup/m1.bin");
425            return;
426        };
427        let mut client = PairSetupClient::new("123-45-678", test_controller()).unwrap();
428        let m1 = client.start();
429        assert_eq!(
430            m1, expected,
431            "M1 must match the captured trace byte-for-byte"
432        );
433    }
434
435    // ---- M6 decrypt + accessory-signature verify against the real trace ----
436    //
437    // Uses the captured premaster S only (no ephemeral secret needed): we derive
438    // K = H(S), drive the M6 path of the machine directly, and assert the
439    // accessory signature verifies and we recover the AccessoryPairing.
440
441    /// The SRP session key `K = H(PAD(S))` from the captured premaster secret.
442    fn captured_session_key() -> Option<Vec<u8>> {
443        let s = fixture("srp/S.bin")?;
444        // S.bin is already PAD'd to len(N) = 384 bytes; K = SHA-512(S).
445        Some(Sha512::digest(&s).to_vec())
446    }
447
448    #[test]
449    fn m6_decrypts_and_verifies_accessory_signature_from_real_trace() {
450        let (Some(m6), Some(session_key)) = (fixture("pair-setup/m6.bin"), captured_session_key())
451        else {
452            eprintln!("skipping: no captured S.bin / m6.bin");
453            return;
454        };
455
456        let mut client = PairSetupClient::new("000-00-000", test_controller()).unwrap();
457        // Inject the captured session key and put the machine in the M6 state.
458        client.state = State::AwaitingM6 { session_key };
459
460        let step = client.handle(&m6).expect("M6 must decrypt and verify");
461        let PairSetupStep::Done(pairing) = step else {
462            panic!("expected Done, got {step:?}");
463        };
464        assert_eq!(pairing.pairing_id, "AE:EC:86:C0:BF:D7");
465        assert_eq!(pairing.ltpk.len(), 32);
466        assert!(!pairing.pairing_id.is_empty());
467    }
468
469    // ---- M5 decrypt against the real trace ----
470    //
471    // Decrypt the captured M5 request and verify the controller signature it
472    // carries under the embedded PublicKey, over iOSDeviceX ‖ id ‖ pubkey.
473
474    #[test]
475    fn m5_decrypts_and_controller_signature_verifies_from_real_trace() {
476        let (Some(m5), Some(session_key)) = (fixture("pair-setup/m5.bin"), captured_session_key())
477        else {
478            eprintln!("skipping: no captured S.bin / m5.bin");
479            return;
480        };
481
482        let map = Tlv8Map::parse(&m5).unwrap();
483        let encrypted = map.get(tlv::ENCRYPTED_DATA).unwrap();
484
485        let mut enc_key = [0u8; 32];
486        hkdf_sha512(&session_key, ENCRYPT_SALT, ENCRYPT_INFO, &mut enc_key).unwrap();
487        let nonce = hap_nonce(NONCE_M5);
488        let plaintext = decrypt(&enc_key, &nonce, b"", encrypted).unwrap();
489
490        let sub = Tlv8Map::parse(&plaintext).unwrap();
491        let id = sub.get(tlv::IDENTIFIER).unwrap();
492        let pubkey = sub.get(tlv::PUBLIC_KEY).unwrap();
493        let sig = sub.get(tlv::SIGNATURE).unwrap();
494        assert_eq!(pubkey.len(), 32, "controller LTPK is 32 bytes");
495        assert_eq!(sig.len(), 64, "controller signature is 64 bytes");
496
497        let mut ios_device_x = [0u8; 32];
498        hkdf_sha512(
499            &session_key,
500            CONTROLLER_SIGN_SALT,
501            CONTROLLER_SIGN_INFO,
502            &mut ios_device_x,
503        )
504        .unwrap();
505
506        let mut signed = Vec::new();
507        signed.extend_from_slice(&ios_device_x);
508        signed.extend_from_slice(id);
509        signed.extend_from_slice(pubkey);
510
511        let ltpk: [u8; 32] = pubkey.try_into().unwrap();
512        let signature: [u8; 64] = sig.try_into().unwrap();
513        verify_ed25519(&ltpk, &signed, &signature)
514            .expect("captured M5 controller signature must verify");
515    }
516
517    // ---- Self-consistency replay: full machine vs a test "accessory" ----
518
519    /// A minimal test accessory: holds the verifier, a fixed `b`, and an Ed25519
520    /// long-term keypair, and produces M2/M4/M6 the way a real accessory would.
521    struct TestAccessory {
522        group: SrpGroup,
523        pairing_id: String,
524        signing: ed25519_dalek::SigningKey,
525        salt: Vec<u8>,
526        b_priv: BigUint,
527        b_pub: BigUint,
528        session_key: Option<Vec<u8>>,
529        a_pub: Option<BigUint>,
530    }
531
532    impl TestAccessory {
533        fn new(password: &str) -> Self {
534            let group = hap_group().unwrap();
535            let salt = vec![0x11u8; 16];
536            let x = compute_x::<Sha512>(&salt, PAIR_SETUP_USERNAME, password.as_bytes());
537            let v = compute_v(&group, &x);
538            let k = compute_k::<Sha512>(&group);
539            let b_priv = BigUint::from_bytes_be(&[0x5Au8; 32]);
540            let b_pub = compute_b(&group, &k, &v, &b_priv);
541            let signing = ed25519_dalek::SigningKey::from_bytes(&[0x99u8; 32]);
542            Self {
543                group,
544                pairing_id: "11:22:33:44:55:66".to_string(),
545                signing,
546                salt,
547                b_priv,
548                b_pub,
549                session_key: None,
550                a_pub: None,
551            }
552        }
553
554        /// Respond to M3 with M2 framing (salt + B). (Sent before the controller
555        /// sends M3, but built from no controller input.)
556        fn m2(&self) -> Vec<u8> {
557            let mut out = Vec::new();
558            let mut w = Tlv8Writer::new(&mut out);
559            w.push_u8(tlv::STATE, tlv::STATE_M2);
560            w.push(tlv::SALT, &self.salt);
561            w.push(tlv::PUBLIC_KEY, &pad_be(&self.b_pub, 384));
562            out
563        }
564
565        /// Consume the controller's M3 (A + M1 proof), compute the shared secret
566        /// and session key, verify M1, and produce M4 (M2 proof).
567        fn m4(&mut self, m3: &[u8]) -> Vec<u8> {
568            let map = Tlv8Map::parse(m3).unwrap();
569            let a_bytes = map.get(tlv::PUBLIC_KEY).unwrap();
570            let a_pub = BigUint::from_bytes_be(a_bytes);
571            let m1 = map.get(tlv::PROOF).unwrap().to_vec();
572
573            let modulus = self.group.modulus();
574            // Verifier-side premaster: S = (A * v^u) ^ b mod N, with the
575            // verifier v recomputed from the (test-known) salt and password.
576            let scrambler = compute_u::<Sha512>(&self.group, &a_pub, &self.b_pub);
577            let x_priv =
578                compute_x::<Sha512>(&self.salt, PAIR_SETUP_USERNAME, TEST_PASSWORD.as_bytes());
579            let verifier = compute_v(&self.group, &x_priv);
580            let vu = verifier.modpow(&scrambler, modulus);
581            let base = (&a_pub * &vu) % modulus;
582            let premaster = base.modpow(&self.b_priv, modulus);
583            let session_key = Sha512::digest(pad_be(&premaster, 384)).to_vec();
584
585            // Verify the controller's M1 proof:
586            // M1 = H(H(N) xor H(g) | H(I) | s | A | B | K)
587            let expected_m1 = self.controller_m1(&a_pub, &session_key);
588            assert_eq!(m1, expected_m1, "test accessory: controller M1 must verify");
589
590            self.a_pub = Some(a_pub);
591            self.session_key = Some(session_key.clone());
592
593            let m2_proof = {
594                let mut h = Sha512::new();
595                h.update(pad_be(self.a_pub.as_ref().unwrap(), 384));
596                h.update(&m1);
597                h.update(&session_key);
598                h.finalize().to_vec()
599            };
600
601            let mut out = Vec::new();
602            let mut w = Tlv8Writer::new(&mut out);
603            w.push_u8(tlv::STATE, tlv::STATE_M4);
604            w.push(tlv::PROOF, &m2_proof);
605            out
606        }
607
608        fn controller_m1(&self, a_pub: &BigUint, session_key: &[u8]) -> Vec<u8> {
609            let h_n = Sha512::digest(self.group.modulus().to_bytes_be());
610            let h_g = Sha512::digest(self.group.generator().to_bytes_be());
611            let h_xor: Vec<u8> = h_n.iter().zip(h_g.iter()).map(|(a, b)| a ^ b).collect();
612            let h_i = Sha512::digest(PAIR_SETUP_USERNAME);
613            let mut h = Sha512::new();
614            h.update(h_xor);
615            h.update(h_i);
616            h.update(&self.salt);
617            h.update(pad_be(a_pub, 384));
618            h.update(pad_be(&self.b_pub, 384));
619            h.update(session_key);
620            h.finalize().to_vec()
621        }
622
623        /// Consume the controller's M5 (encrypted controller sub-TLV), then
624        /// produce M6 (encrypted accessory sub-TLV with a valid signature).
625        fn m6(&self, _m5: &[u8]) -> Vec<u8> {
626            let session_key = self.session_key.as_ref().unwrap();
627            let mut accessory_x = [0u8; 32];
628            hkdf_sha512(
629                session_key,
630                ACCESSORY_SIGN_SALT,
631                ACCESSORY_SIGN_INFO,
632                &mut accessory_x,
633            )
634            .unwrap();
635            let ltpk = self.signing.verifying_key().to_bytes();
636            let id = self.pairing_id.as_bytes();
637
638            let mut signed = Vec::new();
639            signed.extend_from_slice(&accessory_x);
640            signed.extend_from_slice(id);
641            signed.extend_from_slice(&ltpk);
642            let sig = self.signing.sign(&signed).to_bytes();
643
644            let mut sub = Vec::new();
645            let mut sw = Tlv8Writer::new(&mut sub);
646            sw.push(tlv::IDENTIFIER, id);
647            sw.push(tlv::PUBLIC_KEY, &ltpk);
648            sw.push(tlv::SIGNATURE, &sig);
649
650            let mut enc_key = [0u8; 32];
651            hkdf_sha512(session_key, ENCRYPT_SALT, ENCRYPT_INFO, &mut enc_key).unwrap();
652            let sealed = encrypt(&enc_key, &hap_nonce(NONCE_M6), b"", &sub).unwrap();
653
654            let mut out = Vec::new();
655            let mut w = Tlv8Writer::new(&mut out);
656            w.push_u8(tlv::STATE, tlv::STATE_M6);
657            w.push(tlv::ENCRYPTED_DATA, &sealed);
658            out
659        }
660    }
661
662    const TEST_PASSWORD: &str = "123-45-678";
663
664    /// Big-endian bytes left-padded to `width` (mirrors SRP `PAD`).
665    fn pad_be(v: &BigUint, width: usize) -> Vec<u8> {
666        let raw = v.to_bytes_be();
667        if raw.len() >= width {
668            return raw;
669        }
670        let mut out = vec![0u8; width - raw.len()];
671        out.extend_from_slice(&raw);
672        out
673    }
674
675    #[test]
676    fn full_machine_replay_reaches_done() {
677        let mut accessory = TestAccessory::new(TEST_PASSWORD);
678        let a = [0x37u8; 32];
679        let mut client =
680            PairSetupClient::new_with_private(TEST_PASSWORD, test_controller(), &a).unwrap();
681
682        let m1 = client.start();
683        assert_eq!(
684            Tlv8Map::parse(&m1).unwrap().get_u8(tlv::STATE).unwrap(),
685            Some(tlv::STATE_M1)
686        );
687
688        // Accessory replies with M2.
689        let m2 = accessory.m2();
690        let PairSetupStep::Send(m3) = client.handle(&m2).unwrap() else {
691            panic!("expected M3");
692        };
693
694        // Accessory verifies M3, replies with M4.
695        let m4 = accessory.m4(&m3);
696        let PairSetupStep::Send(m5) = client.handle(&m4).unwrap() else {
697            panic!("expected M5");
698        };
699
700        // Accessory replies with M6.
701        let m6 = accessory.m6(&m5);
702        let PairSetupStep::Done(pairing) = client.handle(&m6).unwrap() else {
703            panic!("expected Done");
704        };
705        assert_eq!(pairing.pairing_id, "11:22:33:44:55:66");
706        assert_eq!(pairing.ltpk, accessory.signing.verifying_key().to_bytes());
707    }
708
709    #[test]
710    fn wrong_setup_code_fails_m4_proof() {
711        let accessory = TestAccessory::new(TEST_PASSWORD);
712        let a = [0x37u8; 32];
713        // Controller uses a different code: M1 proof will not match → M2 proof
714        // computed by the accessory differs → verify_m2 rejects it in M4.
715        let mut client =
716            PairSetupClient::new_with_private("999-99-999", test_controller(), &a).unwrap();
717        let _ = client.start();
718        let m2 = accessory.m2();
719        let PairSetupStep::Send(m3) = client.handle(&m2).unwrap() else {
720            panic!("expected M3");
721        };
722        // The test accessory asserts the controller M1 internally; with a wrong
723        // code that assert would fire, so instead drive M4 verification directly:
724        // build an M4 with a deliberately wrong proof.
725        let _ = m3;
726        let mut bad_m4 = Vec::new();
727        let mut w = Tlv8Writer::new(&mut bad_m4);
728        w.push_u8(tlv::STATE, tlv::STATE_M4);
729        w.push(tlv::PROOF, &[0u8; 64]);
730        assert!(matches!(
731            client.handle(&bad_m4),
732            Err(CryptoError::SrpProofMismatch)
733        ));
734    }
735
736    #[test]
737    fn accessory_error_tlv_is_surfaced() {
738        let a = [0x37u8; 32];
739        let mut client =
740            PairSetupClient::new_with_private(TEST_PASSWORD, test_controller(), &a).unwrap();
741        let _ = client.start();
742        // M2 carrying an Authentication error instead of salt/B.
743        let mut err = Vec::new();
744        let mut w = Tlv8Writer::new(&mut err);
745        w.push_u8(tlv::STATE, tlv::STATE_M2);
746        w.push_u8(tlv::ERROR, 2); // kTLVError_Authentication
747        assert!(matches!(
748            client.handle(&err),
749            Err(CryptoError::SrpProofMismatch)
750        ));
751    }
752
753    #[test]
754    fn handle_before_start_errors() {
755        let a = [0x37u8; 32];
756        let mut client =
757            PairSetupClient::new_with_private(TEST_PASSWORD, test_controller(), &a).unwrap();
758        assert!(client.handle(b"").is_err());
759    }
760
761    #[test]
762    fn normalize_setup_code_regroups_bare_digits() {
763        assert_eq!(normalize_setup_code("12345678"), "123-45-678");
764        assert_eq!(normalize_setup_code("123-45-678"), "123-45-678");
765        assert_eq!(normalize_setup_code("oddball"), "oddball");
766    }
767}