diff --git a/Cargo.lock b/Cargo.lock index 96d1bc0..918000e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -282,6 +282,7 @@ dependencies = [ "rustls-pki-types", "thiserror 1.0.69", "tokio", + "tokio-rustls", "tracing", ] @@ -2659,6 +2660,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index 03fb42f..b250d61 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -5,8 +5,9 @@ //! 2. Build a shared [`RouteTable`] from `[tunnel.split]` (default action + direct/vpn CIDR rules); //! record domain rules for resolution. //! 3. [`aura_transport::dial`] the server, trying each transport in `[transport] order` (the -//! UDP→TCP→QUIC "handover") until one connects; QUIC presents `[client] sni` as the outer -//! (mimicry) hostname and TCP uses it as the masquerade `Host`. +//! UDP→TCP→QUIC "handover") until one connects; both QUIC and TCP present `[client] sni` as +//! their outer-TLS SNI (the TCP backend wraps the connection in a real TLS-443 handshake too; +//! see [`aura_transport::TcpClient::connect`]). //! 4. Resolve any split-tunnel domain rules via [`AuraDns`] into host routes (best-effort). //! 5. Create the local TUN ([`AuraTun::create`]) on `[tunnel] local_ip/prefix` and run //! [`AuraRouter`] to bridge the TUN and the connection. @@ -47,10 +48,10 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?); let initial = rot.current().await; dial_cfg.sni = initial.sni.clone(); - dial_cfg.tcp.host = initial.http_host.clone(); - dial_cfg.tcp.user_agent = initial.user_agent.clone(); - dial_cfg.tcp.server_header = initial.server_header.clone(); dial_cfg.udp.padding_profile = initial.padding_profile_id; + // The TCP transport now wraps in real outer TLS-443 (no HTTP preamble), so the per-mask + // `Host:` / `User-Agent:` / `Server:` strings no longer feed `dial_cfg.tcp`. The outer TLS + // SNI for both TCP and QUIC is `dial_cfg.sni` (above). tracing::info!( sni = %initial.sni, padding_profile = initial.padding_profile_id, diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 53312f6..83a483d 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -265,7 +265,9 @@ pub struct TransportSection { pub quic_port: u16, /// UDP transport: pad datagrams up to HTTPS size buckets to blur on-wire sizes. pub obfuscate: bool, - /// TCP transport: prepend a minimal HTTP/1.1 preamble so the open resembles plain HTTP. + /// **Deprecated, ignored.** The TCP transport used to optionally prepend a minimal HTTP/1.1 + /// preamble (a light disguise); in v2 it always uses a real outer TLS-443 handshake (a much + /// stronger camouflage), so this knob has no effect. Kept for backwards-compat config parsing. pub masquerade: bool, /// `[transport.masks]`: daily protocol-mask rotation knobs. pub masks: MasksSection, @@ -542,17 +544,14 @@ impl ServerConfigFile { } } - /// Build the [`TcpOpts`] for the server's TCP transport from `[transport] masquerade`; the - /// masquerade `Host` reuses the mimicry SNI when one is configured. + /// Build the [`TcpOpts`] for the server's TCP transport. + /// + /// In v2 the TCP backend always uses a real outer TLS-443 layer, so there are no per-config + /// knobs here (ALPN keeps its `[h2, http/1.1]` default). The legacy `[transport] masquerade` / + /// `[mimicry] sni` values are still parsed for backwards compatibility but are no longer plumbed + /// into [`TcpOpts`]. pub fn tcp_opts(&self) -> TcpOpts { - let mut opts = TcpOpts { - masquerade: self.transport.masquerade, - ..TcpOpts::default() - }; - if let Some(sni) = &self.mimicry.sni { - opts.host = sni.clone(); - } - opts + TcpOpts::default() } } @@ -588,9 +587,10 @@ impl ClientConfigFile { /// Build the [`DialConfig`] the client passes to [`aura_transport::dial`]. /// /// The server **IP** is taken from `[client] server_addr` (its port is ignored: each transport - /// uses its own port from `[transport]`). `order` becomes the fallback order, and the per- - /// transport options (UDP `obfuscate`, TCP `masquerade`/`host` and the QUIC SNI) come from - /// `[transport]` + `[client] sni`. + /// uses its own port from `[transport]`). `order` becomes the fallback order. Per-transport + /// options: UDP gets `obfuscate` from `[transport]`; TCP/QUIC both use `[client] sni` as their + /// outer-TLS camouflage SNI (TLS is now real on the TCP side too, see + /// [`aura_transport::TcpClient::connect`]). pub fn dial_config(&self) -> anyhow::Result { let ip = self.server_socket_addr()?.ip(); let order = self.transport.modes()?; @@ -611,11 +611,7 @@ impl ClientConfigFile { obfuscate: self.transport.obfuscate, ..UdpOpts::default() }, - tcp: TcpOpts { - masquerade: self.transport.masquerade, - host: self.client.sni.clone(), - ..TcpOpts::default() - }, + tcp: TcpOpts::default(), attempt_timeout: Duration::from_secs(8), }) } @@ -787,9 +783,10 @@ masquerade = true assert_eq!(eps.tcp.unwrap().to_string(), "0.0.0.0:4433"); assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:4434"); assert!(cfg.udp_opts().obfuscate); + // TCP options are now ALPN-only (real outer TLS handles the camouflage); the legacy + // [transport] masquerade / [mimicry] sni values are parsed but no longer plumbed into TcpOpts. let tcp = cfg.tcp_opts(); - assert!(tcp.masquerade); - assert_eq!(tcp.host, "cdn.example.com"); // reuses mimicry SNI + assert!(tcp.alpn.is_none(), "default ALPN is used"); } #[test] @@ -848,8 +845,9 @@ pool_cidr = "10.7.0.0/24" assert!(dial.endpoints.quic.is_none()); assert_eq!(dial.sni, "cdn.example.com"); assert!(!dial.udp.obfuscate); - assert!(dial.tcp.masquerade); - assert_eq!(dial.tcp.host, "cdn.example.com"); + // TCP is wrapped in real outer TLS now; the legacy HTTP `Host` / masquerade fields are gone. + // The outer TLS SNI is `dial.sni`, asserted above. + assert!(dial.tcp.alpn.is_none(), "default ALPN is used"); } #[test] diff --git a/crates/aura-cli/src/masks.rs b/crates/aura-cli/src/masks.rs index b79bafd..d51e876 100644 --- a/crates/aura-cli/src/masks.rs +++ b/crates/aura-cli/src/masks.rs @@ -9,10 +9,18 @@ //! 4. logs the rotation and loops. //! //! Each new connection (`UdpServer::accept`, `UdpClient::connect`, `TcpClient::connect`, ...) -//! reads the **current** mask once when constructing its [`UdpOpts`] / [`TcpOpts`] / QUIC SNI, so -//! already-established connections keep their original mask and only fresh connections see the -//! rotation. There is no need to coordinate with the peer: each side independently derived the same -//! set from the CA fingerprint it already trusts. +//! reads the **current** mask once when constructing its [`UdpOpts`] padding profile / QUIC SNI / +//! TCP outer-TLS SNI, so already-established connections keep their original mask and only fresh +//! connections see the rotation. There is no need to coordinate with the peer: each side +//! independently derived the same set from the CA fingerprint it already trusts. +//! +//! v2 note: the [`MaskSet::user_agent`] / [`MaskSet::server_header`] fields and the corresponding +//! palettes ([`USER_AGENT_PALETTE`](aura_crypto::USER_AGENT_PALETTE) / +//! [`SERVER_HEADER_PALETTE`](aura_crypto::SERVER_HEADER_PALETTE)) survive but are no longer plumbed +//! into the TCP transport — the v1 HTTP/1.1 masquerade preamble has been replaced by a real outer +//! TLS-443 handshake (see [`aura_transport::TcpClient::connect`]) which makes those header strings +//! irrelevant. They are kept for a possible future evolution (e.g. mimicking specific origin +//! fingerprints via ALPN rotation or in an in-stream HTTP request inside the TLS tunnel). //! //! ## Date arithmetic (no external date crate) //! diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index 3918ff5..801dba6 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -72,7 +72,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // Per-transport endpoints (UDP/TCP/QUIC) derived from the listen IP + `[transport]` ports. let endpoints = cfg.transport_endpoints()?; let mut udp_opts = cfg.udp_opts(); - let mut tcp_opts = cfg.tcp_opts(); + let tcp_opts = cfg.tcp_opts(); // Build the daily mask rotator (HKDF over the CA fingerprint + MSK date). When enabled in the // config, the *initial* mask overrides the static SNI / padding-profile / header values from @@ -83,9 +83,9 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?); let initial = rot.current().await; udp_opts.padding_profile = initial.padding_profile_id; - tcp_opts.host = initial.http_host.clone(); - tcp_opts.user_agent = initial.user_agent.clone(); - tcp_opts.server_header = initial.server_header.clone(); + // The TCP transport now uses a real outer TLS-443 layer, which subsumes the old HTTP + // masquerade preamble — there is no longer a per-mask `Host:` / `User-Agent:` / `Server:` + // header to inject. Mask rotation still drives UDP padding (above) and the QUIC SNI. tracing::info!( sni = %initial.sni, padding_profile = initial.padding_profile_id, @@ -110,7 +110,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { tcp = ?endpoints.tcp, quic = ?endpoints.quic, obfuscate = udp_opts.obfuscate, - masquerade = tcp_opts.masquerade, + tcp_tls = "real outer TLS-443 (h2/http1.1 ALPN)", "starting Aura server" ); @@ -129,11 +129,15 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let server_for_apply = Arc::clone(&server); let rot_for_apply = Arc::clone(rot); let base_udp = udp_opts; - let base_tcp = tcp_opts.clone(); tokio::spawn(async move { // Poll the rotator's handle for a change once a minute, and push it into the live // MultiServer when it changes. The actual rotation timer lives inside the rotator's // spawn; this loop is just the "apply to bound sockets" bridge. + // + // Only UDP's padding profile gets pushed: the TCP transport now uses real outer TLS, + // not an HTTP preamble, so the per-mask `Host:` / `User-Agent:` / `Server:` headers no + // longer apply. The QUIC outer SNI is also derived from the mask but is per-connect on + // the client side (the server does not advertise an SNI). let handle = rot_for_apply.handle(); let mut last = rot_for_apply.current().await; loop { @@ -142,13 +146,8 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { if current != last { let mut new_udp = base_udp; new_udp.padding_profile = current.padding_profile_id; - let mut new_tcp = base_tcp.clone(); - new_tcp.host = current.http_host.clone(); - new_tcp.user_agent = current.user_agent.clone(); - new_tcp.server_header = current.server_header.clone(); let srv = server_for_apply.lock().await; srv.set_udp_opts(new_udp).await; - srv.set_tcp_opts(new_tcp).await; tracing::info!( sni = %current.sni, padding_profile = current.padding_profile_id, diff --git a/crates/aura-transport/Cargo.toml b/crates/aura-transport/Cargo.toml index f41d089..d1c93e3 100644 --- a/crates/aura-transport/Cargo.toml +++ b/crates/aura-transport/Cargo.toml @@ -21,6 +21,10 @@ async-trait.workspace = true # PEM (certificates + PKCS#8 keys) -> DER for the outer QUIC/TLS rustls config. Already resolved # in the workspace lockfile (pulled transitively), so this adds no new version resolution. rustls-pemfile = "2" +# Outer TLS-443 wrapper for the TCP transport (real HTTPS-on-the-wire camouflage; the security +# boundary is still the inner Aura handshake, just like for the QUIC backend). Local-only to this +# crate — not a new workspace dependency. +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } [dev-dependencies] # The loopback integration test mints a CA + server/client certs to drive a real QUIC handshake. diff --git a/crates/aura-transport/src/dial.rs b/crates/aura-transport/src/dial.rs index 2a5a06a..db74367 100644 --- a/crates/aura-transport/src/dial.rs +++ b/crates/aura-transport/src/dial.rs @@ -21,7 +21,8 @@ use crate::{AuraClient, AuraServer, TcpClient, TcpOpts, TcpServer, UdpClient, Ud pub enum TransportMode { /// Aura's own protocol over plain UDP (primary). Udp, - /// Aura over TCP (fallback for UDP-blocking networks; optional HTTP masquerade). + /// Aura over TCP wrapped in a real outer TLS-443 handshake (fallback for UDP-blocking + /// networks; the on-wire bytes are indistinguishable from a normal HTTPS connection). Tcp, /// Aura inside QUIC/HTTP3 mimicry (fallback / strong camouflage). Quic, @@ -71,7 +72,9 @@ pub struct Endpoints { pub struct DialConfig { /// Server addresses per transport. pub endpoints: Endpoints, - /// SNI / masquerade hostname (QUIC outer SNI; TCP masquerade Host). + /// Outer-TLS SNI: presented by both the QUIC and the TCP backends as the camouflage hostname. + /// Not verified (both backends use [`crate::quic::AcceptAnyServerCert`] on the outer layer); the + /// real server-name check happens in the inner Aura handshake against `proto_cfg.server_name`. pub sni: String, /// Transports to try, in order. The first that connects wins. pub order: Vec, @@ -149,9 +152,11 @@ async fn dial_one( TransportMode::Udp => UdpClient::connect(addr, proto_cfg.clone(), cfg.udp) .await? .into_dyn(), - TransportMode::Tcp => TcpClient::connect(addr, proto_cfg.clone(), cfg.tcp.clone()) - .await? - .into_dyn(), + TransportMode::Tcp => { + TcpClient::connect(addr, &cfg.sni, proto_cfg.clone(), cfg.tcp.clone()) + .await? + .into_dyn() + } TransportMode::Quic => AuraClient::connect(addr, &cfg.sni, proto_cfg.clone()) .await? .into_dyn(), @@ -176,8 +181,9 @@ pub struct Accepted { /// QUIC, or run one UDP server per client. /// /// The UDP and TCP servers are kept behind shared [`Arc`] handles so the daily mask rotator can -/// update their accept-time options (padding profile, masquerade preamble strings) without -/// disturbing in-flight connections — see [`MultiServer::set_udp_opts`] / [`MultiServer::set_tcp_opts`]. +/// update their accept-time options (UDP padding profile; TCP currently only carries ALPN, which is +/// usually static) without disturbing in-flight connections — see [`MultiServer::set_udp_opts`] / +/// [`MultiServer::set_tcp_opts`]. pub struct MultiServer { rx: mpsc::Receiver, tasks: Vec>, diff --git a/crates/aura-transport/src/lib.rs b/crates/aura-transport/src/lib.rs index 7715375..06e387e 100644 --- a/crates/aura-transport/src/lib.rs +++ b/crates/aura-transport/src/lib.rs @@ -79,7 +79,7 @@ pub use padding::{ HTTPS_SIZE_BUCKETS, PADDING_PROFILES, }; pub use quic::{client_endpoint, server_endpoint, AcceptAnyServerCert}; -pub use tcp::{TcpClient, TcpConnection, TcpOpts, TcpServer}; +pub use tcp::{TcpClient, TcpConnection, TcpOpts, TcpServer, DEFAULT_TCP_ALPN}; pub use udp::{UdpClient, UdpConnection, UdpOpts, UdpServer}; // Re-export the inner proto trait so downstream crates (the CLI) can name the connection as diff --git a/crates/aura-transport/src/tcp.rs b/crates/aura-transport/src/tcp.rs index c99aad3..d7bcd98 100644 --- a/crates/aura-transport/src/tcp.rs +++ b/crates/aura-transport/src/tcp.rs @@ -1,18 +1,19 @@ -//! Aura over plain **TCP** — a fallback transport for networks that block UDP/QUIC (project §7). +//! Aura over **TLS-443 / TCP** — fallback transport for networks that block UDP/QUIC (project §7). //! -//! This runs the SAME Aura proto handshake (hybrid X25519 + ML-KEM-768 + mutual X.509) and -//! [`aura_proto::Session`] directly over a [`TcpStream`], which already implements -//! [`AsyncRead`](tokio::io::AsyncRead) + [`AsyncWrite`](tokio::io::AsyncWrite). No extra crypto and -//! no QUIC are involved — the security boundary is the inner Aura handshake, exactly as for the UDP -//! backend. +//! This wires the Aura proto handshake (hybrid X25519 + ML-KEM-768 + mutual X.509) and +//! [`aura_proto::Session`] **inside a real TLS-443 connection**. The outer rustls TLS layer is +//! exactly the same camouflage idea as for the QUIC backend (see [`crate::quic`]): //! -//! ## Optional HTTP masquerade +//! * On the wire the connection is indistinguishable from a normal HTTPS session up to the start of +//! the Aura handshake (the TLS record stream is identical to e.g. a browser hitting an +//! `nginx`-fronted endpoint with ALPN `h2`/`http/1.1`). +//! * The outer TLS is **not** the source of trust. The client uses [`AcceptAnyServerCert`] +//! (reused verbatim from the QUIC backend) so the outer SNI / server certificate carry no +//! authentication weight. The single security boundary is the inner Aura handshake — mutual X.509 +//! against the Aura CA + hybrid PQ key agreement — which runs over the already-encrypted TLS +//! stream. //! -//! With [`TcpOpts::masquerade`] the peers exchange a minimal HTTP/1.1 request/response preamble -//! before the Aura handshake, so the start of the connection resembles a plain HTTP session to a -//! casual observer. This is a **light disguise, not TLS** — full HTTPS/TLS-443 mimicry (reusing the -//! rustls outer layer from the QUIC backend) is a planned enhancement; for now TCP's main job is to -//! get bytes through where UDP is blocked. +//! [`AcceptAnyServerCert`]: crate::quic::AcceptAnyServerCert use std::io; use std::net::SocketAddr; @@ -20,76 +21,147 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::io::{ReadHalf, WriteHalf}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; +use tokio_rustls::{ + client::TlsStream as ClientTlsStream, server::TlsStream as ServerTlsStream, TlsAcceptor, + TlsConnector, +}; use aura_proto::{ client_handshake, server_handshake, ClientConfig, Frame, PacketConnection, ServerConfig, Session, SessionReceiver, SessionSender, }; +use rustls::pki_types::ServerName; + +use crate::quic::{certs_from_pem, ensure_crypto_provider, key_from_pem, AcceptAnyServerCert}; +use crate::TransportError; + +/// Default outer-TLS ALPN list presented by the TCP transport. The pair `h2` then `http/1.1` is the +/// canonical browser/CDN advert — it is what a passive observer would expect to see on a TLS-443 +/// connection to virtually any modern web origin. +pub const DEFAULT_TCP_ALPN: &[&[u8]] = &[b"h2", b"http/1.1"]; /// Tunables for the TCP transport. /// -/// `user_agent` / `server_header` defaults match the original hard-coded preamble strings, so a -/// pre-rotation deployment that constructs `TcpOpts::default()` retains exact wire compatibility -/// with previous Aura builds (used by existing TCP loopback tests). -#[derive(Clone, Debug)] +/// The HTTP/1.1 "light masquerade" preamble that lived here pre-v2 has been removed: the outer +/// camouflage is now a real rustls TLS-443 handshake (much stronger). The only knob left is the +/// **ALPN advertisement** in case a deployment wants to mimic a specific origin's stack; the +/// default of `[h2, http/1.1]` is the canonical browser-CDN advertisement. +#[derive(Clone, Debug, Default)] pub struct TcpOpts { - /// When `true`, exchange a minimal HTTP/1.1 preamble before the Aura handshake so the connection - /// opening resembles plain HTTP. A light disguise only (not TLS). - pub masquerade: bool, - /// `Host:` header value used in the client's masquerade preamble. - pub host: String, - /// `User-Agent:` header value used in the client's masquerade preamble; the daily mask - /// rotation supplies this from [`aura_crypto::MaskSet::user_agent`]. - pub user_agent: String, - /// `Server:` header value used in the server's masquerade preamble; the daily mask rotation - /// supplies this from [`aura_crypto::MaskSet::server_header`]. - pub server_header: String, + /// Custom ALPN list for the outer TLS handshake. `None` (the default) uses + /// [`DEFAULT_TCP_ALPN`] (= `[b"h2", b"http/1.1"]`). + pub alpn: Option>>, } -impl Default for TcpOpts { - fn default() -> Self { - Self { - masquerade: false, - host: "cdn.example.com".to_string(), - // Match the pre-rotation hard-coded preamble strings exactly so existing loopback tests - // (which build `TcpOpts::default()`) keep observing identical wire bytes. - user_agent: "Mozilla/5.0".to_string(), - server_header: "nginx".to_string(), - } +impl TcpOpts { + /// Materialize the ALPN protocol list this options instance should send on the wire. + fn alpn_protocols(&self) -> Vec> { + self.alpn + .clone() + .unwrap_or_else(|| DEFAULT_TCP_ALPN.iter().map(|p| p.to_vec()).collect()) } } -/// The concrete session type carried over TCP: a proto session over TcpStream's owned halves. -type TcpSession = Session; +// --------------------------------------------------------------------------------------------- +// TLS handshake glue +// --------------------------------------------------------------------------------------------- -/// An established Aura connection carried over **plain TCP**, exposed as a packet pipe. +/// Build the outer rustls server config (mirrors the QUIC server config: ALPN, single cert, no +/// client auth — mutual auth happens inside the Aura handshake on the encrypted stream). +fn server_tls_config( + cert_pem: &str, + key_pem: &str, + alpn: Vec>, +) -> Result { + 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 TCP outer-TLS server config: {e}")))?; + sc.alpn_protocols = alpn; + Ok(sc) +} + +/// Build the outer rustls client config: the dangerous accept-any verifier (reused from the QUIC +/// path) so the outer SNI / server cert carry no authentication weight. +fn client_tls_config(alpn: Vec>) -> Result { + 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; + Ok(cc) +} + +// --------------------------------------------------------------------------------------------- +// Connection +// --------------------------------------------------------------------------------------------- + +/// Server-side proto reader / writer halves: a split TLS stream over a [`TcpStream`]. +type ServerReader = ReadHalf>; +type ServerWriter = WriteHalf>; +/// Client-side proto reader / writer halves: a split TLS stream over a [`TcpStream`]. +type ClientReader = ReadHalf>; +type ClientWriter = WriteHalf>; + +/// An established Aura connection carried over an outer **TLS-443** stream on TCP. /// -/// Implements [`aura_proto::PacketConnection`] (so it works behind `Arc`): -/// outbound packets are sealed as [`Frame::Data`] on `stream_id 0`; inbound `Data` payloads are -/// returned; `Ping` is answered with `Pong`, stray `Pong` ignored, `Close` surfaced as an error. -/// Send and receive use **separate** [`tokio::sync::Mutex`]es so the two directions run concurrently. +/// The proto session can sit on either side's split TLS halves (server or client), so we keep an +/// internal enum and dispatch send / receive accordingly. The public surface is a single +/// [`PacketConnection`] (no caller cares which side opened the underlying TLS). pub struct TcpConnection { - sender: Mutex>, - receiver: Mutex>, + inner: ConnInner, peer_id: Option, } +enum ConnInner { + /// Server-side proto session (carrier = a server-accepted TLS stream). + Server { + sender: Mutex>, + receiver: Mutex>, + }, + /// Client-side proto session (carrier = a client-connected TLS stream). + Client { + sender: Mutex>, + receiver: Mutex>, + }, +} + impl TcpConnection { - fn from_session(session: TcpSession) -> Self { + fn from_server_session(session: Session) -> 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), + inner: ConnInner::Server { + sender: Mutex::new(sender), + receiver: Mutex::new(receiver), + }, peer_id, } } - /// The verified identity (Common Name) of the peer learned during the handshake. + fn from_client_session(session: Session) -> Self { + let peer_id = session.peer_id().map(str::to_owned); + let (sender, receiver) = session.split(); + Self { + inner: ConnInner::Client { + sender: Mutex::new(sender), + receiver: Mutex::new(receiver), + }, + peer_id, + } + } + + /// The verified identity (Common Name) of the peer learned during the inner Aura handshake. #[must_use] pub fn peer_id(&self) -> Option<&str> { self.peer_id.as_deref() @@ -105,29 +177,51 @@ impl TcpConnection { #[async_trait] impl PacketConnection for TcpConnection { async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> { - self.sender - .lock() - .await - .send_frame(Frame::Data { - stream_id: 0, - payload: Bytes::copy_from_slice(packet), - }) - .await?; + match &self.inner { + ConnInner::Server { sender, .. } => { + sender + .lock() + .await + .send_frame(Frame::Data { + stream_id: 0, + payload: Bytes::copy_from_slice(packet), + }) + .await? + } + ConnInner::Client { sender, .. } => { + sender + .lock() + .await + .send_frame(Frame::Data { + stream_id: 0, + payload: Bytes::copy_from_slice(packet), + }) + .await? + } + } Ok(()) } async fn recv_packet(&self) -> anyhow::Result> { - let mut receiver = self.receiver.lock().await; + // Loop on whichever side carries this connection; the only difference between arms is the + // concrete reader/writer types behind the mutexes. loop { - match receiver.recv_frame().await? { + let frame = match &self.inner { + ConnInner::Server { receiver, .. } => receiver.lock().await.recv_frame().await?, + ConnInner::Client { receiver, .. } => receiver.lock().await.recv_frame().await?, + }; + match frame { Frame::Data { payload, .. } => return Ok(payload.to_vec()), Frame::Ping { seq } => { - // Separate mutex from the receive lock we hold => no deadlock. - self.sender - .lock() - .await - .send_frame(Frame::Pong { seq }) - .await?; + // Separate mutex from the receive lock => no deadlock. + match &self.inner { + ConnInner::Server { sender, .. } => { + sender.lock().await.send_frame(Frame::Pong { seq }).await? + } + ConnInner::Client { sender, .. } => { + sender.lock().await.send_frame(Frame::Pong { seq }).await? + } + } } Frame::Pong { .. } => continue, Frame::Close { code, reason } => { @@ -138,75 +232,26 @@ impl PacketConnection for TcpConnection { } } -// --------------------------------------------------------------------------------------------- -// HTTP masquerade preamble helpers -// --------------------------------------------------------------------------------------------- - -/// Write a plausible HTTP/1.1 request line + headers (client side of the masquerade). -async fn write_client_preamble( - stream: &mut TcpStream, - host: &str, - user_agent: &str, -) -> io::Result<()> { - let req = format!( - "GET / HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {user_agent}\r\nAccept: */*\r\nConnection: keep-alive\r\n\r\n" - ); - stream.write_all(req.as_bytes()).await?; - stream.flush().await -} - -/// Write a plausible HTTP/1.1 response head (server side of the masquerade). -async fn write_server_preamble(stream: &mut TcpStream, server_header: &str) -> io::Result<()> { - let resp = format!( - "HTTP/1.1 200 OK\r\nServer: {server_header}\r\nContent-Type: application/octet-stream\r\nConnection: keep-alive\r\n\r\n" - ); - stream.write_all(resp.as_bytes()).await?; - stream.flush().await -} - -/// Read (and discard) bytes up to and including the `\r\n\r\n` header terminator. -/// -/// Reads one byte at a time so it never consumes past the terminator into the handshake stream. The -/// preamble is tiny and one-time, so byte-at-a-time is fine and keeps the boundary exact. -async fn read_until_headers_end(stream: &mut TcpStream) -> io::Result<()> { - let mut last4 = [0u8; 4]; - let mut count = 0usize; - let mut one = [0u8; 1]; - loop { - let n = stream.read(&mut one).await?; - if n == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "eof during masquerade preamble", - )); - } - last4.rotate_left(1); - last4[3] = one[0]; - count += 1; - if count >= 4 && &last4 == b"\r\n\r\n" { - return Ok(()); - } - if count > 8192 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "masquerade preamble exceeded 8 KiB without terminator", - )); - } - } -} - // --------------------------------------------------------------------------------------------- // Server / client // --------------------------------------------------------------------------------------------- -/// An Aura TCP server: a bound [`TcpListener`] that accepts authenticated [`TcpConnection`]s. +/// An Aura TCP server: a bound [`TcpListener`] that accepts authenticated [`TcpConnection`]s over +/// a real outer TLS-443 layer. +/// +/// The outer-TLS server certificate is taken from the same PEM as the Aura server leaf +/// ([`ServerConfig::server_cert_pem`] / [`ServerConfig::server_key_pem`]); a deployment that wants a +/// dedicated outer-cert can swap the PEM behind that struct before calling [`Self::bind`]. The +/// `[transport.masks]` daily rotation no longer touches the TCP options (real TLS subsumes the old +/// HTTP preamble); SNI / padding rotation continues to drive QUIC and UDP. pub struct TcpServer { listener: TcpListener, proto_cfg: Arc, - /// Live options: kept behind an `Arc` so the daily mask rotator can update the - /// masquerade `Server:` header (and `host` if a deployment cares to) and the next - /// [`Self::accept`] picks it up. In-flight connections already exchanged their preamble bytes, - /// so the rotation only changes what *the next handshake* writes. + /// Pre-built rustls server config wrapped in an [`Arc`] (rustls expects `Arc`). + /// Kept behind an [`tokio::sync::RwLock`] so a future "rotate ALPN" path can swap it without + /// disturbing in-flight TLS handshakes (in-flight already snapshotted the previous Arc). + tls: Arc>>, + /// Live options, snapshot once per accept. opts: Arc>, } @@ -214,24 +259,47 @@ impl TcpServer { /// Bind a TCP server on `addr` (use `..:0` for an OS-assigned port, read back with /// [`TcpServer::local_addr`]). /// + /// The outer-TLS cert reuses `proto_cfg.server_cert_pem` / `proto_cfg.server_key_pem` (the same + /// PEMs the inner Aura handshake authenticates with). ALPN is `opts.alpn` (or + /// [`DEFAULT_TCP_ALPN`] when unset). + /// /// # Errors - /// Returns an [`io::Error`] if the listener cannot bind. + /// Returns an error if the listener cannot bind or the rustls outer-TLS config cannot be built + /// (typically: malformed cert/key PEM). pub async fn bind( addr: SocketAddr, proto_cfg: ServerConfig, opts: TcpOpts, - ) -> io::Result { + ) -> anyhow::Result { let listener = TcpListener::bind(addr).await?; + let alpn = opts.alpn_protocols(); + let sc = server_tls_config(&proto_cfg.server_cert_pem, &proto_cfg.server_key_pem, alpn)?; Ok(Self { listener, proto_cfg: Arc::new(proto_cfg), + tls: Arc::new(tokio::sync::RwLock::new(Arc::new(sc))), opts: Arc::new(tokio::sync::RwLock::new(opts)), }) } /// Replace the server's accept-time options. The next [`Self::accept`] picks up the change; - /// in-flight connections keep what they exchanged at their own accept. + /// in-flight connections keep what they used at their own accept. + /// + /// If the new options change the ALPN list, the outer-TLS config is rebuilt; otherwise only the + /// snapshot is swapped. pub async fn set_opts(&self, new_opts: TcpOpts) { + let old_alpn = self.opts.read().await.alpn_protocols(); + let new_alpn = new_opts.alpn_protocols(); + if old_alpn != new_alpn { + // Rebuild the rustls server config with the new ALPN advertisement. + if let Ok(sc) = server_tls_config( + &self.proto_cfg.server_cert_pem, + &self.proto_cfg.server_key_pem, + new_alpn, + ) { + *self.tls.write().await = Arc::new(sc); + } + } *self.opts.write().await = new_opts; } @@ -248,24 +316,21 @@ impl TcpServer { self.listener.local_addr() } - /// Accept the next client: optional masquerade preamble, then the Aura mutual-auth handshake. + /// Accept the next client: real outer TLS handshake (rustls), then the inner Aura mutual-auth + /// handshake inside the encrypted TLS stream. /// /// # Errors - /// Returns an error if accepting fails, the masquerade preamble is malformed, or the Aura + /// Returns an error if accepting fails, the outer TLS 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) -> anyhow::Result { - let (mut stream, _peer) = self.listener.accept().await?; + let (stream, _peer) = self.listener.accept().await?; stream.set_nodelay(true).ok(); - // Snapshot once: the preamble writes immediately, and we want a consistent view in case a - // rotation lands mid-accept. - let opts = self.opts.read().await.clone(); - if opts.masquerade { - read_until_headers_end(&mut stream).await?; - write_server_preamble(&mut stream, &opts.server_header).await?; - } - let (reader, writer) = stream.into_split(); + // Snapshot the current TLS config Arc — `TlsAcceptor::from` just wraps it. + let acceptor = TlsAcceptor::from(Arc::clone(&*self.tls.read().await)); + let tls = acceptor.accept(stream).await?; + let (reader, writer) = tokio::io::split(tls); let session = server_handshake(reader, writer, &self.proto_cfg).await?; - Ok(TcpConnection::from_session(session)) + Ok(TcpConnection::from_server_session(session)) } } @@ -273,25 +338,34 @@ impl TcpServer { pub struct TcpClient; impl TcpClient { - /// Connect to an Aura TCP server at `server`: optional masquerade preamble, then the Aura - /// mutual-auth handshake over the TCP stream. + /// Connect to an Aura TCP server at `server`: real outer TLS-443 handshake (with `sni` as the + /// outer SNI), then the inner Aura mutual-auth handshake over the encrypted TLS stream. + /// + /// * `sni` is the **outer** TLS Server Name Indication (camouflage hostname); the outer cert is + /// not verified ([`AcceptAnyServerCert`]), so this can be any plausible hostname (e.g. the + /// current daily mask SNI). The inner Aura handshake separately verifies the server cert + /// against `proto_cfg.server_name` and the CA in `proto_cfg.ca_cert_pem`. /// /// # Errors - /// Returns an error if the TCP connect fails, the masquerade preamble is malformed, or the Aura + /// Returns an error if the TCP connect or outer TLS handshake fails, or if the inner Aura /// handshake fails (bad server cert chain, SAN mismatch, ...). pub async fn connect( server: SocketAddr, + sni: &str, proto_cfg: ClientConfig, opts: TcpOpts, ) -> anyhow::Result { - let mut stream = TcpStream::connect(server).await?; + let alpn = opts.alpn_protocols(); + let cc = client_tls_config(alpn)?; + let connector = TlsConnector::from(Arc::new(cc)); + let server_name: ServerName<'static> = ServerName::try_from(sni.to_string()) + .map_err(|e| TransportError::Tls(format!("invalid outer-TLS SNI '{sni}': {e}")))?; + + let stream = TcpStream::connect(server).await?; stream.set_nodelay(true).ok(); - if opts.masquerade { - write_client_preamble(&mut stream, &opts.host, &opts.user_agent).await?; - read_until_headers_end(&mut stream).await?; - } - let (reader, writer) = stream.into_split(); + let tls = connector.connect(server_name, stream).await?; + let (reader, writer) = tokio::io::split(tls); let session = client_handshake(reader, writer, &proto_cfg).await?; - Ok(TcpConnection::from_session(session)) + Ok(TcpConnection::from_client_session(session)) } } diff --git a/crates/aura-transport/tests/tcp_loopback.rs b/crates/aura-transport/tests/tcp_loopback.rs index 0e7c440..050c637 100644 --- a/crates/aura-transport/tests/tcp_loopback.rs +++ b/crates/aura-transport/tests/tcp_loopback.rs @@ -1,19 +1,26 @@ -//! End-to-end loopback test for the TCP fallback transport: real TCP on 127.0.0.1, full Aura -//! mutual-auth handshake, packet echo — with the HTTP masquerade both off and on. +//! End-to-end loopback test for the TLS-443 / TCP fallback transport: real outer rustls TLS over +//! plain TCP on 127.0.0.1, full inner Aura mutual-auth handshake, packet echo. +//! +//! Also covers: +//! * A custom (non-default) ALPN advertisement. +//! * The "accept-any" outer-cert guarantee: the client connects with an outer SNI that does NOT +//! match the server's outer-TLS certificate, the outer TLS handshake completes anyway (because +//! the client uses [`AcceptAnyServerCert`]), and the inner Aura mutual auth still succeeds. use aura_pki::AuraCa; use aura_proto::{ClientConfig, PacketConnection, ServerConfig}; use aura_transport::{TcpClient, TcpOpts, TcpServer}; +const SERVER_NAME: &str = "localhost"; +const CLIENT_ID: &str = "client-tcp"; + /// Mint a fresh CA + server("localhost") + client("client-tcp") and build the proto configs. fn make_configs() -> (ServerConfig, ClientConfig) { let ca = AuraCa::generate("Aura Test CA").expect("generate CA"); let server = ca - .issue_server_cert("localhost") + .issue_server_cert(SERVER_NAME) .expect("issue server cert"); - let client = ca - .issue_client_cert("client-tcp") - .expect("issue client cert"); + let client = ca.issue_client_cert(CLIENT_ID).expect("issue client cert"); let ca_pem = ca.ca_cert_pem(); let scfg = ServerConfig { ca_cert_pem: ca_pem.clone(), @@ -24,12 +31,14 @@ fn make_configs() -> (ServerConfig, ClientConfig) { ca_cert_pem: ca_pem, client_cert_pem: client.cert_pem, client_key_pem: client.key_pem, - server_name: "localhost".to_string(), + server_name: SERVER_NAME.to_string(), }; (scfg, ccfg) } -async fn run_case(opts: TcpOpts) { +/// Drive a single loopback handshake + 3-packet echo. `client_sni` is the OUTER TLS SNI the client +/// presents; it is independent of the server cert (the client uses an accept-any verifier). +async fn run_case(opts: TcpOpts, client_sni: &str) { let (scfg, ccfg) = make_configs(); let server = TcpServer::bind("127.0.0.1:0".parse().unwrap(), scfg, opts.clone()) .await @@ -38,7 +47,7 @@ async fn run_case(opts: TcpOpts) { let server_task = tokio::spawn(async move { let conn = server.accept().await.expect("server handshake"); - assert_eq!(conn.peer_id(), Some("client-tcp"), "verified client id"); + assert_eq!(conn.peer_id(), Some(CLIENT_ID), "verified client id"); // Echo three packets back to the client. for _ in 0..3 { let pkt = conn.recv_packet().await.expect("server recv"); @@ -46,9 +55,14 @@ async fn run_case(opts: TcpOpts) { } }); - let client = TcpClient::connect(addr, ccfg, opts) + let client = TcpClient::connect(addr, client_sni, ccfg, opts) .await .expect("client handshake"); + assert_eq!( + client.peer_id(), + Some(SERVER_NAME), + "inner handshake verified the server CN" + ); // Exchange packets of varying sizes (incl. a large one) and assert the echo matches. for i in 0..3u16 { @@ -61,17 +75,25 @@ async fn run_case(opts: TcpOpts) { server_task.await.expect("server task"); } +/// Baseline: default ALPN advert (`h2`, `http/1.1`), outer SNI matches the server cert SAN. #[tokio::test] -async fn tcp_loopback_end_to_end_plain() { - run_case(TcpOpts::default()).await; +async fn tcp_loopback_end_to_end() { + run_case(TcpOpts::default(), SERVER_NAME).await; } +/// A custom ALPN list still negotiates and runs the handshake. #[tokio::test] -async fn tcp_loopback_end_to_end_masquerade() { - run_case(TcpOpts { - masquerade: true, - host: "cdn.example.com".to_string(), - ..TcpOpts::default() - }) - .await; +async fn tcp_loopback_with_custom_alpn() { + let opts = TcpOpts { + alpn: Some(vec![b"http/1.1".to_vec()]), + }; + run_case(opts, SERVER_NAME).await; +} + +/// The client uses [`AcceptAnyServerCert`] on the outer TLS layer, so an outer SNI that has nothing +/// to do with the server's real certificate must still complete the TLS handshake; the inner Aura +/// mutual auth then proves identity. This is the security model: outer = camouflage, inner = trust. +#[tokio::test] +async fn tcp_loopback_outer_sni_mismatch_still_connects() { + run_case(TcpOpts::default(), "definitely-not-the-server.example").await; }