Skip to main content

runsync_transfer/codec/
crypto.rs

1//! End-to-end payload encryption, layered inside the transport's own TLS.
2//!
3//! Why a second layer at all: QUIC/TLS protects the hop. If a transfer is
4//! relayed, or the far endpoint terminates TLS somewhere you do not control,
5//! the relay sees plaintext. This layer is keyed by the two endpoints only, so
6//! a relay forwards bytes it cannot read.
7//!
8//! Nonce discipline, which is the part that has to be exactly right:
9//! every chunk is sealed under a per-file subkey with
10//! `nonce = chunk_index (8 LE) || epoch (4 LE)`. `file_id` is unique within a
11//! session and is bound into the subkey; `chunk_index` is unique within a file.
12//! `epoch` increments when a chunk is re-encoded (a retry that changed the
13//! compression decision), so the same (key, nonce) pair never covers two
14//! different plaintexts. Session keys are ephemeral, so a resumed transfer
15//! starts from fresh keys rather than replaying an old nonce space.
16
17use crate::config::{Cipher, Secrecy};
18use crate::error::{Error, Result};
19use aes_gcm::aead::inout::InOutBuf;
20use aes_gcm::aead::{AeadInOut, KeyInit};
21use aes_gcm::Aes256Gcm;
22use chacha20poly1305::ChaCha20Poly1305;
23use hkdf::Hkdf;
24use rand_core::RngCore;
25use sha2::{Digest, Sha256};
26use std::collections::HashMap;
27use zeroize::Zeroize;
28
29pub const TAG_LEN: usize = 16;
30pub const NONCE_LEN: usize = 12;
31const KEY_LEN: usize = 32;
32pub const HANDSHAKE_MSG_LEN: usize = 2 + 1 + 1 + 32 + 32 + 32;
33
34const PROTOCOL_LABEL: &[u8] = b"runsync-transfer/v1";
35
36/// Which side of the exchange we are. Decides key direction, nothing else.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Role {
39    Initiator,
40    Responder,
41}
42
43#[derive(Clone)]
44struct Key([u8; KEY_LEN]);
45
46impl Drop for Key {
47    fn drop(&mut self) {
48        self.0.zeroize();
49    }
50}
51
52/// An AEAD instance, selected once per session.
53#[derive(Clone)]
54enum Aead {
55    Aes(Box<Aes256Gcm>),
56    ChaCha(Box<ChaCha20Poly1305>),
57    /// Transport-only mode: bytes pass through untouched.
58    Passthrough,
59}
60
61impl Aead {
62    fn new(cipher: Cipher, key: &[u8; KEY_LEN]) -> Self {
63        match resolve_cipher(cipher) {
64            Cipher::Aes256Gcm => Aead::Aes(Box::new(Aes256Gcm::new(key.into()))),
65            _ => Aead::ChaCha(Box::new(ChaCha20Poly1305::new(key.into()))),
66        }
67    }
68
69    fn seal(&self, nonce: &[u8; NONCE_LEN], aad: &[u8], buf: &mut [u8]) -> Result<[u8; TAG_LEN]> {
70        let tag = match self {
71            Aead::Passthrough => return Ok([0u8; TAG_LEN]),
72            Aead::Aes(c) => c
73                .encrypt_inout_detached(nonce.into(), aad, InOutBuf::from(buf))
74                .map_err(|_| Error::Handshake("aes-gcm seal failed".into()))?,
75            Aead::ChaCha(c) => c
76                .encrypt_inout_detached(nonce.into(), aad, InOutBuf::from(buf))
77                .map_err(|_| Error::Handshake("chacha20 seal failed".into()))?,
78        };
79        let mut out = [0u8; TAG_LEN];
80        out.copy_from_slice(&tag);
81        Ok(out)
82    }
83
84    fn open(
85        &self,
86        nonce: &[u8; NONCE_LEN],
87        aad: &[u8],
88        buf: &mut [u8],
89        tag: &[u8; TAG_LEN],
90    ) -> std::result::Result<(), ()> {
91        match self {
92            Aead::Passthrough => Ok(()),
93            Aead::Aes(c) => c
94                .decrypt_inout_detached(nonce.into(), aad, InOutBuf::from(buf), tag.into())
95                .map_err(|_| ()),
96            Aead::ChaCha(c) => c
97                .decrypt_inout_detached(nonce.into(), aad, InOutBuf::from(buf), tag.into())
98                .map_err(|_| ()),
99        }
100    }
101}
102
103/// Which AEAD is actually faster in *this* binary, on *this* machine.
104///
105/// Measured rather than inferred, and the difference is not academic: whether
106/// AES-GCM gets hardware acceleration depends on the backend crate version and
107/// on build flags a library cannot control. Guessing from `target_arch` alone
108/// once had us picking AES at 208 MB/s over ChaCha20 at 530 MB/s on the same
109/// CPU. A 64 KiB timing run costs well under a millisecond and happens once per
110/// process.
111fn measured_preference() -> Cipher {
112    static PREF: std::sync::OnceLock<Cipher> = std::sync::OnceLock::new();
113    *PREF.get_or_init(|| {
114        let key = [0x42u8; KEY_LEN];
115        let nonce = [0u8; NONCE_LEN];
116        let mut buf = vec![0u8; 64 * 1024];
117
118        let bench = |a: &Aead, buf: &mut [u8]| -> u128 {
119            // Warm the code paths and any lazily-built tables first.
120            let _ = a.seal(&nonce, b"", buf);
121            let t = std::time::Instant::now();
122            for _ in 0..8 {
123                let _ = a.seal(&nonce, b"", buf);
124            }
125            t.elapsed().as_nanos().max(1)
126        };
127
128        let aes = Aead::new(Cipher::Aes256Gcm, &key);
129        let cha = Aead::new(Cipher::ChaCha20Poly1305, &key);
130        let t_aes = bench(&aes, &mut buf);
131        let t_cha = bench(&cha, &mut buf);
132
133        if t_aes <= t_cha {
134            Cipher::Aes256Gcm
135        } else {
136            Cipher::ChaCha20Poly1305
137        }
138    })
139}
140
141fn resolve_cipher(c: Cipher) -> Cipher {
142    match c {
143        Cipher::Auto => measured_preference(),
144        other => other,
145    }
146}
147
148fn cipher_id(c: Cipher) -> u8 {
149    match resolve_cipher(c) {
150        Cipher::Aes256Gcm => 1,
151        _ => 2,
152    }
153}
154
155fn cipher_from_id(id: u8) -> Result<Cipher> {
156    match id {
157        0 => Ok(Cipher::Auto),
158        1 => Ok(Cipher::Aes256Gcm),
159        2 => Ok(Cipher::ChaCha20Poly1305),
160        other => Err(Error::Handshake(format!("unknown cipher id {other}"))),
161    }
162}
163
164// ---------------------------------------------------------------------------
165// Handshake
166// ---------------------------------------------------------------------------
167
168/// One side's in-progress key exchange. Built, written to the wire, then
169/// finished with the peer's message.
170pub struct Handshake {
171    role: Role,
172    secrecy: Secrecy,
173    cipher: Cipher,
174    // `StaticSecret` rather than `EphemeralSecret`: the key is used for more
175    // than one Diffie-Hellman (ee, then es in static mode), which the
176    // consume-on-use ephemeral type does not allow. It is still generated per
177    // handshake and dropped with the `Handshake`, so it is ephemeral in fact.
178    ephemeral: x25519_dalek::StaticSecret,
179    our_msg: [u8; HANDSHAKE_MSG_LEN],
180}
181
182impl Handshake {
183    pub fn new(role: Role, secrecy: &Secrecy, cipher: Cipher) -> Self {
184        let ephemeral = x25519_dalek::StaticSecret::random_from_rng(rand_core::OsRng);
185        let eph_pub = x25519_dalek::PublicKey::from(&ephemeral);
186
187        let static_pub = match secrecy {
188            Secrecy::Static { our_secret, .. } => {
189                let s = x25519_dalek::StaticSecret::from(*our_secret);
190                x25519_dalek::PublicKey::from(&s).to_bytes()
191            }
192            _ => [0u8; 32],
193        };
194
195        let mut salt = [0u8; 32];
196        rand_core::OsRng.fill_bytes(&mut salt);
197
198        let mut msg = [0u8; HANDSHAKE_MSG_LEN];
199        msg[0..2].copy_from_slice(&crate::wire::WIRE_VERSION.to_le_bytes());
200        msg[2] = cipher_id(cipher);
201        msg[3] = match secrecy {
202            Secrecy::TransportOnly => 0,
203            Secrecy::Psk(_) => 1,
204            Secrecy::Static { .. } => 2,
205        };
206        msg[4..36].copy_from_slice(eph_pub.as_bytes());
207        msg[36..68].copy_from_slice(&static_pub);
208        msg[68..100].copy_from_slice(&salt);
209
210        Self {
211            role,
212            secrecy: secrecy.clone(),
213            cipher,
214            ephemeral,
215            our_msg: msg,
216        }
217    }
218
219    /// The bytes to send to the peer.
220    pub fn message(&self) -> &[u8; HANDSHAKE_MSG_LEN] {
221        &self.our_msg
222    }
223
224    /// Consume the peer's message and derive directional session keys.
225    pub fn finish(self, peer_msg: &[u8]) -> Result<SessionCrypto> {
226        if peer_msg.len() != HANDSHAKE_MSG_LEN {
227            return Err(Error::Handshake(format!(
228                "handshake message is {} bytes, expected {HANDSHAKE_MSG_LEN}",
229                peer_msg.len()
230            )));
231        }
232        let peer_version = u16::from_le_bytes([peer_msg[0], peer_msg[1]]);
233        if peer_version != crate::wire::WIRE_VERSION {
234            return Err(Error::Version {
235                peer: peer_version,
236                ours: crate::wire::WIRE_VERSION,
237            });
238        }
239
240        let peer_mode = peer_msg[3];
241        let our_mode = self.our_msg[3];
242        if peer_mode != our_mode {
243            // A downgrade to TransportOnly must not be something the peer can
244            // pick unilaterally.
245            return Err(Error::Handshake(format!(
246                "secrecy mode mismatch: we offered {our_mode}, peer offered {peer_mode}"
247            )));
248        }
249
250        // Both sides advertise a concrete cipher, resolved locally from `Auto`
251        // or pinned by config. Agreement wins; disagreement falls back to
252        // ChaCha20-Poly1305, which needs no hardware support and is fast
253        // everywhere. Computed identically on both ends from the two advertised
254        // bytes, so no extra round trip is needed — and a peer whose AES is a
255        // software fallback can never drag us onto it.
256        let peer_cipher = cipher_from_id(peer_msg[2])?;
257        let ours = resolve_cipher(self.cipher);
258        let negotiated = if ours == peer_cipher {
259            ours
260        } else {
261            Cipher::ChaCha20Poly1305
262        };
263
264        let mut peer_eph = [0u8; 32];
265        peer_eph.copy_from_slice(&peer_msg[4..36]);
266        let peer_eph_pub = x25519_dalek::PublicKey::from(peer_eph);
267
268        if matches!(self.secrecy, Secrecy::TransportOnly) {
269            return Ok(SessionCrypto::passthrough());
270        }
271
272        // --- key material -------------------------------------------------
273        let mut ikm: Vec<u8> = Vec::with_capacity(96);
274        let dh_ee = self.ephemeral.diffie_hellman(&peer_eph_pub);
275        if !dh_ee.was_contributory() {
276            return Err(Error::Handshake(
277                "peer sent a low-order X25519 point".into(),
278            ));
279        }
280        ikm.extend_from_slice(dh_ee.as_bytes());
281
282        if let Secrecy::Static {
283            our_secret,
284            peer_public,
285        } = &self.secrecy
286        {
287            let mut claimed = [0u8; 32];
288            claimed.copy_from_slice(&peer_msg[36..68]);
289            // Pinned identity: reject anyone else before deriving anything.
290            use subtle::ConstantTimeEq;
291            if claimed.ct_eq(peer_public).unwrap_u8() != 1 {
292                return Err(Error::Handshake(
293                    "peer static public key does not match the pinned value".into(),
294                ));
295            }
296            let our_static = x25519_dalek::StaticSecret::from(*our_secret);
297            let peer_static_pub = x25519_dalek::PublicKey::from(*peer_public);
298
299            // es and se, ordered by role so both sides build the same string.
300            let dh_es = self.ephemeral.diffie_hellman(&peer_static_pub);
301            let dh_se = our_static.diffie_hellman(&peer_eph_pub);
302            let (first, second) = match self.role {
303                Role::Initiator => (dh_es, dh_se),
304                Role::Responder => (dh_se, dh_es),
305            };
306            ikm.extend_from_slice(first.as_bytes());
307            ikm.extend_from_slice(second.as_bytes());
308        }
309
310        let salt: [u8; 32] = match &self.secrecy {
311            Secrecy::Psk(k) => *k,
312            _ => [0u8; 32],
313        };
314
315        // Bind both handshake messages into the transcript so neither side's
316        // advertised version, cipher, or salt can be tampered with in flight.
317        let (a, b) = match self.role {
318            Role::Initiator => (&self.our_msg[..], peer_msg),
319            Role::Responder => (peer_msg, &self.our_msg[..]),
320        };
321        let mut h = Sha256::new();
322        h.update(PROTOCOL_LABEL);
323        h.update(a);
324        h.update(b);
325        let transcript = h.finalize();
326
327        let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
328        ikm.zeroize();
329
330        let mut key_i2r = [0u8; KEY_LEN];
331        let mut key_r2i = [0u8; KEY_LEN];
332        expand(&hk, b"i2r", &transcript, &mut key_i2r)?;
333        expand(&hk, b"r2i", &transcript, &mut key_r2i)?;
334
335        let (send, recv) = match self.role {
336            Role::Initiator => (key_i2r, key_r2i),
337            Role::Responder => (key_r2i, key_i2r),
338        };
339
340        Ok(SessionCrypto {
341            cipher: negotiated,
342            send: Key(send),
343            recv: Key(recv),
344            passthrough: false,
345        })
346    }
347}
348
349fn expand(hk: &Hkdf<Sha256>, label: &[u8], transcript: &[u8], out: &mut [u8]) -> Result<()> {
350    let mut info = Vec::with_capacity(PROTOCOL_LABEL.len() + 1 + label.len() + transcript.len());
351    info.extend_from_slice(PROTOCOL_LABEL);
352    info.push(b'/');
353    info.extend_from_slice(label);
354    info.extend_from_slice(transcript);
355    hk.expand(&info, out)
356        .map_err(|e| Error::Handshake(format!("hkdf expand: {e}")))
357}
358
359// ---------------------------------------------------------------------------
360// Session
361// ---------------------------------------------------------------------------
362
363/// Derived session keys. Cheap to clone into per-worker sealers.
364pub struct SessionCrypto {
365    cipher: Cipher,
366    send: Key,
367    recv: Key,
368    passthrough: bool,
369}
370
371impl SessionCrypto {
372    fn passthrough() -> Self {
373        Self {
374            cipher: Cipher::ChaCha20Poly1305,
375            send: Key([0u8; KEY_LEN]),
376            recv: Key([0u8; KEY_LEN]),
377            passthrough: true,
378        }
379    }
380
381    pub fn is_passthrough(&self) -> bool {
382        self.passthrough
383    }
384
385    /// Bytes added per chunk by the AEAD layer.
386    pub fn overhead(&self) -> usize {
387        if self.passthrough {
388            0
389        } else {
390            TAG_LEN
391        }
392    }
393
394    pub fn sealer(&self) -> Sealer {
395        Sealer::new(self.cipher, &self.send, self.passthrough)
396    }
397
398    pub fn opener(&self) -> Sealer {
399        Sealer::new(self.cipher, &self.recv, self.passthrough)
400    }
401}
402
403/// Per-worker AEAD handle. Holds a small cache of per-file subkeys so a
404/// multi-file transfer does not run HKDF on every chunk.
405pub struct Sealer {
406    cipher: Cipher,
407    root: Key,
408    passthrough: bool,
409    files: HashMap<u32, Aead>,
410}
411
412impl Sealer {
413    fn new(cipher: Cipher, root: &Key, passthrough: bool) -> Self {
414        Self {
415            cipher,
416            root: root.clone(),
417            passthrough,
418            files: HashMap::new(),
419        }
420    }
421
422    fn for_file(&mut self, file_id: u32) -> &Aead {
423        // Bound the cache; a transfer of millions of tiny files would otherwise
424        // hold a cipher instance per file for the life of the worker.
425        if self.files.len() > 1024 {
426            self.files.clear();
427        }
428        let cipher = self.cipher;
429        let passthrough = self.passthrough;
430        let root = self.root.0;
431        self.files.entry(file_id).or_insert_with(|| {
432            if passthrough {
433                return Aead::Passthrough;
434            }
435            let hk = Hkdf::<Sha256>::from_prk(&root).expect("32-byte prk is valid");
436            let mut info = Vec::with_capacity(PROTOCOL_LABEL.len() + 6 + 4);
437            info.extend_from_slice(PROTOCOL_LABEL);
438            info.extend_from_slice(b"/file");
439            info.extend_from_slice(&file_id.to_le_bytes());
440            let mut sub = [0u8; KEY_LEN];
441            hk.expand(&info, &mut sub)
442                .expect("32 bytes is under the hkdf limit");
443            let a = Aead::new(cipher, &sub);
444            sub.zeroize();
445            a
446        })
447    }
448
449    /// Encrypt `buf` in place and return the authentication tag.
450    ///
451    /// `aad` must be the exact frame header bytes: the header is authenticated
452    /// but not encrypted, so a peer cannot redirect a chunk to a different
453    /// file or offset without the tag failing.
454    pub fn seal(
455        &mut self,
456        file_id: u32,
457        chunk: u64,
458        epoch: u32,
459        aad: &[u8],
460        buf: &mut [u8],
461    ) -> Result<[u8; TAG_LEN]> {
462        let nonce = nonce_for(chunk, epoch);
463        self.for_file(file_id).seal(&nonce, aad, buf)
464    }
465
466    /// Decrypt `buf` in place, verifying `tag` against `aad`.
467    pub fn open(
468        &mut self,
469        file_id: u32,
470        chunk: u64,
471        epoch: u32,
472        aad: &[u8],
473        buf: &mut [u8],
474        tag: &[u8; TAG_LEN],
475    ) -> Result<()> {
476        let nonce = nonce_for(chunk, epoch);
477        self.for_file(file_id)
478            .open(&nonce, aad, buf, tag)
479            .map_err(|_| Error::Decrypt { file_id, chunk })
480    }
481
482    pub fn is_passthrough(&self) -> bool {
483        self.passthrough
484    }
485
486    pub fn overhead(&self) -> usize {
487        if self.passthrough {
488            0
489        } else {
490            TAG_LEN
491        }
492    }
493}
494
495impl Clone for Sealer {
496    fn clone(&self) -> Self {
497        // Subkey cache is per-worker state, not shared; start each clone empty.
498        Self {
499            cipher: self.cipher,
500            root: self.root.clone(),
501            passthrough: self.passthrough,
502            files: HashMap::new(),
503        }
504    }
505}
506
507#[inline]
508fn nonce_for(chunk: u64, epoch: u32) -> [u8; NONCE_LEN] {
509    let mut n = [0u8; NONCE_LEN];
510    n[0..8].copy_from_slice(&chunk.to_le_bytes());
511    n[8..12].copy_from_slice(&epoch.to_le_bytes());
512    n
513}
514
515/// Generate a random 32-byte key, for callers that want a PSK.
516pub fn random_key() -> [u8; 32] {
517    let mut k = [0u8; 32];
518    rand_core::OsRng.fill_bytes(&mut k);
519    k
520}
521
522/// Generate an X25519 identity as `(secret, public)`.
523pub fn generate_identity() -> ([u8; 32], [u8; 32]) {
524    let secret = x25519_dalek::StaticSecret::random_from_rng(rand_core::OsRng);
525    let public = x25519_dalek::PublicKey::from(&secret);
526    (secret.to_bytes(), public.to_bytes())
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn exchange(a_sec: &Secrecy, b_sec: &Secrecy) -> Result<(SessionCrypto, SessionCrypto)> {
534        let a = Handshake::new(Role::Initiator, a_sec, Cipher::Auto);
535        let b = Handshake::new(Role::Responder, b_sec, Cipher::Auto);
536        let am = *a.message();
537        let bm = *b.message();
538        Ok((a.finish(&bm)?, b.finish(&am)?))
539    }
540
541    #[test]
542    fn psk_handshake_produces_matched_directional_keys() {
543        let psk = random_key();
544        let (a, b) = exchange(&Secrecy::Psk(psk), &Secrecy::Psk(psk)).unwrap();
545        assert!(!a.is_passthrough());
546
547        let mut sealer = a.sealer();
548        let mut opener = b.opener();
549        let aad = b"header-bytes";
550        let mut buf = b"the payload of a chunk".to_vec();
551        let orig = buf.clone();
552        let tag = sealer.seal(7, 42, 0, aad, &mut buf).unwrap();
553        assert_ne!(buf, orig, "ciphertext must differ from plaintext");
554        opener.open(7, 42, 0, aad, &mut buf, &tag).unwrap();
555        assert_eq!(buf, orig);
556    }
557
558    #[test]
559    fn wrong_psk_yields_keys_that_cannot_open() {
560        let (a, b) = exchange(&Secrecy::Psk(random_key()), &Secrecy::Psk(random_key())).unwrap();
561        let mut sealer = a.sealer();
562        let mut opener = b.opener();
563        let mut buf = b"secret".to_vec();
564        let tag = sealer.seal(1, 0, 0, b"h", &mut buf).unwrap();
565        assert!(opener.open(1, 0, 0, b"h", &mut buf, &tag).is_err());
566    }
567
568    #[test]
569    fn static_identity_pinning_rejects_an_impostor() {
570        let (a_sec, a_pub) = generate_identity();
571        let (b_sec, b_pub) = generate_identity();
572        let (impostor_sec, _) = generate_identity();
573
574        exchange(
575            &Secrecy::Static {
576                our_secret: a_sec,
577                peer_public: b_pub,
578            },
579            &Secrecy::Static {
580                our_secret: b_sec,
581                peer_public: a_pub,
582            },
583        )
584        .expect("matching pins must succeed");
585
586        let err = exchange(
587            &Secrecy::Static {
588                our_secret: a_sec,
589                peer_public: b_pub,
590            },
591            &Secrecy::Static {
592                our_secret: impostor_sec,
593                peer_public: a_pub,
594            },
595        );
596        assert!(
597            err.is_err(),
598            "an unpinned key must not complete the handshake"
599        );
600    }
601
602    #[test]
603    fn tampered_aad_fails_authentication() {
604        let psk = random_key();
605        let (a, b) = exchange(&Secrecy::Psk(psk), &Secrecy::Psk(psk)).unwrap();
606        let mut sealer = a.sealer();
607        let mut opener = b.opener();
608        let mut buf = vec![0u8; 128];
609        let tag = sealer.seal(3, 9, 0, b"file=3,chunk=9", &mut buf).unwrap();
610        // Redirecting the chunk to another offset must be detected.
611        assert!(opener
612            .open(3, 9, 0, b"file=3,chunk=8", &mut buf, &tag)
613            .is_err());
614    }
615
616    #[test]
617    fn wrong_chunk_index_fails() {
618        let psk = random_key();
619        let (a, b) = exchange(&Secrecy::Psk(psk), &Secrecy::Psk(psk)).unwrap();
620        let mut sealer = a.sealer();
621        let mut opener = b.opener();
622        let mut buf = vec![7u8; 64];
623        let tag = sealer.seal(1, 100, 0, b"h", &mut buf).unwrap();
624        assert!(opener.open(1, 101, 0, b"h", &mut buf, &tag).is_err());
625        assert!(opener.open(2, 100, 0, b"h", &mut buf, &tag).is_err());
626    }
627
628    #[test]
629    fn nonces_are_unique_across_chunk_and_epoch() {
630        let mut seen = std::collections::HashSet::new();
631        for chunk in 0..1000u64 {
632            for epoch in 0..4u32 {
633                assert!(seen.insert(nonce_for(chunk, epoch)), "nonce reuse");
634            }
635        }
636    }
637
638    #[test]
639    fn mode_mismatch_is_rejected() {
640        let r = exchange(&Secrecy::Psk(random_key()), &Secrecy::TransportOnly);
641        assert!(r.is_err(), "peer must not be able to downgrade us");
642    }
643
644    fn exchange_with(
645        a_cipher: Cipher,
646        b_cipher: Cipher,
647        psk: [u8; 32],
648    ) -> Result<(SessionCrypto, SessionCrypto)> {
649        let a = Handshake::new(Role::Initiator, &Secrecy::Psk(psk), a_cipher);
650        let b = Handshake::new(Role::Responder, &Secrecy::Psk(psk), b_cipher);
651        let (am, bm) = (*a.message(), *b.message());
652        Ok((a.finish(&bm)?, b.finish(&am)?))
653    }
654
655    /// Whatever the two sides prefer, they must end up on the *same* AEAD —
656    /// otherwise every chunk fails to open.
657    #[test]
658    fn peers_with_different_cipher_preferences_still_interoperate() {
659        let psk = random_key();
660        for (a, b) in [
661            (Cipher::Aes256Gcm, Cipher::Aes256Gcm),
662            (Cipher::ChaCha20Poly1305, Cipher::ChaCha20Poly1305),
663            (Cipher::Aes256Gcm, Cipher::ChaCha20Poly1305),
664            (Cipher::ChaCha20Poly1305, Cipher::Aes256Gcm),
665            (Cipher::Auto, Cipher::Aes256Gcm),
666            (Cipher::Auto, Cipher::ChaCha20Poly1305),
667            (Cipher::Auto, Cipher::Auto),
668        ] {
669            let (sa, sb) = exchange_with(a, b, psk).unwrap();
670            let mut sealer = sa.sealer();
671            let mut opener = sb.opener();
672            let plain = b"a chunk of payload bytes".to_vec();
673            let mut buf = plain.clone();
674            let tag = sealer.seal(5, 11, 0, b"aad", &mut buf).unwrap();
675            opener
676                .open(5, 11, 0, b"aad", &mut buf, &tag)
677                .unwrap_or_else(|e| panic!("{a:?} vs {b:?} failed to interoperate: {e}"));
678            assert_eq!(buf, plain, "{a:?} vs {b:?}");
679
680            // ...and in the other direction too.
681            let mut sealer = sb.sealer();
682            let mut opener = sa.opener();
683            let mut buf = plain.clone();
684            let tag = sealer.seal(5, 12, 0, b"aad", &mut buf).unwrap();
685            opener.open(5, 12, 0, b"aad", &mut buf, &tag).unwrap();
686            assert_eq!(buf, plain);
687        }
688    }
689
690    /// Disagreement resolves to ChaCha20-Poly1305, which needs no hardware
691    /// support, rather than to whichever side spoke first.
692    #[test]
693    fn mismatched_preferences_fall_back_to_chacha() {
694        let psk = random_key();
695        let (sa, _sb) = exchange_with(Cipher::Aes256Gcm, Cipher::ChaCha20Poly1305, psk).unwrap();
696        assert_eq!(sa.cipher, Cipher::ChaCha20Poly1305);
697        let (sa, _sb) = exchange_with(Cipher::Aes256Gcm, Cipher::Aes256Gcm, psk).unwrap();
698        assert_eq!(sa.cipher, Cipher::Aes256Gcm);
699    }
700
701    #[test]
702    fn measured_preference_is_stable_and_concrete() {
703        let a = measured_preference();
704        let b = measured_preference();
705        assert_eq!(a, b, "calibration must be cached, not re-run");
706        assert_ne!(a, Cipher::Auto, "must resolve to a concrete cipher");
707    }
708
709    #[test]
710    fn transport_only_is_passthrough() {
711        let (a, b) = exchange(&Secrecy::TransportOnly, &Secrecy::TransportOnly).unwrap();
712        assert!(a.is_passthrough() && b.is_passthrough());
713        assert_eq!(a.overhead(), 0);
714        let mut sealer = a.sealer();
715        let mut buf = b"plain".to_vec();
716        let tag = sealer.seal(0, 0, 0, b"", &mut buf).unwrap();
717        assert_eq!(buf, b"plain");
718        b.opener().open(0, 0, 0, b"", &mut buf, &tag).unwrap();
719    }
720}