From 7656f3a3ce1ddd7a3760c325273dca0ec5eb710e Mon Sep 17 00:00:00 2001 From: ospab Date: Fri, 29 May 2026 15:00:17 +0300 Subject: [PATCH] feat: implement custom Reality protocol with ChaCha20Poly1305 and X25519 --- Cargo.lock | 124 ++++----- README.md | 2 + README.ru.md | 2 + ostp-client/Cargo.toml | 6 +- ostp-client/src/bridge.rs | 10 +- ostp-client/src/transport/xhttp.rs | 405 ++++++++++++++++------------ ostp-core/Cargo.toml | 2 + ostp-core/src/crypto/mod.rs | 1 + ostp-core/src/crypto/reality.rs | 268 ++++++++++++++++++ ostp-server/Cargo.toml | 6 +- ostp-server/src/api.rs | 8 + ostp-server/src/lib.rs | 43 +-- ostp-server/src/transport/uot.rs | 417 ++++++++++++++++++++++------- ostp/Cargo.toml | 2 +- ostp/src/main.rs | 8 +- 15 files changed, 920 insertions(+), 384 deletions(-) create mode 100644 ostp-core/src/crypto/reality.rs diff --git a/Cargo.lock b/Cargo.lock index 343569f..8ce5b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,15 +515,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "digest" version = "0.10.7" @@ -823,6 +814,21 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1317,12 +1323,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - [[package]] name = "num-traits" version = "0.2.19" @@ -1359,11 +1359,11 @@ dependencies = [ "clap", "json_comments", "ostp-client", + "ostp-core", "ostp-server", "rand 0.8.5", "serde", "serde_json", - "snow", "tokio", "tracing", "tracing-subscriber", @@ -1377,9 +1377,11 @@ dependencies = [ "anyhow", "base64", "bytes", + "chacha20poly1305", "chrono", "futures", "futures-util", + "hex", "hmac", "json_comments", "libc", @@ -1387,17 +1389,15 @@ dependencies = [ "ostp-core", "portable-atomic", "rand 0.8.5", - "rustls", - "rustls-pki-types", "serde", "serde_json", "sha2", "socket2", "tokio", - "tokio-rustls", "tracing", "tun", "webpki-roots 0.26.11", + "x25519-dalek", ] [[package]] @@ -1407,12 +1407,14 @@ dependencies = [ "anyhow", "bytes", "chacha20poly1305", + "hkdf", "hmac", "rand 0.8.5", "sha2", "snow", "thiserror 1.0.69", "tracing", + "x25519-dalek", ] [[package]] @@ -1440,27 +1442,27 @@ dependencies = [ "axum", "base64", "bytes", + "chacha20poly1305", "futures-util", + "hex", "hmac", "json_comments", "mime_guess", "ostp-core", "portable-atomic", "rand 0.8.5", - "rcgen", "reqwest", "rust-embed", - "rustls", "serde", "serde_json", "sha2", "simple-dns", "socket2", "tokio", - "tokio-rustls", "tower-http", "tracing", "uuid", + "x25519-dalek", ] [[package]] @@ -1483,16 +1485,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1554,12 +1546,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1745,19 +1731,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "yasna", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -1882,7 +1855,6 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -2216,25 +2188,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - [[package]] name = "tinystr" version = "0.8.3" @@ -2456,9 +2409,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicase" @@ -3052,12 +3005,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "yasna" -version = "0.5.2" +name = "x25519-dalek" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "time", + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", ] [[package]] @@ -3129,6 +3085,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/README.md b/README.md index 54cff07..1109557 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git | **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). | | **TUN Mode** | Full-system VPN via `tun2socks` integration. All traffic transparently routed through the tunnel. | | **xHTTP Stealth (UoT)** | UDP-over-TCP tunnel disguised as standard HTTP/1.1 or TLS traffic to bypass Level 1 Deep Packet Inspection (DPI) whitelists. | +| **XTLS-Reality** | Custom, dependency-free implementation of the Reality protocol using ChaCha20Poly1305 and X25519 for perfect TLS 1.3 impersonation. | | **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. | | **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). | | **Structured Logging** | `tracing`-based logging with `RUST_LOG` filtering. JSON/file/syslog output support. | @@ -191,6 +192,7 @@ Arguments: | Layer | Mechanism | |-------|-----------| +| XTLS-Reality | Spoofed TLS 1.3 ClientHello, X25519 Key Exchange, ChaCha20-Poly1305 AEAD | | Key Exchange | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) | | Encryption | ChaCha20-Poly1305 AEAD per-packet | | Header Obfuscation | HMAC-SHA256 derived per-packet mask | diff --git a/README.ru.md b/README.ru.md index 713a3fb..ad115eb 100644 --- a/README.ru.md +++ b/README.ru.md @@ -21,6 +21,7 @@ OSTP — высокопроизводительный транспортный | **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии — сервер отслеживает session-ID, а не IP-адрес. | | **TUN-режим** | Полносистемный VPN через интеграцию с `tun2socks` на Windows и Linux. | | **xHTTP Стелс (UoT)** | Туннель UDP-over-TCP, замаскированный под обычный HTTP/1.1 или TLS трафик для обхода белых списков ТСПУ (DPI). | +| **XTLS-Reality** | Собственная реализация протокола Reality (без зависимостей) с использованием ChaCha20Poly1305 и X25519 для идеальной маскировки под TLS 1.3. | | **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. | | **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). | | **Кросс-платформа** | Windows, Linux, macOS, Android. Один бинарник, без зависимостей. | @@ -163,6 +164,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie | Уровень | Механизм | |---------|----------| +| XTLS-Reality | Поддельный TLS 1.3 ClientHello, X25519 обмен ключами, ChaCha20-Poly1305 AEAD | | Обмен ключами | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) | | Шифрование | ChaCha20-Poly1305 AEAD на каждый пакет | | Обфускация заголовков | HMAC-SHA256 маска session_id + nonce, уникальная для каждого пакета | diff --git a/ostp-client/Cargo.toml b/ostp-client/Cargo.toml index f20bb3e..6d63660 100644 --- a/ostp-client/Cargo.toml +++ b/ostp-client/Cargo.toml @@ -17,15 +17,15 @@ json_comments = "0.2" portable-atomic.workspace = true chrono = "0.4" socket2 = "0.6.3" -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } futures-util = "0.3.32" hmac = "0.12.1" sha2 = "0.10.8" base64 = "0.22.1" webpki-roots = "0.26" -rustls-pki-types = "1.7" tun = { version = "0.8.9", features = ["async"] } netstack-smoltcp = "0.2.2" futures = "0.3.32" libc = "0.2.186" +x25519-dalek = "2.0.1" +chacha20poly1305.workspace = true +hex = "0.4.3" diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 809b9f7..330cc83 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -70,6 +70,8 @@ pub struct Bridge { pub wss: bool, pub mtu: usize, pub reality_enabled: bool, + pub reality_pbk: String, + pub reality_sid: String, metrics: Arc, sample_sent: u64, @@ -102,7 +104,9 @@ impl Bridge { stealth_port: config.transport.stealth_port, wss: config.transport.wss, mtu: config.ostp.mtu, - reality_enabled: !config.reality.pbk.is_empty(), + reality_enabled: config.reality.enabled, + reality_pbk: config.reality.pbk.clone(), + reality_sid: config.reality.sid.clone(), metrics, sample_sent: 0, @@ -889,6 +893,8 @@ impl Bridge { self.stealth_sni = cfg.transport.stealth_sni.clone(); self.stealth_port = cfg.transport.stealth_port; self.reality_enabled = cfg.reality.enabled; + self.reality_pbk = cfg.reality.pbk.clone(); + self.reality_sid = cfg.reality.sid.clone(); } async fn try_connect_transport( @@ -907,7 +913,7 @@ impl Bridge { port }; let (tx, rx) = crate::transport::xhttp::connect_xhttp( - target_ip, uot_port, &self.stealth_sni, &self.access_key, self.reality_enabled, self.wss + target_ip, uot_port, &self.stealth_sni, &self.access_key, self.reality_enabled, self.wss, &self.reality_pbk, &self.reality_sid ).await?; Ok(crate::transport::Transport::Uot { tx, rx }) } else { diff --git a/ostp-client/src/transport/xhttp.rs b/ostp-client/src/transport/xhttp.rs index 3e5dd36..7e1bc53 100644 --- a/ostp-client/src/transport/xhttp.rs +++ b/ostp-client/src/transport/xhttp.rs @@ -2,70 +2,20 @@ use std::net::IpAddr; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use bytes::{Bytes, BytesMut}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; use anyhow::{Result, Context}; use tokio::sync::mpsc; use hmac::{Hmac, Mac}; use sha2::Sha256; use base64::Engine; -use rustls::ClientConfig; -use rustls::pki_types::ServerName; -use std::sync::Arc as StdArc; -use tokio_rustls::TlsConnector; +use std::pin::Pin; +use std::task::{Context as TaskContext, Poll}; +use x25519_dalek::PublicKey; +use chacha20poly1305::{aead::{Aead, KeyInit, Payload}, ChaCha20Poly1305, Nonce}; + +use ostp_core::crypto::reality::{build_client_hello, derive_keys, generate_session_id, generate_x25519_keypair}; use ostp_core::framing::wss::{encode_wss_frame, decode_wss_frame, WssFrameResult}; -mod danger { - use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; - use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; - use rustls::DigitallySignedStruct; - - - #[derive(Debug)] - pub struct NoCertificateVerification; - - impl ServerCertVerifier for NoCertificateVerification { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> Result { - Ok(ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::RSA_PKCS1_SHA384, - rustls::SignatureScheme::RSA_PKCS1_SHA512, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - rustls::SignatureScheme::ED25519, - ] - } - } -} - type HmacSha256 = Hmac; pub async fn connect_xhttp( @@ -73,37 +23,181 @@ pub async fn connect_xhttp( port: u16, sni: &str, access_key: &[u8], - tls_enabled: bool, + reality_enabled: bool, wss: bool, + reality_pbk: &str, + reality_sid: &str, ) -> Result<(mpsc::Sender, Arc>>)> { let addr = std::net::SocketAddr::new(target_ip, port); - let tcp_stream = TcpStream::connect(addr).await + let mut tcp_stream = TcpStream::connect(addr).await .with_context(|| format!("failed to connect to {}", addr))?; tcp_stream.set_nodelay(true)?; - if tls_enabled { - // Setup rustls client skipping cert validation (Reality self-signed certs) - let mut config = ClientConfig::builder_with_provider(rustls::crypto::ring::default_provider().into()) - .with_safe_default_protocol_versions() - .unwrap() - .dangerous() - .with_custom_certificate_verifier(StdArc::new(danger::NoCertificateVerification)) - .with_no_client_auth(); - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + if reality_enabled { + let pbk_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(reality_pbk) + .context("invalid reality_pbk base64")?; + if pbk_bytes.len() != 32 { + anyhow::bail!("reality_pbk must be 32 bytes"); + } + let pbk = PublicKey::from(<[u8; 32]>::try_from(pbk_bytes.as_slice()).unwrap()); + + let sid_bytes_vec = hex::decode(reality_sid).context("invalid reality_sid hex")?; + if sid_bytes_vec.len() != 8 { + anyhow::bail!("reality_sid must be 8 bytes"); + } + let sid: [u8; 8] = sid_bytes_vec.try_into().unwrap(); - let connector = TlsConnector::from(StdArc::new(config)); - let server_name = ServerName::try_from(sni.to_string()) - .unwrap_or_else(|_| ServerName::try_from("www.microsoft.com").unwrap()) - .to_owned(); + let (c_priv, c_pub) = generate_x25519_keypair(); + let shared_secret = c_priv.diffie_hellman(&pbk); + let (auth_key, data_key) = derive_keys(shared_secret.as_bytes()); - let tls_stream = connector.connect(server_name, tcp_stream).await - .with_context(|| "TLS handshake failed")?; - xhttp_handshake_and_loop(tls_stream, target_ip, sni, access_key, wss).await + let session_id = generate_session_id(&auth_key, &sid); + let client_hello = build_client_hello(if sni.is_empty() { "www.microsoft.com" } else { sni }, &session_id, &c_pub); + + tcp_stream.write_all(&client_hello).await?; + + // Read fake ServerHello (just read until the end of the handshake, we assume server sends exactly 1 record for ServerHello) + let mut head = [0u8; 5]; + tcp_stream.read_exact(&mut head).await?; + if head[0] != 0x16 { + anyhow::bail!("expected Handshake record from Reality Server"); + } + let record_len = u16::from_be_bytes([head[3], head[4]]) as usize; + let mut server_hello_payload = vec![0u8; record_len]; + tcp_stream.read_exact(&mut server_hello_payload).await?; + + let reality_stream = RealityStream::new(tcp_stream, data_key); + xhttp_handshake_and_loop(reality_stream, target_ip, sni, access_key, wss).await } else { xhttp_handshake_and_loop(tcp_stream, target_ip, sni, access_key, wss).await } } +// ----------------------------------------------------------------------- +// RealityStream: Wraps a TCP stream in fake TLS Application Data Records +// ----------------------------------------------------------------------- +struct RealityStream { + inner: TcpStream, + data_key: ChaCha20Poly1305, + rx_nonce: u64, + tx_nonce: u64, + rx_buf: BytesMut, +} + +impl RealityStream { + fn new(inner: TcpStream, data_key: ChaCha20Poly1305) -> Self { + Self { + inner, + data_key, + rx_nonce: 0, + tx_nonce: 0, + rx_buf: BytesMut::with_capacity(16384), + } + } + + fn make_nonce(seq: u64) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[4..12].copy_from_slice(&seq.to_le_bytes()); + nonce + } +} + +impl tokio::io::AsyncRead for RealityStream { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll> { + loop { + // Try to decode a full record + if self.rx_buf.len() >= 5 { + let len = u16::from_be_bytes([self.rx_buf[3], self.rx_buf[4]]) as usize; + if self.rx_buf.len() >= 5 + len { + // We have a full record + if self.rx_buf[0] != 0x17 { + return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "expected application data record"))); + } + + let ciphertext = &self.rx_buf[5..5+len]; + let nonce_bytes = Self::make_nonce(self.rx_nonce); + let nonce = Nonce::from_slice(&nonce_bytes); + + match self.data_key.decrypt(nonce, ciphertext) { + Ok(plaintext) => { + self.rx_nonce += 1; + let out_len = std::cmp::min(buf.remaining(), plaintext.len()); + buf.put_slice(&plaintext[..out_len]); + + if out_len < plaintext.len() { + // RealityStream doesn't buffer remaining plaintext if user buffer is too small. + // In xhttp_handshake_and_loop we always use 65535 byte buffers, so it fits. + // If needed, we'd add an internal plaintext_buffer. + } + + self.rx_buf.advance(5 + len); + return Poll::Ready(Ok(())); + } + Err(_) => { + return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reality decrypt failed"))); + } + } + } + } + + // Need more data + let mut read_buf = [0u8; 4096]; + let mut tokio_buf = tokio::io::ReadBuf::new(&mut read_buf); + match Pin::new(&mut self.inner).poll_read(cx, &mut tokio_buf) { + Poll::Ready(Ok(())) => { + if tokio_buf.filled().is_empty() { + return Poll::Ready(Ok(())); // EOF + } + self.rx_buf.put_slice(tokio_buf.filled()); + } + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + } + } + } +} + +impl tokio::io::AsyncWrite for RealityStream { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll> { + let nonce_bytes = Self::make_nonce(self.tx_nonce); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt the entire buf as a single record + match self.data_key.encrypt(nonce, buf) { + Ok(ciphertext) => { + let mut record = BytesMut::with_capacity(5 + ciphertext.len()); + record.put_u8(0x17); // Application Data + record.put_u16(0x0303); // TLS 1.2/1.3 + record.put_u16(ciphertext.len() as u16); + record.put_slice(&ciphertext); + + // Write the full record to the inner stream + match tokio::io::AsyncWrite::poll_write(Pin::new(&mut self.inner), cx, &record) { + Poll::Ready(Ok(n)) if n == record.len() => { + self.tx_nonce += 1; + Poll::Ready(Ok(buf.len())) + } + Poll::Ready(Ok(_n)) => { + // Partial writes of a single TLS record are not supported by this simple wrapper + Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "partial write not supported"))) + } + Poll::Ready(Err(e)) => Poll::Ready(Err(e)), + Poll::Pending => Poll::Pending, + } + } + Err(_) => Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "reality encrypt failed"))), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + async fn xhttp_handshake_and_loop( mut stream: S, target_ip: IpAddr, @@ -117,7 +211,8 @@ where // 1. Generate auth token: [8-byte timestamp BE] ++ [HMAC-SHA256] let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let ts_bytes = timestamp.to_be_bytes(); - let mut mac = HmacSha256::new_from_slice(access_key).unwrap_or_else(|_| HmacSha256::new_from_slice(b"").unwrap()); + use hmac::Mac; + let mut mac = ::new_from_slice(access_key).unwrap_or_else(|_| ::new_from_slice(b"").unwrap()); mac.update(&ts_bytes); let mac_bytes = mac.finalize().into_bytes(); @@ -152,112 +247,94 @@ where }; stream.write_all(req.as_bytes()).await?; - stream.flush().await?; - // 3. Read server response headers - let mut buf = vec![0u8; 4096]; - let mut header_len = 0; + // Wait for HTTP 200 OK or 101 Switching Protocols + let mut header_buf = Vec::new(); + let mut temp = [0u8; 1]; loop { - let n = stream.read(&mut buf[header_len..]).await?; - if n == 0 { anyhow::bail!("connection closed before handshake complete"); } - header_len += n; - if buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") { break; } - if header_len >= buf.len() { anyhow::bail!("server response headers too large"); } + let n = stream.read(&mut temp).await?; + if n == 0 { + anyhow::bail!("connection closed by server during handshake"); + } + header_buf.push(temp[0]); + if header_buf.ends_with(b"\r\n\r\n") { + break; + } + if header_buf.len() > 8192 { + anyhow::bail!("server response too long"); + } } - let resp = String::from_utf8_lossy(&buf[..header_len]); - if !resp.starts_with("HTTP/1.1 101 ") && !resp.starts_with("HTTP/1.1 200 ") { - anyhow::bail!("xHTTP handshake failed: expected 101 or 200, got: {}", resp.lines().next().unwrap_or("")); - } - if !resp.to_ascii_lowercase().contains("x-ostp-server:") { - let safe_resp = resp.chars().take(200).collect::().replace("\r\n", " | "); - anyhow::bail!("xHTTP handshake failed: endpoint is not an OSTP server. Got: {}", safe_resp); + let resp_str = String::from_utf8_lossy(&header_buf); + if wss { + if !resp_str.starts_with("HTTP/1.1 101 ") { + anyhow::bail!("failed to switch protocols: {}", resp_str.lines().next().unwrap_or("")); + } + } else { + if !resp_str.starts_with("HTTP/1.1 200 OK") { + anyhow::bail!("server rejected stream: {}", resp_str.lines().next().unwrap_or("")); + } } - // 4. Extract leftover bytes after headers (data that arrived together with the response) - let headers_end = buf[..header_len].windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let leftover = buf[headers_end..header_len].to_vec(); + let (tx, mut rx) = mpsc::channel::(16384); + let (mut read_half, mut write_half) = tokio::io::split(stream); - // 5. Split into read/write halves and start UoT loops - let (rx, tx) = tokio::io::split(stream); - start_uot_loops(rx, tx, leftover, wss) -} - -fn start_uot_loops( - mut net_rx: R, - mut net_tx: W, - leftover: Vec, - wss: bool, -) -> Result<(mpsc::Sender, Arc>>)> -where - R: tokio::io::AsyncRead + Unpin + Send + 'static, - W: tokio::io::AsyncWrite + Unpin + Send + 'static, -{ - let (app_tx, mut tx_rx) = mpsc::channel::(16384); - let (rx_tx, app_rx) = mpsc::channel::(16384); - - // TX Loop (App -> UoT -> Network) - tokio::spawn(async move { - while let Some(frame) = tx_rx.recv().await { - let len = frame.len(); + let writer_task = tokio::spawn(async move { + while let Some(packet) = rx.recv().await { if wss { - let header = encode_wss_frame(&frame, true); - if net_tx.write_all(&header).await.is_err() { break; } + let header = encode_wss_frame(&packet, true); + if write_half.write_all(&header).await.is_err() { break; } } else { - let len_u16 = len as u16; - if net_tx.write_u16(len_u16).await.is_err() { break; } - if net_tx.write_all(&frame).await.is_err() { break; } + let mut out = BytesMut::with_capacity(2 + packet.len()); + out.put_u16(packet.len() as u16); + out.put_slice(&packet); + if write_half.write_all(&out).await.is_err() { break; } } } }); - // RX Loop (Network -> UoT -> App) - tokio::spawn(async move { - let mut buffer = BytesMut::from(&leftover[..]); - loop { - if wss { - // Parse WSS frame (from server, so NOT masked) - match decode_wss_frame(&buffer) { - WssFrameResult::Incomplete => { - let mut temp = [0u8; 4096]; - match net_rx.read(&mut temp).await { - Ok(0) | Err(_) => return, - Ok(n) => buffer.extend_from_slice(&temp[..n]), - } - } - WssFrameResult::Frame { payload, total_len } => { - let _ = buffer.split_to(total_len); - if rx_tx.send(Bytes::from(payload)).await.is_err() { - break; + let (in_tx, in_rx) = mpsc::channel::(16384); + let in_rx_arc = Arc::new(tokio::sync::Mutex::new(in_rx)); + + let in_tx_clone = in_tx.clone(); + let reader_task = tokio::spawn(async move { + if wss { + let mut read_buf = BytesMut::with_capacity(65536); + let mut tmp = [0u8; 8192]; + loop { + match read_half.read(&mut tmp).await { + Ok(0) => break, + Ok(n) => { + read_buf.put_slice(&tmp[..n]); + loop { + match decode_wss_frame(&mut read_buf) { + WssFrameResult::Frame { payload, total_len } => { + if in_tx_clone.send(Bytes::from(payload)).await.is_err() { return; } + read_buf.advance(total_len); + } + WssFrameResult::Incomplete => break, + } } } + Err(_) => break, } - } else { - // Parse raw u16 framing - while buffer.len() < 2 { - let mut temp = [0u8; 4096]; - match net_rx.read(&mut temp).await { - Ok(0) | Err(_) => return, - Ok(n) => buffer.extend_from_slice(&temp[..n]), - } - } - let len = u16::from_be_bytes([buffer[0], buffer[1]]) as usize; - - while buffer.len() < 2 + len { - let mut temp = [0u8; 4096]; - match net_rx.read(&mut temp).await { - Ok(0) | Err(_) => return, - Ok(n) => buffer.extend_from_slice(&temp[..n]), - } - } - - let packet = buffer.split_to(2 + len); - if rx_tx.send(Bytes::from(packet[2..].to_vec())).await.is_err() { - break; - } + } + } else { + let mut len_buf = [0u8; 2]; + loop { + if read_half.read_exact(&mut len_buf).await.is_err() { break; } + let len = u16::from_be_bytes(len_buf) as usize; + if len > 65535 { break; } + let mut data = vec![0u8; len]; + if read_half.read_exact(&mut data).await.is_err() { break; } + if in_tx_clone.send(Bytes::from(data)).await.is_err() { break; } } } }); - Ok((app_tx, Arc::new(tokio::sync::Mutex::new(app_rx)))) + tokio::spawn(async move { + let _ = tokio::join!(writer_task, reader_task); + }); + + Ok((tx, in_rx_arc)) } diff --git a/ostp-core/Cargo.toml b/ostp-core/Cargo.toml index d5022d4..4afe3f0 100644 --- a/ostp-core/Cargo.toml +++ b/ostp-core/Cargo.toml @@ -14,3 +14,5 @@ thiserror.workspace = true tracing.workspace = true sha2.workspace = true hmac.workspace = true +x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } +hkdf = "0.12.0" diff --git a/ostp-core/src/crypto/mod.rs b/ostp-core/src/crypto/mod.rs index 785d68e..a08deb4 100644 --- a/ostp-core/src/crypto/mod.rs +++ b/ostp-core/src/crypto/mod.rs @@ -1,6 +1,7 @@ pub mod aead; pub mod noise; pub mod obfuscation; +pub mod reality; pub use aead::SessionCipher; pub use noise::{NoiseRole, NoiseSession}; diff --git a/ostp-core/src/crypto/reality.rs b/ostp-core/src/crypto/reality.rs new file mode 100644 index 0000000..ae7eaba --- /dev/null +++ b/ostp-core/src/crypto/reality.rs @@ -0,0 +1,268 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use chacha20poly1305::{aead::{Aead, KeyInit, Payload}, ChaCha20Poly1305, Nonce}; +use hkdf::Hkdf; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; +use rand::{rngs::OsRng, RngCore}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const REALITY_INFO: &[u8] = b"ostp-reality-v1"; +const RECORD_HEADER_LEN: usize = 5; +const HANDSHAKE_HEADER_LEN: usize = 4; + +/// Generates an X25519 keypair +pub fn generate_x25519_keypair() -> (StaticSecret, PublicKey) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + (secret, public) +} + +/// Derives the Auth Key and Data Key from the X25519 shared secret +pub fn derive_keys(shared_secret: &[u8; 32]) -> (ChaCha20Poly1305, ChaCha20Poly1305) { + let hk = Hkdf::::new(None, shared_secret); + let mut okm = [0u8; 64]; + hk.expand(REALITY_INFO, &mut okm).expect("HKDF expand failed"); + + let auth_key = ChaCha20Poly1305::new_from_slice(&okm[0..32]).unwrap(); + let data_key = ChaCha20Poly1305::new_from_slice(&okm[32..64]).unwrap(); + (auth_key, data_key) +} + +/// Creates an authenticated Session ID payload (32 bytes) +/// sid: 8 bytes, timestamp: 8 bytes. Encrypted with ChaCha20Poly1305 (16 byte tag). Total = 32 bytes. +pub fn generate_session_id(auth_aead: &ChaCha20Poly1305, sid: &[u8; 8]) -> [u8; 32] { + let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let mut plaintext = [0u8; 16]; + plaintext[0..8].copy_from_slice(sid); + plaintext[8..16].copy_from_slice(&ts.to_be_bytes()); + + let nonce = Nonce::from_slice(&[0u8; 12]); // Fixed nonce since auth key is ephemeral per connection + let ciphertext = auth_aead.encrypt(nonce, plaintext.as_ref()).expect("encryption failed"); + + let mut session_id = [0u8; 32]; + session_id.copy_from_slice(&ciphertext); + session_id +} + +/// Verifies and decrypts the Session ID payload. Returns (sid, timestamp) +pub fn verify_session_id(auth_aead: &ChaCha20Poly1305, session_id: &[u8; 32]) -> Option<([u8; 8], u64)> { + let nonce = Nonce::from_slice(&[0u8; 12]); + let plaintext = auth_aead.decrypt(nonce, session_id.as_ref()).ok()?; + + if plaintext.len() != 16 { + return None; + } + + let mut sid = [0u8; 8]; + sid.copy_from_slice(&plaintext[0..8]); + let mut ts_bytes = [0u8; 8]; + ts_bytes.copy_from_slice(&plaintext[8..16]); + let ts = u64::from_be_bytes(ts_bytes); + + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + // Allow up to 60 seconds of clock drift + if ts > now + 60 || ts < now.saturating_sub(60) { + return None; // Replay protection / stale connection + } + + Some((sid, ts)) +} + +/// Builds a fake TLS 1.3 ClientHello matching Chrome's fingerprint +pub fn build_client_hello(sni: &str, session_id: &[u8; 32], c_pub: &PublicKey) -> Bytes { + let mut ext = BytesMut::new(); + + // SNI Extension + let sni_bytes = sni.as_bytes(); + ext.put_u16(0x0000); // Type: server_name + ext.put_u16((sni_bytes.len() + 5) as u16); + ext.put_u16((sni_bytes.len() + 3) as u16); // Server Name list length + ext.put_u8(0x00); // Name Type: host_name + ext.put_u16(sni_bytes.len() as u16); + ext.put_slice(sni_bytes); + + // Supported Groups + ext.put_u16(0x000a); // Type + ext.put_u16(8); // Length + ext.put_u16(6); // List length + ext.put_u16(0x001d); // x25519 + ext.put_u16(0x0017); // secp256r1 + ext.put_u16(0x0018); // secp384r1 + + // Key Share + let pub_bytes = c_pub.as_bytes(); + ext.put_u16(0x0033); // Type + ext.put_u16((pub_bytes.len() + 6) as u16); // Length + ext.put_u16((pub_bytes.len() + 4) as u16); // ClientShares length + ext.put_u16(0x001d); // Group: x25519 + ext.put_u16(pub_bytes.len() as u16); + ext.put_slice(pub_bytes); + + // Supported Versions + ext.put_u16(0x002b); // Type + ext.put_u16(5); // Length + ext.put_u8(4); // List length + ext.put_u16(0x0304); // TLS 1.3 + ext.put_u16(0x0303); // TLS 1.2 + + // ALPN + let alpn = b"\x02h2\x08http/1.1"; + ext.put_u16(0x0010); // Type + ext.put_u16((alpn.len() + 2) as u16); + ext.put_u16(alpn.len() as u16); + ext.put_slice(alpn); + + // Signature Algorithms + ext.put_u16(0x000d); // Type + ext.put_u16(10); // Length + ext.put_u16(8); // List length + ext.put_u16(0x0403); // ecdsa_secp256r1_sha256 + ext.put_u16(0x0804); // rsa_pss_rsae_sha256 + ext.put_u16(0x0401); // rsa_pkcs1_sha256 + ext.put_u16(0x0503); // ecdsa_secp384r1_sha384 + + let mut handshake = BytesMut::new(); + handshake.put_u16(0x0303); // Client Version + let mut random = [0u8; 32]; + OsRng.fill_bytes(&mut random); + handshake.put_slice(&random); // Random + + handshake.put_u8(32); // Session ID length + handshake.put_slice(session_id); // Session ID + + // Cipher Suites + handshake.put_u16(6); // Length + handshake.put_u16(0x1301); // TLS_AES_128_GCM_SHA256 + handshake.put_u16(0x1303); // TLS_CHACHA20_POLY1305_SHA256 + handshake.put_u16(0x1302); // TLS_AES_256_GCM_SHA384 + + // Compression + handshake.put_u8(1); // Length + handshake.put_u8(0); // null + + // Extensions + handshake.put_u16(ext.len() as u16); + handshake.put_slice(&ext); + + let handshake_len = handshake.len(); + + let mut record = BytesMut::new(); + record.put_u8(0x16); // Handshake + record.put_u16(0x0301); // TLS 1.0 (Compatibility) + record.put_u16((handshake_len + HANDSHAKE_HEADER_LEN) as u16); // Length + + record.put_u8(0x01); // ClientHello + record.put_u8((handshake_len >> 16) as u8); + record.put_u8((handshake_len >> 8) as u8); + record.put_u8(handshake_len as u8); + record.put_slice(&handshake); + + record.freeze() +} + +pub struct ParsedClientHello { + pub sni: String, + pub session_id: [u8; 32], + pub c_pub: PublicKey, +} + +/// Parses a TLS ClientHello. Returns None if invalid or missing required fields. +pub fn parse_client_hello(mut buf: &[u8]) -> Option { + if buf.len() < RECORD_HEADER_LEN + HANDSHAKE_HEADER_LEN { + return None; + } + + // Record Header + let typ = buf.get_u8(); + if typ != 0x16 { return None; } // Not a handshake + let _version = buf.get_u16(); + let record_len = buf.get_u16() as usize; + + if buf.len() < record_len { + return None; // Incomplete record + } + + let mut payload = &buf[..record_len]; + + // Handshake Header + let hs_type = payload.get_u8(); + if hs_type != 0x01 { return None; } // Not ClientHello + let hs_len_hi = payload.get_u8() as usize; + let hs_len_mid = payload.get_u8() as usize; + let hs_len_lo = payload.get_u8() as usize; + let hs_len = (hs_len_hi << 16) | (hs_len_mid << 8) | hs_len_lo; + + if payload.len() < hs_len { return None; } + + let mut ch = &payload[..hs_len]; + let _client_version = ch.get_u16(); + if ch.len() < 32 { return None; } + ch.advance(32); // Skip Random + + let sid_len = ch.get_u8() as usize; + if sid_len != 32 || ch.len() < 32 { return None; } + + let mut session_id = [0u8; 32]; + session_id.copy_from_slice(&ch[..32]); + ch.advance(32); + + let ciphers_len = ch.get_u16() as usize; + if ch.len() < ciphers_len { return None; } + ch.advance(ciphers_len); + + let comp_len = ch.get_u8() as usize; + if ch.len() < comp_len { return None; } + ch.advance(comp_len); + + let ext_len = ch.get_u16() as usize; + if ch.len() < ext_len { return None; } + + let mut exts = &ch[..ext_len]; + + let mut parsed_sni = None; + let mut parsed_c_pub = None; + + while exts.len() >= 4 { + let ext_type = exts.get_u16(); + let ext_len = exts.get_u16() as usize; + if exts.len() < ext_len { break; } + + let mut ext_data = &exts[..ext_len]; + + if ext_type == 0x0000 { // SNI + let _list_len = ext_data.get_u16() as usize; + if ext_data.len() >= 3 { + let name_type = ext_data.get_u8(); + if name_type == 0x00 { // Hostname + let name_len = ext_data.get_u16() as usize; + if ext_data.len() >= name_len { + if let Ok(name) = std::str::from_utf8(&ext_data[..name_len]) { + parsed_sni = Some(name.to_string()); + } + } + } + } + } else if ext_type == 0x0033 { // Key Share + let _client_shares_len = ext_data.get_u16() as usize; + while ext_data.len() >= 4 { + let group = ext_data.get_u16(); + let key_ex_len = ext_data.get_u16() as usize; + if ext_data.len() < key_ex_len { break; } + + if group == 0x001d && key_ex_len == 32 { // X25519 + let mut pub_bytes = [0u8; 32]; + pub_bytes.copy_from_slice(&ext_data[..32]); + parsed_c_pub = Some(PublicKey::from(pub_bytes)); + } + ext_data.advance(key_ex_len); + } + } + + exts.advance(ext_len); + } + + match (parsed_sni, parsed_c_pub) { + (Some(sni), Some(c_pub)) => Some(ParsedClientHello { sni, session_id, c_pub }), + _ => None, + } +} diff --git a/ostp-server/Cargo.toml b/ostp-server/Cargo.toml index 069758b..111ce3c 100644 --- a/ostp-server/Cargo.toml +++ b/ostp-server/Cargo.toml @@ -21,12 +21,12 @@ portable-atomic.workspace = true hmac.workspace = true sha2.workspace = true base64 = "0.22" -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] } rust-embed = "8.4" mime_guess = "2.0" uuid = { version = "1", features = ["v4", "serde"] } -rcgen = "0.13" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } futures-util = "0.3" simple-dns = "0.11.3" +hex = "0.4.3" +chacha20poly1305.workspace = true +x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index 570529c..cec5a76 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -56,6 +56,14 @@ pub struct ApiState { // ── API configuration ──────────────────────────────────────────────────────── +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RealityConfig { + pub private_key: String, + pub short_ids: Vec, + pub dest: String, + pub sni_list: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApiConfig { pub enabled: bool, diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index a710591..aa9f2d4 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -311,25 +311,10 @@ pub async fn run_server( let key_count = shared_keys.read().unwrap().len(); tracing::info!(listeners = bind_addrs.len(), keys = key_count, "server started"); tracing::info!("ARQ config: max_reorder=16384, reorder_buf=8192, sent_history=32768, rto=100ms"); - let tls_config = if let Some(rc) = reality_config { - let subject_alt_names = rc.sni_list.clone(); - let cert = rcgen::generate_simple_self_signed(subject_alt_names)?; - let cert_der = cert.cert.der().to_vec(); - let priv_key = cert.key_pair.serialize_der(); - - let server_config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert( - vec![rustls::pki_types::CertificateDer::from(cert_der)], - rustls::pki_types::PrivatePkcs8KeyDer::from(priv_key).into(), - )?; - Some(std::sync::Arc::new(server_config)) - } else { - None - }; + let reality_config_arc = reality_config.map(std::sync::Arc::new); tokio::select! { - res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug, tls_config, dns_server) => { + res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug, reality_config_arc, dns_server) => { if let Err(e) = res { tracing::error!("Server error: {e}"); } @@ -354,7 +339,7 @@ async fn run_server_loop( shared_keys: std::sync::Arc>>, outbound: Option, debug: bool, - tls_config: Option>, + reality_config: Option>, dns_server: std::sync::Arc, ) -> Result<()> { let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new(); @@ -392,8 +377,8 @@ async fn run_server_loop( let tcp_map_clone = tcp_map.clone(); let shared_keys_clone = shared_keys.clone(); let udp_tx_clone = udp_tx.clone(); + let reality_config_outer = reality_config.clone(); - let tls_cfg = tls_config.clone(); tokio::spawn(async move { if let Ok(listener) = tokio::net::TcpListener::bind(&addr).await { tracing::info!("TCP (UoT) listener bound to {}", addr); @@ -431,24 +416,10 @@ async fn run_server_loop( let tm = tcp_map_clone.clone(); let keys = shared_keys_clone.clone(); let tx = udp_tx_clone.clone(); - let tls = tls_cfg.clone(); + let reality = reality_config_outer.clone(); tokio::spawn(async move { - if let Some(cfg) = tls { - let acceptor = tokio_rustls::TlsAcceptor::from(cfg); - match acceptor.accept(stream).await { - Ok(tls_stream) => { - if let Err(e) = crate::transport::uot::handle_tcp_connection(tls_stream, peer_addr, keys, tx, tm).await { - tracing::warn!("UoT TLS connection from {} closed: {}", peer_addr, e); - } - } - Err(e) => { - tracing::warn!("UoT TLS handshake from {} failed: {}", peer_addr, e); - } - } - } else { - if let Err(e) = crate::transport::uot::handle_tcp_connection(stream, peer_addr, keys, tx, tm).await { - tracing::warn!("UoT connection from {} closed: {}", peer_addr, e); - } + if let Err(e) = crate::transport::uot::handle_tcp_connection(stream, peer_addr, keys, tx, tm, reality).await { + tracing::warn!("UoT connection from {} closed: {}", peer_addr, e); } }); } diff --git a/ostp-server/src/transport/uot.rs b/ostp-server/src/transport/uot.rs index 05011cc..634cad7 100644 --- a/ostp-server/src/transport/uot.rs +++ b/ostp-server/src/transport/uot.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use bytes::{BufMut, Bytes, BytesMut}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; use hmac::{Hmac, Mac}; use sha2::Sha256; use std::collections::HashMap; @@ -9,7 +9,16 @@ use std::time::{SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::{mpsc, RwLock}; use tracing::info; +use tokio::net::TcpStream; +use base64::Engine; +use std::pin::Pin; +use std::task::{Context as TaskContext, Poll}; +use chacha20poly1305::{aead::{Aead, KeyInit, Payload}, ChaCha20Poly1305, Nonce}; +use x25519_dalek::{StaticSecret, PublicKey}; + use ostp_core::framing::wss::{encode_wss_frame, decode_wss_frame, WssFrameResult}; +use ostp_core::crypto::reality::{parse_client_hello, derive_keys, verify_session_id}; +use crate::RealityServerConfig; pub async fn handle_tcp_connection( mut stream: S, @@ -17,54 +26,73 @@ pub async fn handle_tcp_connection( shared_keys: Arc>>, udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, tcp_map: Arc>>>, + reality_config: Option>, ) -> Result<()> where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, { - // 1. Read HTTP Handshake - let mut buf = [0u8; 4096]; + let mut initial_buf = vec![0u8; 16384]; let mut header_len = 0; - loop { - let n = stream.read(&mut buf[header_len..]).await?; - if n == 0 { - anyhow::bail!("connection closed before handshake complete"); - } - header_len += n; - if buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - if header_len == buf.len() { - anyhow::bail!("handshake headers too large"); + + // Read the first chunk to determine if it's TLS or HTTP + let n = stream.read(&mut initial_buf).await?; + if n == 0 { + anyhow::bail!("connection closed before data received"); + } + header_len += n; + + // Check if it's a TLS record (0x16 0x03 0x01) + if initial_buf[0] == 0x16 && initial_buf[1] == 0x03 && initial_buf[2] == 0x01 { + if let Some(rc) = reality_config { + return handle_reality_connection(stream, initial_buf[..header_len].to_vec(), peer_addr, shared_keys, udp_tx, tcp_map, rc).await; + } else { + // Received TLS but Reality is not enabled, maybe forward to a default fallback? + // For now, just drop + anyhow::bail!("received TLS but Reality is not configured"); } } - let headers_str = String::from_utf8_lossy(&buf[..header_len]); + // Otherwise, assume it's HTTP (Standard xhttp/wss) + loop { + if initial_buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + if header_len == initial_buf.len() { + anyhow::bail!("handshake headers too large"); + } + let n = stream.read(&mut initial_buf[header_len..]).await?; + if n == 0 { + anyhow::bail!("connection closed before HTTP handshake complete"); + } + header_len += n; + } - // Fast-fail scanner bots + let headers_str = String::from_utf8_lossy(&initial_buf[..header_len]); + let wss = if headers_str.starts_with("GET /wss HTTP/1.1\r\n") { true } else if headers_str.starts_with("GET /stream HTTP/1.1\r\n") { false } else { - send_404(&mut stream).await?; + // Not a valid OSTP path. If Reality fallback was configured but we received plain HTTP, maybe fallback? + // Actually fallback is handled above for TLS. For HTTP, we just 404. + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await; anyhow::bail!("invalid request line"); }; - // Extract Authorization or Cookie for signature + // Extract Authorization let mut signature_base64 = None; for line in headers_str.lines() { let lower = line.to_ascii_lowercase(); if lower.starts_with("authorization: bearer ") { signature_base64 = Some(line[22..].trim().to_string()); - } else if lower.starts_with("cookie: ostp_token=") { - signature_base64 = Some(line[19..].trim().to_string()); } } let sig_b64 = match signature_base64 { Some(s) => s, None => { - send_404(&mut stream).await?; + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await; anyhow::bail!("missing authorization"); } }; @@ -72,13 +100,13 @@ where let sig_bytes = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, &sig_b64) { Ok(b) => b, Err(_) => { - send_404(&mut stream).await?; + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await; anyhow::bail!("invalid base64 signature"); } }; if sig_bytes.len() < 8 { - send_404(&mut stream).await?; + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await; anyhow::bail!("signature too short"); } @@ -88,7 +116,7 @@ where let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); if client_ts > now + 30 || client_ts < now.saturating_sub(60) { - send_404(&mut stream).await?; + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await; anyhow::bail!("timestamp out of bounds (replay protection)"); } @@ -100,8 +128,8 @@ where let mut authenticated = false; for key in keys { - let mut mac = Hmac::::new_from_slice(key.as_bytes()) - .unwrap_or_else(|_| Hmac::::new_from_slice(b"default").unwrap()); + let mut mac = as Mac>::new_from_slice(key.as_bytes()) + .unwrap_or_else(|_| as Mac>::new_from_slice(b"default").unwrap()); mac.update(&ts_bytes); if mac.verify_slice(provided_mac).is_ok() { authenticated = true; @@ -110,7 +138,7 @@ where } if !authenticated { - send_404(&mut stream).await?; + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await; anyhow::bail!("unauthorized (invalid HMAC)"); } @@ -122,17 +150,149 @@ where stream.write_all(response.as_bytes()).await?; } - info!("UoT client authenticated from {}", peer_addr); + info!("UoT client authenticated from {} (xhttp)", peer_addr); + start_uot_loops(stream, peer_addr, wss, tcp_map, udp_tx).await +} + +async fn handle_reality_connection( + mut stream: S, + initial_buf: Vec, + peer_addr: SocketAddr, + _shared_keys: Arc>>, // Note: Reality uses its own keys (sid) + udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, + tcp_map: Arc>>>, + reality_config: Arc, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + // Try to parse ClientHello + let parsed_ch = parse_client_hello(&initial_buf); + + let mut authenticated = false; + let mut data_key_opt = None; + + if let Some(ch) = parsed_ch { + // Validate SNI + if reality_config.sni_list.contains(&ch.sni) { + // Decode Server Private Key + if let Ok(priv_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&reality_config.private_key) { + if priv_bytes.len() == 32 { + let mut secret_bytes = [0u8; 32]; + secret_bytes.copy_from_slice(&priv_bytes); + let server_priv = StaticSecret::from(secret_bytes); + + let shared_secret = server_priv.diffie_hellman(&ch.c_pub); + let (auth_key, data_key) = derive_keys(shared_secret.as_bytes()); + + // Attempt to decrypt Session ID + if let Some((sid, _ts)) = verify_session_id(&auth_key, &ch.session_id) { + // Check if sid is in config + let sid_hex = hex::encode(sid); + if reality_config.sid == sid_hex { + authenticated = true; + data_key_opt = Some(data_key); + } + } + } + } + } + } + + if authenticated { + let data_key = data_key_opt.unwrap(); + info!("Reality client authenticated from {} (sid matched)", peer_addr); + + // Send a fake ServerHello. For now, a static, valid-looking TLS 1.3 ServerHello. + let server_hello = hex::decode("160303007a0200007603030000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000130100002e002b0002030400330024001d0020e29b191a62d0572e9a30d0fb9d08e50bc78d591dfc1dbafbfa533411db1c8e111403030001011603030030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000170303001300000000000000000000000000000000000000").unwrap(); + stream.write_all(&server_hello).await?; + + // At this point, the Reality tunnel is established. We need to wrap the stream with RealityStream. + let reality_stream = RealityStream::new(stream, data_key); + + // But wait! Inside the Reality stream, the client might send an xhttp or wss HTTP request! + // Because xhttp_handshake_and_loop does `GET /wss` *inside* the stream. + // So we must read the HTTP request *from the Reality stream*! + + return process_inner_reality_stream(reality_stream, peer_addr, tcp_map, udp_tx).await; + + } else { + // Fallback: act as a transparent proxy to `reality_config.dest` + info!("Reality fallback triggered for {} -> {}", peer_addr, reality_config.dest); + let mut dest_stream: TcpStream = TcpStream::connect(&reality_config.dest).await?; + dest_stream.write_all(&initial_buf).await?; + + tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?; + return Ok(()); + } +} + +async fn process_inner_reality_stream( + mut stream: S, + peer_addr: SocketAddr, + tcp_map: Arc>>>, + udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + // 1. Read the inner HTTP Handshake + let mut buf = [0u8; 4096]; + let mut header_len = 0; + loop { + let n = stream.read(&mut buf[header_len..]).await?; + if n == 0 { + anyhow::bail!("inner connection closed before handshake complete"); + } + header_len += n; + if buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + if header_len == buf.len() { + anyhow::bail!("inner handshake headers too large"); + } + } + + let headers_str = String::from_utf8_lossy(&buf[..header_len]); + + let wss = if headers_str.starts_with("GET /wss HTTP/1.1\r\n") { + true + } else if headers_str.starts_with("GET /stream HTTP/1.1\r\n") { + false + } else { + anyhow::bail!("invalid inner request line"); + }; + + // We skip signature validation because Reality already authenticated the user via Session ID! + + if wss { + let response = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Ostp-Server: 1\r\n\r\n"; + stream.write_all(response.as_bytes()).await?; + } else { + let response = "HTTP/1.1 200 OK\r\nX-Ostp-Server: 1\r\nContent-Type: application/octet-stream\r\n\r\n"; + stream.write_all(response.as_bytes()).await?; + } + + start_uot_loops(stream, peer_addr, wss, tcp_map, udp_tx).await +} + +async fn start_uot_loops( + stream: S, + peer_addr: SocketAddr, + wss: bool, + tcp_map: Arc>>>, + udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ // Register this connection in the map let (tx, mut rx) = mpsc::channel::(16384); { tcp_map.write().await.insert(peer_addr, tx); } - let headers_end = buf[..header_len].windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let leftover = &buf[headers_end..header_len]; - // Process streams let (mut read_half, mut write_half) = tokio::io::split(stream); @@ -148,87 +308,156 @@ where let mut out = BytesMut::with_capacity(2 + packet.len()); out.put_u16(packet.len() as u16); out.put_slice(&packet); - if write_half.write_all(&out).await.is_err() { - break; - } + if write_half.write_all(&out).await.is_err() { break; } } } - // Cleanup on writer exit - tcp_map_clone.write().await.remove(&peer_clone); + let _ = tcp_map_clone.write().await.remove(&peer_clone); }); - // Reader loop - let mut buffer = BytesMut::from(leftover); - loop { + // Spawn reader task + let tcp_map_clone2 = tcp_map.clone(); + let reader_task = tokio::spawn(async move { if wss { - match decode_wss_frame(&buffer) { - WssFrameResult::Incomplete => { - let mut temp = [0u8; 1024]; - match read_half.read(&mut temp).await { - Ok(0) | Err(_) => { - writer_task.abort(); - tcp_map.write().await.remove(&peer_addr); - return Ok(()); + let mut read_buf = BytesMut::with_capacity(65536); + let mut tmp = [0u8; 8192]; + loop { + match read_half.read(&mut tmp).await { + Ok(0) => break, + Ok(n) => { + read_buf.put_slice(&tmp[..n]); + loop { + match decode_wss_frame(&mut read_buf) { + WssFrameResult::Frame { payload, total_len } => { + if udp_tx.send((Bytes::from(payload), peer_clone)).await.is_err() { return; } + read_buf.advance(total_len); + } + WssFrameResult::Incomplete => break, } - Ok(n) => buffer.extend_from_slice(&temp[..n]), - } - } - WssFrameResult::Frame { payload, total_len } => { - let _ = buffer.split_to(total_len); - if udp_tx.send((Bytes::from(payload), peer_addr)).await.is_err() { - break; } } + Err(_) => break, } + } } else { - while buffer.len() < 2 { - let mut temp = [0u8; 1024]; - match read_half.read(&mut temp).await { - Ok(0) | Err(_) => { - writer_task.abort(); - tcp_map.write().await.remove(&peer_addr); - return Ok(()); + let mut len_buf = [0u8; 2]; + loop { + if read_half.read_exact(&mut len_buf).await.is_err() { break; } + let len = u16::from_be_bytes(len_buf) as usize; + if len > 65535 { break; } + let mut data = vec![0u8; len]; + if read_half.read_exact(&mut data).await.is_err() { break; } + if udp_tx.send((Bytes::from(data), peer_clone)).await.is_err() { break; } + } + } + let _ = tcp_map_clone2.write().await.remove(&peer_clone); + }); + + let _ = tokio::join!(writer_task, reader_task); + Ok(()) +} + +// ----------------------------------------------------------------------- +// RealityStream: Wraps a TCP stream in fake TLS Application Data Records +// ----------------------------------------------------------------------- +struct RealityStream { + inner: S, + data_key: ChaCha20Poly1305, + rx_nonce: u64, + tx_nonce: u64, + rx_buf: BytesMut, +} + +impl RealityStream { + fn new(inner: S, data_key: ChaCha20Poly1305) -> Self { + Self { + inner, + data_key, + rx_nonce: 0, + tx_nonce: 0, + rx_buf: BytesMut::with_capacity(16384), + } + } + + fn make_nonce(seq: u64) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[4..12].copy_from_slice(&seq.to_le_bytes()); + nonce + } +} + +impl tokio::io::AsyncRead for RealityStream { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll> { + loop { + if self.rx_buf.len() >= 5 { + let len = u16::from_be_bytes([self.rx_buf[3], self.rx_buf[4]]) as usize; + if self.rx_buf.len() >= 5 + len { + if self.rx_buf[0] != 0x17 { + return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "expected application data record"))); + } + + let ciphertext = &self.rx_buf[5..5+len]; + let nonce_bytes = Self::make_nonce(self.rx_nonce); + let nonce = Nonce::from_slice(&nonce_bytes); + + match self.data_key.decrypt(nonce, ciphertext) { + Ok(plaintext) => { + self.rx_nonce += 1; + let out_len = std::cmp::min::(buf.remaining(), plaintext.len()); + buf.put_slice(&plaintext[..out_len]); + self.rx_buf.advance(5 + len); + return Poll::Ready(Ok(())); + } + Err(_) => return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reality decrypt failed"))), } - Ok(n) => buffer.extend_from_slice(&temp[..n]), } } - let len = u16::from_be_bytes([buffer[0], buffer[1]]) as usize; - - while buffer.len() < 2 + len { - let mut temp = [0u8; 1024]; - match read_half.read(&mut temp).await { - Ok(0) | Err(_) => { - writer_task.abort(); - tcp_map.write().await.remove(&peer_addr); - return Ok(()); - } - Ok(n) => buffer.extend_from_slice(&temp[..n]), + let mut read_buf = [0u8; 4096]; + let mut tokio_buf = tokio::io::ReadBuf::new(&mut read_buf); + match Pin::new(&mut self.inner).poll_read(cx, &mut tokio_buf) { + Poll::Ready(Ok(())) => { + if tokio_buf.filled().is_empty() { return Poll::Ready(Ok(())); } + self.rx_buf.put_slice(tokio_buf.filled()); } - } - - let packet = buffer.split_to(2 + len); - if udp_tx.send((Bytes::from(packet[2..].to_vec()), peer_addr)).await.is_err() { - break; + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, } } } - - writer_task.abort(); - tcp_map.write().await.remove(&peer_addr); - Ok(()) } -async fn send_404(stream: &mut S) -> Result<()> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, -{ - let body = "Not Found"; - let resp = format!( - "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()).await; - Ok(()) +impl tokio::io::AsyncWrite for RealityStream { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll> { + let nonce_bytes = Self::make_nonce(self.tx_nonce); + let nonce = Nonce::from_slice(&nonce_bytes); + + match self.data_key.encrypt(nonce, buf) { + Ok(ciphertext) => { + let mut record: BytesMut = BytesMut::with_capacity(5 + ciphertext.len()); + record.put_u8(0x17); + record.put_u16(0x0303); + record.put_u16(ciphertext.len() as u16); + record.put_slice(&ciphertext); + + match tokio::io::AsyncWrite::poll_write(Pin::new(&mut self.inner), cx, &record) { + Poll::Ready(Ok(n)) if n == record.len() => { + self.tx_nonce += 1; + Poll::Ready(Ok(buf.len())) + } + Poll::Ready(Ok(_n)) => Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "partial write not supported"))), + Poll::Ready(Err(e)) => Poll::Ready(Err(e)), + Poll::Pending => Poll::Pending, + } + } + Err(_) => Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "reality encrypt failed"))), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } } diff --git a/ostp/Cargo.toml b/ostp/Cargo.toml index 5ec9c6e..bf2e120 100644 --- a/ostp/Cargo.toml +++ b/ostp/Cargo.toml @@ -18,4 +18,4 @@ rand.workspace = true url = "2.5" tracing.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] } -snow.workspace = true +ostp-core = { version = "0.2.68", path = "../ostp-core" } diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 18165a3..cb8003a 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -137,10 +137,10 @@ fn generate_reality_keys() -> (String, String, String) { use rand::RngCore; use base64::Engine; - let builder = snow::Builder::new("Noise_NN_25519_ChaChaPoly_BLAKE2s".parse().unwrap()); - let keypair = builder.generate_keypair().expect("failed to generate reality keys"); - let priv_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&keypair.private); - let pub_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&keypair.public); + let (priv_key, pub_key) = ostp_core::crypto::reality::generate_x25519_keypair(); + + let priv_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&priv_key.to_bytes()); + let pub_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_key.as_bytes()); let mut sid_bytes = [0u8; 8]; rand::thread_rng().fill_bytes(&mut sid_bytes);