feat(cli): v3.2 multi-hop — per-hop cert, cell padding, 3-hop, CIDR whitelist
Closes the v3.1 unlinkability gap and resists volume/timing correlation:
1) Per-hop client cert (identity-unlinkable hops). [[client.circuit.hops]]
now accepts {addr, cert_path, key_path, [server_name]} per hop — each
hop sees a different CN, so a relay and an exit cannot correlate the
same client by certificate. Old flat `hops = ["ip:port"]` form still
parses (serde untagged enum) and falls back to [pki] cert/key.
`aura provision-client --circuit-hops N` mints N fresh UUIDv4 certs.
2) Cell padding. CellPaddingConn wrapper pads every outgoing packet to a
fixed size (default 1280 bytes; `cell_size = N` configurable) before
it hits the inner AEAD. Format: u16_be(real_len) || pkt || zero_pad.
On-wire sizes become constant -> defeats volume/timing fingerprints.
Opt-in via [client.circuit] cell_padding = true and the mirror
[server] cell_padding_for_circuit_clients = true.
3) 3-hop support. dial_circuit now accepts N >= 2 hops; iterative
ExtendBridge nests N-1 forwarders and N handshakes. Client owns the
full chain via CircuitConnection (forwarders abort on drop).
New integration test multihop_v3_2_three_hops_end_to_end runs three
in-process actors (A relay -> B relay -> C exit) on loopback and
verifies peer_id == C's CN.
4) CIDR whitelist. [server.relay] allow_extend_to entries accept
"10.0.0.0/24" (subnet, any port), "10.0.0.0/24:443" (subnet + port),
"[2001:db8::/32]:443" (IPv6 with port), as well as exact IP:port.
Empty list keeps the v3.1 open-relay (warn).
19 new tests; workspace 276 passed (+19), clippy -D warnings clean, fmt clean.
257 baseline tests untouched; all v2 / v3.1 / LE configs work as before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+340
-209
@@ -1,38 +1,42 @@
|
||||
//! v3.1 multi-hop / onion routing: the **client side** of the 2-hop circuit
|
||||
//! `client → entry-relay → exit-server`.
|
||||
//! v3.1 / v3.2 multi-hop / onion routing — the **client side** of an N-hop circuit
|
||||
//! `client → hop[0] → hop[1] → ... → hop[N-1]`. v3.1 supports `N = 2` (entry + exit);
|
||||
//! v3.2 supports `N = 2` OR `N = 3` (entry + middle + exit) plus **per-hop client
|
||||
//! certificates** so different hops cannot be linked by certificate CN.
|
||||
//!
|
||||
//! ## Wire dance
|
||||
//! ## Wire dance (recursive)
|
||||
//!
|
||||
//! 1. The client opens a normal UDP transport connection to the **entry relay** via
|
||||
//! [`UdpClient::connect`]. The relay's cert is mutually authenticated by this **outer** Aura
|
||||
//! handshake.
|
||||
//! 2. Through the established outer connection, the client sends one
|
||||
//! [`aura_proto::ControlKind::ExtendBridge`] envelope carrying the literal `IP:port` of the
|
||||
//! downstream **exit server**.
|
||||
//! 3. The relay either replies with [`aura_proto::ControlKind::CircuitReady`] (the bridge to the
|
||||
//! exit is up; every subsequent byte travels opaquely) or
|
||||
//! [`aura_proto::ControlKind::CircuitFailed`] (the relay refused — payload is a UTF-8 reason).
|
||||
//! 4. Once `CircuitReady` arrives the client opens a **local proxy UDP socket** on loopback and
|
||||
//! runs a second [`UdpClient::connect`] **at that loopback address** — this is the **inner**
|
||||
//! handshake, addressed semantically to the exit-server. A background forwarder ferries every
|
||||
//! datagram between the local proxy socket and the outer relay connection: the relay extracts
|
||||
//! each datagram and ships it to the exit verbatim. The exit therefore runs an ordinary
|
||||
//! [`aura_transport::UdpServer`] accepting one connection whose source address is the relay's
|
||||
//! bridge socket.
|
||||
//! For each hop `i` from `0` to `N-1` the dialler:
|
||||
//!
|
||||
//! Result: traffic is wrapped under **two AEAD layers** — first the exit's session keys (inner
|
||||
//! handshake) and again the relay's session keys (outer handshake). The exit knows the client's
|
||||
//! certificate CN but not the client's real source IP; the relay knows the client's source IP but
|
||||
//! not the destination IP nor a single plaintext byte.
|
||||
//! 1. **Outer handshake to `hop[i]`**: opens an Aura UDP transport connection to `hop[i].addr`
|
||||
//! (through any already-stacked proxy/forwarder chain) using `hop[i].proto_cfg`, which carries
|
||||
//! that hop's expected SAN as `server_name` AND the per-hop client cert/key — see [`HopConfig`].
|
||||
//! 2. **ExtendBridge** (only if `i < N - 1`): sends one
|
||||
//! [`aura_proto::ControlKind::ExtendBridge`] envelope carrying `hop[i+1].addr` to ask the
|
||||
//! current hop to splice a bridge to the next downstream hop. Waits for
|
||||
//! [`aura_proto::ControlKind::CircuitReady`] (or [`aura_proto::ControlKind::CircuitFailed`]).
|
||||
//! 3. **Loopback proxy** (only if `i < N - 1`): binds a local UDP socket and spawns a forwarder
|
||||
//! that splices every datagram between that socket and the outer connection to `hop[i]`. The
|
||||
//! next iteration's outer handshake is addressed at this loopback socket — so the actual bytes
|
||||
//! on the wire travel through the existing tunnel to `hop[i]`, which forwards them through its
|
||||
//! bridge to `hop[i+1]`.
|
||||
//! 4. **Final hop** (`i == N - 1`): no ExtendBridge / loopback — the connection returned by step
|
||||
//! 1 is the innermost session and authenticates the *exit's* cert. Its `peer_id()` is the exit
|
||||
//! SAN; every subsequent send/recv on the resulting [`CircuitConnection`] is wrapped in
|
||||
//! `N` AEAD layers (one per hop).
|
||||
//!
|
||||
//! ## Why a local proxy UDP socket?
|
||||
//! Result: every IP packet is encrypted N times — once per hop — so the exit knows the client's
|
||||
//! certificate CN but not the source IP; every intermediate hop knows the previous hop's address
|
||||
//! and the next hop's address but not the destination, and never sees a plaintext byte.
|
||||
//!
|
||||
//! The Aura UDP transport (`aura_transport::udp`) is built around a [`tokio::net::UdpSocket`]: its
|
||||
//! reliable-handshake adapter writes/reads complete datagrams with a 1-byte type prefix
|
||||
//! (`0x01` HS, `0x02` DATA). Re-using the transport without that socket would mean re-implementing
|
||||
//! the whole reliability layer. The loopback proxy is the smallest hack that lets the inner
|
||||
//! [`UdpClient`] talk over its expected datagram interface while every datagram is actually being
|
||||
//! tunnelled through the outer relay connection.
|
||||
//! ## Per-hop client identity (v3.2)
|
||||
//!
|
||||
//! The v3.1 dialler used a single `[pki]` cert/key for every hop, so the entry-relay and the exit
|
||||
//! both saw the *same* certificate CN — trivially linkable. v3.2 lets the caller pass a different
|
||||
//! [`aura_proto::ClientConfig`] for each hop via [`HopConfig`]. The CLI generates an indepedent
|
||||
//! UUID-v4 cert per hop with `aura provision-client --circuit-hops N`. With distinct CNs per hop
|
||||
//! the only thing that is linkable is the *temporal* correlation of one packet leaving the client
|
||||
//! and one packet leaving the exit — which the cell-padding wrapper (see [`crate::cells`]) is the
|
||||
//! companion mitigation for.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
@@ -47,42 +51,70 @@ use aura_transport::{UdpClient, UdpConnection, UdpOpts};
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// How long the client waits for the relay to reply with [`ControlKind::CircuitReady`] (or
|
||||
/// [`ControlKind::CircuitFailed`]) after sending the [`ControlKind::ExtendBridge`] envelope.
|
||||
/// How long the client waits for each hop to reply with [`ControlKind::CircuitReady`] after
|
||||
/// sending the [`ControlKind::ExtendBridge`] envelope.
|
||||
const READY_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// An established 2-hop circuit: it is **literally** a [`UdpConnection`] in disguise. The inner
|
||||
/// connection's outgoing datagrams go to a local proxy socket, which forwards them through the
|
||||
/// outer relay connection to the exit. From the inner handshake / data exchange's point of view
|
||||
/// nothing is special — it is talking to a normal Aura UDP server.
|
||||
/// Per-hop dial configuration. One instance per hop in the circuit; the order matches the wire
|
||||
/// order (`hops[0]` = entry, `hops[N-1]` = exit).
|
||||
///
|
||||
/// The two background tasks (proxy forwarders) and the outer connection are owned here, so dropping
|
||||
/// the circuit tears everything down in order.
|
||||
/// `proto_cfg.server_name` is the SAN the verifier checks on **this hop's** certificate during the
|
||||
/// outer Aura handshake. `proto_cfg.client_cert_pem` / `proto_cfg.client_key_pem` is the client
|
||||
/// identity presented **to this hop** — different per hop in v3.2 so the entry and the exit cannot
|
||||
/// link the two handshakes by certificate CN.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HopConfig {
|
||||
/// Wire address of this hop (already resolved to `IP:port`).
|
||||
pub addr: SocketAddr,
|
||||
/// Aura client config for the handshake to *this* hop.
|
||||
pub proto_cfg: ClientConfig,
|
||||
}
|
||||
|
||||
impl HopConfig {
|
||||
/// Convenience: build a hop using the same client config as the rest of the circuit. Used by
|
||||
/// the v3.1 / `CircuitHop::Addr` back-compat path where the caller wants every hop to use the
|
||||
/// global `[pki]` cert/key (matching the v3.1 behaviour).
|
||||
pub fn from_shared(addr: SocketAddr, proto_cfg: ClientConfig) -> Self {
|
||||
Self { addr, proto_cfg }
|
||||
}
|
||||
}
|
||||
|
||||
/// An established multi-hop circuit. The inner [`UdpConnection`]'s outgoing datagrams travel
|
||||
/// through a chain of loopback proxies + outer relay connections; from the inner handshake / data
|
||||
/// exchange's point of view nothing is special — it is talking to a normal Aura UDP server.
|
||||
///
|
||||
/// The outer connections and forwarder tasks are owned here so dropping the circuit tears
|
||||
/// everything down in order.
|
||||
pub struct CircuitConnection {
|
||||
/// The inner UDP connection (target of the second handshake addressed to the exit). All
|
||||
/// `send_packet` / `recv_packet` go through this; the proxy forwarder splices the bytes onto
|
||||
/// the outer relay connection.
|
||||
/// The innermost UDP connection (target of the final hop's handshake). All `send_packet` /
|
||||
/// `recv_packet` calls delegate to it; the forwarder chain splices its bytes onto the outer
|
||||
/// hops in order.
|
||||
inner: UdpConnection,
|
||||
/// Outer relay connection — pinned alive for the lifetime of the circuit. The forwarder owns
|
||||
/// clones, but holding it here means the outer is dropped at exactly the same time as `Self`.
|
||||
_outer_conn_holder: Arc<dyn PacketConnection>,
|
||||
/// Background task: local proxy socket ↔ outer relay connection. Aborted in [`Drop`].
|
||||
forwarder: JoinHandle<()>,
|
||||
/// Local proxy socket kept alive for the forwarder's lifetime (the forwarder also holds an
|
||||
/// `Arc<UdpSocket>` clone, but this prevents close-on-last-clone races during shutdown).
|
||||
_proxy_socket: Arc<UdpSocket>,
|
||||
/// Every outer hop connection, in order (`hop[0]` first). Pinned alive for the lifetime of the
|
||||
/// circuit; the per-hop forwarder tasks own clones, but holding the originals here means every
|
||||
/// outer is dropped at exactly the same time as `Self`.
|
||||
_outer_conns: Vec<Arc<dyn PacketConnection>>,
|
||||
/// One forwarder task per intermediate hop (so `N - 1` tasks for an N-hop circuit). Aborted in
|
||||
/// [`Drop`] so dropping the circuit cleans them up.
|
||||
forwarders: Vec<JoinHandle<()>>,
|
||||
/// The chain of loopback proxy sockets (one per intermediate hop). Held here so they outlive
|
||||
/// the forwarders that read/write through them; the forwarder also holds an `Arc<UdpSocket>`
|
||||
/// clone, but this prevents a close-on-last-clone race during shutdown.
|
||||
_proxy_sockets: Vec<Arc<UdpSocket>>,
|
||||
}
|
||||
|
||||
impl Drop for CircuitConnection {
|
||||
fn drop(&mut self) {
|
||||
self.forwarder.abort();
|
||||
for f in &self.forwarders {
|
||||
f.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CircuitConnection {
|
||||
/// The verified peer Common Name as learned during the **inner** handshake. This is the
|
||||
/// **exit-server's** identity (NOT the relay's) — the whole point of multi-hop is that the
|
||||
/// inner handshake authenticates the exit through the relay opaquely.
|
||||
/// The verified peer Common Name as learned during the **innermost** handshake. This is the
|
||||
/// **exit-server's** identity (NOT any intermediate hop) — the whole point of multi-hop is that
|
||||
/// the inner handshake authenticates the exit through every relay opaquely.
|
||||
#[must_use]
|
||||
pub fn peer_id(&self) -> Option<&str> {
|
||||
self.inner.peer_id()
|
||||
@@ -100,7 +132,7 @@ impl CircuitConnection {
|
||||
impl PacketConnection for CircuitConnection {
|
||||
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
||||
// Delegate to the inner UdpConnection — the proxy forwarder picks up its outgoing
|
||||
// datagrams from the local proxy socket and tunnels them through the outer relay.
|
||||
// datagrams from the innermost loopback proxy socket and tunnels them through the chain.
|
||||
self.inner.send_packet(packet).await
|
||||
}
|
||||
|
||||
@@ -109,29 +141,262 @@ impl PacketConnection for CircuitConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 2-hop circuit `client → hops[0] (entry relay) → hops[1] (exit server)` and return it
|
||||
/// as a [`CircuitConnection`].
|
||||
/// Build an N-hop circuit `client → hops[0] → hops[1] → ... → hops[N-1]`. Returns the established
|
||||
/// [`CircuitConnection`].
|
||||
///
|
||||
/// Both hops are reached via the [`UdpClient`] transport in v3.1. `proto_cfg.server_name` is used
|
||||
/// by the **inner** handshake to verify the EXIT's certificate SAN. The relay's own cert is also
|
||||
/// CA-verified by the outer handshake; pass [`dial_circuit_with_relay_name`] when the relay's SAN
|
||||
/// differs from the exit's.
|
||||
/// `hops.len()` must be in `{2, 3}` — v3.1 accepted only 2; v3.2 extends to 3. Each entry's
|
||||
/// [`HopConfig::proto_cfg`] supplies:
|
||||
///
|
||||
/// * The SAN expected on that hop's server certificate (`proto_cfg.server_name`).
|
||||
/// * The client cert/key presented **to that hop** (`proto_cfg.client_cert_pem` /
|
||||
/// `proto_cfg.client_key_pem`). Distinct per hop = identity-unlinkable v3.2 behaviour.
|
||||
///
|
||||
/// # Errors
|
||||
/// * The outer UDP connection to the entry relay failed.
|
||||
/// * The relay refused (`CircuitFailed`) or did not reply within [`READY_TIMEOUT_SECS`] seconds.
|
||||
/// * The inner Aura handshake (through the relay) failed (bad exit cert chain, SAN mismatch, etc.).
|
||||
/// * Any outer UDP connection failed.
|
||||
/// * Any intermediate hop refused (`CircuitFailed`) or did not reply within
|
||||
/// [`READY_TIMEOUT_SECS`] seconds.
|
||||
/// * The inner Aura handshake to the exit failed (bad exit cert chain, SAN mismatch, etc.).
|
||||
pub async fn dial_circuit(
|
||||
hops: &[HopConfig],
|
||||
udp_opts: UdpOpts,
|
||||
) -> anyhow::Result<CircuitConnection> {
|
||||
if hops.len() < 2 || hops.len() > 3 {
|
||||
bail!(
|
||||
"v3.2 multi-hop supports 2 or 3 hops (entry, [middle,] exit); got {}",
|
||||
hops.len()
|
||||
);
|
||||
}
|
||||
|
||||
// We build the chain iteratively. At each iteration the "current outer" is what we are
|
||||
// currently dialing through; for the first hop it is a literal `UdpClient::connect`, for every
|
||||
// subsequent hop it is a loopback proxy + forwarder splicing onto the previous outer.
|
||||
let mut outer_conns: Vec<Arc<dyn PacketConnection>> = Vec::with_capacity(hops.len() - 1);
|
||||
let mut forwarders: Vec<JoinHandle<()>> = Vec::with_capacity(hops.len() - 1);
|
||||
let mut proxy_sockets: Vec<Arc<UdpSocket>> = Vec::with_capacity(hops.len() - 1);
|
||||
|
||||
// Step 1: dial the very first hop directly via UDP. This is the only hop whose outer handshake
|
||||
// exits the client process as a real datagram on the OS network stack.
|
||||
let entry = &hops[0];
|
||||
let first = UdpClient::connect(entry.addr, entry.proto_cfg.clone(), udp_opts)
|
||||
.await
|
||||
.with_context(|| format!("dial entry hop at {}", entry.addr))?;
|
||||
let mut current_outer: Arc<dyn PacketConnection> = first.into_dyn();
|
||||
|
||||
// For every *intermediate* hop (every hop except the last) we:
|
||||
// - ask it to bridge to the next hop via ExtendBridge,
|
||||
// - wait for CircuitReady,
|
||||
// - bring up a loopback proxy + forwarder so the next outer handshake travels through
|
||||
// `current_outer`,
|
||||
// - then re-dial the *next* hop via that loopback proxy and update `current_outer`.
|
||||
//
|
||||
// After the loop, `current_outer` is the outer connection to `hops[N-2]` and the next dial
|
||||
// (step 6 below) is the inner handshake to `hops[N-1]` (the exit). We need to keep
|
||||
// `current_outer` itself in `outer_conns` too — it is the outermost of the inner-handshake's
|
||||
// pipe.
|
||||
for i in 0..hops.len() - 1 {
|
||||
let next = &hops[i + 1];
|
||||
|
||||
// 2. Tell the current hop to splice onto `next.addr`.
|
||||
let payload = encode_extend_bridge(next.addr);
|
||||
let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload);
|
||||
current_outer
|
||||
.send_packet(&envelope)
|
||||
.await
|
||||
.with_context(|| format!("send ExtendBridge to hop[{}] at {}", i, hops[i].addr))?;
|
||||
|
||||
// 3. Wait for CircuitReady from this hop (or CircuitFailed = bail). The remote may send
|
||||
// unrelated envelopes (CRL pushes etc.) in front of ours; ignore until our envelope
|
||||
// arrives or the deadline elapses.
|
||||
let ready_deadline =
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(READY_TIMEOUT_SECS);
|
||||
loop {
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= ready_deadline {
|
||||
bail!(
|
||||
"timeout waiting for CircuitReady from hop[{}] at {}",
|
||||
i,
|
||||
hops[i].addr
|
||||
);
|
||||
}
|
||||
let remaining = ready_deadline - now;
|
||||
let pkt = tokio::time::timeout(remaining, current_outer.recv_packet())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"timeout waiting for CircuitReady from hop[{}] at {}",
|
||||
i,
|
||||
hops[i].addr
|
||||
)
|
||||
})?
|
||||
.with_context(|| format!("recv from hop[{}] at {}", i, hops[i].addr))?;
|
||||
match decode_control_envelope(&pkt) {
|
||||
Ok(Some((ControlKind::CircuitReady, _))) => break,
|
||||
Ok(Some((ControlKind::CircuitFailed, reason))) => {
|
||||
let r = String::from_utf8_lossy(&reason);
|
||||
bail!("hop[{}] at {} refused circuit: {}", i, hops[i].addr, r);
|
||||
}
|
||||
Ok(Some((other, _))) => {
|
||||
tracing::debug!(
|
||||
hop = i,
|
||||
kind = ?other,
|
||||
"ignoring unexpected control envelope while waiting for CircuitReady"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
hop = i,
|
||||
"ignoring non-control packet from hop before CircuitReady"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
hop = i,
|
||||
error = %e,
|
||||
"malformed envelope from hop before CircuitReady"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Bring up the local proxy UDP socket. The next iteration's UdpClient::connect will
|
||||
// target this address; the forwarder below splices every datagram between the proxy
|
||||
// socket and the current outer connection.
|
||||
let proxy_socket = UdpSocket::bind("127.0.0.1:0")
|
||||
.await
|
||||
.with_context(|| format!("bind loopback proxy for hop[{}] -> hop[{}]", i, i + 1))?;
|
||||
let proxy_addr = proxy_socket
|
||||
.local_addr()
|
||||
.context("read local proxy address")?;
|
||||
let proxy_socket = Arc::new(proxy_socket);
|
||||
|
||||
// 5. Spawn the forwarder BEFORE running the next outer handshake — the handshake's first
|
||||
// datagram must already be flowing while it is being written.
|
||||
let outer_for_send = Arc::clone(¤t_outer);
|
||||
let outer_for_recv = Arc::clone(¤t_outer);
|
||||
let proxy_for_send = Arc::clone(&proxy_socket);
|
||||
let proxy_for_recv = Arc::clone(&proxy_socket);
|
||||
let hop_idx = i;
|
||||
let forwarder = tokio::spawn(async move {
|
||||
// Source address of the next-hop UdpClient, learned from its first datagram on the
|
||||
// proxy socket. We need it to know where to deliver `outer.recv_packet` payloads back.
|
||||
let inner_peer: Arc<tokio::sync::Mutex<Option<SocketAddr>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(None));
|
||||
|
||||
// Task A: proxy.recv_from -> outer.send_packet
|
||||
let inner_peer_a = Arc::clone(&inner_peer);
|
||||
let to_outer = async move {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
loop {
|
||||
let (n, from) = match proxy_for_recv.recv_from(&mut buf).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => break,
|
||||
};
|
||||
{
|
||||
let mut latch = inner_peer_a.lock().await;
|
||||
if latch.is_none() {
|
||||
*latch = Some(from);
|
||||
}
|
||||
}
|
||||
if outer_for_send.send_packet(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Task B: outer.recv_packet -> proxy.send_to(inner_peer_addr)
|
||||
let inner_peer_b = Arc::clone(&inner_peer);
|
||||
let from_outer = async move {
|
||||
loop {
|
||||
let pkt = match outer_for_recv.recv_packet().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
let dest = { *inner_peer_b.lock().await };
|
||||
if let Some(dest) = dest {
|
||||
if proxy_for_send.send_to(&pkt, dest).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Else: next-hop UdpClient has not sent its first datagram yet; drop. The
|
||||
// reliable adapter will retransmit on its RTO timer. The race window is tiny.
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = to_outer => {}
|
||||
_ = from_outer => {}
|
||||
}
|
||||
tracing::debug!(hop = hop_idx, "circuit forwarder exited");
|
||||
});
|
||||
|
||||
// 6. Move `current_outer` into our owned list, spawn the forwarder + socket into theirs,
|
||||
// then dial the *next* hop through the loopback proxy. The dial returns the new
|
||||
// `current_outer`.
|
||||
outer_conns.push(current_outer);
|
||||
forwarders.push(forwarder);
|
||||
proxy_sockets.push(Arc::clone(&proxy_socket));
|
||||
|
||||
// 7. Dial the next hop through the proxy. For an intermediate next hop this becomes the
|
||||
// new `current_outer`; for the final hop (last iteration) it is the *inner* connection
|
||||
// we return wrapped in `CircuitConnection`.
|
||||
let is_last = i == hops.len() - 2;
|
||||
let next_conn = UdpClient::connect(proxy_addr, next.proto_cfg.clone(), udp_opts)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"{} handshake to hop[{}] at {} through hop[{}]",
|
||||
if is_last { "inner" } else { "intermediate" },
|
||||
i + 1,
|
||||
next.addr,
|
||||
i
|
||||
)
|
||||
})?;
|
||||
if is_last {
|
||||
// The innermost session: wrap it in CircuitConnection along with every outer + proxy
|
||||
// we own. Note: we do NOT push next_conn into outer_conns — it becomes `inner`.
|
||||
return Ok(CircuitConnection {
|
||||
inner: next_conn,
|
||||
_outer_conns: outer_conns,
|
||||
forwarders,
|
||||
_proxy_sockets: proxy_sockets,
|
||||
});
|
||||
} else {
|
||||
// Promote to dyn for the next loop iteration.
|
||||
current_outer = next_conn.into_dyn();
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable: the loop always returns when `is_last` is true (the last intermediate
|
||||
// iteration always produces the inner session for the exit).
|
||||
unreachable!("dial_circuit loop must return on the final hop")
|
||||
}
|
||||
|
||||
/// v3.1 back-compat shim: build hops from a flat `[SocketAddr]` list using a shared
|
||||
/// [`ClientConfig`] for every hop and call [`dial_circuit`]. Useful for code paths that have a
|
||||
/// single proto_cfg (e.g. an old `[client] sni`).
|
||||
///
|
||||
/// Behaviour matches v3.1 exactly when given exactly 2 hops; with 3 hops it now also works (every
|
||||
/// hop uses the same cert / key, i.e. NOT identity-unlinkable — use the per-hop variant for that).
|
||||
pub async fn dial_circuit_shared_cfg(
|
||||
hops: &[SocketAddr],
|
||||
proto_cfg: ClientConfig,
|
||||
udp_opts: UdpOpts,
|
||||
) -> anyhow::Result<CircuitConnection> {
|
||||
dial_circuit_with_relay_name(hops, proto_cfg, udp_opts, None).await
|
||||
let hop_cfgs: Vec<HopConfig> = hops
|
||||
.iter()
|
||||
.map(|a| HopConfig::from_shared(*a, proto_cfg.clone()))
|
||||
.collect();
|
||||
dial_circuit(&hop_cfgs, udp_opts).await
|
||||
}
|
||||
|
||||
/// Variant of [`dial_circuit`] letting the caller override the SAN expected on the relay's cert
|
||||
/// (the outer handshake) independently of the exit's expected SAN (`proto_cfg.server_name`, used
|
||||
/// by the inner handshake). See [`dial_circuit`] for the high-level wire dance.
|
||||
/// Variant of [`dial_circuit_shared_cfg`] letting the caller override the SAN expected on the
|
||||
/// **first hop's** cert (the relay) independently of the exit's expected SAN
|
||||
/// (`proto_cfg.server_name`, used by the inner handshake). v3.1 kept this for the loopback test
|
||||
/// which uses a different SAN per role.
|
||||
///
|
||||
/// Equivalent to v3.1 behaviour. For arbitrary per-hop overrides, build a `Vec<HopConfig>`
|
||||
/// directly and call [`dial_circuit`].
|
||||
pub async fn dial_circuit_with_relay_name(
|
||||
hops: &[SocketAddr],
|
||||
proto_cfg: ClientConfig,
|
||||
@@ -140,151 +405,17 @@ pub async fn dial_circuit_with_relay_name(
|
||||
) -> anyhow::Result<CircuitConnection> {
|
||||
if hops.len() != 2 {
|
||||
bail!(
|
||||
"v3.1 multi-hop requires exactly 2 hops (entry, exit), got {}",
|
||||
"dial_circuit_with_relay_name requires exactly 2 hops (entry, exit); got {}",
|
||||
hops.len()
|
||||
);
|
||||
}
|
||||
let entry = hops[0];
|
||||
|
||||
// 1) Dial entry via the existing UDP transport. The outer mutual-auth handshake against the
|
||||
// relay's certificate runs here; when `relay_server_name` is supplied the verifier
|
||||
// validates the relay's SAN against that name instead of the exit's.
|
||||
let mut outer_cfg = proto_cfg.clone();
|
||||
let mut entry_cfg = proto_cfg.clone();
|
||||
if let Some(name) = relay_server_name {
|
||||
outer_cfg.server_name = name.to_string();
|
||||
entry_cfg.server_name = name.to_string();
|
||||
}
|
||||
let outer = UdpClient::connect(entry, outer_cfg, udp_opts)
|
||||
.await
|
||||
.with_context(|| format!("dial entry relay at {entry}"))?;
|
||||
let outer: Arc<dyn PacketConnection> = outer.into_dyn();
|
||||
|
||||
// 2) Send the ExtendBridge control envelope describing the downstream exit address.
|
||||
let exit = hops[1];
|
||||
let payload = encode_extend_bridge(exit);
|
||||
let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload);
|
||||
outer
|
||||
.send_packet(&envelope)
|
||||
.await
|
||||
.context("send ExtendBridge to relay")?;
|
||||
|
||||
// 3) Wait for CircuitReady (with a hard timeout). The relay may send unrelated control
|
||||
// envelopes in front of ours (e.g. a CRL push from the v2 path) — those are ignored until
|
||||
// the expected envelope arrives or the deadline elapses.
|
||||
let ready_deadline =
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(READY_TIMEOUT_SECS);
|
||||
loop {
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= ready_deadline {
|
||||
bail!("timeout waiting for CircuitReady from relay at {entry}");
|
||||
}
|
||||
let remaining = ready_deadline - now;
|
||||
let pkt = tokio::time::timeout(remaining, outer.recv_packet())
|
||||
.await
|
||||
.map_err(|_| anyhow!("timeout waiting for CircuitReady from relay at {entry}"))?
|
||||
.context("recv from entry relay")?;
|
||||
match decode_control_envelope(&pkt) {
|
||||
Ok(Some((ControlKind::CircuitReady, _))) => break,
|
||||
Ok(Some((ControlKind::CircuitFailed, reason))) => {
|
||||
let r = String::from_utf8_lossy(&reason);
|
||||
bail!("relay refused circuit: {r}");
|
||||
}
|
||||
Ok(Some((other, _))) => {
|
||||
tracing::debug!(
|
||||
kind = ?other,
|
||||
"ignoring unexpected control envelope while waiting for CircuitReady"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("ignoring non-control packet from relay before CircuitReady");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "malformed envelope from relay before CircuitReady");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Bring up the local proxy UDP socket. The inner UdpClient will `connect()` to its address;
|
||||
// every datagram it sends goes through the forwarder below to the outer relay connection,
|
||||
// and every datagram the relay forwards from the exit is replayed back to the inner socket.
|
||||
let proxy_socket = UdpSocket::bind("127.0.0.1:0")
|
||||
.await
|
||||
.context("bind local circuit proxy socket")?;
|
||||
let proxy_addr = proxy_socket
|
||||
.local_addr()
|
||||
.context("read local proxy address")?;
|
||||
let proxy_socket = Arc::new(proxy_socket);
|
||||
|
||||
// 5) Spawn the forwarder BEFORE running the inner handshake — the handshake's first datagram
|
||||
// must already be flowing while it is being written.
|
||||
let outer_for_send = Arc::clone(&outer);
|
||||
let outer_for_recv = Arc::clone(&outer);
|
||||
let proxy_for_send = Arc::clone(&proxy_socket);
|
||||
let proxy_for_recv = Arc::clone(&proxy_socket);
|
||||
let forwarder = tokio::spawn(async move {
|
||||
// Source address of the inner UdpClient, learned from its first datagram on the proxy
|
||||
// socket. We need it to know where to deliver `outer.recv_packet` payloads back.
|
||||
let inner_peer: Arc<tokio::sync::Mutex<Option<SocketAddr>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(None));
|
||||
|
||||
// Task A: proxy.recv_from → outer.send_packet
|
||||
let inner_peer_a = Arc::clone(&inner_peer);
|
||||
let to_outer = async move {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
loop {
|
||||
let (n, from) = match proxy_for_recv.recv_from(&mut buf).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => break,
|
||||
};
|
||||
{
|
||||
let mut latch = inner_peer_a.lock().await;
|
||||
if latch.is_none() {
|
||||
*latch = Some(from);
|
||||
}
|
||||
}
|
||||
if outer_for_send.send_packet(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Task B: outer.recv_packet → proxy.send_to(inner_peer_addr)
|
||||
let inner_peer_b = Arc::clone(&inner_peer);
|
||||
let from_outer = async move {
|
||||
loop {
|
||||
let pkt = match outer_for_recv.recv_packet().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
let dest = { *inner_peer_b.lock().await };
|
||||
if let Some(dest) = dest {
|
||||
if proxy_for_send.send_to(&pkt, dest).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Else: the inner UdpClient has not sent its first datagram yet; drop. (The
|
||||
// reliable adapter will retransmit on its RTO timer.) This race window is tiny —
|
||||
// we always spawn the forwarder before `UdpClient::connect`.
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = to_outer => {}
|
||||
_ = from_outer => {}
|
||||
}
|
||||
});
|
||||
|
||||
// 6) Inner Aura handshake addressed to the EXIT, via the local proxy. The peer_id we capture
|
||||
// is the exit's verified CN (the core invariant: the inner handshake authenticates the
|
||||
// exit, not the relay).
|
||||
let inner = UdpClient::connect(proxy_addr, proto_cfg, udp_opts)
|
||||
.await
|
||||
.context("inner handshake to exit through relay")?;
|
||||
|
||||
Ok(CircuitConnection {
|
||||
inner,
|
||||
_outer_conn_holder: outer,
|
||||
forwarder,
|
||||
_proxy_socket: proxy_socket,
|
||||
})
|
||||
let hop_cfgs = vec![
|
||||
HopConfig::from_shared(hops[0], entry_cfg),
|
||||
HopConfig::from_shared(hops[1], proto_cfg),
|
||||
];
|
||||
dial_circuit(&hop_cfgs, udp_opts).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user