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:
@@ -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())));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user