feat(transport): real TLS-443 on the TCP backend (replaces HTTP/1.1 masquerade)

The TCP fallback now does a full outer TLS handshake (tokio-rustls 0.26 over
rustls 0.23, ring provider) before the Aura proto handshake, exactly like the
QUIC backend: on the wire it is indistinguishable from genuine HTTPS until the
inner Aura mutual-auth handshake starts. Removes v1's "light HTTP masquerade"
limitation; the real security boundary remains the inner PQ handshake.

- aura-transport::tcp: dropped the HTTP/1.1 preamble helpers and TcpOpts
  fields (masquerade, host, user_agent, server_header). New flow:
  TlsAcceptor::accept (server) / TlsConnector::connect (client) →
  tokio::io::split(TlsStream) → server_handshake / client_handshake → Session.
  Client reuses crate::quic::AcceptAnyServerCert (outer SNI not authenticated;
  inner handshake is the security boundary). Outer server cert auto-sourced
  from proto_cfg.server_cert_pem (no API change for the CLI's bind).
- ALPN default: ["h2", "http/1.1"] (DEFAULT_TCP_ALPN, exported).
- TcpOpts: now just { alpn: Option<Vec<Vec<u8>>> }.
- TcpClient::connect gains an outer-SNI &str param; DialConfig.sni passes it
  through (separate from the inner proto_cfg.server_name).
- tokio-rustls 0.26 added as a transport-local dependency (not workspace).

CLI updates: removed dead host/user_agent/server_header wiring; mask rotation
no longer touches TCP outer parameters (TLS doesn't have a Host header on
the wire). [transport] masquerade kept as a no-op for back-compat with old
configs (documented).

3 new tcp_loopback tests (default ALPN end-to-end, custom ALPN, outer SNI
mismatch still connects = proves accept-any is in effect). Workspace: 142
tests passed (+1), clippy -D warnings clean, fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 01:53:02 +03:00
parent 75e350e870
commit 821f7711e7
10 changed files with 348 additions and 225 deletions
+229 -155
View File
@@ -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<Vec<Vec<u8>>>,
}
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<Vec<u8>> {
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<OwnedReadHalf, OwnedWriteHalf>;
// ---------------------------------------------------------------------------------------------
// 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<Vec<u8>>,
) -> Result<rustls::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 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<Vec<u8>>) -> Result<rustls::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;
Ok(cc)
}
// ---------------------------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------------------------
/// Server-side proto reader / writer halves: a split TLS stream over a [`TcpStream`].
type ServerReader = ReadHalf<ServerTlsStream<TcpStream>>;
type ServerWriter = WriteHalf<ServerTlsStream<TcpStream>>;
/// Client-side proto reader / writer halves: a split TLS stream over a [`TcpStream`].
type ClientReader = ReadHalf<ClientTlsStream<TcpStream>>;
type ClientWriter = WriteHalf<ClientTlsStream<TcpStream>>;
/// An established Aura connection carried over an outer **TLS-443** stream on TCP.
///
/// Implements [`aura_proto::PacketConnection`] (so it works behind `Arc<dyn PacketConnection>`):
/// 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<SessionSender<OwnedWriteHalf>>,
receiver: Mutex<SessionReceiver<OwnedReadHalf>>,
inner: ConnInner,
peer_id: Option<String>,
}
enum ConnInner {
/// Server-side proto session (carrier = a server-accepted TLS stream).
Server {
sender: Mutex<SessionSender<ServerWriter>>,
receiver: Mutex<SessionReceiver<ServerReader>>,
},
/// Client-side proto session (carrier = a client-connected TLS stream).
Client {
sender: Mutex<SessionSender<ClientWriter>>,
receiver: Mutex<SessionReceiver<ClientReader>>,
},
}
impl TcpConnection {
fn from_session(session: TcpSession) -> Self {
fn from_server_session(session: Session<ServerReader, ServerWriter>) -> 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<ClientReader, ClientWriter>) -> 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<Vec<u8>> {
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<ServerConfig>,
/// Live options: kept behind an `Arc<RwLock>` 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<ServerConfig>`).
/// 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<tokio::sync::RwLock<Arc<rustls::ServerConfig>>>,
/// Live options, snapshot once per accept.
opts: Arc<tokio::sync::RwLock<TcpOpts>>,
}
@@ -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<Self> {
) -> anyhow::Result<Self> {
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<TcpConnection> {
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<TcpConnection> {
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))
}
}