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
+30 -340
View File
@@ -1,351 +1,41 @@
# Crypto Module Documentation
# Блок `crypto` — криптографический фундамент
## Overview
Весь крипто-фундамент протокола. Снаружи (из [`net`](../net)) видны только
`SessionKeys`, `SessionAuth` и потоковый шифр `ChaChaCipher` — остальное детали.
Данный модуль реализует криптографическую основу протокола:
> Подробности — в rustdoc: `cargo doc --open -p netrunner-core`, модуль `crypto`.
> Здесь — карта блока и ментальная модель, чтобы быстро сориентироваться.
- согласование ключей (ECDH, X25519)
- derivation ключей (HKDF-SHA256)
- симметричное шифрование (ChaCha20-Poly1305, AEAD)
- аутентификация с плавающим временным окном (HMAC)
## Файлы
Архитектура разделена на независимые компоненты:
| Файл | Что внутри |
|---------------|--------------------------------------------------------------------|
| `ecdh.rs` | Эфемерный обмен ключами X25519 (Diffie-Hellman). |
| `hkdf.rs` | Расширение общего секрета в набор ключей (HKDF-SHA256). |
| `session.rs` | Оркестрация: соль → ECDH → HKDF → ключи; time-based auth-теги. |
| `chacha.rs` | Потоковый AEAD ChaCha20-Poly1305 с раздельными nonce по направлениям. |
| `aead.rs` | Трейт `AeadPacker` — абстракция шифра для кодека. |
- `ecdh` — обмен ключами
- `hkdf` — derivation ключей
- `session` — управление состоянием сессии
- `chacha` — AEAD шифрование
- `aead` — абстракция шифрования
## Жизненный цикл ключей
---
## Security Model
Модель безопасности:
- Forward secrecy обеспечивается за счёт `EphemeralSecret`
- Каждая сессия использует уникальные:
- salt (локальный + удалённый)
- ephemeral key pair
- Ключи разделены по направлениям:
- client → server
- server → client
---
## Key Exchange (ECDH)
### Реализация
Используется:
- X25519 (через `x25519-dalek`)
- Ephemeral ключи
````rust
let secret = EphemeralSecret::random_from_rng(&mut OsRng);
let public = PublicKey::from(&secret);
### Shared Secret
```rust
shared = private.diffie_hellman(remote_public)
````
Особенности:
- приватный ключ используется **один раз** (`take()`)
- после вызова `get_shared()` он уничтожается
Это критично для:
- forward secrecy
- защиты от повторного использования
---
## Salt Mechanism
### SaltPair
Каждая сторона генерирует:
- `local_salt` (32 байта, случайный)
- получает `remote_salt`
Финальный salt:
```
initiator:
total = local || remote
responder:
total = remote || local
```text
1. SessionKeys::new(is_initiator) → эфемерный X25519 + локальная соль
2. <обмен ClientHello/ServerHello> → узнаём чужой pubkey и соль
3. SessionKeys::update_keys(...) → ECDH → HKDF → 2×(key+iv) + auth_key
4. ChaChaCipher::set_keys(...) → горячий путь: in-place шифр/дешифр
5. SessionAuth (auth_key) → TOTP-подобный тег в каждом кадре
```
Это гарантирует:
## Инварианты (НЕ ЛОМАТЬ)
- детерминированность
- отсутствие коллизий ролей
- **Forward secrecy** — приватный ключ X25519 эфемерный и расходуется ровно один
раз (`ECDH::get_shared` забирает его через `take()`).
- **Уникальность nonce** — `nonce = base_iv XOR counter`, счётчик у каждого
направления свой; повтор nonce при одном ключе ломает шифр.
- **Постоянное время** — `SessionAuth::verify_tag` всегда проходит всё окно
кандидатов без раннего выхода (иначе утечёт тайминг подбора тега).
---
## Связи
## Key Derivation (HKDF)
Используется:
- HKDF-SHA256
### Extract
```
PRK = HKDF(salt, shared_secret)
```
### Expand
Из PRK генерируются:
| Назначение | Размер |
| --------------- | ------ |
| client_aead key | 32 |
| client_iv | 12 |
| server_aead key | 32 |
| server_iv | 12 |
| auth_key | 32 |
Пример:
```rust
HKDF::expand_key::<32>(&hkdf, b"client_aead")
```
Контекст (`mark`) используется как label.
---
## SessionKeys
Центральная структура управления:
```rust
pub struct SessionKeys {
salt: SaltPair,
ecdh: ECDH,
auth_key: [u8; 32],
current_aead: Option<(...)>,
}
```
### Основные задачи:
1. Принять remote salt
2. Извлечь публичный ключ из TLS extension
3. Выполнить ECDH
4. Сгенерировать ключи
---
## TLS Integration
Ключ извлекается из `ExtensionStack`:
- extension type: `0x0033` (KeyShare)
- поддерживается X25519 (`0x001d`)
### Client / Server различия
**Server:**
- парсит ClientHello
- ищет key share внутри структуры
**Client:**
- читает фиксированное смещение
---
## AEAD Encryption (ChaCha20-Poly1305)
### Используемый алгоритм
- ChaCha20-Poly1305
- 256-bit key
- 96-bit nonce
---
## Nonce Strategy
### NonceState
```rust
nonce = base_iv XOR counter
```
Где:
- `base_iv` — получен из HKDF
- `counter` — 64-bit
Алгоритм:
```rust
for i in 0..8:
iv[i+4] ^= counter[i]
```
Особенности:
- гарантированная уникальность nonce
- отсутствие повторов при корректной работе
---
## Cipher Structure
```rust
pub struct ChaChaCipher {
encrypt_cipher,
decrypt_cipher,
encrypt_state,
decrypt_state,
}
```
Разделение:
- отдельные ключи и nonce для каждого направления
---
## Encryption Flow
```rust
encrypt_in_place(nonce, aad=nonce, data)
```
AAD:
- используется сам nonce
После:
- данные мутируются
- тег добавляется автоматически
---
## Decryption Flow
```rust
decrypt_in_place(nonce, aad=nonce, data)
```
При ошибке:
- ошибка аутентификации
- повреждённые данные
- несинхронный nonce
---
## Authentication Layer
Дополнительный механизм:
### HMAC-SHA256
```rust
tag = HMAC(auth_key, time_step)
```
Где:
```
time_step = unix_time / 60
```
### Проверка
Принимаются значения:
```
[t-2, t-1, t, t+1, t+2]
```
Назначение:
- защита от replay
- tolerance к рассинхрону времени
---
## AeadPacker Trait
Абстракция для шифрования:
```rust
trait AeadPacker {
fn encrypt(...)
fn decrypt(...)
}
```
Позволяет:
- заменить алгоритм
- тестировать разные реализации
---
## Error Handling
Ошибки возникают в случаях:
- неизвестный протокол / extension
- повреждённые данные
- неверный AEAD tag
- отсутствие shared secret
Возвращаются:
- `Result<T, String>`
- `aead::Error`
---
## Important Security Notes
1. Ephemeral ключ используется только один раз
2. Nonce никогда не должен повторяться
3. Salt обязателен с обеих сторон
4. AEAD тег обязателен (встроен)
5. Временные теги не заменяют AEAD
---
## Limitations
- Нет re-keying
- Нет защиты от state desync (nonce)
- Нет replay protection на уровне пакетов (только time-based)
- Жёстко задан HKDF context strings
---
## Summary
Модуль реализует:
- безопасный ECDH handshake
- детерминированный key derivation
- AEAD шифрование с разделением направлений
- дополнительную HMAC-аутентификацию
Подходит для:
- пользовательских протоколов
- туннелей
- транспортных шифрованных каналов
- Ключи извлекаются из TLS-расширений модуля [`tlseng`](../tlseng) (KeyShare `0x0033`).
- Шифрование вызывается из кодека [`nrxp`](../nrxp) через трейт `AeadPacker`.
+18
View File
@@ -1,6 +1,24 @@
//! Абстракция AEAD-шифрования над конкретным алгоритмом.
//!
//! Кодек ([`nrxp`](crate::nrxp)) работает не с ChaCha20-Poly1305 напрямую, а через
//! трейт [`AeadPacker`]. Это развязывает горячий путь от выбора шифра: сегодня за
//! трейтом стоит [`ChaChaStream`](super::chacha::ChaChaStream), завтра можно
//! подставить другой AEAD без правок кодека, а в тестах — фейковую реализацию.
use bytes::BytesMut;
/// Шифрование «на месте» (in-place) для одного направления потока.
///
/// Обе операции мутируют переданный [`BytesMut`] прямо в его буфере — никаких
/// промежуточных аллокаций и копий (zero-copy на горячем пути):
/// - [`encrypt`](AeadPacker::encrypt) дописывает 16-байтовый тег аутентификации в
/// конец буфера (вызывающий код заранее резервирует под него место);
/// - [`decrypt`](AeadPacker::decrypt) проверяет тег и усекает буфер до открытого
/// текста; при провале проверки возвращает ошибку и данные считаются
/// скомпрометированными (tampering).
pub(crate) trait AeadPacker {
/// Шифрует `data` на месте и дописывает тег аутентификации.
fn encrypt(&mut self, data: &mut BytesMut) -> Result<(), chacha20poly1305::aead::Error>;
/// Расшифровывает `data` на месте и проверяет тег; `Err` ⇒ tampering.
fn decrypt(&mut self, data: &mut BytesMut) -> Result<(), chacha20poly1305::aead::Error>;
}
+41
View File
@@ -1,11 +1,31 @@
//! Потоковое AEAD-шифрование ChaCha20-Poly1305.
//!
//! Это «горячий путь» крипто-блока: через него проходит каждый кадр данных.
//! Ключевые свойства:
//!
//! - **Раздельные направления.** [`ChaChaCipher`] держит два независимых потока —
//! `tx` (исходящий) и `rx` (входящий), у каждого свой ключ, IV и счётчик nonce.
//! - **Детерминированный nonce.** Nonce не передаётся по сети: обе стороны
//! синхронно считают `nonce = base_iv XOR counter` (см. [`NonceState`]).
//! Счётчики растут строго в ногу, поэтому любой пропуск/повтор кадра ломает
//! расшифровку — это и есть встроенная защита целостности потока.
//! - **In-place.** Шифр работает прямо в [`BytesMut`] без копий и аллокаций.
use bytes::BytesMut;
use chacha20poly1305::aead::generic_array::GenericArray;
use chacha20poly1305::{AeadInPlace, ChaCha20Poly1305, Key, KeyInit, Nonce};
use crate::crypto::aead::AeadPacker;
/// Генератор nonce для одного направления.
///
/// Nonce строится как `base_iv XOR big_endian(counter)` по младшим 8 байтам IV.
/// `counter` монотонно растёт на каждый кадр, гарантируя уникальность nonce в
/// пределах ключа (повтор nonce при одном ключе фатален для ChaCha20-Poly1305).
struct NonceState {
/// Счётчик кадров. Должен совпадать у отправителя и получателя по направлению.
counter: u64,
/// Базовый IV (12 байт), полученный из HKDF; неизменен на всю сессию.
base_iv: [u8; 12],
}
@@ -17,6 +37,11 @@ impl NonceState {
}
}
/// Возвращает nonce для текущего кадра и инкрементирует счётчик.
///
/// XOR накладывается на байты `iv[4..12]` (младшие 8 байт 12-байтового IV),
/// старшие 4 байта остаются «солью» из IV. После вызова `counter`
/// увеличивается, поэтому следующий кадр получит другой nonce.
pub fn next_nonce(&mut self) -> Nonce {
let mut iv = self.base_iv;
let counter_bytes = self.counter.to_be_bytes();
@@ -30,6 +55,9 @@ impl NonceState {
}
}
/// Однонаправленный шифр: одна пара (ключ, IV) + её счётчик nonce.
///
/// Реализует [`AeadPacker`]. Используется парами внутри [`ChaChaCipher`].
pub struct ChaChaStream {
cipher: ChaCha20Poly1305,
state: NonceState,
@@ -119,12 +147,19 @@ impl AeadPacker for ChaChaStream {
}
}
/// Двунаправленный шифр сессии: исходящий (`tx`) и входящий (`rx`) потоки.
///
/// Создаётся «пустым» (нулевые ключи) до завершения хендшейка, затем
/// [`set_keys`](ChaChaCipher::set_keys) заряжает реальные ключи из HKDF.
pub struct ChaChaCipher {
/// Исходящее направление (шифрование того, что отправляем).
pub tx: ChaChaStream,
/// Входящее направление (расшифровка того, что приняли).
pub rx: ChaChaStream,
}
impl ChaChaCipher {
/// Создаёт шифр с нулевыми ключами-заглушками (до хендшейка).
pub fn new() -> Self {
Self {
tx: ChaChaStream::new(&[0u8; 32], [0u8; 12]),
@@ -132,12 +167,18 @@ impl ChaChaCipher {
}
}
/// Заряжает реальные ключи/IV после хендшейка: `w_*` — на запись (tx),
/// `r_*` — на чтение (rx). Сбрасывает счётчики nonce в 0 для обоих направлений.
pub fn set_keys(&mut self, w_key: [u8; 32], w_iv: [u8; 12], r_key: [u8; 32], r_iv: [u8; 12]) {
self.tx = ChaChaStream::new(&w_key, w_iv);
self.rx = ChaChaStream::new(&r_key, r_iv);
netrunner_logger::debug!("Cipher keys and IVs updated for both directions");
}
/// Разбирает шифр на два независимых потока `(rx, tx)`.
///
/// Нужно, чтобы отдать чтение и запись в разные задачи tokio (reader/writer),
/// не деля шифр под мьютексом — каждое направление владеет своим потоком.
pub fn split(self) -> (ChaChaStream, ChaChaStream) {
(self.rx, self.tx)
}
+22
View File
@@ -1,11 +1,28 @@
//! Эфемерный обмен ключами по схеме X25519 (Elliptic-Curve Diffie-Hellman).
//!
//! Один экземпляр [`ECDH`] обслуживает ровно один хендшейк: на старте генерится
//! эфемерная пара ключей, публичная половина уходит в `ClientHello`/`ServerHello`,
//! а приватная расходуется один раз при вычислении общего секрета и сразу
//! уничтожается. Это и есть механизм forward secrecy: даже компрометация
//! долговременных секретов в будущем не расшифрует записанный ранее трафик.
use aead::OsRng;
use x25519_dalek::{EphemeralSecret, PublicKey};
/// Состояние одной стороны ECDH-обмена.
///
/// `private_key` обёрнут в [`Option`], потому что [`EphemeralSecret`] потребляется
/// при вычислении общего секрета (`diffie_hellman` забирает `self` по значению).
/// После [`ECDH::get_shared`] поле становится `None` и повторный обмен невозможен.
pub(crate) struct ECDH {
/// Публичный ключ, который отправляется удалённой стороне в KeyShare.
pub public_key: PublicKey,
/// Приватный эфемерный ключ. `Some` до первого `get_shared`, затем `None`.
pub private_key: Option<EphemeralSecret>,
}
impl ECDH {
/// Генерирует свежую эфемерную пару ключей из системного ГСЧ ([`OsRng`]).
pub(crate) fn new() -> Self {
let secret = EphemeralSecret::random_from_rng(&mut OsRng);
let public = PublicKey::from(&secret);
@@ -15,6 +32,11 @@ impl ECDH {
}
}
/// Вычисляет общий секрет с публичным ключом удалённой стороны.
///
/// Приватный ключ **расходуется**: `take()` извлекает его из `Option`, после
/// чего поле остаётся `None`. Возвращает `None`, если метод уже вызывался
/// (т.е. приватного ключа больше нет) — защита от повторного использования.
pub(crate) fn get_shared(&mut self, public: &PublicKey) -> Option<[u8; 32]> {
let private_key = self.private_key.take()?;
let shared = private_key.diffie_hellman(&public);
+24
View File
@@ -1,14 +1,38 @@
//! Расширение ключей по HKDF-SHA256 (RFC 5869).
//!
//! Общий секрет, полученный из [`ECDH`](super::ecdh), сам по себе как ключ не
//! используется. Через HKDF из него детерминированно «разворачивается» несколько
//! независимых ключей под разные цели (см. [`session`](super::session)):
//! AEAD-ключи и IV для каждого направления плюс ключ для time-based аутентификации.
//!
//! Схема двухфазная:
//! - **extract**: `PRK = HKDF-Extract(salt, ikm)` — «сжимает» энтропию секрета;
//! - **expand**: `okm = HKDF-Expand(PRK, label, N)` — выдаёт ключ нужной длины,
//! уникальный для каждого `label` (`mark`).
use hkdf::Hkdf;
use sha2::Sha256;
/// Безсостоятельная обёртка над `hkdf::Hkdf<Sha256>` с двумя удобными методами.
pub(crate) struct HKDF;
impl HKDF {
/// Фаза **extract**: связывает соль и входной материал ключа (`ikm`,
/// общий ECDH-секрет) в псевдослучайный ключ `PRK`.
///
/// Возвращает готовый к фазе expand экстрактор. Соль здесь — это
/// объединённые локальная+удалённая соли сторон (см. `SaltPair::get_total`).
pub(crate) fn extract_key(salt: &[u8], ikm: &[u8]) -> Hkdf<Sha256> {
let extracted_key = Hkdf::<Sha256>::new(Some(salt), ikm);
extracted_key
}
/// Фаза **expand**: выводит ключ длины `N` байт под меткой `mark`.
///
/// `mark` (например `b"client_aead"`) играет роль контекстного лейбла:
/// разные метки из одного и того же `PRK` дают криптографически независимые
/// ключи. `N` — параметр-константа, поэтому длина проверяется на этапе
/// компиляции (32 для ключа, 12 для IV и т.п.).
pub(crate) fn expand_key<const N: usize>(
extracted_key: &Hkdf<Sha256>,
mark: &[u8],
+36
View File
@@ -1,3 +1,39 @@
//! # Криптографический блок (`crypto`)
//!
//! Весь криптографический фундамент протокола Netrunner. Модуль самодостаточен:
//! снаружи (из [`net`](crate::net)) видны только высокоуровневые сущности
//! [`SessionKeys`], [`SessionAuth`] и потоковый шифр [`ChaChaCipher`].
//!
//! ## Из чего состоит блок
//!
//! | Файл | Ответственность |
//! |--------------|----------------------------------------------------------------------|
//! | [`ecdh`] | Эфемерный обмен ключами X25519 (Diffie-Hellman). |
//! | [`hkdf`] | Расширение общего секрета в набор ключей (HKDF-SHA256). |
//! | [`session`] | Оркестрация хендшейка: соль → ECDH → HKDF → ключи; time-based auth. |
//! | [`chacha`] | Потоковое AEAD-шифрование ChaCha20-Poly1305 с раздельными nonce. |
//! | [`aead`] | Трейт-абстракция [`AeadPacker`] над шифром (для подмены алгоритма). |
//!
//! ## Жизненный цикл ключей (как всё связано)
//!
//! ```text
//! 1. SessionKeys::new(is_initiator) → генерит эфемерный X25519 + локальную соль
//! 2. <обмен ClientHello/ServerHello> → стороны узнают чужой pubkey и соль
//! 3. SessionKeys::update_keys(...) → ECDH → HKDF → 2×(key+iv) + auth_key
//! 4. ChaChaCipher::set_keys(...) → горячий путь: in-place шифр/дешифр
//! 5. SessionAuth (auth_key) → TOTP-подобный тег в каждом кадре
//! ```
//!
//! ## Модель безопасности (инварианты, которые нельзя ломать)
//!
//! - **Forward secrecy.** Приватный ключ X25519 — эфемерный и используется ровно
//! один раз: [`ECDH::get_shared`](ecdh) забирает его через `take()` и уничтожает.
//! - **Уникальность nonce.** Каждое направление имеет свой счётчик; nonce =
//! `base_iv XOR counter`. Повтор nonce при одном ключе ломает ChaCha20-Poly1305.
//! - **Анти-replay по времени.** [`SessionAuth::verify_tag`] сверяет HMAC-тег в
//! окне `±AUTH_WINDOW_SIZE` шагов и работает **в постоянном времени** (не
//! ветвится по факту совпадения) — иначе утечёт тайминг подбора тега.
mod aead;
mod chacha;
mod ecdh;
+84 -2
View File
@@ -1,3 +1,26 @@
//! Оркестрация сессии: от хендшейка до пер-кадровой аутентификации.
//!
//! Файл связывает воедино [`ecdh`](super::ecdh) и [`hkdf`](super::hkdf) и делится
//! на две фазы, отражённые в коде комментариями-разделителями:
//!
//! 1. **Handshake / генерация ключей** ([`SaltPair`], [`SessionKeys`]).
//! Стороны обмениваются солью и публичными ключами X25519 (внутри TLS-кадров
//! `ClientHello`/`ServerHello`), затем независимо выводят один и тот же набор
//! ключей: AEAD-ключ+IV на каждое направление и общий `auth_key`.
//!
//! 2. **Data phase / аутентификация кадров** ([`SessionAuth`]).
//! Лёгкая копируемая структура с одним лишь `auth_key`, которую забирают
//! кодеки. Считает и проверяет TOTP-подобный тег, привязанный ко времени, —
//! защита от replay-атак на уровне DPI.
//!
//! ## Симметрия ролей
//!
//! Чтобы обе стороны вывели идентичные ключи, важен порядок конкатенации соли и
//! назначение направлений. Инициатор (клиент) и ответчик (сервер) собирают
//! «полную соль» в зеркальном порядке (см. [`SaltPair::get_total`]), а в
//! [`SessionKeys::generate_keys`] флаг `is_server` решает, какой из выведенных
//! ключей идёт на tx, а какой на rx.
use netrunner_logger::{AppError, ERR_NET_TLS_TAMPER};
use x25519_dalek::PublicKey;
@@ -18,9 +41,18 @@ use aead::{rand_core::RngCore, OsRng};
// 1. HANDSHAKE (Генерация ключей)
// ==========================================
/// Пара солей сторон, используемая как salt в HKDF-Extract.
///
/// Каждая сторона генерирует случайную локальную соль и узнаёт удалённую из
/// хендшейка. Итоговая соль детерминированно собирается из обеих половин в
/// порядке, зависящем от роли (см. [`get_total`](SaltPair::get_total)), так что
/// клиент и сервер приходят к одному значению.
pub(crate) struct SaltPair {
/// Своя случайная соль (32 байта), уходит удалённой стороне.
local_salt: [u8; 32],
/// Соль удалённой стороны; нули до вызова [`set_remote_salt`](SaltPair::set_remote_salt).
remote_salt: [u8; 32],
/// Роль: `true` у инициатора (клиента) — определяет порядок конкатенации.
is_initiator: bool,
}
@@ -43,6 +75,11 @@ impl SaltPair {
self.remote_salt = salt
}
/// Собирает 64-байтовую «полную соль» для HKDF.
///
/// Порядок зеркальный по ролям: инициатор кладёт `local || remote`, ответчик —
/// `remote || local`. Благодаря этому обе стороны получают **одинаковый**
/// буфер соли, хотя «локальное» и «удалённое» у них поменяны местами.
pub(crate) fn get_total(&self) -> [u8; 64] {
let mut salt = [0u8; 64];
if self.is_initiator {
@@ -57,10 +94,20 @@ impl SaltPair {
}
}
/// Полное состояние криптографического хендшейка одной стороны.
///
/// Держит свою соль, эфемерный ECDH и — после [`update_keys`](SessionKeys::update_keys) —
/// выведенные ключи. Кортеж `current_aead` упакован как
/// `(tx_key, tx_iv, rx_key, rx_iv)` уже с учётом роли: его можно напрямую отдать
/// в [`ChaChaCipher::set_keys`](super::chacha::ChaChaCipher::set_keys).
pub struct SessionKeys {
/// Пара солей (локальная + удалённая) для HKDF.
salt: SaltPair,
/// Эфемерный ключ X25519; расходуется при выводе общего секрета.
ecdh: ECDH,
/// Ключ для time-based аутентификации кадров (HMAC). Заполняется в HKDF-фазе.
auth_key: [u8; 32],
/// Выведенные AEAD-параметры `(tx_key, tx_iv, rx_key, rx_iv)`; `None` до хендшейка.
current_aead: Option<([u8; 32], [u8; 12], [u8; 32], [u8; 12])>,
}
@@ -83,6 +130,15 @@ impl SessionKeys {
self.auth_key
}
/// Завершает хендшейк: принимает удалённую соль и публичный ключ из
/// TLS-расширений, выводит все ключи сессии.
///
/// Публичный ключ X25519 извлекается из расширения KeyShare (`0x0033`).
/// Парсинг асимметричен: у сервера (`is_server`) `ClientHello` содержит список
/// именованных групп, поэтому ключ ищется по маркеру `00 1d 00 20` (X25519,
/// 32 байта); у клиента `ServerHello` отдаёт ровно один ключ по фиксированному
/// смещению. Любая аномалия (короткий буфер, нет KeyShare, нулевой ключ)
/// трактуется как [`ERR_NET_TLS_TAMPER`] — признак вмешательства/несовместимости.
pub(crate) fn update_keys(
&mut self,
salt: [u8; 32],
@@ -158,6 +214,13 @@ impl SessionKeys {
}
}
/// Низкоуровневый вывод ключей: ECDH → HKDF-Extract → пять HKDF-Expand.
///
/// Из общего секрета и полной соли выводятся пять значений по фиксированным
/// лейблам (`client_aead`, `client_iv`, `server_aead`, `server_iv`, `auth_key`).
/// Затем `is_server` назначает направления: для сервера tx=`server_*`,
/// rx=`client_*`, для клиента — наоборот. Так одна и та же пара ключей
/// у клиента служит на запись, а у сервера — на чтение, и наоборот.
fn generate_keys(
&mut self,
public_key: &PublicKey,
@@ -209,8 +272,14 @@ impl SessionKeys {
// 2. DATA PHASE (Авторизация Кодека)
// ==========================================
/// Легковесная структура, которая передается в RxCodec и TxCodec
/// после завершения Handshake.
/// Лёгкий копируемый «аутентификатор кадров», который забирают `RxCodec`/`TxCodec`
/// после хендшейка.
///
/// Реализует TOTP-подобную схему: тег кадра = первые 16 байт
/// `HMAC-SHA256(auth_key, current_time_step)`, где `step = unix_secs /
/// AUTH_TIME_STEP`. Тег меняется каждые `AUTH_TIME_STEP` секунд, поэтому
/// записанный ранее DPI-перехват нельзя «переиграть» позже — окно валидности
/// уезжает. Допуск на рассинхрон часов задаётся `AUTH_WINDOW_SIZE`.
#[derive(Clone, Copy)]
pub struct SessionAuth {
auth_key: [u8; 32],
@@ -221,6 +290,9 @@ impl SessionAuth {
Self { auth_key }
}
/// Чистая функция: тег для конкретного временного шага `step`.
///
/// Вынесена отдельно, чтобы и генерация, и проверка считали тег одинаково.
pub fn compute_tag(secret: &[u8], step: u64) -> [u8; 16] {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC error");
mac.update(&step.to_be_bytes());
@@ -230,6 +302,7 @@ impl SessionAuth {
tag
}
/// Тег для текущего момента времени — кладётся в исходящий кадр.
pub fn generate_current_tag(&self) -> [u8; 16] {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -243,6 +316,15 @@ impl SessionAuth {
Self::compute_tag(&self.auth_key, now / AUTH_TIME_STEP)
}
/// Проверяет тег входящего кадра против окна `[step-W .. step+W]`.
///
/// # Инвариант безопасности (НЕ ЛОМАТЬ)
///
/// Цикл **всегда** прогоняет все `2*AUTH_WINDOW_SIZE + 1` кандидатов и
/// сравнивает теги побайтово через накопление `diff |= a ^ b`, без раннего
/// `break` и без ветвления по результату внутри цикла. Это постоянное по
/// времени сравнение: длительность `verify_tag` не зависит от того, какой шаг
/// (и совпал ли вообще) подошёл, иначе по таймингу можно подбирать тег.
pub fn verify_tag(&self, received_tag: &[u8; 16]) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)