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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user