docs [AI]

This commit is contained in:
2026-06-30 18:51:09 +07:00
parent 18cad76e38
commit f7a76353ea
67 changed files with 2104 additions and 1417 deletions
+50
View File
@@ -1 +1,51 @@
# Блок `nrxp` — протокол Netrunner eXchange Protocol
Прикладной протокол, который ездит **внутри** замаскированного TLS-канала. Снаружи
трафик выглядит как обычные TLS-записи `ApplicationData` (`0x17`), а внутри каждой
записи — один зашифрованный кадр NRXP с мультиплексированием потоков.
Блок отвечает только за «упаковку/распаковку» и ничего не знает о сети — он
превращает `(stream_id, тип, payload)` в байты и обратно.
> Детали — в rustdoc, модуль `nrxp`.
## Файлы
| Файл | Что внутри |
|--------------|------------------------------------------------------------------|
| `frame.rs` | Структура кадра, (де)сериализация, паддинг. |
| `codec.rs` | Шифрующий слой: `TxCodec`/`RxCodec` (кадр ⇄ зашифрованный TLS). |
| `bridge.rs` | TLS-обёртка `TlsBridge`: хендшейк и `ApplicationData`. |
| `errors.rs` | `TlsError` + стратегия реакции `ErrorAction` (Wait/Redirect/Drop).|
## Формат кадра (25-байтовый заголовок)
```text
┌──────────┬───────────┬──────┬─────────────┬─────────────┬─────────┬─────────┐
│ Auth Tag │ Stream ID │ Type │ Payload Len │ Padding Len │ Payload │ Padding │
│ 16 байт │ 4 байта │ 1 б. │ 2 байта │ 2 байта │ N байт │ 0..255 │
└──────────┴───────────┴──────┴─────────────┴─────────────┴─────────┴─────────┘
└────────────────── FRAME_HEADER_SIZE = 25 ──────────────────┘
```
- **Auth Tag** — TOTP-подобный HMAC-тег (см. [`crypto`](../crypto)), анти-replay.
- **Stream ID** — id логического потока (нечётные у клиента, чётные у сервера).
- **Type** — `Connect`/`Data`/`Close`/`Heartbeat`/`UdpConnect`/`UdpData`.
- **Padding** — случайные байты против анализа длин; только у управляющих кадров
(`Data`/`UdpData` не паддятся ради throughput).
## Конвейер
```text
TX: Frame::new → into_bytes(tag) → AEAD encrypt in-place → TlsBridge::pack_app_data
RX: TlsBridge::unpack_app_data → AEAD decrypt in-place (staging) → Frame::parse
```
**Инвариант:** одна TLS-запись `ApplicationData` = ровно один кадр NRXP. На нём
держится потоковая расшифровка в `codec.rs` (буфер `staging`).
## Связи
- Шифрование — [`crypto`](../crypto) (`AeadPacker`, `SessionAuth`).
- TLS-маскировка и хендшейк — [`tlseng`](../tlseng).
- Кадры передаёт/принимает движок туннеля [`net`](../net).
+35
View File
@@ -1,3 +1,19 @@
//! 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;
@@ -8,6 +24,9 @@ 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;
@@ -22,11 +41,16 @@ trait TlsInterceptor {
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,
@@ -108,21 +132,31 @@ impl TlsInterceptor for ApplicationData {
}
}
/// Фасад 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) — неверный тег ⇒ отказ ещё до любых
/// криптоопераций; затем выводятся ключи сессии и формируется ответ.
pub fn wrap_server_hello(
client_msg: &HandshakeMessage,
keys: &mut SessionKeys,
@@ -156,6 +190,7 @@ impl TlsBridge {
}
}
/// Обернуть готовый шифртекст кадра в TLS-запись `ApplicationData` (`0x17`).
pub fn pack_app_data(buffer: Bytes) -> Bytes {
TlsRecord::build_application_data(buffer)
}
+42
View File
@@ -1,3 +1,25 @@
//! Шифрующий кодек: мост между кадрами [`Frame`] и зашифрованными TLS-записями.
//!
//! Это слой, где встречаются протокол ([`nrxp::frame`](super::frame)),
//! криптография ([`crypto`](crate::crypto)) и TLS-обёртка ([`bridge`](super::bridge)).
//! Кодек разнесён на два независимых направления, чтобы чтение и запись жили в
//! разных задачах tokio без общего мьютекса:
//!
//! - [`TxCodec`] — `Frame` → AEAD-шифр in-place → TLS `ApplicationData`;
//! - [`RxCodec`] — TLS `ApplicationData` → AEAD-дешифр in-place → `Frame`.
//!
//! [`Codec`] — лишь фабрика: создаёт оба направления из одного [`ChaChaCipher`] и
//! `auth_key`, после чего [`split`](Codec::split) раздаёт их reader'у и writer'у.
//!
//! ## Буфер `staging` в [`RxCodec`]
//!
//! Опирается на инвариант «1 TLS-запись = 1 кадр NRXP». TLS-записи
//! расшифровываются по одной в общий буфер `staging`, и сразу делается попытка
//! распарсить кадр. `staging` переживает вызовы `decode_inbound`: если в одном
//! TCP-чтении пришло несколько записей, лишние остаются в нём до следующего
//! вызова. Любой провал AEAD или парсинга после успешной расшифровки трактуется
//! как рассинхрон/tampering → [`ErrorAction::Drop`] (пересоздать ногу с нуля).
use crate::crypto::{AeadPacker, ChaChaCipher, ChaChaStream, SessionAuth};
use crate::nrxp::bridge::TlsBridge;
use crate::nrxp::errors::{ErrorAction, ErrorStage, TlsError};
@@ -5,6 +27,7 @@ use crate::nrxp::frame::{Frame, FrameType};
use crate::parser::Parser;
use bytes::{Bytes, BytesMut};
/// Исходящее направление: шифрует кадры для отправки в туннель.
pub struct TxCodec {
crypto: ChaChaStream,
auth: SessionAuth,
@@ -15,6 +38,11 @@ impl TxCodec {
Self { crypto, auth }
}
/// Кодирует один кадр в готовую к отправке TLS-запись `ApplicationData`.
///
/// Шаги: сгенерировать time-based тег → собрать байты кадра → зашифровать
/// in-place (буфер вырастает на 16 байт AEAD-тега) → обернуть в TLS-запись.
/// Любая ошибка шифрования критична → [`ErrorAction::Drop`].
pub(crate) fn encode_frame(
&mut self,
stream_id: u32,
@@ -42,9 +70,12 @@ impl TxCodec {
}
}
/// Входящее направление: расшифровывает TLS-записи и собирает из них кадры.
pub struct RxCodec {
crypto: ChaChaStream,
auth: SessionAuth,
/// Накопитель расшифрованного открытого текста между вызовами `decode_inbound`
/// (хранит «хвост» кадров, не разобранных в текущем вызове).
staging: BytesMut,
}
@@ -56,6 +87,12 @@ impl RxCodec {
staging,
}
}
/// Пытается извлечь **один** следующий кадр из накопленных TCP-данных.
///
/// Возвращает `Ok(Some(frame))`, если кадр готов; `Ok(None)`, если данных
/// пока недостаточно (ждём следующего чтения сокета); `Err(Drop)` при провале
/// AEAD/парсинга. Сначала дочищает «хвост» из `staging`, затем по одной
/// расшифровывает новые TLS-записи из `buffer`.
pub(crate) fn decode_inbound(
&mut self,
buffer: &mut BytesMut,
@@ -139,12 +176,17 @@ impl RxCodec {
}
}
/// Фабрика кодеков: владеет обоими направлениями до момента, пока их не раздадут
/// в задачи reader/writer через [`split`](Codec::split).
pub struct Codec {
tx: Option<TxCodec>,
rx: Option<RxCodec>,
}
impl Codec {
/// Создаёт оба направления из шифра сессии и ключа аутентификации.
/// `auth` (одна `SessionAuth`) общий для tx и rx — тег зависит только от
/// времени и `auth_key`, а не от направления.
pub fn new(cipher: ChaChaCipher, auth_key: [u8; 32]) -> Self {
let (rx_stream, tx_stream) = cipher.split();
let auth = SessionAuth::new(auth_key);
+24
View File
@@ -1,24 +1,46 @@
//! Ошибки разбора/обработки протокола и стратегия реакции на них.
//!
//! Ключевая идея — ошибка несёт в себе не только «что и где сломалось»
//! ([`ErrorStage`]), но и **что с этим делать** ([`ErrorAction`]). Вызывающий код
//! не принимает решение сам: он берёт [`TlsError::action`] и реагирует
//! единообразно. Это разводит «частичные данные» (норма — ждём ещё) и
//! «вмешательство/рассинхрон» (рвём ногу) по разным веткам без дублирования
//! логики в каждом месте парсинга.
use bytes::Bytes;
use netrunner_logger::{error, trace};
/// Что делать с соединением после ошибки.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorAction {
/// Данных пока недостаточно — это не ошибка, ждём следующего чтения сокета.
Wait,
/// Проксировать как обычный TLS (stealth-fallback) вместо разрыва.
Redirect,
/// Критично (tampering/рассинхрон) — закрыть ногу и переподключиться.
Drop,
}
/// На каком уровне протокола произошла ошибка (со static-описанием причины).
#[derive(Debug)]
pub enum ErrorStage {
/// Слой TLS-записи / шифрования.
Tls(&'static str),
/// Фаза хендшейка (`ClientHello`/`ServerHello`, обмен ключами).
Handshake(&'static str),
/// Фаза передачи данных (`ApplicationData`).
ApplicationData(&'static str),
}
/// Ошибка протокола: стадия + предписанное действие + (опционально) сырые данные
/// для перенаправления/диагностики.
#[derive(Debug)]
pub struct TlsError {
/// Где и почему сломалось.
pub stage: ErrorStage,
/// Как на это реагировать.
pub action: ErrorAction,
/// Сырые байты (например, для `Redirect` — переслать как есть; иначе пусто).
pub data: Bytes,
}
@@ -81,6 +103,8 @@ impl TlsError {
}
}
/// Логирует ошибку с уровнем, соответствующим её серьёзности, и возвращает
/// предписанное действие. Вызывающий код матчится по результату.
pub fn execute_strategy(&self) -> ErrorAction {
self.log_error();
self.action
+49
View File
@@ -1,44 +1,79 @@
//! Кадр NRXP: структура и (де)сериализация.
//!
//! Полный байтовый формат см. в [обзоре модуля](super). Здесь — типы кадра и две
//! зеркальные операции:
//! - [`Frame::into_bytes`] — собрать заголовок + payload + случайный padding в
//! единый [`BytesMut`] (заготовка под последующее AEAD-шифрование на месте);
//! - реализации [`Parser`] для [`FrameHeader`] и [`Frame`] — разобрать буфер
//! обратно в кадр, не копируя payload лишний раз (zero-copy через `split_to`).
//!
//! Весь файл написан под zero-copy/zero-alloc на горячем пути — комментарии
//! «🔥 ОПТИМИЗАЦИЯ» помечают места, где это сознательно важно.
use crate::parser::Parser;
use bytes::{Buf, BufMut, Bytes, BytesMut};
use rand::Rng;
/// Тип кадра — первый байт после `stream_id`. Числовые значения фиксированы и
/// являются частью wire-формата (менять — это смена версии протокола).
#[derive(Copy, Clone, Debug, PartialEq)]
#[repr(u8)]
pub(crate) enum FrameType {
/// Открыть TCP-поток к цели (payload — адрес назначения).
Connect = 0x00,
/// Данные TCP-потока.
Data = 0x01,
/// Закрыть поток (FIN/abort).
Close = 0x02,
/// Keep-alive; держит туннель живым и измеряет RTT.
Heartbeat = 0x03,
/// Открыть UDP-«сессию» к цели.
UdpConnect = 0x04,
/// Датаграмма UDP-сессии.
UdpData = 0x05,
}
/// Разобранный заголовок кадра (25 байт). Поля идут в том же порядке, что и в wire.
#[derive(Copy, Clone)]
pub(crate) struct FrameHeader {
/// Time-based HMAC-тег (анти-replay). Проверяется приёмной стороной.
pub(crate) auth_tag: [u8; 16],
/// Идентификатор логического потока внутри туннеля.
pub(crate) stream_id: u32,
/// Длина полезной нагрузки в байтах.
pub(crate) payload_len: u16,
/// Длина случайного padding после payload (0 для Data/UdpData).
pub(crate) padding_len: u16,
/// Тип кадра.
pub(crate) frame_type: FrameType,
}
/// Полный разобранный кадр: заголовок + payload (без padding — он отбрасывается).
pub(crate) struct Frame {
// 🔥 ОПТИМИЗАЦИЯ: Поле _padding удалено, так как оно никогда не используется.
/// Полезная нагрузка как [`Bytes`] (zero-copy ссылка на исходный буфер).
pub(crate) payload: Bytes,
/// Разобранный заголовок.
pub(crate) header: FrameHeader,
}
// Размеры полей заголовка в байтах (см. формат в обзоре модуля).
const AUTH_TAG_SIZE: u16 = 16;
const STREAM_ID_SIZE: u16 = 4;
const FRAME_TYPE_SIZE: u16 = 1;
const PAYLOAD_LEN_SIZE: u16 = 2;
const PADDING_LEN_SIZE: u16 = 2;
/// Суммарный размер заголовка кадра — 25 байт.
pub const FRAME_HEADER_SIZE: u16 =
AUTH_TAG_SIZE + STREAM_ID_SIZE + FRAME_TYPE_SIZE + PAYLOAD_LEN_SIZE + PADDING_LEN_SIZE; // 25 bytes
/// Потолок payload одного кадра (16 КБ). Совпадает с размером interleave-чанка
/// writer'а: большие сообщения режутся на куски не больше этого значения.
pub const MAX_FRAME_PAYLOAD: usize = 16 * 1024;
impl Frame {
/// Конструирует кадр с нулевым `auth_tag` и `padding_len` — оба заполняются
/// позже в [`into_bytes`](Frame::into_bytes) при сериализации.
#[inline(always)]
pub(crate) fn new(stream_id: u32, frame_type: FrameType, payload: Bytes) -> Self {
Self {
@@ -53,6 +88,13 @@ impl Frame {
}
}
/// Сериализует кадр в [`BytesMut`], готовый к шифрованию на месте.
///
/// `auth_key` здесь — это уже готовый 16-байтовый тег (имя историческое),
/// который кладётся в начало заголовка. Для `Data`/`UdpData` padding не
/// добавляется (throughput важнее), для остальных типов — 0..255 случайных
/// байт. Буфер выделяется один раз точно под итоговый размер; заголовок
/// собирается на стеке и пишется одним `copy_from_slice`.
#[inline]
pub(crate) fn into_bytes(mut self, auth_key: &[u8; 16]) -> BytesMut {
// 🔥 ОПТИМИЗАЦИЯ: Быстрая побитовая маска (& 0xFF) вместо дорогого деления с остатком (%)
@@ -91,6 +133,8 @@ impl Frame {
}
}
/// Разбор только заголовка: `can_parse` проверяет, накопились ли 25 байт,
/// `parse` читает их и сдвигает курсор буфера (payload остаётся в `bytes`).
impl Parser for FrameHeader {
type Error = String;
@@ -147,6 +191,9 @@ impl Parser for FrameHeader {
}
}
/// Разбор полного кадра. `can_parse` подглядывает в поля длин прямо в буфере
/// (без сдвига курсора), чтобы убедиться, что пришёл весь кадр целиком; только
/// тогда `parse` извлекает заголовок и payload и пропускает padding.
impl Parser for Frame {
type Error = String;
@@ -156,6 +203,8 @@ impl Parser for Frame {
return false;
}
// Подглядываем payload_len и padding_len по их смещениям в заголовке,
// не трогая курсор: байты 21..23 и 23..25.
let p_len = u16::from_be_bytes([bytes[21], bytes[22]]) as usize;
let pad_len = u16::from_be_bytes([bytes[23], bytes[24]]) as usize;
+48
View File
@@ -1,3 +1,51 @@
//! # Протокол NRXP (`nrxp`) — Netrunner eXchange Protocol
//!
//! Прикладной протокол, который ездит **внутри** замаскированного TLS-канала.
//! Снаружи трафик выглядит как обычные TLS-записи `ApplicationData` (`0x17`), а
//! внутри каждой записи лежит один зашифрованный кадр NRXP с мультиплексированием
//! логических потоков.
//!
//! Этот блок отвечает за «упаковку/распаковку» и ничего не знает о сети как
//! таковой — он лишь превращает `(stream_id, тип, payload)` в байты и обратно.
//!
//! ## Состав блока
//!
//! | Файл | Ответственность |
//! |-------------|-----------------------------------------------------------------------|
//! | [`frame`] | Структура кадра, его (де)сериализация, паддинг. |
//! | [`codec`] | Шифрующий слой: [`TxCodec`]/[`RxCodec`] (кадр ⇄ зашифрованный TLS). |
//! | [`bridge`] | TLS-обёртка: хендшейк (`ClientHello`/`ServerHello`) и `ApplicationData`.|
//! | [`errors`] | [`TlsError`] и стратегия реакции ([`ErrorAction`]: Wait/Redirect/Drop).|
//!
//! ## Формат кадра (25-байтовый заголовок + payload + padding)
//!
//! ```text
//! ┌──────────────┬───────────┬──────┬────────────┬────────────┬─────────┬─────────┐
//! │ Auth Tag │ Stream ID │ Type │ Payload Len│ Padding Len│ Payload │ Padding │
//! │ 16 байт │ 4 байта │ 1 б. │ 2 байта │ 2 байта │ N байт │ 0..255 │
//! └──────────────┴───────────┴──────┴────────────┴────────────┴─────────┴─────────┘
//! └──────────────────── FRAME_HEADER_SIZE = 25 ────────────────────┘
//! ```
//!
//! - **Auth Tag** — TOTP-подобный HMAC-тег (см. [`SessionAuth`](crate::crypto)),
//! привязан ко времени → защита от replay со стороны DPI.
//! - **Stream ID** — id логического потока внутри туннеля (нечётные у клиента,
//! чётные у сервера — так стороны не конфликтуют за номера).
//! - **Type** — [`FrameType`]: `Connect`/`Data`/`Close`/`Heartbeat`/`UdpConnect`/`UdpData`.
//! - **Padding** — случайные байты переменной длины: ломают анализ длин пакетов.
//! Добавляется только к управляющим кадрам; кадры `Data`/`UdpData` не паддятся
//! (их и так много, паддинг бил бы по throughput).
//!
//! ## Конвейер кодирования
//!
//! ```text
//! TX: Frame::new → into_bytes(tag) → AEAD encrypt in-place → TlsBridge::pack_app_data
//! RX: TlsBridge::unpack_app_data → AEAD decrypt in-place (staging) → Frame::parse
//! ```
//!
//! **Инвариант кодирования:** одна TLS-запись `ApplicationData` = ровно один
//! зашифрованный кадр NRXP. На нём держится потоковая расшифровка в [`codec`].
mod bridge;
mod codec;
mod errors;