docs: rewrite all documentation in Russian + add deployment guide

- docs/protocol.md, pki.md, split-tunnel.md, sing-box.md переведены на русский
  и сверены с текущим кодом (транспорт v2: свой UDP + TCP/443 + QUIC fallback,
  handover; PKI; split-tunnel; sing-box-план).
- docs/deployment.md (новый, 369 строк): пошаговое руководство для удалённого
  сервера — сборка, PKI init/issue-server/issue-client (проверено бинарём),
  server.toml/client.toml на основе фактических config/*.example, firewall +
  NAT/IP-форвардинг, sudo-запуск, бандл клиента (ca.crt + client.crt + client.key
  + server addr/sni), на каком транспорте идёт трафик, ограничения v1.
- README.md (новый, корень): краткий обзор + таблица крейтов + быстрый старт.

Всё на русском (проза); команды/идентификаторы/конфиги — как есть.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-26 10:42:08 +03:00
parent d5b9a8611d
commit 083c441e4c
6 changed files with 1075 additions and 515 deletions
+373 -233
View File
@@ -1,314 +1,436 @@
# Aura Protocol
# Протокол Aura
The Aura protocol provides a mutually-authenticated, post-quantum-secure tunnel between a
client and a server. It is implemented in the `aura-proto` crate on top of `aura-crypto`
(hybrid KEM, HKDF, AEAD) and `aura-pki` (mutual X.509 verification).
Протокол Aura обеспечивает взаимно аутентифицированный, пост-квантово стойкий туннель между
клиентом и сервером. Он реализован в крейте `aura-proto` поверх `aura-crypto` (гибридный KEM, HKDF,
AEAD) и `aura-pki` (взаимная проверка X.509).
This document is for an engineer auditing or reimplementing the protocol. Everything below
reflects the **actual implementation**, not an idealized spec. Where the original spec was
ambiguous (notably the handshake message order), the implementation pins an exact choice and
that pinned choice is what is documented here.
## Layering
```
+-------------------------------------------------------------+
| Application IP packets (TUN) |
+-------------------------------------------------------------+
| Aura inner session: Frame -> AEAD-sealed Data record | <- real security boundary
| Aura inner handshake: hybrid KEM + mutual X.509 |
+-------------------------------------------------------------+
| Outer QUIC/TLS (quinn + rustls) — MIMICRY ONLY | <- NOT a security boundary
| ALPN h3 / h3-29, Chrome-like transport params, |
| client accepts ANY server cert |
+-------------------------------------------------------------+
| UDP |
+-------------------------------------------------------------+
```
The two layers have very different jobs:
- **Outer QUIC/TLS** is camouflage. It is configured to look like ordinary browser HTTP/3
traffic. It performs **no** meaningful authentication — see [Mimicry layer](#mimicry-layer).
- **Inner Aura handshake/session** is the real security boundary: hybrid post-quantum key
agreement plus mutual certificate verification against the Aura CA, then an AEAD-protected
record stream with replay protection.
The inner protocol is transport-agnostic: `client_handshake` / `server_handshake` are generic
over a separate `tokio::io::AsyncRead` reader and `AsyncWrite` writer, so the same code drives
an in-memory duplex pipe (tests) and quinn's split `RecvStream` / `SendStream` (the QUIC
transport) identically.
Этот документ предназначен для инженера, который проводит аудит протокола или реализует его заново.
Всё, что описано ниже, отражает **фактическую реализацию**, а не идеализированную спецификацию. Там,
где исходная спецификация была неоднозначной (особенно порядок сообщений рукопожатия), реализация
фиксирует конкретный выбор — и именно этот зафиксированный выбор здесь задокументирован.
---
## Wire format
## Транспорт v2: свой UDP-канал, TCP/443 и QUIC как fallback
Every Aura protocol message is a **5-byte header** followed by a payload
Это ключевое изменение по сравнению с ранней версией: основной канал данных теперь — это **собственный
транспорт Aura поверх обычного UDP**, без QUIC и без внешнего TLS на основном пути. Единственная
граница безопасности — внутреннее рукопожатие Aura (гибридное X25519 + ML-KEM-768 со взаимной X.509),
которое само по себе уже пост-квантовое. QUIC сохраняется как fallback и средство камуфляжа.
Доступны три транспорта, все они выдают единый `Arc<dyn aura_proto::PacketConnection>`, поэтому
маршрутизатору туннеля безразлично, какой транспорт перенёс соединение
(`crates/aura-transport/src/dial.rs`):
| Режим (`TransportMode`) | Реализация | Роль |
|-------------------------|------------|------|
| `Udp` | `crates/aura-transport/src/udp.rs` | Свой протокол Aura поверх обычного UDP — **основной путь** |
| `Tcp` | `crates/aura-transport/src/tcp.rs` | Aura поверх TCP/443 (fallback для сетей, блокирующих UDP; опц. HTTP-маскировка) |
| `Quic` | `crates/aura-transport/src/lib.rs`, `conn.rs`, `quic.rs` | Aura внутри QUIC/HTTP3-мимикрии (fallback / сильный камуфляж) |
### Порядок переключения (handover)
Клиентский `dial` (`dial.rs`) пробует транспорты по списку `order` слева направо; первый, который
дозвонился, побеждает. По умолчанию `order = [Udp, Tcp, Quic]`. Транспорт без сконфигурированного
адреса пропускается; транспорт, который ошибся или вышел по таймауту (`attempt_timeout`, по умолчанию
8 с), уступает место следующему.
```
dial(order = [Udp, Tcp, Quic])
└─ Udp → UdpClient::connect ── ок? → соединение по UDP
│ ошибка/таймаут
└─ Tcp → TcpClient::connect ── ок? → соединение по TCP
│ ошибка/таймаут
└─ Quic → AuraClient::connect ── ок? → соединение по QUIC
│ все упали
Err(последняя ошибка)
```
Серверная сторона (`MultiServer`, `dial.rs`) поступает иначе: она привязывается и слушает **все**
включённые в `order` транспорты одновременно и выдаёт принятые соединения из любого из них через один
`MultiServer::accept`.
> **Важно про порты.** Свой UDP-транспорт и QUIC оба используют UDP, поэтому им нужны **разные**
> порты (`udp_port` ≠ `quic_port`). TCP может занимать тот же номер порта, что и UDP-транспорт (это
> другой протокол). См. `docs/deployment.md`.
### Свой транспорт поверх UDP (основной путь)
Один сокет `tokio::net::UdpSocket` несёт обе фазы, различаемые по первому байту-типу
(`crates/aura-transport/src/udp.rs`):
```text
0x01 HS : 0x01 || hs_seq(u16 BE) || ack_upto(u16 BE) || msg_bytes
0x02 DATA : 0x02 || rec_len(u16 BE) || sealed_record [|| random_padding]
```
* **Фаза рукопожатия (`0x01` HS).** Рукопожатие Aura написано поверх `AsyncRead` + `AsyncWrite` и
обменивается целыми кадрами. UDP теряет и переупорядочивает датаграммы, поэтому рукопожатие
выполняется через `ReliableHsAdapter` — небольшой слой надёжности в стиле «полётов» DTLS:
- **границы сообщений.** Записанные байты буферизуются; адаптер парсит 5-байтный заголовок кадра
Aura, чтобы узнать полный размер сообщения (`5 + len`), и эмитит ровно одну HS-датаграмму на
каждое целое сообщение (границы берутся из заголовка, а не из `flush`).
- **надёжность отправки.** Каждая отправленная HS-датаграмма хранится в упорядоченной карте по
`hs_seq`. Каждые `hs_rto` (по умолчанию 250 мс) все ещё не подтверждённые датаграммы
перепосылаются, пока их не подтвердят или пока не истечёт общий дедлайн `hs_timeout` (по
умолчанию 10 с → ошибка).
- **подтверждения (ack).** Каждая HS-датаграмма несёт `ack_upto` = наибольший **непрерывный**
полученный `hs_seq` (кумулятивный ack); значение-сентинел `ACK_NONE = 0xFFFF` означает «ничего не
получено». При приёме адаптер отбрасывает свои неподтверждённые записи с `hs_seq <= ack_upto`.
Если подтверждать есть что, а посылать нечего, эмитится «голая» HS-датаграмма с пустым телом.
- **упорядочивание приёма.** Полученные HS-полезные нагрузки буферизуются в карте по `hs_seq` и
выдаются читателю строго в непрерывном порядке; дубликаты отбрасываются.
- **linger.** После завершения рукопожатия короткая фаза дослки (`hs_linger`, по умолчанию 2 с)
повторно отправляет последний «полёт», чтобы потеря финального сообщения не сорвала установление.
* **Фаза данных (`0x02` DATA).** После рукопожатия берутся
`Session::into_datagram_parts`, и каждый прикладной пакет уходит как одна explicit-nonce
AEAD-запись в одной UDP-датаграмме — ненадёжно, ровно как сама сеть. Потери/переупорядочивание —
забота вызывающего (Aura туннелирует IP-пакеты, которые это терпят), а датаграммный кодек уже
проверяет replay. Для DATA `sealed_record` — это ровно `rec_len` байт (один вывод
`DatagramSender::seal`); любые хвостовые байты — это обфускационный паддинг, и приёмник их
игнорирует (читает ровно `rec_len`).
**Обфускация (`UdpOpts::obfuscate`).** Когда включена, каждая исходящая DATA-датаграмма дополняется
случайными байтами до следующей «корзины» размера из `padding::HTTPS_SIZE_BUCKETS`
(`[64, 128, 256, 512, 1024, 1280, 1460]`), чтобы размыть распределение размеров на проводе под
HTTPS-подобное. Приёмник читает ровно `rec_len` запечатанной записи и игнорирует паддинг.
**Один пир на принятое соединение (v1).** `UdpServer::accept` обслуживает **одного** клиента за
вызов: ждёт первую HS-датаграмму клиента, фиксирует адрес источника, проводит рукопожатие, привязанное
к этому адресу, и возвращает выделенное `UdpConnection`. Для обслуживания многих клиентов на одном
порту понадобился бы слой демультиплексирования по адресу источника — это вне рамок v1; пока для
нескольких клиентов предпочтительны TCP/QUIC.
### TCP/443 (fallback)
`crates/aura-transport/src/tcp.rs` гоняет **то же** рукопожатие Aura и `aura_proto::Session`
напрямую поверх `TcpStream` (он уже реализует `AsyncRead` + `AsyncWrite`). Никакой дополнительной
криптографии и никакого QUIC — граница безопасности по-прежнему внутреннее рукопожатие Aura.
**Опциональная HTTP-маскировка (`TcpOpts::masquerade`).** Перед рукопожатием стороны обмениваются
минимальной преамбулой HTTP/1.1 (клиент шлёт `GET / HTTP/1.1` с заголовком `Host`, сервер отвечает
`HTTP/1.1 200 OK`), так что начало соединения для поверхностного наблюдателя похоже на обычный HTTP.
Это **лёгкая маскировка, а не TLS** — полноценная HTTPS/TLS-443-мимикрия (переиспользование внешнего
слоя rustls из QUIC-бэкенда) запланирована; сейчас задача TCP — пропихнуть байты там, где UDP
заблокирован. Преамбула читается побайтно до терминатора `\r\n\r\n`, чтобы не залезть в поток
рукопожатия.
### QUIC (fallback / камуфляж)
QUIC-путь (`crates/aura-transport/`) переносит протокол Aura поверх настоящего QUIC и выдаёт
установленное соединение как `aura_proto::PacketConnection`. У него два слоя, и какой из них является
границей безопасности — ключевой момент дизайна (подробности — в разделе
[Слой мимикрии](#слой-мимикрии)):
- **Внешний QUIC/TLS** — это камуфляж под трафик браузера HTTP/3. Он **не** выполняет осмысленной
аутентификации.
- **Внутреннее рукопожатие/сессия Aura** — настоящая граница безопасности.
```
+-------------------------------------------------------------+
| Прикладные IP-пакеты (TUN) |
+-------------------------------------------------------------+
| Внутренняя сессия Aura: Frame -> AEAD-запись Data | <- настоящая граница безопасности
| Внутреннее рукопожатие Aura: гибридный KEM + взаимный X.509 |
+-------------------------------------------------------------+
| Несущий транспорт: |
| • свой UDP (без TLS) — основной |
| • TCP/443 (опц. HTTP-маскировка) — fallback |
| • QUIC/TLS (мимикрия под HTTP/3) — fallback / камуфляж |
+-------------------------------------------------------------+
```
Внутренний протокол транспортно-независим: `client_handshake` / `server_handshake` обобщены по
отдельным `tokio::io::AsyncRead`-читателю и `AsyncWrite`-писателю, поэтому один и тот же код одинаково
управляет надёжным UDP-адаптером, TCP-потоком, разрезанными `RecvStream` / `SendStream` от quinn
(QUIC) и in-memory дуплекс-каналом (тесты).
---
## Формат кадра
Каждое сообщение протокола Aura — это **5-байтный заголовок**, за которым следует полезная нагрузка
(`crates/aura-proto/src/frame.rs`):
```
byte 0 : msg_type (u8)
bytes 1..4 : length (u24, big-endian) = payload length in bytes
byte 4 : version = 0x01
bytes 5.. : payload (length bytes)
байт 0 : msg_type (u8)
байты 1..4 : length (u24, big-endian) = длина полезной нагрузки в байтах
байт 4 : version = 0x01
байты 5.. : payload (length байт)
```
- `length` is a 24-bit big-endian integer, so the maximum payload is `0x00FF_FFFF`
(16 MiB 1). An oversize payload is rejected with `FrameTooLarge`.
- `version` is `0x01`. A header whose byte 4 is not `0x01` is rejected with `BadVersion`.
- `length` — это 24-битное целое big-endian, так что максимальная полезная нагрузка —
`0x00FF_FFFF` (16 МиБ 1). Слишком большая нагрузка отвергается с `FrameTooLarge`.
- `version` равна `0x01`. Заголовок, у которого байт 4 не `0x01`, отвергается с `BadVersion`.
### Message types
### Типы сообщений
| Byte | `MsgType` | Direction | Encrypted | Role |
|--------|---------------|-----------|-----------|--------------------------------------------|
| `0x01` | `ClientHello` | C→S | no | Handshake 1: hybrid public key + nonce |
| `0x02` | `ServerHello` | S→C | no | Handshake 2: hybrid ciphertext + nonce |
| `0x03` | `ClientAuth` | C→S | yes | Handshake 4: client cert + signature |
| `0x04` | `ServerAuth` | S→C | yes | Handshake 3: server cert + signature |
| `0x05` | `Finished` | both | yes | Handshake 5/6: HMAC over the transcript |
| `0x06` | `Data` | both | yes | Application record (AEAD-sealed `Frame`) |
| `0xFF` | `Alert` | both | no | Fatal alert; payload byte 0 is the code |
| Байт | `MsgType` | Направление | Шифрование | Роль |
|--------|---------------|-------------|------------|--------------------------------------------|
| `0x01` | `ClientHello` | C→S | нет | Рукопожатие 1: гибридный публичный ключ + nonce |
| `0x02` | `ServerHello` | S→C | нет | Рукопожатие 2: гибридный шифртекст + nonce |
| `0x03` | `ClientAuth` | C→S | да | Рукопожатие 4: сертификат клиента + подпись |
| `0x04` | `ServerAuth` | S→C | да | Рукопожатие 3: сертификат сервера + подпись |
| `0x05` | `Finished` | оба | да | Рукопожатие 5/6: HMAC по transcript |
| `0x06` | `Data` | оба | да | Прикладная запись (AEAD-запечатанный `Frame`) |
| `0xFF` | `Alert` | оба | нет | Фатальный alert; байт 0 нагрузки — код |
> Note: the numeric byte values do **not** follow the send order. `ServerAuth` (`0x04`) is
> sent *before* `ClientAuth` (`0x03`). The send order is fixed by the state machine
> (below), not by the type byte.
> Замечание: числовые значения байтов **не** соответствуют порядку отправки. `ServerAuth` (`0x04`)
> отправляется *перед* `ClientAuth` (`0x03`). Порядок отправки задаётся конечным автоматом (ниже), а
> не байтом-типом.
### Application frames
### Прикладные кадры
Once the session is established, the application payload carried inside each encrypted `Data`
record is a `Frame` (`crates/aura-proto/src/frame.rs`). All multi-byte integers are
big-endian:
Когда сессия установлена, прикладная нагрузка внутри каждой зашифрованной записи `Data` — это `Frame`
(`crates/aura-proto/src/frame.rs`). Все многобайтные целые — big-endian:
| Frame | Tag | Encoding |
|---------|--------|-----------------------------------------------------|
| `Data` | `0x01` | `0x01 \|\| stream_id(u32) \|\| payload` |
| `Ping` | `0x02` | `0x02 \|\| seq(u32)` |
| `Pong` | `0x03` | `0x03 \|\| seq(u32)` |
| Кадр | Тег | Кодирование |
|---------|--------|------------------------------------------------------|
| `Data` | `0x01` | `0x01 \|\| stream_id(u32) \|\| payload` |
| `Ping` | `0x02` | `0x02 \|\| seq(u32)` |
| `Pong` | `0x03` | `0x03 \|\| seq(u32)` |
| `Close` | `0x04` | `0x04 \|\| code(u8) \|\| reason_len(u32) \|\| reason_utf8` |
На всех трёх транспортах прикладные IP-пакеты упаковываются как `Frame::Data` на `stream_id 0`;
входящий `Ping` отвечается `Pong`, лишний `Pong` игнорируется, а `Close` всплывает как ошибка.
---
## Handshake
## Рукопожатие
### Pinned message order
### Зафиксированный порядок сообщений
The original spec diagram was ambiguous about the order of the encrypted auth/Finished
messages. The implementation pins this exact order, and both peers follow it lock-step
Исходная диаграмма спецификации была неоднозначна насчёт порядка зашифрованных сообщений
auth/Finished. Реализация фиксирует ровно такой порядок, и обе стороны следуют ему шаг в шаг
(`crates/aura-proto/src/handshake.rs`):
```
1. C -> S ClientHello (plaintext): x25519_pub[32] || mlkem_ek[1184] || client_nonce[32]
2. S -> C ServerHello (plaintext): x25519_ephemeral[32] || mlkem_ct[1088] || server_nonce[32]
-- both sides derive the hybrid shared secret and the two directional SessionKeys --
3. S -> C ServerAuth (encrypted under s2c): u16(cert_der_len) || server_leaf_cert_der || sig(transcript)
4. C -> S ClientAuth (encrypted under c2s): u16(cert_der_len) || client_leaf_cert_der || sig(transcript)
5. C -> S Finished (encrypted under c2s): HMAC-SHA256(key_c2s, transcript)
6. S -> C Finished (encrypted under s2c): HMAC-SHA256(key_s2c, transcript)
-- encrypted Data channel is now open in both directions --
1. C -> S ClientHello (открытый текст): x25519_pub[32] || mlkem_ek[1184] || client_nonce[32]
2. S -> C ServerHello (открытый текст): x25519_ephemeral[32] || mlkem_ct[1088] || server_nonce[32]
-- обе стороны выводят гибридный общий секрет и два направленных SessionKeys --
3. S -> C ServerAuth (зашифровано под s2c): u16(cert_der_len) || server_leaf_cert_der || sig(transcript)
4. C -> S ClientAuth (зашифровано под c2s): u16(cert_der_len) || client_leaf_cert_der || sig(transcript)
5. C -> S Finished (зашифровано под c2s): HMAC-SHA256(key_c2s, transcript)
6. S -> C Finished (зашифровано под s2c): HMAC-SHA256(key_s2c, transcript)
-- зашифрованный канал Data теперь открыт в обе стороны --
```
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: plaintext
participant C as Клиент
participant S as Сервер
Note over C,S: открытый текст
C->>S: 1. ClientHello (x25519_pub, mlkem_ek, client_nonce)
S->>C: 2. ServerHello (x25519_eph, mlkem_ct, server_nonce)
Note over C,S: both derive shared secret + SessionKeys<br/>transcript = SHA-256(CH_frame || SH_frame)
Note over C,S: encrypted (AEAD under directional keys)
S->>C: 3. ServerAuth (server cert + sig over transcript)
C->>S: 4. ClientAuth (client cert + sig over transcript)
C->>S: 5. Finished (HMAC_c2s over transcript)
S->>C: 6. Finished (HMAC_s2c over transcript)
Note over C,S: session established; Data records flow both ways
Note over C,S: обе стороны выводят общий секрет + SessionKeys<br/>transcript = SHA-256(CH_frame || SH_frame)
Note over C,S: зашифровано (AEAD под направленными ключами)
S->>C: 3. ServerAuth (сертификат сервера + подпись по transcript)
C->>S: 4. ClientAuth (сертификат клиента + подпись по transcript)
C->>S: 5. Finished (HMAC_c2s по transcript)
S->>C: 6. Finished (HMAC_s2c по transcript)
Note over C,S: сессия установлена; записи Data идут в обе стороны
```
### Hello payloads (exact sizes)
### Полезные нагрузки Hello (точные размеры)
| Field | ClientHello | ServerHello | Bytes |
|-------------------|:-----------:|:-----------:|------:|
| X25519 pub / eph | ✔ | ✔ | 32 |
| ML-KEM-768 ek | ✔ | | 1184 |
| ML-KEM-768 ct | | ✔ | 1088 |
| nonce | ✔ | ✔ | 32 |
| **Total payload** | **1248** | **1152** | |
| Поле | ClientHello | ServerHello | Байт |
|-------------------|:-----------:|:-----------:|-----:|
| X25519 pub / eph | ✔ | ✔ | 32 |
| ML-KEM-768 ek | ✔ | | 1184 |
| ML-KEM-768 ct | | ✔ | 1088 |
| nonce | ✔ | ✔ | 32 |
| **Итого нагрузка**| **1248** | **1152** | |
Hellos are sent in plaintext and validated for exact length on receipt; a wrong length is
rejected with `MalformedHandshake`.
Hello отправляются в открытом виде и при приёме проверяются на точную длину; неверная длина
отвергается с `MalformedHandshake`.
### Transcript hash
### Transcript-хеш
```
transcript = SHA-256( ClientHello_frame_bytes || ServerHello_frame_bytes )
```
The hash covers the **full serialized frames** (5-byte header + payload) of ClientHello and
ServerHello, exactly as transmitted on the wire. This binds the negotiated key material and
the protocol version into both the signatures and the Finished MACs.
Хеш покрывает **полные сериализованные кадры** (5-байтный заголовок + полезная нагрузка) ClientHello
и ServerHello, ровно как они передаются на проводе. Это привязывает согласованный ключевой материал и
версию протокола одновременно к подписям и к MAC-ам Finished.
### Authentication (ServerAuth / ClientAuth)
### Аутентификация (ServerAuth / ClientAuth)
Each Auth payload is:
Каждая нагрузка Auth:
```
u16_be(cert_der_len) || leaf_cert_der || signature
```
- `leaf_cert_der` is the sender's **leaf certificate** in DER (sent inline; no chain — the
CA is the trust anchor on the receiving side).
- `signature` is an **ECDSA P-256 / SHA-256** signature, ASN.1 DER encoded
(`ECDSA_P256_SHA256_ASN1`), computed over the 32-byte `transcript` (via `ring`).
- `leaf_cert_der` — это **листовой сертификат** отправителя в DER (передаётся встроенным, без цепочки —
CA является якорем доверия на принимающей стороне).
- `signature` — это подпись **ECDSA P-256 / SHA-256**, в кодировке ASN.1 DER
(`ECDSA_P256_SHA256_ASN1`), вычисленная по 32-байтному `transcript` (через `ring`).
Verification (`crates/aura-proto/src/handshake.rs`):
Проверка (`crates/aura-proto/src/handshake.rs`):
1. The receiver builds an `AuraCertVerifier` from its configured CA PEM and verifies the
peer's leaf against the CA (chain + key-usage + validity; see `pki.md`).
- The **client** additionally requires the server leaf to be valid for the expected
`server_name` (DNS SAN match).
- The **server** captures the verified **client id** (leaf Common Name) and stores it as
the session's `peer_id`.
2. The receiver extracts the leaf's EC public-key point and verifies `signature` over
`transcript`. A failure is `Signature(...)`.
1. Принимающая сторона строит `AuraCertVerifier` из настроенного PEM своего CA и проверяет листовой
сертификат пира против CA (цепочка + назначение ключа + срок действия; см. `pki.md`).
- **Клиент** дополнительно требует, чтобы листовой сертификат сервера был валиден для ожидаемого
`server_name` (совпадение DNS SAN).
- **Сервер** захватывает проверенный **id клиента** (Common Name листа) и сохраняет его как
`peer_id` сессии.
2. Принимающая сторона извлекает точку EC-публичного ключа из листа и проверяет `signature` по
`transcript`. Неуспех — это `Signature(...)`.
Possession of the certificate's private key is therefore proven by the signature over the
transcript; the certificate identity is proven by the CA chain check.
Таким образом, владение приватным ключом сертификата доказывается подписью по transcript, а личность
сертификата — проверкой цепочки против CA.
### Finished
Each side sends, then verifies, a Finished MAC bound to the transcript and the direction key:
Каждая сторона отправляет, затем проверяет, MAC Finished, привязанный к transcript и ключу
направления:
```
Finished_c2s = HMAC-SHA256(key_c2s, transcript) // client sends (msg 5), server verifies
Finished_s2c = HMAC-SHA256(key_s2c, transcript) // server sends (msg 6), client verifies
Finished_c2s = HMAC-SHA256(key_c2s, transcript) // отправляет клиент (сообщение 5), проверяет сервер
Finished_s2c = HMAC-SHA256(key_s2c, transcript) // отправляет сервер (сообщение 6), проверяет клиент
```
Verification is constant-time (`Hmac::verify_slice`); a mismatch is `FinishedMismatch`. The
Finished exchange confirms both sides derived identical keys and agree on the full transcript.
Проверка выполняется за постоянное время (`Hmac::verify_slice`); несовпадение — это
`FinishedMismatch`. Обмен Finished подтверждает, что обе стороны вывели одинаковые ключи и согласны по
всему transcript.
### Encrypted handshake messages and counter continuity
### Зашифрованные сообщения рукопожатия и непрерывность счётчиков
Messages 36 are AEAD-sealed under the **same** two directional `AeadSession`s that protect
application Data; their nonce counters are continuous across the handshake/data boundary.
Сообщения 3–6 запечатываются AEAD под **теми же** двумя направленными `AeadSession`, что защищают
прикладные Data; их счётчики nonce непрерывны через границу рукопожатие/данные.
- The AAD for each encrypted handshake message is its 5-byte frame header (binding type +
length), matching the Data-record convention.
- Each direction seals **exactly two** encrypted handshake messages before Data begins:
- c2s seals `ClientAuth` (counter 0) and `Finished` (counter 1)
- s2c seals `ServerAuth` (counter 0) and `Finished` (counter 1)
- Therefore both directions reach AEAD counter **2** at the end of the handshake, and the
first application Data record stamps `seq == 2` (`POST_HANDSHAKE_COUNTER`). This seeds the
replay window (below).
- AAD каждого зашифрованного сообщения рукопожатия — это его 5-байтный заголовок кадра (привязка типа
+ длины), как и в записях Data.
- Каждое направление запечатывает **ровно два** зашифрованных сообщения рукопожатия до начала Data:
- c2s запечатывает `ClientAuth` (счётчик 0) и `Finished` (счётчик 1)
- s2c запечатывает `ServerAuth` (счётчик 0) и `Finished` (счётчик 1)
- Поэтому оба направления достигают AEAD-счётчика **2** в конце рукопожатия, и первая прикладная
запись Data ставит `seq == 2` (`POST_HANDSHAKE_COUNTER`). Это задаёт начальное состояние
replay-окна (ниже). На датаграммном (UDP) пути это же значение счётчика переносится в
explicit-nonce кодеки через `into_datagram_parts`, так что nonce, уже использованные в рукопожатии,
не переиспользуются.
---
## Hybrid KEM
## Гибридный KEM
The key exchange is a hybrid of classical X25519 ECDH and post-quantum ML-KEM-768
(`crates/aura-crypto/src/kem/`). An attacker must break **both** primitives to recover the
session key.
Обмен ключами — гибрид классического X25519 ECDH и пост-квантового ML-KEM-768
(`crates/aura-crypto/src/kem/`). Атакующему нужно сломать **оба** примитива, чтобы восстановить ключ
сессии.
> **ML-KEM-768 (FIPS 203)**, via the RustCrypto `ml-kem` crate (v0.3) — this is the
> standardized FIPS 203 scheme, **not** round-3 Kyber.
> **ML-KEM-768 (FIPS 203)** через крейт RustCrypto `ml-kem` (v0.3) — это стандартизованная схема
> FIPS 203, а **не** Kyber раунда 3.
### Roles
### Роли
- The **client** owns the long-term `HybridPrivateKey` and publishes its `HybridPublicKey`
in ClientHello.
- The **server** calls `encapsulate()` against that public key: it generates an **ephemeral**
X25519 keypair and an ML-KEM encapsulation, returns the `HybridCiphertext` in ServerHello,
and derives the shared secret.
- The **client** recovers the same secret via `decapsulate()`.
- **Клиент** владеет долговременным `HybridPrivateKey` и публикует свой `HybridPublicKey` в
ClientHello.
- **Сервер** вызывает `encapsulate()` против этого публичного ключа: он генерирует **эфемерную** пару
X25519 и ML-KEM-инкапсуляцию, возвращает `HybridCiphertext` в ServerHello и выводит общий секрет.
- **Клиент** восстанавливает тот же секрет через `decapsulate()`.
So X25519 is **ephemeralstatic** (server ephemeral against client static public), while
ML-KEM is a standard KEM against the client's encapsulation key.
То есть X25519 здесь **эфемерно–статический** (эфемерный сервера против статического публичного
клиента), а ML-KEM — это стандартный KEM против инкапсуляционного ключа клиента.
### Sizes
### Размеры
| Quantity | Bytes | Constant |
| Величина | Байт | Константа |
|-----------------------------------|------:|---------------------|
| X25519 public / ephemeral / secret| 32 | `X25519_LEN` |
| ML-KEM-768 encapsulation key (ek) | 1184 | `EK_LEN` |
| ML-KEM-768 ciphertext (ct) | 1088 | `CT_LEN` |
| ML-KEM-768 shared secret | 32 | `SS_LEN` |
| ML-KEM-768 decapsulation key (dk) | 2400 | `DK_LEN` |
| X25519 публичный / эфемерный / секрет | 32 | `X25519_LEN` |
| ML-KEM-768 инкапсуляционный ключ (ek) | 1184 | `EK_LEN` |
| ML-KEM-768 шифртекст (ct) | 1088 | `CT_LEN` |
| ML-KEM-768 общий секрет | 32 | `SS_LEN` |
| ML-KEM-768 деинкапсуляционный ключ (dk) | 2400 | `DK_LEN` |
> **Implementation detail — dk encoding.** The decapsulation (secret) key is stored in the
> FIPS 203 **expanded 2400-byte** form (`ExpandedKeyEncoding`), not the 64-byte seed that
> `ml-kem` 0.3 prefers. This is the encoding the project's ACVP / FIPS-203 known-answer test
> vectors operate on, so it is used for interop/KAT compatibility. The dk never travels on the
> wire — only `ek` (1184 B) and `ct` (1088 B) do.
> **Деталь реализации — кодирование dk.** Деинкапсуляционный (секретный) ключ хранится в
> **развёрнутой 2400-байтной** форме FIPS 203 (`ExpandedKeyEncoding`), а не в 64-байтном seed,
> который предпочитает `ml-kem` 0.3. Именно с этим кодированием работают KAT-векторы ACVP / FIPS 203
> проекта, поэтому оно используется для совместимости/interop. dk никогда не путешествует по проводу —
> по нему идут только `ek` (1184 Б) и `ct` (1088 Б).
### Combined shared secret
### Объединённый общий секрет
```
shared = x25519_ss (32 B) || mlkem_ss (32 B) // 64 bytes total
shared = x25519_ss (32 Б) || mlkem_ss (32 Б) // всего 64 байта
```
ML-KEM decapsulation is infallible on a correctly sized ciphertext: a tampered ciphertext
yields a pseudo-random secret (implicit rejection) rather than an error, which surfaces later
as an AEAD/Finished failure.
Деинкапсуляция ML-KEM не может завершиться ошибкой на корректно размерном шифртексте: подделанный
шифртекст даёт псевдослучайный секрет (неявное отклонение), а не ошибку, что позже всплывает как сбой
AEAD/Finished.
---
## Key derivation (HKDF)
## Вывод ключей (HKDF)
Directional session keys are derived with **HKDF-SHA256** (RFC 5869)
Направленные ключи сессии выводятся с помощью **HKDF-SHA256** (RFC 5869)
(`crates/aura-crypto/src/kdf.rs`):
```
salt = client_nonce || server_nonce (64 bytes)
IKM = x25519_ss || mlkem_ss (64 bytes)
salt = client_nonce || server_nonce (64 байта)
IKM = x25519_ss || mlkem_ss (64 байта)
info = "aura-v1-session"
OKM = HKDF-Expand(HKDF-Extract(salt, IKM), info, 64) (64 bytes)
OKM = HKDF-Expand(HKDF-Extract(salt, IKM), info, 64) (64 байта)
key_client_to_server = OKM[0..32]
key_server_to_client = OKM[32..64]
```
The derivation is fully deterministic in its inputs. The `info` string provides domain
separation. Intermediate secret material (`salt`, `IKM`, `OKM`) is zeroized after use, and
`SessionKeys` zeroizes its keys on drop.
Вывод полностью детерминирован по своим входам. Строка `info` обеспечивает доменное разделение.
Промежуточный секретный материал (`salt`, `IKM`, `OKM`) обнуляется после использования, а
`SessionKeys` обнуляет свои ключи при drop.
---
## AEAD
The record cipher is **ChaCha20-Poly1305** (`crates/aura-crypto/src/aead.rs`). An
`AeadSession` holds a 256-bit key and a 64-bit message counter; each direction has its own
session.
Шифр записей — **ChaCha20-Poly1305** (`crates/aura-crypto/src/aead.rs`). Есть два режима:
### Nonce scheme
- `AeadSession` — для **потоковых** транспортов (TCP, QUIC, поток рукопожатия): держит 256-битный ключ
и 64-битный счётчик сообщений; nonce выводится из счётчика, который продвигается шаг в шаг на каждом
`seal` и `open`, поэтому стороны остаются синхронными без передачи nonce.
- `AeadKey` — для **датаграммного** (UDP) пути: nonce-счётчик передаётся аргументом на каждый вызов,
потому что датаграммы могут теряться или переупорядочиваться, так что счётчик каждой записи несётся
на проводе. Схема nonce идентична `AeadSession`, поэтому оба совместимы на одном ключе, пока их
диапазоны счётчиков не пересекаются.
The 96-bit (12-byte) nonce is derived from the counter:
### Схема nonce
96-битный (12-байтный) nonce выводится из счётчика:
```
nonce[0..8] = counter as little-endian u64
nonce[0..8] = counter как little-endian u64
nonce[8..12] = 0x00 00 00 00
```
The counter advances by one on every `seal` **and** every `open` (even on a failed `open`),
so a paired seal/open stay aligned without transmitting the nonce. The nonce is never reused
within a session (the 2^64 counter wrap is unreachable; an overflow panics rather than
reusing a nonce). The key is zeroized on drop.
В потоковом режиме nonce никогда не переиспользуется в рамках сессии (переполнение счётчика 2^64
недостижимо; при переполнении происходит паника, а не повторное использование nonce). В датаграммном
режиме за уникальность счётчика отвечает отправитель (`DatagramSender` монотонно увеличивает `seq`).
Ключ обнуляется при drop.
---
## Data records and replay protection
## Записи данных и защита от повтора (replay)
After the handshake, application `Frame`s are exchanged as `Data` records
(`crates/aura-proto/src/session.rs`). Each `Data` record's **payload** is:
После рукопожатия прикладные `Frame` передаются как записи `Data`
(`crates/aura-proto/src/session.rs`). Кодирование зависит от того, потоковый транспорт или
датаграммный — но логика replay-окна общая.
### Потоковый путь (TCP / QUIC)
**Полезная нагрузка** каждой записи `Data`:
```
seq (u64, big-endian) || ChaCha20Poly1305_seal( frame_bytes, aad = header || seq )
```
- `seq` is the 8-byte big-endian record counter. On the happy path it equals the sealing
AEAD's counter (and the receiver's expected AEAD counter).
- The AEAD **AAD** is the 5-byte frame `header` concatenated with the 8-byte `seq`, so the
record is cryptographically bound to both its declared length/type and its claimed position.
- The ciphertext includes the 16-byte Poly1305 tag.
- `seq` — 8-байтный big-endian счётчик записи. На «счастливом пути» он равен счётчику запечатывающего
AEAD (и ожидаемому счётчику AEAD приёмника).
- **AAD** AEAD — это 5-байтный заголовок кадра `header`, сцепленный с 8-байтным `seq`, так что запись
криптографически привязана и к своей объявленной длине/типу, и к заявленной позиции.
- Шифртекст включает 16-байтный тег Poly1305.
So the full record on the wire is:
Полная запись на проводе:
```
[ header(5) ][ seq(8) ][ ciphertext + tag ]
@@ -316,56 +438,74 @@ So the full record on the wire is:
header.length = 8 + len(ciphertext+tag)
```
### Sliding replay window
### Датаграммный путь (свой UDP)
The receiver runs a **64-wide sliding-window** replay check (`REPLAY_WINDOW = 64`) *before*
touching the AEAD, so a duplicate or too-old record is rejected with `Replay(seq)` without
disturbing the AEAD counter (the session stays usable). The window:
Здесь нет 5-байтного потокового заголовка внутри записи. Датаграммная запись
(`DatagramSender::seal`):
- tracks the highest accepted `seq` plus a 64-bit bitmap of accepted positions below it;
- accepts a `seq` iff it is strictly newer than everything seen, or falls within the window
and has not been seen before;
- rejects a `seq` that equals the current highest, is already marked in the bitmap, or is
more than `REPLAY_WINDOW` below the highest.
```
seq(8, big-endian) || ChaCha20Poly1305_seal( frame_bytes, aad = seq )
```
The window is seeded at the post-handshake counter (`start = 2`): everything strictly below
`start` is treated as already-consumed, so the first legitimate Data record (`seq == 2`) is
accepted as "newer".
То есть AAD — это **только** `seq` (а не `header || seq`). На проводе эта запись несётся внутри
DATA-датаграммы UDP-транспорта как `0x02 || rec_len(u16 BE) || запись [|| паддинг]` (см. раздел про
транспорт v2 выше).
### Full-duplex split
### Скользящее replay-окно
A `Session` can be `split()` into independent `SessionSender` (writer + outbound AEAD +
send counter) and `SessionReceiver` (reader + inbound AEAD + replay window) halves, which can
be driven from separate tasks for a concurrent read/write data path (e.g. the VPN tunnel).
`recv_frame` is **not** cancellation-safe and must be driven from a single owning task.
Приёмник запускает проверку повтора **скользящим окном шириной 64** (`REPLAY_WINDOW = 64`) *до*
обращения к AEAD, так что дубликат или слишком старая запись отвергаются с `Replay(seq)`, не трогая
счётчик AEAD (сессия остаётся работоспособной). Окно:
- отслеживает наибольший принятый `seq` плюс 64-битную битовую карту принятых позиций ниже него;
- принимает `seq`, только если он строго новее всего виденного, либо попадает в окно и ранее не был
виден;
- отвергает `seq`, который равен текущему наибольшему, уже отмечен в битовой карте или находится более
чем на `REPLAY_WINDOW` ниже наибольшего.
Окно инициализируется на пост-рукопожатном счётчике (`start = 2`): всё строго ниже `start` считается
уже потреблённым, поэтому первая легитимная запись Data (`seq == 2`) принимается как «новейшая». Этот
механизм работает одинаково на потоковом и датаграммном путях.
### Полнодуплексное разделение
`Session` можно `split()` на независимые половины `SessionSender` (писатель + исходящий AEAD +
счётчик отправки) и `SessionReceiver` (читатель + входящий AEAD + replay-окно), которыми можно
управлять из разных задач для конкурентного чтения/записи (например, в VPN-туннеле). `recv_frame`
**не** безопасен к отмене (cancellation-safe) и должен выполняться из одной владеющей задачи.
Для датаграммного пути аналогично есть `into_datagram_parts`, выдающий `DatagramSender` /
`DatagramReceiver`.
---
## Mimicry layer
## Слой мимикрии
The outer QUIC/TLS layer (`crates/aura-transport/`) exists purely to disguise the connection
as browser HTTP/3 traffic. It is explicitly **not** the authentication boundary.
Внешний слой QUIC/TLS (`crates/aura-transport/`) существует исключительно для маскировки соединения
под трафик браузера HTTP/3. Он явно **не** является границей аутентификации.
- **ALPN** advertises `h3` and `h3-29` (`ALPN_H3`) — exactly what Chrome offers for HTTP/3 —
so the ALPN extension is indistinguishable from a real browser's.
- **Transport params** mirror a Chromium HTTP/3 connection: ~30 s idle timeout, ~15 s
keep-alive, 100 concurrent bidi/uni streams, ~10 MB flow-control receive windows
- **ALPN** объявляет `h3` и `h3-29` (`ALPN_H3`) — ровно то, что предлагает Chrome для HTTP/3 —
поэтому расширение ALPN неотличимо от реального браузерного.
- **Параметры транспорта** зеркалят соединение Chromium HTTP/3: ~30 с idle-таймаут, ~15 с
keep-alive, 100 конкурентных bidi/uni-потоков, ~10 МБ окна управления потоком на приём
(`chrome_quic_transport_config`).
- **SNI** defaults to a generic CDN-looking hostname (`cdn.example.com`) when the caller does
not supply one; deployments pass their own camouflage hostname.
- The QUIC **client accepts any server certificate** (`AcceptAnyServerCert`all verifier
methods return success). This is safe *only* because the outer TLS is not authentication:
the real mutual auth is the inner Aura handshake. The server's outer TLS likewise disables
client auth (`with_no_client_auth`).
- **SNI** по умолчанию — обобщённое CDN-подобное имя (`cdn.example.com`), если вызывающий его не задал;
развёртывания передают своё камуфляжное имя.
- QUIC-**клиент принимает любой серверный сертификат** (`AcceptAnyServerCert`все методы
верификатора возвращают успех). Это безопасно *только* потому, что внешний TLS не является
аутентификацией: настоящая взаимная аутентификация — это внутреннее рукопожатие Aura. Внешний TLS
сервера также отключает клиентскую аутентификацию (`with_no_client_auth`).
> Do not reuse `AcceptAnyServerCert` anywhere the TLS layer *is* the authentication boundary.
> Не переиспользуйте `AcceptAnyServerCert` нигде, где слой TLS *является* границей аутентификации.
Лёгкая HTTP-маскировка TCP-транспорта (`TcpOpts::masquerade`) преследует ту же цель, но гораздо
скромнее (см. раздел про транспорт v2): это преамбула HTTP/1.1, а не TLS.
---
## Error model
## Модель ошибок
The protocol layer surfaces `ProtoError` (`crates/aura-proto/src/lib.rs`), including:
Слой протокола выдаёт `ProtoError` (`crates/aura-proto/src/lib.rs`), в том числе:
`Io`, `Crypto`, `Pki`, `UnknownMsgType`, `BadVersion`, `FrameTooLarge`, `UnexpectedMsg`,
`MalformedHandshake`, `MalformedFrame`, `Signature`, `FinishedMismatch`, `Replay`, and
`Alert`. A peer may send a fatal `Alert` frame (type `0xFF`); the first payload byte is the
alert code, surfaced to the local side as `ProtoError::Alert(code)`.
`MalformedHandshake`, `MalformedFrame`, `Signature`, `FinishedMismatch`, `Replay` и `Alert`. Пир может
отправить фатальный кадр `Alert` (тип `0xFF`); первый байт полезной нагрузки — код alert, который
всплывает на локальной стороне как `ProtoError::Alert(code)`.