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,56 @@
|
||||
//! Shared test helpers: minting an Aura CA + leaf certs, and wiring an in-memory duplex transport.
|
||||
|
||||
#![allow(dead_code)] // each integration test binary uses a different subset of these helpers
|
||||
|
||||
use aura_pki::AuraCa;
|
||||
use aura_proto::{ClientConfig, ServerConfig};
|
||||
|
||||
/// A minted PKI fixture: a CA, a server cert/key, and a client cert/key.
|
||||
pub struct Pki {
|
||||
pub ca_cert_pem: String,
|
||||
pub server_cert_pem: String,
|
||||
pub server_key_pem: String,
|
||||
pub client_cert_pem: String,
|
||||
pub client_key_pem: String,
|
||||
pub server_name: String,
|
||||
pub client_id: String,
|
||||
}
|
||||
|
||||
/// Mint a CA plus a server cert (for `server_name`) and a client cert (CN = `client_id`).
|
||||
pub fn mint_pki(server_name: &str, client_id: &str) -> Pki {
|
||||
let ca = AuraCa::generate("Aura Test Root CA").expect("generate CA");
|
||||
let server = ca
|
||||
.issue_server_cert(server_name)
|
||||
.expect("issue server cert");
|
||||
let client = ca.issue_client_cert(client_id).expect("issue client cert");
|
||||
Pki {
|
||||
ca_cert_pem: ca.ca_cert_pem(),
|
||||
server_cert_pem: server.cert_pem,
|
||||
server_key_pem: server.key_pem,
|
||||
client_cert_pem: client.cert_pem,
|
||||
client_key_pem: client.key_pem,
|
||||
server_name: server_name.to_string(),
|
||||
client_id: client_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Pki {
|
||||
/// Build a matching [`ClientConfig`] from this fixture.
|
||||
pub fn client_config(&self) -> ClientConfig {
|
||||
ClientConfig {
|
||||
ca_cert_pem: self.ca_cert_pem.clone(),
|
||||
client_cert_pem: self.client_cert_pem.clone(),
|
||||
client_key_pem: self.client_key_pem.clone(),
|
||||
server_name: self.server_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a matching [`ServerConfig`] from this fixture.
|
||||
pub fn server_config(&self) -> ServerConfig {
|
||||
ServerConfig {
|
||||
ca_cert_pem: self.ca_cert_pem.clone(),
|
||||
server_cert_pem: self.server_cert_pem.clone(),
|
||||
server_key_pem: self.server_key_pem.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! `test_data_exchange_1000pkts` — after the handshake, exchange 1000 Data frames in each
|
||||
//! direction and assert payload integrity and ordering.
|
||||
|
||||
mod common;
|
||||
|
||||
use aura_proto::{client_handshake, server_handshake, Frame};
|
||||
use bytes::Bytes;
|
||||
use tokio::io::split;
|
||||
|
||||
const N: u32 = 1000;
|
||||
|
||||
/// Build the deterministic payload for frame `i` from `who`.
|
||||
fn payload_for(who: &str, i: u32) -> Bytes {
|
||||
Bytes::from(format!(
|
||||
"{who}-packet-{i}-{}",
|
||||
"x".repeat((i % 37) as usize)
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_data_exchange_1000pkts() {
|
||||
let pki = common::mint_pki("vpn.aura.example", "client-alpha");
|
||||
let client_cfg = pki.client_config();
|
||||
let server_cfg = pki.server_config();
|
||||
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let (c_read, c_write) = split(client_end);
|
||||
let (s_read, s_write) = split(server_end);
|
||||
|
||||
let client = tokio::spawn(async move {
|
||||
let mut sess = client_handshake(c_read, c_write, &client_cfg)
|
||||
.await
|
||||
.expect("client handshake");
|
||||
// Interleave send + recv in lockstep to avoid filling the duplex buffer.
|
||||
for i in 0..N {
|
||||
sess.send_frame(Frame::Data {
|
||||
stream_id: 1,
|
||||
payload: payload_for("client", i),
|
||||
})
|
||||
.await
|
||||
.expect("client send");
|
||||
|
||||
match sess.recv_frame().await.expect("client recv") {
|
||||
Frame::Data { stream_id, payload } => {
|
||||
assert_eq!(stream_id, 2, "wrong stream id at i={i}");
|
||||
assert_eq!(
|
||||
payload,
|
||||
payload_for("server", i),
|
||||
"payload mismatch at i={i}"
|
||||
);
|
||||
}
|
||||
other => panic!("client expected Data, got {other:?}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let mut sess = server_handshake(s_read, s_write, &server_cfg)
|
||||
.await
|
||||
.expect("server handshake");
|
||||
for i in 0..N {
|
||||
// Receive the client's i-th packet first, then reply, mirroring the client's lockstep.
|
||||
match sess.recv_frame().await.expect("server recv") {
|
||||
Frame::Data { stream_id, payload } => {
|
||||
assert_eq!(stream_id, 1, "wrong stream id at i={i}");
|
||||
assert_eq!(
|
||||
payload,
|
||||
payload_for("client", i),
|
||||
"payload mismatch at i={i}"
|
||||
);
|
||||
}
|
||||
other => panic!("server expected Data, got {other:?}"),
|
||||
}
|
||||
|
||||
sess.send_frame(Frame::Data {
|
||||
stream_id: 2,
|
||||
payload: payload_for("server", i),
|
||||
})
|
||||
.await
|
||||
.expect("server send");
|
||||
}
|
||||
});
|
||||
|
||||
let (c, s) = tokio::join!(client, server);
|
||||
c.expect("client task");
|
||||
s.expect("server task");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_pong_and_close_frames_roundtrip() {
|
||||
let pki = common::mint_pki("vpn.aura.example", "c1");
|
||||
let client_cfg = pki.client_config();
|
||||
let server_cfg = pki.server_config();
|
||||
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let (c_read, c_write) = split(client_end);
|
||||
let (s_read, s_write) = split(server_end);
|
||||
|
||||
let client = tokio::spawn(async move {
|
||||
let mut sess = client_handshake(c_read, c_write, &client_cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
sess.send_frame(Frame::Ping { seq: 7 }).await.unwrap();
|
||||
match sess.recv_frame().await.unwrap() {
|
||||
Frame::Pong { seq } => assert_eq!(seq, 7),
|
||||
other => panic!("expected Pong, got {other:?}"),
|
||||
}
|
||||
sess.send_frame(Frame::Close {
|
||||
code: 0,
|
||||
reason: "bye".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let mut sess = server_handshake(s_read, s_write, &server_cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
match sess.recv_frame().await.unwrap() {
|
||||
Frame::Ping { seq } => sess.send_frame(Frame::Pong { seq }).await.unwrap(),
|
||||
other => panic!("expected Ping, got {other:?}"),
|
||||
}
|
||||
match sess.recv_frame().await.unwrap() {
|
||||
Frame::Close { code, reason } => {
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(reason, "bye");
|
||||
}
|
||||
other => panic!("expected Close, got {other:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
let (c, s) = tokio::join!(client, server);
|
||||
c.unwrap();
|
||||
s.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! `test_full_handshake_loopback` — a full client+server handshake over an in-memory duplex.
|
||||
|
||||
mod common;
|
||||
|
||||
use aura_proto::{client_handshake, server_handshake};
|
||||
use tokio::io::split;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_handshake_loopback() {
|
||||
let pki = common::mint_pki("vpn.aura.example", "client-alpha");
|
||||
let client_cfg = pki.client_config();
|
||||
let server_cfg = pki.server_config();
|
||||
|
||||
// Connected in-memory transport; split each end into independent read/write halves so the
|
||||
// handshake can use separate reader + writer (matching quinn's split streams).
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let (c_read, c_write) = split(client_end);
|
||||
let (s_read, s_write) = split(server_end);
|
||||
|
||||
let client = tokio::spawn(async move {
|
||||
client_handshake(c_read, c_write, &client_cfg)
|
||||
.await
|
||||
.map(|s| s.peer_id().map(str::to_string))
|
||||
});
|
||||
let server = tokio::spawn(async move {
|
||||
server_handshake(s_read, s_write, &server_cfg)
|
||||
.await
|
||||
.map(|s| s.peer_id().map(str::to_string))
|
||||
});
|
||||
|
||||
let (client_res, server_res) = tokio::join!(client, server);
|
||||
let client_peer = client_res
|
||||
.expect("client task")
|
||||
.expect("client handshake ok");
|
||||
let server_peer = server_res
|
||||
.expect("server task")
|
||||
.expect("server handshake ok");
|
||||
|
||||
// Server learned the client id from the verified client certificate.
|
||||
assert_eq!(server_peer.as_deref(), Some("client-alpha"));
|
||||
// Client recorded the server name it authenticated.
|
||||
assert_eq!(client_peer.as_deref(), Some("vpn.aura.example"));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! `test_pki_mutual_auth` — the server must reject a client whose certificate was issued by a
|
||||
//! different CA, and must reject a client that presents a valid certificate but a forged signature
|
||||
//! (one made with a key that does not match the certificate).
|
||||
|
||||
mod common;
|
||||
|
||||
use aura_pki::AuraCa;
|
||||
use aura_proto::{client_handshake, server_handshake, ClientConfig, ProtoError};
|
||||
use tokio::io::split;
|
||||
|
||||
/// Run a handshake and return both sides' results.
|
||||
async fn run(
|
||||
client_cfg: ClientConfig,
|
||||
server_cfg: aura_proto::ServerConfig,
|
||||
) -> (Result<(), ProtoError>, Result<Option<String>, ProtoError>) {
|
||||
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||
let (c_read, c_write) = split(client_end);
|
||||
let (s_read, s_write) = split(server_end);
|
||||
|
||||
let client = tokio::spawn(async move {
|
||||
client_handshake(c_read, c_write, &client_cfg)
|
||||
.await
|
||||
.map(|_| ())
|
||||
});
|
||||
let server = tokio::spawn(async move {
|
||||
server_handshake(s_read, s_write, &server_cfg)
|
||||
.await
|
||||
.map(|s| s.peer_id().map(str::to_string))
|
||||
});
|
||||
let (c, s) = tokio::join!(client, server);
|
||||
(c.expect("client task"), s.expect("server task"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_ca_client_cert_is_rejected() {
|
||||
// The legitimate server-side PKI.
|
||||
let pki = common::mint_pki("vpn.aura.example", "client-alpha");
|
||||
|
||||
// An attacker CA issues a client cert with a plausible CN, but it does NOT chain to the
|
||||
// server's trusted CA.
|
||||
let rogue_ca = AuraCa::generate("Rogue CA").expect("rogue CA");
|
||||
let rogue_client = rogue_ca
|
||||
.issue_client_cert("client-alpha")
|
||||
.expect("rogue client cert");
|
||||
|
||||
let client_cfg = ClientConfig {
|
||||
ca_cert_pem: pki.ca_cert_pem.clone(),
|
||||
client_cert_pem: rogue_client.cert_pem,
|
||||
client_key_pem: rogue_client.key_pem,
|
||||
server_name: pki.server_name.clone(),
|
||||
};
|
||||
|
||||
let (_client_res, server_res) = run(client_cfg, pki.server_config()).await;
|
||||
// The server must fail verifying the client chain against its trusted CA.
|
||||
assert!(
|
||||
matches!(server_res, Err(ProtoError::Pki(_))),
|
||||
"expected a PKI verification failure, got {server_res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forged_client_signature_is_rejected() {
|
||||
let pki = common::mint_pki("vpn.aura.example", "client-alpha");
|
||||
|
||||
// Mint an unrelated P-256 keypair (via a throwaway issued cert) to use as the WRONG signing
|
||||
// key. We pair the legitimate client's certificate with this mismatched private key: the chain
|
||||
// verifies fine, but the signature over the transcript is made with a key that does not match
|
||||
// the certificate's public key, so signature verification must fail.
|
||||
let throwaway_ca = AuraCa::generate("throwaway").expect("throwaway CA");
|
||||
let mismatched = throwaway_ca
|
||||
.issue_client_cert("mismatched")
|
||||
.expect("throwaway cert");
|
||||
|
||||
let client_cfg = ClientConfig {
|
||||
ca_cert_pem: pki.ca_cert_pem.clone(),
|
||||
client_cert_pem: pki.client_cert_pem.clone(), // valid cert (chains to trusted CA)
|
||||
client_key_pem: mismatched.key_pem, // WRONG key -> forged signature
|
||||
server_name: pki.server_name.clone(),
|
||||
};
|
||||
|
||||
let (_client_res, server_res) = run(client_cfg, pki.server_config()).await;
|
||||
assert!(
|
||||
matches!(server_res, Err(ProtoError::Signature(_))),
|
||||
"expected a signature verification failure, got {server_res:?}"
|
||||
);
|
||||
}
|
||||
@@ -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