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:
xah30
2026-05-27 20:07:12 +03:00
parent f26ed7fce0
commit 9b98004424
13 changed files with 1768 additions and 298 deletions
@@ -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() {