From 3abde52de8c4e63ece0f8649297cd685c5545c71 Mon Sep 17 00:00:00 2001 From: David Osipov Date: Fri, 20 Mar 2026 21:00:36 +0400 Subject: [PATCH] refactor: update TLS record size constants and related validations - Rename MAX_TLS_RECORD_SIZE to MAX_TLS_PLAINTEXT_SIZE for clarity. - Rename MAX_TLS_CHUNK_SIZE to MAX_TLS_CIPHERTEXT_SIZE to reflect its purpose. - Deprecate old constants in favor of new ones. - Update various parts of the codebase to use the new constants, including validation checks and tests. - Add new tests to ensure compliance with RFC 8446 regarding TLS record sizes. --- src/protocol/constants.rs | 26 +- src/protocol/tls.rs | 2 +- src/protocol/tls_security_tests.rs | 4 +- .../tls_size_constants_security_tests.rs | 15 + src/proxy/client.rs | 6 +- src/proxy/client_security_tests.rs | 12 +- ...ent_tls_clienthello_size_security_tests.rs | 22 +- ...ent_tls_mtproto_fallback_security_tests.rs | 6 +- src/stream/tls_stream.rs | 92 ++- .../tls_stream_size_adversarial_tests.rs | 579 ++++++++++++++++++ src/tls_front/emulator.rs | 3 +- 11 files changed, 713 insertions(+), 54 deletions(-) create mode 100644 src/protocol/tls_size_constants_security_tests.rs create mode 100644 src/stream/tls_stream_size_adversarial_tests.rs diff --git a/src/protocol/constants.rs b/src/protocol/constants.rs index 7d67446..819678c 100644 --- a/src/protocol/constants.rs +++ b/src/protocol/constants.rs @@ -152,17 +152,29 @@ pub const TLS_RECORD_CHANGE_CIPHER: u8 = 0x14; pub const TLS_RECORD_APPLICATION: u8 = 0x17; /// TLS record type: Alert pub const TLS_RECORD_ALERT: u8 = 0x15; -/// Maximum TLS record size (RFC 8446 §5.1: MUST NOT exceed 2^14 = 16_384 bytes) -pub const MAX_TLS_RECORD_SIZE: usize = 16_384; +/// Maximum TLS plaintext record payload size. +/// RFC 8446 §5.1: "The length MUST NOT exceed 2^14 bytes." +/// Use this for validating incoming unencrypted records +/// (ClientHello, ChangeCipherSpec, unprotected Handshake messages). +pub const MAX_TLS_PLAINTEXT_SIZE: usize = 16_384; /// Structural minimum for a valid TLS 1.3 ClientHello with SNI. /// Derived from RFC 8446 §4.1.2 field layout + Appendix D.4 compat mode. /// Deliberately conservative (below any real client) to avoid false /// positives on legitimate connections with compact extension sets. pub const MIN_TLS_CLIENT_HELLO_SIZE: usize = 100; -/// Maximum TLS chunk size (with overhead) -/// RFC 8446 §5.2 allows up to 16384 + 256 bytes of ciphertext -pub const MAX_TLS_CHUNK_SIZE: usize = 16384 + 256; + +/// Maximum TLS ciphertext record payload size. +/// RFC 8446 §5.2: "The length MUST NOT exceed 2^14 + 256 bytes." +/// The +256 accounts for maximum AEAD expansion overhead. +/// Use this for validating or sizing buffers for encrypted records. +pub const MAX_TLS_CIPHERTEXT_SIZE: usize = 16_384 + 256; + +#[deprecated(note = "use MAX_TLS_PLAINTEXT_SIZE")] +pub const MAX_TLS_RECORD_SIZE: usize = MAX_TLS_PLAINTEXT_SIZE; + +#[deprecated(note = "use MAX_TLS_CIPHERTEXT_SIZE")] +pub const MAX_TLS_CHUNK_SIZE: usize = MAX_TLS_CIPHERTEXT_SIZE; /// Secure Intermediate payload is expected to be 4-byte aligned. pub fn is_valid_secure_payload_len(data_len: usize) -> bool { @@ -325,6 +337,10 @@ pub mod rpc_flags { pub const ME_CONNECT_TIMEOUT_SECS: u64 = 5; pub const ME_HANDSHAKE_TIMEOUT_SECS: u64 = 10; + + #[cfg(test)] + #[path = "tls_size_constants_security_tests.rs"] + mod tls_size_constants_security_tests; #[cfg(test)] mod tests { diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index ac49ae3..cca3cc9 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -450,7 +450,7 @@ pub fn build_server_hello( new_session_tickets: u8, ) -> Vec { const MIN_APP_DATA: usize = 64; - const MAX_APP_DATA: usize = 16640; // RFC 8446 §5.2 upper bound + const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE; let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA); let x25519_key = gen_fake_x25519_key(rng); diff --git a/src/protocol/tls_security_tests.rs b/src/protocol/tls_security_tests.rs index e551cca..a6e7b2b 100644 --- a/src/protocol/tls_security_tests.rs +++ b/src/protocol/tls_security_tests.rs @@ -1189,7 +1189,7 @@ fn test_parse_tls_record_header() { let header = [0x17, 0x03, 0x03, 0x40, 0x00]; let result = parse_tls_record_header(&header).unwrap(); assert_eq!(result.0, TLS_RECORD_APPLICATION); - assert_eq!(result.1, 16384); + assert_eq!(usize::from(result.1), MAX_TLS_PLAINTEXT_SIZE); } #[test] @@ -1887,7 +1887,7 @@ fn server_hello_clamps_fake_cert_len_upper_bound() { let app_len = u16::from_be_bytes([response[app_pos + 3], response[app_pos + 4]]) as usize; assert_eq!(response[app_pos], TLS_RECORD_APPLICATION); - assert_eq!(app_len, 16_640, "fake cert payload must be clamped to TLS record max bound"); + assert_eq!(app_len, MAX_TLS_CIPHERTEXT_SIZE, "fake cert payload must be clamped to TLS record max bound"); } #[test] diff --git a/src/protocol/tls_size_constants_security_tests.rs b/src/protocol/tls_size_constants_security_tests.rs new file mode 100644 index 0000000..1389ab6 --- /dev/null +++ b/src/protocol/tls_size_constants_security_tests.rs @@ -0,0 +1,15 @@ +use super::{ + MAX_TLS_CIPHERTEXT_SIZE, + MAX_TLS_PLAINTEXT_SIZE, + MIN_TLS_CLIENT_HELLO_SIZE, +}; + +#[test] +fn tls_size_constants_match_rfc_8446() { + assert_eq!(MAX_TLS_PLAINTEXT_SIZE, 16_384); + assert_eq!(MAX_TLS_CIPHERTEXT_SIZE, 16_640); + + assert!(MIN_TLS_CLIENT_HELLO_SIZE < 512); + assert!(MIN_TLS_CLIENT_HELLO_SIZE > 64); + assert!(MAX_TLS_CIPHERTEXT_SIZE > MAX_TLS_PLAINTEXT_SIZE); +} diff --git a/src/proxy/client.rs b/src/proxy/client.rs index 6af1b13..0f2d42a 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -111,7 +111,7 @@ fn wrap_tls_application_record(payload: &[u8]) -> Vec { } fn tls_clienthello_len_in_bounds(tls_len: usize) -> bool { - (MIN_TLS_CLIENT_HELLO_SIZE..=MAX_TLS_RECORD_SIZE).contains(&tls_len) + (MIN_TLS_CLIENT_HELLO_SIZE..=MAX_TLS_PLAINTEXT_SIZE).contains(&tls_len) } async fn read_with_progress(reader: &mut R, mut buf: &mut [u8]) -> std::io::Result { @@ -281,7 +281,7 @@ where // incorrectly rejecting compact but spec-compliant ClientHellos from // third-party clients or future Telegram versions. if !tls_clienthello_len_in_bounds(tls_len) { - debug!(peer = %real_peer, tls_len = tls_len, max_tls_len = MAX_TLS_RECORD_SIZE, "TLS handshake length out of bounds"); + debug!(peer = %real_peer, tls_len = tls_len, max_tls_len = MAX_TLS_PLAINTEXT_SIZE, "TLS handshake length out of bounds"); stats.increment_connects_bad(); let (reader, writer) = tokio::io::split(stream); handle_bad_client( @@ -729,7 +729,7 @@ impl RunningClientHandler { // incorrectly rejecting compact but spec-compliant ClientHellos from // third-party clients or future Telegram versions. if !tls_clienthello_len_in_bounds(tls_len) { - debug!(peer = %peer, tls_len = tls_len, max_tls_len = MAX_TLS_RECORD_SIZE, "TLS handshake length out of bounds"); + debug!(peer = %peer, tls_len = tls_len, max_tls_len = MAX_TLS_PLAINTEXT_SIZE, "TLS handshake length out of bounds"); self.stats.increment_connects_bad(); let (reader, writer) = self.stream.into_split(); handle_bad_client( diff --git a/src/proxy/client_security_tests.rs b/src/proxy/client_security_tests.rs index 5686f3b..056d8fb 100644 --- a/src/proxy/client_security_tests.rs +++ b/src/proxy/client_security_tests.rs @@ -3335,8 +3335,8 @@ async fn oversized_tls_record_is_masked_in_generic_stream_pipeline() { 0x16, 0x03, 0x01, - (((MAX_TLS_RECORD_SIZE + 1) >> 8) & 0xff) as u8, - ((MAX_TLS_RECORD_SIZE + 1) & 0xff) as u8, + (((MAX_TLS_PLAINTEXT_SIZE + 1) >> 8) & 0xff) as u8, + ((MAX_TLS_PLAINTEXT_SIZE + 1) & 0xff) as u8, ]; let backend_reply = b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n".to_vec(); @@ -3438,8 +3438,8 @@ async fn oversized_tls_record_is_masked_in_client_handler_pipeline() { 0x16, 0x03, 0x01, - (((MAX_TLS_RECORD_SIZE + 1) >> 8) & 0xff) as u8, - ((MAX_TLS_RECORD_SIZE + 1) & 0xff) as u8, + (((MAX_TLS_PLAINTEXT_SIZE + 1) >> 8) & 0xff) as u8, + ((MAX_TLS_PLAINTEXT_SIZE + 1) & 0xff) as u8, ]; let backend_reply = b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(); @@ -3769,7 +3769,7 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() { let backend_addr = listener.local_addr().unwrap(); let secret = [0x55u8; 16]; - let client_hello = make_valid_tls_client_hello_with_len(&secret, 0, MAX_TLS_RECORD_SIZE); + let client_hello = make_valid_tls_client_hello_with_len(&secret, 0, MAX_TLS_PLAINTEXT_SIZE); let mut cfg = ProxyConfig::default(); cfg.general.beobachten = false; @@ -3865,7 +3865,7 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() { let front_addr = front_listener.local_addr().unwrap(); let secret = [0x66u8; 16]; - let client_hello = make_valid_tls_client_hello_with_len(&secret, 0, MAX_TLS_RECORD_SIZE); + let client_hello = make_valid_tls_client_hello_with_len(&secret, 0, MAX_TLS_PLAINTEXT_SIZE); let mut cfg = ProxyConfig::default(); cfg.general.beobachten = false; diff --git a/src/proxy/client_tls_clienthello_size_security_tests.rs b/src/proxy/client_tls_clienthello_size_security_tests.rs index e128ae9..e54791f 100644 --- a/src/proxy/client_tls_clienthello_size_security_tests.rs +++ b/src/proxy/client_tls_clienthello_size_security_tests.rs @@ -4,7 +4,7 @@ use super::*; use crate::config::{UpstreamConfig, UpstreamType}; -use crate::protocol::constants::{MAX_TLS_RECORD_SIZE, MIN_TLS_CLIENT_HELLO_SIZE}; +use crate::protocol::constants::{MAX_TLS_PLAINTEXT_SIZE, MIN_TLS_CLIENT_HELLO_SIZE}; use std::net::SocketAddr; use std::time::Duration; use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt}; @@ -124,7 +124,7 @@ async fn tls_client_hello_lower_bound_minus_one_is_masked_and_counted_bad() { #[tokio::test] async fn tls_client_hello_upper_bound_plus_one_is_masked_and_counted_bad() { - run_probe_and_assert_masking(MAX_TLS_RECORD_SIZE + 1, true).await; + run_probe_and_assert_masking(MAX_TLS_PLAINTEXT_SIZE + 1, true).await; } #[tokio::test] @@ -142,9 +142,9 @@ fn tls_client_hello_len_bounds_unit_adversarial_sweep() { (101usize, true), (511usize, true), (512usize, true), - (16_383usize, true), - (16_384usize, true), - (16_385usize, false), + (MAX_TLS_PLAINTEXT_SIZE - 1, true), + (MAX_TLS_PLAINTEXT_SIZE, true), + (MAX_TLS_PLAINTEXT_SIZE + 1, false), (u16::MAX as usize, false), (usize::MAX, false), ]; @@ -168,12 +168,12 @@ fn tls_client_hello_len_bounds_light_fuzz_deterministic_lcg() { 0 => MIN_TLS_CLIENT_HELLO_SIZE - 1, 1 => MIN_TLS_CLIENT_HELLO_SIZE, 2 => MIN_TLS_CLIENT_HELLO_SIZE + 1, - 3 => MAX_TLS_RECORD_SIZE - 1, - 4 => MAX_TLS_RECORD_SIZE, - 5 => MAX_TLS_RECORD_SIZE + 1, + 3 => MAX_TLS_PLAINTEXT_SIZE - 1, + 4 => MAX_TLS_PLAINTEXT_SIZE, + 5 => MAX_TLS_PLAINTEXT_SIZE + 1, _ => base, }; - let expect_bad = !(MIN_TLS_CLIENT_HELLO_SIZE..=MAX_TLS_RECORD_SIZE).contains(&len); + let expect_bad = !(MIN_TLS_CLIENT_HELLO_SIZE..=MAX_TLS_PLAINTEXT_SIZE).contains(&len); assert_eq!( tls_clienthello_len_in_bounds(len), !expect_bad, @@ -186,9 +186,9 @@ fn tls_client_hello_len_bounds_light_fuzz_deterministic_lcg() { fn tls_client_hello_len_bounds_stress_many_evaluations() { for _ in 0..100_000 { assert!(tls_clienthello_len_in_bounds(MIN_TLS_CLIENT_HELLO_SIZE)); - assert!(tls_clienthello_len_in_bounds(MAX_TLS_RECORD_SIZE)); + assert!(tls_clienthello_len_in_bounds(MAX_TLS_PLAINTEXT_SIZE)); assert!(!tls_clienthello_len_in_bounds(MIN_TLS_CLIENT_HELLO_SIZE - 1)); - assert!(!tls_clienthello_len_in_bounds(MAX_TLS_RECORD_SIZE + 1)); + assert!(!tls_clienthello_len_in_bounds(MAX_TLS_PLAINTEXT_SIZE + 1)); } } diff --git a/src/proxy/client_tls_mtproto_fallback_security_tests.rs b/src/proxy/client_tls_mtproto_fallback_security_tests.rs index 9451016..262630e 100644 --- a/src/proxy/client_tls_mtproto_fallback_security_tests.rs +++ b/src/proxy/client_tls_mtproto_fallback_security_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::config::{UpstreamConfig, UpstreamType}; use crate::crypto::sha256_hmac; -use crate::protocol::constants::{HANDSHAKE_LEN, MAX_TLS_CHUNK_SIZE, TLS_VERSION}; +use crate::protocol::constants::{HANDSHAKE_LEN, MAX_TLS_CIPHERTEXT_SIZE, TLS_VERSION}; use crate::protocol::tls; use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -347,7 +347,7 @@ async fn tls_bad_mtproto_fallback_forwards_max_tls_record_verbatim() { let client_hello = make_valid_tls_client_hello(&secret, 3, 600, 0x45); let invalid_mtproto = vec![0u8; HANDSHAKE_LEN]; let invalid_mtproto_record = wrap_tls_application_data(&invalid_mtproto); - let trailing_payload = vec![0xAB; MAX_TLS_CHUNK_SIZE]; + let trailing_payload = vec![0xAB; MAX_TLS_CIPHERTEXT_SIZE]; let trailing_record = wrap_tls_application_data(&trailing_payload); let expected_client_hello = client_hello.clone(); @@ -1786,7 +1786,7 @@ async fn tls_bad_mtproto_fallback_coalesced_tail_max_payload_is_forwarded() { let secret = [0xA5u8; 16]; let client_hello = make_valid_tls_client_hello(&secret, 304, 600, 0x35); - let coalesced_tail = vec![0xEF; MAX_TLS_CHUNK_SIZE - HANDSHAKE_LEN]; + let coalesced_tail = vec![0xEF; MAX_TLS_CIPHERTEXT_SIZE - HANDSHAKE_LEN]; let coalesced_record = wrap_invalid_mtproto_with_coalesced_tail(&coalesced_tail); let expected_tail_record = wrap_tls_application_data(&coalesced_tail); diff --git a/src/stream/tls_stream.rs b/src/stream/tls_stream.rs index c87c350..d405cda 100644 --- a/src/stream/tls_stream.rs +++ b/src/stream/tls_stream.rs @@ -12,7 +12,7 @@ //! Telegram MTProto proxy "FakeTLS" mode uses a TLS-looking outer layer for //! domain fronting / traffic camouflage. iOS Telegram clients are known to //! produce slightly different TLS record sizing patterns than Android/Desktop, -//! including records that exceed 16384 payload bytes by a small overhead. +//! including records that exceed MAX_TLS_PLAINTEXT_SIZE payload bytes by a small overhead. //! //! Key design principles: //! - Explicit state machines for all async operations @@ -23,14 +23,13 @@ //! - Proper handling of all TLS record types //! //! Important nuance (Telegram FakeTLS): -//! - The TLS spec limits "plaintext fragments" to 2^14 (16384) bytes. -//! - However, the on-the-wire record length can exceed 16384 because TLS 1.3 +//! - The TLS spec limits "plaintext fragments" to MAX_TLS_PLAINTEXT_SIZE bytes. +//! - However, the on-the-wire record length can exceed MAX_TLS_PLAINTEXT_SIZE because TLS 1.3 //! uses AEAD and can include tag/overhead/padding. //! - Telegram FakeTLS clients (notably iOS) may send Application Data records -//! with length up to 16384 + 256 bytes (RFC 8446 §5.2). We accept that as -//! MAX_TLS_CHUNK_SIZE. +//! with length up to MAX_TLS_CIPHERTEXT_SIZE bytes (RFC 8446 §5.2). //! -//! If you reject those (e.g. validate length <= 16384), you will see errors like: +//! If you reject those (e.g. validate length <= MAX_TLS_PLAINTEXT_SIZE), you will see errors like: //! "TLS record too large: 16408 bytes" //! and uploads from iOS will break (media/file sending), while small traffic //! may still work. @@ -42,10 +41,11 @@ use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncWrite, AsyncReadExt, AsyncWriteExt, ReadBuf}; use crate::protocol::constants::{ + MAX_TLS_PLAINTEXT_SIZE, TLS_VERSION, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_RECORD_ALERT, - MAX_TLS_CHUNK_SIZE, + MAX_TLS_CIPHERTEXT_SIZE, }; use super::state::{StreamState, HeaderBuffer, YieldBuffer, WriteBuffer}; @@ -56,7 +56,7 @@ const TLS_HEADER_SIZE: usize = 5; /// Maximum TLS fragment size we emit for Application Data. /// Real TLS 1.3 allows up to 16384 + 256 bytes of ciphertext (incl. tag). -const MAX_TLS_PAYLOAD: usize = 16384 + 256; +const MAX_TLS_PAYLOAD: usize = MAX_TLS_CIPHERTEXT_SIZE; /// Maximum pending write buffer for one record remainder. /// Note: we never queue unlimited amount of data here; state holds at most one record. @@ -93,10 +93,10 @@ impl TlsRecordHeader { /// - We accept TLS 1.0 header version for ClientHello-like records (0x03 0x01), /// and TLS 1.2/1.3 style version bytes for the rest (we use TLS_VERSION = 0x03 0x03). /// - For Application Data, Telegram FakeTLS may send payload length up to - /// MAX_TLS_CHUNK_SIZE (16384 + 256). + /// MAX_TLS_CIPHERTEXT_SIZE (16384 + 256). /// - For other record types we keep stricter bounds to avoid memory abuse. fn validate(&self) -> Result<()> { - // Version: accept TLS 1.0 header (ClientHello quirk) and TLS_VERSION (0x0303). + // Version precheck: only 0x0301 and 0x0303 are recognized at all. if self.version != [0x03, 0x01] && self.version != TLS_VERSION { return Err(Error::new( ErrorKind::InvalidData, @@ -104,31 +104,75 @@ impl TlsRecordHeader { )); } + // Narrow FakeTLS wire profile: TLS 1.0 compatibility header is allowed + // only on Handshake records (ClientHello compatibility quirk). + if self.record_type != TLS_RECORD_HANDSHAKE && self.version != TLS_VERSION { + return Err(Error::new( + ErrorKind::InvalidData, + format!( + "invalid TLS version for record type 0x{:02x}: {:02x?}", + self.record_type, + self.version + ), + )); + } + let len = self.length as usize; // Length checks depend on record type. - // Telegram FakeTLS: ApplicationData length may be 16384 + 256. + // Telegram FakeTLS: ApplicationData may use ciphertext envelope limit, + // while control records stay structurally strict to reduce probe surface. match self.record_type { TLS_RECORD_APPLICATION => { - if len > MAX_TLS_CHUNK_SIZE { + if len == 0 || len > MAX_TLS_CIPHERTEXT_SIZE { return Err(Error::new( ErrorKind::InvalidData, - format!("TLS record too large: {} bytes (max {})", len, MAX_TLS_CHUNK_SIZE), + format!( + "invalid TLS application data length: {} (min 1, max {})", + len, + MAX_TLS_CIPHERTEXT_SIZE + ), )); } } - // ChangeCipherSpec/Alert/Handshake should never be that large for our usage - // (post-handshake we don't expect Handshake at all). - // Keep strict to reduce attack surface. - _ => { - if len > MAX_TLS_PAYLOAD { + TLS_RECORD_CHANGE_CIPHER => { + if len != 1 { return Err(Error::new( ErrorKind::InvalidData, - format!("TLS control record too large: {} bytes (max {})", len, MAX_TLS_PAYLOAD), + format!("invalid TLS ChangeCipherSpec length: {} (expected 1)", len), )); } } + + TLS_RECORD_ALERT => { + if len != 2 { + return Err(Error::new( + ErrorKind::InvalidData, + format!("invalid TLS alert length: {} (expected 2)", len), + )); + } + } + + TLS_RECORD_HANDSHAKE => { + if len < 4 || len > MAX_TLS_PLAINTEXT_SIZE { + return Err(Error::new( + ErrorKind::InvalidData, + format!( + "invalid TLS handshake length: {} (min 4, max {})", + len, + MAX_TLS_PLAINTEXT_SIZE + ), + )); + } + } + + _ => { + return Err(Error::new( + ErrorKind::InvalidData, + format!("unknown TLS record type: 0x{:02x}", self.record_type), + )); + } } Ok(()) @@ -592,10 +636,10 @@ impl StreamState for TlsWriterState { /// Writer that wraps bytes into TLS 1.3 Application Data records. /// -/// We chunk outgoing data into records of <= 16384 payload bytes (MAX_TLS_PAYLOAD). +/// We chunk outgoing data into records of <= MAX_TLS_CIPHERTEXT_SIZE payload bytes. /// We do not try to mimic AEAD overhead on the wire; Telegram clients accept it. /// If you want to be more camouflage-accurate later, you could add optional padding -/// to produce records sized closer to MAX_TLS_CHUNK_SIZE. +/// to produce records sized closer to MAX_TLS_CIPHERTEXT_SIZE. pub struct FakeTlsWriter { upstream: W, state: TlsWriterState, @@ -831,7 +875,7 @@ impl AsyncWrite for FakeTlsWriter { impl FakeTlsWriter { /// Write all data wrapped in TLS records. /// - /// Convenience method that chunks into <= 16384 records. + /// Convenience method that chunks into <= MAX_TLS_CIPHERTEXT_SIZE records. pub async fn write_all_tls(&mut self, data: &[u8]) -> Result<()> { let mut written = 0; while written < data.len() { @@ -846,6 +890,10 @@ impl FakeTlsWriter { } } +#[cfg(test)] +#[path = "tls_stream_size_adversarial_tests.rs"] +mod size_adversarial_tests; + // ============= Tests ============= #[cfg(test)] diff --git a/src/stream/tls_stream_size_adversarial_tests.rs b/src/stream/tls_stream_size_adversarial_tests.rs new file mode 100644 index 0000000..ec408cd --- /dev/null +++ b/src/stream/tls_stream_size_adversarial_tests.rs @@ -0,0 +1,579 @@ +use super::*; +use crate::protocol::constants::MAX_TLS_PLAINTEXT_SIZE; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[test] +fn handshake_record_above_plaintext_limit_must_be_rejected_early() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_HANDSHAKE, + version: TLS_VERSION, + length: (MAX_TLS_PLAINTEXT_SIZE + 1) as u16, + }; + + assert!( + header.validate().is_err(), + "control-plane handshake record > MAX_TLS_PLAINTEXT_SIZE must fail closed" + ); +} + +#[test] +fn alert_record_above_plaintext_limit_must_be_rejected_early() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_ALERT, + version: TLS_VERSION, + length: (MAX_TLS_PLAINTEXT_SIZE + 1) as u16, + }; + + assert!( + header.validate().is_err(), + "TLS alert record > MAX_TLS_PLAINTEXT_SIZE must be rejected" + ); +} + +#[test] +fn ccs_record_len_not_equal_one_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_CHANGE_CIPHER, + version: TLS_VERSION, + length: 2, + }; + + assert!( + header.validate().is_err(), + "ChangeCipherSpec length must be exactly 1 byte in compat mode" + ); +} + +#[test] +fn handshake_record_len_zero_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_HANDSHAKE, + version: TLS_VERSION, + length: 0, + }; + + assert!( + header.validate().is_err(), + "zero-length handshake record is structurally invalid" + ); +} + +#[test] +fn handshake_record_len_one_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_HANDSHAKE, + version: TLS_VERSION, + length: 1, + }; + + assert!( + header.validate().is_err(), + "tiny handshake record must be rejected to avoid malformed parser states" + ); +} + +#[test] +fn handshake_record_len_four_is_accepted() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_HANDSHAKE, + version: TLS_VERSION, + length: 4, + }; + + assert!( + header.validate().is_ok(), + "4-byte handshake payload is the minimum carrying handshake header" + ); +} + +#[test] +fn handshake_record_at_plaintext_limit_is_accepted() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_HANDSHAKE, + version: TLS_VERSION, + length: MAX_TLS_PLAINTEXT_SIZE as u16, + }; + + assert!( + header.validate().is_ok(), + "handshake record at plaintext RFC limit must be accepted" + ); +} + +#[test] +fn handshake_record_at_ciphertext_limit_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_HANDSHAKE, + version: TLS_VERSION, + length: MAX_TLS_CIPHERTEXT_SIZE as u16, + }; + + assert!( + header.validate().is_err(), + "control-plane handshake must never use ciphertext upper bound" + ); +} + +#[test] +fn alert_record_len_zero_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_ALERT, + version: TLS_VERSION, + length: 0, + }; + + assert!( + header.validate().is_err(), + "TLS alert must always carry level+description bytes" + ); +} + +#[test] +fn alert_record_len_one_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_ALERT, + version: TLS_VERSION, + length: 1, + }; + + assert!( + header.validate().is_err(), + "one-byte TLS alert is malformed and must fail closed" + ); +} + +#[test] +fn alert_record_len_two_is_accepted() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_ALERT, + version: TLS_VERSION, + length: 2, + }; + + assert!( + header.validate().is_ok(), + "standard TLS alert shape should be accepted" + ); +} + +#[test] +fn alert_record_len_three_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_ALERT, + version: TLS_VERSION, + length: 3, + }; + + assert!( + header.validate().is_err(), + "oversized plaintext alert should be rejected to avoid parser confusion" + ); +} + +#[test] +fn ccs_record_len_zero_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_CHANGE_CIPHER, + version: TLS_VERSION, + length: 0, + }; + + assert!( + header.validate().is_err(), + "ChangeCipherSpec with zero length is malformed" + ); +} + +#[test] +fn ccs_record_len_one_is_accepted() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_CHANGE_CIPHER, + version: TLS_VERSION, + length: 1, + }; + + assert!( + header.validate().is_ok(), + "ChangeCipherSpec compat record length must be accepted only for len=1" + ); +} + +#[test] +fn ccs_record_len_at_plaintext_limit_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_CHANGE_CIPHER, + version: TLS_VERSION, + length: MAX_TLS_PLAINTEXT_SIZE as u16, + }; + + assert!( + header.validate().is_err(), + "oversized CCS control frame must fail closed" + ); +} + +#[test] +fn unknown_record_type_small_len_must_be_rejected_early() { + let header = TlsRecordHeader { + record_type: 0x19, + version: TLS_VERSION, + length: 8, + }; + + assert!( + header.validate().is_err(), + "unknown TLS record type should be rejected during header validation" + ); +} + +#[test] +fn unknown_record_type_large_len_must_be_rejected_early() { + let header = TlsRecordHeader { + record_type: 0x7f, + version: TLS_VERSION, + length: MAX_TLS_CIPHERTEXT_SIZE as u16, + }; + + assert!( + header.validate().is_err(), + "unknown record type with large payload must fail before body allocation" + ); +} + +#[test] +fn handshake_tls10_header_with_plaintext_plus_one_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_HANDSHAKE, + version: [0x03, 0x01], + length: (MAX_TLS_PLAINTEXT_SIZE + 1) as u16, + }; + + assert!( + header.validate().is_err(), + "TLS 1.0 compatibility header must not bypass plaintext size cap" + ); +} + +#[test] +fn alert_tls10_header_with_invalid_len_must_be_rejected() { + let header = TlsRecordHeader { + record_type: TLS_RECORD_ALERT, + version: [0x03, 0x01], + length: 3, + }; + + assert!( + header.validate().is_err(), + "TLS 1.0 compatibility header must not bypass strict alert framing" + ); +} + +fn validates(record_type: u8, version: [u8; 2], length: u16) -> bool { + TlsRecordHeader { + record_type, + version, + length, + } + .validate() + .is_ok() +} + +macro_rules! expect_reject { + ($name:ident, $record_type:expr, $version:expr, $length:expr) => { + #[test] + fn $name() { + assert!( + !validates($record_type, $version, $length), + "expected reject for type=0x{:02x} version={:02x?} len={}", + $record_type, + $version, + $length + ); + } + }; +} + +macro_rules! expect_accept { + ($name:ident, $record_type:expr, $version:expr, $length:expr) => { + #[test] + fn $name() { + assert!( + validates($record_type, $version, $length), + "expected accept for type=0x{:02x} version={:02x?} len={}", + $record_type, + $version, + $length + ); + } + }; +} + +expect_reject!(appdata_zero_len_must_be_rejected, TLS_RECORD_APPLICATION, TLS_VERSION, 0); +expect_accept!(appdata_one_len_is_accepted, TLS_RECORD_APPLICATION, TLS_VERSION, 1); +expect_accept!(appdata_small_len_is_accepted, TLS_RECORD_APPLICATION, TLS_VERSION, 32); +expect_accept!(appdata_medium_len_is_accepted, TLS_RECORD_APPLICATION, TLS_VERSION, 1024); +expect_accept!(appdata_plaintext_limit_is_accepted, TLS_RECORD_APPLICATION, TLS_VERSION, MAX_TLS_PLAINTEXT_SIZE as u16); +expect_accept!(appdata_ciphertext_limit_is_accepted, TLS_RECORD_APPLICATION, TLS_VERSION, MAX_TLS_CIPHERTEXT_SIZE as u16); +expect_reject!(appdata_ciphertext_plus_one_must_be_rejected, TLS_RECORD_APPLICATION, TLS_VERSION, (MAX_TLS_CIPHERTEXT_SIZE as u16) + 1); + +expect_reject!(appdata_tls10_header_len_one_must_be_rejected, TLS_RECORD_APPLICATION, [0x03, 0x01], 1); +expect_reject!(appdata_tls10_header_medium_must_be_rejected, TLS_RECORD_APPLICATION, [0x03, 0x01], 1024); +expect_reject!(appdata_tls10_header_ciphertext_limit_must_be_rejected, TLS_RECORD_APPLICATION, [0x03, 0x01], MAX_TLS_CIPHERTEXT_SIZE as u16); + +expect_reject!(ccs_tls10_header_len_one_must_be_rejected, TLS_RECORD_CHANGE_CIPHER, [0x03, 0x01], 1); +expect_reject!(ccs_tls10_header_len_zero_must_be_rejected, TLS_RECORD_CHANGE_CIPHER, [0x03, 0x01], 0); +expect_reject!(ccs_tls10_header_len_two_must_be_rejected, TLS_RECORD_CHANGE_CIPHER, [0x03, 0x01], 2); + +expect_reject!(alert_tls10_header_len_two_must_be_rejected, TLS_RECORD_ALERT, [0x03, 0x01], 2); +expect_reject!(alert_tls10_header_len_one_must_be_rejected, TLS_RECORD_ALERT, [0x03, 0x01], 1); +expect_reject!(alert_tls10_header_len_three_must_be_rejected, TLS_RECORD_ALERT, [0x03, 0x01], 3); + +expect_accept!(handshake_tls10_header_min_len_is_accepted, TLS_RECORD_HANDSHAKE, [0x03, 0x01], 4); +expect_accept!(handshake_tls10_header_plaintext_limit_is_accepted, TLS_RECORD_HANDSHAKE, [0x03, 0x01], MAX_TLS_PLAINTEXT_SIZE as u16); +expect_reject!(handshake_tls10_header_too_small_must_be_rejected, TLS_RECORD_HANDSHAKE, [0x03, 0x01], 3); +expect_reject!(handshake_tls10_header_too_large_must_be_rejected, TLS_RECORD_HANDSHAKE, [0x03, 0x01], (MAX_TLS_PLAINTEXT_SIZE as u16) + 1); + +expect_reject!(unknown_type_tls13_zero_must_be_rejected, 0x00, TLS_VERSION, 0); +expect_reject!(unknown_type_tls13_small_must_be_rejected, 0x13, TLS_VERSION, 32); +expect_reject!(unknown_type_tls13_large_must_be_rejected, 0xfe, TLS_VERSION, MAX_TLS_CIPHERTEXT_SIZE as u16); +expect_reject!(unknown_type_tls10_small_must_be_rejected, 0x13, [0x03, 0x01], 32); + +expect_reject!(appdata_invalid_version_0302_must_be_rejected, TLS_RECORD_APPLICATION, [0x03, 0x02], 128); +expect_reject!(handshake_invalid_version_0302_must_be_rejected, TLS_RECORD_HANDSHAKE, [0x03, 0x02], 128); +expect_reject!(alert_invalid_version_0302_must_be_rejected, TLS_RECORD_ALERT, [0x03, 0x02], 2); +expect_reject!(ccs_invalid_version_0302_must_be_rejected, TLS_RECORD_CHANGE_CIPHER, [0x03, 0x02], 1); + +expect_reject!(appdata_invalid_version_0304_must_be_rejected, TLS_RECORD_APPLICATION, [0x03, 0x04], 128); +expect_reject!(handshake_invalid_version_0304_must_be_rejected, TLS_RECORD_HANDSHAKE, [0x03, 0x04], 128); +expect_reject!(alert_invalid_version_0304_must_be_rejected, TLS_RECORD_ALERT, [0x03, 0x04], 2); +expect_reject!(ccs_invalid_version_0304_must_be_rejected, TLS_RECORD_CHANGE_CIPHER, [0x03, 0x04], 1); + +expect_accept!(handshake_tls13_len_5_is_accepted, TLS_RECORD_HANDSHAKE, TLS_VERSION, 5); +expect_accept!(appdata_tls13_len_16385_is_accepted, TLS_RECORD_APPLICATION, TLS_VERSION, (MAX_TLS_PLAINTEXT_SIZE as u16) + 1); + +#[test] +fn matrix_version_policy_is_strict_and_deterministic() { + let versions = [[0x03, 0x01], TLS_VERSION, [0x03, 0x02], [0x03, 0x04], [0x00, 0x00]]; + let record_types = [ + TLS_RECORD_APPLICATION, + TLS_RECORD_CHANGE_CIPHER, + TLS_RECORD_ALERT, + TLS_RECORD_HANDSHAKE, + ]; + + for version in versions { + for record_type in record_types { + let len = match record_type { + TLS_RECORD_APPLICATION => 1, + TLS_RECORD_CHANGE_CIPHER => 1, + TLS_RECORD_ALERT => 2, + TLS_RECORD_HANDSHAKE => 4, + _ => unreachable!(), + }; + + let accepted = validates(record_type, version, len); + let expected = if version == TLS_VERSION { + true + } else { + version == [0x03, 0x01] && record_type == TLS_RECORD_HANDSHAKE + }; + + assert_eq!( + accepted, expected, + "version policy mismatch for type=0x{:02x} version={:02x?}", + record_type, version + ); + } + } +} + +#[test] +fn appdata_partition_property_holds_for_all_u16_edges() { + for len in [0u16, 1, 2, 3, 64, 255, 1024, 4096, 8192, 16_384, 16_385, 16_640, 16_641, u16::MAX] { + let accepted = validates(TLS_RECORD_APPLICATION, TLS_VERSION, len); + let expected = len >= 1 && usize::from(len) <= MAX_TLS_CIPHERTEXT_SIZE; + assert_eq!(accepted, expected, "unexpected appdata decision for len={len}"); + } +} + +#[test] +fn handshake_partition_property_holds_for_all_u16_edges() { + for len in [0u16, 1, 2, 3, 4, 5, 64, 255, 1024, 4096, 8192, 16_383, 16_384, 16_385, u16::MAX] { + let accepted_tls13 = validates(TLS_RECORD_HANDSHAKE, TLS_VERSION, len); + let accepted_tls10 = validates(TLS_RECORD_HANDSHAKE, [0x03, 0x01], len); + let expected = (4..=MAX_TLS_PLAINTEXT_SIZE).contains(&usize::from(len)); + + assert_eq!(accepted_tls13, expected, "TLS1.3 handshake mismatch for len={len}"); + assert_eq!(accepted_tls10, expected, "TLS1.0 compat handshake mismatch for len={len}"); + } +} + +#[test] +fn control_record_exact_lengths_are_enforced_under_fuzzed_lengths() { + let mut x: u32 = 0xC0FFEE11; + for _ in 0..5000 { + x = x.wrapping_mul(1664525).wrapping_add(1013904223); + let len = (x & 0xFFFF) as u16; + + let ccs_ok = validates(TLS_RECORD_CHANGE_CIPHER, TLS_VERSION, len); + let alert_ok = validates(TLS_RECORD_ALERT, TLS_VERSION, len); + + assert_eq!(ccs_ok, len == 1, "ccs length gate mismatch for len={len}"); + assert_eq!(alert_ok, len == 2, "alert length gate mismatch for len={len}"); + } +} + +#[test] +fn unknown_record_types_never_validate_under_supported_versions() { + for record_type in 0u8..=255 { + if matches!(record_type, TLS_RECORD_APPLICATION | TLS_RECORD_CHANGE_CIPHER | TLS_RECORD_ALERT | TLS_RECORD_HANDSHAKE) { + continue; + } + + assert!( + !validates(record_type, TLS_VERSION, 1), + "unknown type must not validate under TLS_VERSION: 0x{record_type:02x}" + ); + assert!( + !validates(record_type, [0x03, 0x01], 4), + "unknown type must not validate under TLS1.0 compat: 0x{record_type:02x}" + ); + } +} + +#[tokio::test] +async fn reader_rejects_tls10_appdata_header_before_payload_processing() { + let (mut tx, rx) = tokio::io::duplex(128); + tx.write_all(&[TLS_RECORD_APPLICATION, 0x03, 0x01, 0x00, 0x01, 0xAB]) + .await + .unwrap(); + tx.shutdown().await.unwrap(); + + let mut reader = FakeTlsReader::new(rx); + let mut out = [0u8; 1]; + let err = reader.read(&mut out).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); +} + +#[tokio::test] +async fn reader_rejects_zero_len_appdata_record() { + let (mut tx, rx) = tokio::io::duplex(128); + tx.write_all(&[TLS_RECORD_APPLICATION, TLS_VERSION[0], TLS_VERSION[1], 0x00, 0x00]) + .await + .unwrap(); + tx.shutdown().await.unwrap(); + + let mut reader = FakeTlsReader::new(rx); + let mut out = [0u8; 1]; + let err = reader.read(&mut out).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); +} + +#[tokio::test] +async fn reader_accepts_single_byte_tls13_appdata_and_yields_payload() { + let (mut tx, rx) = tokio::io::duplex(128); + tx.write_all(&[TLS_RECORD_APPLICATION, TLS_VERSION[0], TLS_VERSION[1], 0x00, 0x01, 0x5A]) + .await + .unwrap(); + tx.shutdown().await.unwrap(); + + let mut reader = FakeTlsReader::new(rx); + let mut out = [0u8; 1]; + let n = reader.read(&mut out).await.unwrap(); + assert_eq!(n, 1); + assert_eq!(out[0], 0x5A); +} + +#[tokio::test] +async fn reader_rejects_tls10_alert_even_with_structural_length() { + let (mut tx, rx) = tokio::io::duplex(128); + tx.write_all(&[TLS_RECORD_ALERT, 0x03, 0x01, 0x00, 0x02, 0x02, 0x28]) + .await + .unwrap(); + tx.shutdown().await.unwrap(); + + let mut reader = FakeTlsReader::new(rx); + let mut out = [0u8; 8]; + let err = reader.read(&mut out).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); +} + +#[tokio::test] +async fn reader_rejects_unknown_record_type_fast() { + let (mut tx, rx) = tokio::io::duplex(128); + tx.write_all(&[0x7f, TLS_VERSION[0], TLS_VERSION[1], 0x00, 0x01, 0x01]) + .await + .unwrap(); + tx.shutdown().await.unwrap(); + + let mut reader = FakeTlsReader::new(rx); + let mut out = [0u8; 8]; + let err = reader.read(&mut out).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); +} + +#[tokio::test] +async fn reader_preserves_data_after_valid_ccs_then_valid_appdata() { + let (mut tx, rx) = tokio::io::duplex(256); + tx.write_all(&[ + TLS_RECORD_CHANGE_CIPHER, + TLS_VERSION[0], + TLS_VERSION[1], + 0x00, + 0x01, + 0x01, + TLS_RECORD_APPLICATION, + TLS_VERSION[0], + TLS_VERSION[1], + 0x00, + 0x03, + 0xDE, + 0xAD, + 0xBE, + ]) + .await + .unwrap(); + tx.shutdown().await.unwrap(); + + let mut reader = FakeTlsReader::new(rx); + let mut out = [0u8; 3]; + let n = reader.read(&mut out).await.unwrap(); + assert_eq!(n, 3); + assert_eq!(out, [0xDE, 0xAD, 0xBE]); +} + +#[test] +fn deterministic_lcg_never_breaks_validation_invariants() { + let mut x: u64 = 0xD1A5_CE55_0BAD_F00D; + for _ in 0..20000 { + x = x.wrapping_mul(6364136223846793005).wrapping_add(1); + let record_type = (x & 0xFF) as u8; + let version = match (x >> 8) & 0x3 { + 0 => TLS_VERSION, + 1 => [0x03, 0x01], + 2 => [0x03, 0x02], + _ => [0x03, 0x04], + }; + let len = ((x >> 16) & 0xFFFF) as u16; + + let accepted = validates(record_type, version, len); + + let expected = match record_type { + TLS_RECORD_APPLICATION => { + version == TLS_VERSION && len >= 1 && usize::from(len) <= MAX_TLS_CIPHERTEXT_SIZE + } + TLS_RECORD_CHANGE_CIPHER => version == TLS_VERSION && len == 1, + TLS_RECORD_ALERT => version == TLS_VERSION && len == 2, + TLS_RECORD_HANDSHAKE => { + (version == TLS_VERSION || version == [0x03, 0x01]) + && (4..=MAX_TLS_PLAINTEXT_SIZE).contains(&usize::from(len)) + } + _ => false, + }; + + assert_eq!( + accepted, expected, + "invariant mismatch: type=0x{record_type:02x} version={version:02x?} len={len}" + ); + } +} diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 9140b39..063fcbb 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -1,12 +1,13 @@ use crate::crypto::{sha256_hmac, SecureRandom}; use crate::protocol::constants::{ + MAX_TLS_CIPHERTEXT_SIZE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_VERSION, }; use crate::protocol::tls::{TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key}; use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource}; const MIN_APP_DATA: usize = 64; -const MAX_APP_DATA: usize = 16640; // RFC 8446 §5.2 allows up to 2^14 + 256 +const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE; fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec { sizes