1use 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#[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#[derive(Clone)]
54enum Aead {
55 Aes(Box<Aes256Gcm>),
56 ChaCha(Box<ChaCha20Poly1305>),
57 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
103fn 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 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
164pub struct Handshake {
171 role: Role,
172 secrecy: Secrecy,
173 cipher: Cipher,
174 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 pub fn message(&self) -> &[u8; HANDSHAKE_MSG_LEN] {
221 &self.our_msg
222 }
223
224 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 return Err(Error::Handshake(format!(
246 "secrecy mode mismatch: we offered {our_mode}, peer offered {peer_mode}"
247 )));
248 }
249
250 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 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 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 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 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
359pub 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 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
403pub 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 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 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 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 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
515pub fn random_key() -> [u8; 32] {
517 let mut k = [0u8; 32];
518 rand_core::OsRng.fill_bytes(&mut k);
519 k
520}
521
522pub 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 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 #[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 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 #[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}