Files
xah30 7c8ea919c4 docs(tests): TEST_CASES.md + wire-tap proof for university practice
Adds proof artifacts that the PQ tunnel is real:

- crates/aura-proto/tests/pq_wire_tap.rs — new integration test that
  intercepts every byte flowing on the in-memory transport and asserts:
  (1) ClientHello payload = 32 + 1184 + 32 (X25519 + ML-KEM-768 ek + nonce),
  (2) ServerHello payload = 32 + 1088 + 32 (X25519_eph + ML-KEM-768 ct + nonce),
  (3) a 56-byte plaintext marker shipped in a Data frame is absent from
      the wire in both directions,
  (4) ServerAuth/Data AEAD bodies have Shannon entropy >= 7 bits/byte.

- TEST_CASES.md — Russian-language report mapping 12 test cases to the
  exact code and captured outputs (KAT, hybrid round-trip, AEAD tamper
  detection, mutual X.509 rejection, replay window, 1000-packet flow,
  in-vivo ping, bench-crypto timings, new wire-tap proof).

- docs/test_evidence/ — full captured stdout of cargo test runs and
  aura bench-crypto, referenced from TEST_CASES.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:59:19 +03:00

376 lines
19 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `pq_wire_tap.rs` — наглядное доказательство (для отчёта по практике), что Aura собирает
//! постквантовый туннель и что трафик после хендшейка реально зашифрован.
//!
//! Тест прокачивает один полный клиент↔сервер обмен (handshake + одна Data-запись) поверх
//! in-memory duplex-пайпа, обёрнутого «отводом» ([`TeeWriter`]), который сохраняет каждый байт
//! на проводе. Затем по сохранённому потоку байтов проверяются четыре утверждения, каждое из
//! которых соответствует тест-кейсу в `TEST_CASES.md`:
//!
//! 1. **Туннель собран.** Хендшейк завершается успешно, каждая сторона распознала Common Name
//! другой стороны по сертификату.
//! 2. **Размеры PQ-полей соответствуют FIPS 203.** В ClientHello payload ровно
//! 32 (X25519 pub) + 1184 (ML-KEM-768 encapsulation key) + 32 (nonce); в ServerHello payload
//! ровно 32 (X25519 эфемеральный) + 1088 (ML-KEM-768 ciphertext) + 32 (nonce).
//! 3. **Открытого текста на проводе нет.** Уникальный 56-байтовый маркер, посланный в Data-кадре,
//! не встречается ни в одном из двух направлений.
//! 4. **Шифротекст похож на случайный.** Тело ServerAuth (первый зашифрованный кадр после
//! ServerHello) имеет Shannon-энтропию ≥ 7.0 бит на байт — характерная подпись AEAD-вывода.
mod common;
use std::io;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use aura_proto::{client_handshake, server_handshake, Frame, MsgType};
use bytes::Bytes;
use tokio::io::{split, AsyncWrite};
/// Уникальная 56-байтовая строка, которую мы шлём в Data-кадре. После шифрования ChaCha20-Poly1305
/// её не должно остаться на проводе ни в одном направлении — иначе крипты нет.
const PLAINTEXT_MARKER: &[u8] = b"AURA_PQ_PRACTICE_PROOF_MARKER_NEVER_APPEARS_ON_WIRE_2026";
/// Размер «дополнительного» payload'а, который мы шлём вместе с маркером, чтобы получить
/// достаточно большой блок AEAD-вывода для энтропийной проверки. ChaCha20-Poly1305 на этих
/// данных даст ≈ 1 КБ шифротекста; на такой выборке энтропия уверенно близка к 8 бит/байт.
const ENTROPY_PADDING_LEN: usize = 1024;
/// Длина 8-байтного `seq`-префикса перед AEAD-телом в Data-записи (см. `aura_proto::session`).
/// Это часть открытых метаданных, а не вывод шифра, поэтому из энтропийной оценки её исключаем.
const SEQ_LEN: usize = 8;
// ===== Помощник: writer, дублирующий каждый байт в общий буфер ===============================
/// Прозрачно проксирует все вызовы [`AsyncWrite`] на `inner`, попутно складывая успешно
/// записанные байты в общий `log`. Используется, чтобы перехватить полный набор байтов на
/// проводе без необходимости лезть в реальный сетевой стек.
struct TeeWriter<W> {
inner: W,
log: Arc<Mutex<Vec<u8>>>,
}
impl<W> TeeWriter<W> {
fn new(inner: W, log: Arc<Mutex<Vec<u8>>>) -> Self {
Self { inner, log }
}
}
impl<W: AsyncWrite + Unpin> AsyncWrite for TeeWriter<W> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let res = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &res {
self.log
.lock()
.expect("tap log mutex not poisoned")
.extend_from_slice(&buf[..*n]);
}
res
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
// ===== Помощники для разбора заголовков и анализа байтов =====================================
/// Длина протокольного заголовка Aura (см. `aura_proto::frame`).
const HEADER_LEN: usize = 5;
/// Распаковать u24-be длину payload из 5-байтового заголовка по смещению `off`.
fn read_payload_len(buf: &[u8], off: usize) -> usize {
((buf[off + 1] as usize) << 16) | ((buf[off + 2] as usize) << 8) | (buf[off + 3] as usize)
}
/// Подсчитать классическую Shannon-энтропию (бит/байт) последовательности.
///
/// Для равномерно случайных байт энтропия стремится к 8.0; AEAD-вывод на практике даёт
/// > 7.0 даже на коротких блобах в сотни байт.
fn shannon_entropy(b: &[u8]) -> f64 {
if b.is_empty() {
return 0.0;
}
let mut counts = [0u64; 256];
for &x in b {
counts[x as usize] += 1;
}
let n = b.len() as f64;
let mut h = 0.0;
for &c in &counts {
if c == 0 {
continue;
}
let p = c as f64 / n;
h -= p * p.log2();
}
h
}
/// Линейный поиск `needle` в `hay`.
fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
hay.windows(needle.len()).any(|w| w == needle)
}
// ===== Основной интеграционный тест ==========================================================
/// Полный цикл: handshake + одно зашифрованное сообщение + ответ. По завершении исследуем
/// сохранённые на «отводах» байты и убеждаемся, что:
///
/// * формат ClientHello / ServerHello точно соответствует профилю Aura (X25519 + ML-KEM-768);
/// * маркерная строка не утекла на провод ни в одну сторону;
/// * первый постхендшейковый кадр (ServerAuth) выглядит случайным.
///
/// Эти три проверки в сумме — наблюдаемое доказательство, что туннель действительно работает
/// поверх гибридного PQ-KEM и применяет AEAD-шифрование ко всему, что идёт после ServerHello.
#[tokio::test]
async fn pq_handshake_and_data_wire_capture() {
let pki = common::mint_pki("vpn.aura.example", "client-pq-proof");
let client_cfg = pki.client_config();
let server_cfg = pki.server_config();
// Связанный in-memory транспорт. Половинки для чтения отдаём «как есть»; половинки для
// записи оборачиваем TeeWriter'ом, чтобы скопить полный поток байтов на проводе.
let (client_end, server_end) = tokio::io::duplex(256 * 1024);
let (c_read, c_write_raw) = split(client_end);
let (s_read, s_write_raw) = split(server_end);
let c_to_s_log: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let s_to_c_log: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let c_write = TeeWriter::new(c_write_raw, c_to_s_log.clone());
let s_write = TeeWriter::new(s_write_raw, s_to_c_log.clone());
// Собираем payload: маркер + 1 КБ нулей. Нули в plaintext'е после ChaCha20 превращаются в
// чистый поток ключа — это даёт нам объективно большой и репрезентативный AEAD-блок для
// энтропийной проверки, при этом сохраняя возможность искать маркер на проводе.
let mut data_payload = Vec::with_capacity(PLAINTEXT_MARKER.len() + ENTROPY_PADDING_LEN);
data_payload.extend_from_slice(PLAINTEXT_MARKER);
data_payload.extend_from_slice(&vec![0u8; ENTROPY_PADDING_LEN]);
let data_payload_for_client = data_payload.clone();
// Запускаем обе стороны параллельно.
let client = tokio::spawn(async move {
let mut session = client_handshake(c_read, c_write, &client_cfg)
.await
.expect("client handshake");
// Шлём один Data-кадр с заведомо узнаваемым маркером + большим зануленным хвостом.
session
.send_frame(Frame::Data {
stream_id: 0xC0FF_EEBB,
payload: Bytes::from(data_payload_for_client),
})
.await
.expect("send_frame");
// Дожидаемся встречного Pong, чтобы исключить TOCTOU между записью и проверкой логов.
let reply = session.recv_frame().await.expect("recv reply");
assert!(matches!(reply, Frame::Pong { seq: 42 }));
session.peer_id().map(str::to_string)
});
let server = tokio::spawn(async move {
let mut session = server_handshake(s_read, s_write, &server_cfg)
.await
.expect("server handshake");
let incoming = session.recv_frame().await.expect("recv data");
// Сервер видит plaintext в чистом виде (после AEAD-open), но на проводе его не было.
if let Frame::Data { ref payload, .. } = incoming {
assert_eq!(
payload.as_ref(),
data_payload.as_slice(),
"plaintext after decryption matches what client sent"
);
} else {
panic!("expected Data frame, got {incoming:?}");
}
session
.send_frame(Frame::Pong { seq: 42 })
.await
.expect("send Pong");
session.peer_id().map(str::to_string)
});
let client_peer = client.await.expect("client task").expect("client peer id");
let server_peer = server.await.expect("server task").expect("server peer id");
// === ТК-1: туннель собран — обе стороны взаимно аутентифицированы. =======================
assert_eq!(
server_peer, "client-pq-proof",
"server learned client CN from verified leaf certificate"
);
assert_eq!(
client_peer, "vpn.aura.example",
"client recorded the server name it authenticated"
);
// Снимаем итоговые буферы байтов.
let c_to_s = c_to_s_log.lock().expect("c->s log").clone();
let s_to_c = s_to_c_log.lock().expect("s->c log").clone();
// === ТК-2: ClientHello имеет точный PQ-гибридный layout. ================================
// FIPS 203: ML-KEM-768 encapsulation key = 1184 bytes; X25519 public key = 32 bytes;
// handshake nonce = 32 bytes. Версионный байт = 0x01 (project §6.1).
const ML_KEM_EK_LEN: usize = 1184;
const ML_KEM_CT_LEN: usize = 1088;
const X25519_LEN: usize = 32;
const NONCE_LEN: usize = 32;
const CH_PAYLOAD_LEN: usize = X25519_LEN + ML_KEM_EK_LEN + NONCE_LEN;
const SH_PAYLOAD_LEN: usize = X25519_LEN + ML_KEM_CT_LEN + NONCE_LEN;
assert!(
c_to_s.len() >= HEADER_LEN + CH_PAYLOAD_LEN,
"client must have written at least the ClientHello frame ({} bytes), got {}",
HEADER_LEN + CH_PAYLOAD_LEN,
c_to_s.len(),
);
assert_eq!(
c_to_s[0],
MsgType::ClientHello as u8,
"first byte on wire is the ClientHello msg-type tag (0x01)",
);
assert_eq!(
read_payload_len(&c_to_s, 0),
CH_PAYLOAD_LEN,
"ClientHello payload = X25519(32) || ML-KEM-768 ek(1184) || nonce(32)",
);
assert_eq!(c_to_s[4], 0x01, "protocol version byte 0x01");
// === ТК-2': ServerHello точно так же. ====================================================
assert!(s_to_c.len() >= HEADER_LEN + SH_PAYLOAD_LEN);
assert_eq!(
s_to_c[0],
MsgType::ServerHello as u8,
"first byte from server is the ServerHello tag (0x02)",
);
assert_eq!(
read_payload_len(&s_to_c, 0),
SH_PAYLOAD_LEN,
"ServerHello payload = X25519_eph(32) || ML-KEM-768 ct(1088) || nonce(32)",
);
// === ТК-3: открытого маркера нет ни в одну сторону. ======================================
// Это самое прямое доказательство: если бы AEAD не работал, plaintext «PROOF_MARKER...»
// лежал бы прямо в шифрованной части Data-кадра.
assert!(
!contains_subslice(&c_to_s, PLAINTEXT_MARKER),
"plaintext marker LEAKED into c->s wire bytes ({} bytes captured)",
c_to_s.len(),
);
assert!(
!contains_subslice(&s_to_c, PLAINTEXT_MARKER),
"plaintext marker LEAKED into s->c wire bytes",
);
// === ТК-4: тело ServerAuth выглядит случайным (Shannon entropy ≥ 7 бит/байт). ============
// Сразу после ServerHello сервер шлёт ServerAuth (зашифрованный AEAD'ом сессионным ключом
// s2c). Если бы шифра не было, мы бы увидели DER-сертификат и подпись — низкая энтропия.
let server_auth_off = HEADER_LEN + SH_PAYLOAD_LEN;
assert!(
s_to_c.len() > server_auth_off + HEADER_LEN,
"server must have sent ServerAuth right after ServerHello",
);
assert_eq!(
s_to_c[server_auth_off],
MsgType::ServerAuth as u8,
"next frame after ServerHello is ServerAuth (0x04)",
);
let sa_payload_len = read_payload_len(&s_to_c, server_auth_off);
let body_start = server_auth_off + HEADER_LEN;
let body_end = body_start + sa_payload_len;
assert!(s_to_c.len() >= body_end);
let body = &s_to_c[body_start..body_end];
let ent = shannon_entropy(body);
assert!(
ent >= 7.0,
"ServerAuth body must look like AEAD ciphertext (entropy = {ent:.3} bits/byte over {} bytes; \
clear DER would be < 5)",
body.len(),
);
// === ТК-4': аналогично для Data-кадра, который шёл с клиента на сервер. ==================
// Data всегда последний кадр в c_to_s после ClientHello, ClientAuth, Finished.
// Найдём его, просканировав c_to_s по заголовкам.
let mut off = 0;
let mut last_data: Option<(usize, usize)> = None;
while off + HEADER_LEN <= c_to_s.len() {
let ty = c_to_s[off];
let len = read_payload_len(&c_to_s, off);
let end = off + HEADER_LEN + len;
if end > c_to_s.len() {
break;
}
if ty == MsgType::Data as u8 {
last_data = Some((off + HEADER_LEN, end));
}
off = end;
}
let (ds, de) = last_data.expect("c->s must contain at least one Data record");
// Тело Data-записи = seq(8) || AEAD(frame_bytes, ...). Первые 8 байт — открытый счётчик,
// именно поэтому считать энтропию надо ТОЛЬКО по AEAD-части, иначе нули в seq её занижают.
assert!(de - ds > SEQ_LEN, "Data body must include AEAD-ciphertext");
let data_ciphertext = &c_to_s[ds + SEQ_LEN..de];
let data_ent = shannon_entropy(data_ciphertext);
assert!(
data_ent >= 7.0,
"Data-record AEAD body must look encrypted (entropy = {data_ent:.3} bits/byte over {} bytes; \
clear text padded with zeros would be near 0)",
data_ciphertext.len(),
);
// === Резюме (печатается на --nocapture, удобно для отчёта). ==============================
eprintln!("=== Aura PQ wire-tap test summary ===");
eprintln!(
"client_peer = {client_peer:?}, server_peer = {server_peer:?}"
);
eprintln!(
"captured c->s = {} bytes, s->c = {} bytes",
c_to_s.len(),
s_to_c.len()
);
eprintln!(
"ClientHello payload = {} bytes (= 32 + 1184 + 32, X25519 + ML-KEM-768 ek + nonce)",
read_payload_len(&c_to_s, 0)
);
eprintln!(
"ServerHello payload = {} bytes (= 32 + 1088 + 32, X25519_eph + ML-KEM-768 ct + nonce)",
read_payload_len(&s_to_c, 0)
);
eprintln!(
"ServerAuth body Shannon entropy = {ent:.3} bits/byte over {} bytes",
body.len()
);
eprintln!(
"Data record AEAD body Shannon entropy = {data_ent:.3} bits/byte over {} bytes \
(plaintext was marker + {} zero bytes; zeros become keystream after ChaCha20)",
data_ciphertext.len(),
ENTROPY_PADDING_LEN
);
eprintln!("Plaintext marker present on wire? c->s: NO, s->c: NO");
}
/// Микро-тест: вспомогательная функция `shannon_entropy` ведёт себя как ожидается.
/// Это не часть основного отчёта, но защищает от регрессий в самом проверочном коде.
#[test]
fn shannon_entropy_baseline() {
// Полностью одинаковые байты → 0 бит.
assert!((shannon_entropy(&[0xAAu8; 1024]) - 0.0).abs() < 1e-9);
// 256 различных значений по одному разу → ровно 8 бит.
let uniform: Vec<u8> = (0u32..256).map(|i| i as u8).collect();
let h = shannon_entropy(&uniform);
assert!((h - 8.0).abs() < 1e-9, "uniform entropy = {h}");
// ASCII-текст обычно даёт < 5 бит.
let text = b"This is some readable English text written here for the entropy baseline.";
let ht = shannon_entropy(text);
assert!(ht < 5.0, "ASCII entropy = {ht}");
}