f26ed7fce0
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>
326 lines
12 KiB
Rust
326 lines
12 KiB
Rust
//! 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);
|
|
}
|