feat(cli,transport): Let's Encrypt outer-cert support on TLS-443/QUIC
Server admins can now point the outer TLS layer at a real CA-signed cert
(e.g. Let's Encrypt fullchain.pem) so the on-wire HTTPS camouflage is
indistinguishable from a normal CA-trusted HTTPS server. The inner Aura
mutual-auth handshake still uses the Aura CA (necessarily — that's where
the PQ mutual auth lives).
- aura-cli config: optional [server.outer_cert] {cert_path, key_path}.
Both fields together (or neither); resolve() reads PEMs and returns
(cert, key) tuple. Absent section -> falls back to reusing the Aura
server cert (v2 behavior, fully back-compat).
- aura-transport: additive MultiServer::bind_with_outer and
TcpServer::bind_with_outer that accept an optional separate outer cert.
Old MultiServer::bind / TcpServer::bind preserved as thin wrappers
(back-compat: existing callers untouched). AuraServer::bind already
took outer cert separately.
- UDP transport doesn't have outer TLS, so outer cert is irrelevant
there — only QUIC + TCP layers benefit.
- 4 new tests (parsing, back-compat, partial-section validation, two-CA
loopback verifying inner peer_id is the inner CN). Workspace: 257 tests
passed (+4), clippy -D warnings clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
//! v3 "Let's Encrypt outer cert" tests for `[server.outer_cert]`.
|
||||
//!
|
||||
//! These tests cover the three guarantees of the new feature:
|
||||
//!
|
||||
//! 1. **Parsing** — a `server.toml` with `[server.outer_cert] cert_path = "...", key_path = "..."`
|
||||
//! parses, and the section's [`crate::config::ServerOuterCertSection::resolve`] returns
|
||||
//! `Some((cert_pem, key_pem))`. A `server.toml` without the section parses too (back-compat)
|
||||
//! and `resolve` returns `None`.
|
||||
//! 2. **Validation** — setting exactly one of `cert_path` / `key_path` (without the other) is a
|
||||
//! hard error from `resolve`.
|
||||
//! 3. **Loopback with a separate outer cert** — a real `MultiServer` bound via
|
||||
//! [`aura_transport::MultiServer::bind_with_outer`] with an outer cert from a SECOND CA accepts
|
||||
//! a normal Aura client whose inner cert is from the FIRST CA. The verified `peer_id` matches
|
||||
//! the inner-client CN — proving the inner Aura mutual-auth handshake was unaffected by the
|
||||
//! outer-TLS cert coming from a different trust root.
|
||||
//!
|
||||
//! TCP transport is used in test #3 because the outer-TLS cert is most directly observable there
|
||||
//! (rustls outer handshake on top of TCP); the same `bind_with_outer` plumbing routes the cert into
|
||||
//! QUIC as well via [`aura_transport::AuraServer::bind`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use aura_cli::config::{ServerConfigFile, ServerOuterCertSection};
|
||||
use aura_pki::AuraCa;
|
||||
use aura_proto::PacketConnection;
|
||||
use aura_transport::{dial, MultiServer, TransportMode};
|
||||
|
||||
const INNER_SERVER_NAME: &str = "localhost";
|
||||
|
||||
/// A unique temp directory for this test process.
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let mut dir = std::env::temp_dir();
|
||||
dir.push(format!(
|
||||
"aura-cli-le-outer-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
/// Grab a currently-free TCP port on loopback by binding `:0` and releasing it.
|
||||
fn free_tcp_port() -> u16 {
|
||||
let sock = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral tcp");
|
||||
sock.local_addr().expect("local_addr").port()
|
||||
}
|
||||
|
||||
/// (1) `[server.outer_cert]` with both paths parses and `resolve()` returns the read PEMs.
|
||||
#[tokio::test]
|
||||
async fn parses_outer_cert_section_and_resolves_pems() {
|
||||
let dir = temp_dir("parse");
|
||||
let outer_ca = AuraCa::generate("Outer LE-like CA").expect("outer CA");
|
||||
let outer = outer_ca
|
||||
.issue_server_cert(INNER_SERVER_NAME)
|
||||
.expect("outer cert");
|
||||
let outer_cert_path = dir.join("outer.crt");
|
||||
let outer_key_path = dir.join("outer.key");
|
||||
std::fs::write(&outer_cert_path, &outer.cert_pem).unwrap();
|
||||
std::fs::write(&outer_key_path, &outer.key_pem).unwrap();
|
||||
|
||||
let server_toml = format!(
|
||||
r#"
|
||||
[server]
|
||||
name = "edge-test"
|
||||
|
||||
[server.outer_cert]
|
||||
cert_path = "{cert}"
|
||||
key_path = "{key}"
|
||||
|
||||
[pki]
|
||||
ca_cert = "ignored"
|
||||
cert = "ignored"
|
||||
key = "ignored"
|
||||
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#,
|
||||
cert = outer_cert_path.display(),
|
||||
key = outer_key_path.display(),
|
||||
);
|
||||
let cfg = ServerConfigFile::parse(&server_toml).expect("parse server.toml");
|
||||
let oc = cfg
|
||||
.server
|
||||
.outer_cert
|
||||
.as_ref()
|
||||
.expect("outer_cert section parsed");
|
||||
assert!(oc.cert_path.is_some() && oc.key_path.is_some());
|
||||
|
||||
let resolved = oc.resolve().expect("resolve PEMs");
|
||||
let (cert_pem, key_pem) = resolved.expect("Some when both paths set");
|
||||
assert!(cert_pem.starts_with("-----BEGIN CERTIFICATE-----"));
|
||||
assert!(key_pem.contains("PRIVATE KEY-----"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// (1b) A `server.toml` WITHOUT `[server.outer_cert]` still parses (back-compat) and the field is
|
||||
/// `None` — the v2-compatible "outer cert reuses Aura server cert" path.
|
||||
#[tokio::test]
|
||||
async fn omitted_outer_cert_section_is_backwards_compatible() {
|
||||
let server_toml = r#"
|
||||
[server]
|
||||
name = "edge-test"
|
||||
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(server_toml).expect("parse server.toml");
|
||||
assert!(
|
||||
cfg.server.outer_cert.is_none(),
|
||||
"no [server.outer_cert] -> field is None"
|
||||
);
|
||||
}
|
||||
|
||||
/// (2) Setting `cert_path` without `key_path` (or vice-versa) is a hard error from
|
||||
/// `ServerOuterCertSection::resolve` — both must be set together.
|
||||
#[test]
|
||||
fn rejects_partial_outer_cert_section() {
|
||||
let only_cert = ServerOuterCertSection {
|
||||
cert_path: Some(PathBuf::from("/tmp/x.crt")),
|
||||
key_path: None,
|
||||
};
|
||||
let err = only_cert.resolve().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("cert_path") && err.contains("key_path"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let only_key = ServerOuterCertSection {
|
||||
cert_path: None,
|
||||
key_path: Some(PathBuf::from("/tmp/x.key")),
|
||||
};
|
||||
assert!(only_key.resolve().is_err());
|
||||
|
||||
// And the all-None case resolves to None (the v2 fallback).
|
||||
let none = ServerOuterCertSection::default();
|
||||
assert!(none.resolve().expect("None resolves").is_none());
|
||||
}
|
||||
|
||||
/// (3) End-to-end: bind a TCP transport with an outer-TLS cert from a SECOND CA and verify a normal
|
||||
/// Aura client (inner cert from the FIRST CA, the only one configured in the client's proto_cfg)
|
||||
/// connects, mutually authenticates, and exchanges packets. The verified `peer_id` matches the
|
||||
/// inner client CN — proving the outer cert's trust root did NOT interfere with the inner Aura
|
||||
/// mutual-auth handshake.
|
||||
#[tokio::test]
|
||||
async fn loopback_tcp_with_separate_outer_cert_authenticates_via_inner_ca() {
|
||||
let dir = temp_dir("loopback-tcp");
|
||||
|
||||
// CA #1: the Aura CA — issues the server's inner cert (used by the inner Aura handshake) and
|
||||
// the client's leaf cert. This is the only trust root the client knows about.
|
||||
let inner_ca = AuraCa::generate("Aura Inner CA").expect("inner CA");
|
||||
let inner_server = inner_ca
|
||||
.issue_server_cert(INNER_SERVER_NAME)
|
||||
.expect("inner server cert");
|
||||
let client_cert = inner_ca
|
||||
.issue_client_cert("le-test-client")
|
||||
.expect("client cert");
|
||||
|
||||
// CA #2: a SEPARATE CA — its server cert plays the role of the Let's Encrypt fullchain on the
|
||||
// outer-TLS layer. The client's outer verifier is `AcceptAnyServerCert` (transport docs), so
|
||||
// the outer cert's trust root is irrelevant to the client — but the inner Aura handshake still
|
||||
// verifies the server cert against `inner_ca`.
|
||||
let outer_ca = AuraCa::generate("Outer LE-like CA").expect("outer CA");
|
||||
let outer_cert = outer_ca
|
||||
.issue_server_cert(INNER_SERVER_NAME)
|
||||
.expect("outer cert");
|
||||
|
||||
// Write all the PEM files for the CLI config to read.
|
||||
let ca_path = dir.join("ca.crt");
|
||||
let srv_cert_path = dir.join("server.crt");
|
||||
let srv_key_path = dir.join("server.key");
|
||||
let cli_cert_path = dir.join("client.crt");
|
||||
let cli_key_path = dir.join("client.key");
|
||||
let outer_cert_path = dir.join("outer.crt");
|
||||
let outer_key_path = dir.join("outer.key");
|
||||
std::fs::write(&ca_path, inner_ca.ca_cert_pem()).unwrap();
|
||||
std::fs::write(&srv_cert_path, &inner_server.cert_pem).unwrap();
|
||||
std::fs::write(&srv_key_path, &inner_server.key_pem).unwrap();
|
||||
std::fs::write(&cli_cert_path, &client_cert.cert_pem).unwrap();
|
||||
std::fs::write(&cli_key_path, &client_cert.key_pem).unwrap();
|
||||
std::fs::write(&outer_cert_path, &outer_cert.cert_pem).unwrap();
|
||||
std::fs::write(&outer_key_path, &outer_cert.key_pem).unwrap();
|
||||
|
||||
// TCP-only on a learned free loopback port. (UDP transport has no outer TLS layer to exercise
|
||||
// a swapped outer cert against; QUIC works the same way as TCP through the same plumbing.)
|
||||
let tcp_port = free_tcp_port();
|
||||
|
||||
let server_toml = format!(
|
||||
r#"
|
||||
[server]
|
||||
name = "edge-le-test"
|
||||
listen = "127.0.0.1:{tcp_port}"
|
||||
|
||||
[server.outer_cert]
|
||||
cert_path = "{outer_cert}"
|
||||
key_path = "{outer_key}"
|
||||
|
||||
[pki]
|
||||
ca_cert = "{ca}"
|
||||
cert = "{cert}"
|
||||
key = "{key}"
|
||||
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
|
||||
[transport]
|
||||
order = ["tcp"]
|
||||
udp_port = {udp_port}
|
||||
tcp_port = {tcp_port}
|
||||
quic_port = {quic_port}
|
||||
obfuscate = false
|
||||
"#,
|
||||
ca = ca_path.display(),
|
||||
cert = srv_cert_path.display(),
|
||||
key = srv_key_path.display(),
|
||||
outer_cert = outer_cert_path.display(),
|
||||
outer_key = outer_key_path.display(),
|
||||
udp_port = tcp_port + 1,
|
||||
quic_port = tcp_port + 2,
|
||||
);
|
||||
|
||||
let client_toml = format!(
|
||||
r#"
|
||||
[client]
|
||||
name = "le-client-test"
|
||||
server_addr = "127.0.0.1:{tcp_port}"
|
||||
sni = "{sni}"
|
||||
|
||||
[pki]
|
||||
ca_cert = "{ca}"
|
||||
cert = "{cert}"
|
||||
key = "{key}"
|
||||
|
||||
[tunnel]
|
||||
local_ip = "10.7.0.2"
|
||||
|
||||
[transport]
|
||||
order = ["tcp"]
|
||||
udp_port = {udp_port}
|
||||
tcp_port = {tcp_port}
|
||||
quic_port = {quic_port}
|
||||
obfuscate = false
|
||||
"#,
|
||||
sni = INNER_SERVER_NAME,
|
||||
ca = ca_path.display(),
|
||||
cert = cli_cert_path.display(),
|
||||
key = cli_key_path.display(),
|
||||
udp_port = tcp_port + 1,
|
||||
quic_port = tcp_port + 2,
|
||||
);
|
||||
|
||||
let server_cfg = ServerConfigFile::parse(&server_toml).expect("parse server.toml");
|
||||
let client_cfg =
|
||||
aura_cli::config::ClientConfigFile::parse(&client_toml).expect("parse client.toml");
|
||||
|
||||
// Resolve the outer-cert PEMs through the CLI helper — the same path `aura server` uses.
|
||||
let outer_resolved = server_cfg
|
||||
.server
|
||||
.outer_cert
|
||||
.as_ref()
|
||||
.expect("outer_cert section parsed")
|
||||
.resolve()
|
||||
.expect("outer cert resolves")
|
||||
.expect("Some when both paths set");
|
||||
|
||||
let endpoints = server_cfg.transport_endpoints().expect("server endpoints");
|
||||
let server_proto = server_cfg.to_proto().expect("server proto cfg");
|
||||
let client_proto = client_cfg.to_proto().expect("client proto cfg");
|
||||
let dial_cfg = client_cfg.dial_config().expect("client dial config");
|
||||
assert_eq!(dial_cfg.order, vec![TransportMode::Tcp]);
|
||||
|
||||
// Bind via the new `bind_with_outer`, passing the SECOND CA's leaf as the outer-TLS cert.
|
||||
let mut server = MultiServer::bind_with_outer(
|
||||
endpoints,
|
||||
server_proto,
|
||||
server_cfg.udp_opts(),
|
||||
server_cfg.tcp_opts(),
|
||||
Some(outer_resolved.0.as_str()),
|
||||
Some(outer_resolved.1.as_str()),
|
||||
)
|
||||
.await
|
||||
.expect("bind MultiServer with outer cert");
|
||||
|
||||
let accept = tokio::spawn(async move { server.accept().await.map(|a| (a, server)) });
|
||||
let connect = tokio::spawn(async move { dial(client_proto, dial_cfg).await });
|
||||
|
||||
let (accepted, _server_keepalive) = accept
|
||||
.await
|
||||
.expect("accept join")
|
||||
.expect("MultiServer accepted a connection");
|
||||
let (client_conn, mode): (Arc<dyn PacketConnection>, TransportMode) = connect
|
||||
.await
|
||||
.expect("connect join")
|
||||
.expect("dial connected");
|
||||
|
||||
assert_eq!(mode, TransportMode::Tcp);
|
||||
assert_eq!(accepted.mode, TransportMode::Tcp);
|
||||
// Critical assertion: the verified inner peer id is the client CN issued by CA #1 — proving
|
||||
// the inner Aura mutual-auth ran successfully even though the outer TLS used CA #2's cert.
|
||||
assert_eq!(accepted.peer_id.as_deref(), Some("le-test-client"));
|
||||
|
||||
let server_conn = accepted.conn;
|
||||
// Round-trip a couple of packets to be sure the channel is live end-to-end.
|
||||
client_conn
|
||||
.send_packet(b"hello-from-le-client")
|
||||
.await
|
||||
.expect("client send");
|
||||
let got = server_conn.recv_packet().await.expect("server recv");
|
||||
assert_eq!(got, b"hello-from-le-client");
|
||||
|
||||
server_conn.send_packet(b"hi-back").await.expect("srv send");
|
||||
let got = client_conn.recv_packet().await.expect("client recv");
|
||||
assert_eq!(got, b"hi-back");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in New Issue
Block a user