feat(cli): v3.2 multi-hop — per-hop cert, cell padding, 3-hop, CIDR whitelist
Closes the v3.1 unlinkability gap and resists volume/timing correlation:
1) Per-hop client cert (identity-unlinkable hops). [[client.circuit.hops]]
now accepts {addr, cert_path, key_path, [server_name]} per hop — each
hop sees a different CN, so a relay and an exit cannot correlate the
same client by certificate. Old flat `hops = ["ip:port"]` form still
parses (serde untagged enum) and falls back to [pki] cert/key.
`aura provision-client --circuit-hops N` mints N fresh UUIDv4 certs.
2) Cell padding. CellPaddingConn wrapper pads every outgoing packet to a
fixed size (default 1280 bytes; `cell_size = N` configurable) before
it hits the inner AEAD. Format: u16_be(real_len) || pkt || zero_pad.
On-wire sizes become constant -> defeats volume/timing fingerprints.
Opt-in via [client.circuit] cell_padding = true and the mirror
[server] cell_padding_for_circuit_clients = true.
3) 3-hop support. dial_circuit now accepts N >= 2 hops; iterative
ExtendBridge nests N-1 forwarders and N handshakes. Client owns the
full chain via CircuitConnection (forwarders abort on drop).
New integration test multihop_v3_2_three_hops_end_to_end runs three
in-process actors (A relay -> B relay -> C exit) on loopback and
verifies peer_id == C's CN.
4) CIDR whitelist. [server.relay] allow_extend_to entries accept
"10.0.0.0/24" (subnet, any port), "10.0.0.0/24:443" (subnet + port),
"[2001:db8::/32]:443" (IPv6 with port), as well as exact IP:port.
Empty list keeps the v3.1 open-relay (warn).
19 new tests; workspace 276 passed (+19), clippy -D warnings clean, fmt clean.
257 baseline tests untouched; all v2 / v3.1 / LE configs work as before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+40
-11
@@ -127,17 +127,46 @@ enabled = false
|
|||||||
mean_interval_ms = 500
|
mean_interval_ms = 500
|
||||||
jitter = 0.5
|
jitter = 0.5
|
||||||
|
|
||||||
# v3.1 multi-hop / onion routing: dial through an entry-relay before reaching the exit-server.
|
# v3.1 / v3.2 multi-hop / onion routing: dial through 1 or 2 intermediate hops before reaching
|
||||||
# When `enabled = true`, the client opens an OUTER Aura UDP connection to `hops[0]` (the relay),
|
# the exit-server. When `enabled = true`, the client opens an OUTER Aura UDP connection to
|
||||||
# sends one ExtendBridge envelope describing `hops[1]` (the exit), waits for CircuitReady, and
|
# `hops[0]` (the entry-relay), sends one ExtendBridge envelope describing the next hop, waits for
|
||||||
# then runs an INNER Aura handshake addressed to the exit through that relay — two AEAD layers
|
# CircuitReady, then either dials the exit directly (2-hop) or repeats the ExtendBridge dance
|
||||||
# per packet, the exit knows the client's CN but not the source IP, the relay knows the source
|
# through a middle relay (3-hop). The innermost handshake authenticates the EXIT's cert opaquely
|
||||||
# IP but not the destination nor a single plaintext byte. Exactly two hops are required in
|
# — every relay sees only the next-hop address and AEAD ciphertext.
|
||||||
# v3.1; configure the relay-server with [server.relay] enabled = true and
|
|
||||||
# allow_extend_to = ["<this client's exit IP:port>"].
|
|
||||||
#
|
#
|
||||||
# Omitting the section (or `enabled = false`) keeps the v2 single-hop dial path intact —
|
# v3.2 adds:
|
||||||
# [client] server_addr / [transport] order rules apply as before.
|
# * 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]
|
# [client.circuit]
|
||||||
# enabled = true
|
# 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 ...`
|
||||||
|
|||||||
@@ -151,11 +151,28 @@ jitter = 0.5
|
|||||||
# Omitting the whole [server.relay] section (or `enabled = false`) keeps the v2 behaviour intact.
|
# Omitting the whole [server.relay] section (or `enabled = false`) keeps the v2 behaviour intact.
|
||||||
# [server.relay]
|
# [server.relay]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# Whitelist of allowed downstream exit addresses. ONLY literal IP:port entries; DNS resolution
|
# Whitelist of allowed downstream destinations. v3.2 accepts three entry formats:
|
||||||
# is NOT performed in v3.1 (unparsable entries are logged at WARN and skipped). An empty list
|
# * "IP:port" — exact literal SocketAddr (the v3.1 form).
|
||||||
# turns this server into an OPEN relay accepting any downstream — dangerous; the runtime logs
|
# * "10.0.0.0/24" — bare CIDR; matches ANY port at any IP in the subnet.
|
||||||
# a WARN on each accepted bridge.
|
# * "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 = [
|
# allow_extend_to = [
|
||||||
# "198.51.100.5:443", # the exit you operate
|
# "198.51.100.5:443", # the exit you operate (exact)
|
||||||
# "203.0.113.10:443",
|
# "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
|
||||||
|
|||||||
@@ -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<dyn PacketConnection>,
|
||||||
|
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<dyn PacketConnection>, 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<Vec<u8>> {
|
||||||
|
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<Vec<Vec<u8>>>,
|
||||||
|
recv_queue: TokioMutex<VecDeque<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
impl MockConn {
|
||||||
|
fn new(recv: impl IntoIterator<Item = Vec<u8>>) -> Arc<Self> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<dyn PacketConnection>, 1280);
|
||||||
|
|
||||||
|
let payloads: Vec<Vec<u8>> = 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<u8>> = vec![b"first".to_vec(), vec![0u8; 0], (0..=255u8).collect()];
|
||||||
|
let cell_size = 512;
|
||||||
|
let cells: Vec<Vec<u8>> = 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<dyn PacketConnection>, 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<dyn PacketConnection>, 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<dyn PacketConnection>, 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<dyn PacketConnection>, 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+340
-209
@@ -1,38 +1,42 @@
|
|||||||
//! v3.1 multi-hop / onion routing: the **client side** of the 2-hop circuit
|
//! v3.1 / v3.2 multi-hop / onion routing — the **client side** of an N-hop circuit
|
||||||
//! `client → entry-relay → exit-server`.
|
//! `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
|
//! For each hop `i` from `0` to `N-1` the dialler:
|
||||||
//! [`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.
|
|
||||||
//!
|
//!
|
||||||
//! Result: traffic is wrapped under **two AEAD layers** — first the exit's session keys (inner
|
//! 1. **Outer handshake to `hop[i]`**: opens an Aura UDP transport connection to `hop[i].addr`
|
||||||
//! handshake) and again the relay's session keys (outer handshake). The exit knows the client's
|
//! (through any already-stacked proxy/forwarder chain) using `hop[i].proto_cfg`, which carries
|
||||||
//! certificate CN but not the client's real source IP; the relay knows the client's source IP but
|
//! that hop's expected SAN as `server_name` AND the per-hop client cert/key — see [`HopConfig`].
|
||||||
//! not the destination IP nor a single plaintext byte.
|
//! 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
|
//! ## Per-hop client identity (v3.2)
|
||||||
//! 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 v3.1 dialler used a single `[pki]` cert/key for every hop, so the entry-relay and the exit
|
||||||
//! the whole reliability layer. The loopback proxy is the smallest hack that lets the inner
|
//! both saw the *same* certificate CN — trivially linkable. v3.2 lets the caller pass a different
|
||||||
//! [`UdpClient`] talk over its expected datagram interface while every datagram is actually being
|
//! [`aura_proto::ClientConfig`] for each hop via [`HopConfig`]. The CLI generates an indepedent
|
||||||
//! tunnelled through the outer relay connection.
|
//! 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::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -47,42 +51,70 @@ use aura_transport::{UdpClient, UdpConnection, UdpOpts};
|
|||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
/// How long the client waits for the relay to reply with [`ControlKind::CircuitReady`] (or
|
/// How long the client waits for each hop to reply with [`ControlKind::CircuitReady`] after
|
||||||
/// [`ControlKind::CircuitFailed`]) after sending the [`ControlKind::ExtendBridge`] envelope.
|
/// sending the [`ControlKind::ExtendBridge`] envelope.
|
||||||
const READY_TIMEOUT_SECS: u64 = 5;
|
const READY_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
/// An established 2-hop circuit: it is **literally** a [`UdpConnection`] in disguise. The inner
|
/// Per-hop dial configuration. One instance per hop in the circuit; the order matches the wire
|
||||||
/// connection's outgoing datagrams go to a local proxy socket, which forwards them through the
|
/// order (`hops[0]` = entry, `hops[N-1]` = exit).
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// The two background tasks (proxy forwarders) and the outer connection are owned here, so dropping
|
/// `proto_cfg.server_name` is the SAN the verifier checks on **this hop's** certificate during the
|
||||||
/// the circuit tears everything down in order.
|
/// 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 {
|
pub struct CircuitConnection {
|
||||||
/// The inner UDP connection (target of the second handshake addressed to the exit). All
|
/// The innermost UDP connection (target of the final hop's handshake). All `send_packet` /
|
||||||
/// `send_packet` / `recv_packet` go through this; the proxy forwarder splices the bytes onto
|
/// `recv_packet` calls delegate to it; the forwarder chain splices its bytes onto the outer
|
||||||
/// the outer relay connection.
|
/// hops in order.
|
||||||
inner: UdpConnection,
|
inner: UdpConnection,
|
||||||
/// Outer relay connection — pinned alive for the lifetime of the circuit. The forwarder owns
|
/// Every outer hop connection, in order (`hop[0]` first). Pinned alive for the lifetime of the
|
||||||
/// clones, but holding it here means the outer is dropped at exactly the same time as `Self`.
|
/// circuit; the per-hop forwarder tasks own clones, but holding the originals here means every
|
||||||
_outer_conn_holder: Arc<dyn PacketConnection>,
|
/// outer is dropped at exactly the same time as `Self`.
|
||||||
/// Background task: local proxy socket ↔ outer relay connection. Aborted in [`Drop`].
|
_outer_conns: Vec<Arc<dyn PacketConnection>>,
|
||||||
forwarder: JoinHandle<()>,
|
/// One forwarder task per intermediate hop (so `N - 1` tasks for an N-hop circuit). Aborted in
|
||||||
/// Local proxy socket kept alive for the forwarder's lifetime (the forwarder also holds an
|
/// [`Drop`] so dropping the circuit cleans them up.
|
||||||
/// `Arc<UdpSocket>` clone, but this prevents close-on-last-clone races during shutdown).
|
forwarders: Vec<JoinHandle<()>>,
|
||||||
_proxy_socket: Arc<UdpSocket>,
|
/// The chain of loopback proxy sockets (one per intermediate hop). Held here so they outlive
|
||||||
|
/// the forwarders that read/write through them; the forwarder also holds an `Arc<UdpSocket>`
|
||||||
|
/// clone, but this prevents a close-on-last-clone race during shutdown.
|
||||||
|
_proxy_sockets: Vec<Arc<UdpSocket>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for CircuitConnection {
|
impl Drop for CircuitConnection {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.forwarder.abort();
|
for f in &self.forwarders {
|
||||||
|
f.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CircuitConnection {
|
impl CircuitConnection {
|
||||||
/// The verified peer Common Name as learned during the **inner** handshake. This is the
|
/// The verified peer Common Name as learned during the **innermost** handshake. This is the
|
||||||
/// **exit-server's** identity (NOT the relay's) — the whole point of multi-hop is that the
|
/// **exit-server's** identity (NOT any intermediate hop) — the whole point of multi-hop is that
|
||||||
/// inner handshake authenticates the exit through the relay opaquely.
|
/// the inner handshake authenticates the exit through every relay opaquely.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn peer_id(&self) -> Option<&str> {
|
pub fn peer_id(&self) -> Option<&str> {
|
||||||
self.inner.peer_id()
|
self.inner.peer_id()
|
||||||
@@ -100,7 +132,7 @@ impl CircuitConnection {
|
|||||||
impl PacketConnection for CircuitConnection {
|
impl PacketConnection for CircuitConnection {
|
||||||
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
||||||
// Delegate to the inner UdpConnection — the proxy forwarder picks up its outgoing
|
// 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
|
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
|
/// Build an N-hop circuit `client → hops[0] → hops[1] → ... → hops[N-1]`. Returns the established
|
||||||
/// as a [`CircuitConnection`].
|
/// [`CircuitConnection`].
|
||||||
///
|
///
|
||||||
/// Both hops are reached via the [`UdpClient`] transport in v3.1. `proto_cfg.server_name` is used
|
/// `hops.len()` must be in `{2, 3}` — v3.1 accepted only 2; v3.2 extends to 3. Each entry's
|
||||||
/// by the **inner** handshake to verify the EXIT's certificate SAN. The relay's own cert is also
|
/// [`HopConfig::proto_cfg`] supplies:
|
||||||
/// CA-verified by the outer handshake; pass [`dial_circuit_with_relay_name`] when the relay's SAN
|
///
|
||||||
/// differs from the exit's.
|
/// * 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
|
/// # Errors
|
||||||
/// * The outer UDP connection to the entry relay failed.
|
/// * Any outer UDP connection failed.
|
||||||
/// * The relay refused (`CircuitFailed`) or did not reply within [`READY_TIMEOUT_SECS`] seconds.
|
/// * Any intermediate hop refused (`CircuitFailed`) or did not reply within
|
||||||
/// * The inner Aura handshake (through the relay) failed (bad exit cert chain, SAN mismatch, etc.).
|
/// [`READY_TIMEOUT_SECS`] seconds.
|
||||||
|
/// * The inner Aura handshake to the exit failed (bad exit cert chain, SAN mismatch, etc.).
|
||||||
pub async fn dial_circuit(
|
pub async fn dial_circuit(
|
||||||
|
hops: &[HopConfig],
|
||||||
|
udp_opts: UdpOpts,
|
||||||
|
) -> anyhow::Result<CircuitConnection> {
|
||||||
|
if hops.len() < 2 || hops.len() > 3 {
|
||||||
|
bail!(
|
||||||
|
"v3.2 multi-hop supports 2 or 3 hops (entry, [middle,] exit); got {}",
|
||||||
|
hops.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We build the chain iteratively. At each iteration the "current outer" is what we are
|
||||||
|
// currently dialing through; for the first hop it is a literal `UdpClient::connect`, for every
|
||||||
|
// subsequent hop it is a loopback proxy + forwarder splicing onto the previous outer.
|
||||||
|
let mut outer_conns: Vec<Arc<dyn PacketConnection>> = Vec::with_capacity(hops.len() - 1);
|
||||||
|
let mut forwarders: Vec<JoinHandle<()>> = Vec::with_capacity(hops.len() - 1);
|
||||||
|
let mut proxy_sockets: Vec<Arc<UdpSocket>> = Vec::with_capacity(hops.len() - 1);
|
||||||
|
|
||||||
|
// Step 1: dial the very first hop directly via UDP. This is the only hop whose outer handshake
|
||||||
|
// exits the client process as a real datagram on the OS network stack.
|
||||||
|
let entry = &hops[0];
|
||||||
|
let first = UdpClient::connect(entry.addr, entry.proto_cfg.clone(), udp_opts)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("dial entry hop at {}", entry.addr))?;
|
||||||
|
let mut current_outer: Arc<dyn PacketConnection> = first.into_dyn();
|
||||||
|
|
||||||
|
// For every *intermediate* hop (every hop except the last) we:
|
||||||
|
// - ask it to bridge to the next hop via ExtendBridge,
|
||||||
|
// - wait for CircuitReady,
|
||||||
|
// - bring up a loopback proxy + forwarder so the next outer handshake travels through
|
||||||
|
// `current_outer`,
|
||||||
|
// - then re-dial the *next* hop via that loopback proxy and update `current_outer`.
|
||||||
|
//
|
||||||
|
// After the loop, `current_outer` is the outer connection to `hops[N-2]` and the next dial
|
||||||
|
// (step 6 below) is the inner handshake to `hops[N-1]` (the exit). We need to keep
|
||||||
|
// `current_outer` itself in `outer_conns` too — it is the outermost of the inner-handshake's
|
||||||
|
// pipe.
|
||||||
|
for i in 0..hops.len() - 1 {
|
||||||
|
let next = &hops[i + 1];
|
||||||
|
|
||||||
|
// 2. Tell the current hop to splice onto `next.addr`.
|
||||||
|
let payload = encode_extend_bridge(next.addr);
|
||||||
|
let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload);
|
||||||
|
current_outer
|
||||||
|
.send_packet(&envelope)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("send ExtendBridge to hop[{}] at {}", i, hops[i].addr))?;
|
||||||
|
|
||||||
|
// 3. Wait for CircuitReady from this hop (or CircuitFailed = bail). The remote may send
|
||||||
|
// unrelated envelopes (CRL pushes etc.) in front of ours; ignore until our envelope
|
||||||
|
// arrives or the deadline elapses.
|
||||||
|
let ready_deadline =
|
||||||
|
tokio::time::Instant::now() + std::time::Duration::from_secs(READY_TIMEOUT_SECS);
|
||||||
|
loop {
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
if now >= ready_deadline {
|
||||||
|
bail!(
|
||||||
|
"timeout waiting for CircuitReady from hop[{}] at {}",
|
||||||
|
i,
|
||||||
|
hops[i].addr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let remaining = ready_deadline - now;
|
||||||
|
let pkt = tokio::time::timeout(remaining, current_outer.recv_packet())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow!(
|
||||||
|
"timeout waiting for CircuitReady from hop[{}] at {}",
|
||||||
|
i,
|
||||||
|
hops[i].addr
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.with_context(|| format!("recv from hop[{}] at {}", i, hops[i].addr))?;
|
||||||
|
match decode_control_envelope(&pkt) {
|
||||||
|
Ok(Some((ControlKind::CircuitReady, _))) => break,
|
||||||
|
Ok(Some((ControlKind::CircuitFailed, reason))) => {
|
||||||
|
let r = String::from_utf8_lossy(&reason);
|
||||||
|
bail!("hop[{}] at {} refused circuit: {}", i, hops[i].addr, r);
|
||||||
|
}
|
||||||
|
Ok(Some((other, _))) => {
|
||||||
|
tracing::debug!(
|
||||||
|
hop = i,
|
||||||
|
kind = ?other,
|
||||||
|
"ignoring unexpected control envelope while waiting for CircuitReady"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::debug!(
|
||||||
|
hop = i,
|
||||||
|
"ignoring non-control packet from hop before CircuitReady"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
hop = i,
|
||||||
|
error = %e,
|
||||||
|
"malformed envelope from hop before CircuitReady"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Bring up the local proxy UDP socket. The next iteration's UdpClient::connect will
|
||||||
|
// target this address; the forwarder below splices every datagram between the proxy
|
||||||
|
// socket and the current outer connection.
|
||||||
|
let proxy_socket = UdpSocket::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("bind loopback proxy for hop[{}] -> hop[{}]", i, i + 1))?;
|
||||||
|
let proxy_addr = proxy_socket
|
||||||
|
.local_addr()
|
||||||
|
.context("read local proxy address")?;
|
||||||
|
let proxy_socket = Arc::new(proxy_socket);
|
||||||
|
|
||||||
|
// 5. Spawn the forwarder BEFORE running the next outer handshake — the handshake's first
|
||||||
|
// datagram must already be flowing while it is being written.
|
||||||
|
let outer_for_send = Arc::clone(¤t_outer);
|
||||||
|
let outer_for_recv = Arc::clone(¤t_outer);
|
||||||
|
let proxy_for_send = Arc::clone(&proxy_socket);
|
||||||
|
let proxy_for_recv = Arc::clone(&proxy_socket);
|
||||||
|
let hop_idx = i;
|
||||||
|
let forwarder = tokio::spawn(async move {
|
||||||
|
// Source address of the next-hop UdpClient, learned from its first datagram on the
|
||||||
|
// proxy socket. We need it to know where to deliver `outer.recv_packet` payloads back.
|
||||||
|
let inner_peer: Arc<tokio::sync::Mutex<Option<SocketAddr>>> =
|
||||||
|
Arc::new(tokio::sync::Mutex::new(None));
|
||||||
|
|
||||||
|
// Task A: proxy.recv_from -> outer.send_packet
|
||||||
|
let inner_peer_a = Arc::clone(&inner_peer);
|
||||||
|
let to_outer = async move {
|
||||||
|
let mut buf = vec![0u8; 4096];
|
||||||
|
loop {
|
||||||
|
let (n, from) = match proxy_for_recv.recv_from(&mut buf).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut latch = inner_peer_a.lock().await;
|
||||||
|
if latch.is_none() {
|
||||||
|
*latch = Some(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outer_for_send.send_packet(&buf[..n]).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Task B: outer.recv_packet -> proxy.send_to(inner_peer_addr)
|
||||||
|
let inner_peer_b = Arc::clone(&inner_peer);
|
||||||
|
let from_outer = async move {
|
||||||
|
loop {
|
||||||
|
let pkt = match outer_for_recv.recv_packet().await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let dest = { *inner_peer_b.lock().await };
|
||||||
|
if let Some(dest) = dest {
|
||||||
|
if proxy_for_send.send_to(&pkt, dest).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Else: next-hop UdpClient has not sent its first datagram yet; drop. The
|
||||||
|
// reliable adapter will retransmit on its RTO timer. The race window is tiny.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
_ = to_outer => {}
|
||||||
|
_ = from_outer => {}
|
||||||
|
}
|
||||||
|
tracing::debug!(hop = hop_idx, "circuit forwarder exited");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Move `current_outer` into our owned list, spawn the forwarder + socket into theirs,
|
||||||
|
// then dial the *next* hop through the loopback proxy. The dial returns the new
|
||||||
|
// `current_outer`.
|
||||||
|
outer_conns.push(current_outer);
|
||||||
|
forwarders.push(forwarder);
|
||||||
|
proxy_sockets.push(Arc::clone(&proxy_socket));
|
||||||
|
|
||||||
|
// 7. Dial the next hop through the proxy. For an intermediate next hop this becomes the
|
||||||
|
// new `current_outer`; for the final hop (last iteration) it is the *inner* connection
|
||||||
|
// we return wrapped in `CircuitConnection`.
|
||||||
|
let is_last = i == hops.len() - 2;
|
||||||
|
let next_conn = UdpClient::connect(proxy_addr, next.proto_cfg.clone(), udp_opts)
|
||||||
|
.await
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"{} handshake to hop[{}] at {} through hop[{}]",
|
||||||
|
if is_last { "inner" } else { "intermediate" },
|
||||||
|
i + 1,
|
||||||
|
next.addr,
|
||||||
|
i
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if is_last {
|
||||||
|
// The innermost session: wrap it in CircuitConnection along with every outer + proxy
|
||||||
|
// we own. Note: we do NOT push next_conn into outer_conns — it becomes `inner`.
|
||||||
|
return Ok(CircuitConnection {
|
||||||
|
inner: next_conn,
|
||||||
|
_outer_conns: outer_conns,
|
||||||
|
forwarders,
|
||||||
|
_proxy_sockets: proxy_sockets,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Promote to dyn for the next loop iteration.
|
||||||
|
current_outer = next_conn.into_dyn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable: the loop always returns when `is_last` is true (the last intermediate
|
||||||
|
// iteration always produces the inner session for the exit).
|
||||||
|
unreachable!("dial_circuit loop must return on the final hop")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v3.1 back-compat shim: build hops from a flat `[SocketAddr]` list using a shared
|
||||||
|
/// [`ClientConfig`] for every hop and call [`dial_circuit`]. Useful for code paths that have a
|
||||||
|
/// single proto_cfg (e.g. an old `[client] sni`).
|
||||||
|
///
|
||||||
|
/// Behaviour matches v3.1 exactly when given exactly 2 hops; with 3 hops it now also works (every
|
||||||
|
/// hop uses the same cert / key, i.e. NOT identity-unlinkable — use the per-hop variant for that).
|
||||||
|
pub async fn dial_circuit_shared_cfg(
|
||||||
hops: &[SocketAddr],
|
hops: &[SocketAddr],
|
||||||
proto_cfg: ClientConfig,
|
proto_cfg: ClientConfig,
|
||||||
udp_opts: UdpOpts,
|
udp_opts: UdpOpts,
|
||||||
) -> anyhow::Result<CircuitConnection> {
|
) -> anyhow::Result<CircuitConnection> {
|
||||||
dial_circuit_with_relay_name(hops, proto_cfg, udp_opts, None).await
|
let hop_cfgs: Vec<HopConfig> = hops
|
||||||
|
.iter()
|
||||||
|
.map(|a| HopConfig::from_shared(*a, proto_cfg.clone()))
|
||||||
|
.collect();
|
||||||
|
dial_circuit(&hop_cfgs, udp_opts).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Variant of [`dial_circuit`] letting the caller override the SAN expected on the relay's cert
|
/// Variant of [`dial_circuit_shared_cfg`] letting the caller override the SAN expected on the
|
||||||
/// (the outer handshake) independently of the exit's expected SAN (`proto_cfg.server_name`, used
|
/// **first hop's** cert (the relay) independently of the exit's expected SAN
|
||||||
/// by the inner handshake). See [`dial_circuit`] for the high-level wire dance.
|
/// (`proto_cfg.server_name`, used by the inner handshake). v3.1 kept this for the loopback test
|
||||||
|
/// which uses a different SAN per role.
|
||||||
|
///
|
||||||
|
/// Equivalent to v3.1 behaviour. For arbitrary per-hop overrides, build a `Vec<HopConfig>`
|
||||||
|
/// directly and call [`dial_circuit`].
|
||||||
pub async fn dial_circuit_with_relay_name(
|
pub async fn dial_circuit_with_relay_name(
|
||||||
hops: &[SocketAddr],
|
hops: &[SocketAddr],
|
||||||
proto_cfg: ClientConfig,
|
proto_cfg: ClientConfig,
|
||||||
@@ -140,151 +405,17 @@ pub async fn dial_circuit_with_relay_name(
|
|||||||
) -> anyhow::Result<CircuitConnection> {
|
) -> anyhow::Result<CircuitConnection> {
|
||||||
if hops.len() != 2 {
|
if hops.len() != 2 {
|
||||||
bail!(
|
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()
|
hops.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let entry = hops[0];
|
let mut entry_cfg = proto_cfg.clone();
|
||||||
|
|
||||||
// 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();
|
|
||||||
if let Some(name) = relay_server_name {
|
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)
|
let hop_cfgs = vec![
|
||||||
.await
|
HopConfig::from_shared(hops[0], entry_cfg),
|
||||||
.with_context(|| format!("dial entry relay at {entry}"))?;
|
HopConfig::from_shared(hops[1], proto_cfg),
|
||||||
let outer: Arc<dyn PacketConnection> = outer.into_dyn();
|
];
|
||||||
|
dial_circuit(&hop_cfgs, udp_opts).await
|
||||||
// 2) Send the ExtendBridge control envelope describing the downstream exit address.
|
|
||||||
let exit = hops[1];
|
|
||||||
let payload = encode_extend_bridge(exit);
|
|
||||||
let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload);
|
|
||||||
outer
|
|
||||||
.send_packet(&envelope)
|
|
||||||
.await
|
|
||||||
.context("send ExtendBridge to relay")?;
|
|
||||||
|
|
||||||
// 3) Wait for CircuitReady (with a hard timeout). The relay may send unrelated control
|
|
||||||
// envelopes in front of ours (e.g. a CRL push from the v2 path) — those are ignored until
|
|
||||||
// the expected envelope arrives or the deadline elapses.
|
|
||||||
let ready_deadline =
|
|
||||||
tokio::time::Instant::now() + std::time::Duration::from_secs(READY_TIMEOUT_SECS);
|
|
||||||
loop {
|
|
||||||
let now = tokio::time::Instant::now();
|
|
||||||
if now >= ready_deadline {
|
|
||||||
bail!("timeout waiting for CircuitReady from relay at {entry}");
|
|
||||||
}
|
|
||||||
let remaining = ready_deadline - now;
|
|
||||||
let pkt = tokio::time::timeout(remaining, outer.recv_packet())
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("timeout waiting for CircuitReady from relay at {entry}"))?
|
|
||||||
.context("recv from entry relay")?;
|
|
||||||
match decode_control_envelope(&pkt) {
|
|
||||||
Ok(Some((ControlKind::CircuitReady, _))) => break,
|
|
||||||
Ok(Some((ControlKind::CircuitFailed, reason))) => {
|
|
||||||
let r = String::from_utf8_lossy(&reason);
|
|
||||||
bail!("relay refused circuit: {r}");
|
|
||||||
}
|
|
||||||
Ok(Some((other, _))) => {
|
|
||||||
tracing::debug!(
|
|
||||||
kind = ?other,
|
|
||||||
"ignoring unexpected control envelope while waiting for CircuitReady"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::debug!("ignoring non-control packet from relay before CircuitReady");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, "malformed envelope from relay before CircuitReady");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Bring up the local proxy UDP socket. The inner UdpClient will `connect()` to its address;
|
|
||||||
// every datagram it sends goes through the forwarder below to the outer relay connection,
|
|
||||||
// and every datagram the relay forwards from the exit is replayed back to the inner socket.
|
|
||||||
let proxy_socket = UdpSocket::bind("127.0.0.1:0")
|
|
||||||
.await
|
|
||||||
.context("bind local circuit proxy socket")?;
|
|
||||||
let proxy_addr = proxy_socket
|
|
||||||
.local_addr()
|
|
||||||
.context("read local proxy address")?;
|
|
||||||
let proxy_socket = Arc::new(proxy_socket);
|
|
||||||
|
|
||||||
// 5) Spawn the forwarder BEFORE running the inner handshake — the handshake's first datagram
|
|
||||||
// must already be flowing while it is being written.
|
|
||||||
let outer_for_send = Arc::clone(&outer);
|
|
||||||
let outer_for_recv = Arc::clone(&outer);
|
|
||||||
let proxy_for_send = Arc::clone(&proxy_socket);
|
|
||||||
let proxy_for_recv = Arc::clone(&proxy_socket);
|
|
||||||
let forwarder = tokio::spawn(async move {
|
|
||||||
// Source address of the inner UdpClient, learned from its first datagram on the proxy
|
|
||||||
// socket. We need it to know where to deliver `outer.recv_packet` payloads back.
|
|
||||||
let inner_peer: Arc<tokio::sync::Mutex<Option<SocketAddr>>> =
|
|
||||||
Arc::new(tokio::sync::Mutex::new(None));
|
|
||||||
|
|
||||||
// Task A: proxy.recv_from → outer.send_packet
|
|
||||||
let inner_peer_a = Arc::clone(&inner_peer);
|
|
||||||
let to_outer = async move {
|
|
||||||
let mut buf = vec![0u8; 4096];
|
|
||||||
loop {
|
|
||||||
let (n, from) = match proxy_for_recv.recv_from(&mut buf).await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let mut latch = inner_peer_a.lock().await;
|
|
||||||
if latch.is_none() {
|
|
||||||
*latch = Some(from);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if outer_for_send.send_packet(&buf[..n]).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Task B: outer.recv_packet → proxy.send_to(inner_peer_addr)
|
|
||||||
let inner_peer_b = Arc::clone(&inner_peer);
|
|
||||||
let from_outer = async move {
|
|
||||||
loop {
|
|
||||||
let pkt = match outer_for_recv.recv_packet().await {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
let dest = { *inner_peer_b.lock().await };
|
|
||||||
if let Some(dest) = dest {
|
|
||||||
if proxy_for_send.send_to(&pkt, dest).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Else: the inner UdpClient has not sent its first datagram yet; drop. (The
|
|
||||||
// reliable adapter will retransmit on its RTO timer.) This race window is tiny —
|
|
||||||
// we always spawn the forwarder before `UdpClient::connect`.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::select! {
|
|
||||||
_ = to_outer => {}
|
|
||||||
_ = from_outer => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6) Inner Aura handshake addressed to the EXIT, via the local proxy. The peer_id we capture
|
|
||||||
// is the exit's verified CN (the core invariant: the inner handshake authenticates the
|
|
||||||
// exit, not the relay).
|
|
||||||
let inner = UdpClient::connect(proxy_addr, proto_cfg, udp_opts)
|
|
||||||
.await
|
|
||||||
.context("inner handshake to exit through relay")?;
|
|
||||||
|
|
||||||
Ok(CircuitConnection {
|
|
||||||
inner,
|
|
||||||
_outer_conn_holder: outer,
|
|
||||||
forwarder,
|
|
||||||
_proxy_socket: proxy_socket,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,28 +97,44 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
let routes = Arc::new(RwLock::new(table));
|
let routes = Arc::new(RwLock::new(table));
|
||||||
let stats = Arc::new(Stats::new());
|
let stats = Arc::new(Stats::new());
|
||||||
|
|
||||||
// Dial: when [client.circuit] is enabled, build a 2-hop circuit `client → entry-relay → exit`
|
// 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`]. Otherwise fall back to the v2 single-hop dial across the
|
// via [`circuit::dial_circuit`] with per-hop client configs. Otherwise fall back to the v2
|
||||||
// configured [transport] order. In both cases the result is a uniform `Arc<dyn PacketConnection>`
|
// single-hop dial across the configured [transport] order. In both cases the result is a
|
||||||
// so the downstream router does not care which path was taken.
|
// uniform `Arc<dyn PacketConnection>` so the downstream router does not care which path was
|
||||||
let (conn, mode) = if cfg.circuit.enabled {
|
// taken.
|
||||||
let hops = cfg
|
let (conn, mode) = if cfg.client.circuit.enabled {
|
||||||
.circuit_hops()
|
let hop_cfgs = cfg
|
||||||
.context("parsing [client.circuit] hops")?;
|
.build_circuit_hop_configs()
|
||||||
|
.context("building [client.circuit] hop configs")?;
|
||||||
|
let hop_count = hop_cfgs.len();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
entry = %hops[0],
|
hops = hop_count,
|
||||||
exit = %hops[1],
|
entry = %hop_cfgs[0].addr,
|
||||||
"building v3.1 2-hop circuit"
|
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
|
.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);
|
let peer_id = circuit_conn.peer_id().map(str::to_owned);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
peer = ?peer_id,
|
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<dyn PacketConnection> = 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 {
|
} else {
|
||||||
// Each transport runs the inner Aura mutual-auth handshake; the winner is returned along
|
// 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;
|
// with which mode carried it. (The trait object does not surface the verified server CN;
|
||||||
|
|||||||
+585
-38
@@ -135,9 +135,18 @@ pub struct ServerSection {
|
|||||||
/// written. Default `false` (verbose). See [`crate::no_logs`].
|
/// written. Default `false` (verbose). See [`crate::no_logs`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub no_logs: bool,
|
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
|
/// 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
|
/// [`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
|
/// Omitting the section (the default) gives the v2 behaviour: every accepted connection is a
|
||||||
/// VPN client and the relay path is dead code.
|
/// 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)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct RelaySection {
|
pub struct RelaySection {
|
||||||
/// Master switch. `false` (default) keeps the v2 behaviour intact.
|
/// Master switch. `false` (default) keeps the v2 behaviour intact.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Whitelist of allowed downstream exit addresses (`IP:port`). DNS hostnames are NOT resolved
|
/// Whitelist of allowed downstream exit destinations. Each entry is either:
|
||||||
/// 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
|
/// * A literal `IP:port` — exact match.
|
||||||
/// is detected.
|
/// * 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<String>,
|
pub allow_extend_to: Vec<String>,
|
||||||
|
/// 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.
|
/// `[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.
|
/// Top-level `client.toml` document.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ClientConfigFile {
|
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,
|
pub client: ClientSection,
|
||||||
/// `[pki]` section: CA + leaf cert/key file paths.
|
/// `[pki]` section: CA + leaf cert/key file paths.
|
||||||
pub pki: PkiSection,
|
pub pki: PkiSection,
|
||||||
@@ -286,26 +323,106 @@ pub struct ClientConfigFile {
|
|||||||
/// `[transport]` section: fallback order and per-transport ports/options.
|
/// `[transport]` section: fallback order and per-transport ports/options.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub transport: TransportSection,
|
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`,
|
/// See the module-level docs of [`crate::circuit`] for the wire protocol.
|
||||||
/// `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
|
/// ## Two hop formats (both accepted)
|
||||||
/// non-UDP transport order is a hard error at dial time (the dial helper checks the order).
|
///
|
||||||
|
/// **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)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CircuitSection {
|
pub struct CircuitSection {
|
||||||
/// Master switch. `false` (default) keeps the v2 single-hop dial path.
|
/// Master switch. `false` (default) keeps the v2 single-hop dial path.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Ordered list of hops: `[entry_relay, exit_server]`. Exactly two literal `IP:port` entries.
|
/// Ordered list of hops. Each entry is either a literal `"IP:port"` string (v3.1 flat
|
||||||
pub hops: Vec<String>,
|
/// 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<CircuitHop>,
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// `[client]` section.
|
||||||
@@ -332,6 +449,12 @@ pub struct ClientSection {
|
|||||||
/// See [`crate::dial_targets::build_dial_targets`].
|
/// See [`crate::dial_targets::build_dial_targets`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bridges: Vec<String>,
|
pub bridges: Vec<String>,
|
||||||
|
/// `[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`.
|
/// `[tunnel]` section of `client.toml`.
|
||||||
@@ -859,23 +982,50 @@ impl ServerConfigFile {
|
|||||||
TcpOpts::default()
|
TcpOpts::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s, skipping (with a
|
/// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s (v3.1 back
|
||||||
/// `warn` log) any entries that are not bare `IP:port` strings. v3.1 does NOT perform DNS
|
/// compat). Use [`Self::relay_allow_rules`] if you also want to honour CIDR entries
|
||||||
/// resolution; the operator must supply literal IPs.
|
/// introduced in v3.2.
|
||||||
///
|
///
|
||||||
/// Returns the parsed addresses paired with their original strings (so the caller can log
|
/// Returns the parsed addresses. Non-`IP:port` entries are skipped with a warn log.
|
||||||
/// what was skipped). An empty result for a non-empty config means every entry was unparsable.
|
|
||||||
pub fn relay_whitelist(&self) -> Vec<SocketAddr> {
|
pub fn relay_whitelist(&self) -> Vec<SocketAddr> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for raw in &self.server.relay.allow_extend_to {
|
for raw in &self.server.relay.allow_extend_to {
|
||||||
match raw.parse::<SocketAddr>() {
|
match raw.parse::<SocketAddr>() {
|
||||||
Ok(a) => out.push(a),
|
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<RelayAllowRule> {
|
||||||
|
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!(
|
tracing::warn!(
|
||||||
entry = %raw,
|
entry = %raw,
|
||||||
error = %e,
|
"[server.relay] allow_extend_to: skipping unparseable entry \
|
||||||
"[server.relay] allow_extend_to: skipping entry — only literal IP:port is \
|
(expected IP:port, CIDR, or CIDR:port)"
|
||||||
supported in v3.1 (DNS resolution is out of scope)"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Self> {
|
||||||
|
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::<IpNetwork>().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::<u16>().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::<SocketAddr>() {
|
||||||
|
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 {
|
impl ClientConfigFile {
|
||||||
/// Parse a `client.toml` document from a string.
|
/// Parse a `client.toml` document from a string.
|
||||||
pub fn parse(text: &str) -> anyhow::Result<Self> {
|
pub fn parse(text: &str) -> anyhow::Result<Self> {
|
||||||
@@ -959,29 +1195,91 @@ impl ClientConfigFile {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Returns an error if any
|
/// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Both the v3.1 flat string
|
||||||
/// entry fails to parse as `IP:port` or the count is wrong for v3.1 (exactly 2). When
|
/// form and the v3.2 per-hop table form are accepted (the addresses are extracted from
|
||||||
/// `[client.circuit]` is disabled this still validates the configured hops so misconfiguration
|
/// either). Returns an error if any address fails to parse or the count is wrong for v3.2
|
||||||
/// is caught early; the caller decides whether to actually use the result.
|
/// (must be 2 or 3 when enabled).
|
||||||
///
|
|
||||||
/// v3.1 does NOT perform DNS resolution; the operator must supply literal IPs.
|
|
||||||
pub fn circuit_hops(&self) -> anyhow::Result<Vec<SocketAddr>> {
|
pub fn circuit_hops(&self) -> anyhow::Result<Vec<SocketAddr>> {
|
||||||
let mut out = Vec::with_capacity(self.circuit.hops.len());
|
let mut out = Vec::with_capacity(self.client.circuit.hops.len());
|
||||||
for raw in &self.circuit.hops {
|
for hop in &self.client.circuit.hops {
|
||||||
|
let raw = hop.addr();
|
||||||
let addr: SocketAddr = raw.parse().with_context(|| {
|
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);
|
out.push(addr);
|
||||||
}
|
}
|
||||||
if self.circuit.enabled && out.len() != 2 {
|
if self.client.circuit.enabled && !(2..=3).contains(&out.len()) {
|
||||||
return Err(anyhow!(
|
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()
|
out.len()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(out)
|
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<Vec<crate::circuit::HopConfig>> {
|
||||||
|
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]`.
|
/// Build a [`RouteTable`] from `[tunnel.split]`.
|
||||||
///
|
///
|
||||||
/// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`]
|
/// 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");
|
let cfg = ClientConfigFile::parse(bad).expect("parse");
|
||||||
assert!(cfg.dial_config().is_err());
|
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<CircuitHop>` 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,15 @@ pub struct ProvisionClientOpts {
|
|||||||
pub enable_cover_traffic: bool,
|
pub enable_cover_traffic: bool,
|
||||||
/// Optional bridge addresses (`bridges = [...]`).
|
/// Optional bridge addresses (`bridges = [...]`).
|
||||||
pub bridges: Vec<String>,
|
pub bridges: Vec<String>,
|
||||||
|
/// 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<usize>,
|
||||||
/// When `true`, overwrite existing files in `out_dir`. Default `false` errors.
|
/// When `true`, overwrite existing files in `out_dir`. Default `false` errors.
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
@@ -318,6 +327,7 @@ impl ProvisionClientOpts {
|
|||||||
enable_knock: false,
|
enable_knock: false,
|
||||||
enable_cover_traffic: false,
|
enable_cover_traffic: false,
|
||||||
bridges: Vec::new(),
|
bridges: Vec::new(),
|
||||||
|
circuit_hops: None,
|
||||||
force: false,
|
force: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +348,10 @@ pub struct ProvisionClientReport {
|
|||||||
pub client_key: PathBuf,
|
pub client_key: PathBuf,
|
||||||
/// Rendered client.toml.
|
/// Rendered client.toml.
|
||||||
pub client_config: PathBuf,
|
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.
|
/// Run the provision-client workflow. Pure: returns a [`ProvisionClientReport`] without printing.
|
||||||
@@ -373,9 +387,40 @@ pub fn provision_client(opts: &ProvisionClientOpts) -> anyhow::Result<ProvisionC
|
|||||||
std::fs::copy(&ca_src, &bundled_ca)
|
std::fs::copy(&ca_src, &bundled_ca)
|
||||||
.with_context(|| format!("copying {} -> {}", ca_src.display(), bundled_ca.display()))?;
|
.with_context(|| format!("copying {} -> {}", 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 —
|
// 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`.
|
// 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");
|
let client_config = opts.out_dir.join("client.toml");
|
||||||
std::fs::write(&client_config, toml_text)
|
std::fs::write(&client_config, toml_text)
|
||||||
.with_context(|| format!("writing {}", client_config.display()))?;
|
.with_context(|| format!("writing {}", client_config.display()))?;
|
||||||
@@ -387,12 +432,22 @@ pub fn provision_client(opts: &ProvisionClientOpts) -> anyhow::Result<ProvisionC
|
|||||||
client_cert,
|
client_cert,
|
||||||
client_key,
|
client_key,
|
||||||
client_config,
|
client_config,
|
||||||
|
circuit_hop_certs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the `client.toml` document for `opts` + the assigned `id`. Public for tests that want
|
/// Render the `client.toml` document for `opts` + the assigned `id`. Public for tests that want
|
||||||
/// to parse-roundtrip the output without going through the full filesystem dance.
|
/// to parse-roundtrip the output without going through the full filesystem dance.
|
||||||
pub fn render_client_toml(opts: &ProvisionClientOpts, id: &str) -> 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** (`<EDIT-ME>`)
|
||||||
|
/// 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();
|
let mut s = String::new();
|
||||||
s.push_str(
|
s.push_str(
|
||||||
"# Generated by `aura provision-client`. Edit by hand if you know what you're doing.\n\n",
|
"# 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("mean_interval_ms = 500\n");
|
||||||
s.push_str("jitter = 0.5\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 = \"<EDIT-ME-HOP-ADDR:PORT>\"\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
|
s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
|
pub mod cells;
|
||||||
pub mod circuit;
|
pub mod circuit;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|||||||
@@ -237,6 +237,13 @@ struct ProvisionClientArgs {
|
|||||||
/// Comma-separated list of fallback server addresses (IP or IP:port).
|
/// Comma-separated list of fallback server addresses (IP or IP:port).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
bridges: Option<String>,
|
bridges: Option<String>,
|
||||||
|
/// 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<usize>,
|
||||||
/// Overwrite an existing bundle directory.
|
/// Overwrite an existing bundle directory.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
@@ -532,6 +539,7 @@ fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> {
|
|||||||
enable_knock: args.enable_knock,
|
enable_knock: args.enable_knock,
|
||||||
enable_cover_traffic: args.enable_cover_traffic,
|
enable_cover_traffic: args.enable_cover_traffic,
|
||||||
bridges,
|
bridges,
|
||||||
|
circuit_hops: args.circuit_hops,
|
||||||
force: args.force,
|
force: args.force,
|
||||||
};
|
};
|
||||||
let report = init::provision_client(&opts)?;
|
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.crt: {}", report.client_cert.display());
|
||||||
println!(" client.key: {}", report.client_key.display());
|
println!(" client.key: {}", report.client_key.display());
|
||||||
println!(" client.toml: {}", report.client_config.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!();
|
||||||
println!("Hand the entire bundle directory to the client via any secure channel.");
|
println!("Hand the entire bundle directory to the client via any secure channel.");
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ use aura_proto::{
|
|||||||
};
|
};
|
||||||
use tokio::net::UdpSocket;
|
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
|
/// 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
|
/// 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
|
/// a loopback round-trip (the client sends `ExtendBridge` immediately after the outer handshake
|
||||||
@@ -63,7 +65,9 @@ pub enum RendezvousOutcome {
|
|||||||
Refused,
|
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
|
/// 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:
|
/// as [`ControlKind::ExtendBridge`] and the requested exit is whitelisted, this function:
|
||||||
@@ -79,6 +83,22 @@ pub enum RendezvousOutcome {
|
|||||||
pub async fn rendezvous(
|
pub async fn rendezvous(
|
||||||
conn: &Arc<dyn PacketConnection>,
|
conn: &Arc<dyn PacketConnection>,
|
||||||
whitelist: &[SocketAddr],
|
whitelist: &[SocketAddr],
|
||||||
|
) -> RendezvousOutcome {
|
||||||
|
// Adapter: lift the flat whitelist into v3.2 `RelayAllowRule::Exact` entries and delegate.
|
||||||
|
let rules: Vec<RelayAllowRule> = 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<dyn PacketConnection>,
|
||||||
|
rules: &[RelayAllowRule],
|
||||||
) -> RendezvousOutcome {
|
) -> RendezvousOutcome {
|
||||||
let pkt = match tokio::time::timeout(
|
let pkt = match tokio::time::timeout(
|
||||||
Duration::from_secs(EXTEND_RENDEZVOUS_SECS),
|
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
|
// emitted when the section was loaded; we also re-log here so each accepted bridge leaves a
|
||||||
// breadcrumb).
|
// breadcrumb).
|
||||||
if whitelist.is_empty() {
|
if rules.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
exit = %exit_addr,
|
exit = %exit_addr,
|
||||||
"relay running as OPEN relay (allow_extend_to is empty); accepting bridge"
|
"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!(
|
tracing::warn!(
|
||||||
exit = %exit_addr,
|
exit = %exit_addr,
|
||||||
"relay rejecting bridge: exit not in allow_extend_to"
|
"relay rejecting bridge: exit not in allow_extend_to"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use aura_proto::PacketConnection;
|
||||||
use aura_transport::{MultiServer, TransportMode};
|
use aura_transport::{MultiServer, TransportMode};
|
||||||
use aura_tunnel::{AuraTun, RouteAction, RouteTable};
|
use aura_tunnel::{AuraTun, RouteAction, RouteTable};
|
||||||
use ipnetwork::IpNetwork;
|
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
|
// v3.1 / v3.2: when [server.relay] is enabled, parse the allow-rules once. The rules accept
|
||||||
// empty (open relay). The whitelist is a `Vec<SocketAddr>`; an empty list means "all
|
// literal `IP:port`, bare CIDR (any port), or CIDR with explicit port. An empty list means
|
||||||
// addresses allowed" (dangerous; see the section's docs).
|
// "all addresses allowed" (dangerous; the runtime logs a warning).
|
||||||
let relay_enabled = cfg.server.relay.enabled;
|
let relay_enabled = cfg.server.relay.enabled;
|
||||||
let relay_whitelist: Vec<std::net::SocketAddr> = if relay_enabled {
|
let relay_cell_padding = cfg.server.relay.cell_padding;
|
||||||
let wl = cfg.relay_whitelist();
|
let relay_cell_size = cfg.server.relay.cell_size;
|
||||||
if wl.is_empty() {
|
let relay_allow_rules: Vec<crate::config::RelayAllowRule> = if relay_enabled {
|
||||||
|
let rules = cfg.relay_allow_rules();
|
||||||
|
if rules.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[server.relay] is enabled with an EMPTY allow_extend_to — running as OPEN relay; \
|
"[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."
|
every ExtendBridge request will be accepted. Set allow_extend_to to a curated list."
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
count = wl.len(),
|
count = rules.len(),
|
||||||
"[server.relay] enabled with {} whitelisted exit address(es)",
|
cell_padding = relay_cell_padding,
|
||||||
wl.len()
|
cell_size = relay_cell_size,
|
||||||
|
"[server.relay] enabled with {} allow-rule(s)",
|
||||||
|
rules.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
wl
|
rules
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
@@ -324,15 +329,18 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
let mode = accepted.mode;
|
let mode = accepted.mode;
|
||||||
let conn = accepted.conn;
|
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 {
|
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 } => {
|
RendezvousOutcome::Bridged { bridge } => {
|
||||||
// Spawn the two forwarder tasks and skip everything else (no IP pool entry,
|
// Spawn the two forwarder tasks and skip everything else (no IP pool entry,
|
||||||
// no router registration, no CRL push — bridged peers are opaque).
|
// no router registration, no CRL push — bridged peers are opaque).
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
peer = ?peer_id, %mode,
|
peer = ?peer_id, %mode,
|
||||||
"v3.1 relay: bridging connection to exit"
|
"v3.x relay: bridging connection to exit"
|
||||||
);
|
);
|
||||||
let client_conn = Arc::clone(&conn);
|
let client_conn = Arc::clone(&conn);
|
||||||
tokio::spawn(async move {
|
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<dyn PacketConnection> =
|
||||||
|
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
|
// 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
|
// 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
|
// one in practice) fall back to a unique-per-instance marker so dynamic allocation still
|
||||||
|
|||||||
@@ -178,6 +178,108 @@ fn provision_client_anti_surveillance_toggles() {
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
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<String> = 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`.
|
/// A non-empty bundle directory triggers an error without `--force`.
|
||||||
#[test]
|
#[test]
|
||||||
fn provision_client_refuses_non_empty_bundle() {
|
fn provision_client_refuses_non_empty_bundle() {
|
||||||
|
|||||||
@@ -308,3 +308,203 @@ async fn multihop_back_compat_relay_disabled() {
|
|||||||
|
|
||||||
relay_task.abort();
|
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<u8>> = 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<dyn PacketConnection> = 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<dyn PacketConnection> =
|
||||||
|
Arc::new(CellPaddingConn::new(circuit_conn.into_dyn(), cell_size));
|
||||||
|
|
||||||
|
let payloads: Vec<Vec<u8>> = 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user