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
+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.
///