Улучшить TLS-маскировку, ввести версионирование протокола, добавить тесты и нагрузочный инструмент
Маскировка: - ChangeCipherSpec middlebox-compat запись после ClientHello/ServerHello — без неё последовательность типов TLS-записей отличала нас от настоящего браузера даже при идеальном JA3/JA4-отпечатке. - SNI-aware stealth fallback: невалидный клиент проксируется на запрошенный им хост, а не всегда на один и тот же decoy — иначе активное зондирование с разными SNI на одном IP получало одинаковый ответ. Резолв только в публичные IP (защита от SSRF на внутреннюю сеть ноды через подставной SNI). - Профиль браузера привязан к session_id, а не к номеру попытки реконнекта: раньше при нескольких быстрых ретраях с одного IP летели ClientHello Chrome→Edge→Firefox подряд — сама по себе аномалия для DPI. - Бакетный паддинг Data/UdpData кадров вместо полного отсутствия паддинга; джиттер перед ServerHello вместо мгновенного детерминированного ответа. Версионирование: - PROTOCOL_VERSION в session_id ClientHello — сервер узнаёт версию клиента до отправки чего-либо и может включать версионно-зависимое поведение (сейчас — CCS) только для тех, кто его понимает. Позволяет катить будущие несовместимые изменения протокола без синхронного flag-day релиза. Тесты (core, было 0): - Round-trip тесты TLS-записей/hello-сообщений/кадров/кодека. - Полный цикл хендшейка с реальным выводом ключей на обеих сторонах. - Вся SSRF-защита (hostname/IP валидация, v4/v6). - Интеграционные тесты по настоящему TCP: легитимный хендшейк и stealth-fallback на мусорный ClientHello. tools/loadtest — нагрузочный тест поверх настоящего скомпилированного netrunner-server (реальный процесс, метрики RSS/CPU из /proc) и настоящего публичного клиентского API. Попутно найден и обойдён гоночный баг: Connect-кадр в ClientHandler::connect() уходит через try_send ещё до того, как поднимется хоть одна нога тунеля, и молча теряется при синтетической нагрузке. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,11 @@
|
||||
- **Auth Tag** — TOTP-подобный HMAC-тег (см. [`crypto`](../crypto)), анти-replay.
|
||||
- **Stream ID** — id логического потока (нечётные у клиента, чётные у сервера).
|
||||
- **Type** — `Connect`/`Data`/`Close`/`Heartbeat`/`UdpConnect`/`UdpData`.
|
||||
- **Padding** — случайные байты против анализа длин; только у управляющих кадров
|
||||
(`Data`/`UdpData` не паддятся ради throughput).
|
||||
- **Padding** — против анализа длин. Управляющие кадры получают 0..255
|
||||
случайных байт; `Data`/`UdpData` выравниваются до ближайшего бакета
|
||||
(256/512/1024/2048/4096/8192) — кроме кадров, уже близких к максимальному
|
||||
размеру (крупные закачки), где паддинг только бил бы по throughput без
|
||||
выигрыша в приватности.
|
||||
|
||||
## Конвейер
|
||||
|
||||
|
||||
+91
-14
@@ -112,17 +112,31 @@ impl TlsInterceptor for HandshakeMessage {
|
||||
let hello = ClientHello::parse(&mut payload)?.ok_or_else(malformed)?;
|
||||
let ext = ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))?
|
||||
.ok_or_else(|| {
|
||||
TlsError::new(ErrorStage::Handshake("Ext Err"), ErrorAction::Drop, Bytes::new())
|
||||
TlsError::new(
|
||||
ErrorStage::Handshake("Ext Err"),
|
||||
ErrorAction::Drop,
|
||||
Bytes::new(),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(HandshakeMessage::Client { base: hello, extensions: ext }))
|
||||
Ok(Some(HandshakeMessage::Client {
|
||||
base: hello,
|
||||
extensions: ext,
|
||||
}))
|
||||
}
|
||||
HelloType::Server => {
|
||||
let hello = ServerHello::parse(&mut payload)?.ok_or_else(malformed)?;
|
||||
let ext = ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))?
|
||||
.ok_or_else(|| {
|
||||
TlsError::new(ErrorStage::Handshake("Ext Err"), ErrorAction::Drop, Bytes::new())
|
||||
TlsError::new(
|
||||
ErrorStage::Handshake("Ext Err"),
|
||||
ErrorAction::Drop,
|
||||
Bytes::new(),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(HandshakeMessage::Server { base: hello, extensions: ext }))
|
||||
Ok(Some(HandshakeMessage::Server {
|
||||
base: hello,
|
||||
extensions: ext,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +160,26 @@ impl TlsInterceptor for ApplicationData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Маркер «фиктивная запись ChangeCipherSpec прочитана и вырезана из буфера» —
|
||||
/// см. [`TlsBridge::build_middlebox_ccs`]/[`TlsBridge::unpack_middlebox_ccs`].
|
||||
/// Сама запись не несёт полезной нагрузки, поэтому у типа нет полей.
|
||||
struct ChangeCipherSpecMarker;
|
||||
|
||||
impl TlsInterceptor for ChangeCipherSpecMarker {
|
||||
type Output = ChangeCipherSpecMarker;
|
||||
|
||||
fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError> {
|
||||
if record.content_type != ContentType::ChangeCipherSpec {
|
||||
return Err(TlsError::new(
|
||||
ErrorStage::Tls("Expected ChangeCipherSpec record"),
|
||||
ErrorAction::Drop,
|
||||
record.serialize(),
|
||||
));
|
||||
}
|
||||
Ok(Some(ChangeCipherSpecMarker))
|
||||
}
|
||||
}
|
||||
|
||||
/// Фасад TLS-обёртки. Безсостоятельный набор статических операций над буферами;
|
||||
/// всё состояние сессии живёт в [`SessionKeys`], которые передаются явно.
|
||||
pub(crate) struct TlsBridge;
|
||||
@@ -171,16 +205,27 @@ impl TlsBridge {
|
||||
/// Порядок критичен: сначала проверяется auth-тег из `session_id`
|
||||
/// (16 байт со смещения 16) — неверный тег ⇒ отказ ещё до любых
|
||||
/// криптоопераций; затем выводятся ключи сессии и формируется ответ.
|
||||
///
|
||||
/// Возвращает вместе с готовым `ServerHello` заявленную клиентом версию
|
||||
/// протокола (`session_id[0]`, см. [`crate::PROTOCOL_VERSION`]) — вызывающий
|
||||
/// код использует её, чтобы решить, какое версионно-зависимое поведение
|
||||
/// (например, обмен `ChangeCipherSpec`) включать именно для этого клиента.
|
||||
pub fn wrap_server_hello(
|
||||
client_msg: &HandshakeMessage,
|
||||
keys: &mut SessionKeys,
|
||||
profile: &ServerProfile,
|
||||
) -> Result<Bytes, TlsError> {
|
||||
) -> Result<(Bytes, u8), TlsError> {
|
||||
if let HandshakeMessage::Client { base, extensions } = client_msg {
|
||||
if base.session_id.len() != 32 {
|
||||
return Err(TlsError::new(ErrorStage::Handshake("Invalid SessionID len"), ErrorAction::Drop, Bytes::new()));
|
||||
return Err(TlsError::new(
|
||||
ErrorStage::Handshake("Invalid SessionID len"),
|
||||
ErrorAction::Drop,
|
||||
Bytes::new(),
|
||||
));
|
||||
}
|
||||
|
||||
let peer_version = base.session_id[0];
|
||||
|
||||
let mut received_tag = [0u8; 16];
|
||||
received_tag.copy_from_slice(&base.session_id[16..32]);
|
||||
|
||||
@@ -188,24 +233,56 @@ impl TlsBridge {
|
||||
let auth = SessionAuth::new(keys.get_auth_key());
|
||||
if !auth.verify_tag(&received_tag) {
|
||||
netrunner_logger::warn!("Unauthorized ClientHello: Auth Tag mismatch");
|
||||
return Err(TlsError::new(ErrorStage::Handshake("Auth Failed"), ErrorAction::Drop, Bytes::new()));
|
||||
return Err(TlsError::new(
|
||||
ErrorStage::Handshake("Auth Failed"),
|
||||
ErrorAction::Drop,
|
||||
Bytes::new(),
|
||||
));
|
||||
}
|
||||
|
||||
keys.update_keys(base.random, extensions, true).map_err(|e| {
|
||||
netrunner_logger::error!(error = %e, "Server failed key update");
|
||||
TlsError::new(ErrorStage::Handshake("Key Exchange Failed"), ErrorAction::Drop, Bytes::new())
|
||||
})?;
|
||||
keys.update_keys(base.random, extensions, true)
|
||||
.map_err(|e| {
|
||||
netrunner_logger::error!(error = %e, "Server failed key update");
|
||||
TlsError::new(
|
||||
ErrorStage::Handshake("Key Exchange Failed"),
|
||||
ErrorAction::Drop,
|
||||
Bytes::new(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let server_pub_key = keys.public_key_bytes();
|
||||
|
||||
Ok(ServerHello::make_server_hello(base, &server_pub_key, keys.local_salt(), profile))
|
||||
let hello =
|
||||
ServerHello::make_server_hello(base, &server_pub_key, keys.local_salt(), profile);
|
||||
|
||||
Ok((hello, peer_version))
|
||||
} else {
|
||||
Err(TlsError::new(ErrorStage::Handshake("Expected ClientHello"), ErrorAction::Drop, Bytes::new()))
|
||||
Err(TlsError::new(
|
||||
ErrorStage::Handshake("Expected ClientHello"),
|
||||
ErrorAction::Drop,
|
||||
Bytes::new(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Обернуть готовый шифртекст кадра в TLS-запись `ApplicationData` (`0x17`).
|
||||
pub fn pack_app_data(buffer: Bytes) -> Bytes {
|
||||
TlsRecord::build_application_data(buffer)
|
||||
}
|
||||
|
||||
/// Байты фиктивной записи `ChangeCipherSpec` — обе наши стороны шлют её
|
||||
/// сразу после своего Hello ради middlebox-совместимости TLS 1.3 (RFC 8446
|
||||
/// Appendix D.4), как это делают настоящие браузеры (см. doc на
|
||||
/// [`ContentType::ChangeCipherSpec`](crate::tlseng::ContentType)).
|
||||
pub fn build_middlebox_ccs() -> Bytes {
|
||||
TlsRecord::build_change_cipher_spec()
|
||||
}
|
||||
|
||||
/// Дождаться (если нужно больше байт — `Ok(None)`) и вырезать из буфера
|
||||
/// `ChangeCipherSpec`, присланный пиром сразу после его Hello. Пир — наша
|
||||
/// же реализация на другом конце, поэтому запись всегда присутствует;
|
||||
/// её отсутствие/искажение — рассинхрон протокола ([`ErrorAction::Drop`]).
|
||||
pub fn unpack_middlebox_ccs(buffer: &mut BytesMut) -> Result<Option<()>, TlsError> {
|
||||
Ok(ChangeCipherSpecMarker::start_process(buffer)?.map(|_| ()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,3 +204,112 @@ impl Codec {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::ChaChaCipher;
|
||||
|
||||
const AUTH_KEY: [u8; 32] = [0x11; 32];
|
||||
const KEY_A: [u8; 32] = [0xAA; 32];
|
||||
const IV_A: [u8; 12] = [0x01; 12];
|
||||
const KEY_B: [u8; 32] = [0xBB; 32];
|
||||
const IV_B: [u8; 12] = [0x02; 12];
|
||||
|
||||
/// Пара codec'ов "клиент+сервер" с ключами, зеркальными друг другу — так же,
|
||||
/// как их реально назначает `SessionKeys::generate_keys` по ролям (tx одной
|
||||
/// стороны == rx другой).
|
||||
fn client_server_pair() -> ((RxCodec, TxCodec), (RxCodec, TxCodec)) {
|
||||
let mut client_cipher = ChaChaCipher::new();
|
||||
client_cipher.set_keys(KEY_A, IV_A, KEY_B, IV_B); // tx=A, rx=B
|
||||
let mut server_cipher = ChaChaCipher::new();
|
||||
server_cipher.set_keys(KEY_B, IV_B, KEY_A, IV_A); // tx=B, rx=A
|
||||
|
||||
(
|
||||
Codec::new(client_cipher, AUTH_KEY).split(),
|
||||
Codec::new(server_cipher, AUTH_KEY).split(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_to_server_round_trip() {
|
||||
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
|
||||
|
||||
let wire = client_tx
|
||||
.encode_frame(
|
||||
5,
|
||||
FrameType::Data,
|
||||
Bytes::from_static(b"payload from client"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut buf = BytesMut::from(&wire[..]);
|
||||
let frame = server_rx.decode_inbound(&mut buf).unwrap().unwrap();
|
||||
|
||||
assert_eq!(frame.header.stream_id, 5);
|
||||
assert_eq!(frame.header.frame_type, FrameType::Data);
|
||||
assert_eq!(&frame.payload[..], b"payload from client");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_to_client_round_trip() {
|
||||
let ((mut client_rx, _client_tx), (_server_rx, mut server_tx)) = client_server_pair();
|
||||
|
||||
let wire = server_tx
|
||||
.encode_frame(6, FrameType::Heartbeat, Bytes::from_static(b"pong"))
|
||||
.unwrap();
|
||||
|
||||
let mut buf = BytesMut::from(&wire[..]);
|
||||
let frame = client_rx.decode_inbound(&mut buf).unwrap().unwrap();
|
||||
assert_eq!(&frame.payload[..], b"pong");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_frames_keep_nonce_counters_in_sync() {
|
||||
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
|
||||
|
||||
for i in 0..20u32 {
|
||||
let payload = format!("frame-{i}");
|
||||
let wire = client_tx
|
||||
.encode_frame(1, FrameType::Data, Bytes::from(payload.clone()))
|
||||
.unwrap();
|
||||
let mut buf = BytesMut::from(&wire[..]);
|
||||
let frame = server_rx.decode_inbound(&mut buf).unwrap().unwrap();
|
||||
assert_eq!(&frame.payload[..], payload.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_ciphertext_fails_aead_and_drops() {
|
||||
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
|
||||
|
||||
let wire = client_tx
|
||||
.encode_frame(1, FrameType::Data, Bytes::from_static(b"secret"))
|
||||
.unwrap();
|
||||
|
||||
let mut tampered = BytesMut::from(&wire[..]);
|
||||
let last = tampered.len() - 1;
|
||||
tampered[last] ^= 0xFF; // flip a byte inside the AEAD tag
|
||||
|
||||
let result = server_rx.decode_inbound(&mut tampered);
|
||||
assert!(result.is_err(), "tampered ciphertext must not decrypt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_frame_desyncs_nonce_and_fails() {
|
||||
// Кадр расшифровывается один раз успешно; повторная подача ТЕХ ЖЕ байт
|
||||
// получателю с уже продвинувшимся счётчиком nonce должна провалиться —
|
||||
// это и есть встроенная защита от replay на уровне AEAD-потока.
|
||||
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
|
||||
|
||||
let wire = client_tx
|
||||
.encode_frame(1, FrameType::Data, Bytes::from_static(b"once"))
|
||||
.unwrap();
|
||||
|
||||
let mut first = BytesMut::from(&wire[..]);
|
||||
assert!(server_rx.decode_inbound(&mut first).unwrap().is_some());
|
||||
|
||||
let mut replay = BytesMut::from(&wire[..]);
|
||||
assert!(server_rx.decode_inbound(&mut replay).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+146
-5
@@ -99,15 +99,17 @@ impl Frame {
|
||||
/// Сериализует кадр в [`BytesMut`], готовый к шифрованию на месте.
|
||||
///
|
||||
/// `auth_key` здесь — это уже готовый 16-байтовый тег (имя историческое),
|
||||
/// который кладётся в начало заголовка. Для `Data`/`UdpData` padding не
|
||||
/// добавляется (throughput важнее), для остальных типов — 0..255 случайных
|
||||
/// байт. Буфер выделяется один раз точно под итоговый размер; заголовок
|
||||
/// собирается на стеке и пишется одним `copy_from_slice`.
|
||||
/// который кладётся в начало заголовка. Для `Data`/`UdpData` — выравнивание
|
||||
/// до ближайшего бакета из [`bucket_padding`] (throughput всё ещё важнее,
|
||||
/// поэтому кадры, уже близкие к максимальному размеру, не паддятся вовсе),
|
||||
/// для остальных типов — 0..255 случайных байт. Буфер выделяется один раз
|
||||
/// точно под итоговый размер; заголовок собирается на стеке и пишется
|
||||
/// одним `copy_from_slice`.
|
||||
#[inline]
|
||||
pub(crate) fn into_bytes(mut self, auth_key: &[u8; 16]) -> BytesMut {
|
||||
// 🔥 ОПТИМИЗАЦИЯ: Быстрая побитовая маска (& 0xFF) вместо дорогого деления с остатком (%)
|
||||
let padding_len = match self.header.frame_type {
|
||||
FrameType::Data | FrameType::UdpData => 0,
|
||||
FrameType::Data | FrameType::UdpData => Self::bucket_padding(self.payload.len()),
|
||||
_ => (rand::rng().next_u32() & 0xFF) as u16,
|
||||
};
|
||||
|
||||
@@ -139,6 +141,29 @@ impl Frame {
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Длина паддинга для выравнивания `Data`/`UdpData` кадра до ближайшего
|
||||
/// "круглого" бакета вместо точной длины полезной нагрузки.
|
||||
///
|
||||
/// Не паддит кадры, уже близкие к [`MAX_FRAME_PAYLOAD`] (крупные бакеты
|
||||
/// закачек) — это почти весь трафик объёмных передач, где паддинг только
|
||||
/// снижал бы throughput без выигрыша в приватности (снаружи и так виден
|
||||
/// кадр максимального размера, угадывать в нём нечего). Именно маленькие
|
||||
/// кадры (запросы, интерактив, начало HTTP-ответа) — то место, где по
|
||||
/// точной длине конкретного пакета легче всего строить атаки
|
||||
/// website/traffic fingerprinting поверх уже неотличимого от HTTPS
|
||||
/// хендшейка, поэтому их выравнивание даёт больше всего эффекта за
|
||||
/// наименьшие накладные расходы.
|
||||
#[inline]
|
||||
fn bucket_padding(payload_len: usize) -> u16 {
|
||||
const BUCKETS: [usize; 6] = [256, 512, 1024, 2048, 4096, 8192];
|
||||
for &bucket in &BUCKETS {
|
||||
if payload_len <= bucket {
|
||||
return (bucket - payload_len) as u16;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Разбор только заголовка: `can_parse` проверяет, накопились ли 25 байт,
|
||||
@@ -243,3 +268,119 @@ impl Parser for Frame {
|
||||
Ok(Some(Self { header, payload }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AUTH_KEY: [u8; 16] = [0x42; 16];
|
||||
|
||||
fn round_trip(frame_type: FrameType, payload: &[u8]) -> Frame {
|
||||
let frame = Frame::new(7, frame_type, Bytes::copy_from_slice(payload));
|
||||
let mut wire = frame.into_bytes(&AUTH_KEY);
|
||||
Frame::parse(&mut wire).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_payload_and_metadata() {
|
||||
let parsed = round_trip(FrameType::Data, b"some tunnel payload");
|
||||
assert_eq!(parsed.header.stream_id, 7);
|
||||
assert_eq!(parsed.header.frame_type, FrameType::Data);
|
||||
assert_eq!(&parsed.payload[..], b"some tunnel payload");
|
||||
assert_eq!(parsed.header.auth_tag, AUTH_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_frames_get_random_padding_0_to_255() {
|
||||
for frame_type in [
|
||||
FrameType::Connect,
|
||||
FrameType::Close,
|
||||
FrameType::Heartbeat,
|
||||
FrameType::UdpConnect,
|
||||
FrameType::Diag,
|
||||
FrameType::Credit,
|
||||
] {
|
||||
let frame = Frame::new(1, frame_type, Bytes::from_static(b"x"));
|
||||
let wire = frame.into_bytes(&AUTH_KEY);
|
||||
// padding_len живёт в байтах 23..25 заголовка.
|
||||
let padding_len = u16::from_be_bytes([wire[23], wire[24]]);
|
||||
assert!(
|
||||
padding_len <= 255,
|
||||
"{:?} padding {} exceeds the 0..=255 range",
|
||||
frame_type,
|
||||
padding_len
|
||||
);
|
||||
assert_eq!(
|
||||
wire.len(),
|
||||
FRAME_HEADER_SIZE as usize + 1 + padding_len as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_frames_never_get_legacy_unbounded_padding() {
|
||||
// Регрессия: раньше Data/UdpData вообще не паддились (padding_len == 0
|
||||
// всегда). Теперь бакетное выравнивание — здесь просто фиксируем, что
|
||||
// поведение осознанно изменилось, а не просто "иногда 0".
|
||||
let frame = Frame::new(1, FrameType::Data, Bytes::copy_from_slice(&[0u8; 100]));
|
||||
let wire = frame.into_bytes(&AUTH_KEY);
|
||||
let padding_len = u16::from_be_bytes([wire[23], wire[24]]);
|
||||
assert_eq!(padding_len, (256 - 100) as u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_padding_boundaries() {
|
||||
// На границе бакета — паддинг 0 (уже ровно на бакете).
|
||||
assert_eq!(Frame::bucket_padding(256), 0);
|
||||
assert_eq!(Frame::bucket_padding(512), 0);
|
||||
assert_eq!(Frame::bucket_padding(8192), 0);
|
||||
// На единицу больше границы — едет в следующий бакет.
|
||||
assert_eq!(Frame::bucket_padding(257), 512 - 257);
|
||||
assert_eq!(Frame::bucket_padding(2049), 4096 - 2049);
|
||||
// Пустой payload — паддится до первого бакета.
|
||||
assert_eq!(Frame::bucket_padding(0), 256);
|
||||
// Крупные кадры (около MAX_FRAME_PAYLOAD) — без паддинга вовсе,
|
||||
// throughput объёмных закачек не должен страдать.
|
||||
assert_eq!(Frame::bucket_padding(8193), 0);
|
||||
assert_eq!(Frame::bucket_padding(MAX_FRAME_PAYLOAD), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_and_udpdata_frames_are_bucketed_identically() {
|
||||
for frame_type in [FrameType::Data, FrameType::UdpData] {
|
||||
let frame = Frame::new(1, frame_type, Bytes::copy_from_slice(&[0u8; 300]));
|
||||
let wire = frame.into_bytes(&AUTH_KEY);
|
||||
let padding_len = u16::from_be_bytes([wire[23], wire[24]]);
|
||||
assert_eq!(padding_len, (512 - 300) as u16);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skips_padding_without_exposing_it() {
|
||||
let frame = Frame::new(3, FrameType::Heartbeat, Bytes::from_static(b"auth-payload"));
|
||||
let mut wire = frame.into_bytes(&AUTH_KEY);
|
||||
|
||||
let parsed = Frame::parse(&mut wire).unwrap().unwrap();
|
||||
assert_eq!(&parsed.payload[..], b"auth-payload");
|
||||
assert!(
|
||||
wire.is_empty(),
|
||||
"parse must advance past payload AND padding (random 0..=255 for control frames), leaving nothing behind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_frame_is_none() {
|
||||
let frame = Frame::new(1, FrameType::Data, Bytes::copy_from_slice(&[0u8; 50]));
|
||||
let mut wire = frame.into_bytes(&AUTH_KEY);
|
||||
wire.truncate(wire.len() - 1);
|
||||
assert!(Frame::parse(&mut wire).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_frame_type_byte_is_an_error() {
|
||||
let frame = Frame::new(1, FrameType::Data, Bytes::copy_from_slice(&[0u8; 10]));
|
||||
let mut wire = frame.into_bytes(&AUTH_KEY);
|
||||
wire[20] = 0xEE; // frame_type byte — не входит ни в один известный вариант
|
||||
assert!(Frame::parse(&mut wire).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@
|
||||
//! - **Stream ID** — id логического потока внутри туннеля (нечётные у клиента,
|
||||
//! чётные у сервера — так стороны не конфликтуют за номера).
|
||||
//! - **Type** — [`FrameType`]: `Connect`/`Data`/`Close`/`Heartbeat`/`UdpConnect`/`UdpData`.
|
||||
//! - **Padding** — случайные байты переменной длины: ломают анализ длин пакетов.
|
||||
//! Добавляется только к управляющим кадрам; кадры `Data`/`UdpData` не паддятся
|
||||
//! (их и так много, паддинг бил бы по throughput).
|
||||
//! - **Padding** — ломает анализ длин пакетов. Управляющие кадры получают
|
||||
//! 0..255 случайных байт; `Data`/`UdpData` выравниваются до ближайшего
|
||||
//! "круглого" бакета (256/512/1024/2048/4096/8192, см. `Frame::into_bytes`)
|
||||
//! — кроме кадров, уже близких к максимальному размеру (крупные закачки),
|
||||
//! где паддинг только бил бы по throughput без выигрыша в приватности.
|
||||
//!
|
||||
//! ## Конвейер кодирования
|
||||
//!
|
||||
|
||||
Reference in New Issue
Block a user