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:
xah30
2026-05-27 19:35:22 +03:00
parent fe618b839d
commit f26ed7fce0
6 changed files with 555 additions and 19 deletions
+60 -8
View File
@@ -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> {
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<Self> {
// 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::<Accepted>(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())));
}
+46 -5
View File
@@ -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<ServerConfig>,
@@ -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<Self> {
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.
///