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:
Generated
+11
@@ -282,6 +282,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2659,6 +2660,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
//! 2. Build a shared [`RouteTable`] from `[tunnel.split]` (default action + direct/vpn CIDR rules);
|
//! 2. Build a shared [`RouteTable`] from `[tunnel.split]` (default action + direct/vpn CIDR rules);
|
||||||
//! record domain rules for resolution.
|
//! record domain rules for resolution.
|
||||||
//! 3. [`aura_transport::dial`] the server, trying each transport in `[transport] order` (the
|
//! 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
|
//! UDP→TCP→QUIC "handover") until one connects; both QUIC and TCP present `[client] sni` as
|
||||||
//! (mimicry) hostname and TCP uses it as the masquerade `Host`.
|
//! 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).
|
//! 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
|
//! 5. Create the local TUN ([`AuraTun::create`]) on `[tunnel] local_ip/prefix` and run
|
||||||
//! [`AuraRouter`] to bridge the TUN and the connection.
|
//! [`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 rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?);
|
||||||
let initial = rot.current().await;
|
let initial = rot.current().await;
|
||||||
dial_cfg.sni = initial.sni.clone();
|
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;
|
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!(
|
tracing::info!(
|
||||||
sni = %initial.sni,
|
sni = %initial.sni,
|
||||||
padding_profile = initial.padding_profile_id,
|
padding_profile = initial.padding_profile_id,
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ pub struct TransportSection {
|
|||||||
pub quic_port: u16,
|
pub quic_port: u16,
|
||||||
/// UDP transport: pad datagrams up to HTTPS size buckets to blur on-wire sizes.
|
/// UDP transport: pad datagrams up to HTTPS size buckets to blur on-wire sizes.
|
||||||
pub obfuscate: bool,
|
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,
|
pub masquerade: bool,
|
||||||
/// `[transport.masks]`: daily protocol-mask rotation knobs.
|
/// `[transport.masks]`: daily protocol-mask rotation knobs.
|
||||||
pub masks: MasksSection,
|
pub masks: MasksSection,
|
||||||
@@ -542,17 +544,14 @@ impl ServerConfigFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the [`TcpOpts`] for the server's TCP transport from `[transport] masquerade`; the
|
/// Build the [`TcpOpts`] for the server's TCP transport.
|
||||||
/// masquerade `Host` reuses the mimicry SNI when one is configured.
|
///
|
||||||
|
/// 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 {
|
pub fn tcp_opts(&self) -> TcpOpts {
|
||||||
let mut opts = TcpOpts {
|
TcpOpts::default()
|
||||||
masquerade: self.transport.masquerade,
|
|
||||||
..TcpOpts::default()
|
|
||||||
};
|
|
||||||
if let Some(sni) = &self.mimicry.sni {
|
|
||||||
opts.host = sni.clone();
|
|
||||||
}
|
|
||||||
opts
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,9 +587,10 @@ impl ClientConfigFile {
|
|||||||
/// Build the [`DialConfig`] the client passes to [`aura_transport::dial`].
|
/// 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
|
/// 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-
|
/// uses its own port from `[transport]`). `order` becomes the fallback order. Per-transport
|
||||||
/// transport options (UDP `obfuscate`, TCP `masquerade`/`host` and the QUIC SNI) come from
|
/// options: UDP gets `obfuscate` from `[transport]`; TCP/QUIC both use `[client] sni` as their
|
||||||
/// `[transport]` + `[client] sni`.
|
/// 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> {
|
pub fn dial_config(&self) -> anyhow::Result<DialConfig> {
|
||||||
let ip = self.server_socket_addr()?.ip();
|
let ip = self.server_socket_addr()?.ip();
|
||||||
let order = self.transport.modes()?;
|
let order = self.transport.modes()?;
|
||||||
@@ -611,11 +611,7 @@ impl ClientConfigFile {
|
|||||||
obfuscate: self.transport.obfuscate,
|
obfuscate: self.transport.obfuscate,
|
||||||
..UdpOpts::default()
|
..UdpOpts::default()
|
||||||
},
|
},
|
||||||
tcp: TcpOpts {
|
tcp: TcpOpts::default(),
|
||||||
masquerade: self.transport.masquerade,
|
|
||||||
host: self.client.sni.clone(),
|
|
||||||
..TcpOpts::default()
|
|
||||||
},
|
|
||||||
attempt_timeout: Duration::from_secs(8),
|
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.tcp.unwrap().to_string(), "0.0.0.0:4433");
|
||||||
assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:4434");
|
assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:4434");
|
||||||
assert!(cfg.udp_opts().obfuscate);
|
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();
|
let tcp = cfg.tcp_opts();
|
||||||
assert!(tcp.masquerade);
|
assert!(tcp.alpn.is_none(), "default ALPN is used");
|
||||||
assert_eq!(tcp.host, "cdn.example.com"); // reuses mimicry SNI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -848,8 +845,9 @@ pool_cidr = "10.7.0.0/24"
|
|||||||
assert!(dial.endpoints.quic.is_none());
|
assert!(dial.endpoints.quic.is_none());
|
||||||
assert_eq!(dial.sni, "cdn.example.com");
|
assert_eq!(dial.sni, "cdn.example.com");
|
||||||
assert!(!dial.udp.obfuscate);
|
assert!(!dial.udp.obfuscate);
|
||||||
assert!(dial.tcp.masquerade);
|
// TCP is wrapped in real outer TLS now; the legacy HTTP `Host` / masquerade fields are gone.
|
||||||
assert_eq!(dial.tcp.host, "cdn.example.com");
|
// The outer TLS SNI is `dial.sni`, asserted above.
|
||||||
|
assert!(dial.tcp.alpn.is_none(), "default ALPN is used");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,10 +9,18 @@
|
|||||||
//! 4. logs the rotation and loops.
|
//! 4. logs the rotation and loops.
|
||||||
//!
|
//!
|
||||||
//! Each new connection (`UdpServer::accept`, `UdpClient::connect`, `TcpClient::connect`, ...)
|
//! Each new connection (`UdpServer::accept`, `UdpClient::connect`, `TcpClient::connect`, ...)
|
||||||
//! reads the **current** mask once when constructing its [`UdpOpts`] / [`TcpOpts`] / QUIC SNI, so
|
//! reads the **current** mask once when constructing its [`UdpOpts`] padding profile / QUIC SNI /
|
||||||
//! already-established connections keep their original mask and only fresh connections see the
|
//! TCP outer-TLS SNI, so already-established connections keep their original mask and only fresh
|
||||||
//! rotation. There is no need to coordinate with the peer: each side independently derived the same
|
//! connections see the rotation. There is no need to coordinate with the peer: each side
|
||||||
//! set from the CA fingerprint it already trusts.
|
//! 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)
|
//! ## 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.
|
// Per-transport endpoints (UDP/TCP/QUIC) derived from the listen IP + `[transport]` ports.
|
||||||
let endpoints = cfg.transport_endpoints()?;
|
let endpoints = cfg.transport_endpoints()?;
|
||||||
let mut udp_opts = cfg.udp_opts();
|
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
|
// 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
|
// 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 rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?);
|
||||||
let initial = rot.current().await;
|
let initial = rot.current().await;
|
||||||
udp_opts.padding_profile = initial.padding_profile_id;
|
udp_opts.padding_profile = initial.padding_profile_id;
|
||||||
tcp_opts.host = initial.http_host.clone();
|
// The TCP transport now uses a real outer TLS-443 layer, which subsumes the old HTTP
|
||||||
tcp_opts.user_agent = initial.user_agent.clone();
|
// masquerade preamble — there is no longer a per-mask `Host:` / `User-Agent:` / `Server:`
|
||||||
tcp_opts.server_header = initial.server_header.clone();
|
// header to inject. Mask rotation still drives UDP padding (above) and the QUIC SNI.
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
sni = %initial.sni,
|
sni = %initial.sni,
|
||||||
padding_profile = initial.padding_profile_id,
|
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,
|
tcp = ?endpoints.tcp,
|
||||||
quic = ?endpoints.quic,
|
quic = ?endpoints.quic,
|
||||||
obfuscate = udp_opts.obfuscate,
|
obfuscate = udp_opts.obfuscate,
|
||||||
masquerade = tcp_opts.masquerade,
|
tcp_tls = "real outer TLS-443 (h2/http1.1 ALPN)",
|
||||||
"starting Aura server"
|
"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 server_for_apply = Arc::clone(&server);
|
||||||
let rot_for_apply = Arc::clone(rot);
|
let rot_for_apply = Arc::clone(rot);
|
||||||
let base_udp = udp_opts;
|
let base_udp = udp_opts;
|
||||||
let base_tcp = tcp_opts.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Poll the rotator's handle for a change once a minute, and push it into the live
|
// 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
|
// MultiServer when it changes. The actual rotation timer lives inside the rotator's
|
||||||
// spawn; this loop is just the "apply to bound sockets" bridge.
|
// 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 handle = rot_for_apply.handle();
|
||||||
let mut last = rot_for_apply.current().await;
|
let mut last = rot_for_apply.current().await;
|
||||||
loop {
|
loop {
|
||||||
@@ -142,13 +146,8 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
if current != last {
|
if current != last {
|
||||||
let mut new_udp = base_udp;
|
let mut new_udp = base_udp;
|
||||||
new_udp.padding_profile = current.padding_profile_id;
|
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;
|
let srv = server_for_apply.lock().await;
|
||||||
srv.set_udp_opts(new_udp).await;
|
srv.set_udp_opts(new_udp).await;
|
||||||
srv.set_tcp_opts(new_tcp).await;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
sni = %current.sni,
|
sni = %current.sni,
|
||||||
padding_profile = current.padding_profile_id,
|
padding_profile = current.padding_profile_id,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ async-trait.workspace = true
|
|||||||
# PEM (certificates + PKCS#8 keys) -> DER for the outer QUIC/TLS rustls config. Already resolved
|
# 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.
|
# in the workspace lockfile (pulled transitively), so this adds no new version resolution.
|
||||||
rustls-pemfile = "2"
|
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]
|
[dev-dependencies]
|
||||||
# The loopback integration test mints a CA + server/client certs to drive a real QUIC handshake.
|
# The loopback integration test mints a CA + server/client certs to drive a real QUIC handshake.
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ use crate::{AuraClient, AuraServer, TcpClient, TcpOpts, TcpServer, UdpClient, Ud
|
|||||||
pub enum TransportMode {
|
pub enum TransportMode {
|
||||||
/// Aura's own protocol over plain UDP (primary).
|
/// Aura's own protocol over plain UDP (primary).
|
||||||
Udp,
|
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,
|
Tcp,
|
||||||
/// Aura inside QUIC/HTTP3 mimicry (fallback / strong camouflage).
|
/// Aura inside QUIC/HTTP3 mimicry (fallback / strong camouflage).
|
||||||
Quic,
|
Quic,
|
||||||
@@ -71,7 +72,9 @@ pub struct Endpoints {
|
|||||||
pub struct DialConfig {
|
pub struct DialConfig {
|
||||||
/// Server addresses per transport.
|
/// Server addresses per transport.
|
||||||
pub endpoints: Endpoints,
|
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,
|
pub sni: String,
|
||||||
/// Transports to try, in order. The first that connects wins.
|
/// Transports to try, in order. The first that connects wins.
|
||||||
pub order: Vec<TransportMode>,
|
pub order: Vec<TransportMode>,
|
||||||
@@ -149,9 +152,11 @@ async fn dial_one(
|
|||||||
TransportMode::Udp => UdpClient::connect(addr, proto_cfg.clone(), cfg.udp)
|
TransportMode::Udp => UdpClient::connect(addr, proto_cfg.clone(), cfg.udp)
|
||||||
.await?
|
.await?
|
||||||
.into_dyn(),
|
.into_dyn(),
|
||||||
TransportMode::Tcp => TcpClient::connect(addr, proto_cfg.clone(), cfg.tcp.clone())
|
TransportMode::Tcp => {
|
||||||
.await?
|
TcpClient::connect(addr, &cfg.sni, proto_cfg.clone(), cfg.tcp.clone())
|
||||||
.into_dyn(),
|
.await?
|
||||||
|
.into_dyn()
|
||||||
|
}
|
||||||
TransportMode::Quic => AuraClient::connect(addr, &cfg.sni, proto_cfg.clone())
|
TransportMode::Quic => AuraClient::connect(addr, &cfg.sni, proto_cfg.clone())
|
||||||
.await?
|
.await?
|
||||||
.into_dyn(),
|
.into_dyn(),
|
||||||
@@ -176,8 +181,9 @@ pub struct Accepted {
|
|||||||
/// QUIC, or run one UDP server per client.
|
/// 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
|
/// 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
|
/// update their accept-time options (UDP padding profile; TCP currently only carries ALPN, which is
|
||||||
/// disturbing in-flight connections — see [`MultiServer::set_udp_opts`] / [`MultiServer::set_tcp_opts`].
|
/// usually static) without disturbing in-flight connections — see [`MultiServer::set_udp_opts`] /
|
||||||
|
/// [`MultiServer::set_tcp_opts`].
|
||||||
pub struct MultiServer {
|
pub struct MultiServer {
|
||||||
rx: mpsc::Receiver<Accepted>,
|
rx: mpsc::Receiver<Accepted>,
|
||||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ pub use padding::{
|
|||||||
HTTPS_SIZE_BUCKETS, PADDING_PROFILES,
|
HTTPS_SIZE_BUCKETS, PADDING_PROFILES,
|
||||||
};
|
};
|
||||||
pub use quic::{client_endpoint, server_endpoint, AcceptAnyServerCert};
|
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};
|
pub use udp::{UdpClient, UdpConnection, UdpOpts, UdpServer};
|
||||||
|
|
||||||
// Re-export the inner proto trait so downstream crates (the CLI) can name the connection as
|
// Re-export the inner proto trait so downstream crates (the CLI) can name the connection as
|
||||||
|
|||||||
+229
-155
@@ -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
|
//! This wires the Aura proto handshake (hybrid X25519 + ML-KEM-768 + mutual X.509) and
|
||||||
//! [`aura_proto::Session`] directly over a [`TcpStream`], which already implements
|
//! [`aura_proto::Session`] **inside a real TLS-443 connection**. The outer rustls TLS layer is
|
||||||
//! [`AsyncRead`](tokio::io::AsyncRead) + [`AsyncWrite`](tokio::io::AsyncWrite). No extra crypto and
|
//! exactly the same camouflage idea as for the QUIC backend (see [`crate::quic`]):
|
||||||
//! no QUIC are involved — the security boundary is the inner Aura handshake, exactly as for the UDP
|
|
||||||
//! backend.
|
|
||||||
//!
|
//!
|
||||||
//! ## 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
|
//! [`AcceptAnyServerCert`]: crate::quic::AcceptAnyServerCert
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -20,76 +21,147 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{ReadHalf, WriteHalf};
|
||||||
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use tokio_rustls::{
|
||||||
|
client::TlsStream as ClientTlsStream, server::TlsStream as ServerTlsStream, TlsAcceptor,
|
||||||
|
TlsConnector,
|
||||||
|
};
|
||||||
|
|
||||||
use aura_proto::{
|
use aura_proto::{
|
||||||
client_handshake, server_handshake, ClientConfig, Frame, PacketConnection, ServerConfig,
|
client_handshake, server_handshake, ClientConfig, Frame, PacketConnection, ServerConfig,
|
||||||
Session, SessionReceiver, SessionSender,
|
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.
|
/// Tunables for the TCP transport.
|
||||||
///
|
///
|
||||||
/// `user_agent` / `server_header` defaults match the original hard-coded preamble strings, so a
|
/// The HTTP/1.1 "light masquerade" preamble that lived here pre-v2 has been removed: the outer
|
||||||
/// pre-rotation deployment that constructs `TcpOpts::default()` retains exact wire compatibility
|
/// camouflage is now a real rustls TLS-443 handshake (much stronger). The only knob left is the
|
||||||
/// with previous Aura builds (used by existing TCP loopback tests).
|
/// **ALPN advertisement** in case a deployment wants to mimic a specific origin's stack; the
|
||||||
#[derive(Clone, Debug)]
|
/// default of `[h2, http/1.1]` is the canonical browser-CDN advertisement.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct TcpOpts {
|
pub struct TcpOpts {
|
||||||
/// When `true`, exchange a minimal HTTP/1.1 preamble before the Aura handshake so the connection
|
/// Custom ALPN list for the outer TLS handshake. `None` (the default) uses
|
||||||
/// opening resembles plain HTTP. A light disguise only (not TLS).
|
/// [`DEFAULT_TCP_ALPN`] (= `[b"h2", b"http/1.1"]`).
|
||||||
pub masquerade: bool,
|
pub alpn: Option<Vec<Vec<u8>>>,
|
||||||
/// `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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TcpOpts {
|
impl TcpOpts {
|
||||||
fn default() -> Self {
|
/// Materialize the ALPN protocol list this options instance should send on the wire.
|
||||||
Self {
|
fn alpn_protocols(&self) -> Vec<Vec<u8>> {
|
||||||
masquerade: false,
|
self.alpn
|
||||||
host: "cdn.example.com".to_string(),
|
.clone()
|
||||||
// Match the pre-rotation hard-coded preamble strings exactly so existing loopback tests
|
.unwrap_or_else(|| DEFAULT_TCP_ALPN.iter().map(|p| p.to_vec()).collect())
|
||||||
// (which build `TcpOpts::default()`) keep observing identical wire bytes.
|
|
||||||
user_agent: "Mozilla/5.0".to_string(),
|
|
||||||
server_header: "nginx".to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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>`):
|
/// The proto session can sit on either side's split TLS halves (server or client), so we keep an
|
||||||
/// outbound packets are sealed as [`Frame::Data`] on `stream_id 0`; inbound `Data` payloads are
|
/// internal enum and dispatch send / receive accordingly. The public surface is a single
|
||||||
/// returned; `Ping` is answered with `Pong`, stray `Pong` ignored, `Close` surfaced as an error.
|
/// [`PacketConnection`] (no caller cares which side opened the underlying TLS).
|
||||||
/// Send and receive use **separate** [`tokio::sync::Mutex`]es so the two directions run concurrently.
|
|
||||||
pub struct TcpConnection {
|
pub struct TcpConnection {
|
||||||
sender: Mutex<SessionSender<OwnedWriteHalf>>,
|
inner: ConnInner,
|
||||||
receiver: Mutex<SessionReceiver<OwnedReadHalf>>,
|
|
||||||
peer_id: Option<String>,
|
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 {
|
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 peer_id = session.peer_id().map(str::to_owned);
|
||||||
let (sender, receiver) = session.split();
|
let (sender, receiver) = session.split();
|
||||||
Self {
|
Self {
|
||||||
sender: Mutex::new(sender),
|
inner: ConnInner::Server {
|
||||||
receiver: Mutex::new(receiver),
|
sender: Mutex::new(sender),
|
||||||
|
receiver: Mutex::new(receiver),
|
||||||
|
},
|
||||||
peer_id,
|
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]
|
#[must_use]
|
||||||
pub fn peer_id(&self) -> Option<&str> {
|
pub fn peer_id(&self) -> Option<&str> {
|
||||||
self.peer_id.as_deref()
|
self.peer_id.as_deref()
|
||||||
@@ -105,29 +177,51 @@ impl TcpConnection {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PacketConnection for TcpConnection {
|
impl PacketConnection for TcpConnection {
|
||||||
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
||||||
self.sender
|
match &self.inner {
|
||||||
.lock()
|
ConnInner::Server { sender, .. } => {
|
||||||
.await
|
sender
|
||||||
.send_frame(Frame::Data {
|
.lock()
|
||||||
stream_id: 0,
|
.await
|
||||||
payload: Bytes::copy_from_slice(packet),
|
.send_frame(Frame::Data {
|
||||||
})
|
stream_id: 0,
|
||||||
.await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recv_packet(&self) -> anyhow::Result<Vec<u8>> {
|
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 {
|
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::Data { payload, .. } => return Ok(payload.to_vec()),
|
||||||
Frame::Ping { seq } => {
|
Frame::Ping { seq } => {
|
||||||
// Separate mutex from the receive lock we hold => no deadlock.
|
// Separate mutex from the receive lock => no deadlock.
|
||||||
self.sender
|
match &self.inner {
|
||||||
.lock()
|
ConnInner::Server { sender, .. } => {
|
||||||
.await
|
sender.lock().await.send_frame(Frame::Pong { seq }).await?
|
||||||
.send_frame(Frame::Pong { seq })
|
}
|
||||||
.await?;
|
ConnInner::Client { sender, .. } => {
|
||||||
|
sender.lock().await.send_frame(Frame::Pong { seq }).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Frame::Pong { .. } => continue,
|
Frame::Pong { .. } => continue,
|
||||||
Frame::Close { code, reason } => {
|
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
|
// 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 {
|
pub struct TcpServer {
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
proto_cfg: Arc<ServerConfig>,
|
proto_cfg: Arc<ServerConfig>,
|
||||||
/// Live options: kept behind an `Arc<RwLock>` so the daily mask rotator can update the
|
/// Pre-built rustls server config wrapped in an [`Arc`] (rustls expects `Arc<ServerConfig>`).
|
||||||
/// masquerade `Server:` header (and `host` if a deployment cares to) and the next
|
/// Kept behind an [`tokio::sync::RwLock`] so a future "rotate ALPN" path can swap it without
|
||||||
/// [`Self::accept`] picks it up. In-flight connections already exchanged their preamble bytes,
|
/// disturbing in-flight TLS handshakes (in-flight already snapshotted the previous Arc).
|
||||||
/// so the rotation only changes what *the next handshake* writes.
|
tls: Arc<tokio::sync::RwLock<Arc<rustls::ServerConfig>>>,
|
||||||
|
/// Live options, snapshot once per accept.
|
||||||
opts: Arc<tokio::sync::RwLock<TcpOpts>>,
|
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
|
/// Bind a TCP server on `addr` (use `..:0` for an OS-assigned port, read back with
|
||||||
/// [`TcpServer::local_addr`]).
|
/// [`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
|
/// # 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(
|
pub async fn bind(
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
proto_cfg: ServerConfig,
|
proto_cfg: ServerConfig,
|
||||||
opts: TcpOpts,
|
opts: TcpOpts,
|
||||||
) -> io::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let listener = TcpListener::bind(addr).await?;
|
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 {
|
Ok(Self {
|
||||||
listener,
|
listener,
|
||||||
proto_cfg: Arc::new(proto_cfg),
|
proto_cfg: Arc::new(proto_cfg),
|
||||||
|
tls: Arc::new(tokio::sync::RwLock::new(Arc::new(sc))),
|
||||||
opts: Arc::new(tokio::sync::RwLock::new(opts)),
|
opts: Arc::new(tokio::sync::RwLock::new(opts)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the server's accept-time options. The next [`Self::accept`] picks up the change;
|
/// 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) {
|
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;
|
*self.opts.write().await = new_opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,24 +316,21 @@ impl TcpServer {
|
|||||||
self.listener.local_addr()
|
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
|
/// # 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).
|
/// handshake fails (e.g. the client's certificate does not verify against the CA).
|
||||||
pub async fn accept(&self) -> anyhow::Result<TcpConnection> {
|
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();
|
stream.set_nodelay(true).ok();
|
||||||
// Snapshot once: the preamble writes immediately, and we want a consistent view in case a
|
// Snapshot the current TLS config Arc — `TlsAcceptor::from` just wraps it.
|
||||||
// rotation lands mid-accept.
|
let acceptor = TlsAcceptor::from(Arc::clone(&*self.tls.read().await));
|
||||||
let opts = self.opts.read().await.clone();
|
let tls = acceptor.accept(stream).await?;
|
||||||
if opts.masquerade {
|
let (reader, writer) = tokio::io::split(tls);
|
||||||
read_until_headers_end(&mut stream).await?;
|
|
||||||
write_server_preamble(&mut stream, &opts.server_header).await?;
|
|
||||||
}
|
|
||||||
let (reader, writer) = stream.into_split();
|
|
||||||
let session = server_handshake(reader, writer, &self.proto_cfg).await?;
|
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;
|
pub struct TcpClient;
|
||||||
|
|
||||||
impl TcpClient {
|
impl TcpClient {
|
||||||
/// Connect to an Aura TCP server at `server`: optional masquerade preamble, then the Aura
|
/// Connect to an Aura TCP server at `server`: real outer TLS-443 handshake (with `sni` as the
|
||||||
/// mutual-auth handshake over the TCP stream.
|
/// 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
|
/// # 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, ...).
|
/// handshake fails (bad server cert chain, SAN mismatch, ...).
|
||||||
pub async fn connect(
|
pub async fn connect(
|
||||||
server: SocketAddr,
|
server: SocketAddr,
|
||||||
|
sni: &str,
|
||||||
proto_cfg: ClientConfig,
|
proto_cfg: ClientConfig,
|
||||||
opts: TcpOpts,
|
opts: TcpOpts,
|
||||||
) -> anyhow::Result<TcpConnection> {
|
) -> 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();
|
stream.set_nodelay(true).ok();
|
||||||
if opts.masquerade {
|
let tls = connector.connect(server_name, stream).await?;
|
||||||
write_client_preamble(&mut stream, &opts.host, &opts.user_agent).await?;
|
let (reader, writer) = tokio::io::split(tls);
|
||||||
read_until_headers_end(&mut stream).await?;
|
|
||||||
}
|
|
||||||
let (reader, writer) = stream.into_split();
|
|
||||||
let session = client_handshake(reader, writer, &proto_cfg).await?;
|
let session = client_handshake(reader, writer, &proto_cfg).await?;
|
||||||
Ok(TcpConnection::from_session(session))
|
Ok(TcpConnection::from_client_session(session))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
//! End-to-end loopback test for the TCP fallback transport: real TCP on 127.0.0.1, full Aura
|
//! End-to-end loopback test for the TLS-443 / TCP fallback transport: real outer rustls TLS over
|
||||||
//! mutual-auth handshake, packet echo — with the HTTP masquerade both off and on.
|
//! 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_pki::AuraCa;
|
||||||
use aura_proto::{ClientConfig, PacketConnection, ServerConfig};
|
use aura_proto::{ClientConfig, PacketConnection, ServerConfig};
|
||||||
use aura_transport::{TcpClient, TcpOpts, TcpServer};
|
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.
|
/// Mint a fresh CA + server("localhost") + client("client-tcp") and build the proto configs.
|
||||||
fn make_configs() -> (ServerConfig, ClientConfig) {
|
fn make_configs() -> (ServerConfig, ClientConfig) {
|
||||||
let ca = AuraCa::generate("Aura Test CA").expect("generate CA");
|
let ca = AuraCa::generate("Aura Test CA").expect("generate CA");
|
||||||
let server = ca
|
let server = ca
|
||||||
.issue_server_cert("localhost")
|
.issue_server_cert(SERVER_NAME)
|
||||||
.expect("issue server cert");
|
.expect("issue server cert");
|
||||||
let client = ca
|
let client = ca.issue_client_cert(CLIENT_ID).expect("issue client cert");
|
||||||
.issue_client_cert("client-tcp")
|
|
||||||
.expect("issue client cert");
|
|
||||||
let ca_pem = ca.ca_cert_pem();
|
let ca_pem = ca.ca_cert_pem();
|
||||||
let scfg = ServerConfig {
|
let scfg = ServerConfig {
|
||||||
ca_cert_pem: ca_pem.clone(),
|
ca_cert_pem: ca_pem.clone(),
|
||||||
@@ -24,12 +31,14 @@ fn make_configs() -> (ServerConfig, ClientConfig) {
|
|||||||
ca_cert_pem: ca_pem,
|
ca_cert_pem: ca_pem,
|
||||||
client_cert_pem: client.cert_pem,
|
client_cert_pem: client.cert_pem,
|
||||||
client_key_pem: client.key_pem,
|
client_key_pem: client.key_pem,
|
||||||
server_name: "localhost".to_string(),
|
server_name: SERVER_NAME.to_string(),
|
||||||
};
|
};
|
||||||
(scfg, ccfg)
|
(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 (scfg, ccfg) = make_configs();
|
||||||
let server = TcpServer::bind("127.0.0.1:0".parse().unwrap(), scfg, opts.clone())
|
let server = TcpServer::bind("127.0.0.1:0".parse().unwrap(), scfg, opts.clone())
|
||||||
.await
|
.await
|
||||||
@@ -38,7 +47,7 @@ async fn run_case(opts: TcpOpts) {
|
|||||||
|
|
||||||
let server_task = tokio::spawn(async move {
|
let server_task = tokio::spawn(async move {
|
||||||
let conn = server.accept().await.expect("server handshake");
|
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.
|
// Echo three packets back to the client.
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let pkt = conn.recv_packet().await.expect("server recv");
|
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
|
.await
|
||||||
.expect("client handshake");
|
.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.
|
// Exchange packets of varying sizes (incl. a large one) and assert the echo matches.
|
||||||
for i in 0..3u16 {
|
for i in 0..3u16 {
|
||||||
@@ -61,17 +75,25 @@ async fn run_case(opts: TcpOpts) {
|
|||||||
server_task.await.expect("server task");
|
server_task.await.expect("server task");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Baseline: default ALPN advert (`h2`, `http/1.1`), outer SNI matches the server cert SAN.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tcp_loopback_end_to_end_plain() {
|
async fn tcp_loopback_end_to_end() {
|
||||||
run_case(TcpOpts::default()).await;
|
run_case(TcpOpts::default(), SERVER_NAME).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A custom ALPN list still negotiates and runs the handshake.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tcp_loopback_end_to_end_masquerade() {
|
async fn tcp_loopback_with_custom_alpn() {
|
||||||
run_case(TcpOpts {
|
let opts = TcpOpts {
|
||||||
masquerade: true,
|
alpn: Some(vec![b"http/1.1".to_vec()]),
|
||||||
host: "cdn.example.com".to_string(),
|
};
|
||||||
..TcpOpts::default()
|
run_case(opts, SERVER_NAME).await;
|
||||||
})
|
}
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user