From f7913721e2063aef4fb8c7e0a51b1291aa0aeac9 Mon Sep 17 00:00:00 2001 From: TWRoman Date: Wed, 15 Apr 2026 10:25:31 +0300 Subject: [PATCH 1/3] Updates in CONFIG_PARAMS based on lastest commits --- docs/Config_params/CONFIG_PARAMS.en.md | 9 +++------ docs/Config_params/CONFIG_PARAMS.ru.md | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index a36182c..253fe83 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2542,8 +2542,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche [censorship] mask_relay_max_bytes = 5242880 ``` -## "cfg-censorship-mask_relay_timeout_ms" -- `mask_relay_timeout_ms` +## mask_relay_timeout_ms - **Constraints / validation**: Should be `>= 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 +2551,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 +2560,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-маскировки. From 0bcc3bf9358646748cea501996332c6f571b5f8d Mon Sep 17 00:00:00 2001 From: Roman <140929436+TWRoman@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:29:37 +0300 Subject: [PATCH 2/3] Update CONFIG_PARAMS.en.md --- docs/Config_params/CONFIG_PARAMS.en.md | 65 +++++++++++++------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index 253fe83..26abe44 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2268,40 +2268,39 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche | Key | Type | Default | | --- | ---- | ------- | -| [`tls_domain`](#cfg-censorship-tls_domain) | `String` | `"petrovich.ru"` | -| [`tls_domains`](#cfg-censorship-tls_domains) | `String[]` | `[]` | -| [`unknown_sni_action`](#cfg-censorship-unknown_sni_action) | `"drop"`, `"mask"`, `"accept"` | `"drop"` | -| [`tls_fetch_scope`](#cfg-censorship-tls_fetch_scope) | `String` | `""` | -| [`tls_fetch`](#cfg-censorship-tls_fetch) | `Table` | built-in defaults | -| [`mask`](#cfg-censorship-mask) | `bool` | `true` | -| [`mask_host`](#cfg-censorship-mask_host) | `String` | — | -| [`mask_port`](#cfg-censorship-mask_port) | `u16` | `443` | -| [`mask_unix_sock`](#cfg-censorship-mask_unix_sock) | `String` | — | -| [`fake_cert_len`](#cfg-censorship-fake_cert_len) | `usize` | `2048` | -| [`tls_emulation`](#cfg-censorship-tls_emulation) | `bool` | `true` | -| [`tls_front_dir`](#cfg-censorship-tls_front_dir) | `String` | `"tlsfront"` | -| [`server_hello_delay_min_ms`](#cfg-censorship-server_hello_delay_min_ms) | `u64` | `0` | -| [`server_hello_delay_max_ms`](#cfg-censorship-server_hello_delay_max_ms) | `u64` | `0` | -| [`tls_new_session_tickets`](#cfg-censorship-tls_new_session_tickets) | `u8` | `0` | -| [`tls_full_cert_ttl_secs`](#cfg-censorship-tls_full_cert_ttl_secs) | `u64` | `90` | -| [`alpn_enforce`](#cfg-censorship-alpn_enforce) | `bool` | `true` | -| [`mask_proxy_protocol`](#cfg-censorship-mask_proxy_protocol) | `u8` | `0` | -| [`mask_shape_hardening`](#cfg-censorship-mask_shape_hardening) | `bool` | `true` | -| [`mask_shape_hardening_aggressive_mode`](#cfg-censorship-mask_shape_hardening_aggressive_mode) | `bool` | `false` | -| [`mask_shape_bucket_floor_bytes`](#cfg-censorship-mask_shape_bucket_floor_bytes) | `usize` | `512` | -| [`mask_shape_bucket_cap_bytes`](#cfg-censorship-mask_shape_bucket_cap_bytes) | `usize` | `4096` | -| [`mask_shape_above_cap_blur`](#cfg-censorship-mask_shape_above_cap_blur) | `bool` | `false` | -| [`mask_shape_above_cap_blur_max_bytes`](#cfg-censorship-mask_shape_above_cap_blur_max_bytes) | `usize` | `512` | -| [`mask_relay_max_bytes`](#cfg-censorship-mask_relay_max_bytes) | `usize` | `5242880` | -| [`mask_relay_timeout_ms`](#cfg-censorship-mask_relay_timeout_ms) | `u64` | `60_000` | -| [`mask_relay_idle_timeout_ms`](#cfg-censorship-mask_relay_idle_timeout_ms) | `u64` | `5_000` | -| [`mask_classifier_prefetch_timeout_ms`](#cfg-censorship-mask_classifier_prefetch_timeout_ms) | `u64` | `5` | -| [`mask_timing_normalization_enabled`](#cfg-censorship-mask_timing_normalization_enabled) | `bool` | `false` | -| [`mask_timing_normalization_floor_ms`](#cfg-censorship-mask_timing_normalization_floor_ms) | `u64` | `0` | -| [`mask_timing_normalization_ceiling_ms`](#cfg-censorship-mask_timing_normalization_ceiling_ms) | `u64` | `0` | +| [`tls_domain`](#tls_domain) | `String` | `"petrovich.ru"` | +| [`tls_domains`](#tls_domains) | `String[]` | `[]` | +| [`unknown_sni_action`](#unknown_sni_action) | `"drop"`, `"mask"`, `"accept"` | `"drop"` | +| [`tls_fetch_scope`](#tls_fetch_scope) | `String` | `""` | +| [`tls_fetch`](#tls_fetch) | `Table` | built-in defaults | +| [`mask`](#mask) | `bool` | `true` | +| [`mask_host`](#mask_host) | `String` | — | +| [`mask_port`](#mask_port) | `u16` | `443` | +| [`mask_unix_sock`](#mask_unix_sock) | `String` | — | +| [`fake_cert_len`](#fake_cert_len) | `usize` | `2048` | +| [`tls_emulation`](#tls_emulation) | `bool` | `true` | +| [`tls_front_dir`](#tls_front_dir) | `String` | `"tlsfront"` | +| [`server_hello_delay_min_ms`](#server_hello_delay_min_ms) | `u64` | `0` | +| [`server_hello_delay_max_ms`](#server_hello_delay_max_ms) | `u64` | `0` | +| [`tls_new_session_tickets`](#tls_new_session_tickets) | `u8` | `0` | +| [`tls_full_cert_ttl_secs`](#tls_full_cert_ttl_secs) | `u64` | `90` | +| [`alpn_enforce`](#alpn_enforce) | `bool` | `true` | +| [`mask_proxy_protocol`](#mask_proxy_protocol) | `u8` | `0` | +| [`mask_shape_hardening`](#mask_shape_hardening) | `bool` | `true` | +| [`mask_shape_hardening_aggressive_mode`](#mask_shape_hardening_aggressive_mode) | `bool` | `false` | +| [`mask_shape_bucket_floor_bytes`](#mask_shape_bucket_floor_bytes) | `usize` | `512` | +| [`mask_shape_bucket_cap_bytes`](#mask_shape_bucket_cap_bytes) | `usize` | `4096` | +| [`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` | +| [`mask_timing_normalization_ceiling_ms`](#mask_timing_normalization_ceiling_ms) | `u64` | `0` | -## "cfg-censorship-tls_domain" -- `tls_domain` +## tls_domain - **Constraints / validation**: Must be a non-empty domain name. Must not contain spaces or `/`. - **Description**: Primary domain used for Fake-TLS masking / fronting profile and as the default SNI domain presented to clients. This value becomes part of generated `ee` links, and changing it invalidates previously generated links. From f5b5ea3bbff6b87542900b5f2ae35310b228e3fa Mon Sep 17 00:00:00 2001 From: astronaut808 <38975427+astronaut808@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:35:09 +0500 Subject: [PATCH 3/3] Improve FakeTLS server-flight fidelity and macOS portability --- Cargo.lock | 2 +- Cargo.toml | 3 +- .../TLS_FRONT_PROFILE_FIDELITY.en.md | 225 ++++++++++++++++++ .../TLS_FRONT_PROFILE_FIDELITY.ru.md | 225 ++++++++++++++++++ src/daemon/mod.rs | 28 ++- src/tls_front/emulator.rs | 122 ++++++---- ...mulator_profile_fidelity_security_tests.rs | 95 ++++++++ 7 files changed, 653 insertions(+), 47 deletions(-) create mode 100644 docs/Architecture/Fronting-splitting/TLS_FRONT_PROFILE_FIDELITY.en.md create mode 100644 docs/Architecture/Fronting-splitting/TLS_FRONT_PROFILE_FIDELITY.ru.md create mode 100644 src/tls_front/tests/emulator_profile_fidelity_security_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 348c652..2001c9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,7 +2780,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.3.39" +version = "3.3.40" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 536ad04..15f3119 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.3.39" +version = "3.3.40" edition = "2024" [features] @@ -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 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]); +}