diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index eaf7190f9..f84ad2513 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -16,7 +16,7 @@ use hbb_common::{ register_pk_response::Result::{TOO_FREQUENT, UUID_MISMATCH}, *, }, - tcp::FramedStream, + tcp::{Encrypt, FramedStream}, timeout, tokio::{ self, @@ -31,7 +31,7 @@ use hbb_common::{ AddrMangle, ResultType, }; use ipnetwork::Ipv4Network; -use sodiumoxide::crypto::sign; +use sodiumoxide::crypto::{box_, sign}; use std::{ collections::HashMap, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -51,7 +51,9 @@ const REG_TIMEOUT: i64 = 30_000; type TcpStreamSink = SplitSink, Bytes>; type WsSink = SplitSink, tungstenite::Message>; enum Sink { - TcpStream(TcpStreamSink), + // The optional `Encrypt` is installed once a client completes the key + // exchange; from then on every outgoing frame is sealed with it. + TcpStream(TcpStreamSink, Option), Ws(WsSink), } type Sender = mpsc::UnboundedSender; @@ -852,7 +854,11 @@ impl RendezvousServer { if let Some(sink) = sink.as_mut() { if let Ok(bytes) = msg.write_to_bytes() { match sink { - Sink::TcpStream(s) => { + Sink::TcpStream(s, enc) => { + let bytes = match enc { + Some(enc) => enc.enc(&bytes), + None => bytes, + }; allow_err!(s.send(Bytes::from(bytes)).await); } Sink::Ws(ws) => { @@ -1216,8 +1222,44 @@ impl RendezvousServer { } } else { let (a, mut b) = Framed::new(stream, BytesCodec::new()).split(); - sink = Some(Sink::TcpStream(a)); - while let Ok(Some(Ok(bytes))) = timeout(30_000, b.next()).await { + sink = Some(Sink::TcpStream(a, None)); + // Offer the key exchange before anything else: a client signed in + // to an API account waits for it and gives up after READ_TIMEOUT. + let mut offer = KeyExchangeOffer::new(self.inner.sk.as_ref()); + if let Some(msg) = offer.as_ref().map(|o| o.offer_msg()) { + Self::send_to_sink(&mut sink, msg).await; + } + let mut decrypt: Option = None; + while let Ok(Some(Ok(mut bytes))) = timeout(30_000, b.next()).await { + if let Some(dec) = decrypt.as_mut() { + if let Err(err) = dec.dec(&mut bytes) { + log::debug!("Failed to decrypt frame from {:?}: {}", addr, err); + break; + } + } else if let Some(o) = offer.take() { + // The answer has to be the very first frame: the client + // calls `secure_tcp` before it sends anything else, so the + // offer is consumed here whatever comes in. A frame that is + // not a key exchange means the client ignored the offer, + // and the connection stays in the clear for good. + match o.accept(&bytes) { + KeyExchangeOutcome::Secured(key) => { + decrypt = Some(Encrypt::new(key.clone())); + if let Some(Sink::TcpStream(_, enc)) = sink.as_mut() { + *enc = Some(Encrypt::new(key)); + } + log::debug!("Connection from {:?} secured", addr); + continue; + } + // Clients that are not signed in never answer the + // offer; they just send their request in the clear. + KeyExchangeOutcome::Plain => {} + KeyExchangeOutcome::Failed(err) => { + log::warn!("Key exchange with {:?} failed: {}", addr, err); + break; + } + } + } if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await { break; } @@ -1408,10 +1450,184 @@ async fn create_tcp_listener(bind_addr: Option, port: i32) -> ResultType Ok(s) } +/// Outcome of feeding the first frame of a TCP connection to a pending +/// [`KeyExchangeOffer`]. +enum KeyExchangeOutcome { + /// The client answered the offer; the connection continues encrypted. + Secured(sodiumoxide::crypto::secretbox::Key), + /// The client ignored the offer; the frame is a regular message. + Plain, + Failed(String), +} + +/// Server side of the rendezvous key exchange. +/// +/// A client that is signed in to an API account carries a token, and refuses +/// to send it over a plain connection: right after connecting it waits for the +/// server to hand over an ephemeral public key, signed with the server's +/// private key (`hbb_common::config::READ_TIMEOUT`, 18s). Without an answer it +/// fails with `Failed to secure tcp: deadline has elapsed` and the whole +/// ID-based connection never happens. +/// +/// The exchange is a single round trip: +/// +/// 1. server → client: `KeyExchange { keys: [sign(our box public key)] }` +/// 2. client → server: `KeyExchange { keys: [their box public key, +/// sealed symmetric key] }` +/// +/// Both sides then switch to that symmetric key. Clients that are not signed +/// in never answer step 2 and keep talking in the clear, so the offer stays +/// backwards compatible. +struct KeyExchangeOffer { + our_sk_b: box_::SecretKey, + signed_pk_b: Vec, +} + +impl KeyExchangeOffer { + fn new(sk: Option<&sign::SecretKey>) -> Option { + let sk = sk?; + let (our_pk_b, our_sk_b) = box_::gen_keypair(); + Some(Self { + our_sk_b, + signed_pk_b: sign::sign(&our_pk_b.0, sk), + }) + } + + fn offer_msg(&self) -> RendezvousMessage { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_key_exchange(KeyExchange { + keys: vec![Bytes::from(self.signed_pk_b.clone())], + ..Default::default() + }); + msg_out + } + + fn accept(self, bytes: &BytesMut) -> KeyExchangeOutcome { + let Ok(msg_in) = RendezvousMessage::parse_from_bytes(bytes) else { + return KeyExchangeOutcome::Plain; + }; + let Some(rendezvous_message::Union::KeyExchange(ex)) = msg_in.union else { + return KeyExchangeOutcome::Plain; + }; + if ex.keys.len() != 2 { + return KeyExchangeOutcome::Failed(format!( + "invalid key exchange message, {} keys", + ex.keys.len() + )); + } + match Encrypt::decode(&ex.keys[1], &ex.keys[0], &self.our_sk_b) { + Ok(key) => KeyExchangeOutcome::Secured(key), + Err(err) => KeyExchangeOutcome::Failed(err.to_string()), + } + } +} + #[cfg(test)] mod tests { use super::*; + // Mirrors what the client does in `secure_tcp_impl`/`create_symmetric_key_msg` + // (rustdesk/src/common.rs), so the exchange is tested against the real + // peer behaviour rather than against itself. + fn client_answer( + offer: &RendezvousMessage, + server_pk: &sign::PublicKey, + ) -> (BytesMut, sodiumoxide::crypto::secretbox::Key) { + use sodiumoxide::crypto::secretbox; + let Some(rendezvous_message::Union::KeyExchange(ex)) = offer.union.clone() else { + panic!("offer is not a key exchange"); + }; + assert_eq!(ex.keys.len(), 1, "server offers exactly one signed key"); + let their_pk_b = sign::verify(&ex.keys[0], server_pk).expect("signature must verify"); + let mut pk_ = [0u8; box_::PUBLICKEYBYTES]; + pk_.copy_from_slice(&their_pk_b); + let their_pk_b = box_::PublicKey(pk_); + let (our_pk_b, our_sk_b) = box_::gen_keypair(); + let key = secretbox::gen_key(); + let nonce = box_::Nonce([0u8; box_::NONCEBYTES]); + let sealed_key = box_::seal(&key.0, &nonce, &their_pk_b, &our_sk_b); + let mut msg_out = RendezvousMessage::new(); + msg_out.set_key_exchange(KeyExchange { + keys: vec![Bytes::from(our_pk_b.0.to_vec()), Bytes::from(sealed_key)], + ..Default::default() + }); + let bytes = msg_out.write_to_bytes().expect("serialize answer"); + (BytesMut::from(&bytes[..]), key) + } + + #[test] + fn key_exchange_agrees_on_the_same_symmetric_key() { + let (pk, sk) = sign::gen_keypair(); + let offer = KeyExchangeOffer::new(Some(&sk)).expect("offer with a server key"); + let (answer, client_key) = client_answer(&offer.offer_msg(), &pk); + match offer.accept(&answer) { + KeyExchangeOutcome::Secured(server_key) => assert_eq!(server_key, client_key), + _ => panic!("expected the connection to be secured"), + } + } + + #[test] + fn no_offer_without_a_server_key() { + assert!(KeyExchangeOffer::new(None).is_none()); + } + + #[test] + fn clients_that_ignore_the_offer_keep_talking_in_the_clear() { + let (_, sk) = sign::gen_keypair(); + let offer = KeyExchangeOffer::new(Some(&sk)).expect("offer with a server key"); + let mut msg_out = RendezvousMessage::new(); + msg_out.set_punch_hole_request(PunchHoleRequest { + id: "123456789".to_owned(), + ..Default::default() + }); + let bytes = msg_out.write_to_bytes().expect("serialize request"); + assert!(matches!( + offer.accept(&BytesMut::from(&bytes[..])), + KeyExchangeOutcome::Plain + )); + } + + #[test] + fn a_malformed_answer_is_rejected() { + let (_, sk) = sign::gen_keypair(); + let offer = KeyExchangeOffer::new(Some(&sk)).expect("offer with a server key"); + let mut msg_out = RendezvousMessage::new(); + msg_out.set_key_exchange(KeyExchange { + keys: vec![Bytes::from(vec![0u8; box_::PUBLICKEYBYTES])], + ..Default::default() + }); + let bytes = msg_out.write_to_bytes().expect("serialize answer"); + assert!(matches!( + offer.accept(&BytesMut::from(&bytes[..])), + KeyExchangeOutcome::Failed(_) + )); + } + + #[test] + fn an_answer_sealed_for_another_key_is_rejected() { + use sodiumoxide::crypto::secretbox; + let (_, sk) = sign::gen_keypair(); + let offer = KeyExchangeOffer::new(Some(&sk)).expect("offer with a server key"); + // Well formed, two keys, but sealed for an unrelated public key: this + // is the answer the server cannot open, as opposed to the one it + // rejects on sight. + let (other_pk_b, _) = box_::gen_keypair(); + let (our_pk_b, our_sk_b) = box_::gen_keypair(); + let key = secretbox::gen_key(); + let nonce = box_::Nonce([0u8; box_::NONCEBYTES]); + let sealed_key = box_::seal(&key.0, &nonce, &other_pk_b, &our_sk_b); + let mut msg_out = RendezvousMessage::new(); + msg_out.set_key_exchange(KeyExchange { + keys: vec![Bytes::from(our_pk_b.0.to_vec()), Bytes::from(sealed_key)], + ..Default::default() + }); + let bytes = msg_out.write_to_bytes().expect("serialize answer"); + assert!(matches!( + offer.accept(&BytesMut::from(&bytes[..])), + KeyExchangeOutcome::Failed(_) + )); + } + #[hbb_common::tokio::test] async fn udp_listener_uses_bind_address() { let bind_addr = IpAddr::V4(Ipv4Addr::LOCALHOST); diff --git a/tests/key_exchange.rs b/tests/key_exchange.rs new file mode 100644 index 000000000..4ab60a2ab --- /dev/null +++ b/tests/key_exchange.rs @@ -0,0 +1,154 @@ +//! End-to-end check of the rendezvous key exchange against a real hbbs. +//! +//! The client half is a transcription of what RustDesk does in +//! `secure_tcp_impl`/`create_symmetric_key_msg` (rustdesk/src/common.rs) when +//! it is signed in to an API account, so this exercises the wire protocol the +//! actual client speaks rather than the server's own idea of it. + +use hbb_common::{ + bytes::Bytes, + protobuf::Message as _, + rendezvous_proto::*, + tcp::FramedStream, + tokio, +}; +use sodiumoxide::crypto::{box_, secretbox, sign}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +// hbbs binds three ports around this one: PORT - 1 (NAT type test), PORT (the +// rendezvous listener this test speaks to) and PORT + 2 (the websocket +// listener). 41115, 41116 and 41118 are the defaults shifted by 20000, kept +// clear of anything a developer machine or a CI runner is likely to use; if the +// test cannot bind, that whole range is what has to be free. +const PORT: i32 = 41116; + +async fn secure_tcp(conn: &mut FramedStream, server_pk: &sign::PublicKey) { + let bytes = hbb_common::timeout(5_000, conn.next()) + .await + .expect("server must offer a key exchange before the read timeout") + .expect("stream stays open") + .expect("frame decodes"); + let msg_in = RendezvousMessage::parse_from_bytes(&bytes).expect("offer parses"); + let Some(rendezvous_message::Union::KeyExchange(ex)) = msg_in.union else { + panic!("first frame is not a key exchange: {msg_in:?}"); + }; + assert_eq!(ex.keys.len(), 1); + let their_pk_b = sign::verify(&ex.keys[0], server_pk).expect("offer is signed by the server"); + let mut pk_ = [0u8; box_::PUBLICKEYBYTES]; + pk_.copy_from_slice(&their_pk_b); + let their_pk_b = box_::PublicKey(pk_); + + let (our_pk_b, our_sk_b) = box_::gen_keypair(); + let key = secretbox::gen_key(); + let nonce = box_::Nonce([0u8; box_::NONCEBYTES]); + let sealed_key = box_::seal(&key.0, &nonce, &their_pk_b, &our_sk_b); + let mut msg_out = RendezvousMessage::new(); + msg_out.set_key_exchange(KeyExchange { + keys: vec![Bytes::from(our_pk_b.0.to_vec()), Bytes::from(sealed_key)], + ..Default::default() + }); + conn.send(&msg_out).await.expect("answer is sent in clear"); + conn.set_key(key); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn signed_in_client_completes_the_exchange_and_keeps_talking_encrypted() { + let dir = std::env::temp_dir().join(format!("hbbs-key-exchange-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + std::env::set_var("DB_URL", dir.join("db.sqlite3")); + std::env::set_var("TEST_HBBS", "no"); + + let (pk, sk) = sign::gen_keypair(); + let key = base64::encode(sk.0); + // `start_with_bind` is `#[tokio::main]`, i.e. it builds its own runtime and + // blocks, so it gets a thread of its own. Its error travels back over a + // channel: panicking on that thread would leave this one retrying the + // connection for five seconds and then failing with the wrong reason. + let (failed_tx, failed_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + if let Err(err) = hbbs::RendezvousServer::start_with_bind( + Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), + PORT, + 0, + &key, + 0, + ) { + let _ = failed_tx.send(err.to_string()); + } + }); + + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), PORT as u16); + let mut conn = None; + for _ in 0..50 { + if let Ok(err) = failed_rx.try_recv() { + panic!("hbbs did not start: {err}"); + } + if let Ok(c) = FramedStream::new(addr, None, 1_000).await { + conn = Some(c); + break; + } + hbb_common::sleep(0.1).await; + } + // Check the channel once more before blaming the connection: if the server + // never came up, that is the failure worth reporting. + let mut conn = match conn { + Some(conn) => conn, + None => match failed_rx.try_recv() { + Ok(err) => panic!("hbbs did not start: {err}"), + Err(_) => panic!("hbbs accepts tcp connections"), + }, + }; + + secure_tcp(&mut conn, &pk).await; + + // Everything from here on is encrypted in both directions. + let mut msg_out = RendezvousMessage::new(); + msg_out.set_punch_hole_request(PunchHoleRequest { + id: "123456789".to_owned(), + licence_key: base64::encode(pk.0), + ..Default::default() + }); + conn.send(&msg_out).await.expect("request is sent"); + + let bytes = hbb_common::timeout(5_000, conn.next()) + .await + .expect("server answers the encrypted request") + .expect("stream stays open") + .expect("frame decrypts with the negotiated key"); + let msg_in = RendezvousMessage::parse_from_bytes(&bytes).expect("response parses"); + match msg_in.union { + Some(rendezvous_message::Union::PunchHoleResponse(ph)) => { + assert_eq!(ph.failure.enum_value_or_default(), punch_hole_response::Failure::ID_NOT_EXIST); + } + other => panic!("unexpected response: {other:?}"), + } + + // A client that is not signed in never answers the offer: it just sends + // its request in the clear, and must still be served. + let mut conn = FramedStream::new(addr, None, 1_000) + .await + .expect("second connection"); + let _offer = hbb_common::timeout(5_000, conn.next()) + .await + .expect("offer arrives") + .expect("stream stays open") + .expect("frame decodes"); + conn.send(&msg_out).await.expect("plain request is sent"); + let bytes = hbb_common::timeout(5_000, conn.next()) + .await + .expect("server answers the plain request") + .expect("stream stays open") + .expect("frame decodes"); + let msg_in = RendezvousMessage::parse_from_bytes(&bytes).expect("response parses"); + match msg_in.union { + Some(rendezvous_message::Union::PunchHoleResponse(ph)) => { + assert_eq!( + ph.failure.enum_value_or_default(), + punch_hole_response::Failure::ID_NOT_EXIST + ); + } + other => panic!("unexpected response for the plain client: {other:?}"), + } + + std::fs::remove_dir_all(&dir).ok(); +}