ferogram_connect/tls_record.rs
1/*
2 * Copyright (c) 2026 Ankit Chaubey <ankitchaubey.dev@gmail.com>
3 * https://github.com/ankit-chaubey
4 *
5 * Project: ferogram
6 * Website: https://ferogram.dev
7 *
8 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
9 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
10 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
11 * This file may not be copied, modified, or distributed except according
12 * to those terms.
13 */
14
15//! TLS record byte-stream framing used by the FakeTLS (`0xEE`) MTProxy
16//! transport.
17//!
18//! Real MTProxy FakeTLS is the *same* Obfuscated2/PaddedIntermediate
19//! transport used by `dd` secrets. It is simply carried inside a decoy TLS
20//! 1.3 handshake and TLS record byte-stream framing so the traffic looks
21//! like ordinary HTTPS to DPI. These helpers only deal with that outer
22//! record framing -- they know nothing about the inner Obfuscated2 cipher
23//! or PaddedIntermediate frame shape, so they're pure and independently
24//! testable.
25
26pub const RECORD_HANDSHAKE: u8 = 0x16;
27pub const RECORD_CHANGE_CIPHER_SPEC: u8 = 0x14;
28pub const RECORD_APPLICATION_DATA: u8 = 0x17;
29pub const RECORD_HEADER_LEN: usize = 5;
30/// TLS max plaintext record payload size (2^14), the same chunk ceiling
31/// tdesktop and other clients split at.
32pub const RECORD_MAX_CHUNK: usize = 16384;
33
34/// Wrap already-encrypted bytes into one or more TLS Application Data
35/// records (`0x17 0x03 0x03 <len(be16)> <data>`), splitting at
36/// [`RECORD_MAX_CHUNK`]. Appends to `out`.
37pub fn wrap_application_data(ciphertext: &[u8], out: &mut Vec<u8>) {
38 if ciphertext.is_empty() {
39 return;
40 }
41 for chunk in ciphertext.chunks(RECORD_MAX_CHUNK) {
42 out.push(RECORD_APPLICATION_DATA);
43 out.extend_from_slice(&[0x03, 0x03]);
44 out.extend_from_slice(&(chunk.len() as u16).to_be_bytes());
45 out.extend_from_slice(chunk);
46 }
47}
48
49/// The one-time leading ChangeCipherSpec decoy record
50/// (`0x14 0x03 0x03 0x00 0x01 0x01`) real MTProxy FakeTLS servers expect
51/// before the first real Application Data record.
52pub fn change_cipher_spec_record() -> [u8; 6] {
53 [0x14, 0x03, 0x03, 0x00, 0x01, 0x01]
54}
55
56/// Result of scanning a byte buffer for complete TLS records.
57pub struct Unwrapped {
58 /// Ciphertext bytes extracted from complete data-bearing records
59 /// (ChangeCipherSpec and Application Data), concatenated in wire order.
60 pub ciphertext: Vec<u8>,
61 /// Bytes consumed from the front of the input (always a whole number of
62 /// complete records). The caller should drain this many bytes from its
63 /// pending buffer and keep the remainder (a partial trailing record, if
64 /// any) for the next call.
65 pub consumed: usize,
66}
67
68/// Scan `pending` for complete TLS records during steady-state (post
69/// handshake) I/O.
70///
71/// ChangeCipherSpec (`0x14`) and Application Data (`0x17`) record payloads
72/// are both treated as real ciphertext bytes: some MTProxy servers echo a
73/// ChangeCipherSpec-typed record as their own one-time decoy prefix,
74/// mirroring the client's own leading record (see
75/// [`change_cipher_spec_record`]), and it must be folded into the same
76/// byte stream the client's leading record occupies on the wire.
77///
78/// A Handshake (`0x16`) record at this point is a protocol error: the decoy
79/// handshake is already over by the time this is called.
80///
81/// Stops at the first incomplete/partial record, leaving it (and anything
82/// after) unconsumed.
83pub fn unwrap_records(pending: &[u8]) -> Result<Unwrapped, String> {
84 let mut offset = 0usize;
85 let mut ciphertext = Vec::new();
86 loop {
87 if pending.len() < offset + RECORD_HEADER_LEN {
88 break;
89 }
90 let rec_type = pending[offset];
91 let len = u16::from_be_bytes([pending[offset + 3], pending[offset + 4]]) as usize;
92 let total = RECORD_HEADER_LEN + len;
93 if pending.len() < offset + total {
94 break;
95 }
96 match rec_type {
97 RECORD_APPLICATION_DATA | RECORD_CHANGE_CIPHER_SPEC => {
98 ciphertext.extend_from_slice(&pending[offset + RECORD_HEADER_LEN..offset + total]);
99 }
100 RECORD_HANDSHAKE => {
101 return Err(
102 "FakeTLS: unexpected Handshake record after handshake completed".into(),
103 );
104 }
105 other => {
106 return Err(format!("FakeTLS: unexpected TLS record type 0x{other:02x}"));
107 }
108 }
109 offset += total;
110 }
111 Ok(Unwrapped {
112 ciphertext,
113 consumed: offset,
114 })
115}
116
117/// A single raw TLS record as read during the handshake: its type byte and
118/// full wire bytes (5-byte header included).
119pub struct RawRecord {
120 pub rec_type: u8,
121 pub bytes: Vec<u8>,
122}
123
124/// Read exactly one TLS record from `stream`, blocking until the 5-byte
125/// header and its declared-length payload have both arrived.
126pub async fn read_one_record(stream: &mut tokio::net::TcpStream) -> std::io::Result<RawRecord> {
127 use tokio::io::AsyncReadExt;
128 let mut hdr = [0u8; RECORD_HEADER_LEN];
129 stream.read_exact(&mut hdr).await?;
130 let len = u16::from_be_bytes([hdr[3], hdr[4]]) as usize;
131 let mut bytes = Vec::with_capacity(RECORD_HEADER_LEN + len);
132 bytes.extend_from_slice(&hdr);
133 let mut body = vec![0u8; len];
134 stream.read_exact(&mut body).await?;
135 bytes.extend_from_slice(&body);
136 Ok(RawRecord {
137 rec_type: hdr[0],
138 bytes,
139 })
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn wrap_then_unwrap_roundtrip() {
148 let data = vec![7u8; 40000]; // spans multiple 16384-byte records
149 let mut wire = Vec::new();
150 wrap_application_data(&data, &mut wire);
151
152 let result = unwrap_records(&wire).expect("unwrap");
153 assert_eq!(result.consumed, wire.len());
154 assert_eq!(result.ciphertext, data);
155 }
156
157 #[test]
158 fn partial_record_left_unconsumed() {
159 let data = vec![1u8, 2, 3, 4, 5];
160 let mut wire = Vec::new();
161 wrap_application_data(&data, &mut wire);
162 wire.truncate(wire.len() - 1); // chop the last byte off
163
164 let result = unwrap_records(&wire).expect("unwrap");
165 assert_eq!(result.consumed, 0);
166 assert!(result.ciphertext.is_empty());
167 }
168
169 #[test]
170 fn change_cipher_spec_payload_is_folded_in() {
171 let mut wire = Vec::new();
172 wire.extend_from_slice(&change_cipher_spec_record());
173 wrap_application_data(&[9, 9, 9], &mut wire);
174
175 let result = unwrap_records(&wire).expect("unwrap");
176 assert_eq!(result.consumed, wire.len());
177 assert_eq!(result.ciphertext, vec![1, 9, 9, 9]);
178 }
179}