docs [AI]
This commit is contained in:
+30
-340
@@ -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`.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user