feat(proto): implement Wave 2 — hybrid PKI handshake + session

aura-proto: 5-byte wire header + Frame codec (§6.1/§6.3); transport-agnostic
handshake state machine (§6.2) over split tokio AsyncRead/AsyncWrite —
hybrid X25519+ML-KEM-768 KEM, SHA-256 transcript, mutual X.509 auth with
ECDSA-P256 transcript signatures (ring), constant-time HMAC Finished;
Session with sliding-window replay protection. 13 tests green, clippy clean.

Handshake message order pinned (resolves spec diagram ambiguity); reader/writer
taken by value since Session owns both halves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-25 18:05:11 +03:00
parent b8ce58ddf0
commit bb835e4ca7
11 changed files with 1710 additions and 1 deletions
@@ -0,0 +1,122 @@
//! `test_replay_protection` — a Data record that was already delivered, replayed verbatim, must be
//! rejected by the receiver's sliding replay window.
//!
//! Topology: the client and server each talk to one end of their own duplex. A relay task in the
//! middle forwards bytes between the two. For the client->server direction the relay parses whole
//! frames (using the crate's public framing helpers) so that, once the client has sent its data
//! packet, the relay can forward that exact record to the server a SECOND time — a verbatim replay.
mod common;
use aura_proto::frame::{read_frame, write_frame, MsgType, RawFrame};
use aura_proto::{client_handshake, server_handshake, Frame, ProtoError};
use bytes::Bytes;
use tokio::io::{split, AsyncWriteExt};
use tokio::sync::oneshot;
#[tokio::test]
async fn test_replay_protection() {
let pki = common::mint_pki("vpn.aura.example", "client-alpha");
let client_cfg = pki.client_config();
let server_cfg = pki.server_config();
// Two duplexes with a relay in the middle.
let (client_io, relay_a) = tokio::io::duplex(64 * 1024);
let (relay_b, server_io) = tokio::io::duplex(64 * 1024);
let (c_read, c_write) = split(client_io);
let (s_read, s_write) = split(server_io);
let (ra_read, ra_write) = split(relay_a); // faces the client
let (rb_read, rb_write) = split(relay_b); // faces the server
// Signal so the relay forwards the replay only after the server has consumed the genuine copy.
let (genuine_done_tx, genuine_done_rx) = oneshot::channel::<()>();
// ---- Relay: client -> server, with a one-shot verbatim replay of the first Data record ----
let relay_c2s = tokio::spawn(async move {
let mut ra_read = ra_read;
let mut rb_write = rb_write;
let mut genuine_done = Some(genuine_done_rx);
let mut replayed = false;
loop {
let frame: RawFrame = match read_frame(&mut ra_read).await {
Ok(f) => f,
Err(_) => break, // EOF when the client side closes
};
// Forward the frame unchanged.
write_frame(&mut rb_write, frame.msg_type, &frame.payload)
.await
.expect("relay forward c->s");
// On the first Data record, wait until the server has accepted it, then replay it once.
if frame.msg_type == MsgType::Data && !replayed {
replayed = true;
if let Some(rx) = genuine_done.take() {
let _ = rx.await; // server signals after it accepted the genuine record
}
write_frame(&mut rb_write, frame.msg_type, &frame.payload)
.await
.expect("relay replay c->s");
rb_write.flush().await.expect("flush replay");
}
}
});
// ---- Relay: server -> client (straight byte copy) ----
let relay_s2c = tokio::spawn(async move {
let mut rb_read = rb_read;
let mut ra_write = ra_write;
let _ = tokio::io::copy(&mut rb_read, &mut ra_write).await;
let _ = ra_write.shutdown().await;
});
// ---- Client: handshake, then send exactly one Data frame ----
let client = tokio::spawn(async move {
let mut sess = client_handshake(c_read, c_write, &client_cfg)
.await
.expect("client handshake");
sess.send_frame(Frame::Data {
stream_id: 9,
payload: Bytes::from_static(b"the one and only payload"),
})
.await
.expect("client send");
// Keep the session (and thus the transport) alive until the test signals completion.
sess
});
// ---- Server: handshake, accept the genuine record, then expect the replay to be rejected ----
let server = tokio::spawn(async move {
let mut sess = server_handshake(s_read, s_write, &server_cfg)
.await
.expect("server handshake");
// 1) Genuine record is accepted.
let first = sess.recv_frame().await.expect("genuine recv");
match first {
Frame::Data { stream_id, payload } => {
assert_eq!(stream_id, 9);
assert_eq!(&payload[..], b"the one and only payload");
}
other => panic!("expected Data, got {other:?}"),
}
// Tell the relay it may now inject the verbatim replay.
genuine_done_tx.send(()).expect("signal genuine done");
// 2) The replayed record must be rejected by the replay window.
let replay_result = sess.recv_frame().await;
assert!(
matches!(replay_result, Err(ProtoError::Replay(_))),
"expected ProtoError::Replay, got {replay_result:?}"
);
sess
});
let (_client_sess, server_outcome) = tokio::join!(client, server);
server_outcome.expect("server task");
drop(_client_sess); // closes the client side -> relays drain and exit
let _ = relay_c2s.await;
let _ = relay_s2c.await;
}