diff --git a/config/client.toml.example b/config/client.toml.example index 8a77e9f..e35877b 100644 --- a/config/client.toml.example +++ b/config/client.toml.example @@ -127,17 +127,46 @@ enabled = false mean_interval_ms = 500 jitter = 0.5 -# v3.1 multi-hop / onion routing: dial through an entry-relay before reaching the exit-server. -# When `enabled = true`, the client opens an OUTER Aura UDP connection to `hops[0]` (the relay), -# sends one ExtendBridge envelope describing `hops[1]` (the exit), waits for CircuitReady, and -# then runs an INNER Aura handshake addressed to the exit through that relay — two AEAD layers -# per packet, the exit knows the client's CN but not the source IP, the relay knows the source -# IP but not the destination nor a single plaintext byte. Exactly two hops are required in -# v3.1; configure the relay-server with [server.relay] enabled = true and -# allow_extend_to = [""]. +# v3.1 / v3.2 multi-hop / onion routing: dial through 1 or 2 intermediate hops before reaching +# the exit-server. When `enabled = true`, the client opens an OUTER Aura UDP connection to +# `hops[0]` (the entry-relay), sends one ExtendBridge envelope describing the next hop, waits for +# CircuitReady, then either dials the exit directly (2-hop) or repeats the ExtendBridge dance +# through a middle relay (3-hop). The innermost handshake authenticates the EXIT's cert opaquely +# — every relay sees only the next-hop address and AEAD ciphertext. # -# Omitting the section (or `enabled = false`) keeps the v2 single-hop dial path intact — -# [client] server_addr / [transport] order rules apply as before. +# v3.2 adds: +# * per-hop client certificates (the entry-relay and the exit see DIFFERENT CNs — they cannot +# link the two handshakes by identity), and +# * cell padding (every packet is padded to a constant `cell_size` bytes before sending — the +# exit MUST also enable `[server] cell_padding_for_circuit_clients = true` to decode), and +# * 3-hop support (just add a third [[client.circuit.hops]] table). +# +# Omitting the section (or `enabled = false`) keeps the v2 single-hop dial path intact. +# +# --- v3.1 FLAT FORM (back-compat) — every hop uses the [pki] cert/key above (NOT unlinkable): # [client.circuit] # enabled = true -# hops = ["198.51.100.5:443", "203.0.113.10:443"] # [entry_relay, exit_server] — literal IP:port +# hops = ["198.51.100.5:443", "203.0.113.10:443"] +# +# --- v3.2 PER-HOP FORM — each hop has its own cert/key (identity-unlinkable): +# [client.circuit] +# enabled = true +# cell_padding = true +# cell_size = 1280 +# +# [[client.circuit.hops]] +# addr = "198.51.100.5:443" +# cert_path = "~/.config/aura/circuit/entry.crt" +# key_path = "~/.config/aura/circuit/entry.key" +# +# [[client.circuit.hops]] # OPTIONAL middle hop for a 3-hop circuit +# addr = "198.51.100.99:443" +# cert_path = "~/.config/aura/circuit/middle.crt" +# key_path = "~/.config/aura/circuit/middle.key" +# +# [[client.circuit.hops]] +# addr = "203.0.113.10:443" +# cert_path = "~/.config/aura/circuit/exit.crt" +# key_path = "~/.config/aura/circuit/exit.key" +# +# Generate per-hop certs in one command: `aura provision-client --circuit-hops 3 ...` diff --git a/config/server.toml.example b/config/server.toml.example index 5d0e883..5378d14 100644 --- a/config/server.toml.example +++ b/config/server.toml.example @@ -151,11 +151,28 @@ jitter = 0.5 # Omitting the whole [server.relay] section (or `enabled = false`) keeps the v2 behaviour intact. # [server.relay] # enabled = true -# Whitelist of allowed downstream exit addresses. ONLY literal IP:port entries; DNS resolution -# is NOT performed in v3.1 (unparsable entries are logged at WARN and skipped). An empty list -# turns this server into an OPEN relay accepting any downstream — dangerous; the runtime logs -# a WARN on each accepted bridge. +# Whitelist of allowed downstream destinations. v3.2 accepts three entry formats: +# * "IP:port" — exact literal SocketAddr (the v3.1 form). +# * "10.0.0.0/24" — bare CIDR; matches ANY port at any IP in the subnet. +# * "10.0.0.0/24:443" — CIDR with explicit port; matches that port on any IP in the subnet. +# * "[2001:db8::/32]:443" — square-bracket IPv6 CIDR with port. +# * "2001:db8::/32" — bare IPv6 CIDR (any port). +# Unparseable entries are logged at WARN and skipped. An empty list turns this server into an +# OPEN relay accepting any downstream — dangerous; the runtime logs a WARN on each accepted bridge. # allow_extend_to = [ -# "198.51.100.5:443", # the exit you operate -# "203.0.113.10:443", +# "198.51.100.5:443", # the exit you operate (exact) +# "203.0.113.0/24", # a whole /24 of trusted exits (any port) +# "10.0.0.0/16:443", # a /16 of relays on port 443 only # ] +# +# v3.2 cell padding: opt-in. The relay itself does NOT decode cells — it just forwards bytes. +# These knobs are documented here for symmetry; the actual decode happens on the EXIT (see +# [server] cell_padding_for_circuit_clients below). +# cell_padding = false +# cell_size = 1280 + +# v3.2 EXIT-side cell padding. When an exit-server serves cell-padded circuit clients (i.e. the +# clients have `[client.circuit] cell_padding = true`), add the following field to the [server] +# block at the top of this file so the inner-handshake session's recv decodes the constant-size +# cells and the send re-pads on the way back. Defaults to `false` for v3.1 compatibility. +# cell_padding_for_circuit_clients = true diff --git a/crates/aura-cli/src/cells.rs b/crates/aura-cli/src/cells.rs new file mode 100644 index 0000000..f6235c5 --- /dev/null +++ b/crates/aura-cli/src/cells.rs @@ -0,0 +1,273 @@ +//! v3.2: **cell padding** — a constant-size frame wrapper around any [`PacketConnection`]. +//! +//! ## Why +//! +//! In v3.1 a packet's on-wire size leaks the *type* of payload (a TCP ack vs an HTTP response vs a +//! video chunk). Even with AEAD encryption a traffic analyst can correlate sizes with applications. +//! v3.2 closes that side-channel by **padding every packet to a fixed cell size** before it is +//! handed to the underlying connection: the analyst sees a uniform stream of equal-size cells with +//! no length information leaking out. +//! +//! ## Wire format +//! +//! Each cell is a `cell_size`-byte buffer: +//! +//! ```text +//! ┌─────────┬──────────────────────┬────────────────────────┐ +//! │ len: u16│ payload (len bytes)│ padding (zero bytes) │ +//! │ big-end │ │ (or random; AEAD hides)│ +//! └─────────┴──────────────────────┴────────────────────────┘ +//! 0 2 2 + len cell_size +//! ``` +//! +//! Bytes `0..2` are the big-endian payload length. Bytes `2..2+len` hold the real payload (an inner +//! IP packet). The remainder `2+len..cell_size` is zero-filled padding — the underlying AEAD layer +//! (inside the Aura transport) re-encrypts the entire cell so the zeros are indistinguishable from +//! random bytes on the wire. +//! +//! ## Symmetric requirement +//! +//! Both peers MUST agree on `cell_size`. If the sender pads to 1280 but the receiver tries to parse +//! the bytes as a raw packet, parsing will fail (or, worse, succeed silently with garbage). The CLI +//! exposes the `[client.circuit] cell_padding` and `[server] cell_padding_for_circuit_clients` +//! knobs; **enable them together on every hop** in a circuit (entry-relay + exit, or entry + +//! middle + exit). +//! +//! ## Capacity +//! +//! A cell of `cell_size` bytes carries at most `cell_size - 2` bytes of payload (the 2-byte length +//! prefix). Sending a packet larger than that is a hard error — the caller must fragment upstream. +//! With the default `cell_size = 1280`, capacity is 1278 bytes which comfortably fits an IPv4 MTU +//! of 1280 (the Aura TUN default is 1420; operators using cell padding should lower it accordingly). + +use std::sync::Arc; + +use anyhow::bail; +use async_trait::async_trait; +use aura_proto::PacketConnection; + +/// A [`PacketConnection`] wrapper that pads every outgoing packet to a constant `cell_size` and +/// strips the padding on the receive side. Both peers MUST use the same `cell_size` (see the module +/// docs). +pub struct CellPaddingConn { + inner: Arc, + cell_size: usize, +} + +impl CellPaddingConn { + /// Default cell size: 1280 bytes (the IPv6 minimum MTU). Comfortably fits the common IPv4 MTU + /// and matches a value an HTTPS observer would not find suspicious. + pub const DEFAULT_CELL_SIZE: usize = 1280; + + /// Maximum payload bytes carried by a default-sized cell (1280 - 2 = 1278). + pub const MAX_PAYLOAD: usize = Self::DEFAULT_CELL_SIZE - 2; + + /// Wrap `inner` with constant-size cell padding at `cell_size` bytes. + /// + /// `cell_size` MUST be at least 3 (length prefix + 1 payload byte). The constructor does not + /// validate this; callers should use [`CellPaddingConn::DEFAULT_CELL_SIZE`] unless they have a + /// reason to override it (the runtime check inside [`PacketConnection::send_packet`] would + /// reject the resulting connection for any non-empty packet anyway). + #[must_use] + pub fn new(inner: Arc, cell_size: usize) -> Self { + Self { inner, cell_size } + } + + /// The cell size this wrapper is using (informational; for tests / logs). + #[must_use] + pub fn cell_size(&self) -> usize { + self.cell_size + } +} + +#[async_trait] +impl PacketConnection for CellPaddingConn { + async fn send_packet(&self, pkt: &[u8]) -> anyhow::Result<()> { + let cap = self.cell_size.saturating_sub(2); + if pkt.len() > cap { + bail!( + "packet {} bytes exceeds cell payload capacity {} (cell_size = {})", + pkt.len(), + cap, + self.cell_size + ); + } + // Allocate the constant-size cell, write the 2-byte big-endian length, copy the payload, + // leave the rest as zeros. The encryption layer (Aura transport AEAD, wrapped around this + // by every hop) will turn the zero-tail into ciphertext indistinguishable from random. + let mut cell = vec![0u8; self.cell_size]; + let len_bytes = (pkt.len() as u16).to_be_bytes(); + cell[0] = len_bytes[0]; + cell[1] = len_bytes[1]; + cell[2..2 + pkt.len()].copy_from_slice(pkt); + self.inner.send_packet(&cell).await + } + + async fn recv_packet(&self) -> anyhow::Result> { + let cell = self.inner.recv_packet().await?; + if cell.len() < 2 { + bail!( + "cell shorter than the 2-byte length prefix ({} bytes received)", + cell.len() + ); + } + let real_len = u16::from_be_bytes([cell[0], cell[1]]) as usize; + if real_len > cell.len().saturating_sub(2) { + bail!( + "cell length prefix {} exceeds available cell payload ({})", + real_len, + cell.len().saturating_sub(2) + ); + } + Ok(cell[2..2 + real_len].to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use tokio::sync::Mutex as TokioMutex; + + /// In-memory bidirectional pipe: each call to `send_packet` pushes the bytes onto a queue; + /// `recv_packet` pops from a (separately-loaded) queue. This lets us drive both sides of a + /// padded conversation without bringing in a real Aura transport. + struct MockConn { + send_log: TokioMutex>>, + recv_queue: TokioMutex>>, + } + impl MockConn { + fn new(recv: impl IntoIterator>) -> Arc { + Arc::new(Self { + send_log: TokioMutex::new(Vec::new()), + recv_queue: TokioMutex::new(recv.into_iter().collect()), + }) + } + } + #[async_trait] + impl PacketConnection for MockConn { + async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> { + self.send_log.lock().await.push(packet.to_vec()); + Ok(()) + } + async fn recv_packet(&self) -> anyhow::Result> { + self.recv_queue + .lock() + .await + .pop_front() + .ok_or_else(|| anyhow::anyhow!("mock recv_queue empty")) + } + } + + /// Every outgoing packet — empty, tiny, mid-sized, or maxed — is written to the inner + /// connection as EXACTLY `cell_size` bytes. This is the constant-size invariant. + #[tokio::test] + async fn cell_roundtrip_various_sizes() { + let mock = MockConn::new(std::iter::empty()); + let wrapped = CellPaddingConn::new(mock.clone() as Arc, 1280); + + let payloads: Vec> = vec![ + vec![], + vec![0x42], + b"hello cell padding".to_vec(), + vec![0xCDu8; 100], + vec![0xABu8; 1278], // max payload for cell_size = 1280 + ]; + for pkt in &payloads { + wrapped.send_packet(pkt).await.expect("send"); + } + + let sent = mock.send_log.lock().await.clone(); + assert_eq!(sent.len(), payloads.len(), "one cell per send"); + for (i, cell) in sent.iter().enumerate() { + assert_eq!( + cell.len(), + 1280, + "cell {i} has constant size; sent payload was {} bytes", + payloads[i].len() + ); + // Length-prefix encodes the original payload length. + let parsed_len = u16::from_be_bytes([cell[0], cell[1]]) as usize; + assert_eq!(parsed_len, payloads[i].len(), "len-prefix matches payload"); + assert_eq!( + &cell[2..2 + payloads[i].len()], + &payloads[i][..], + "payload bytes are preserved at offset 2" + ); + } + } + + /// Roundtrip: feed a recv queue with cells and recover the original payloads through + /// [`CellPaddingConn::recv_packet`]. + #[tokio::test] + async fn cell_recv_strips_padding() { + // Build three cells by hand, then feed them to the recv queue. + let payloads: Vec> = vec![b"first".to_vec(), vec![0u8; 0], (0..=255u8).collect()]; + let cell_size = 512; + let cells: Vec> = payloads + .iter() + .map(|p| { + let mut c = vec![0u8; cell_size]; + let lb = (p.len() as u16).to_be_bytes(); + c[0] = lb[0]; + c[1] = lb[1]; + c[2..2 + p.len()].copy_from_slice(p); + c + }) + .collect(); + let mock = MockConn::new(cells); + let wrapped = CellPaddingConn::new(mock as Arc, cell_size); + + for expected in &payloads { + let got = wrapped.recv_packet().await.expect("recv"); + assert_eq!(&got, expected, "recovered payload matches"); + } + } + + /// Sending a packet larger than `cell_size - 2` is a hard error (the caller must fragment). + #[tokio::test] + async fn cell_too_large_returns_err() { + let mock = MockConn::new(std::iter::empty()); + let wrapped = CellPaddingConn::new(mock as Arc, 256); + // 256 - 2 = 254 is the cap; 255 must fail. + let oversized = vec![0u8; 255]; + let err = wrapped.send_packet(&oversized).await.unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("exceeds cell payload capacity") || msg.contains("exceeds"), + "expected size-related error, got: {msg}" + ); + } + + /// A received cell shorter than 2 bytes (corrupt; never produced by a well-behaved peer) is + /// rejected so we surface the problem rather than silently returning empty. + #[tokio::test] + async fn cell_short_recv_is_rejected() { + let mock = MockConn::new([vec![0x05]]); + let wrapped = CellPaddingConn::new(mock as Arc, 1280); + let err = wrapped.recv_packet().await.unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("shorter than"), + "expected short-cell error, got: {msg}" + ); + } + + /// A received cell whose embedded length is larger than the cell capacity is also rejected. + #[tokio::test] + async fn cell_recv_overlong_len_prefix_is_rejected() { + // cell with len = 9999 but only 50 bytes of cell — must be rejected. + let mut bad = vec![0u8; 50]; + let lb = 9999u16.to_be_bytes(); + bad[0] = lb[0]; + bad[1] = lb[1]; + let mock = MockConn::new([bad]); + let wrapped = CellPaddingConn::new(mock as Arc, 50); + let err = wrapped.recv_packet().await.unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("exceeds available cell payload") || msg.contains("exceeds"), + "expected overlong-len-prefix error, got: {msg}" + ); + } +} diff --git a/crates/aura-cli/src/circuit.rs b/crates/aura-cli/src/circuit.rs index 0d2f05d..f59b4cb 100644 --- a/crates/aura-cli/src/circuit.rs +++ b/crates/aura-cli/src/circuit.rs @@ -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, - /// 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` clone, but this prevents close-on-last-clone races during shutdown). - _proxy_socket: Arc, + /// 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>, + /// 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>, + /// 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` + /// clone, but this prevents a close-on-last-clone race during shutdown. + _proxy_sockets: Vec>, } 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 { + 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> = Vec::with_capacity(hops.len() - 1); + let mut forwarders: Vec> = Vec::with_capacity(hops.len() - 1); + let mut proxy_sockets: Vec> = 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 = 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>> = + 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 { - dial_circuit_with_relay_name(hops, proto_cfg, udp_opts, None).await + let hop_cfgs: Vec = 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` +/// 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 { 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 = 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>> = - 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 } diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index e9bee27..343742a 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -97,28 +97,44 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let routes = Arc::new(RwLock::new(table)); let stats = Arc::new(Stats::new()); - // Dial: when [client.circuit] is enabled, build a 2-hop circuit `client → entry-relay → exit` - // via [`circuit::dial_circuit`]. Otherwise fall back to the v2 single-hop dial across the - // configured [transport] order. In both cases the result is a uniform `Arc` - // so the downstream router does not care which path was taken. - let (conn, mode) = if cfg.circuit.enabled { - let hops = cfg - .circuit_hops() - .context("parsing [client.circuit] hops")?; + // Dial: when [client.circuit] is enabled, build an N-hop circuit (v3.1: N=2; v3.2: N=2 or 3) + // via [`circuit::dial_circuit`] with per-hop client configs. Otherwise fall back to the v2 + // single-hop dial across the configured [transport] order. In both cases the result is a + // uniform `Arc` so the downstream router does not care which path was + // taken. + let (conn, mode) = if cfg.client.circuit.enabled { + let hop_cfgs = cfg + .build_circuit_hop_configs() + .context("building [client.circuit] hop configs")?; + let hop_count = hop_cfgs.len(); tracing::info!( - entry = %hops[0], - exit = %hops[1], - "building v3.1 2-hop circuit" + hops = hop_count, + entry = %hop_cfgs[0].addr, + exit = %hop_cfgs[hop_count - 1].addr, + cell_padding = cfg.client.circuit.cell_padding, + cell_size = cfg.client.circuit.cell_size, + "building v3.2 multi-hop circuit" ); - let circuit_conn = circuit::dial_circuit(&hops, proto_cfg.clone(), dial_cfg.udp) + let circuit_conn = circuit::dial_circuit(&hop_cfgs, dial_cfg.udp) .await - .context("building multi-hop circuit (v3.1)")?; + .context("building multi-hop circuit (v3.2)")?; let peer_id = circuit_conn.peer_id().map(str::to_owned); tracing::info!( peer = ?peer_id, - "v3.1 circuit established (inner handshake authenticated the EXIT server)" + "v3.2 circuit established (inner handshake authenticated the EXIT server)" ); - (circuit_conn.into_dyn(), TransportMode::Udp) + // v3.2 cell padding: wrap the circuit in a constant-size cell stream so on-wire bytes do + // not leak per-packet size. The exit's [server] cell_padding_for_circuit_clients flag + // MUST match. + let conn: Arc = if cfg.client.circuit.cell_padding { + Arc::new(crate::cells::CellPaddingConn::new( + circuit_conn.into_dyn(), + cfg.client.circuit.cell_size, + )) + } else { + circuit_conn.into_dyn() + }; + (conn, TransportMode::Udp) } else { // Each transport runs the inner Aura mutual-auth handshake; the winner is returned along // with which mode carried it. (The trait object does not surface the verified server CN; diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 80eb0cf..ee3ee38 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -135,9 +135,18 @@ pub struct ServerSection { /// written. Default `false` (verbose). See [`crate::no_logs`]. #[serde(default)] pub no_logs: bool, + /// v3.2: when `true`, **every** accepted UDP connection that ends up serving as a normal VPN + /// client (i.e. not bridged through the relay path) is wrapped in + /// [`crate::cells::CellPaddingConn`] using `[server.relay] cell_size` bytes per cell. This is + /// the server-side complement to `[client.circuit] cell_padding`: the **exit** of a multi-hop + /// circuit MUST enable this so its inner-handshake session decodes the client's padded cells. + /// Default `false` (v3.1-compatible). Operators running an exit-only server with cell-padded + /// circuit clients should set this to `true`. + #[serde(default)] + pub cell_padding_for_circuit_clients: bool, } -/// `[server.relay]` section: v3.1 multi-hop / onion routing. +/// `[server.relay]` section: v3.1 / v3.2 multi-hop / onion routing. /// /// When `enabled = true`, an accepted connection is **not** immediately registered with the /// [`crate::server_router::ServerRouter`]. Instead the server listens (for a short window) for a @@ -149,16 +158,44 @@ pub struct ServerSection { /// /// Omitting the section (the default) gives the v2 behaviour: every accepted connection is a /// VPN client and the relay path is dead code. +/// +/// ## v3.2 `cell_padding` +/// +/// When `cell_padding = true`, this server treats every bridged client connection as a +/// constant-size cell stream (see [`crate::cells`]) — every accepted [`aura_proto::PacketConnection`] +/// on the relay path is wrapped in [`crate::cells::CellPaddingConn`] using `cell_size` bytes per +/// cell (default 1280). The **client must enable the matching flag in `[client.circuit]`** or the +/// transport bytes will not be a valid cell stream. Default `false` (v3.1-compatible). #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct RelaySection { /// Master switch. `false` (default) keeps the v2 behaviour intact. pub enabled: bool, - /// Whitelist of allowed downstream exit addresses (`IP:port`). DNS hostnames are NOT resolved - /// in v3.1 — they are logged as a warning and ignored. An empty list means "all addresses - /// allowed", which is dangerous (open relay); the runtime logs a warning when this combination - /// is detected. + /// Whitelist of allowed downstream exit destinations. Each entry is either: + /// + /// * A literal `IP:port` — exact match. + /// * A CIDR `IP/prefix` — matches any port at any IP in the subnet (v3.2). + /// * A CIDR with explicit port `IP/prefix:port` — matches the port on any IP in the subnet + /// (v3.2). For IPv6 the syntax is `[2001:db8::/32]:443`; bare-IPv6 syntax mirrors the + /// `SocketAddr` brackets convention. + /// + /// DNS hostnames are NOT resolved (logged at WARN and skipped). An empty list means "all + /// addresses allowed" — dangerous (open relay); the runtime logs a warning when this is + /// detected. pub allow_extend_to: Vec, + /// When `true`, every relayed connection's bytes pass through [`crate::cells::CellPaddingConn`] + /// at `cell_size`. The client MUST enable the matching flag. Default `false`. + pub cell_padding: bool, + /// Cell size for [`crate::cells::CellPaddingConn`] when `cell_padding = true`. Default 1280. + /// MUST match the client's `[client.circuit] cell_size`. + #[serde(default = "default_cell_size")] + pub cell_size: usize, +} + +/// Default cell size (bytes) for the cell-padding wrapper. 1280 is the IPv6 minimum MTU and a +/// commonly-seen HTTPS path MTU, so it is unlikely to look suspicious on the wire. +fn default_cell_size() -> usize { + crate::cells::CellPaddingConn::DEFAULT_CELL_SIZE } /// `[server.outer_cert]` section: v3 explicit outer-TLS cert/key for the QUIC and TCP transports. @@ -274,7 +311,7 @@ pub struct ServerMimicrySection { /// Top-level `client.toml` document. #[derive(Debug, Clone, Deserialize)] pub struct ClientConfigFile { - /// `[client]` section: identity and server address. + /// `[client]` section: identity and server address (and v3.1 / v3.2 `[client.circuit]` sub). pub client: ClientSection, /// `[pki]` section: CA + leaf cert/key file paths. pub pki: PkiSection, @@ -286,26 +323,106 @@ pub struct ClientConfigFile { /// `[transport]` section: fallback order and per-transport ports/options. #[serde(default)] pub transport: TransportSection, - /// `[client.circuit]` section: v3.1 multi-hop / onion routing dial. When `enabled = true`, - /// instead of dialing the server directly via [`aura_transport::dial`], the client builds a - /// 2-hop circuit `client → entry-relay → exit-server` from `hops`. Default `enabled = false`. - #[serde(default)] - pub circuit: CircuitSection, } -/// `[client.circuit]` section: v3.1 multi-hop / onion routing on the client. +impl ClientConfigFile { + /// Shorthand accessor for `[client.circuit]`. The section lives on the [`ClientSection`] so + /// the TOML key path matches (`client.circuit`); callers conventionally write `cfg.circuit`. + pub fn circuit(&self) -> &CircuitSection { + &self.client.circuit + } +} + +/// `[client.circuit]` section: v3.1 / v3.2 multi-hop / onion routing on the client. /// -/// See the module-level docs of [`crate::circuit`] for the wire protocol. When `enabled = true`, -/// `hops` MUST contain exactly two `IP:port` entries — the entry relay (UDP) and the exit server -/// (UDP). v3.1 supports only UDP transport for both hops; configuring `enabled = true` with a -/// non-UDP transport order is a hard error at dial time (the dial helper checks the order). +/// See the module-level docs of [`crate::circuit`] for the wire protocol. +/// +/// ## Two hop formats (both accepted) +/// +/// **v3.1 flat** (back-compat — every hop uses the global `[pki]` cert/key): +/// +/// ```toml +/// [client.circuit] +/// enabled = true +/// hops = ["198.51.100.5:443", "203.0.113.10:443"] +/// ``` +/// +/// **v3.2 per-hop** (each hop carries its own client cert so the entry and the exit cannot +/// link the two handshakes by certificate CN): +/// +/// ```toml +/// [client.circuit] +/// enabled = true +/// +/// [[client.circuit.hops]] +/// addr = "198.51.100.5:443" +/// cert_path = "~/.config/aura/circuit/entry.crt" +/// key_path = "~/.config/aura/circuit/entry.key" +/// +/// [[client.circuit.hops]] +/// addr = "203.0.113.10:443" +/// cert_path = "~/.config/aura/circuit/exit.crt" +/// key_path = "~/.config/aura/circuit/exit.key" +/// ``` +/// +/// In v3.2 the `hops` array MAY also mix string entries with table entries — the string entries +/// fall back to the global `[pki]` cert/key, as in v3.1. +/// +/// `hops.len()` must be 2 OR 3 (v3.2 extended). v3.1 only accepted 2. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct CircuitSection { /// Master switch. `false` (default) keeps the v2 single-hop dial path. pub enabled: bool, - /// Ordered list of hops: `[entry_relay, exit_server]`. Exactly two literal `IP:port` entries. - pub hops: Vec, + /// Ordered list of hops. Each entry is either a literal `"IP:port"` string (v3.1 flat + /// format — uses the global `[pki]` cert/key) or a table with per-hop overrides: + /// `{ addr, cert_path, key_path, server_name? }` (v3.2). Serde's `untagged` enum + /// resolves the two formats transparently. + pub hops: Vec, + /// v3.2: pad every outgoing packet to a constant `cell_size`-byte cell before sending it + /// through the circuit. Must match the relay's `[server.relay] cell_padding`. Default `false`. + pub cell_padding: bool, + /// v3.2: cell size in bytes when `cell_padding = true`. Default 1280. Must match the relay's + /// `[server.relay] cell_size`. + #[serde(default = "default_cell_size")] + pub cell_size: usize, +} + +/// One entry in `[[client.circuit.hops]]`. Accepts either a flat `"IP:port"` string (v3.1 back +/// compat — uses the global `[pki]` cert/key for the outer handshake to this hop) or a table with +/// per-hop cert/key overrides (v3.2). The two variants are distinguished by serde's +/// `#[serde(untagged)]`. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum CircuitHop { + /// v3.1 flat: just the wire `IP:port`. The hop's outer handshake uses the client's global + /// `[pki]` cert/key, same as every other hop — NOT identity-unlinkable. + Addr(String), + /// v3.2 full: `IP:port` plus per-hop cert/key paths. The optional `server_name` overrides the + /// SAN expected on this hop's server cert (defaults to the global `[client] sni`). + Full { + /// Wire address of the hop. + addr: String, + /// PEM file holding this client's certificate for the handshake to **this hop**. Path may + /// begin with `~`. + cert_path: PathBuf, + /// PEM file holding the matching PKCS#8 private key. Path may begin with `~`. + key_path: PathBuf, + /// Optional SAN expected on the hop's server cert. When omitted, the global `[client] sni` + /// is used (matching v3.1 behaviour where every hop's SAN comes from one place). + #[serde(default)] + server_name: Option, + }, +} + +impl CircuitHop { + /// The wire address of this hop, regardless of variant. + pub fn addr(&self) -> &str { + match self { + Self::Addr(s) => s.as_str(), + Self::Full { addr, .. } => addr.as_str(), + } + } } /// `[client]` section. @@ -332,6 +449,12 @@ pub struct ClientSection { /// See [`crate::dial_targets::build_dial_targets`]. #[serde(default)] pub bridges: Vec, + /// `[client.circuit]` sub-section: v3.1 / v3.2 multi-hop / onion routing dial. When + /// `enabled = true`, instead of dialing the server directly via [`aura_transport::dial`], the + /// client builds an N-hop circuit (N = 2 or 3) from `hops`. Default `enabled = false`. + /// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`. + #[serde(default)] + pub circuit: CircuitSection, } /// `[tunnel]` section of `client.toml`. @@ -859,23 +982,50 @@ impl ServerConfigFile { TcpOpts::default() } - /// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s, skipping (with a - /// `warn` log) any entries that are not bare `IP:port` strings. v3.1 does NOT perform DNS - /// resolution; the operator must supply literal IPs. + /// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s (v3.1 back + /// compat). Use [`Self::relay_allow_rules`] if you also want to honour CIDR entries + /// introduced in v3.2. /// - /// Returns the parsed addresses paired with their original strings (so the caller can log - /// what was skipped). An empty result for a non-empty config means every entry was unparsable. + /// Returns the parsed addresses. Non-`IP:port` entries are skipped with a warn log. pub fn relay_whitelist(&self) -> Vec { let mut out = Vec::new(); for raw in &self.server.relay.allow_extend_to { match raw.parse::() { Ok(a) => out.push(a), - Err(e) => { + Err(_) => { + // v3.2 may have non-literal entries (CIDRs); skip silently here — the v3.2 + // path uses [`Self::relay_allow_rules`] which understands both. + } + } + } + out + } + + /// v3.2: parse `[server.relay] allow_extend_to` into a list of structured allow-rules that + /// may be literal `IP:port`, bare CIDR (any port), or CIDR with an explicit port. The + /// returned vector is meant to be fed straight to [`RelayAllowRule::matches`]. + /// + /// Format: + /// + /// * `"203.0.113.10:443"` — exact `SocketAddr`. + /// * `"10.0.0.0/24"` — any port at any IP in the IPv4 subnet. + /// * `"10.0.0.0/24:443"` — port 443 at any IP in the IPv4 subnet. + /// * `"[2001:db8::/32]:443"` — port 443 at any IP in the IPv6 subnet (square-bracket form). + /// * `"2001:db8::/32"` — any port at any IP in the IPv6 subnet (no port). + /// + /// Unparseable entries are logged at WARN and skipped. An empty result for a non-empty config + /// means every entry was rejected; the caller decides whether to refuse all extends or to + /// treat that as an open relay. + pub fn relay_allow_rules(&self) -> Vec { + let mut out = Vec::new(); + for raw in &self.server.relay.allow_extend_to { + match RelayAllowRule::parse(raw) { + Some(r) => out.push(r), + None => { tracing::warn!( entry = %raw, - error = %e, - "[server.relay] allow_extend_to: skipping entry — only literal IP:port is \ - supported in v3.1 (DNS resolution is out of scope)" + "[server.relay] allow_extend_to: skipping unparseable entry \ + (expected IP:port, CIDR, or CIDR:port)" ); } } @@ -884,6 +1034,92 @@ impl ServerConfigFile { } } +/// A single entry in `[server.relay] allow_extend_to`, normalised to one of three shapes: +/// +/// * [`RelayAllowRule::Exact`] — literal `IP:port`, matches only that exact `SocketAddr`. +/// * [`RelayAllowRule::Cidr`] — bare CIDR, matches any port at any IP in the subnet. +/// * [`RelayAllowRule::CidrPort`] — CIDR with explicit port, matches only that port at any IP in +/// the subnet. +/// +/// `matches(addr)` returns `true` when the given destination satisfies the rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayAllowRule { + /// Exact `SocketAddr` match — the v3.1 literal-IP:port form. + Exact(SocketAddr), + /// CIDR with no port restriction. + Cidr(IpNetwork), + /// CIDR with a specific port. + CidrPort(IpNetwork, u16), +} + +impl RelayAllowRule { + /// Parse one `allow_extend_to` entry. Returns `None` on any format error (the caller is + /// expected to log at WARN). + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + // Detect the IPv6-with-explicit-port form first: `[...]:port`. + if let Some(stripped) = s.strip_prefix('[') { + // Find the closing bracket. Whatever follows must be `:port` (or empty for bare). + if let Some(end) = stripped.find(']') { + let inside = &stripped[..end]; + let after = &stripped[end + 1..]; + // `inside` is either a bare IPv6 (no slash) or an IPv6 CIDR. + let net = if inside.contains('/') { + inside.parse::().ok()? + } else { + // bare IPv6: treat as a /128 CIDR for uniformity. + let ip: std::net::Ipv6Addr = inside.parse().ok()?; + IpNetwork::V6(ipnetwork::Ipv6Network::new(ip, 128).ok()?) + }; + if after.is_empty() { + return Some(Self::Cidr(net)); + } + let port = after.strip_prefix(':')?.parse::().ok()?; + return Some(Self::CidrPort(net, port)); + } + return None; + } + // Not IPv6-bracketed: try as a literal SocketAddr first (v4 `1.2.3.4:443`, or v6 in plain + // form — though the latter wouldn't fit here without brackets, leave it to SocketAddr). + if let Ok(a) = s.parse::() { + return Some(Self::Exact(a)); + } + // Try CIDR (with optional port suffix). Split on `:` *after* the slash so we do not eat + // an IPv6 inside a bracket — we already handled that branch above. + if let Some(slash) = s.find('/') { + // Everything before slash is the IP; everything after slash is `prefix[:port]`. + let ip_part = &s[..slash]; + let after = &s[slash + 1..]; + // If `after` contains a colon, port is the trailing piece. + if let Some(colon) = after.find(':') { + let prefix_str = &after[..colon]; + let port_str = &after[colon + 1..]; + let prefix: u8 = prefix_str.parse().ok()?; + let port: u16 = port_str.parse().ok()?; + let ip: IpAddr = ip_part.parse().ok()?; + let net = IpNetwork::new(ip, prefix).ok()?; + return Some(Self::CidrPort(net, port)); + } else { + let net: IpNetwork = s.parse().ok()?; + return Some(Self::Cidr(net)); + } + } + None + } + + /// Does this rule allow `addr`? + pub fn matches(&self, addr: SocketAddr) -> bool { + match self { + Self::Exact(a) => *a == addr, + Self::Cidr(net) => net.contains(addr.ip()), + Self::CidrPort(net, p) => *p == addr.port() && net.contains(addr.ip()), + } + } +} + impl ClientConfigFile { /// Parse a `client.toml` document from a string. pub fn parse(text: &str) -> anyhow::Result { @@ -959,29 +1195,91 @@ impl ClientConfigFile { }) } - /// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Returns an error if any - /// entry fails to parse as `IP:port` or the count is wrong for v3.1 (exactly 2). When - /// `[client.circuit]` is disabled this still validates the configured hops so misconfiguration - /// is caught early; the caller decides whether to actually use the result. - /// - /// v3.1 does NOT perform DNS resolution; the operator must supply literal IPs. + /// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Both the v3.1 flat string + /// form and the v3.2 per-hop table form are accepted (the addresses are extracted from + /// either). Returns an error if any address fails to parse or the count is wrong for v3.2 + /// (must be 2 or 3 when enabled). pub fn circuit_hops(&self) -> anyhow::Result> { - let mut out = Vec::with_capacity(self.circuit.hops.len()); - for raw in &self.circuit.hops { + let mut out = Vec::with_capacity(self.client.circuit.hops.len()); + for hop in &self.client.circuit.hops { + let raw = hop.addr(); let addr: SocketAddr = raw.parse().with_context(|| { - format!("invalid [client.circuit] hop '{raw}' (expected IP:port)") + format!("invalid [client.circuit] hop addr '{raw}' (expected IP:port)") })?; out.push(addr); } - if self.circuit.enabled && out.len() != 2 { + if self.client.circuit.enabled && !(2..=3).contains(&out.len()) { return Err(anyhow!( - "[client.circuit] requires exactly 2 hops (entry, exit) in v3.1; got {}", + "[client.circuit] requires 2 or 3 hops in v3.2; got {}", out.len() )); } Ok(out) } + /// v3.2: build the per-hop dial configs for [`crate::circuit::dial_circuit`]. + /// + /// For each `CircuitHop` entry: + /// + /// * [`CircuitHop::Addr`] (flat string): uses the global `[pki]` cert/key and the global + /// `[client] sni` as the expected server SAN (v3.1 back compat). + /// * [`CircuitHop::Full`] (table): loads the per-hop cert/key PEMs and applies the optional + /// `server_name` override (defaulting to `[client] sni`). + pub fn build_circuit_hop_configs(&self) -> anyhow::Result> { + let mut hops = Vec::with_capacity(self.client.circuit.hops.len()); + // Cache the global PKI cert/key once — every flat entry needs them. + let global_ca = read_pem(&self.pki.ca_cert)?; + let global_cert = read_pem(&self.pki.cert)?; + let global_key = read_pem(&self.pki.key)?; + for hop in &self.client.circuit.hops { + match hop { + CircuitHop::Addr(s) => { + let addr: SocketAddr = s.parse().with_context(|| { + format!("invalid [client.circuit] hop addr '{s}' (expected IP:port)") + })?; + let proto_cfg = aura_proto::ClientConfig { + ca_cert_pem: global_ca.clone(), + client_cert_pem: global_cert.clone(), + client_key_pem: global_key.clone(), + server_name: self.client.sni.clone(), + }; + hops.push(crate::circuit::HopConfig { addr, proto_cfg }); + } + CircuitHop::Full { + addr, + cert_path, + key_path, + server_name, + } => { + let parsed_addr: SocketAddr = addr.parse().with_context(|| { + format!("invalid [client.circuit] hop addr '{addr}' (expected IP:port)") + })?; + let cert_pem = read_pem(&cert_path.to_string_lossy())?; + let key_pem = read_pem(&key_path.to_string_lossy())?; + let proto_cfg = aura_proto::ClientConfig { + ca_cert_pem: global_ca.clone(), + client_cert_pem: cert_pem, + client_key_pem: key_pem, + server_name: server_name + .clone() + .unwrap_or_else(|| self.client.sni.clone()), + }; + hops.push(crate::circuit::HopConfig { + addr: parsed_addr, + proto_cfg, + }); + } + } + } + if self.client.circuit.enabled && !(2..=3).contains(&hops.len()) { + return Err(anyhow!( + "[client.circuit] requires 2 or 3 hops in v3.2; got {}", + hops.len() + )); + } + Ok(hops) + } + /// Build a [`RouteTable`] from `[tunnel.split]`. /// /// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`] @@ -1626,4 +1924,253 @@ order = ["udp", "smoke-signals"] let cfg = ClientConfigFile::parse(bad).expect("parse"); assert!(cfg.dial_config().is_err()); } + + // -------- v3.2: [[client.circuit.hops]] / CIDR whitelist / cell_padding --------------------- + + /// v3.1 back-compat: the flat `hops = ["a:port", "b:port"]` form still parses, with the v3.2 + /// `CircuitSection::hops` now typed as `Vec` via the `untagged` enum. The TOML + /// table is `[client.circuit]` because `CircuitSection` lives inside `ClientSection`. + #[test] + fn circuit_v3_1_flat_hops_back_compat() { + let c = r#" +[client] +name = "x" +server_addr = "1.2.3.4:443" +sni = "vpn.example.com" +[client.circuit] +enabled = true +hops = ["198.51.100.5:443", "203.0.113.10:443"] +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +local_ip = "10.7.0.2" +"#; + let cfg = ClientConfigFile::parse(c).expect("parse v3.1 flat hops"); + assert!(cfg.client.circuit.enabled); + assert_eq!(cfg.client.circuit.hops.len(), 2); + match &cfg.client.circuit.hops[0] { + CircuitHop::Addr(s) => assert_eq!(s, "198.51.100.5:443"), + _ => panic!("expected flat Addr variant"), + } + let addrs = cfg.circuit_hops().expect("addrs"); + assert_eq!(addrs.len(), 2); + assert_eq!(addrs[0].to_string(), "198.51.100.5:443"); + // cell_padding defaults to false (v3.1 behaviour). + assert!(!cfg.client.circuit.cell_padding); + assert_eq!(cfg.client.circuit.cell_size, 1280); + } + + /// v3.2 per-hop format: `[[client.circuit.hops]]` tables parse and `build_circuit_hop_configs` + /// honours per-hop cert/key paths (the read fails here because the paths point at synthetic + /// names; we only check addr-level parsing in this test). + #[test] + fn circuit_v3_2_per_hop_table_parses() { + let c = r#" +[client] +name = "x" +server_addr = "1.2.3.4:443" +sni = "vpn.example.com" + +[client.circuit] +enabled = true + +[[client.circuit.hops]] +addr = "198.51.100.5:443" +cert_path = "/path/entry.crt" +key_path = "/path/entry.key" + +[[client.circuit.hops]] +addr = "203.0.113.10:443" +cert_path = "/path/exit.crt" +key_path = "/path/exit.key" +server_name = "alt-exit.example.com" + +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +local_ip = "10.7.0.2" +"#; + let cfg = ClientConfigFile::parse(c).expect("parse v3.2 per-hop hops"); + assert!(cfg.client.circuit.enabled); + assert_eq!(cfg.client.circuit.hops.len(), 2); + match &cfg.client.circuit.hops[1] { + CircuitHop::Full { + addr, + cert_path, + key_path, + server_name, + } => { + assert_eq!(addr, "203.0.113.10:443"); + assert_eq!(cert_path.to_string_lossy(), "/path/exit.crt"); + assert_eq!(key_path.to_string_lossy(), "/path/exit.key"); + assert_eq!(server_name.as_deref(), Some("alt-exit.example.com")); + } + _ => panic!("expected Full variant for hop[1]"), + } + let addrs = cfg.circuit_hops().expect("addrs"); + assert_eq!(addrs.len(), 2); + assert_eq!(addrs[1].to_string(), "203.0.113.10:443"); + } + + /// v3.2 allows 3 hops (entry, middle, exit) — both for the addr-only validator and as part of + /// the new per-hop tables. + #[test] + fn circuit_v3_2_three_hops_parses() { + let c = r#" +[client] +name = "x" +server_addr = "1.2.3.4:443" +sni = "vpn.example.com" +[client.circuit] +enabled = true +hops = ["198.51.100.5:443", "198.51.100.99:443", "203.0.113.10:443"] +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +local_ip = "10.7.0.2" +"#; + let cfg = ClientConfigFile::parse(c).expect("parse"); + let addrs = cfg.circuit_hops().expect("addrs"); + assert_eq!(addrs.len(), 3); + assert_eq!(addrs[2].to_string(), "203.0.113.10:443"); + } + + /// `[client.circuit] cell_padding = true` parses and the `cell_size` default kicks in. + #[test] + fn circuit_cell_padding_flag_parses() { + let c = r#" +[client] +name = "x" +server_addr = "1.2.3.4:443" +sni = "vpn.example.com" +[client.circuit] +enabled = true +hops = ["198.51.100.5:443", "203.0.113.10:443"] +cell_padding = true +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +local_ip = "10.7.0.2" +"#; + let cfg = ClientConfigFile::parse(c).expect("parse"); + assert!(cfg.client.circuit.cell_padding); + assert_eq!(cfg.client.circuit.cell_size, 1280); + } + + /// `[server.relay]` allow_extend_to with an exact `IP:port` matches only that exact address. + #[test] + fn cidr_whitelist_exact_ip() { + let rule = RelayAllowRule::parse("203.0.113.10:443").expect("parse exact"); + assert!(rule.matches("203.0.113.10:443".parse().unwrap())); + assert!(!rule.matches("203.0.113.10:444".parse().unwrap())); + assert!(!rule.matches("203.0.113.11:443".parse().unwrap())); + } + + /// CIDR with no port matches any port at any IP in the subnet (v3.2). + #[test] + fn cidr_whitelist_subnet() { + let rule = RelayAllowRule::parse("10.0.0.0/24").expect("parse cidr"); + assert!(rule.matches("10.0.0.5:443".parse().unwrap())); + assert!(rule.matches("10.0.0.250:8080".parse().unwrap())); + assert!( + !rule.matches("10.0.1.5:443".parse().unwrap()), + "outside /24" + ); + assert!(!rule.matches("11.0.0.5:443".parse().unwrap())); + } + + /// CIDR with explicit port matches only that port within the subnet. + #[test] + fn cidr_whitelist_subnet_with_port() { + let rule = RelayAllowRule::parse("10.0.0.0/24:443").expect("parse cidr+port"); + assert!(rule.matches("10.0.0.5:443".parse().unwrap())); + assert!( + !rule.matches("10.0.0.5:8080".parse().unwrap()), + "wrong port" + ); + assert!( + !rule.matches("11.0.0.5:443".parse().unwrap()), + "outside subnet" + ); + } + + /// IPv6 CIDR forms: bare `2001:db8::/32` (no port) and `[2001:db8::/32]:443` (with port). + #[test] + fn cidr_whitelist_v6() { + let bare = RelayAllowRule::parse("2001:db8::/32").expect("parse v6 cidr"); + assert!(bare.matches("[2001:db8::1]:443".parse().unwrap())); + assert!(bare.matches("[2001:db8:abcd::5]:9999".parse().unwrap())); + assert!( + !bare.matches("[2001:db9::1]:443".parse().unwrap()), + "outside /32" + ); + + let with_port = RelayAllowRule::parse("[2001:db8::/32]:443").expect("parse v6 cidr+port"); + assert!(with_port.matches("[2001:db8::1]:443".parse().unwrap())); + assert!( + !with_port.matches("[2001:db8::1]:8080".parse().unwrap()), + "wrong port on v6 cidr+port rule" + ); + } + + /// `relay_allow_rules` parses a heterogeneous list (literal + CIDR + CIDR:port) and skips bad + /// entries with a warn log (still returning the valid ones). + #[test] + fn relay_allow_rules_heterogeneous_list() { + let s = r#" +[server] +name = "edge" +[server.relay] +enabled = true +allow_extend_to = [ + "203.0.113.10:443", + "10.0.0.0/24", + "10.1.0.0/24:443", + "garbage-not-an-ip", +] +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +pool_cidr = "10.7.0.0/24" +"#; + let cfg = ServerConfigFile::parse(s).expect("parse"); + let rules = cfg.relay_allow_rules(); + assert_eq!(rules.len(), 3, "3 valid rules; 1 garbage entry skipped"); + assert!(rules[0].matches("203.0.113.10:443".parse().unwrap())); + assert!(rules[1].matches("10.0.0.5:9999".parse().unwrap())); + assert!(rules[2].matches("10.1.0.5:443".parse().unwrap())); + assert!(!rules[2].matches("10.1.0.5:444".parse().unwrap())); + } + + /// `[server.relay] cell_padding` parses and the default `cell_size` kicks in (1280). + #[test] + fn relay_cell_padding_parses() { + let s = r#" +[server] +name = "edge" +[server.relay] +enabled = true +allow_extend_to = ["203.0.113.10:443"] +cell_padding = true +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +pool_cidr = "10.7.0.0/24" +"#; + let cfg = ServerConfigFile::parse(s).expect("parse"); + assert!(cfg.server.relay.cell_padding); + assert_eq!(cfg.server.relay.cell_size, 1280); + } } diff --git a/crates/aura-cli/src/init.rs b/crates/aura-cli/src/init.rs index 7ce133b..abe453b 100644 --- a/crates/aura-cli/src/init.rs +++ b/crates/aura-cli/src/init.rs @@ -290,6 +290,15 @@ pub struct ProvisionClientOpts { pub enable_cover_traffic: bool, /// Optional bridge addresses (`bridges = [...]`). pub bridges: Vec, + /// v3.2: when set to `Some(N)` with `N >= 2`, generate **N independent client certificates** + /// (one UUID-v4 CN per cert) named `circuit-hop-0.crt` / `.key`, `circuit-hop-1.crt` / `.key`, + /// ..., `circuit-hop-{N-1}.crt` / `.key` inside the bundle. Each cert is rendered as a + /// `[[client.circuit.hops]]` table in the bundled `client.toml`, with `cert_path` / `key_path` + /// pointing at the freshly-issued file. This is what makes the v3.2 entry-relay and the exit + /// see *different* certificate CNs and therefore unable to link the two handshakes by + /// identity. The hop addresses are NOT filled in here — the operator must edit them into + /// the rendered `client.toml` before use. + pub circuit_hops: Option, /// When `true`, overwrite existing files in `out_dir`. Default `false` errors. pub force: bool, } @@ -318,6 +327,7 @@ impl ProvisionClientOpts { enable_knock: false, enable_cover_traffic: false, bridges: Vec::new(), + circuit_hops: None, force: false, } } @@ -338,6 +348,10 @@ pub struct ProvisionClientReport { pub client_key: PathBuf, /// Rendered client.toml. pub client_config: PathBuf, + /// v3.2: per-hop circuit cert/key pairs (one per hop in `circuit_hops`). Empty when + /// `opts.circuit_hops` is `None`. Each tuple is `(cn, cert_path, key_path)`; `cn` is a + /// freshly-generated UUID v4 distinct from the main `id` above. + pub circuit_hop_certs: Vec<(String, PathBuf, PathBuf)>, } /// Run the provision-client workflow. Pure: returns a [`ProvisionClientReport`] without printing. @@ -373,9 +387,40 @@ pub fn provision_client(opts: &ProvisionClientOpts) -> anyhow::Result {}", ca_src.display(), bundled_ca.display()))?; + // 3.5 (v3.2): when --circuit-hops N is set, issue N independent client certs (UUID-v4 CN + // each) named circuit-hop-{i}.crt / .key. Each cert gets its own random CN so the entry-relay + // and the exit cannot link the two handshakes by identity. We use a per-hop stem rather than + // a separate subdirectory so a flat bundle directory stays readable. + let mut circuit_hop_certs: Vec<(String, PathBuf, PathBuf)> = Vec::new(); + if let Some(n) = opts.circuit_hops { + if n < 2 { + return Err(anyhow!( + "--circuit-hops requires N >= 2 (got {n}); v3.2 supports 2 or 3 hops" + )); + } + for i in 0..n { + // Generate a fresh UUID v4 per hop (NOT the main `id`). + let cn = uuid::Uuid::new_v4().to_string(); + let stem = format!("circuit-hop-{i}"); + let (cert, key) = pki::issue_client(&cn, &opts.out_dir, &opts.ca_dir) + .with_context(|| format!("issuing v3.2 circuit hop-{i} client cert (cn = {cn})"))?; + // Rename client.crt / client.key from `issue_client` (which writes to a fixed stem) + // into our per-hop names. issue_client uses write_leaf with stem "client", so it + // emits client.crt / client.key — rename to circuit-hop-{i}.crt / .key. + let new_cert = opts.out_dir.join(format!("{stem}.crt")); + let new_key = opts.out_dir.join(format!("{stem}.key")); + std::fs::rename(&cert, &new_cert).with_context(|| { + format!("renaming {} -> {}", cert.display(), new_cert.display()) + })?; + std::fs::rename(&key, &new_key) + .with_context(|| format!("renaming {} -> {}", key.display(), new_key.display()))?; + circuit_hop_certs.push((cn, new_cert, new_key)); + } + } + // 4: render client.toml. Use file names (not absolute paths) so the bundle is portable — // the client can drop the whole directory anywhere and `cd` in to run `aura client`. - let toml_text = render_client_toml(opts, &id); + let toml_text = render_client_toml(opts, &id, &circuit_hop_certs); let client_config = opts.out_dir.join("client.toml"); std::fs::write(&client_config, toml_text) .with_context(|| format!("writing {}", client_config.display()))?; @@ -387,12 +432,22 @@ pub fn provision_client(opts: &ProvisionClientOpts) -> anyhow::Result String { +/// +/// When `circuit_hop_certs` is non-empty, append a `[client.circuit]` block followed by one +/// `[[client.circuit.hops]]` table per hop. The hop **addresses are placeholders** (``) +/// because `provision-client` does not know the relay topology — the operator MUST fill in real +/// `IP:port` strings before running `aura client`. +pub fn render_client_toml( + opts: &ProvisionClientOpts, + id: &str, + circuit_hop_certs: &[(String, std::path::PathBuf, std::path::PathBuf)], +) -> String { let mut s = String::new(); s.push_str( "# Generated by `aura provision-client`. Edit by hand if you know what you're doing.\n\n", @@ -460,5 +515,37 @@ pub fn render_client_toml(opts: &ProvisionClientOpts, id: &str) -> String { s.push_str("mean_interval_ms = 500\n"); s.push_str("jitter = 0.5\n"); + // v3.2: append the [client.circuit] block if --circuit-hops was passed. The hop addresses + // are placeholders — the operator fills them in before running `aura client`. + if !circuit_hop_certs.is_empty() { + s.push('\n'); + s.push_str("# v3.2 multi-hop: per-hop client certificates were generated by\n"); + s.push_str("# `aura provision-client --circuit-hops N`. The entry-relay and the exit\n"); + s.push_str("# (and any middle hop) see DIFFERENT certificate CNs — they cannot link\n"); + s.push_str( + "# the two handshakes by identity. Fill in the `addr` fields below before use.\n", + ); + s.push_str("[client.circuit]\n"); + s.push_str("enabled = true\n"); + s.push_str("cell_padding = true\n"); + s.push_str("cell_size = 1280\n\n"); + for (i, (cn, cert, key)) in circuit_hop_certs.iter().enumerate() { + s.push_str("[[client.circuit.hops]]\n"); + s.push_str(&format!("# hop {i} — cn = {cn}\n")); + s.push_str("addr = \"\"\n"); + let cert_name = cert + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| cert.display().to_string()); + let key_name = key + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| key.display().to_string()); + s.push_str(&format!("cert_path = \"{}\"\n", cert_name)); + s.push_str(&format!("key_path = \"{}\"\n", key_name)); + s.push('\n'); + } + } + s } diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs index c793727..a84582f 100644 --- a/crates/aura-cli/src/lib.rs +++ b/crates/aura-cli/src/lib.rs @@ -14,6 +14,7 @@ pub mod admin; pub mod bench; +pub mod cells; pub mod circuit; pub mod client; pub mod config; diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index df51add..4dad57e 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -237,6 +237,13 @@ struct ProvisionClientArgs { /// Comma-separated list of fallback server addresses (IP or IP:port). #[arg(long)] bridges: Option, + /// v3.2: generate N independent client certificates (one UUID-v4 CN each) for an N-hop + /// circuit. Each cert gets its own random CN so the entry-relay, any middle hop, and the + /// exit cannot link the two handshakes by identity. N must be 2 or 3. When set, the bundled + /// `client.toml` gains a `[client.circuit]` block with N `[[client.circuit.hops]]` tables + /// (the operator must fill in real hop addresses). + #[arg(long)] + circuit_hops: Option, /// Overwrite an existing bundle directory. #[arg(long)] force: bool, @@ -532,6 +539,7 @@ fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> { enable_knock: args.enable_knock, enable_cover_traffic: args.enable_cover_traffic, bridges, + circuit_hops: args.circuit_hops, force: args.force, }; let report = init::provision_client(&opts)?; @@ -542,6 +550,22 @@ fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> { println!(" client.crt: {}", report.client_cert.display()); println!(" client.key: {}", report.client_key.display()); println!(" client.toml: {}", report.client_config.display()); + if !report.circuit_hop_certs.is_empty() { + println!( + " v3.2 per-hop circuit certs ({}):", + report.circuit_hop_certs.len() + ); + for (i, (cn, cert, key)) in report.circuit_hop_certs.iter().enumerate() { + println!( + " hop {i}: cn = {cn}\n cert: {}\n key: {}", + cert.display(), + key.display() + ); + } + println!( + " EDIT the rendered client.toml and fill in the `addr` of each [[client.circuit.hops]] entry." + ); + } println!(); println!("Hand the entire bundle directory to the client via any secure channel."); println!( diff --git a/crates/aura-cli/src/relay.rs b/crates/aura-cli/src/relay.rs index f715ce6..4a57569 100644 --- a/crates/aura-cli/src/relay.rs +++ b/crates/aura-cli/src/relay.rs @@ -33,6 +33,8 @@ use aura_proto::{ }; use tokio::net::UdpSocket; +use crate::config::RelayAllowRule; + /// How long the relay waits for the client's first packet on a fresh connection before falling /// back to treating the connection as a normal VPN client. Two seconds is comfortably longer than /// a loopback round-trip (the client sends `ExtendBridge` immediately after the outer handshake @@ -63,7 +65,9 @@ pub enum RendezvousOutcome { Refused, } -/// Perform the rendezvous on a freshly-accepted relay connection. +/// Perform the rendezvous on a freshly-accepted relay connection (v3.1 back-compat API: +/// whitelist is a flat `&[SocketAddr]`). For v3.2's CIDR-aware allow rules, use +/// [`rendezvous_with_rules`] — it accepts the [`RelayAllowRule`] enum. /// /// Reads (with a [`EXTEND_RENDEZVOUS_SECS`] timeout) the next packet from `conn`. When it decodes /// as [`ControlKind::ExtendBridge`] and the requested exit is whitelisted, this function: @@ -79,6 +83,22 @@ pub enum RendezvousOutcome { pub async fn rendezvous( conn: &Arc, whitelist: &[SocketAddr], +) -> RendezvousOutcome { + // Adapter: lift the flat whitelist into v3.2 `RelayAllowRule::Exact` entries and delegate. + let rules: Vec = whitelist + .iter() + .copied() + .map(RelayAllowRule::Exact) + .collect(); + rendezvous_with_rules(conn, &rules).await +} + +/// v3.2: rendezvous variant that takes a list of [`RelayAllowRule`] (literal `IP:port` / +/// bare CIDR / CIDR with explicit port). Semantics are identical to [`rendezvous`] otherwise — +/// see its docstring. +pub async fn rendezvous_with_rules( + conn: &Arc, + rules: &[RelayAllowRule], ) -> RendezvousOutcome { let pkt = match tokio::time::timeout( Duration::from_secs(EXTEND_RENDEZVOUS_SECS), @@ -140,15 +160,15 @@ pub async fn rendezvous( } }; - // Whitelist enforcement. Empty whitelist == open relay (operator was warned via the log line + // Whitelist enforcement. Empty rule list == open relay (operator was warned via the log line // emitted when the section was loaded; we also re-log here so each accepted bridge leaves a // breadcrumb). - if whitelist.is_empty() { + if rules.is_empty() { tracing::warn!( exit = %exit_addr, "relay running as OPEN relay (allow_extend_to is empty); accepting bridge" ); - } else if !whitelist.contains(&exit_addr) { + } else if !rules.iter().any(|r| r.matches(exit_addr)) { tracing::warn!( exit = %exit_addr, "relay rejecting bridge: exit not in allow_extend_to" diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index 9bbaa2d..53bb7f1 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -31,6 +31,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Context; +use aura_proto::PacketConnection; use aura_transport::{MultiServer, TransportMode}; use aura_tunnel::{AuraTun, RouteAction, RouteTable}; use ipnetwork::IpNetwork; @@ -275,25 +276,29 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { } }); - // v3.1: when [server.relay] is enabled, parse the whitelist once and log a warning if it is - // empty (open relay). The whitelist is a `Vec`; an empty list means "all - // addresses allowed" (dangerous; see the section's docs). + // v3.1 / v3.2: when [server.relay] is enabled, parse the allow-rules once. The rules accept + // literal `IP:port`, bare CIDR (any port), or CIDR with explicit port. An empty list means + // "all addresses allowed" (dangerous; the runtime logs a warning). let relay_enabled = cfg.server.relay.enabled; - let relay_whitelist: Vec = if relay_enabled { - let wl = cfg.relay_whitelist(); - if wl.is_empty() { + let relay_cell_padding = cfg.server.relay.cell_padding; + let relay_cell_size = cfg.server.relay.cell_size; + let relay_allow_rules: Vec = if relay_enabled { + let rules = cfg.relay_allow_rules(); + if rules.is_empty() { tracing::warn!( "[server.relay] is enabled with an EMPTY allow_extend_to — running as OPEN relay; \ every ExtendBridge request will be accepted. Set allow_extend_to to a curated list." ); } else { tracing::info!( - count = wl.len(), - "[server.relay] enabled with {} whitelisted exit address(es)", - wl.len() + count = rules.len(), + cell_padding = relay_cell_padding, + cell_size = relay_cell_size, + "[server.relay] enabled with {} allow-rule(s)", + rules.len() ); } - wl + rules } else { Vec::new() }; @@ -324,15 +329,18 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let mode = accepted.mode; let conn = accepted.conn; - // v3.1 relay rendezvous (only on UDP-mode connections; v3.1 does not bridge TCP / QUIC). + // v3.1 / v3.2 relay rendezvous (only on UDP-mode connections; relay does not bridge + // TCP / QUIC in v3.x). The relay never decodes cell padding — the bytes it forwards are + // the **inner** AEAD-encrypted ciphertext from the client to the exit; cell structure + // lives one layer below (only the exit and the client see cells). if relay_enabled && mode == TransportMode::Udp { - match relay::rendezvous(&conn, &relay_whitelist).await { + match relay::rendezvous_with_rules(&conn, &relay_allow_rules).await { RendezvousOutcome::Bridged { bridge } => { // Spawn the two forwarder tasks and skip everything else (no IP pool entry, // no router registration, no CRL push — bridged peers are opaque). tracing::info!( peer = ?peer_id, %mode, - "v3.1 relay: bridging connection to exit" + "v3.x relay: bridging connection to exit" ); let client_conn = Arc::clone(&conn); tokio::spawn(async move { @@ -361,6 +369,21 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { } } + // v3.2: when this server runs as an EXIT for cell-padded circuit clients, wrap the + // accepted inner-session conn in CellPaddingConn. Every send/recv on this conn (CRL push, + // router register, inbound forwarder) now goes through the cell wrapper so its bytes are + // padded cells end-to-end. Wrapped here (not earlier) so the relay rendezvous, which + // reads control envelopes naked on the outer connection, is not affected. + let conn: Arc = + if cfg.server.cell_padding_for_circuit_clients && mode == TransportMode::Udp { + Arc::new(crate::cells::CellPaddingConn::new( + conn, + cfg.server.relay.cell_size, + )) + } else { + conn + }; + // Pick the client id used for static-pool lookup. The certificate CN is the only // identity we can trust here; if absent (defensive — every authenticated connection has // one in practice) fall back to a unique-per-instance marker so dynamic allocation still diff --git a/crates/aura-cli/tests/cli_provision_client.rs b/crates/aura-cli/tests/cli_provision_client.rs index 4ec07f4..dda5991 100644 --- a/crates/aura-cli/tests/cli_provision_client.rs +++ b/crates/aura-cli/tests/cli_provision_client.rs @@ -178,6 +178,108 @@ fn provision_client_anti_surveillance_toggles() { let _ = std::fs::remove_dir_all(&root); } +/// v3.2: `--circuit-hops N` issues N independent client certs, each with its own UUID v4 CN. +/// The bundled `client.toml` gains a `[client.circuit]` section with N `[[client.circuit.hops]]` +/// tables. Each hop's `cert_path` / `key_path` references the freshly-issued PEM file in the +/// bundle, and each cert's CN is a distinct UUID v4. +#[test] +fn provision_client_with_v3_2_circuit_hops() { + let root = temp_dir("v32hops"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("bundle"); + + let mut opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.7", + &bundle, + ); + opts.circuit_hops = Some(3); // entry + middle + exit + let report = init::provision_client(&opts).expect("provision"); + + // Three distinct per-hop certs were issued, all with unique UUID-v4 CNs. + assert_eq!(report.circuit_hop_certs.len(), 3, "3 hop certs issued"); + let mut cns: Vec = report + .circuit_hop_certs + .iter() + .map(|(cn, _, _)| cn.clone()) + .collect(); + cns.sort(); + cns.dedup(); + assert_eq!(cns.len(), 3, "all hop CNs are distinct"); + for (cn, _, _) in &report.circuit_hop_certs { + let parsed = uuid::Uuid::parse_str(cn).expect("hop cn is a uuid"); + assert_eq!(parsed.get_version_num(), 4, "hop cn is uuid v4"); + } + for (i, (_, cert, key)) in report.circuit_hop_certs.iter().enumerate() { + assert!(cert.exists(), "hop {i} cert exists"); + assert!(key.exists(), "hop {i} key exists"); + assert!(cert + .file_name() + .unwrap() + .to_string_lossy() + .contains(&format!("circuit-hop-{i}"))); + } + + // The bundled client.toml has `[client.circuit] enabled = true` and 3 hop tables. + let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml"); + assert!(cfg.client.circuit.enabled, "[client.circuit] enabled"); + assert_eq!(cfg.client.circuit.hops.len(), 3, "3 hops in client.toml"); + // Every hop entry is the Full variant (per-hop cert/key paths). + use aura_cli::config::CircuitHop; + for (i, hop) in cfg.client.circuit.hops.iter().enumerate() { + match hop { + CircuitHop::Full { + cert_path, + key_path, + .. + } => { + let cert_str = cert_path.to_string_lossy(); + let key_str = key_path.to_string_lossy(); + assert!( + cert_str.contains(&format!("circuit-hop-{i}")), + "hop {i} cert_path references circuit-hop-{i}.crt; got {cert_str}" + ); + assert!( + key_str.contains(&format!("circuit-hop-{i}")), + "hop {i} key_path references circuit-hop-{i}.key; got {key_str}" + ); + } + _ => panic!("hop {i}: expected Full variant in rendered client.toml"), + } + } + // Cell padding is enabled by default in the v3.2 rendered config. + assert!( + cfg.client.circuit.cell_padding, + "cell_padding defaults true in v3.2 render" + ); + + let _ = std::fs::remove_dir_all(&root); +} + +/// `--circuit-hops 1` is rejected (N must be >= 2). +#[test] +fn provision_client_circuit_hops_too_few_errors() { + let root = temp_dir("v32hops_few"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("bundle"); + + let mut opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.8", + &bundle, + ); + opts.circuit_hops = Some(1); + let err = init::provision_client(&opts).unwrap_err().to_string(); + assert!(err.contains("circuit-hops"), "got: {err}"); + let _ = std::fs::remove_dir_all(&root); +} + /// A non-empty bundle directory triggers an error without `--force`. #[test] fn provision_client_refuses_non_empty_bundle() { diff --git a/crates/aura-cli/tests/multihop.rs b/crates/aura-cli/tests/multihop.rs index 8b7ea36..1732565 100644 --- a/crates/aura-cli/tests/multihop.rs +++ b/crates/aura-cli/tests/multihop.rs @@ -308,3 +308,203 @@ async fn multihop_back_compat_relay_disabled() { relay_task.abort(); } + +// ---- v3.2: 3-hop + per-hop client certs + cell padding ----------------------------------------- + +use aura_cli::cells::CellPaddingConn; +use aura_cli::circuit::HopConfig; + +const ENTRY_SAN: &str = "localhost-entry"; +const MIDDLE_SAN: &str = "localhost-middle"; +const CLIENT_ID_ENTRY: &str = "client-entry"; +const CLIENT_ID_MIDDLE: &str = "client-middle"; +const CLIENT_ID_EXIT: &str = "client-exit"; + +/// Build a [`ClientConfig`] with the given CN and expected server SAN. v3.2: a different cert / +/// CN per hop is the identity-unlinkable design. +fn client_cfg_with_cn(ca: &AuraCa, cn: &str, server_name: &str) -> ClientConfig { + let issued = ca.issue_client_cert(cn).expect("issue client cert"); + ClientConfig { + ca_cert_pem: ca.ca_cert_pem(), + client_cert_pem: issued.cert_pem, + client_key_pem: issued.key_pem, + server_name: server_name.to_string(), + } +} + +/// v3.2 3-hop end-to-end: `client → A (entry-relay) → B (middle-relay) → C (exit)`. Each hop is +/// a real Aura UdpServer on loopback. The client uses a **different** client cert per hop +/// (identity-unlinkable). The exit echoes three packets which the client must receive back +/// through three layers of AEAD encryption. +#[tokio::test(flavor = "multi_thread")] +async fn multihop_v3_2_three_hops_end_to_end() { + let ca = AuraCa::generate("Aura v3.2 3-hop Test CA").expect("ca"); + + let entry_proto = server_cfg(&ca, ENTRY_SAN); + let middle_proto = server_cfg(&ca, MIDDLE_SAN); + let exit_proto = server_cfg(&ca, EXIT_SAN); + + let entry_port = free_udp_port(); + let middle_port = free_udp_port(); + let exit_port = free_udp_port(); + let entry_addr: SocketAddr = format!("127.0.0.1:{entry_port}").parse().unwrap(); + let middle_addr: SocketAddr = format!("127.0.0.1:{middle_port}").parse().unwrap(); + let exit_addr: SocketAddr = format!("127.0.0.1:{exit_port}").parse().unwrap(); + + let entry_server = + UdpServer::bind(entry_addr, entry_proto, UdpOpts::default()).expect("bind entry"); + let middle_server = + UdpServer::bind(middle_addr, middle_proto, UdpOpts::default()).expect("bind middle"); + let exit_server = + UdpServer::bind(exit_addr, exit_proto, UdpOpts::default()).expect("bind exit"); + let entry_actual = entry_server.local_addr().expect("entry addr"); + let middle_actual = middle_server.local_addr().expect("middle addr"); + let exit_actual = exit_server.local_addr().expect("exit addr"); + + // Whitelists per hop (CIDR-aware): entry allows middle; middle allows exit. Both can be exact + // entries here; this test exercises the literal-IP:port path. + let entry_whitelist = vec![middle_actual]; + let middle_whitelist = vec![exit_actual]; + + let exit_task = tokio::spawn(spawn_exit(exit_server)); + let middle_task = tokio::spawn(spawn_relay(middle_server, middle_whitelist)); + let entry_task = tokio::spawn(spawn_relay(entry_server, entry_whitelist)); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // Per-hop client configs: distinct CN per hop, distinct server_name per hop. + let hops = vec![ + HopConfig { + addr: entry_actual, + proto_cfg: client_cfg_with_cn(&ca, CLIENT_ID_ENTRY, ENTRY_SAN), + }, + HopConfig { + addr: middle_actual, + proto_cfg: client_cfg_with_cn(&ca, CLIENT_ID_MIDDLE, MIDDLE_SAN), + }, + HopConfig { + addr: exit_actual, + proto_cfg: client_cfg_with_cn(&ca, CLIENT_ID_EXIT, EXIT_SAN), + }, + ]; + + let circuit_conn = tokio::time::timeout( + Duration::from_secs(60), + circuit::dial_circuit(&hops, UdpOpts::default()), + ) + .await + .expect("dial_circuit did not finish within 60s") + .expect("dial_circuit succeeded"); + + // peer_id is the exit's SAN — the innermost handshake authenticated the exit cert through + // every relay opaquely. + assert_eq!( + circuit_conn.peer_id(), + Some(EXIT_SAN), + "circuit.peer_id() must be the exit's SAN through 3 hops" + ); + + // Echo three packets — through THREE AEAD layers. + let payloads: Vec> = vec![ + b"hello 3-hop".to_vec(), + vec![0x77u8; 600], + (0..200u8).collect(), + ]; + for pkt in &payloads { + circuit_conn.send_packet(pkt).await.expect("circuit send"); + let echoed = tokio::time::timeout(Duration::from_secs(10), circuit_conn.recv_packet()) + .await + .expect("recv timeout") + .expect("recv from exit through 3-hop circuit"); + assert_eq!(&echoed, pkt, "echoed payload must match"); + } + + drop(circuit_conn); + let _ = tokio::time::timeout(Duration::from_secs(5), exit_task).await; + let _ = tokio::time::timeout(Duration::from_secs(5), middle_task).await; + let _ = tokio::time::timeout(Duration::from_secs(5), entry_task).await; +} + +/// v3.2: smoke-test the [`CellPaddingConn`] wrap around a 2-hop circuit. The exit also wraps its +/// `Accepted.conn` in a `CellPaddingConn`; the bytes the client sends are padded cells, ferried +/// opaquely through the relay, and unwrapped by the exit. We exchange three payloads of varying +/// (small) sizes through the padded layer. +#[tokio::test(flavor = "multi_thread")] +async fn multihop_v3_2_cell_padding_smoke() { + let ca = AuraCa::generate("Aura v3.2 cell-padding Test CA").expect("ca"); + let exit_proto = server_cfg(&ca, EXIT_SAN); + let relay_proto = server_cfg(&ca, RELAY_SAN); + let client_proto = client_cfg(&ca, EXIT_SAN); + + let exit_port = free_udp_port(); + let relay_port = free_udp_port(); + let exit_addr: SocketAddr = format!("127.0.0.1:{exit_port}").parse().unwrap(); + let relay_addr: SocketAddr = format!("127.0.0.1:{relay_port}").parse().unwrap(); + + let exit_server = + UdpServer::bind(exit_addr, exit_proto, UdpOpts::default()).expect("bind exit"); + let relay_server = + UdpServer::bind(relay_addr, relay_proto, UdpOpts::default()).expect("bind relay"); + let exit_actual = exit_server.local_addr().expect("exit addr"); + let relay_actual = relay_server.local_addr().expect("relay addr"); + + let whitelist = vec![exit_actual]; + + // Exit echoes three CELL-PADDED packets back. The CellPaddingConn wrap on the exit's side + // means recv_packet returns the original (unpadded) payload, and send_packet pads it again. + let cell_size = 512; + let exit_task = tokio::spawn(async move { + let conn = exit_server.accept().await.expect("exit accept"); + drop(exit_server); + let conn: Arc = Arc::new(conn); + let wrapped = Arc::new(CellPaddingConn::new(conn, cell_size)); + for _ in 0..3 { + match wrapped.recv_packet().await { + Ok(pkt) => { + if wrapped.send_packet(&pkt).await.is_err() { + return; + } + } + Err(_) => return, + } + } + }); + let relay_task = tokio::spawn(spawn_relay(relay_server, whitelist)); + + tokio::time::sleep(Duration::from_millis(20)).await; + + let circuit_conn = tokio::time::timeout( + Duration::from_secs(30), + circuit::dial_circuit_with_relay_name( + &[relay_actual, exit_actual], + client_proto, + UdpOpts::default(), + Some(RELAY_SAN), + ), + ) + .await + .expect("dial_circuit did not finish within 30s") + .expect("dial_circuit succeeded"); + + // Wrap the client side in CellPaddingConn so its sends become cells. + let padded: Arc = + Arc::new(CellPaddingConn::new(circuit_conn.into_dyn(), cell_size)); + + let payloads: Vec> = vec![ + b"tiny".to_vec(), + vec![0xEFu8; 100], + b"another payload that fits inside cell".to_vec(), + ]; + for pkt in &payloads { + padded.send_packet(pkt).await.expect("padded send"); + let echoed = tokio::time::timeout(Duration::from_secs(10), padded.recv_packet()) + .await + .expect("recv timeout") + .expect("recv from padded exit"); + assert_eq!(&echoed, pkt, "padded roundtrip preserves payload"); + } + + drop(padded); + let _ = tokio::time::timeout(Duration::from_secs(5), exit_task).await; + let _ = tokio::time::timeout(Duration::from_secs(5), relay_task).await; +}