From 0c540180e01afe0fff9fecba53e9c665bf210b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:00:29 -0300 Subject: [PATCH 1/3] perf(socket): seal each noise frame where it lands in the batch buffer The sender staged every frame in a scratch `Vec`, encrypted it there, then copied the framed result into the batch buffer. That is a second full pass over every byte the client sends, on top of the one the encryption itself already makes. A frame's ciphertext is exactly its plaintext plus the 16-byte GCM tag, so the length prefix is known before the bytes it counts exist. Write the prefix, copy the plaintext once into the batch buffer, and hand AES-GCM a view of just that frame's region so it seals in place and appends its tag there. The scratch buffer and one copy per frame both go away. `FrameBody` is that view: it holds the frame's start as an offset, not a slice, so the AEAD can grow the buffer by the tag through it. Every one of its operations is relative to that offset, or it would eat the frames already staged for the same write. Sealing in place is the one path that can leave partial output behind on failure, which the previous code never could, so it rolls the prefix and the plaintext back. Only a `set_crypto_provider` backend can fail there; the counter must stay unburned either way, or the next frame reuses its nonce. quinn (`PacketKey::encrypt` straight into the datagram buffer) and rustls (each fragment encrypted into the record it will send) are both built this way for the same reason. --- src/socket/noise_socket.rs | 226 +++++++++++++++++++++++++++++++++--- wacore/noise/src/framing.rs | 74 ++++++++++-- 2 files changed, 277 insertions(+), 23 deletions(-) diff --git a/src/socket/noise_socket.rs b/src/socket/noise_socket.rs index 94df69741..a8dc0f719 100644 --- a/src/socket/noise_socket.rs +++ b/src/socket/noise_socket.rs @@ -6,10 +6,51 @@ use futures::channel::oneshot; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; use wacore::handshake::{NoiseCipher, NoiseError}; +use wacore::libsignal::crypto::GcmInPlaceBuffer; use wacore::runtime::{AbortHandle, Runtime}; const INLINE_ENCRYPT_THRESHOLD: usize = 16 * 1024; +/// AES-GCM tag length. A frame's wire size is a fixed function of its plaintext +/// length, which is what lets the length prefix be written before the ciphertext +/// exists. +const TAG_LEN: usize = 16; + +/// The region of the batch buffer one frame's ciphertext occupies, exposed to +/// AES-GCM as if it were a buffer of its own. +/// +/// Sealing through this view puts the ciphertext and its tag straight where the +/// transport will read them. The alternative, sealing into scratch space and +/// copying the result in, costs a second full pass over every byte sent, which +/// is the copy comparable stacks are built to avoid: quinn seals with +/// `PacketKey::encrypt(&self, packet, buf, header_len)` directly in the datagram +/// buffer, and rustls encrypts each fragment into the record it will send. +struct FrameBody<'a> { + out: &'a mut BytesMut, + /// Offset in `out` where this frame's ciphertext starts, i.e. just past its + /// length prefix. Held as an offset rather than a slice so the AEAD can grow + /// the buffer by the tag through the same view. + base: usize, +} + +impl GcmInPlaceBuffer for FrameBody<'_> { + fn as_mut_slice(&mut self) -> &mut [u8] { + &mut self.out[self.base..] + } + + fn as_slice(&self) -> &[u8] { + &self.out[self.base..] + } + + fn resize(&mut self, new_len: usize, value: u8) { + self.out.resize(self.base + new_len, value); + } + + fn truncate(&mut self, len: usize) { + self.out.truncate(self.base + len); + } +} + /// Ceilings on one batched write. They bound how much is buffered before the /// first frame reaches the socket; the batch never waits for work, so these /// only matter when a burst is already queued. @@ -23,7 +64,6 @@ type SendResult = std::result::Result<(), EncryptSendError>; /// plus the length prefix. Used to test a queued frame against the batch ceiling /// before paying to encrypt it. fn frame_wire_len(plaintext_len: usize) -> usize { - const TAG_LEN: usize = 16; plaintext_len + TAG_LEN + wacore::framing::FRAME_LENGTH_SIZE } @@ -124,7 +164,6 @@ impl NoiseSocket { stats: Option>, ) { let mut write_counter: u32 = 0; - let mut enc_buf = Vec::with_capacity(4096); // BytesMut: split().freeze() yields a zero-copy Bytes while retaining // the underlying allocation for the next frame. let mut out_buf = BytesMut::with_capacity(4096); @@ -173,7 +212,6 @@ impl NoiseSocket { &write_key, &mut write_counter, job.plaintext, - &mut enc_buf, &mut out_buf, ) .await @@ -297,16 +335,15 @@ impl NoiseSocket { /// Encrypt one plaintext and append the framed result to `out_buf`, /// returning its wire size. The counter is burned once the framed ciphertext /// is committed to `out_buf`, whether or not the write that carries it - /// succeeds. Every error path returns before writing a byte into `out_buf`, - /// which is the only reason leaving the counter unburned there is sound: a - /// change that keeps partial output must burn the counter too, or the next - /// frame reuses its nonce. + /// succeeds. Every error path leaves `out_buf` exactly as it found it, which + /// is the only reason leaving the counter unburned there is sound: a change + /// that keeps partial output must burn the counter too, or the next frame + /// reuses its nonce. async fn encrypt_frame_into( runtime: &Arc, write_key: &Arc, write_counter: &mut u32, plaintext: bytes::Bytes, - enc_buf: &mut Vec, out_buf: &mut BytesMut, ) -> std::result::Result { let counter = *write_counter; @@ -320,14 +357,28 @@ impl NoiseSocket { let before = out_buf.len(); if plaintext.len() <= INLINE_ENCRYPT_THRESHOLD { - enc_buf.clear(); - enc_buf.extend_from_slice(&plaintext); - if let Err(e) = write_key.encrypt_in_place_with_counter(counter, enc_buf) { - return Err(EncryptSendError::crypto(e)); - } - if let Err(e) = wacore::framing::append_frame_into(enc_buf, None, out_buf) { + // Ciphertext is exactly the plaintext plus the tag, so the length + // prefix is known before the bytes it counts exist and the frame can + // be sealed where it already sits in the batch. + let body_len = plaintext.len() + TAG_LEN; + if let Err(e) = wacore::framing::append_frame_header_into(body_len, None, out_buf) { return Err(EncryptSendError::framing(e)); } + let base = out_buf.len(); + out_buf.extend_from_slice(&plaintext); + if let Err(e) = write_key + .encrypt_in_place_with_counter(counter, &mut FrameBody { out: out_buf, base }) + { + // Unlike the paths above, this one has already appended the + // prefix and the plaintext. Rolling both back is what keeps the + // rest of the batch, which still has to go out, contiguous, and + // what keeps this counter safe to hand to the next frame. The + // default AEAD cannot fail on a fixed-size key and nonce, so + // only a `set_crypto_provider` backend reaches this: it is the + // contract for those, not dead code. + out_buf.truncate(before); + return Err(EncryptSendError::crypto(e)); + } } else { let write_key = write_key.clone(); // `Bytes` is Send + 'static: move it into the blocking task (a @@ -655,7 +706,6 @@ mod tests { let write_key = Arc::new(NoiseCipher::new(&key).expect("32-byte key")); let mut write_counter: u32 = 0; - let mut enc_buf = Vec::new(); let mut out_buf = BytesMut::new(); for expected_counter in 0..2u32 { @@ -665,7 +715,6 @@ mod tests { &write_key, &mut write_counter, bytes::Bytes::from(vec![expected_counter as u8; 32]), - &mut enc_buf, &mut out_buf, ) .await @@ -687,6 +736,151 @@ mod tests { } } + /// The frame is sealed straight into the batch buffer, so the offset it is + /// sealed at is load-bearing in a way a staging copy never was: too low and + /// AES-GCM overwrites the length prefix or the frame before it, too high and + /// the plaintext leaks past the ciphertext. Pinned by encrypting a second + /// frame behind a first and checking the first is untouched, the header + /// counts exactly the ciphertext, and the body decrypts under its counter. + #[tokio::test] + async fn a_frame_is_sealed_in_place_behind_the_one_before_it() { + let key = [0x21u8; 32]; + let runtime: Arc = Arc::new(crate::runtime_impl::TokioRuntime); + let write_key = Arc::new(NoiseCipher::new(&key).expect("32-byte key")); + let mut write_counter: u32 = 0; + let mut out_buf = BytesMut::new(); + + let first = bytes::Bytes::from(vec![0xA1u8; 40]); + NoiseSocket::encrypt_frame_into( + &runtime, + &write_key, + &mut write_counter, + first, + &mut out_buf, + ) + .await + .expect("first frame"); + let first_frame = out_buf.to_vec(); + + let second_plain = vec![0xB2u8; 77]; + let wire_len = NoiseSocket::encrypt_frame_into( + &runtime, + &write_key, + &mut write_counter, + bytes::Bytes::from(second_plain.clone()), + &mut out_buf, + ) + .await + .expect("second frame"); + + assert_eq!( + &out_buf[..first_frame.len()], + &first_frame[..], + "sealing the second frame must not reach back into the first" + ); + assert_eq!(wire_len, frame_wire_len(second_plain.len())); + assert_eq!(out_buf.len(), first_frame.len() + wire_len); + + let second = &out_buf[first_frame.len()..]; + let declared = + ((second[0] as usize) << 16) | ((second[1] as usize) << 8) | second[2] as usize; + assert_eq!( + declared, + second_plain.len() + TAG_LEN, + "the header must count the ciphertext that was sealed after it" + ); + + let read_key = NoiseCipher::new(&key).expect("32-byte key"); + let mut body = BytesMut::from(&second[FRAME_LENGTH_SIZE..]); + read_key + .decrypt_in_place_with_counter(1, &mut body) + .expect("the sealed body must authenticate under its own counter"); + assert_eq!(&body[..], &second_plain[..]); + } + + /// The AEAD grows and shrinks the buffer through this view, so every one of + /// its operations has to be relative to the frame's own start. An absolute + /// `resize` or `truncate` here would silently eat the frames already staged + /// for the same write. + #[test] + fn the_frame_body_view_never_reaches_before_its_own_frame() { + let mut out = BytesMut::from(&b"earlier-frame"[..]); + let base = out.len(); + out.extend_from_slice(b"body"); + + let mut view = FrameBody { + out: &mut out, + base, + }; + assert_eq!(view.as_slice(), b"body"); + assert_eq!(view.len(), 4); + view.as_mut_slice()[0] = b'B'; + + // Growing by a tag-sized amount, the way sealing does. + view.resize(4 + TAG_LEN, 0); + assert_eq!(view.len(), 4 + TAG_LEN); + view.truncate(4); + assert_eq!(view.as_slice(), b"Body"); + + assert_eq!( + &out[..base], + &b"earlier-frame"[..], + "no view operation may touch the bytes staged before this frame" + ); + } + + /// A frame that cannot be encrypted must leave the batch buffer byte for byte + /// as it found it: the frames already in it still have to reach the wire, and + /// the counter it declined to burn is handed to whoever comes next. Counter + /// exhaustion is the failure that is reachable without swapping the process + /// wide crypto provider. + #[tokio::test] + async fn a_failed_frame_leaves_the_batch_buffer_byte_identical() { + let key = [0x22u8; 32]; + let runtime: Arc = Arc::new(crate::runtime_impl::TokioRuntime); + let write_key = Arc::new(NoiseCipher::new(&key).expect("32-byte key")); + let mut out_buf = BytesMut::new(); + + // One frame already staged, then the counter runs out mid-batch. + let mut write_counter: u32 = u32::MAX - 1; + NoiseSocket::encrypt_frame_into( + &runtime, + &write_key, + &mut write_counter, + bytes::Bytes::from(vec![0xC3u8; 24]), + &mut out_buf, + ) + .await + .expect("the last usable counter must still encrypt"); + let staged = out_buf.to_vec(); + assert_eq!(write_counter, u32::MAX); + + let err = NoiseSocket::encrypt_frame_into( + &runtime, + &write_key, + &mut write_counter, + bytes::Bytes::from(vec![0xD4u8; 24]), + &mut out_buf, + ) + .await + .expect_err("an exhausted counter must not wrap"); + assert!(matches!(err.kind, EncryptSendErrorKind::Crypto)); + assert_eq!( + out_buf.to_vec(), + staged, + "the rejected frame must not leave a header or a plaintext behind" + ); + assert_eq!(write_counter, u32::MAX, "a rejected frame burns no counter"); + + // The staged frame is intact and complete, not just the right length. + let read_key = NoiseCipher::new(&key).expect("32-byte key"); + let mut body = BytesMut::from(&staged[FRAME_LENGTH_SIZE..]); + read_key + .decrypt_in_place_with_counter(u32::MAX - 1, &mut body) + .expect("the frame staged before the failure must still be sendable"); + assert_eq!(&body[..], &[0xC3u8; 24][..]); + } + /// Order must survive a full job channel, not just an empty one. /// /// A burst larger than the channel leaves some sends parked waiting for a diff --git a/wacore/noise/src/framing.rs b/wacore/noise/src/framing.rs index 8693f1a5d..34709e837 100644 --- a/wacore/noise/src/framing.rs +++ b/wacore/noise/src/framing.rs @@ -55,16 +55,21 @@ pub fn encode_frame_into( append_frame_into(payload, header, out) } -/// Like [`encode_frame_into`], but appends to `out` instead of clearing it, so -/// several frames can be laid out back to back in one buffer and handed to the -/// transport as a single write. -pub fn append_frame_into( - payload: &[u8], +/// Appends a frame's prefix for a payload of `payload_len` and reserves room for +/// the payload itself, without needing the payload's bytes. +/// +/// Exists for producers that write the payload straight into `out` afterwards +/// (encrypting in place where the bytes will already be on the wire) instead of +/// building it elsewhere and copying it in. The length prefix is derivable ahead +/// of the payload because AEAD ciphertext length is a fixed function of the +/// plaintext length. +/// +/// On error nothing is written, so `out` is left exactly as it was. +pub fn append_frame_header_into( + payload_len: usize, header: Option<&[u8]>, out: &mut impl FrameBuf, ) -> Result<(), anyhow::Error> { - let payload_len = payload.len(); - if payload_len >= FRAME_MAX_SIZE { return Err(anyhow::anyhow!( "Frame is too large (max: {}, got: {})", @@ -85,6 +90,19 @@ pub fn append_frame_into( let len_bytes = u32::to_be_bytes(payload_len as u32); out.extend_from_slice(&len_bytes[1..]); + + Ok(()) +} + +/// Like [`encode_frame_into`], but appends to `out` instead of clearing it, so +/// several frames can be laid out back to back in one buffer and handed to the +/// transport as a single write. +pub fn append_frame_into( + payload: &[u8], + header: Option<&[u8]>, + out: &mut impl FrameBuf, +) -> Result<(), anyhow::Error> { + append_frame_header_into(payload.len(), header, out)?; out.extend_from_slice(payload); Ok(()) @@ -321,6 +339,48 @@ mod tests { assert!(decoder.decode_frame().is_none()); } + /// The header-only form has to lay down exactly what `append_frame_into` + /// lays down before the payload, or a caller that writes its payload in + /// place afterwards produces a frame the peer cannot split. Asserted + /// against the literal big-endian bytes, not against `append_frame_into`: + /// that one delegates here, so comparing the two would agree with itself + /// no matter what either wrote. + #[test] + fn append_frame_header_writes_the_length_big_endian() { + let mut out = vec![0x5Au8]; + append_frame_header_into(0x012345, None, &mut out).expect("header only"); + assert_eq!( + out, + vec![0x5A, 0x01, 0x23, 0x45], + "the header must append 3 big-endian length bytes after what was staged" + ); + + // A length that only fills the low byte must still occupy all three. + let mut short = Vec::new(); + append_frame_header_into(5, None, &mut short).expect("header only"); + assert_eq!(short, vec![0x00, 0x00, 0x05]); + assert_eq!(short.len(), FRAME_LENGTH_SIZE); + } + + /// Reserving for the payload is the header call's job: the caller writes the + /// payload straight into `out` and must not have to grow it again. + #[test] + fn append_frame_header_reserves_room_for_the_payload() { + let mut out = Vec::new(); + append_frame_header_into(1000, None, &mut out).expect("header only"); + assert!(out.capacity() >= FRAME_LENGTH_SIZE + 1000); + } + + /// An oversize frame is rejected before a single byte is written, so a + /// buffer that is already staging other frames survives the rejection. + #[test] + fn append_frame_header_rejects_oversize_without_writing() { + let mut out = vec![0xEE; 7]; + let err = append_frame_header_into(FRAME_MAX_SIZE, None, &mut out); + assert!(err.is_err()); + assert_eq!(out, vec![0xEE; 7]); + } + #[test] fn test_encode_frame_too_large() { let large_payload = vec![0u8; FRAME_MAX_SIZE]; From 1e5a19a845bbf0ab51dab44a944fcc2fe5a99f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:08:52 -0300 Subject: [PATCH 2/3] fix(socket): refuse a frame whose sealed size disagrees with its prefix The length prefix is now written from plaintext.len() + TAG_LEN before the ciphertext exists, which holds only because TransportAead is AES-256-GCM by contract. A set_crypto_provider backend that grew the buffer by anything else would put a frame on the wire whose prefix disagrees with its body and desync the peer's parser for the rest of the connection. One comparison turns that into a refused send. --- src/socket/noise_socket.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/socket/noise_socket.rs b/src/socket/noise_socket.rs index a8dc0f719..01f8be271 100644 --- a/src/socket/noise_socket.rs +++ b/src/socket/noise_socket.rs @@ -379,6 +379,19 @@ impl NoiseSocket { out_buf.truncate(before); return Err(EncryptSendError::crypto(e)); } + // The length prefix was written from `plaintext.len() + TAG_LEN` + // before the ciphertext existed, which is sound only because + // `TransportAead` is AES-256-GCM by contract. A `set_crypto_provider` + // backend that grows the buffer by anything else would put a frame + // on the wire whose prefix disagrees with its body, desyncing the + // peer's parser for the rest of the connection. Checking costs one + // comparison and turns that into a refused send. + if out_buf.len() - base != body_len { + out_buf.truncate(before); + return Err(EncryptSendError::crypto(NoiseError::Encrypt( + wacore::libsignal::crypto::CryptoProviderError::BackendFailed, + ))); + } } else { let write_key = write_key.clone(); // `Bytes` is Send + 'static: move it into the blocking task (a From 71792d1dd70fc93abd3b80c5d16d151014332414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:20:43 -0300 Subject: [PATCH 3/3] test(socket): cover the ciphertext-size guard with a broken provider The guard was the one path with no coverage, on the grounds that the crypto provider is a process-wide OnceLock and a deliberately broken backend would poison the unit-test binary. An integration test is its own binary, so it can install one safely. The fake provider delegates everything to the real one except transport_aead, which grows the buffer by 15 bytes instead of 16 and reports success: exactly the shape that would put a frame on the wire whose prefix disagrees with its body. Removing the guard makes the test fail. --- tests/noise_frame_size_contract.rs | 151 +++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/noise_frame_size_contract.rs diff --git a/tests/noise_frame_size_contract.rs b/tests/noise_frame_size_contract.rs new file mode 100644 index 000000000..90d75a548 --- /dev/null +++ b/tests/noise_frame_size_contract.rs @@ -0,0 +1,151 @@ +//! The sender writes a frame's length prefix from `plaintext.len() + 16` before +//! the ciphertext exists, which holds only because `TransportAead` is +//! AES-256-GCM by contract. This pins what happens when an installed provider +//! breaks that contract: the frame must be refused, never written. +//! +//! Its own integration binary because `set_crypto_provider` writes a +//! process-wide `OnceLock`. Installing a deliberately broken backend inside the +//! unit-test binary would poison every other test in it. + +use std::sync::Arc; +use std::sync::Mutex; + +use wacore::libsignal::crypto::{ + CryptoProviderError, GcmInPlaceBuffer, RustCryptoProvider, SignalCryptoProvider, TransportAead, + set_crypto_provider, +}; + +/// Grows the buffer by one byte less than the GCM tag, so the frame body ends +/// up shorter than the prefix already written for it. +struct ShortTagAead; + +impl TransportAead for ShortTagAead { + fn encrypt_in_place( + &self, + _nonce: &[u8; 12], + _aad: &[u8], + buffer: &mut dyn GcmInPlaceBuffer, + ) -> Result<(), CryptoProviderError> { + // 15 bytes instead of 16: reports success, leaves the wire inconsistent. + buffer.resize(buffer.as_slice().len() + 15, 0); + Ok(()) + } + + fn decrypt_in_place( + &self, + _nonce: &[u8; 12], + _aad: &[u8], + _buffer: &mut dyn GcmInPlaceBuffer, + ) -> Result<(), CryptoProviderError> { + Ok(()) + } +} + +/// Delegates everything to the real provider except the transport AEAD, so only +/// the contract under test is broken. +struct ShortTagProvider(RustCryptoProvider); + +impl SignalCryptoProvider for ShortTagProvider { + fn aes_256_cbc_encrypt( + &self, + key: &[u8; 32], + iv: &[u8; 16], + plaintext: &[u8], + out: &mut Vec, + ) -> Result<(), CryptoProviderError> { + self.0.aes_256_cbc_encrypt(key, iv, plaintext, out) + } + + fn aes_256_cbc_decrypt( + &self, + key: &[u8; 32], + iv: &[u8; 16], + ciphertext: &[u8], + out: &mut Vec, + ) -> Result<(), CryptoProviderError> { + self.0.aes_256_cbc_decrypt(key, iv, ciphertext, out) + } + + fn aes_256_gcm_encrypt( + &self, + key: &[u8; 32], + nonce: &[u8; 12], + aad: &[u8], + plaintext: &[u8], + out: &mut Vec, + ) -> Result<(), CryptoProviderError> { + self.0.aes_256_gcm_encrypt(key, nonce, aad, plaintext, out) + } + + fn aes_256_gcm_decrypt( + &self, + key: &[u8; 32], + nonce: &[u8; 12], + aad: &[u8], + ciphertext_with_tag: &[u8], + out: &mut Vec, + ) -> Result<(), CryptoProviderError> { + self.0 + .aes_256_gcm_decrypt(key, nonce, aad, ciphertext_with_tag, out) + } + + fn hmac_sha256(&self, key: &[u8], input: &[u8]) -> [u8; 32] { + self.0.hmac_sha256(key, input) + } + + fn transport_aead( + &'static self, + _key: &[u8; 32], + ) -> Result, CryptoProviderError> { + Ok(Box::new(ShortTagAead)) + } +} + +/// Records writes so the test can assert nothing reached the wire. +struct RecordingTransport { + writes: Mutex>, +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl whatsapp_rust::transport::Transport for RecordingTransport { + async fn send(&self, data: bytes::Bytes) -> Result<(), anyhow::Error> { + self.writes.lock().expect("writes mutex").push(data); + Ok(()) + } + async fn disconnect(&self) {} +} + +#[tokio::test] +async fn a_provider_that_breaks_the_tag_size_contract_cannot_write_a_frame() { + set_crypto_provider(ShortTagProvider(RustCryptoProvider)) + .expect("this binary installs the provider exactly once"); + + let key = [0x11u8; 32]; + let transport = Arc::new(RecordingTransport { + writes: Mutex::new(Vec::new()), + }); + let socket = whatsapp_rust::socket::NoiseSocket::new( + Arc::new(whatsapp_rust::runtime_impl::TokioRuntime), + transport.clone(), + wacore::handshake::NoiseCipher::new(&key).expect("32-byte key"), + wacore::handshake::NoiseCipher::new(&key).expect("32-byte key"), + ); + + let err = socket + .encrypt_and_send(bytes::Bytes::from(vec![7u8; 64])) + .await + .expect_err("a frame whose sealed size disagrees with its prefix must be refused"); + + assert!( + matches!( + err.kind, + whatsapp_rust::socket::error::EncryptSendErrorKind::Crypto + ), + "the mismatch is a crypto-layer fault, got {err:?}" + ); + assert!( + transport.writes.lock().expect("writes mutex").is_empty(), + "a frame with a prefix that disagrees with its body would desync the peer's parser, so it must never be written" + ); +}