Skip to main content

waf_proxy/
tls.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Inbound TLS termination (Phase 12).
5//!
6//! **Basic, cert-from-file termination is OPEN core** (`BOUNDARY.md` §3.2): it makes a
7//! single node self-sufficient for `https://`. Cert management *at scale* — ACME/Let's
8//! Encrypt, rotation, centralized multi-node certs, **mTLS with managed PKI** — is
9//! ENTERPRISE (governance/scale) and plugs in behind the [`TlsCertSource`] seam, the §4
10//! boundary pattern: the OPEN tier ships [`FileCertSource`]; an enterprise crate provides
11//! an at-scale impl of the SAME trait.
12//!
13//! ALPN advertises `h2` + `http/1.1` (config-driven) so an h2-capable client (e.g.
14//! gRPC-over-TLS, a later phase) negotiates HTTP/2 while an h1-only client falls back
15//! cleanly — both then flow through the SAME protocol-neutral `handle()`.
16//!
17//! **No silent downgrade**: when TLS is enabled the listener serves ONLY TLS. A required
18//! cert that cannot be loaded is a fatal boot error (`acceptor_from_config` returns `Err`,
19//! the proxy refuses to bind) — never a fallback to cleartext on the same port.
20
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use tokio_rustls::rustls::pki_types::pem::{self, PemObject};
25use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
26use tokio_rustls::rustls::server::danger::ClientCertVerifier;
27use tokio_rustls::rustls::server::ResolvesServerCert;
28use tokio_rustls::rustls::ServerConfig;
29use tokio_rustls::TlsAcceptor;
30
31use waf_core::TlsConfig;
32
33/// Server certificate material: a leaf-first chain + its private key, as rustls DER types.
34pub struct TlsMaterial {
35    pub cert_chain: Vec<CertificateDer<'static>>,
36    pub private_key: PrivateKeyDer<'static>,
37}
38
39/// Source of the server's TLS certificate material — the §4 boundary seam. [`FileCertSource`]
40/// is the OPEN implementation; ACME/rotation/managed-PKI are enterprise implementations of
41/// this same trait (the core depends only on the trait, never on the at-scale impl).
42///
43/// `load()` is the single-cert path (the OPEN baseline). The two *defaulted* hooks below are
44/// additive extension points (compatible with the BOUNDARY §5 freeze, like
45/// `WafModule::structural()`): an enterprise source overrides them to take over server-cert
46/// resolution (hitless ACME rotation) or to require client certificates (mTLS / managed PKI),
47/// while every existing impl keeps `load()` + no client auth unchanged.
48pub trait TlsCertSource: Send + Sync {
49    /// Load a single server certificate chain + key. Used unless [`Self::resolver`]
50    /// returns `Some` (in which case the core never calls this).
51    fn load(&self) -> Result<TlsMaterial, TlsError>;
52
53    /// Optional dynamic server-cert resolver. When `Some`, the core builds the
54    /// `ServerConfig` with this resolver INSTEAD of `load()` (so the cert can rotate
55    /// at runtime without rebuilding the acceptor — enterprise hitless ACME), and
56    /// advertises the `acme-tls/1` ALPN so an on-listener TLS-ALPN-01 challenge can
57    /// negotiate on the same port. Default `None` → the OPEN single-cert path.
58    fn resolver(&self) -> Option<Arc<dyn ResolvesServerCert>> {
59        None
60    }
61
62    /// Optional client-certificate verifier. When `Some`, the listener requires and
63    /// verifies client certificates (mTLS / managed PKI). Default `None` → no client
64    /// auth, exactly as before.
65    fn client_verifier(&self) -> Option<Arc<dyn ClientCertVerifier>> {
66        None
67    }
68}
69
70/// Why building the TLS terminator failed. All variants are fatal at boot (a required TLS
71/// that cannot be built must stop the proxy, never degrade to cleartext).
72#[derive(Debug)]
73pub enum TlsError {
74    Io { path: PathBuf, source: std::io::Error },
75    NoCertificates(PathBuf),
76    NoPrivateKey(PathBuf),
77    Rustls(tokio_rustls::rustls::Error),
78}
79
80impl std::fmt::Display for TlsError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::Io { path, source } =>
84                write!(f, "cannot read TLS file {}: {source}", path.display()),
85            Self::NoCertificates(p) =>
86                write!(f, "no PEM certificates found in {}", p.display()),
87            Self::NoPrivateKey(p) =>
88                write!(f, "no PEM private key found in {}", p.display()),
89            Self::Rustls(e) => write!(f, "TLS configuration error: {e}"),
90        }
91    }
92}
93
94impl std::error::Error for TlsError {}
95
96/// OPEN cert source: read a PEM certificate chain + private key from two files.
97pub struct FileCertSource {
98    cert_path: PathBuf,
99    key_path: PathBuf,
100}
101
102impl FileCertSource {
103    pub fn new(cert_path: impl Into<PathBuf>, key_path: impl Into<PathBuf>) -> Self {
104        Self { cert_path: cert_path.into(), key_path: key_path.into() }
105    }
106}
107
108impl TlsCertSource for FileCertSource {
109    fn load(&self) -> Result<TlsMaterial, TlsError> {
110        Ok(TlsMaterial {
111            cert_chain: load_certs(&self.cert_path)?,
112            private_key: load_key(&self.key_path)?,
113        })
114    }
115}
116
117/// A malformed PEM file reads as an I/O error on that file (invalid data).
118fn pem_io_error(e: pem::Error) -> std::io::Error {
119    std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
120}
121
122fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>, TlsError> {
123    let data = std::fs::read(path).map_err(|source| TlsError::Io { path: path.to_path_buf(), source })?;
124    let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&data)
125        .collect::<Result<_, _>>()
126        .map_err(|e| TlsError::Io { path: path.to_path_buf(), source: pem_io_error(e) })?;
127    if certs.is_empty() {
128        return Err(TlsError::NoCertificates(path.to_path_buf()));
129    }
130    Ok(certs)
131}
132
133fn load_key(path: &Path) -> Result<PrivateKeyDer<'static>, TlsError> {
134    let data = std::fs::read(path).map_err(|source| TlsError::Io { path: path.to_path_buf(), source })?;
135    match PrivateKeyDer::from_pem_slice(&data) {
136        Ok(key) => Ok(key),
137        Err(pem::Error::NoItemsFound) => Err(TlsError::NoPrivateKey(path.to_path_buf())),
138        Err(e) => Err(TlsError::Io { path: path.to_path_buf(), source: pem_io_error(e) }),
139    }
140}
141
142/// Build a rustls [`ServerConfig`] from a cert source + ALPN list. Uses the `ring` crypto
143/// provider explicitly (no process-global `install_default`, so multiple configs — e.g. in
144/// tests — never race over the default).
145pub fn build_server_config(source: &dyn TlsCertSource, alpn: &[String]) -> Result<ServerConfig, TlsError> {
146    let provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
147    let builder = ServerConfig::builder_with_provider(provider)
148        .with_safe_default_protocol_versions()
149        .map_err(TlsError::Rustls)?;
150
151    // Client auth: an injected verifier (enterprise mTLS / managed PKI) or none (OPEN).
152    let builder = match source.client_verifier() {
153        Some(verifier) => builder.with_client_cert_verifier(verifier),
154        None => builder.with_no_client_auth(),
155    };
156
157    // Server cert: a dynamic resolver (enterprise hitless rotation / ACME) takes over and
158    // bypasses load(); otherwise the single cert from load() (the OPEN FileCertSource path,
159    // byte-identical to before).
160    let mut cfg = match source.resolver() {
161        Some(resolver) => builder.with_cert_resolver(resolver),
162        None => {
163            let material = source.load()?;
164            builder
165                .with_single_cert(material.cert_chain, material.private_key)
166                .map_err(TlsError::Rustls)?
167        }
168    };
169
170    cfg.alpn_protocols = alpn.iter().map(|p| p.as_bytes().to_vec()).collect();
171    // TLS-ALPN-01: when a resolver drives ACME on this same listener, the challenge is
172    // negotiated via the `acme-tls/1` ALPN — advertise it so the validation handshake can
173    // select it (the resolver serves the challenge cert for it). Only when a resolver is set,
174    // so the normal (None) path keeps exactly the configured ALPN list.
175    if source.resolver().is_some() {
176        cfg.alpn_protocols.push(b"acme-tls/1".to_vec());
177    }
178    Ok(cfg)
179}
180
181/// Build the [`TlsAcceptor`] with an **injected** cert source, or `Ok(None)` when TLS is
182/// off. `tls.enabled` (operator switch) and `tls.alpn` still come from config; the source
183/// only governs cert *provenance* — when `Some`, the file paths in config are ignored, so
184/// an enterprise ACME/managed-PKI source needs no `cert_path`/`key_path`. `None` falls back
185/// to the OPEN [`FileCertSource`]. An enabled-but-unbuildable terminator is `Err` → the
186/// proxy fails to bind (fatal boot, no silent cleartext fallback).
187pub fn acceptor_from_source(
188    tls: &TlsConfig,
189    source: Option<Arc<dyn TlsCertSource>>,
190) -> Result<Option<TlsAcceptor>, TlsError> {
191    if !tls.enabled {
192        return Ok(None);
193    }
194    let cfg = match source {
195        Some(s) => build_server_config(s.as_ref(), &tls.alpn)?,
196        None => build_server_config(&FileCertSource::new(&tls.cert_path, &tls.key_path), &tls.alpn)?,
197    };
198    Ok(Some(TlsAcceptor::from(Arc::new(cfg))))
199}
200
201/// Build the [`TlsAcceptor`] from config with the default OPEN [`FileCertSource`]. Thin
202/// wrapper over [`acceptor_from_source`] with no injected source.
203pub fn acceptor_from_config(tls: &TlsConfig) -> Result<Option<TlsAcceptor>, TlsError> {
204    acceptor_from_source(tls, None)
205}