diff --git a/Cargo.toml b/Cargo.toml index bb45cb9..7b95bf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,4 +98,3 @@ harness = false [profile.release] lto = "fat" codegen-units = 1 - diff --git a/docs/Architecture/Fronting-splitting/TLS_FRONT_PROFILE_FIDELITY.en.md b/docs/Architecture/Fronting-splitting/TLS_FRONT_PROFILE_FIDELITY.en.md new file mode 100644 index 0000000..21d6558 --- /dev/null +++ b/docs/Architecture/Fronting-splitting/TLS_FRONT_PROFILE_FIDELITY.en.md @@ -0,0 +1,225 @@ +# TLS Front Profile Fidelity + +## Overview + +This document describes how Telemt reuses captured TLS behavior in the FakeTLS server flight and how to validate the result on a real deployment. + +When TLS front emulation is enabled, Telemt can capture useful server-side TLS behavior from the selected origin and reuse that behavior in the emulated success path. The goal is not to reproduce the origin byte-for-byte, but to reduce stable synthetic traits and make the emitted server flight structurally closer to the captured profile. + +## Why this change exists + +The project already captures useful server-side TLS behavior in the TLS front fetch path: + +- `change_cipher_spec_count` +- `app_data_record_sizes` +- `ticket_record_sizes` + +Before this change, the emulator used only part of that information. This left a gap between captured origin behavior and emitted FakeTLS server flight. + +## What is implemented + +- The emulator now replays the observed `ChangeCipherSpec` count from the fetched behavior profile. +- The emulator now replays observed ticket-like tail ApplicationData record sizes when raw or merged TLS profile data is available. +- The emulator now preserves more of the profiled encrypted-flight structure instead of collapsing it into a smaller synthetic shape. +- The emulator still falls back to the previous synthetic behavior when the cached profile does not contain raw TLS behavior information. +- Operator-configured `tls_new_session_tickets` still works as an additive fallback when the profile does not provide enough tail records. + +## Practical benefit + +- Reduced distinguishability between profiled origin TLS behavior and emulated TLS behavior. +- Lower chance of stable server-flight fingerprints caused by fixed CCS count or synthetic-only tail record sizes. +- Better reuse of already captured TLS profile data without changing MTProto logic, KDF routing, or transport architecture. + +## Limitations + +This mechanism does not aim to make Telemt byte-identical to the origin server. + +It also does not change: + +- MTProto business logic; +- KDF routing behavior; +- the overall transport architecture. + +The practical goal is narrower: + +- reuse more captured profile data; +- reduce fixed synthetic behavior in the server flight; +- preserve a valid FakeTLS success path while changing the emitted shape on the wire. + +## Validation targets + +- Correct count of emulated `ChangeCipherSpec` records. +- Correct replay of observed ticket-tail record sizes. +- No regression in existing ALPN and payload-placement behavior. + +## How to validate the result + +Recommended validation consists of two layers: + +- focused unit and security tests for CCS-count replay and ticket-tail replay; +- real packet-capture comparison for a selected origin and a successful FakeTLS session. + +When testing on the network, the expected result is: + +- a valid FakeTLS and MTProto success path is preserved; +- the early encrypted server flight changes shape when richer profile data is available; +- the change is visible on the wire without changing MTProto logic or transport architecture. + +This validation is intended to show better reuse of captured TLS profile data. +It is not intended to prove byte-level equivalence with the real origin server. + +## How to test on a real deployment + +The strongest practical validation is a side-by-side trace comparison between: + +- a real TLS origin server used as `mask_host`; +- a Telemt FakeTLS success-path connection for the same SNI; +- optional captures from different Telemt builds or configurations. + +The purpose of the comparison is to inspect the shape of the server flight: + +- record order; +- count of `ChangeCipherSpec` records; +- count and grouping of early encrypted `ApplicationData` records; +- lengths of tail or continuation `ApplicationData` records. + +## Recommended environment + +Use a Linux host or Docker container for the cleanest reproduction. + +Recommended setup: + +1. One Telemt instance. +2. One real HTTPS origin as `mask_host`. +3. One Telegram client configured with an `ee` proxy link for the Telemt instance. +4. `tcpdump` or Wireshark available for capture analysis. + +## Step-by-step test procedure + +### 1. Prepare the origin + +1. Choose a real HTTPS origin. +2. Set both `censorship.tls_domain` and `censorship.mask_host` to that hostname. +3. Confirm that a direct TLS request works: + +```bash +openssl s_client -connect ORIGIN_IP:443 -servername YOUR_DOMAIN = mask_relay_idle_timeout_ms`. - **Description**: Wall-clock cap for the full masking relay on non-MTProto fallback paths. Raise when the mask target is a long-lived service (e.g. WebSocket). Default: 60 000 ms (1 minute). - **Example**: @@ -2552,8 +2550,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche [censorship] mask_relay_timeout_ms = 60000 ``` -## "cfg-censorship-mask_relay_idle_timeout_ms" -- `mask_relay_idle_timeout_ms` +## mask_relay_idle_timeout_ms - **Constraints / validation**: Should be `<= mask_relay_timeout_ms`. - **Description**: Per-read idle timeout on masking relay and drain paths. Limits resource consumption by slow-loris attacks and port scanners. A read call stalling beyond this value is treated as an abandoned connection. Default: 5 000 ms (5 s). - **Example**: @@ -2562,8 +2559,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche [censorship] mask_relay_idle_timeout_ms = 5000 ``` -## "cfg-censorship-mask_classifier_prefetch_timeout_ms" -- `mask_classifier_prefetch_timeout_ms` +## mask_classifier_prefetch_timeout_ms - **Constraints / validation**: Must be within `[5, 50]` (milliseconds). - **Description**: Timeout budget (ms) for extending fragmented initial classifier window on masking fallback. - **Example**: diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index bdd2dac..eb00ffc 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -2299,6 +2299,8 @@ | [`mask_shape_above_cap_blur`](#mask_shape_above_cap_blur) | `bool` | `false` | | [`mask_shape_above_cap_blur_max_bytes`](#mask_shape_above_cap_blur_max_bytes) | `usize` | `512` | | [`mask_relay_max_bytes`](#mask_relay_max_bytes) | `usize` | `5242880` | +| [`mask_relay_timeout_ms`](mask_relay_timeout_ms) | `u64` | `60_000` | +| [`mask_relay_idle_timeout_ms`](mask_relay_idle_timeout_ms) | `u64` | `5_000` | | [`mask_classifier_prefetch_timeout_ms`](#mask_classifier_prefetch_timeout_ms) | `u64` | `5` | | [`mask_timing_normalization_enabled`](#mask_timing_normalization_enabled) | `bool` | `false` | | [`mask_timing_normalization_floor_ms`](#mask_timing_normalization_floor_ms) | `u64` | `0` | @@ -2544,6 +2546,26 @@ [censorship] mask_relay_max_bytes = 5242880 ``` + +## mask_relay_timeout_ms + - **Constraints / validation**: Должно быть больше или равно `mask_relay_idle_timeout_ms`. + - **Description**: Жёсткий лимит по реальному времени (wall-clock) для полного маскирующего проксирования на fallback-путях без MTProto. Увеличивайте значение, если целевой сервис маскирования является долгоживущим (например, WebSocket-соединение). Значение по умолчанию: 60 000 мс (1 минута). + - **Example**: + + ```toml + [censorship] + mask_relay_timeout_ms = 60000 + ``` +## mask_relay_idle_timeout_ms + - **Constraints / validation**: Должно быть меньше или равно `mask_relay_timeout_ms`. + - **Description**: Тайм-аут простоя на каждую операцию чтения (per-read idle timeout) в маскирующем прокси и drain-пайплайнах. Ограничивает потребление ресурсов при атаках типа slow-loris и сканировании портов. Если операция чтения блокируется дольше заданного времени, соединение считается заброшенным и закрывается. Значение по умолчанию: 5 000 мс (5 с). + - **Example**: + + ```toml + [censorship] + mask_relay_idle_timeout_ms = 5000 + ``` + ## mask_classifier_prefetch_timeout_ms - **Ограничения / валидация**: Должно быть в пределах `[5, 50]` (миллисекунд). - **Описание**: Лимит времени ожидания (в миллисекундах) для расширения первых входящих данных в режиме fallback-маскировки. diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 6f7de16..15d8812 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -9,6 +9,7 @@ use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use nix::fcntl::{Flock, FlockArg}; +use nix::errno::Errno; use nix::unistd::{self, ForkResult, Gid, Pid, Uid, chdir, close, fork, getpid, setsid}; use tracing::{debug, info, warn}; @@ -337,6 +338,31 @@ fn is_process_running(pid: i32) -> bool { nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok() } +// macOS gates nix::unistd::setgroups differently in the current dependency set, +// so call libc directly there while preserving the original nix path elsewhere. +fn set_supplementary_groups(gid: Gid) -> Result<(), nix::Error> { + #[cfg(target_os = "macos")] + { + let groups = [gid.as_raw()]; + let rc = unsafe { + libc::setgroups( + i32::try_from(groups.len()).expect("single supplementary group must fit in c_int"), + groups.as_ptr(), + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(Errno::last()) + } + } + + #[cfg(not(target_os = "macos"))] + { + unistd::setgroups(&[gid]) + } +} + /// Drops privileges to the specified user and group. /// /// This should be called after binding privileged ports but before entering @@ -368,7 +394,7 @@ pub fn drop_privileges( if let Some(gid) = target_gid { unistd::setgid(gid).map_err(DaemonError::PrivilegeDrop)?; - unistd::setgroups(&[gid]).map_err(DaemonError::PrivilegeDrop)?; + set_supplementary_groups(gid).map_err(DaemonError::PrivilegeDrop)?; info!(gid = gid.as_raw(), "Dropped group privileges"); } diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 290e203..2aa83c8 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -11,6 +11,7 @@ use crc32fast::Hasher; const MIN_APP_DATA: usize = 64; const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE; +const MAX_TICKET_RECORDS: usize = 4; fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec { sizes @@ -62,6 +63,51 @@ fn ensure_payload_capacity(mut sizes: Vec, payload_len: usize) -> Vec Vec { + match cached.behavior_profile.source { + TlsProfileSource::Raw | TlsProfileSource::Merged => { + if !cached.behavior_profile.app_data_record_sizes.is_empty() { + return cached.behavior_profile.app_data_record_sizes.clone(); + } + } + TlsProfileSource::Default | TlsProfileSource::Rustls => {} + } + + let mut sizes = cached.app_data_records_sizes.clone(); + if sizes.is_empty() { + sizes.push(cached.total_app_data_len.max(1024)); + } + sizes +} + +fn emulated_change_cipher_spec_count(cached: &CachedTlsData) -> usize { + usize::from(cached.behavior_profile.change_cipher_spec_count.max(1)) +} + +fn emulated_ticket_record_sizes( + cached: &CachedTlsData, + new_session_tickets: u8, + rng: &SecureRandom, +) -> Vec { + let mut sizes = match cached.behavior_profile.source { + TlsProfileSource::Raw | TlsProfileSource::Merged => { + cached.behavior_profile.ticket_record_sizes.clone() + } + TlsProfileSource::Default | TlsProfileSource::Rustls => Vec::new(), + }; + + let target_count = sizes + .len() + .max(usize::from(new_session_tickets.min(MAX_TICKET_RECORDS as u8))) + .min(MAX_TICKET_RECORDS); + + while sizes.len() < target_count { + sizes.push(rng.range(48) + 48); + } + + sizes +} + fn build_compact_cert_info_payload(cert_info: &ParsedCertificateInfo) -> Option> { let mut fields = Vec::new(); @@ -180,39 +226,21 @@ pub fn build_emulated_server_hello( server_hello.extend_from_slice(&message); // --- ChangeCipherSpec --- - let change_cipher_spec = [ - TLS_RECORD_CHANGE_CIPHER, - TLS_VERSION[0], - TLS_VERSION[1], - 0x00, - 0x01, - 0x01, - ]; + let change_cipher_spec_count = emulated_change_cipher_spec_count(cached); + let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6); + for _ in 0..change_cipher_spec_count { + change_cipher_spec.extend_from_slice(&[ + TLS_RECORD_CHANGE_CIPHER, + TLS_VERSION[0], + TLS_VERSION[1], + 0x00, + 0x01, + 0x01, + ]); + } // --- ApplicationData (fake encrypted records) --- - let sizes = match cached.behavior_profile.source { - TlsProfileSource::Raw | TlsProfileSource::Merged => cached - .app_data_records_sizes - .first() - .copied() - .or_else(|| { - cached - .behavior_profile - .app_data_record_sizes - .first() - .copied() - }) - .map(|size| vec![size]) - .unwrap_or_else(|| vec![cached.total_app_data_len.max(1024)]), - _ => { - let mut sizes = cached.app_data_records_sizes.clone(); - if sizes.is_empty() { - sizes.push(cached.total_app_data_len.max(1024)); - } - sizes - } - }; - let mut sizes = jitter_and_clamp_sizes(&sizes, rng); + let mut sizes = jitter_and_clamp_sizes(&emulated_app_data_sizes(cached), rng); let compact_payload = cached .cert_info .as_ref() @@ -299,17 +327,13 @@ pub fn build_emulated_server_hello( // --- Combine --- // Optional NewSessionTicket mimic records (opaque ApplicationData for fingerprint). let mut tickets = Vec::new(); - let ticket_count = new_session_tickets.min(4); - if ticket_count > 0 { - for _ in 0..ticket_count { - let ticket_len: usize = rng.range(48) + 48; - let mut rec = Vec::with_capacity(5 + ticket_len); + for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) { + let mut rec = Vec::with_capacity(5 + ticket_len); rec.push(TLS_RECORD_APPLICATION); rec.extend_from_slice(&TLS_VERSION); rec.extend_from_slice(&(ticket_len as u16).to_be_bytes()); rec.extend_from_slice(&rng.bytes(ticket_len)); tickets.extend_from_slice(&rec); - } } let mut response = Vec::with_capacity( @@ -334,6 +358,10 @@ pub fn build_emulated_server_hello( #[path = "tests/emulator_security_tests.rs"] mod security_tests; +#[cfg(test)] +#[path = "tests/emulator_profile_fidelity_security_tests.rs"] +mod emulator_profile_fidelity_security_tests; + #[cfg(test)] mod tests { use std::time::SystemTime; @@ -478,7 +506,7 @@ mod tests { } #[test] - fn test_build_emulated_server_hello_ignores_tail_records_for_raw_profile() { + fn test_build_emulated_server_hello_replays_tail_records_for_profiled_tls() { let mut cached = make_cached(None); cached.app_data_records_sizes = vec![27, 3905, 537, 69]; cached.total_app_data_len = 4538; @@ -500,11 +528,19 @@ mod tests { let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; let ccs_start = 5 + hello_len; - let app_start = ccs_start + 6; - let app_len = - u16::from_be_bytes([response[app_start + 3], response[app_start + 4]]) as usize; + let mut pos = ccs_start + 6; + let mut app_lengths = Vec::new(); + while pos + 5 <= response.len() { + assert_eq!(response[pos], TLS_RECORD_APPLICATION); + let record_len = u16::from_be_bytes([response[pos + 3], response[pos + 4]]) as usize; + app_lengths.push(record_len); + pos += 5 + record_len; + } - assert_eq!(response[app_start], TLS_RECORD_APPLICATION); - assert_eq!(app_start + 5 + app_len, response.len()); + assert_eq!(app_lengths.len(), 4); + assert_eq!(app_lengths[0], 64); + assert_eq!(app_lengths[3], 69); + assert!(app_lengths[1] >= 64); + assert!(app_lengths[2] >= 64); } } diff --git a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs new file mode 100644 index 0000000..694fd76 --- /dev/null +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -0,0 +1,95 @@ +use std::time::SystemTime; + +use crate::crypto::SecureRandom; +use crate::protocol::constants::{ + TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, +}; +use crate::tls_front::emulator::build_emulated_server_hello; +use crate::tls_front::types::{ + CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource, +}; + +fn make_cached() -> CachedTlsData { + CachedTlsData { + server_hello_template: ParsedServerHello { + version: [0x03, 0x03], + random: [0u8; 32], + session_id: Vec::new(), + cipher_suite: [0x13, 0x01], + compression: 0, + extensions: Vec::new(), + }, + cert_info: None, + cert_payload: None, + app_data_records_sizes: vec![1200, 900, 220, 180], + total_app_data_len: 2500, + behavior_profile: TlsBehaviorProfile { + change_cipher_spec_count: 2, + app_data_record_sizes: vec![1200, 900], + ticket_record_sizes: vec![220, 180], + source: TlsProfileSource::Merged, + }, + fetched_at: SystemTime::now(), + domain: "example.com".to_string(), + } +} + +fn record_lengths_by_type(response: &[u8], wanted_type: u8) -> Vec { + let mut out = Vec::new(); + let mut pos = 0usize; + while pos + 5 <= response.len() { + let record_type = response[pos]; + let record_len = u16::from_be_bytes([response[pos + 3], response[pos + 4]]) as usize; + if pos + 5 + record_len > response.len() { + break; + } + if record_type == wanted_type { + out.push(record_len); + } + pos += 5 + record_len; + } + out +} + +#[test] +fn emulated_server_hello_replays_profile_change_cipher_spec_count() { + let cached = make_cached(); + let rng = SecureRandom::new(); + + let response = build_emulated_server_hello( + b"secret", + &[0x71; 32], + &[0x72; 16], + &cached, + false, + &rng, + None, + 0, + ); + + assert_eq!(response[0], TLS_RECORD_HANDSHAKE); + let ccs_records = record_lengths_by_type(&response, TLS_RECORD_CHANGE_CIPHER); + assert_eq!(ccs_records.len(), 2); + assert!(ccs_records.iter().all(|len| *len == 1)); +} + +#[test] +fn emulated_server_hello_replays_profile_ticket_tail_lengths() { + let cached = make_cached(); + let rng = SecureRandom::new(); + + let response = build_emulated_server_hello( + b"secret", + &[0x81; 32], + &[0x82; 16], + &cached, + false, + &rng, + None, + 0, + ); + + let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); + assert!(app_records.len() >= 4); + assert_eq!(&app_records[app_records.len() - 2..], &[220, 180]); +}