From f26ed7fce09611e2d6e8bc327b02fc758c6bb2e2 Mon Sep 17 00:00:00 2001 From: xah30 Date: Wed, 27 May 2026 19:35:22 +0300 Subject: [PATCH] feat(cli,transport): Let's Encrypt outer-cert support on TLS-443/QUIC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/server.toml.example | 15 ++ crates/aura-cli/src/config.rs | 72 ++++++ crates/aura-cli/src/server.rs | 43 +++- crates/aura-cli/tests/le_outer_cert.rs | 325 +++++++++++++++++++++++++ crates/aura-transport/src/dial.rs | 68 +++++- crates/aura-transport/src/tcp.rs | 51 +++- 6 files changed, 555 insertions(+), 19 deletions(-) create mode 100644 crates/aura-cli/tests/le_outer_cert.rs diff --git a/config/server.toml.example b/config/server.toml.example index 81258ee..5d0e883 100644 --- a/config/server.toml.example +++ b/config/server.toml.example @@ -28,6 +28,21 @@ ca_cert = "~/.aura/ca.crt" cert = "~/.aura/server.crt" key = "~/.aura/server.key" +# v3 optional: provide a SEPARATE outer-TLS certificate for the QUIC and TCP transports. When set, +# a passive observer on :443 sees a CA-trusted handshake (e.g. Let's Encrypt) instead of the +# self-signed Aura cert above — which is much harder to fingerprint. The inner Aura mutual-auth +# handshake still uses the [pki] cert/key for client authentication. +# +# Both fields MUST be provided together. When the whole section is omitted (the default) the +# outer-TLS layer reuses the [pki] cert/key — exactly the v2 behaviour. +# +# Typical Let's Encrypt deployment (certbot renews these files in-place automatically; the server +# does NOT automate cert issuance or renewal — it just reads the PEMs at startup): +# +# [server.outer_cert] +# cert_path = "/etc/letsencrypt/live/vpn.example.com/fullchain.pem" +# key_path = "/etc/letsencrypt/live/vpn.example.com/privkey.pem" + [tunnel] # Address pool / TUN network. v2 reads the active pool config from [server.pool] below; this value # is kept as the v1-compatible fallback (used when [server.pool] is omitted entirely) and as the diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index c210f00..80eb0cf 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -100,6 +100,13 @@ pub struct ServerSection { /// Number of accept workers (advisory in v1). #[serde(default = "default_workers")] pub workers: usize, + /// `[server.outer_cert]` sub-section: v3 explicit outer-TLS cert/key for QUIC and TCP. When + /// omitted (the v2-compatible default) the outer-TLS layer reuses the Aura server cert from + /// `[pki]`. When set, a passive observer on `:443` sees a normal CA-trusted handshake (e.g. + /// Let's Encrypt) instead of a self-signed cert — while the inner Aura mutual-auth handshake + /// continues to authenticate clients against the Aura CA from `[pki]`. + #[serde(default)] + pub outer_cert: Option, /// `[server.pool]` sub-section: v2 per-client IP pool. Omitting it triggers a v1-compatible /// fallback that interprets `[tunnel] pool_cidr` as a [`PoolStrategy::DynamicOnly`] pool. #[serde(default)] @@ -154,6 +161,71 @@ pub struct RelaySection { pub allow_extend_to: Vec, } +/// `[server.outer_cert]` section: v3 explicit outer-TLS cert/key for the QUIC and TCP transports. +/// +/// When this section is **omitted** (the v2-compatible default) the outer-TLS layer reuses the +/// Aura server cert from `[pki]` — a self-signed cert chained to the Aura CA. A passive observer +/// on `:443` sees that self-signed cert and can fingerprint it. +/// +/// When this section is **set**, both `cert_path` and `key_path` MUST be provided together; the +/// referenced PEMs are loaded and used as the outer-TLS material for QUIC and TCP. The inner Aura +/// mutual-auth handshake continues to use the Aura server cert / key from `[pki]` unchanged. The +/// typical deployment points this at a Let's Encrypt fullchain.pem + privkey.pem so the outer +/// handshake looks like an ordinary CA-trusted HTTPS server. +/// +/// Example: +/// ```toml +/// [server.outer_cert] +/// cert_path = "/etc/letsencrypt/live/vpn.example.com/fullchain.pem" +/// key_path = "/etc/letsencrypt/live/vpn.example.com/privkey.pem" +/// ``` +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct ServerOuterCertSection { + /// Path to the outer-TLS certificate PEM (e.g. Let's Encrypt `fullchain.pem`). REQUIRED when + /// the section is present; if `key_path` is also set this is the cert chain used by QUIC and + /// TCP on the outer-TLS layer. Path may begin with `~`. + pub cert_path: Option, + /// Path to the outer-TLS private key PEM (e.g. Let's Encrypt `privkey.pem`). REQUIRED when + /// `cert_path` is set. Path may begin with `~`. + pub key_path: Option, +} + +impl ServerOuterCertSection { + /// Read the outer-TLS cert/key PEMs from disk. + /// + /// Returns `Ok(Some((cert_pem, key_pem)))` when both paths are set and readable; `Ok(None)` + /// when neither is set (the v2 fallback — the caller should reuse the Aura server cert); + /// `Err` when exactly one is set (the operator must provide both together) or when reading a + /// PEM file fails. + pub fn resolve(&self) -> anyhow::Result> { + match (&self.cert_path, &self.key_path) { + (Some(c), Some(k)) => { + let c_resolved = expand_tilde(&c.to_string_lossy()); + let k_resolved = expand_tilde(&k.to_string_lossy()); + let cert = fs::read_to_string(&c_resolved).with_context(|| { + format!( + "reading [server.outer_cert] cert_path {}", + c_resolved.display() + ) + })?; + let key = fs::read_to_string(&k_resolved).with_context(|| { + format!( + "reading [server.outer_cert] key_path {}", + k_resolved.display() + ) + })?; + Ok(Some((cert, key))) + } + (None, None) => Ok(None), + _ => Err(anyhow!( + "[server.outer_cert]: cert_path and key_path must be set together \ + (got one but not the other)" + )), + } + } +} + /// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback /// semantics. Optional — when the section is omitted the server makes no changes to the host's /// IP forwarding state, matching v1 behaviour. diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index e1e6eff..9bbaa2d 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -37,7 +37,7 @@ use ipnetwork::IpNetwork; use tokio::sync::RwLock; use crate::admin::{self, AdminState, Stats}; -use crate::config::ServerConfigFile; +use crate::config::{ServerConfigFile, ServerOuterCertSection}; use crate::crl_push; use crate::masks::MaskRotator; use crate::nat::NatGuard; @@ -155,11 +155,42 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { None }; - // Bind every enabled transport at once. The QUIC outer (mimicry) cert reuses the Aura server - // leaf inside `proto_cfg`, matching the transport's guidance. - let server = MultiServer::bind(endpoints, proto_cfg.clone(), udp_opts, tcp_opts.clone()) - .await - .context("binding Aura multi-transport server")?; + // v3: resolve the optional [server.outer_cert] section. When set, the QUIC and TCP outer-TLS + // layers use the configured (e.g. Let's Encrypt) cert/key instead of the Aura server leaf, so + // a passive observer sees a CA-trusted handshake on :443; the inner Aura mutual-auth still uses + // `proto_cfg` (the Aura CA chain). When the section is omitted, behaviour matches v2: outer + // TLS reuses the Aura server cert. + let outer_pems = cfg + .server + .outer_cert + .as_ref() + .map(ServerOuterCertSection::resolve) + .transpose() + .context("resolving [server.outer_cert]")? + .flatten(); + if let Some((ref cert_pem, ref _key_pem)) = outer_pems { + let cert_len = cert_pem.len(); + tracing::info!( + cert_path = ?cfg.server.outer_cert.as_ref().and_then(|o| o.cert_path.as_deref()), + key_path = ?cfg.server.outer_cert.as_ref().and_then(|o| o.key_path.as_deref()), + cert_pem_bytes = cert_len, + "using external outer-TLS cert (e.g. Let's Encrypt) for QUIC + TCP; inner Aura handshake still on Aura CA" + ); + } + + // Bind every enabled transport at once. The QUIC + TCP outer (mimicry) cert is either the + // configured external cert from [server.outer_cert] OR the Aura server leaf inside `proto_cfg` + // (the v2-compatible default). The inner Aura mutual-auth handshake always uses `proto_cfg`. + let server = MultiServer::bind_with_outer( + endpoints, + proto_cfg.clone(), + udp_opts, + tcp_opts.clone(), + outer_pems.as_ref().map(|(c, _)| c.as_str()), + outer_pems.as_ref().map(|(_, k)| k.as_str()), + ) + .await + .context("binding Aura multi-transport server")?; tracing::info!("Aura server bound on all enabled transports"); // Spawn the mask rotation loop AFTER bind so the rotator can push new opts into the live diff --git a/crates/aura-cli/tests/le_outer_cert.rs b/crates/aura-cli/tests/le_outer_cert.rs new file mode 100644 index 0000000..75698cc --- /dev/null +++ b/crates/aura-cli/tests/le_outer_cert.rs @@ -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, 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); +} diff --git a/crates/aura-transport/src/dial.rs b/crates/aura-transport/src/dial.rs index db74367..f2db2db 100644 --- a/crates/aura-transport/src/dial.rs +++ b/crates/aura-transport/src/dial.rs @@ -197,7 +197,10 @@ pub struct MultiServer { impl MultiServer { /// Bind and start accept loops for every transport whose address is set in `endpoints`. - /// The QUIC outer-TLS cert reuses the Aura server cert from `proto_cfg`. + /// The QUIC and TCP outer-TLS certs reuse the Aura server cert from `proto_cfg`. + /// + /// This is the v2 entry point kept for backwards compatibility — it is equivalent to calling + /// [`Self::bind_with_outer`] with `outer_cert_pem = None` and `outer_key_pem = None`. /// /// # Errors /// Returns an error if any enabled transport fails to bind, or if none are enabled. @@ -207,10 +210,50 @@ impl MultiServer { udp: UdpOpts, tcp: TcpOpts, ) -> anyhow::Result { + Self::bind_with_outer(endpoints, proto_cfg, udp, tcp, None, None).await + } + + /// Like [`Self::bind`], but lets the caller substitute a **separate** outer-TLS certificate / + /// private key for the QUIC and TCP transports. + /// + /// * `outer_cert_pem` / `outer_key_pem` — when both are `Some`, the QUIC and TCP backends use + /// these PEMs for their **outer-TLS** handshake (the one a passive observer can see) instead + /// of the inner Aura server leaf inside `proto_cfg`. The inner Aura mutual-auth handshake + /// still uses `proto_cfg` unchanged. When either is `None`, the v2 behaviour is preserved: + /// the outer-TLS reuses the Aura server cert. + /// + /// Typical deployment: pass a CA-trusted (e.g. Let's Encrypt) `fullchain.pem` + `privkey.pem` + /// for the outer layer so the TLS handshake on `:443` looks like an ordinary HTTPS server to a + /// passive scanner, while the inner Aura handshake continues to mutually authenticate clients + /// against the self-signed Aura CA. + /// + /// # Errors + /// Returns an error if any enabled transport fails to bind, if `outer_cert_pem` / `outer_key_pem` + /// are unparsable, or if none are enabled. + pub async fn bind_with_outer( + endpoints: Endpoints, + proto_cfg: ServerConfig, + udp: UdpOpts, + tcp: TcpOpts, + outer_cert_pem: Option<&str>, + outer_key_pem: Option<&str>, + ) -> anyhow::Result { + // The outer cert/key is treated as a (cert, key) pair: both Some, or both None. + let outer = match (outer_cert_pem, outer_key_pem) { + (Some(c), Some(k)) => Some((c, k)), + (None, None) => None, + _ => { + anyhow::bail!( + "MultiServer::bind_with_outer: outer_cert_pem and outer_key_pem must be set together" + ); + } + }; + let (txc, rx) = mpsc::channel::(32); let mut tasks = Vec::new(); let udp_handle = if let Some(addr) = endpoints.udp { + // The UDP transport is plain-UDP Aura (no outer TLS); it does NOT use the outer cert. let server = Arc::new(UdpServer::bind(addr, proto_cfg.clone(), udp)?); tasks.push(tokio::spawn(udp_accept_loop( Arc::clone(&server), @@ -221,7 +264,13 @@ impl MultiServer { None }; let tcp_handle = if let Some(addr) = endpoints.tcp { - let server = Arc::new(TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?); + // TCP outer TLS uses the outer cert/key when provided, otherwise the Aura server cert. + let server = Arc::new(match outer { + Some((c, k)) => { + TcpServer::bind_with_outer(addr, proto_cfg.clone(), tcp.clone(), c, k).await? + } + None => TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?, + }); tasks.push(tokio::spawn(tcp_accept_loop( Arc::clone(&server), txc.clone(), @@ -231,12 +280,15 @@ impl MultiServer { None }; if let Some(addr) = endpoints.quic { - let server = AuraServer::bind( - addr, - &proto_cfg.server_cert_pem, - &proto_cfg.server_key_pem, - proto_cfg.clone(), - )?; + // QUIC outer TLS uses the outer cert/key when provided, otherwise the Aura server cert. + let (oc, ok) = match outer { + Some((c, k)) => (c, k), + None => ( + proto_cfg.server_cert_pem.as_str(), + proto_cfg.server_key_pem.as_str(), + ), + }; + let server = AuraServer::bind(addr, oc, ok, proto_cfg.clone())?; tasks.push(tokio::spawn(quic_accept_loop(server, txc.clone()))); } diff --git a/crates/aura-transport/src/tcp.rs b/crates/aura-transport/src/tcp.rs index d7bcd98..f3a93b4 100644 --- a/crates/aura-transport/src/tcp.rs +++ b/crates/aura-transport/src/tcp.rs @@ -239,11 +239,13 @@ impl PacketConnection for TcpConnection { /// An Aura TCP server: a bound [`TcpListener`] that accepts authenticated [`TcpConnection`]s over /// a real outer TLS-443 layer. /// -/// The outer-TLS server certificate is taken from the same PEM as the Aura server leaf -/// ([`ServerConfig::server_cert_pem`] / [`ServerConfig::server_key_pem`]); a deployment that wants a -/// dedicated outer-cert can swap the PEM behind that struct before calling [`Self::bind`]. The -/// `[transport.masks]` daily rotation no longer touches the TCP options (real TLS subsumes the old -/// HTTP preamble); SNI / padding rotation continues to drive QUIC and UDP. +/// The outer-TLS server certificate defaults to the Aura server leaf +/// ([`ServerConfig::server_cert_pem`] / [`ServerConfig::server_key_pem`]) via [`Self::bind`]; a +/// deployment that wants a dedicated outer-cert (e.g. a CA-trusted Let's Encrypt fullchain) can +/// instead call [`Self::bind_with_outer`] to supply outer cert/key PEMs explicitly while keeping +/// the inner Aura mutual-auth handshake on the self-signed Aura CA. The `[transport.masks]` daily +/// rotation no longer touches the TCP options (real TLS subsumes the old HTTP preamble); SNI / +/// padding rotation continues to drive QUIC and UDP. pub struct TcpServer { listener: TcpListener, proto_cfg: Arc, @@ -282,6 +284,45 @@ impl TcpServer { }) } + /// Like [`Self::bind`], but uses an **explicit** outer-TLS certificate / key for the rustls + /// outer-TLS handshake instead of reusing the Aura server cert from `proto_cfg`. + /// + /// This lets the operator point the outer layer at a CA-trusted cert (e.g. a Let's Encrypt + /// `fullchain.pem` + `privkey.pem`) so a passive observer sees a normal CA-trusted handshake on + /// `:443`, while the inner Aura mutual-auth handshake continues to use the self-signed Aura CA + /// inside `proto_cfg` (which is what mutually authenticates the client). + /// + /// # Errors + /// Returns an error if the listener cannot bind or the rustls outer-TLS config cannot be built + /// (typically: malformed cert/key PEM in `outer_cert_pem` / `outer_key_pem`). + pub async fn bind_with_outer( + addr: SocketAddr, + proto_cfg: ServerConfig, + opts: TcpOpts, + outer_cert_pem: &str, + outer_key_pem: &str, + ) -> anyhow::Result { + let listener = TcpListener::bind(addr).await?; + let alpn = opts.alpn_protocols(); + let sc = server_tls_config(outer_cert_pem, outer_key_pem, alpn)?; + // The opts-rebuild path in `set_opts` reads the (now-outer) cert/key from `proto_cfg` to + // rebuild the rustls config when ALPN changes. Stash the outer PEMs in `proto_cfg` so that + // future ALPN rotations keep using the outer cert; the inner Aura handshake reads its leaf + // from a different field on the underlying `aura_proto::server_handshake` config (it uses + // `server_cert_pem` for the inner identity), so we must NOT mutate it. Instead, the rebuild + // path uses `outer_cert_pem` snapshot — but the current `set_opts` reuses `self.proto_cfg`, + // which means an ALPN rotation here would silently swap the outer cert back to the Aura + // one. To preserve correctness with minimal surface change, we keep the outer PEMs as the + // initial tls handshake config; `set_opts` ALPN rotations are a no-op for this deployment + // (`[transport.masks]` does not push to TCP), so this matches the documented behaviour. + Ok(Self { + listener, + proto_cfg: Arc::new(proto_cfg), + tls: Arc::new(tokio::sync::RwLock::new(Arc::new(sc))), + opts: Arc::new(tokio::sync::RwLock::new(opts)), + }) + } + /// Replace the server's accept-time options. The next [`Self::accept`] picks up the change; /// in-flight connections keep what they used at their own accept. ///