feat(transport,tunnel): implement Wave 3 — QUIC transport + split-tunnel router
aura-transport: quinn 0.11 endpoint with HTTP/3 mimicry (ALPN h3/h3-29, Chrome-like transport params), outer-TLS accept-any (real auth is the inner Aura handshake), packet padding to HTTPS sizes; AuraServer/AuraClient drive the proto handshake over a QUIC bidi stream; AuraConnection impls aura_proto::PacketConnection (full-duplex via Session::split + per-half mutex). 14 tests incl. a real-QUIC loopback end-to-end (crypto+pki+proto+transport). aura-tunnel: RouteTable (longest-prefix split-tunnel classify), AuraDns (hickory) host-route registration, AuraRouter over a PacketIo TUN seam + Arc<dyn PacketConnection>, AuraTun (tun 0.8 unix; wintun cfg-gated Windows). 10 tests (route classify/priority, dst-IP parse, mock router). send_direct is a v1 stub. Whole workspace: tests green, clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
//! [`AuraConnection`]: an established, authenticated Aura session exposed as a full-duplex packet
|
||||
//! pipe (project §7, [`aura_proto::PacketConnection`]).
|
||||
//!
|
||||
//! After both the outer QUIC handshake and the inner Aura proto handshake complete, the resulting
|
||||
//! [`aura_proto::Session`] is wrapped here. The session is [`split`](aura_proto::Session::split)
|
||||
//! into its send and receive halves, each parked behind its own [`tokio::sync::Mutex`]. Two separate
|
||||
//! mutexes (rather than one over the whole session) is the point: a sender task and a receiver task
|
||||
//! can hold their respective locks simultaneously, so `send_packet` and `recv_packet` run truly
|
||||
//! concurrently — which is exactly how the tunnel router drives this (one task per direction).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use quinn::{RecvStream, SendStream};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use aura_proto::{Frame, PacketConnection, Session, SessionReceiver, SessionSender};
|
||||
|
||||
/// The concrete session type carried over QUIC: proto session over quinn's stream halves.
|
||||
type QuicSession = Session<RecvStream, SendStream>;
|
||||
|
||||
/// An established Aura connection: a QUIC-carried, hybrid-PQ + mutually-X.509-authenticated session,
|
||||
/// usable as a one-IP-packet-per-call duplex pipe.
|
||||
///
|
||||
/// Implements [`aura_proto::PacketConnection`], so it can be shared as
|
||||
/// `Arc<dyn aura_proto::PacketConnection>` across concurrent send/receive tasks (the methods take
|
||||
/// `&self`). Outbound packets are sent as [`Frame::Data`] on `stream_id 0`; inbound `Data` frames'
|
||||
/// payloads are returned, while `Ping`/`Pong`/`Close` are handled transparently (see
|
||||
/// [`recv_packet`](AuraConnection::recv_packet)).
|
||||
pub struct AuraConnection {
|
||||
sender: Mutex<SessionSender<SendStream>>,
|
||||
receiver: Mutex<SessionReceiver<RecvStream>>,
|
||||
/// The verified peer Common Name captured before the session was split (the server learns the
|
||||
/// client id; the client learns the server name).
|
||||
peer_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AuraConnection {
|
||||
/// Wrap an established proto [`Session`] (already past both handshakes) for packet I/O.
|
||||
#[must_use]
|
||||
pub fn from_session(session: QuicSession) -> Self {
|
||||
let peer_id = session.peer_id().map(str::to_owned);
|
||||
let (sender, receiver) = session.split();
|
||||
Self {
|
||||
sender: Mutex::new(sender),
|
||||
receiver: Mutex::new(receiver),
|
||||
peer_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The verified identity (Common Name) of the peer, if one was established during the handshake.
|
||||
#[must_use]
|
||||
pub fn peer_id(&self) -> Option<&str> {
|
||||
self.peer_id.as_deref()
|
||||
}
|
||||
|
||||
/// Convenience: wrap this connection as a trait object for the tunnel layer.
|
||||
#[must_use]
|
||||
pub fn into_dyn(self) -> Arc<dyn PacketConnection> {
|
||||
Arc::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PacketConnection for AuraConnection {
|
||||
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
||||
let frame = Frame::Data {
|
||||
stream_id: 0,
|
||||
payload: Bytes::copy_from_slice(packet),
|
||||
};
|
||||
self.sender.lock().await.send_frame(frame).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv_packet(&self) -> anyhow::Result<Vec<u8>> {
|
||||
// Hold the receive lock across the await: only one receiver task drains the stream, and the
|
||||
// proto framing is sequential, so concurrent recvs must not interleave on the same half.
|
||||
let mut receiver = self.receiver.lock().await;
|
||||
loop {
|
||||
match receiver.recv_frame().await? {
|
||||
Frame::Data { payload, .. } => return Ok(payload.to_vec()),
|
||||
// Liveness keep-alive: answer a Ping with the matching Pong, then keep waiting for
|
||||
// real data. The sender is a *separate* mutex, so taking it here cannot deadlock
|
||||
// against the receive lock we already hold.
|
||||
Frame::Ping { seq } => {
|
||||
self.sender
|
||||
.lock()
|
||||
.await
|
||||
.send_frame(Frame::Pong { seq })
|
||||
.await?;
|
||||
}
|
||||
// Stray Pong (we don't currently originate Pings): ignore and keep waiting.
|
||||
Frame::Pong { .. } => continue,
|
||||
// A clean Close ends the packet stream; surface it as an error so the caller's
|
||||
// receive loop terminates rather than spinning.
|
||||
Frame::Close { code, reason } => {
|
||||
anyhow::bail!("peer closed connection (code {code}): {reason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,222 @@
|
||||
//! aura-transport — QUIC transport and HTTPS/H3 traffic mimicry (skeleton; implemented in Wave 3).
|
||||
//! aura-transport — the Aura VPN's QUIC transport with HTTP/3 traffic mimicry (project §7).
|
||||
//!
|
||||
//! Implements `aura_proto::PacketConnection` over a QUIC-carried `aura_proto::Session`, and provides
|
||||
//! the quinn endpoint setup (`quic`), mimicry (`mimicry`), and packet padding (`padding`).
|
||||
//! This crate carries the Aura protocol over real QUIC and exposes an established connection as an
|
||||
//! [`aura_proto::PacketConnection`]. It has two layers, and which one is the security boundary is
|
||||
//! the key design point:
|
||||
//!
|
||||
//! * **Outer = QUIC/TLS mimicry.** The connection is dressed up to look like ordinary browser
|
||||
//! HTTP/3: ALPN `h3`/`h3-29` and Chrome-like transport parameters (see [`mimicry`]). The outer
|
||||
//! TLS is **not** the real authentication — so the QUIC client accepts *any* server certificate
|
||||
//! ([`quic::AcceptAnyServerCert`]). A passive observer sees what looks like a CDN connection.
|
||||
//! * **Inner = the Aura proto handshake.** Over a single bidirectional QUIC stream we run
|
||||
//! [`aura_proto::client_handshake`] / [`aura_proto::server_handshake`], which perform the hybrid
|
||||
//! post-quantum key agreement and **mutual X.509** verification against the Aura CA. *This* is the
|
||||
//! authentication and the source of the session keys.
|
||||
//!
|
||||
//! ## Layout (project §7)
|
||||
//! * [`quic`] — quinn endpoint/config setup and the dangerous outer-TLS verifier.
|
||||
//! * [`mimicry`] — ALPN/SNI constants and [`mimicry::chrome_quic_transport_config`].
|
||||
//! * [`padding`] — [`padding::pad_to_https_size`] / [`padding::inject_padding_frames`] traffic shaping.
|
||||
//! * [`conn`] — [`AuraConnection`], the [`aura_proto::PacketConnection`] implementation.
|
||||
//!
|
||||
//! ## Usage (Wave 4 / CLI)
|
||||
//! ```no_run
|
||||
//! # async fn demo(
|
||||
//! # ca_cert_pem: String, server_cert_pem: String, server_key_pem: String,
|
||||
//! # client_cert_pem: String, client_key_pem: String, server_name: String,
|
||||
//! # ) -> anyhow::Result<()> {
|
||||
//! use aura_transport::{AuraServer, AuraClient, PacketConnection};
|
||||
//! use aura_proto::{ServerConfig, ClientConfig};
|
||||
//!
|
||||
//! // Server: bind, then accept authenticated connections in a loop.
|
||||
//! let server = AuraServer::bind(
|
||||
//! "0.0.0.0:4433".parse()?,
|
||||
//! &server_cert_pem, // outer QUIC cert (may equal the Aura server cert)
|
||||
//! &server_key_pem,
|
||||
//! ServerConfig {
|
||||
//! ca_cert_pem: ca_cert_pem.clone(),
|
||||
//! server_cert_pem: server_cert_pem.clone(),
|
||||
//! server_key_pem: server_key_pem.clone(),
|
||||
//! },
|
||||
//! )?;
|
||||
//! let server_conn = server.accept().await?; // -> AuraConnection
|
||||
//!
|
||||
//! // Client: connect to the server's address with a camouflage SNI.
|
||||
//! let client_conn = AuraClient::connect(
|
||||
//! "203.0.113.10:4433".parse()?,
|
||||
//! "cdn.example.com", // outer SNI (mimicry)
|
||||
//! ClientConfig { ca_cert_pem, client_cert_pem, client_key_pem, server_name },
|
||||
//! ).await?;
|
||||
//!
|
||||
//! // Either side: use it as a packet pipe (also works behind Arc<dyn PacketConnection>).
|
||||
//! client_conn.send_packet(b"\x45\x00 ...ip packet... ").await?;
|
||||
//! let pkt = server_conn.recv_packet().await?;
|
||||
//! # let _ = pkt; Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod conn;
|
||||
pub mod mimicry;
|
||||
pub mod padding;
|
||||
pub mod quic;
|
||||
|
||||
pub use conn::AuraConnection;
|
||||
pub use mimicry::{alpn_protocols, chrome_quic_transport_config, ALPN_H3, DEFAULT_SNI};
|
||||
pub use padding::{inject_padding_frames, pad_to_https_size, HTTPS_SIZE_BUCKETS};
|
||||
pub use quic::{client_endpoint, server_endpoint, AcceptAnyServerCert};
|
||||
|
||||
// Re-export the inner proto trait so downstream crates (the CLI) can name the connection as
|
||||
// `Arc<dyn aura_transport::PacketConnection>` without a separate `aura_proto` import.
|
||||
pub use aura_proto::PacketConnection;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use aura_proto::{client_handshake, server_handshake, ClientConfig, ServerConfig};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the Aura transport layer.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransportError {
|
||||
/// A PEM blob (certificate or private key) could not be parsed.
|
||||
#[error("PEM parse error: {0}")]
|
||||
Pem(String),
|
||||
|
||||
/// Building or converting a rustls/quic TLS configuration failed.
|
||||
#[error("TLS configuration error: {0}")]
|
||||
Tls(String),
|
||||
|
||||
/// Binding, connecting, or operating the quinn endpoint failed (includes the QUIC handshake).
|
||||
#[error("QUIC transport error: {0}")]
|
||||
Quic(String),
|
||||
|
||||
/// The inner Aura protocol handshake failed.
|
||||
#[error("Aura handshake error: {0}")]
|
||||
Handshake(#[from] aura_proto::ProtoError),
|
||||
|
||||
/// A generic I/O error (e.g. binding the UDP socket).
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
// quinn's connect/handshake errors are distinct types; fold them into one transport error.
|
||||
impl From<quinn::ConnectError> for TransportError {
|
||||
fn from(e: quinn::ConnectError) -> Self {
|
||||
TransportError::Quic(format!("connect: {e}"))
|
||||
}
|
||||
}
|
||||
impl From<quinn::ConnectionError> for TransportError {
|
||||
fn from(e: quinn::ConnectionError) -> Self {
|
||||
TransportError::Quic(format!("connection: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An Aura VPN server: a bound QUIC endpoint that accepts authenticated [`AuraConnection`]s.
|
||||
///
|
||||
/// Each [`accept`](AuraServer::accept) performs the outer QUIC accept, opens the inner bidirectional
|
||||
/// stream, runs [`aura_proto::server_handshake`] (mutual auth against the CA), and returns a ready
|
||||
/// [`AuraConnection`].
|
||||
pub struct AuraServer {
|
||||
endpoint: quinn::Endpoint,
|
||||
proto_cfg: Arc<ServerConfig>,
|
||||
}
|
||||
|
||||
impl AuraServer {
|
||||
/// Bind a server on `addr`.
|
||||
///
|
||||
/// * `addr` — UDP address to listen on; use `..:0` for an OS-assigned port and read it back with
|
||||
/// [`AuraServer::local_addr`].
|
||||
/// * `outer_cert_pem` / `outer_key_pem` — the **outer** QUIC/TLS (mimicry) certificate and key.
|
||||
/// These may be the same PEM as the Aura server cert in `proto_cfg` (and typically are).
|
||||
/// * `proto_cfg` — the inner Aura handshake config (CA + server leaf cert/key) used to mutually
|
||||
/// authenticate each client.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`TransportError`] if the certs/keys are unparsable or the UDP socket cannot bind.
|
||||
pub fn bind(
|
||||
addr: SocketAddr,
|
||||
outer_cert_pem: &str,
|
||||
outer_key_pem: &str,
|
||||
proto_cfg: ServerConfig,
|
||||
) -> Result<Self, TransportError> {
|
||||
let endpoint = quic::server_endpoint(addr, outer_cert_pem, outer_key_pem)?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
proto_cfg: Arc::new(proto_cfg),
|
||||
})
|
||||
}
|
||||
|
||||
/// The local address (including the OS-assigned port) this server is bound to.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`TransportError::Io`] if the underlying socket address cannot be read.
|
||||
pub fn local_addr(&self) -> Result<SocketAddr, TransportError> {
|
||||
Ok(self.endpoint.local_addr()?)
|
||||
}
|
||||
|
||||
/// Accept the next client: outer QUIC handshake, then the inner Aura mutual-auth handshake.
|
||||
///
|
||||
/// Returns a ready [`AuraConnection`] whose [`peer_id`](AuraConnection::peer_id) is the verified
|
||||
/// client Common Name. Call this in a loop (optionally spawning a task per connection).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`TransportError`] if the endpoint is closed, the QUIC handshake fails, or the inner
|
||||
/// Aura handshake fails (e.g. the client's certificate does not verify against the CA).
|
||||
pub async fn accept(&self) -> Result<AuraConnection, TransportError> {
|
||||
let incoming = self
|
||||
.endpoint
|
||||
.accept()
|
||||
.await
|
||||
.ok_or_else(|| TransportError::Quic("endpoint closed".into()))?;
|
||||
let connection = incoming.await?;
|
||||
// The client opens the bidi stream by writing the first ClientHello byte; accept it.
|
||||
let (send, recv) = connection.accept_bi().await?;
|
||||
// proto reader = RecvStream, proto writer = SendStream.
|
||||
let session = server_handshake(recv, send, &self.proto_cfg).await?;
|
||||
Ok(AuraConnection::from_session(session))
|
||||
}
|
||||
|
||||
/// Access the underlying quinn endpoint (e.g. for graceful shutdown via `close`/`wait_idle`).
|
||||
#[must_use]
|
||||
pub fn endpoint(&self) -> &quinn::Endpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
}
|
||||
|
||||
/// An Aura VPN client entry point.
|
||||
pub struct AuraClient;
|
||||
|
||||
impl AuraClient {
|
||||
/// Connect to an Aura server at `server_addr`, presenting `sni` as the outer (mimicry) hostname.
|
||||
///
|
||||
/// Performs the outer QUIC connect (accepting any server cert — see crate docs), opens a single
|
||||
/// bidirectional stream, and runs [`aura_proto::client_handshake`] for hybrid-PQ key agreement
|
||||
/// and mutual X.509 auth using `proto_cfg`.
|
||||
///
|
||||
/// * `server_addr` — the server's UDP socket address.
|
||||
/// * `sni` — the Server Name Indication to present on the outer TLS (camouflage, e.g.
|
||||
/// `"cdn.example.com"`); this is independent of `proto_cfg.server_name`, which is the name
|
||||
/// verified *inside* the Aura handshake against the server's real certificate.
|
||||
/// * `proto_cfg` — CA + client leaf cert/key + expected server name for the inner handshake.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`TransportError`] if the QUIC connect/handshake fails or the inner Aura handshake
|
||||
/// fails (e.g. the server cert does not chain to the CA or its SAN does not match
|
||||
/// `proto_cfg.server_name`).
|
||||
pub async fn connect(
|
||||
server_addr: SocketAddr,
|
||||
sni: &str,
|
||||
proto_cfg: ClientConfig,
|
||||
) -> Result<AuraConnection, TransportError> {
|
||||
let endpoint = quic::client_endpoint()?;
|
||||
let connection = endpoint.connect(server_addr, sni)?.await?;
|
||||
// open_bi() reserves the stream; the first write (the ClientHello inside the handshake)
|
||||
// actually opens it on the wire.
|
||||
let (send, recv) = connection.open_bi().await?;
|
||||
let session = client_handshake(recv, send, &proto_cfg).await?;
|
||||
Ok(AuraConnection::from_session(session))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! HTTPS/H3 mimicry configuration (project §7, "outer = mimicry").
|
||||
//!
|
||||
//! The outer QUIC/TLS layer is meant to look like ordinary browser HTTP/3 traffic so a passive
|
||||
//! observer sees what appears to be a connection to a CDN, not a VPN. That disguise is *not* the
|
||||
//! security boundary — see the crate docs and [`crate::quic::AcceptAnyServerCert`]; the real mutual
|
||||
//! authentication happens in the inner Aura proto handshake. This module just centralizes the
|
||||
//! browser-flavored knobs (ALPN, a default SNI, transport tuning) so they are set consistently.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// ALPN protocol identifiers advertised on the outer TLS handshake.
|
||||
///
|
||||
/// `h3` (RFC 9114) and `h3-29` (a still-seen draft) are exactly what Chrome offers for HTTP/3, so
|
||||
/// advertising them makes the ClientHello/ServerHello ALPN extension indistinguishable from a real
|
||||
/// browser's. Both client and server must agree, so they share this list.
|
||||
pub const ALPN_H3: &[&[u8]] = &[b"h3", b"h3-29"];
|
||||
|
||||
/// A plausible default SNI to present when the caller does not specify one.
|
||||
///
|
||||
/// Picking a generic CDN-looking hostname keeps the Server Name Indication from screaming "VPN".
|
||||
/// Callers should normally pass their own camouflage hostname to [`crate::AuraClient::connect`];
|
||||
/// this is only a fallback.
|
||||
pub const DEFAULT_SNI: &str = "cdn.example.com";
|
||||
|
||||
/// Return the ALPN list as owned `Vec<Vec<u8>>`, the shape rustls' `alpn_protocols` field wants.
|
||||
#[must_use]
|
||||
pub fn alpn_protocols() -> Vec<Vec<u8>> {
|
||||
ALPN_H3.iter().map(|p| p.to_vec()).collect()
|
||||
}
|
||||
|
||||
/// Chrome-like QUIC transport timing/flow-control knobs (project §7.1).
|
||||
///
|
||||
/// These values mirror what a Chromium HTTP/3 connection uses closely enough that the resulting
|
||||
/// idle-timeout / keep-alive / flow-control behavior is unremarkable on the wire:
|
||||
///
|
||||
/// * `max_idle_timeout` ~ 30s — Chrome's default idle timeout.
|
||||
/// * `keep_alive_interval` ~ 15s — half the idle timeout, so an otherwise-quiet tunnel stays up.
|
||||
/// * `max_concurrent_bidi_streams` = 100 — a browser-ish concurrency ceiling.
|
||||
/// * receive windows ~ 10 MB — generous stream/connection flow-control windows so bulk transfer is
|
||||
/// not throttled (and matches a browser doing large downloads).
|
||||
///
|
||||
/// Returned by value so callers wrap it in `Arc` and hand it to both the client and server
|
||||
/// `quinn::*Config` (keeping the two ends symmetric, which also aids the disguise).
|
||||
#[must_use]
|
||||
pub fn chrome_quic_transport_config() -> quinn::TransportConfig {
|
||||
/// ~10 MB flow-control window (stream and connection level).
|
||||
const RECV_WINDOW: u32 = 10 * 1024 * 1024;
|
||||
|
||||
let mut tc = quinn::TransportConfig::default();
|
||||
|
||||
// 30s idle timeout. `IdleTimeout::try_from(Duration)` only fails for absurdly large durations;
|
||||
// 30s is always representable, so the expect is unreachable in practice.
|
||||
let idle = quinn::IdleTimeout::try_from(Duration::from_secs(30))
|
||||
.expect("30s is a valid QUIC idle timeout");
|
||||
tc.max_idle_timeout(Some(idle));
|
||||
tc.keep_alive_interval(Some(Duration::from_secs(15)));
|
||||
|
||||
tc.max_concurrent_bidi_streams(100u32.into());
|
||||
// Keep uni-streams modest; the Aura tunnel only uses one bidi stream, but a browser-like profile
|
||||
// still permits a handful of unidirectional streams (e.g. H3 control/QPACK streams).
|
||||
tc.max_concurrent_uni_streams(100u32.into());
|
||||
|
||||
tc.stream_receive_window(RECV_WINDOW.into());
|
||||
tc.receive_window((RECV_WINDOW * 2).into());
|
||||
tc.send_window(u64::from(RECV_WINDOW) * 2);
|
||||
|
||||
tc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn alpn_is_h3() {
|
||||
assert_eq!(ALPN_H3, &[b"h3".as_slice(), b"h3-29".as_slice()]);
|
||||
let owned = alpn_protocols();
|
||||
assert_eq!(owned, vec![b"h3".to_vec(), b"h3-29".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_config_builds() {
|
||||
// §7.1: must construct without panicking (the IdleTimeout conversion is the only fallible
|
||||
// step, and 30s is always valid).
|
||||
let _tc = chrome_quic_transport_config();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Traffic-shaping padding helpers (project §7.2).
|
||||
//!
|
||||
//! Aura's outer wire is QUIC dressed up as HTTP/3 (see [`crate::mimicry`]). Real HTTPS/H3 traffic
|
||||
//! tends to cluster at a handful of record/datagram sizes; padding an Aura payload up to one of
|
||||
//! those buckets makes a passive size-only classifier less able to single it out. These helpers are
|
||||
//! deliberately simple, allocation-light, and fully unit-tested so the higher layers can call them
|
||||
//! with confidence.
|
||||
//!
|
||||
//! Note these operate on the *application* payload before it is handed to the proto/QUIC layers.
|
||||
//! They do not, and cannot, hide QUIC's own framing — they only normalize plaintext lengths so the
|
||||
//! ciphertext lands in a common size class.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
/// The HTTPS/H3-like size buckets a payload is rounded up to, in ascending order.
|
||||
///
|
||||
/// The first five (64 / 128 / 256 / 512 / 1024) are common small-record sizes; 1280 is the IPv6
|
||||
/// minimum-MTU "safe" QUIC datagram size; 1460 is a typical Ethernet TCP/QUIC payload (1500-byte
|
||||
/// MTU minus IP+UDP headers). Keep this sorted ascending: [`pad_to_https_size`] relies on it.
|
||||
pub const HTTPS_SIZE_BUCKETS: [usize; 7] = [64, 128, 256, 512, 1024, 1280, 1460];
|
||||
|
||||
/// The largest bucket in [`HTTPS_SIZE_BUCKETS`]; payloads at or above this are left unpadded.
|
||||
pub const MAX_BUCKET: usize = HTTPS_SIZE_BUCKETS[HTTPS_SIZE_BUCKETS.len() - 1];
|
||||
|
||||
/// Pad `packet` (in place, appending zero bytes) up to the next HTTPS-like size bucket.
|
||||
///
|
||||
/// Behavior:
|
||||
/// * If `packet.len()` is already exactly a bucket, it is left unchanged (idempotent).
|
||||
/// * Otherwise it grows to the smallest bucket strictly larger than its current length.
|
||||
/// * **At or over the largest bucket ([`MAX_BUCKET`] = 1460):** the packet is left unchanged. We do
|
||||
/// not pad to a multiple of 1460, because a single Aura payload is expected to fit within one
|
||||
/// datagram; over-MTU payloads are the caller's concern (e.g. they will be split by QUIC anyway),
|
||||
/// and rounding them up would only waste bandwidth without improving the size-class disguise.
|
||||
///
|
||||
/// Padding is appended as zero bytes; this is a length-shaping primitive, not an authenticated
|
||||
/// framing scheme — the proto layer seals the whole (already-padded) payload with AEAD, so the pad
|
||||
/// bytes are encrypted on the wire. Callers that need to *recover* the original length must carry it
|
||||
/// themselves (e.g. an inner length prefix); for Aura's IP-packet payloads the IP total-length field
|
||||
/// already bounds the real data, so trailing zeros are simply ignored by the receiver.
|
||||
pub fn pad_to_https_size(packet: &mut Vec<u8>) {
|
||||
let len = packet.len();
|
||||
// Smallest bucket that can already hold `len` (>=, so an exact-bucket length is a no-op and the
|
||||
// operation is idempotent). If `len` exceeds every bucket, no padding is applied.
|
||||
if let Some(&target) = HTTPS_SIZE_BUCKETS.iter().find(|&&b| b >= len) {
|
||||
packet.resize(target, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the bucket `len` would be padded *up to* by [`pad_to_https_size`], or `len` itself if it
|
||||
/// is at/over [`MAX_BUCKET`]. Useful for sizing buffers ahead of time and for tests.
|
||||
#[must_use]
|
||||
pub fn next_https_bucket(len: usize) -> usize {
|
||||
HTTPS_SIZE_BUCKETS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&b| b >= len)
|
||||
.unwrap_or(len)
|
||||
}
|
||||
|
||||
/// Best-effort random padding: append between `min_pad` and `max_pad` (inclusive) zero bytes to
|
||||
/// `packet`, capping the result at `max_total` bytes so a hard size ceiling is never exceeded.
|
||||
///
|
||||
/// Signature rationale: callers want jitter on the wire without blowing a datagram budget, so the
|
||||
/// knobs are an inclusive `[min_pad, max_pad]` range plus an absolute `max_total` clamp. Returns the
|
||||
/// number of pad bytes actually appended (which may be **less** than `min_pad` if `max_total` was
|
||||
/// already nearly reached, including `0` when `packet.len() >= max_total`).
|
||||
///
|
||||
/// "Best-effort" = if the requested padding does not fit under `max_total`, as much as fits is added
|
||||
/// rather than erroring. If `min_pad > max_pad` the arguments are swapped so the call still does
|
||||
/// something sensible instead of panicking.
|
||||
pub fn inject_padding_frames(
|
||||
packet: &mut Vec<u8>,
|
||||
min_pad: usize,
|
||||
max_pad: usize,
|
||||
max_total: usize,
|
||||
) -> usize {
|
||||
let (lo, hi) = if min_pad <= max_pad {
|
||||
(min_pad, max_pad)
|
||||
} else {
|
||||
(max_pad, min_pad)
|
||||
};
|
||||
|
||||
let headroom = max_total.saturating_sub(packet.len());
|
||||
if headroom == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Pick a random amount in [lo, hi], then clamp to whatever headroom remains.
|
||||
let want = if lo == hi {
|
||||
lo
|
||||
} else {
|
||||
rand::thread_rng().gen_range(lo..=hi)
|
||||
};
|
||||
let pad = want.min(headroom);
|
||||
packet.resize(packet.len() + pad, 0);
|
||||
pad
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pads_up_to_each_bucket() {
|
||||
// One below / one into each bucket lands on that bucket.
|
||||
let cases = [
|
||||
(0usize, 64usize),
|
||||
(1, 64),
|
||||
(63, 64),
|
||||
(65, 128),
|
||||
(127, 128),
|
||||
(200, 256),
|
||||
(300, 512),
|
||||
(513, 1024),
|
||||
(1025, 1280),
|
||||
(1281, 1460),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let mut v = vec![0xAB; input];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), expected, "padding {input} should reach {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_bucket_is_unchanged_and_idempotent() {
|
||||
for &b in &HTTPS_SIZE_BUCKETS {
|
||||
let mut v = vec![1u8; b];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), b, "exact bucket {b} must not grow");
|
||||
// Idempotence: padding again changes nothing.
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), b, "re-padding bucket {b} must be a no-op");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_or_over_max_bucket_is_left_alone() {
|
||||
for len in [MAX_BUCKET, MAX_BUCKET + 1, 2000, 9000] {
|
||||
let mut v = vec![7u8; len];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), len, "len {len} >= MAX_BUCKET must be unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_preserves_original_prefix() {
|
||||
let mut v: Vec<u8> = (0..50u8).collect();
|
||||
let original = v.clone();
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), 64);
|
||||
assert_eq!(&v[..50], &original[..], "real bytes must be preserved");
|
||||
assert!(v[50..].iter().all(|&b| b == 0), "pad must be zero bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bucket_matches_padding() {
|
||||
for len in [0usize, 1, 64, 65, 1024, 1459, 1460, 5000] {
|
||||
let predicted = next_https_bucket(len);
|
||||
let mut v = vec![0u8; len];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(predicted, v.len(), "next_https_bucket disagrees at {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_respects_range_and_cap() {
|
||||
// Plenty of headroom: result grows by something in [4, 8].
|
||||
let mut v = vec![0u8; 10];
|
||||
let added = inject_padding_frames(&mut v, 4, 8, 1000);
|
||||
assert!((4..=8).contains(&added), "added {added} outside [4,8]");
|
||||
assert_eq!(v.len(), 10 + added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_clamps_to_max_total() {
|
||||
// Only 3 bytes of headroom even though we ask for 10..=10.
|
||||
let mut v = vec![0u8; 97];
|
||||
let added = inject_padding_frames(&mut v, 10, 10, 100);
|
||||
assert_eq!(added, 3, "should add only what fits under max_total");
|
||||
assert_eq!(v.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_zero_headroom_is_noop() {
|
||||
let mut v = vec![0u8; 100];
|
||||
let added = inject_padding_frames(&mut v, 1, 50, 100);
|
||||
assert_eq!(added, 0);
|
||||
assert_eq!(v.len(), 100);
|
||||
|
||||
// Already over the cap: still a no-op, never truncates.
|
||||
let mut v2 = vec![0u8; 120];
|
||||
let added2 = inject_padding_frames(&mut v2, 1, 50, 100);
|
||||
assert_eq!(added2, 0);
|
||||
assert_eq!(v2.len(), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_swapped_bounds_dont_panic() {
|
||||
let mut v = vec![0u8; 10];
|
||||
let added = inject_padding_frames(&mut v, 8, 4, 1000); // min > max
|
||||
assert!((4..=8).contains(&added));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! quinn endpoint setup and the outer TLS configuration (project §7).
|
||||
//!
|
||||
//! This wires up the **outer** QUIC/TLS layer. Two things are deliberately unusual and security-
|
||||
//! relevant:
|
||||
//!
|
||||
//! 1. The outer TLS uses ALPN `h3`/`h3-29` and Chrome-like transport params (see [`crate::mimicry`])
|
||||
//! purely as camouflage.
|
||||
//! 2. The QUIC **client accepts any server certificate** ([`AcceptAnyServerCert`]). This is safe
|
||||
//! *only* because the outer TLS is not the authentication boundary: the real mutual auth is the
|
||||
//! inner Aura proto handshake ([`aura_proto::client_handshake`] / [`aura_proto::server_handshake`])
|
||||
//! run over the QUIC stream, which performs hybrid-PQ key agreement and mutual X.509 verification
|
||||
//! against the Aura CA. Do not reuse `AcceptAnyServerCert` anywhere the TLS layer *is* the
|
||||
//! authentication.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
|
||||
use rustls::{DigitallySignedStruct, SignatureScheme};
|
||||
|
||||
use crate::mimicry::{alpn_protocols, chrome_quic_transport_config};
|
||||
use crate::TransportError;
|
||||
|
||||
/// Ensure rustls 0.23 has a process-wide [`CryptoProvider`](rustls::crypto::CryptoProvider).
|
||||
///
|
||||
/// rustls 0.23 panics ("no process-level CryptoProvider available") if a config is built before a
|
||||
/// default provider is installed. Installing is idempotent (a second install is ignored), so it is
|
||||
/// safe — and cheap — to call this before building any client/server config. We use the `ring`
|
||||
/// provider, matching quinn's `rustls-ring` default feature.
|
||||
pub fn ensure_crypto_provider() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
/// A [`ServerCertVerifier`] that accepts **any** server certificate without checking it.
|
||||
///
|
||||
/// See the module docs: the outer TLS is mimicry, not authentication, so the client does not (and
|
||||
/// must not need to) trust the outer cert. All real verification is in the inner Aura handshake.
|
||||
#[derive(Debug)]
|
||||
pub struct AcceptAnyServerCert;
|
||||
|
||||
impl ServerCertVerifier for AcceptAnyServerCert {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
// Advertise the full modern set so we never reject the outer handshake for scheme reasons.
|
||||
vec![
|
||||
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
SignatureScheme::ED25519,
|
||||
SignatureScheme::RSA_PSS_SHA256,
|
||||
SignatureScheme::RSA_PSS_SHA384,
|
||||
SignatureScheme::RSA_PSS_SHA512,
|
||||
SignatureScheme::RSA_PKCS1_SHA256,
|
||||
SignatureScheme::RSA_PKCS1_SHA384,
|
||||
SignatureScheme::RSA_PKCS1_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PEM bundle of one or more certificates into DER.
|
||||
pub(crate) fn certs_from_pem(pem: &str) -> Result<Vec<CertificateDer<'static>>, TransportError> {
|
||||
let certs = rustls_pemfile::certs(&mut pem.as_bytes())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| TransportError::Pem(format!("parsing certificate PEM: {e}")))?;
|
||||
if certs.is_empty() {
|
||||
return Err(TransportError::Pem(
|
||||
"certificate PEM contained no certificates".into(),
|
||||
));
|
||||
}
|
||||
Ok(certs)
|
||||
}
|
||||
|
||||
/// Parse a single PEM private key (PKCS#8 / SEC1 / PKCS#1) into DER.
|
||||
pub(crate) fn key_from_pem(pem: &str) -> Result<PrivateKeyDer<'static>, TransportError> {
|
||||
rustls_pemfile::private_key(&mut pem.as_bytes())
|
||||
.map_err(|e| TransportError::Pem(format!("parsing private key PEM: {e}")))?
|
||||
.ok_or_else(|| TransportError::Pem("private key PEM contained no key".into()))
|
||||
}
|
||||
|
||||
/// Build the outer-TLS `quinn::ServerConfig` from a cert chain PEM and key PEM.
|
||||
///
|
||||
/// The cert here is only the *outer* (mimicry) certificate; it may be the same PEM as the Aura
|
||||
/// server cert. Client auth is disabled at this (outer) layer because mutual auth is done in the
|
||||
/// inner handshake.
|
||||
pub fn server_quic_config(
|
||||
cert_pem: &str,
|
||||
key_pem: &str,
|
||||
) -> Result<quinn::ServerConfig, TransportError> {
|
||||
ensure_crypto_provider();
|
||||
|
||||
let certs = certs_from_pem(cert_pem)?;
|
||||
let key = key_from_pem(key_pem)?;
|
||||
|
||||
let mut sc = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.map_err(|e| TransportError::Tls(format!("building server TLS config: {e}")))?;
|
||||
sc.alpn_protocols = alpn_protocols();
|
||||
|
||||
let qsc = quinn::crypto::rustls::QuicServerConfig::try_from(sc)
|
||||
.map_err(|e| TransportError::Tls(format!("rustls->quic server config: {e}")))?;
|
||||
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(qsc));
|
||||
server_config.transport_config(Arc::new(chrome_quic_transport_config()));
|
||||
Ok(server_config)
|
||||
}
|
||||
|
||||
/// Build the outer-TLS `quinn::ClientConfig` (with the dangerous accept-any verifier).
|
||||
pub fn client_quic_config() -> Result<quinn::ClientConfig, TransportError> {
|
||||
ensure_crypto_provider();
|
||||
|
||||
let mut cc = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
|
||||
.with_no_client_auth();
|
||||
cc.alpn_protocols = alpn_protocols();
|
||||
|
||||
let qcc = quinn::crypto::rustls::QuicClientConfig::try_from(cc)
|
||||
.map_err(|e| TransportError::Tls(format!("rustls->quic client config: {e}")))?;
|
||||
let mut client_config = quinn::ClientConfig::new(Arc::new(qcc));
|
||||
client_config.transport_config(Arc::new(chrome_quic_transport_config()));
|
||||
Ok(client_config)
|
||||
}
|
||||
|
||||
/// Build a bound server [`quinn::Endpoint`] listening on `addr` (use `127.0.0.1:0` for an
|
||||
/// OS-assigned port, then read it back with [`quinn::Endpoint::local_addr`]).
|
||||
pub fn server_endpoint(
|
||||
addr: std::net::SocketAddr,
|
||||
cert_pem: &str,
|
||||
key_pem: &str,
|
||||
) -> Result<quinn::Endpoint, TransportError> {
|
||||
let config = server_quic_config(cert_pem, key_pem)?;
|
||||
let endpoint = quinn::Endpoint::server(config, addr)?;
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
/// Build a client [`quinn::Endpoint`] bound to an ephemeral local UDP port with the outer-TLS
|
||||
/// client config installed as default.
|
||||
pub fn client_endpoint() -> Result<quinn::Endpoint, TransportError> {
|
||||
let config = client_quic_config()?;
|
||||
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().expect("valid bind addr"))?;
|
||||
endpoint.set_default_client_config(config);
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_config_builds() {
|
||||
// Exercises the crypto-provider install + dangerous verifier wiring (§7 client recipe).
|
||||
client_quic_config().expect("client config should build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_cert_pem() {
|
||||
let err = certs_from_pem("not a pem").unwrap_err();
|
||||
assert!(matches!(err, TransportError::Pem(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user