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:
@@ -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,
|
||||
|
||||
@@ -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<DialConfig> {
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
//!
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user