Skip to main content

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}