Files
netrunner-proxy/core/src/nrxp/bridge.rs
T
nineap 99a9eee908 Улучшить 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>
2026-07-06 18:05:10 +07:00

289 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TLS-обёртка: граница между NRXP и маскирующим слоем [`tlseng`](crate::tlseng).
//!
//! [`TlsBridge`] — единственная точка, где протокол соприкасается с TLS-кадрами.
//! Он умеет две вещи:
//!
//! 1. **Хендшейк.** Собрать `ClientHello` (клиент) / `ServerHello` (сервер) с
//! нужным профилем браузера и провести обмен ключами. На стороне сервера здесь
//! же проверяется начальный auth-тег, спрятанный в `session_id` ClientHello, —
//! первый барьер против чужих/сканирующих подключений.
//! 2. **Data-фаза.** Упаковать готовый шифртекст в TLS-запись `ApplicationData`
//! ([`pack_app_data`](TlsBridge::pack_app_data)) и распаковать обратно
//! ([`unpack_app_data`](TlsBridge::unpack_app_data)).
//!
//! Внутренний трейт [`TlsInterceptor`] задаёт общий каркас «распарсить TLS-запись
//! → проверить её тип → достать полезное содержимое» для хендшейка и AppData.
use crate::crypto::{SessionAuth, SessionKeys};
use crate::nrxp::errors::{ErrorAction, ErrorStage, TlsError};
use crate::parser::Parser;
use crate::tlseng::ExtensionStack;
use crate::tlseng::{ApplicationData, TlsRecord};
use crate::tlseng::{BrowserProfile, ServerProfile};
use crate::tlseng::{ClientHello, HelloHeader, ServerHello};
use crate::tlseng::{ContentType, HelloType};
use bytes::{Bytes, BytesMut};
/// Каркас разбора TLS-записи нужного типа: `start_process` парсит запись и
/// делегирует в `handle_record`, который проверяет content-type и извлекает
/// типизированный результат.
trait TlsInterceptor {
type Output;
fn start_process(buffer: &mut BytesMut) -> Result<Option<Self::Output>, TlsError> {
match TlsRecord::parse(buffer) {
Ok(Some(record)) => Self::handle_record(record),
Ok(None) => Ok(None),
Err(e) => Err(e),
}
}
fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError>;
}
/// Разобранное handshake-сообщение одной из сторон вместе с его TLS-расширениями
/// (в расширениях лежат публичный ключ KeyShare и прочие поля, нужные для вывода
/// ключей сессии).
pub(crate) enum HandshakeMessage {
/// `ClientHello` от клиента.
Client {
base: ClientHello,
extensions: ExtensionStack,
},
/// `ServerHello` от сервера.
Server {
base: ServerHello,
extensions: ExtensionStack,
},
}
impl HandshakeMessage {
pub fn random(&self) -> [u8; 32] {
match self {
Self::Client { base, .. } => base.random,
Self::Server { base, .. } => base.random,
}
}
pub fn extensions(&self) -> &ExtensionStack {
match self {
Self::Client { extensions, .. } => extensions,
Self::Server { extensions, .. } => extensions,
}
}
}
impl TlsInterceptor for HandshakeMessage {
type Output = HandshakeMessage;
/// `record` здесь уже целиком получена с провода (длина взята из заголовка
/// TLS-записи и `TlsRecord::parse` дожидается ровно стольких байт). Значит,
/// если `HelloHeader`/`ClientHello`/`ServerHello` не смогли разобрать этот
/// payload целиком — это не «пришло не всё», а испорченный/чужой hello.
///
/// Раньше такой случай тихо возвращал `Ok(None)`, и вызывающий код
/// (`ServerHandler::run`) трактовал его как «нужно больше данных» и ждал
/// ещё до [`TLS_HELLO_TIMEOUT`](crate::net::TLS_HELLO_TIMEOUT) (10с), хотя
/// новых байт для уже полностью прочитанной записи никогда не придёт —
/// stealth-fallback запускался с большой задержкой вместо немедленно,
/// нарушая заявленный принцип «при малейшем несоответствии — fallback».
fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError> {
if record.content_type != ContentType::Handshake {
return Err(TlsError::new(
ErrorStage::Handshake("Expected Handshake record"),
ErrorAction::Drop,
record.serialize(),
));
}
let malformed = || {
TlsError::new(
ErrorStage::Handshake("Malformed handshake message"),
ErrorAction::Drop,
Bytes::new(),
)
};
let mut payload = BytesMut::from(record.payload.as_ref());
let header = HelloHeader::parse(&mut payload)?.ok_or_else(malformed)?;
match header.header_type {
HelloType::Client => {
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(),
)
})?;
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(),
)
})?;
Ok(Some(HandshakeMessage::Server {
base: hello,
extensions: ext,
}))
}
}
}
}
impl TlsInterceptor for ApplicationData {
type Output = ApplicationData;
fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError> {
if record.content_type != ContentType::ApplicationData {
return Err(TlsError::new(
ErrorStage::ApplicationData("Expected AppData record"),
ErrorAction::Drop,
record.serialize(),
));
}
Ok(Some(ApplicationData {
_len: record.payload.len(),
payload: record.payload,
}))
}
}
/// Маркер «фиктивная запись 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;
impl TlsBridge {
/// Распаковать handshake-сообщение (`ClientHello`/`ServerHello`) из буфера.
pub fn unpack_handshake(buffer: &mut BytesMut) -> Result<Option<HandshakeMessage>, TlsError> {
HandshakeMessage::start_process(buffer)
}
/// Распаковать TLS-запись `ApplicationData` (ещё зашифрованный кадр NRXP).
pub fn unpack_app_data(buffer: &mut BytesMut) -> Result<Option<ApplicationData>, TlsError> {
ApplicationData::start_process(buffer)
}
/// Собрать `ClientHello` по профилю браузера для маскировки (клиент).
pub fn wrap_client_hello(profile: &BrowserProfile, host: &str, keys: &SessionKeys) -> Bytes {
ClientHello::make_client_hello(profile, host, keys)
}
/// Обработать `ClientHello` и собрать ответный `ServerHello` (сервер).
///
/// Порядок критичен: сначала проверяется 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, 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(),
));
}
let peer_version = base.session_id[0];
let mut received_tag = [0u8; 16];
received_tag.copy_from_slice(&base.session_id[16..32]);
// ВАЖНО: Используем SessionAuth для проверки начального тега хендшейка
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(),
));
}
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();
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(),
))
}
}
/// Обернуть готовый шифртекст кадра в 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(|_| ()))
}
}