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:
@@ -178,6 +178,108 @@ fn provision_client_anti_surveillance_toggles() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// v3.2: `--circuit-hops N` issues N independent client certs, each with its own UUID v4 CN.
|
||||
/// The bundled `client.toml` gains a `[client.circuit]` section with N `[[client.circuit.hops]]`
|
||||
/// tables. Each hop's `cert_path` / `key_path` references the freshly-issued PEM file in the
|
||||
/// bundle, and each cert's CN is a distinct UUID v4.
|
||||
#[test]
|
||||
fn provision_client_with_v3_2_circuit_hops() {
|
||||
let root = temp_dir("v32hops");
|
||||
let ca_dir = root.join("ca");
|
||||
bootstrap_ca(&ca_dir);
|
||||
let bundle = root.join("bundle");
|
||||
|
||||
let mut opts = ProvisionClientOpts::new(
|
||||
&ca_dir,
|
||||
"203.0.113.10",
|
||||
"vpn.example.com",
|
||||
"10.7.0.7",
|
||||
&bundle,
|
||||
);
|
||||
opts.circuit_hops = Some(3); // entry + middle + exit
|
||||
let report = init::provision_client(&opts).expect("provision");
|
||||
|
||||
// Three distinct per-hop certs were issued, all with unique UUID-v4 CNs.
|
||||
assert_eq!(report.circuit_hop_certs.len(), 3, "3 hop certs issued");
|
||||
let mut cns: Vec<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`.
|
||||
#[test]
|
||||
fn provision_client_refuses_non_empty_bundle() {
|
||||
|
||||
@@ -308,3 +308,203 @@ async fn multihop_back_compat_relay_disabled() {
|
||||
|
||||
relay_task.abort();
|
||||
}
|
||||
|
||||
// ---- v3.2: 3-hop + per-hop client certs + cell padding -----------------------------------------
|
||||
|
||||
use aura_cli::cells::CellPaddingConn;
|
||||
use aura_cli::circuit::HopConfig;
|
||||
|
||||
const ENTRY_SAN: &str = "localhost-entry";
|
||||
const MIDDLE_SAN: &str = "localhost-middle";
|
||||
const CLIENT_ID_ENTRY: &str = "client-entry";
|
||||
const CLIENT_ID_MIDDLE: &str = "client-middle";
|
||||
const CLIENT_ID_EXIT: &str = "client-exit";
|
||||
|
||||
/// Build a [`ClientConfig`] with the given CN and expected server SAN. v3.2: a different cert /
|
||||
/// CN per hop is the identity-unlinkable design.
|
||||
fn client_cfg_with_cn(ca: &AuraCa, cn: &str, server_name: &str) -> ClientConfig {
|
||||
let issued = ca.issue_client_cert(cn).expect("issue client cert");
|
||||
ClientConfig {
|
||||
ca_cert_pem: ca.ca_cert_pem(),
|
||||
client_cert_pem: issued.cert_pem,
|
||||
client_key_pem: issued.key_pem,
|
||||
server_name: server_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// v3.2 3-hop end-to-end: `client → A (entry-relay) → B (middle-relay) → C (exit)`. Each hop is
|
||||
/// a real Aura UdpServer on loopback. The client uses a **different** client cert per hop
|
||||
/// (identity-unlinkable). The exit echoes three packets which the client must receive back
|
||||
/// through three layers of AEAD encryption.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn multihop_v3_2_three_hops_end_to_end() {
|
||||
let ca = AuraCa::generate("Aura v3.2 3-hop Test CA").expect("ca");
|
||||
|
||||
let entry_proto = server_cfg(&ca, ENTRY_SAN);
|
||||
let middle_proto = server_cfg(&ca, MIDDLE_SAN);
|
||||
let exit_proto = server_cfg(&ca, EXIT_SAN);
|
||||
|
||||
let entry_port = free_udp_port();
|
||||
let middle_port = free_udp_port();
|
||||
let exit_port = free_udp_port();
|
||||
let entry_addr: SocketAddr = format!("127.0.0.1:{entry_port}").parse().unwrap();
|
||||
let middle_addr: SocketAddr = format!("127.0.0.1:{middle_port}").parse().unwrap();
|
||||
let exit_addr: SocketAddr = format!("127.0.0.1:{exit_port}").parse().unwrap();
|
||||
|
||||
let entry_server =
|
||||
UdpServer::bind(entry_addr, entry_proto, UdpOpts::default()).expect("bind entry");
|
||||
let middle_server =
|
||||
UdpServer::bind(middle_addr, middle_proto, UdpOpts::default()).expect("bind middle");
|
||||
let exit_server =
|
||||
UdpServer::bind(exit_addr, exit_proto, UdpOpts::default()).expect("bind exit");
|
||||
let entry_actual = entry_server.local_addr().expect("entry addr");
|
||||
let middle_actual = middle_server.local_addr().expect("middle addr");
|
||||
let exit_actual = exit_server.local_addr().expect("exit addr");
|
||||
|
||||
// Whitelists per hop (CIDR-aware): entry allows middle; middle allows exit. Both can be exact
|
||||
// entries here; this test exercises the literal-IP:port path.
|
||||
let entry_whitelist = vec![middle_actual];
|
||||
let middle_whitelist = vec![exit_actual];
|
||||
|
||||
let exit_task = tokio::spawn(spawn_exit(exit_server));
|
||||
let middle_task = tokio::spawn(spawn_relay(middle_server, middle_whitelist));
|
||||
let entry_task = tokio::spawn(spawn_relay(entry_server, entry_whitelist));
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// Per-hop client configs: distinct CN per hop, distinct server_name per hop.
|
||||
let hops = vec![
|
||||
HopConfig {
|
||||
addr: entry_actual,
|
||||
proto_cfg: client_cfg_with_cn(&ca, CLIENT_ID_ENTRY, ENTRY_SAN),
|
||||
},
|
||||
HopConfig {
|
||||
addr: middle_actual,
|
||||
proto_cfg: client_cfg_with_cn(&ca, CLIENT_ID_MIDDLE, MIDDLE_SAN),
|
||||
},
|
||||
HopConfig {
|
||||
addr: exit_actual,
|
||||
proto_cfg: client_cfg_with_cn(&ca, CLIENT_ID_EXIT, EXIT_SAN),
|
||||
},
|
||||
];
|
||||
|
||||
let circuit_conn = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
circuit::dial_circuit(&hops, UdpOpts::default()),
|
||||
)
|
||||
.await
|
||||
.expect("dial_circuit did not finish within 60s")
|
||||
.expect("dial_circuit succeeded");
|
||||
|
||||
// peer_id is the exit's SAN — the innermost handshake authenticated the exit cert through
|
||||
// every relay opaquely.
|
||||
assert_eq!(
|
||||
circuit_conn.peer_id(),
|
||||
Some(EXIT_SAN),
|
||||
"circuit.peer_id() must be the exit's SAN through 3 hops"
|
||||
);
|
||||
|
||||
// Echo three packets — through THREE AEAD layers.
|
||||
let payloads: Vec<Vec<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