From 37d0184a0bd99c9f22b45898b057fde408a6929b Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:50:08 +0300 Subject: [PATCH] Implement shared MTProto framing and ME address role separation Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/types.rs | 2 +- src/protocol/constants.rs | 10 +- src/protocol/framing.rs | 92 ++++++++++++ src/protocol/mod.rs | 1 + src/proxy/handshake.rs | 135 ++---------------- src/proxy/handshake/tls_auth.rs | 126 ++++++++++++++++ src/proxy/middle_relay/d2c.rs | 19 ++- src/proxy/middle_relay/idle/read.rs | 6 +- src/stream/frame_codec.rs | 41 +++--- src/stream/frame_stream.rs | 83 ++++++++--- src/transport/middle_proxy/handshake.rs | 3 + src/transport/middle_proxy/send.rs | 6 +- .../tests/send_adversarial_tests.rs | 75 ++++++++++ 13 files changed, 415 insertions(+), 184 deletions(-) create mode 100644 src/protocol/framing.rs create mode 100644 src/proxy/handshake/tls_auth.rs diff --git a/src/config/types.rs b/src/config/types.rs index e0f7b04..6b95260 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -429,7 +429,7 @@ pub struct GeneralConfig { pub ad_tag: Option, /// Public IP override for middle-proxy NAT environments. - /// When set, this IP is used in ME key derivation and RPC_PROXY_REQ "our_addr". + /// When set, this IP is used in ME key derivation and local address translation. #[serde(default)] pub middle_proxy_nat_ip: Option, diff --git a/src/protocol/constants.rs b/src/protocol/constants.rs index 6f6c3ae..19246aa 100644 --- a/src/protocol/constants.rs +++ b/src/protocol/constants.rs @@ -5,6 +5,9 @@ use std::net::{IpAddr, Ipv4Addr}; use crate::crypto::SecureRandom; +use crate::protocol::framing::{ + secure_version_d_body_len_from_wire_len, secure_version_d_padding_len, +}; use std::sync::LazyLock; // ============= Telegram Datacenters ============= @@ -239,10 +242,7 @@ pub fn is_valid_secure_payload_len(data_len: usize) -> bool { /// Secure mode cannot distinguish full-word padding from payload, so only the /// non-aligned tail bytes are stripped. pub fn secure_payload_len_from_wire_len(wire_len: usize) -> Option { - if wire_len < 4 { - return None; - } - Some(wire_len - (wire_len % 4)) + secure_version_d_body_len_from_wire_len(wire_len) } /// Generate padding length for Secure Intermediate protocol. @@ -252,7 +252,7 @@ pub fn secure_padding_len(data_len: usize, rng: &SecureRandom) -> usize { is_valid_secure_payload_len(data_len), "Secure payload must be 4-byte aligned, got {data_len}" ); - rng.range(16) + secure_version_d_padding_len(rng) } // ============= Timeouts ============= diff --git a/src/protocol/framing.rs b/src/protocol/framing.rs new file mode 100644 index 0000000..dd63e89 --- /dev/null +++ b/src/protocol/framing.rs @@ -0,0 +1,92 @@ +//! Shared MTProto transport framing helpers. + +use crate::crypto::SecureRandom; + +/// QuickACK marker bit used by Intermediate and Secure Intermediate headers. +pub(crate) const INTERMEDIATE_QUICKACK_FLAG: u32 = 0x8000_0000; + +/// Payload length mask used by Intermediate and Secure Intermediate headers. +pub(crate) const INTERMEDIATE_WIRE_LEN_MASK: u32 = 0x7fff_ffff; + +/// Maximum random tail length used by Telegram Desktop VersionD packets. +pub(crate) const SECURE_VERSION_D_PADDING_MAX: usize = 15; + +/// Parsed Intermediate/Secure Intermediate length header. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct IntermediateHeader { + /// Payload length on the wire, excluding the four-byte header. + pub(crate) wire_len: usize, + /// Whether the QuickACK marker bit was set in the length header. + pub(crate) quickack: bool, +} + +/// Parse an Intermediate/Secure Intermediate length header. +pub(crate) fn parse_intermediate_header(header: [u8; 4]) -> IntermediateHeader { + let raw = u32::from_le_bytes(header); + IntermediateHeader { + wire_len: (raw & INTERMEDIATE_WIRE_LEN_MASK) as usize, + quickack: (raw & INTERMEDIATE_QUICKACK_FLAG) != 0, + } +} + +/// Encode an Intermediate/Secure Intermediate length header. +pub(crate) fn encode_intermediate_header(wire_len: usize, quickack: bool) -> Option { + if wire_len > INTERMEDIATE_WIRE_LEN_MASK as usize { + return None; + } + + let mut raw = u32::try_from(wire_len).ok()?; + if quickack { + raw |= INTERMEDIATE_QUICKACK_FLAG; + } + Some(raw) +} + +/// Recover the VersionD body length visible to MTProto from the encrypted wire length. +pub(crate) fn secure_version_d_body_len_from_wire_len(wire_len: usize) -> Option { + if wire_len < 4 { + return None; + } + + Some(wire_len - (wire_len % 4)) +} + +/// Generate Telegram Desktop-compatible VersionD random tail length. +pub(crate) fn secure_version_d_padding_len(rng: &SecureRandom) -> usize { + rng.range(SECURE_VERSION_D_PADDING_MAX + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intermediate_header_roundtrip_preserves_quickack_zero_length() { + let encoded = encode_intermediate_header(0, true).unwrap(); + assert_eq!(encoded, INTERMEDIATE_QUICKACK_FLAG); + + let parsed = parse_intermediate_header(encoded.to_le_bytes()); + assert_eq!(parsed.wire_len, 0); + assert!(parsed.quickack); + } + + #[test] + fn intermediate_header_rejects_lengths_above_31_bits() { + assert_eq!( + encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize, false), + Some(INTERMEDIATE_WIRE_LEN_MASK) + ); + assert_eq!( + encode_intermediate_header(INTERMEDIATE_WIRE_LEN_MASK as usize + 1, false), + None + ); + } + + #[test] + fn secure_version_d_body_len_strips_only_non_word_tail() { + assert_eq!(secure_version_d_body_len_from_wire_len(3), None); + assert_eq!(secure_version_d_body_len_from_wire_len(8), Some(8)); + assert_eq!(secure_version_d_body_len_from_wire_len(11), Some(8)); + assert_eq!(secure_version_d_body_len_from_wire_len(12), Some(12)); + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 9ffff7c..63e75b7 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -2,6 +2,7 @@ pub mod constants; pub mod frame; +pub(crate) mod framing; pub mod obfuscation; pub mod tls; pub mod tls_fingerprint; diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 084fadc..f9f55de 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -4,7 +4,6 @@ use dashmap::DashMap; use dashmap::mapref::entry::Entry; -use hmac::{Hmac, Mac}; #[cfg(test)] use std::collections::HashSet; use std::collections::hash_map::DefaultHasher; @@ -33,8 +32,10 @@ use crate::stream::{CryptoReader, CryptoWriter, FakeTlsReader, FakeTlsWriter}; use crate::tls_front::{TlsFrontCache, emulator}; #[cfg(test)] use rand::RngExt; -use sha2::Sha256; -use subtle::ConstantTimeEq; + +mod tls_auth; + +use self::tls_auth::{parse_tls_auth_material, validate_tls_secret_candidate}; const ACCESS_SECRET_BYTES: usize = 16; const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5; @@ -58,8 +59,6 @@ const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8; const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64; const RECENT_USER_RING_SCAN_LIMIT: usize = 32; -type HmacSha256 = Hmac; - #[cfg(test)] const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1; #[cfg(not(test))] @@ -104,23 +103,6 @@ fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> b true } -#[derive(Clone, Copy)] -struct ParsedTlsAuthMaterial { - digest: [u8; tls::TLS_DIGEST_LEN], - session_id: [u8; 32], - session_id_len: usize, - now: i64, - ignore_time_skew: bool, - boot_time_cap_secs: u32, -} - -#[derive(Clone, Copy)] -struct TlsCandidateValidation { - digest: [u8; tls::TLS_DIGEST_LEN], - session_id: [u8; 32], - session_id_len: usize, -} - struct MtprotoCandidateValidation { proto_tag: ProtoTag, dc_idx: i16, @@ -251,104 +233,6 @@ fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) -> total_users.min(cap.max(1)) } -fn parse_tls_auth_material( - handshake: &[u8], - ignore_time_skew: bool, - replay_window_secs: u64, -) -> Option { - if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 { - return None; - } - - let digest: [u8; tls::TLS_DIGEST_LEN] = handshake - [tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] - .try_into() - .ok()?; - - let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN; - let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?); - if session_id_len > 32 { - return None; - } - let session_id_start = session_id_len_pos + 1; - if handshake.len() < session_id_start + session_id_len { - return None; - } - - let mut session_id = [0u8; 32]; - session_id[..session_id_len] - .copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]); - - let now = if !ignore_time_skew { - let d = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok()?; - i64::try_from(d.as_secs()).ok()? - } else { - 0_i64 - }; - - let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX); - let boot_time_cap_secs = if ignore_time_skew { - 0 - } else { - tls::BOOT_TIME_MAX_SECS - .min(replay_window_u32) - .min(tls::BOOT_TIME_COMPAT_MAX_SECS) - }; - - Some(ParsedTlsAuthMaterial { - digest, - session_id, - session_id_len, - now, - ignore_time_skew, - boot_time_cap_secs, - }) -} - -fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> [u8; 32] { - let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); - mac.update(&handshake[..tls::TLS_DIGEST_POS]); - mac.update(&[0u8; tls::TLS_DIGEST_LEN]); - mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]); - mac.finalize().into_bytes().into() -} - -fn validate_tls_secret_candidate( - parsed: &ParsedTlsAuthMaterial, - handshake: &[u8], - secret: &[u8], -) -> Option { - let computed = compute_tls_hmac_zeroed_digest(secret, handshake); - if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) { - return None; - } - - let timestamp = u32::from_le_bytes([ - parsed.digest[28] ^ computed[28], - parsed.digest[29] ^ computed[29], - parsed.digest[30] ^ computed[30], - parsed.digest[31] ^ computed[31], - ]); - - if !parsed.ignore_time_skew { - let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs; - if !is_boot_time { - let time_diff = parsed.now - i64::from(timestamp); - if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) { - return None; - } - } - } - - Some(TlsCandidateValidation { - digest: parsed.digest, - session_id: parsed.session_id, - session_id_len: parsed.session_id_len, - }) -} - fn validate_mtproto_secret_candidate( handshake: &[u8; HANDSHAKE_LEN], dec_prekey: &[u8; PREKEY_LEN], @@ -1857,7 +1741,16 @@ where return HandshakeResult::BadClient { reader, writer }; } - let validation = matched_validation.expect("validation must exist when matched"); + let Some(validation) = matched_validation else { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %matched_user, + "MTProto handshake matched user without validation material" + ); + return HandshakeResult::BadClient { reader, writer }; + }; if config .access diff --git a/src/proxy/handshake/tls_auth.rs b/src/proxy/handshake/tls_auth.rs new file mode 100644 index 0000000..2feb666 --- /dev/null +++ b/src/proxy/handshake/tls_auth.rs @@ -0,0 +1,126 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +use crate::protocol::tls; + +type HmacSha256 = Hmac; + +/// Parsed TLS authentication material extracted from a ClientHello candidate. +#[derive(Clone, Copy)] +pub(super) struct ParsedTlsAuthMaterial { + digest: [u8; tls::TLS_DIGEST_LEN], + session_id: [u8; 32], + session_id_len: usize, + now: i64, + ignore_time_skew: bool, + boot_time_cap_secs: u32, +} + +/// Successful TLS secret validation output used by the handshake state machine. +#[derive(Clone, Copy)] +pub(super) struct TlsCandidateValidation { + pub(super) digest: [u8; tls::TLS_DIGEST_LEN], + pub(super) session_id: [u8; 32], + pub(super) session_id_len: usize, +} + +/// Parse TLS auth digest and session-id material from a candidate handshake. +pub(super) fn parse_tls_auth_material( + handshake: &[u8], + ignore_time_skew: bool, + replay_window_secs: u64, +) -> Option { + if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 { + return None; + } + + let digest: [u8; tls::TLS_DIGEST_LEN] = handshake + [tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] + .try_into() + .ok()?; + + let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN; + let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?); + if session_id_len > 32 { + return None; + } + let session_id_start = session_id_len_pos + 1; + if handshake.len() < session_id_start + session_id_len { + return None; + } + + let mut session_id = [0u8; 32]; + session_id[..session_id_len] + .copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]); + + let now = if !ignore_time_skew { + let d = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + i64::try_from(d.as_secs()).ok()? + } else { + 0_i64 + }; + + let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX); + let boot_time_cap_secs = if ignore_time_skew { + 0 + } else { + tls::BOOT_TIME_MAX_SECS + .min(replay_window_u32) + .min(tls::BOOT_TIME_COMPAT_MAX_SECS) + }; + + Some(ParsedTlsAuthMaterial { + digest, + session_id, + session_id_len, + now, + ignore_time_skew, + boot_time_cap_secs, + }) +} + +fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> Option<[u8; 32]> { + let mut mac = HmacSha256::new_from_slice(secret).ok()?; + mac.update(&handshake[..tls::TLS_DIGEST_POS]); + mac.update(&[0u8; tls::TLS_DIGEST_LEN]); + mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]); + Some(mac.finalize().into_bytes().into()) +} + +/// Validate a candidate secret against parsed TLS authentication material. +pub(super) fn validate_tls_secret_candidate( + parsed: &ParsedTlsAuthMaterial, + handshake: &[u8], + secret: &[u8], +) -> Option { + let computed = compute_tls_hmac_zeroed_digest(secret, handshake)?; + if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) { + return None; + } + + let timestamp = u32::from_le_bytes([ + parsed.digest[28] ^ computed[28], + parsed.digest[29] ^ computed[29], + parsed.digest[30] ^ computed[30], + parsed.digest[31] ^ computed[31], + ]); + + if !parsed.ignore_time_skew { + let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs; + if !is_boot_time { + let time_diff = parsed.now - i64::from(timestamp); + if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) { + return None; + } + } + } + + Some(TlsCandidateValidation { + digest: parsed.digest, + session_id: parsed.session_id, + session_id_len: parsed.session_id_len, + }) +} diff --git a/src/proxy/middle_relay/d2c.rs b/src/proxy/middle_relay/d2c.rs index 92fe3c1..d227aa9 100644 --- a/src/proxy/middle_relay/d2c.rs +++ b/src/proxy/middle_relay/d2c.rs @@ -276,20 +276,17 @@ pub(in crate::proxy::middle_relay) fn compute_intermediate_secure_wire_len( let wire_len = data_len .checked_add(padding_len) .ok_or_else(|| ProxyError::Proxy("Frame length overflow".into()))?; - if wire_len > 0x7fff_ffffusize { - return Err(ProxyError::Proxy(format!( - "Intermediate/Secure frame too large: {wire_len}" - ))); - } - + let len_val = + crate::protocol::framing::encode_intermediate_header(wire_len, quickack).ok_or_else( + || { + ProxyError::Proxy(format!( + "Intermediate/Secure frame too large: {wire_len}" + )) + }, + )?; let total = 4usize .checked_add(wire_len) .ok_or_else(|| ProxyError::Proxy("Frame buffer size overflow".into()))?; - let mut len_val = u32::try_from(wire_len) - .map_err(|_| ProxyError::Proxy("Frame length conversion overflow".into()))?; - if quickack { - len_val |= 0x8000_0000; - } Ok((len_val, total)) } diff --git a/src/proxy/middle_relay/idle/read.rs b/src/proxy/middle_relay/idle/read.rs index 80ca0cc..652041c 100644 --- a/src/proxy/middle_relay/idle/read.rs +++ b/src/proxy/middle_relay/idle/read.rs @@ -236,10 +236,10 @@ where } Err(e) => return Err(e), } - let quickack = (len_buf[3] & 0x80) != 0; + let header = crate::protocol::framing::parse_intermediate_header(len_buf); ( - (u32::from_le_bytes(len_buf) & 0x7fff_ffff) as usize, - quickack, + header.wire_len, + header.quickack, Some(len_buf), ) } diff --git a/src/stream/frame_codec.rs b/src/stream/frame_codec.rs index cbec951..ddf4bde 100644 --- a/src/stream/frame_codec.rs +++ b/src/stream/frame_codec.rs @@ -15,6 +15,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ ProtoTag, is_valid_secure_payload_len, secure_padding_len, secure_payload_len_from_wire_len, }; +use crate::protocol::framing::{encode_intermediate_header, parse_intermediate_header}; // ============= Unified Codec ============= @@ -197,13 +198,9 @@ fn decode_intermediate(src: &mut BytesMut, max_size: usize) -> io::Result