//! 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, 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); }