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    /// Handle M4: accept `State=4` (surfacing an accessory error code) and emit
289    /// the derived [`SessionKeys`].
290    fn handle_m4(&mut self, response: &[u8]) -> Result<PairVerifyStep> {
291        let map = Tlv8Map::parse(response)?;
292        check_error(&map)?;
293        expect_state(&map, tlv::STATE_M4)?;
294
295        let shared = self
296            .shared_secret
297            .ok_or(CryptoError::Encoding("Pair Verify shared secret missing"))?;
298        let read_key = derive_key(&shared, CONTROL_SALT, CONTROL_READ_INFO)?;
299        let write_key = derive_key(&shared, CONTROL_SALT, CONTROL_WRITE_INFO)?;
300
301        self.state = State::Done;
302        Ok(PairVerifyStep::Done(SessionKeys {
303            read_key,
304            write_key,
305        }))
306    }
307}
308
309/// Derive a 32-byte key with HKDF-SHA512 over the X25519 `shared` secret.
310fn derive_key(shared: &[u8; 32], salt: &[u8], info: &[u8]) -> Result<[u8; 32]> {
311    let mut out = [0u8; 32];
312    hkdf_sha512(shared, salt, info, &mut out)?;
313    Ok(out)
314}
315
316/// Map an accessory `Error` TLV to a [`CryptoError`], if present.
317fn check_error(map: &Tlv8Map) -> Result<()> {
318    match map.get(tlv::ERROR) {
319        None | Some([]) => Ok(()),
320        Some(_) => Err(CryptoError::Encoding(
321            "accessory returned a Pair Verify error",
322        )),
323    }
324}
325
326/// Require the response to carry the expected `State` value.
327fn expect_state(map: &Tlv8Map, expected: u8) -> Result<()> {
328    match map.get_u8(tlv::STATE)? {
329        // Some accessories omit State (a known quirk); tolerate that.
330        None => Ok(()),
331        Some(s) if s == expected => Ok(()),
332        Some(_) => Err(CryptoError::Encoding("unexpected Pair Verify state")),
333    }
334}
335
336#[cfg(test)]
337// Test code only: CLAUDE.md carves out `unwrap`/`expect` and indexing for tests
338// with a documented justification. Fixtures are fixed captured/known values, so
339// a failing `unwrap`/index here is itself a test failure, which is intended.
340#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
341mod tests {
342    use super::*;
343    use std::fs;
344    use std::path::PathBuf;
345
346    /// Load a committed fixture from the workspace `test-vectors/pair-verify/`
347    /// tree, returning `None` when the directory/file is absent so CI without
348    /// the captured trace still passes (mirrors the M2 fixture tests).
349    fn vec_dir() -> PathBuf {
350        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
351            .join("../..")
352            .join("test-vectors/pair-verify")
353    }
354
355    fn load(name: &str) -> Option<Vec<u8>> {
356        fs::read(vec_dir().join(name)).ok()
357    }
358
359    fn load32(name: &str) -> Option<[u8; 32]> {
360        load(name).and_then(|v| v.try_into().ok())
361    }
362
363    /// A throwaway controller identity for signing M3 (the real controller LTSK
364    /// is not committed, so M3 cannot be compared byte-for-byte).
365    fn test_controller() -> ControllerKeypair {
366        ControllerKeypair::from_seed("ABCDEF01-2345-6789".to_string(), [7u8; 32])
367    }
368
369    fn accessory_from_fixtures() -> Option<AccessoryPairing> {
370        let id = String::from_utf8(load("accessory_id.txt")?)
371            .ok()?
372            .trim()
373            .to_string();
374        let ltpk = load32("accessory_ltpk.bin")?;
375        Some(AccessoryPairing {
376            pairing_id: id,
377            ltpk,
378        })
379    }
380
381    // --- Test 1: M1 reproduces the captured m1.bin byte-for-byte. ---
382    #[test]
383    fn m1_reproduces_captured() {
384        let (Some(accessory), Some(eph_priv), Some(m1)) = (
385            accessory_from_fixtures(),
386            load32("ios_eph_priv.bin"),
387            load("m1.bin"),
388        ) else {
389            eprintln!("skipping m1_reproduces_captured: fixtures absent");
390            return;
391        };
392        let mut client =
393            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
394        assert_eq!(client.start(), m1);
395    }
396
397    // --- Test 2: X25519 shared secret matches the captured value. ---
398    #[test]
399    fn x25519_matches_captured_shared_secret() {
400        let (Some(eph_priv), Some(m2), Some(shared)) = (
401            load32("ios_eph_priv.bin"),
402            load("m2.bin"),
403            load32("shared_secret.bin"),
404        ) else {
405            eprintln!("skipping x25519_matches_captured_shared_secret: fixtures absent");
406            return;
407        };
408        let map = Tlv8Map::parse(&m2).unwrap();
409        let accessory_eph: [u8; 32] = map.get(tlv::PUBLIC_KEY).unwrap().try_into().unwrap();
410        let kp = EphemeralKeypair::from_secret(eph_priv);
411        assert_eq!(kp.diffie_hellman(&accessory_eph), shared);
412        // Free-function path agrees too.
413        assert_eq!(
414            crate::x25519::x25519_shared(&eph_priv, &accessory_eph),
415            shared
416        );
417    }
418
419    // --- Test 3: session-key derivation matches the captured control keys. ---
420    #[test]
421    fn session_keys_match_captured() {
422        let (Some(shared), Some(read), Some(write)) = (
423            load32("shared_secret.bin"),
424            load32("control_read_encryption_key.bin"),
425            load32("control_write_encryption_key.bin"),
426        ) else {
427            eprintln!("skipping session_keys_match_captured: fixtures absent");
428            return;
429        };
430        assert_eq!(
431            derive_key(&shared, CONTROL_SALT, CONTROL_READ_INFO).unwrap(),
432            read
433        );
434        assert_eq!(
435            derive_key(&shared, CONTROL_SALT, CONTROL_WRITE_INFO).unwrap(),
436            write
437        );
438    }
439
440    // --- Test 4: full handle replay against the real trace. This is the
441    // high-value cross-check: it exercises HKDF + ChaCha + PV-Msg02 nonce +
442    // Ed25519 verification of the accessory signature over real bytes. ---
443    #[test]
444    fn full_replay_reaches_done_with_matching_keys() {
445        let (Some(accessory), Some(eph_priv), Some(m2), Some(m4), Some(read), Some(write)) = (
446            accessory_from_fixtures(),
447            load32("ios_eph_priv.bin"),
448            load("m2.bin"),
449            load("m4.bin"),
450            load32("control_read_encryption_key.bin"),
451            load32("control_write_encryption_key.bin"),
452        ) else {
453            eprintln!("skipping full_replay_reaches_done_with_matching_keys: fixtures absent");
454            return;
455        };
456
457        let mut client =
458            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
459        let _m1 = client.start();
460
461        // handle(M2) must DECRYPT and VERIFY the accessory signature, then emit M3.
462        let step = client.handle(&m2).unwrap();
463        let PairVerifyStep::Send(m3) = step else {
464            panic!("expected Send(m3) after M2, got {step:?}");
465        };
466        // M3 is a well-formed State=3 + EncryptedData payload (not byte-compared:
467        // the real controller LTSK is not committed).
468        let m3map = Tlv8Map::parse(&m3).unwrap();
469        assert_eq!(m3map.get_u8(tlv::STATE).unwrap(), Some(tlv::STATE_M3));
470        assert!(m3map.get(tlv::ENCRYPTED_DATA).is_some());
471
472        // handle(M4) yields the session keys matching the captured control keys.
473        let done = client.handle(&m4).unwrap();
474        let PairVerifyStep::Done(keys) = done else {
475            panic!("expected Done(SessionKeys) after M4, got {done:?}");
476        };
477        assert_eq!(keys.read_key, read);
478        assert_eq!(keys.write_key, write);
479    }
480
481    // --- Test 5 (negative): a corrupted M2 EncryptedData yields a CryptoError,
482    // not a panic. ---
483    #[test]
484    fn corrupt_m2_encrypted_data_errors() {
485        let (Some(accessory), Some(eph_priv), Some(m2)) = (
486            accessory_from_fixtures(),
487            load32("ios_eph_priv.bin"),
488            load("m2.bin"),
489        ) else {
490            eprintln!("skipping corrupt_m2_encrypted_data_errors: fixtures absent");
491            return;
492        };
493
494        // Flip a bit inside the EncryptedData item. Rebuild M2 with the tampered
495        // ciphertext so the TLV framing stays valid but the AEAD tag fails.
496        let map = Tlv8Map::parse(&m2).unwrap();
497        let accessory_eph = map.get(tlv::PUBLIC_KEY).unwrap().to_vec();
498        let mut enc = map.get(tlv::ENCRYPTED_DATA).unwrap().to_vec();
499        enc[0] ^= 0x01;
500
501        let mut tampered = Vec::new();
502        let mut w = Tlv8Writer::new(&mut tampered);
503        w.push_u8(tlv::STATE, tlv::STATE_M2);
504        w.push(tlv::PUBLIC_KEY, &accessory_eph);
505        w.push(tlv::ENCRYPTED_DATA, &enc);
506
507        let mut client =
508            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
509        let _m1 = client.start();
510        let err = client.handle(&tampered);
511        assert!(
512            matches!(err, Err(CryptoError::Aead | CryptoError::Signature)),
513            "expected Aead/Signature error, got {err:?}"
514        );
515    }
516
517    // --- Out-of-order: handle before start is rejected, not a panic. ---
518    #[test]
519    fn handle_before_start_errors() {
520        let accessory = AccessoryPairing {
521            pairing_id: "AA:BB:CC:DD:EE:FF".to_string(),
522            ltpk: [0u8; 32],
523        };
524        let mut client = PairVerifyClient::new(&test_controller(), &accessory);
525        assert!(client.handle(b"\x06\x01\x02").is_err());
526    }
527
528    // --- Accessory Error TLV in M4 is surfaced as an error. ---
529    #[test]
530    fn accessory_error_in_m4_errors() {
531        let (Some(accessory), Some(eph_priv), Some(m2)) = (
532            accessory_from_fixtures(),
533            load32("ios_eph_priv.bin"),
534            load("m2.bin"),
535        ) else {
536            eprintln!("skipping accessory_error_in_m4_errors: fixtures absent");
537            return;
538        };
539        let mut client =
540            PairVerifyClient::new_with_ephemeral(&test_controller(), &accessory, eph_priv);
541        let _m1 = client.start();
542        client.handle(&m2).unwrap();
543        // M4 with State=4 + Error=2 (authentication).
544        let mut m4err = Vec::new();
545        let mut w = Tlv8Writer::new(&mut m4err);
546        w.push_u8(tlv::STATE, tlv::STATE_M4);
547        w.push_u8(tlv::ERROR, 2);
548        assert!(client.handle(&m4err).is_err());
549    }
550}