From e272fede36fafa63e6f6b8c71b918267a7ea4195 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 14 Sep 2026 22:22:44 +0800 Subject: [PATCH] tcp: key exchange version 1, one stream key per direction Version 1 of the key exchange splits the exchanged key into a subkey per direction, the way Noise splits its cipher states after the handshake, and leaves the nonce layout and the handshake messages as they are. Each subkey is a keyed BLAKE2b over the handshake transcript: both ephemeral public keys, the version the responder advertised and the one the initiator picked, so a change to any of it in transit leaves the two sides with different keys. The original scheme is version 0, which is also what an absent field means. Negotiated through new fields old peers do not see: the side that speaks first advertises the newest version it has (`KeyExchange.version` from the server, `IdPk.kx_version` inside the signed identity from the controlled side), the other side answers with the highest both have, and the pair runs that. Any old peer on either end keeps today's stream byte for byte. The controlled side's advertisement sits inside the signature; the server's is repeated in `RendezvousMessage.kx_advertised` on its first message after the exchange, inside the encrypted channel, and a client that was shown a different value in the clear refuses the stream. `Encrypt::new` is unchanged; `new_split`, `set_key_split` and `check_kx_advertised` are added beside it, and `box_pk_of` for a responder that kept only the secret half of its ephemeral pair. The tests pin the version 0 output as well as the split and the echo. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns --- protos/rendezvous.proto | 11 ++ src/stream.rs | 32 +++++ src/tcp.rs | 269 +++++++++++++++++++++++++++++++++++++++- src/websocket.rs | 20 ++- 4 files changed, 327 insertions(+), 5 deletions(-) diff --git a/protos/rendezvous.proto b/protos/rendezvous.proto index f87882385..e8c7d6c2a 100644 --- a/protos/rendezvous.proto +++ b/protos/rendezvous.proto @@ -8,6 +8,9 @@ message IdPk { // a WebRTC peer's DTLS channel can be bound to its verified identity (defeats a rendezvous/relay // that swaps SDP fingerprints). Empty for non-WebRTC handshakes. string dtls_fingerprint = 3; + // Newest key exchange version the signer speaks; absent, i.e. 0, is the original scheme. + // Inside the signed identity so a rendezvous or relay cannot lower it. + uint32 kx_version = 4; } message RegisterPeer { @@ -232,6 +235,9 @@ message OnlineResponse { message KeyExchange { repeated bytes keys = 1; + // From the server: the newest key exchange version it speaks. From the client: the version it + // picked, the highest both speak. Absent, i.e. 0, is the original scheme. + uint32 version = 2; } message HealthCheck { @@ -291,4 +297,9 @@ message RendezvousMessage { HttpProxyResponse http_proxy_response = 28; IceCandidate ice_candidate = 29; } + // From the server, on its first message after a key exchange: the version it advertised, + // repeated inside the encrypted channel where nothing in transit can alter it. A client that + // was shown a different value in the clear knows the handshake was altered and refuses the + // stream. Absent from servers that predate versions. + uint32 kx_advertised = 30; } diff --git a/src/stream.rs b/src/stream.rs index e651be554..8702828da 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -80,6 +80,38 @@ impl Stream { } } + /// Per-direction keys bound to the handshake; see `tcp::KX_VERSION_LATEST`. `is_initiator` + /// is the side that sent the sealed key. + #[inline] + pub fn set_key_split( + &mut self, + key: Key, + is_initiator: bool, + t: &tcp::KxTranscript, + ) -> ResultType<()> { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(s) => { + s.set_key(key); + Ok(()) + } + Stream::WebSocket(s) => s.set_key_split(key, is_initiator, t), + Stream::Tcp(s) => s.set_key_split(key, is_initiator, t), + } + } + + /// Refuse the stream if the server's first encrypted message says it advertised a key + /// exchange version other than `seen`, the one this side received in the clear. + #[inline] + pub fn check_kx_advertised(&mut self, seen: u32) { + match self { + #[cfg(feature = "webrtc")] + Stream::WebRTC(_) => {} + Stream::WebSocket(s) => s.check_kx_advertised(seen), + Stream::Tcp(s) => s.check_kx_advertised(seen), + } + } + /// An opaque token that changes whenever bytes have arrived from the peer, a fragment of a /// message that is still incomplete included. Compare successive samples; the value itself /// means nothing. `None` where the transport reports no such thing. diff --git a/src/tcp.rs b/src/tcp.rs index 2296edb1d..6673327ac 100644 --- a/src/tcp.rs +++ b/src/tcp.rs @@ -4,7 +4,7 @@ use bytes::{BufMut, Bytes, BytesMut}; use futures::{SinkExt, StreamExt}; use protobuf::Message; use sodiumoxide::crypto::{ - box_, + box_, generichash, scalarmult, secretbox::{self, Key, Nonce}, }; use std::{ @@ -24,8 +24,47 @@ use tokio_util::codec::Framed; pub trait TcpStreamTrait: AsyncRead + AsyncWrite + Unpin {} pub struct DynTcpStream(pub Box); +/// The newest key exchange version this build speaks. Version 0 is the original scheme, and +/// what an absent field means: one key for both directions, each direction counting its own +/// nonce from 1, so the n-th message each way is sealed under the same (key, nonce). Version 1 +/// splits the exchanged key into one per direction, the way Noise splits its cipher states +/// after the handshake, binds the handshake transcript into both, and keeps the nonce layout. +pub const KX_VERSION_LATEST: u32 = 1; + +/// The version to run against a peer that advertised `advertised`: the highest both sides +/// support. +#[inline] +pub fn kx_version_for(advertised: u32) -> u32 { + advertised.min(KX_VERSION_LATEST) +} + +const KX_SPLIT_CONTEXT: &[u8] = b"rdkx-spl"; +const KX_SPLIT_INITIATOR: u8 = 1; +const KX_SPLIT_RESPONDER: u8 = 2; + +/// What both sides saw during the key exchange, each from its own side of the wire. Mixed into +/// the split keys, so a change to any of it in transit leaves the two sides with different keys +/// and the first message undecryptable, without a check per field. +pub struct KxTranscript<'a> { + /// The ephemeral public key of the side that sent the sealed key. + pub initiator_pk: &'a [u8], + /// The ephemeral public key the sealed key was sealed to. + pub responder_pk: &'a [u8], + /// The version the responder advertised: what it sent, what the initiator received. + pub advertised: u32, + /// The version the initiator picked. + pub picked: u32, +} + +/// The public half of an X25519 secret key, for a responder that kept only the secret half. +pub fn box_pk_of(sk: &box_::SecretKey) -> box_::PublicKey { + box_::PublicKey(scalarmult::scalarmult_base(&scalarmult::Scalar(sk.0)).0) +} + +/// The sending key, the send and receive counters, the receiving key where it differs, and +/// the server's advertised version as this side saw it, while its echo is still awaited. #[derive(Clone)] -pub struct Encrypt(pub Key, pub u64, pub u64); +pub struct Encrypt(pub Key, pub u64, pub u64, Option, Option); pub struct FramedStream( pub Framed, @@ -200,6 +239,22 @@ impl FramedStream { self.2 = Some(Encrypt::new(key)); } + pub fn set_key_split( + &mut self, + key: Key, + is_initiator: bool, + t: &KxTranscript, + ) -> ResultType<()> { + self.2 = Some(Encrypt::new_split(key, is_initiator, t)?); + Ok(()) + } + + pub fn check_kx_advertised(&mut self, seen: u32) { + if let Some(enc) = self.2.as_mut() { + enc.check_kx_advertised(seen); + } + } + fn get_nonce(seqnum: u64) -> Nonce { let mut nonce = Nonce([0u8; secretbox::NONCEBYTES]); nonce.0[..std::mem::size_of_val(&seqnum)].copy_from_slice(&seqnum.to_le_bytes()); @@ -295,7 +350,69 @@ impl TcpStreamTrait for R {} impl Encrypt { pub fn new(key: Key) -> Self { - Self(key, 0, 0) + Self(key, 0, 0, None, None) + } + + /// Arm the check of the server's advertisement echo, `seen` being the version this side + /// received in the clear. Call once the key is set and before the first frame is read. + pub fn check_kx_advertised(&mut self, seen: u32) { + self.4 = Some(seen); + } + + /// The server repeats what it advertised inside its first encrypted message, where it can + /// be neither forged nor stripped. A different value in the clear means the handshake was + /// altered on the way, and the stream is refused rather than run at the version it was + /// pushed down to. No echo is a server from before versions, whose advertisement was none. + fn check_kx_advertised_echo(plain: &[u8], seen: u32) -> Result<(), Error> { + let echoed = crate::rendezvous_proto::RendezvousMessage::parse_from_bytes(plain) + .map(|m| m.kx_advertised) + .unwrap_or(0); + if echoed != 0 && echoed != seen { + return Err(Error::new( + ErrorKind::Other, + format!( + "key exchange advertised {} but {} arrived: altered in transit", + echoed, seen + ), + )); + } + Ok(()) + } + + /// Version 1: the initiator, the side that sent the sealed key, sends under one subkey and + /// receives under the other; the responder the reverse. + pub fn new_split(key: Key, is_initiator: bool, t: &KxTranscript) -> ResultType { + let initiator = Self::derive_subkey(&key, KX_SPLIT_INITIATOR, t)?; + let responder = Self::derive_subkey(&key, KX_SPLIT_RESPONDER, t)?; + Ok(if is_initiator { + Self(initiator, 0, 0, Some(responder), None) + } else { + Self(responder, 0, 0, Some(initiator), None) + }) + } + + fn derive_subkey(key: &Key, direction: u8, t: &KxTranscript) -> ResultType { + if t.initiator_pk.len() != box_::PUBLICKEYBYTES + || t.responder_pk.len() != box_::PUBLICKEYBYTES + { + bail!("key exchange transcript has a public key of the wrong length"); + } + let failed = |_| anyhow::anyhow!("key derivation failed"); + let mut h = + generichash::State::new(Some(secretbox::KEYBYTES), Some(&key.0)).map_err(failed)?; + for part in [ + KX_SPLIT_CONTEXT, + &[direction], + &t.advertised.to_le_bytes(), + &t.picked.to_le_bytes(), + t.initiator_pk, + t.responder_pk, + ] { + h.update(part).map_err(failed)?; + } + let mut subkey = [0u8; secretbox::KEYBYTES]; + subkey.copy_from_slice(h.finalize().map_err(failed)?.as_ref()); + Ok(Key(subkey)) } pub fn dec(&mut self, bytes: &mut BytesMut) -> Result<(), Error> { @@ -304,8 +421,11 @@ impl Encrypt { } self.2 += 1; let nonce = FramedStream::get_nonce(self.2); - match secretbox::open(bytes, &nonce, &self.0) { + match secretbox::open(bytes, &nonce, self.3.as_ref().unwrap_or(&self.0)) { Ok(res) => { + if let Some(seen) = self.4.take() { + Self::check_kx_advertised_echo(&res, seen)?; + } bytes.clear(); bytes.put_slice(&res); Ok(()) @@ -342,3 +462,144 @@ impl Encrypt { Ok(Key(key)) } } + +#[cfg(test)] +mod tests { + use super::*; + + const INITIATOR_PK: [u8; 32] = [1u8; 32]; + const RESPONDER_PK: [u8; 32] = [2u8; 32]; + + fn transcript(advertised: u32) -> KxTranscript<'static> { + KxTranscript { + initiator_pk: &INITIATOR_PK, + responder_pk: &RESPONDER_PK, + advertised, + picked: KX_VERSION_LATEST, + } + } + + fn seal_and_open(a: &mut Encrypt, b: &mut Encrypt, msg: &[u8]) -> Vec { + let sealed = a.enc(msg); + let mut buf = BytesMut::from(&sealed[..]); + b.dec(&mut buf).unwrap(); + assert_eq!(&buf[..], msg); + sealed + } + + #[test] + fn test_v0_shares_one_key_across_directions() { + let key = secretbox::gen_key(); + let (mut initiator, mut responder) = (Encrypt::new(key.clone()), Encrypt::new(key)); + let sent = seal_and_open(&mut initiator, &mut responder, b"hello"); + let back = seal_and_open(&mut responder, &mut initiator, b"hello"); + // The n-th message each way is sealed under the same (key, nonce): the defect the split + // fixes, pinned here so the path a peer without versions relies on stays byte-for-byte. + assert_eq!(sent, back); + } + + #[test] + fn test_split_directions_use_different_keys() { + let key = secretbox::gen_key(); + let t = transcript(KX_VERSION_LATEST); + let mut initiator = Encrypt::new_split(key.clone(), true, &t).unwrap(); + let mut responder = Encrypt::new_split(key.clone(), false, &t).unwrap(); + let sent = seal_and_open(&mut initiator, &mut responder, b"hello"); + let back = seal_and_open(&mut responder, &mut initiator, b"hello"); + assert_ne!(sent, back); + // Neither direction's key is the exchanged key itself, so a version 0 peer cannot be + // paired with a split one by accident. + let mut v0 = Encrypt::new(key); + let mut buf = BytesMut::from(&sent[..]); + assert!(v0.dec(&mut buf).is_err()); + } + + #[test] + fn test_split_roles_must_differ() { + let key = secretbox::gen_key(); + let t = transcript(KX_VERSION_LATEST); + let mut a = Encrypt::new_split(key.clone(), true, &t).unwrap(); + let mut b = Encrypt::new_split(key, true, &t).unwrap(); + let sealed = a.enc(b"hello"); + let mut buf = BytesMut::from(&sealed[..]); + assert!(b.dec(&mut buf).is_err()); + } + + #[test] + fn test_kx_version_for_picks_the_highest_shared() { + assert_eq!(kx_version_for(0), 0); + assert_eq!(kx_version_for(1), 1); + assert_eq!(kx_version_for(KX_VERSION_LATEST), KX_VERSION_LATEST); + assert_eq!(kx_version_for(KX_VERSION_LATEST + 7), KX_VERSION_LATEST); + } + + #[test] + fn test_split_binds_the_transcript() { + let key = secretbox::gen_key(); + // The responder advertised LATEST; the initiator was handed something else in transit. + let mut initiator = + Encrypt::new_split(key.clone(), true, &transcript(KX_VERSION_LATEST + 1)).unwrap(); + let mut responder = Encrypt::new_split(key, false, &transcript(KX_VERSION_LATEST)).unwrap(); + let sealed = initiator.enc(b"hello"); + let mut buf = BytesMut::from(&sealed[..]); + assert!(responder.dec(&mut buf).is_err()); + } + + #[test] + fn test_split_rejects_a_short_public_key() { + let t = KxTranscript { + initiator_pk: &[1u8; 31], + responder_pk: &RESPONDER_PK, + advertised: KX_VERSION_LATEST, + picked: KX_VERSION_LATEST, + }; + assert!(Encrypt::new_split(secretbox::gen_key(), true, &t).is_err()); + } + + fn rendezvous_frame(kx_advertised: u32) -> Vec { + let mut msg = crate::rendezvous_proto::RendezvousMessage::new(); + msg.set_hc(Default::default()); + msg.kx_advertised = kx_advertised; + msg.write_to_bytes().unwrap() + } + + fn first_frame_after(seen: u32, echoed: u32) -> Result<(), Error> { + let key = secretbox::gen_key(); + let mut server = Encrypt::new(key.clone()); + let mut client = Encrypt::new(key); + client.check_kx_advertised(seen); + // An empty heartbeat is not a frame, and must not use up the check. + let mut empty = BytesMut::new(); + client.dec(&mut empty).unwrap(); + let mut buf = BytesMut::from(&server.enc(&rendezvous_frame(echoed))[..]); + client.dec(&mut buf) + } + + #[test] + fn test_kx_advertised_echo_must_match_what_was_seen() { + assert!(first_frame_after(KX_VERSION_LATEST, KX_VERSION_LATEST).is_ok()); + // A server from before versions echoes nothing, and advertised nothing. + assert!(first_frame_after(0, 0).is_ok()); + // The advertisement was lowered on the way; the echo says otherwise. + assert!(first_frame_after(0, KX_VERSION_LATEST).is_err()); + assert!(first_frame_after(KX_VERSION_LATEST, KX_VERSION_LATEST + 1).is_err()); + } + + #[test] + fn test_kx_advertised_check_is_one_shot() { + let key = secretbox::gen_key(); + let mut server = Encrypt::new(key.clone()); + let mut client = Encrypt::new(key); + client.check_kx_advertised(KX_VERSION_LATEST); + let mut first = BytesMut::from(&server.enc(&rendezvous_frame(KX_VERSION_LATEST))[..]); + client.dec(&mut first).unwrap(); + let mut later = BytesMut::from(&server.enc(&rendezvous_frame(KX_VERSION_LATEST + 9))[..]); + client.dec(&mut later).unwrap(); + } + + #[test] + fn test_box_pk_of_matches_the_generated_pair() { + let (pk, sk) = box_::gen_keypair(); + assert_eq!(box_pk_of(&sk), pk); + } +} diff --git a/src/websocket.rs b/src/websocket.rs index b2901e322..d4dc85883 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -5,7 +5,7 @@ use crate::{ protobuf::Message, socket_client::split_host_port, sodiumoxide::crypto::secretbox::Key, - tcp::Encrypt, + tcp::{Encrypt, KxTranscript}, tls::{get_cached_tls_accept_invalid_cert, get_cached_tls_type, upsert_tls_cache, TlsType}, ResultType, }; @@ -257,6 +257,24 @@ impl WsFramedStream { self.encrypt = Some(Encrypt::new(key)); } + #[inline] + pub fn set_key_split( + &mut self, + key: Key, + is_initiator: bool, + t: &KxTranscript, + ) -> ResultType<()> { + self.encrypt = Some(Encrypt::new_split(key, is_initiator, t)?); + Ok(()) + } + + #[inline] + pub fn check_kx_advertised(&mut self, seen: u32) { + if let Some(enc) = self.encrypt.as_mut() { + enc.check_kx_advertised(seen); + } + } + #[inline] pub fn is_secured(&self) -> bool { self.encrypt.is_some()