Skip to main content

plecto_control/
tls.rs

1//! TLS termination config (ADR 000014): build a rustls `ServerConfig` from the manifest's
2//! `[[tls]]` certs at load/reload. Cert selection is by SNI — a per-host cert, falling back to a
3//! host-less default; if neither matches, the handshake is refused (no cert to present), which is
4//! fail-closed. Building here (not in the server) means a bad cert aborts the whole build, so a
5//! failed reload never swaps in a TLS config that cannot serve, and the config rides `ActiveConfig`
6//! behind the same `ArcSwap` as the filter set. Sync rustls + the `aws_lc_rs` provider (ADR
7//! 000051 — one crypto backend shared with the QUIC config and the host's `sigstore` dependency);
8//! the async acceptor lives in `plecto-server`. Session resumption is stateless (ADR 000052): by
9//! default one process-lifetime ticket key (6h rotation / 12h window, node-local per ADR 000053),
10//! or — opt-in via `[resumption]` (ADR 000062) — cert-bound keys derived from a shared file
11//! (`stek.rs`) so tickets resume across replicas. Either way: no stateful session cache, 0-RTT
12//! refused as an invariant.
13
14use std::collections::HashMap;
15use std::path::Path;
16use std::sync::{Arc, LazyLock, OnceLock};
17
18use rustls::ServerConfig;
19use rustls::crypto::aws_lc_rs as provider;
20use rustls::pki_types::pem::PemObject;
21use rustls::pki_types::{CertificateDer, PrivateKeyDer};
22use rustls::server::{ClientHello, NoServerSessionStorage, ProducesTickets, ResolvesServerCert};
23use rustls::sign::CertifiedKey;
24use sha2::{Digest, Sha256};
25
26use crate::error::ControlError;
27use crate::manifest::{ClientAuth, Resumption, TlsCert};
28use crate::stek::SharedStekTicketer;
29
30/// SNI cert selection (ADR 000014): a per-host cert map plus an optional default. `resolve` is
31/// called by rustls during the handshake with the client's SNI; no match and no default → `None`,
32/// and rustls fails the handshake (fail-closed — Plecto presents no cert it was not configured to).
33#[derive(Debug)]
34struct SniResolver {
35    by_host: HashMap<String, Arc<CertifiedKey>>,
36    default: Option<Arc<CertifiedKey>>,
37}
38
39impl ResolvesServerCert for SniResolver {
40    fn resolve(&self, hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
41        hello
42            .server_name()
43            .and_then(|name| {
44                // The map keys are lowercased at build; a wire SNI is almost always lowercase
45                // already, so the common case looks up borrowed — the lowercasing allocation is
46                // paid only when a client actually sent uppercase.
47                if name.bytes().any(|b| b.is_ascii_uppercase()) {
48                    self.by_host.get(&name.to_ascii_lowercase())
49                } else {
50                    self.by_host.get(name)
51                }
52            })
53            .cloned()
54            .or_else(|| self.default.clone())
55    }
56}
57
58/// The process-lifetime session-ticket producer (ADR 000052): `aws_lc_rs::Ticketer` = rustls'
59/// `TicketRotator` over RFC 5077 §4 self-encrypted tickets, 6h key rotation with a 12h acceptance
60/// window (current + previous key). ONE instance for the whole process — shared by the TCP and
61/// QUIC configs (a ticket obtained over either resumes on both; both configs present the same
62/// certs via the shared `SniResolver`, so rustls' cross-config resumption caveat does not bite)
63/// and, critically, surviving manifest reloads: rebuilding the `ServerConfig`s must not invalidate
64/// outstanding tickets. Keys live in process memory only, node-local (ADR 000053) — never on disk,
65/// never in the manifest.
66///
67/// **Exception — `[listen.client_auth]`:** when mTLS gates access, the build uses a per-CA-content
68/// ticketer (`client_auth_ticketer`), never this process-wide producer. TLS 1.3 PSK resumption
69/// skips CertificateRequest, so ticket keys must be isolated from the anonymous context and
70/// rotate with the trust roots that authenticated the peer.
71fn shared_ticketer() -> Result<Arc<dyn ProducesTickets>, ControlError> {
72    static TICKETER: OnceLock<Arc<dyn ProducesTickets>> = OnceLock::new();
73    if let Some(ticketer) = TICKETER.get() {
74        return Ok(ticketer.clone());
75    }
76    // Fallible init outside `get_or_init` (which can't return errors): the race where two threads
77    // both construct is benign — one wins the OnceLock, the loser's keys are dropped unissued.
78    let fresh = provider::Ticketer::new().map_err(|e| ControlError::TlsCert {
79        host: None,
80        path: String::new(),
81        reason: format!("session-ticket key init: {e}"),
82    })?;
83    Ok(TICKETER.get_or_init(|| fresh).clone())
84}
85
86/// Ticket producer for an mTLS-enabled build, keyed by the CA bundle's content digest: a reload
87/// that leaves the CA bytes unchanged keeps outstanding tickets resumable (no full-handshake
88/// stampede for an unrelated config edit), while a CA rotation re-keys so no ticket outlives the
89/// trust roots that authenticated its peer (TLS 1.3 PSK resumption skips CertificateRequest).
90/// Never the process-wide anonymous ticketer: an anonymous-era ticket must not resume once
91/// client_auth gates access. The map holds one entry per distinct CA content seen this process —
92/// operator-driven rotations, so bounded in practice.
93fn client_auth_ticketer(ca_bytes: &[u8]) -> Result<Arc<dyn ProducesTickets>, ControlError> {
94    type ByCaDigest = HashMap<[u8; 32], Arc<dyn ProducesTickets>>;
95    static BY_CA: LazyLock<parking_lot::Mutex<ByCaDigest>> =
96        LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
97    let digest: [u8; 32] = Sha256::digest(ca_bytes).into();
98    let mut by_ca = BY_CA.lock();
99    if let Some(ticketer) = by_ca.get(&digest) {
100        return Ok(ticketer.clone());
101    }
102    let fresh = provider::Ticketer::new().map_err(|e| ControlError::TlsCert {
103        host: None,
104        path: String::new(),
105        reason: format!("client-auth session-ticket key init: {e}"),
106    })?;
107    by_ca.insert(digest, fresh.clone());
108    Ok(fresh)
109}
110
111/// The TLS configs built from `[[tls]]`: the TCP config (HTTP/1.1 + HTTP/2 via ALPN, ADR 000015)
112/// and the QUIC config (HTTP/3, ADR 000016). Both share one SNI cert resolver, so a host's cert is
113/// presented identically over TCP and QUIC.
114pub(crate) struct TlsConfigs {
115    /// HTTP/1.1 + HTTP/2 over TCP: ALPN `[h2, http/1.1]`, TLS 1.2 + 1.3.
116    pub(crate) tcp: Arc<ServerConfig>,
117    /// HTTP/3 over QUIC: ALPN `[h3]`, TLS 1.3 only.
118    pub(crate) quic: Arc<ServerConfig>,
119}
120
121/// Build the TCP + QUIC TLS `ServerConfig`s from the manifest's `[[tls]]` entries, or `None` when
122/// there are none (the server then serves plain HTTP/1.1, no h3). Any unreadable / unparsable /
123/// duplicate cert is a fail-closed `ControlError` that aborts the caller's build (ADR 000014). The
124/// TCP config advertises ALPN `[h2, http/1.1]` (h2 preferred, ADR 000015); the QUIC config
125/// advertises `[h3]` and is TLS-1.3 only (QUIC mandates 1.3, RFC 9001). Both share one `SniResolver`.
126///
127/// `client_auth` carries the CA bundle's bytes alongside the manifest section: the caller reads
128/// the file ONCE (`Manifest::read_client_auth_ca`) and shares it between the config version and
129/// the verifier built here, so the two can never describe different trust roots.
130pub(crate) fn build_server_configs(
131    entries: &[TlsCert],
132    resumption: Option<&Resumption>,
133    client_auth: Option<(&ClientAuth, &[u8])>,
134    base_dir: &Path,
135) -> Result<Option<TlsConfigs>, ControlError> {
136    // ADR 000062 (b) / 000078: TLS 1.3 resumption accepts a ticket without re-running
137    // client-certificate verification (CertificateRequest is omitted under a resumption PSK;
138    // rustls restores the chain from the ticket instead). A shared STEK lets that ticket open
139    // on every replica — amplifying stolen-ticket blast radius and the CVE-2025-23419-class
140    // cross-context risk — so refuse the combination. Per-node resumption stays available:
141    // the ticket still carries the verified identity, and its keys never leave this node.
142    if let (Some(resumption), Some(_)) = (resumption, client_auth) {
143        return Err(ControlError::Stek {
144            path: resumption.stek_file.clone(),
145            reason: "[resumption] shared STEK cannot be combined with [listen.client_auth]: a \
146                     cross-replica ticket would resume without re-running client-certificate \
147                     verification (ADR 000062 (b) / 000078) — drop [resumption] to keep \
148                     per-node resumption"
149                .to_string(),
150        });
151    }
152    if entries.is_empty() {
153        // `[resumption]` without any `[[tls]]` is a config mistake, not a no-op: the operator
154        // asked for cross-replica resumption on a proxy that terminates no TLS. Fail closed.
155        if let Some(resumption) = resumption {
156            return Err(ControlError::Stek {
157                path: resumption.stek_file.clone(),
158                reason: "[resumption] requires at least one [[tls]] cert (ticket keys bind to \
159                         the cert set, ADR 000062)"
160                    .to_string(),
161            });
162        }
163        // Same shape for `[listen.client_auth]`: there is no TLS handshake to authenticate a
164        // client on, so the section is a mistake, not a no-op.
165        if let Some((auth, _)) = client_auth {
166            return Err(ControlError::ClientAuthCa {
167                path: auth.ca_path.clone(),
168                reason: "[listen.client_auth] requires at least one [[tls]] cert — a plain-HTTP \
169                         listener has no handshake to verify a client certificate in"
170                    .to_string(),
171            });
172        }
173        return Ok(None);
174    }
175
176    let mut by_host: HashMap<String, Arc<CertifiedKey>> = HashMap::new();
177    let mut default: Option<Arc<CertifiedKey>> = None;
178    let mut all_certified: Vec<Arc<CertifiedKey>> = Vec::with_capacity(entries.len());
179    for entry in entries {
180        let certified = Arc::new(load_certified_key(entry, base_dir)?);
181        all_certified.push(certified.clone());
182        match &entry.host {
183            Some(host) => {
184                if by_host
185                    .insert(host.to_ascii_lowercase(), certified)
186                    .is_some()
187                {
188                    return Err(tls_err(entry, "duplicate cert for this SNI host"));
189                }
190            }
191            None => {
192                if default.replace(certified).is_some() {
193                    return Err(tls_err(entry, "more than one default (host-less) cert"));
194                }
195            }
196        }
197    }
198
199    // One resolver, shared by both configs (Arc clone): SNI selects the same cert over TCP and QUIC.
200    let resolver = Arc::new(SniResolver { by_host, default });
201    // One ticket producer, ditto: stateless TLS 1.3 resumption over both paths. Default is the
202    // per-node process-lifetime key (ADR 000052); `[resumption]` swaps in the shared cert-bound
203    // ticketer (ADR 000062) — rebuilt each build, which is safe because its keys are a pure
204    // function of (key file, cert set): unchanged inputs re-derive the same keys across reloads.
205    // `[listen.client_auth]` uses the per-CA-content ticketer: a CA rotation re-keys (PSK
206    // resumption must not outlive the verifier), an unrelated reload keeps tickets valid.
207    let ticketer: Arc<dyn ProducesTickets> = match (resumption, client_auth) {
208        (Some(resumption), _) => SharedStekTicketer::from_manifest(
209            resumption,
210            base_dir,
211            cert_set_binding(&all_certified, resumption)?,
212        )?,
213        (None, Some((_, ca_bytes))) => client_auth_ticketer(ca_bytes)?,
214        (None, None) => shared_ticketer()?,
215    };
216
217    // Downstream client-certificate verification ([listen.client_auth], ADR 000078): one
218    // required-mode verifier for BOTH configs — the granularity is the listener, and TCP + QUIC
219    // are two wire faces of the same one. `None` = today's no-client-auth listener.
220    let client_verifier = client_auth
221        .map(|(auth, ca_bytes)| build_client_verifier(auth, ca_bytes))
222        .transpose()?;
223
224    // TCP: HTTP/1.1 + HTTP/2 via ALPN (h2 preferred, ADR 000015), TLS 1.2 + 1.3.
225    let tcp_builder = ServerConfig::builder_with_provider(Arc::new(provider::default_provider()))
226        .with_safe_default_protocol_versions()
227        .map_err(provider_init_err)?;
228    let mut tcp = match &client_verifier {
229        Some(verifier) => tcp_builder.with_client_cert_verifier(verifier.clone()),
230        None => tcp_builder.with_no_client_auth(),
231    }
232    .with_cert_resolver(resolver.clone());
233    tcp.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
234
235    // QUIC: HTTP/3, ALPN `h3`, TLS 1.3 only (ADR 000016). 0-RTT stays disabled: `max_early_data_size`
236    // is left at its default 0, so the server refuses TLS early data. A gateway must not forward
237    // early-data requests to upstreams that may not emit 425 (Too Early) (RFC 8470), so refusing it
238    // outright is the only safe choice here. The stateless ticketer below refuses it a second way
239    // (rustls only configures early data when the ticketer is DISabled) — but the invariant test
240    // pins `max_early_data_size == 0` on its own, so a rustls behavior change cannot reopen 0-RTT.
241    let quic_builder = ServerConfig::builder_with_provider(Arc::new(provider::default_provider()))
242        .with_protocol_versions(&[&rustls::version::TLS13])
243        .map_err(provider_init_err)?;
244    let mut quic = match &client_verifier {
245        Some(verifier) => quic_builder.with_client_cert_verifier(verifier.clone()),
246        None => quic_builder.with_no_client_auth(),
247    }
248    .with_cert_resolver(resolver);
249    quic.alpn_protocols = vec![b"h3".to_vec()];
250
251    // Stateless session resumption (ADR 000052), replacing the implicit rustls default (a
252    // 256-entry stateful cache whose tickets are lookup keys). The self-encrypted ticket carries
253    // the session; per-session server memory is ZERO and there is no cache-size knob to fall off —
254    // the same bounded-memory discipline as native rate limit (ADR 000033, CWE-770). TLS 1.2
255    // clients get RFC 5077 stateless tickets from the same producer; 1.2 session-ID resumption is
256    // gone with the cache, which is the point.
257    for config in [&mut tcp, &mut quic] {
258        config.ticketer = ticketer.clone();
259        config.session_storage = Arc::new(NoServerSessionStorage {});
260    }
261
262    Ok(Some(TlsConfigs {
263        tcp: Arc::new(tcp),
264        quic: Arc::new(quic),
265    }))
266}
267
268/// Build the required-mode client-certificate verifier from `[listen.client_auth]` (ADR 000078).
269/// `ca_path` holds trust ANCHORS only — a client's intermediates belong in the chain the client
270/// presents, per X.509 path building. Required mode is the only mode: a peer presenting no (or
271/// an untrusted) certificate fails the handshake, since "request but allow none" only pays off
272/// once verified identities propagate to filters — declared deferred by the ADR. Certificate
273/// revocation (CRL/OCSP) is likewise out of this slice: no CRLs are configured, matching that
274/// declared deferral.
275fn build_client_verifier(
276    auth: &ClientAuth,
277    ca_bytes: &[u8],
278) -> Result<Arc<dyn rustls::server::danger::ClientCertVerifier>, ControlError> {
279    let err = |reason: String| ControlError::ClientAuthCa {
280        path: auth.ca_path.clone(),
281        reason,
282    };
283    let certs = CertificateDer::pem_slice_iter(ca_bytes)
284        .collect::<Result<Vec<_>, _>>()
285        .map_err(|e| err(format!("bad CA PEM: {e}")))?;
286    if certs.is_empty() {
287        return Err(err("no certificates in CA PEM".to_string()));
288    }
289    let mut roots = rustls::RootCertStore::empty();
290    let (added, _ignored) = roots.add_parsable_certificates(certs);
291    if added == 0 {
292        return Err(err("no usable trust anchor in CA PEM".to_string()));
293    }
294    rustls::server::WebPkiClientVerifier::builder_with_provider(
295        Arc::new(roots),
296        Arc::new(provider::default_provider()),
297    )
298    .build()
299    .map_err(|e| err(format!("client verifier: {e}")))
300}
301
302/// Build the rustls `ClientConfig` for one `[upstream.tls]` entry (ADR 000042): server
303/// certificate verification is ALWAYS on — against the manifest's CA bundle when `ca_path` is
304/// set (replacing, not extending, the webpki roots: an internal-CA deployment trusts exactly its
305/// CA), else against the webpki (Mozilla) roots. ALPN is left unset here BY CONTRACT: the fast
306/// path's HTTPS connector owns it (hyper-rustls rejects a pre-populated list) and advertises
307/// `[h2, http/1.1]` — the negotiation result, not manifest config, selects the upstream protocol.
308/// Built at load/reload like the server configs above, so a bad CA fails the build closed.
309/// When `client_cert_path`/`client_key_path` declare an identity (upstream mTLS, ADR 000078),
310/// every TLS leg to this upstream presents it — forwarded requests and health probes share the
311/// connector this config feeds.
312pub(crate) fn build_upstream_client_config(
313    upstream_name: &str,
314    tls: &crate::manifest::UpstreamTls,
315    base_dir: &Path,
316) -> Result<Arc<rustls::ClientConfig>, ControlError> {
317    let mut roots = rustls::RootCertStore::empty();
318    match &tls.ca_path {
319        Some(ca_path) => {
320            let err = |reason: String| ControlError::UpstreamTlsCa {
321                upstream: upstream_name.to_string(),
322                path: ca_path.clone(),
323                reason,
324            };
325            let bytes = std::fs::read(base_dir.join(ca_path))
326                .map_err(|e| err(format!("read failed: {e}")))?;
327            let certs = CertificateDer::pem_slice_iter(&bytes)
328                .collect::<Result<Vec<_>, _>>()
329                .map_err(|e| err(format!("bad CA PEM: {e}")))?;
330            if certs.is_empty() {
331                return Err(err("no certificates in CA PEM".to_string()));
332            }
333            let (added, _ignored) = roots.add_parsable_certificates(certs);
334            if added == 0 {
335                return Err(err("no usable root certificate in CA PEM".to_string()));
336            }
337        }
338        None => {
339            roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
340        }
341    }
342    let builder =
343        rustls::ClientConfig::builder_with_provider(Arc::new(provider::default_provider()))
344            .with_safe_default_protocol_versions()
345            .map_err(provider_init_err)?
346            .with_root_certificates(roots);
347    let config = match (&tls.client_cert_path, &tls.client_key_path) {
348        (None, None) => builder.with_no_client_auth(),
349        (Some(cert_path), Some(key_path)) => {
350            let cerr = |path: &str, reason: String| ControlError::UpstreamClientCert {
351                upstream: upstream_name.to_string(),
352                path: path.to_string(),
353                reason,
354            };
355            let cert_bytes = std::fs::read(base_dir.join(cert_path))
356                .map_err(|e| cerr(cert_path, format!("read failed: {e}")))?;
357            let chain = CertificateDer::pem_slice_iter(&cert_bytes)
358                .collect::<Result<Vec<_>, _>>()
359                .map_err(|e| cerr(cert_path, format!("bad cert PEM: {e}")))?;
360            if chain.is_empty() {
361                return Err(cerr(
362                    cert_path,
363                    "no certificates in client cert PEM".to_string(),
364                ));
365            }
366            let key_file = base_dir.join(key_path);
367            owner_only(&key_file).map_err(|reason| cerr(key_path, reason))?;
368            // Same Zeroizing discipline as `read_key` for [[tls]] server keys: wipe the source
369            // PEM buffer after `PrivateKeyDer` has copied the material.
370            let key_bytes = zeroize::Zeroizing::new(
371                std::fs::read(&key_file)
372                    .map_err(|e| cerr(key_path, format!("read failed: {e}")))?,
373            );
374            let key = PrivateKeyDer::from_pem_slice(&key_bytes)
375                .map_err(|e| cerr(key_path, format!("bad key PEM: {e}")))?;
376            builder
377                .with_client_auth_cert(chain, key)
378                .map_err(|e| cerr(cert_path, format!("client cert/key rejected: {e}")))?
379        }
380        (Some(path), None) | (None, Some(path)) => {
381            return Err(ControlError::UpstreamClientCert {
382                upstream: upstream_name.to_string(),
383                path: path.clone(),
384                reason: "client_cert_path and client_key_path must be set together — half an \
385                         identity cannot be presented (fail-closed)"
386                    .to_string(),
387            });
388        }
389    };
390    Ok(Arc::new(config))
391}
392
393/// The ADR 000062 (d) key-file discipline applied to this slice's new private keys: reject
394/// group/other readability outright (unix; other platforms have no mode bits to check). The
395/// pre-existing `[[tls]]` server keys are NOT checked — retrofitting would break running
396/// configs, a separate decision (ADR 000078 grill 確定 6).
397fn owner_only(path: &Path) -> Result<(), String> {
398    #[cfg(unix)]
399    {
400        use std::os::unix::fs::PermissionsExt;
401        let mode = std::fs::metadata(path)
402            .map_err(|e| format!("read failed: {e}"))?
403            .permissions()
404            .mode();
405        if mode & 0o077 != 0 {
406            return Err(format!(
407                "file mode {:o} is readable by group/other — chmod 600 (owner-only) required",
408                mode & 0o777
409            ));
410        }
411    }
412    #[cfg(not(unix))]
413    {
414        let _ = path;
415    }
416    Ok(())
417}
418
419/// Parse one `[upstream.tls] sni` verification-name override into a rustls `ServerName` (ADR
420/// 000050). Fail-closed at build, like the CA bundle above: a name that parses as neither a DNS
421/// name nor an IP address aborts the reconcile before the registry mutates, rather than letting
422/// every TLS leg to this upstream fail at request time.
423pub(crate) fn parse_upstream_sni(
424    upstream_name: &str,
425    sni: &str,
426) -> Result<rustls::pki_types::ServerName<'static>, ControlError> {
427    rustls::pki_types::ServerName::try_from(sni.to_string()).map_err(|e| {
428        ControlError::UpstreamTlsSni {
429            upstream: upstream_name.to_string(),
430            sni: sni.to_string(),
431            reason: e.to_string(),
432        }
433    })
434}
435
436/// The cert-set binding identity fed to the shared-STEK key schedule (ADR 000062 (a)): SHA-256
437/// over the sorted, deduplicated SPKI SHA-256 fingerprints of every `[[tls]]` cert. SPKI (the
438/// RFC 7469 pin basis), not the whole cert: the key pair is the cryptographic identity, so a
439/// routine renewal under the same key keeps outstanding tickets resumable, while any cert-set
440/// difference between deployments derives disjoint ticket keys. `ProducesTickets` is per-config
441/// (rustls cannot tell the ticketer which SNI cert a handshake selected), so the binding is the
442/// SET — cross-SNI acceptance inside one config is separately refused by rustls' own SNI match
443/// on resumption (`resumedata.sni == sni`, pinned by an E2E test).
444///
445/// The SPKI comes from the loaded private key (`SigningKey::public_key`), not from parsing the
446/// cert: `load_certified_key` already verified they match (`keys_match`), and this avoids an
447/// X.509 parser dependency. A provider that cannot expose it fails the build closed — shared
448/// STEK without a binding identity is exactly the unbound sharing the ADR rejects.
449fn cert_set_binding(
450    certified: &[Arc<CertifiedKey>],
451    resumption: &Resumption,
452) -> Result<[u8; 32], ControlError> {
453    use sha2::{Digest, Sha256};
454    let mut fingerprints: Vec<[u8; 32]> = Vec::with_capacity(certified.len());
455    for certified_key in certified {
456        let spki = certified_key
457            .key
458            .public_key()
459            .ok_or_else(|| ControlError::Stek {
460                path: resumption.stek_file.clone(),
461                reason:
462                    "a [[tls]] key's SPKI is unavailable from the provider; shared STEK cannot \
463                     bind tickets to the cert set (ADR 000062 (a))"
464                        .to_string(),
465            })?;
466        fingerprints.push(Sha256::digest(spki.as_ref()).into());
467    }
468    fingerprints.sort_unstable();
469    fingerprints.dedup();
470    let mut hasher = Sha256::new();
471    for fingerprint in &fingerprints {
472        hasher.update(fingerprint);
473    }
474    Ok(hasher.finalize().into())
475}
476
477/// A rustls provider/version init failure (not a per-cert fault) mapped to a fail-closed error.
478fn provider_init_err(e: rustls::Error) -> ControlError {
479    ControlError::TlsCert {
480        host: None,
481        path: String::new(),
482        reason: format!("rustls provider init: {e}"),
483    }
484}
485
486/// Read + parse one `[[tls]]` entry's PEM cert chain and private key into a `CertifiedKey`.
487fn load_certified_key(entry: &TlsCert, base_dir: &Path) -> Result<CertifiedKey, ControlError> {
488    let cert_chain = read_certs(entry, base_dir)?;
489    if cert_chain.is_empty() {
490        return Err(tls_err(entry, "no certificates in cert_path PEM"));
491    }
492    let key = read_key(entry, base_dir)?;
493    let signing_key = provider::sign::any_supported_type(&key)
494        .map_err(|e| tls_err(entry, &format!("unsupported private key: {e}")))?;
495    let certified = CertifiedKey::new(cert_chain, signing_key);
496    // `CertifiedKey::new` does NOT verify the private key matches the leaf certificate, so a
497    // mismatched cert/key pair would build successfully and then fail EVERY TLS handshake at
498    // runtime — contradicting this module's fail-closed-at-build contract. Verify here. `Unknown`
499    // (the provider can't expose the key's SPKI) is not a mismatch — accept it, mirroring rustls'
500    // own `CertifiedKey::from_der`. The `aws_lc_rs` provider does expose it, so a real mismatch is
501    // caught.
502    match certified.keys_match() {
503        Ok(()) | Err(rustls::Error::InconsistentKeys(rustls::InconsistentKeys::Unknown)) => {}
504        Err(e) => return Err(tls_err(entry, &format!("cert/key mismatch: {e}"))),
505    }
506    Ok(certified)
507}
508
509fn read_certs(
510    entry: &TlsCert,
511    base_dir: &Path,
512) -> Result<Vec<CertificateDer<'static>>, ControlError> {
513    let bytes = std::fs::read(base_dir.join(&entry.cert_path))
514        .map_err(|e| tls_err_path(entry, &entry.cert_path, &format!("read failed: {e}")))?;
515    // PEM parsing lives in rustls-pki-types now (rustls-pemfile is unmaintained, RUSTSEC-2025-0134).
516    CertificateDer::pem_slice_iter(&bytes)
517        .collect::<Result<Vec<_>, _>>()
518        .map_err(|e| tls_err_path(entry, &entry.cert_path, &format!("bad cert PEM: {e}")))
519}
520
521fn read_key(entry: &TlsCert, base_dir: &Path) -> Result<PrivateKeyDer<'static>, ControlError> {
522    // `Zeroizing`: `PrivateKeyDer` wipes its own copy on drop, but the source PEM buffer would
523    // otherwise linger in freed heap — wipe it too (same discipline as the STEK IKM read).
524    let bytes = zeroize::Zeroizing::new(
525        std::fs::read(base_dir.join(&entry.key_path))
526            .map_err(|e| tls_err_path(entry, &entry.key_path, &format!("read failed: {e}")))?,
527    );
528    // `from_pem_slice` returns the first private key, or a typed error if there is none / it is
529    // malformed — so the previous "no key" branch folds into the error path.
530    PrivateKeyDer::from_pem_slice(&bytes)
531        .map_err(|e| tls_err_path(entry, &entry.key_path, &format!("bad key PEM: {e}")))
532}
533
534fn tls_err(entry: &TlsCert, reason: &str) -> ControlError {
535    tls_err_path(entry, &entry.cert_path, reason)
536}
537
538fn tls_err_path(entry: &TlsCert, path: &str, reason: &str) -> ControlError {
539    ControlError::TlsCert {
540        host: entry.host.clone(),
541        path: path.to_string(),
542        reason: reason.to_string(),
543    }
544}
545
546impl crate::Control {
547    /// The active TLS server config (ADR 000014), or `None` for plain HTTP/1.1. The fast-path
548    /// server reads this per accepted connection, so a reload's new certs apply to new connections
549    /// while in-flight ones keep the cert they negotiated with.
550    pub fn tls_config(&self) -> Option<Arc<ServerConfig>> {
551        self.active.load().tls.clone()
552    }
553
554    /// The active QUIC TLS config for HTTP/3 (ADR 000016): ALPN `h3`, TLS 1.3, sharing the TCP
555    /// config's SNI cert resolver. `None` whenever there is no `[[tls]]` (h3 requires TLS, so it is
556    /// only offered alongside TLS termination). The fast-path server reads this once to decide
557    /// whether to bind a QUIC listener and what to advertise via `Alt-Svc`.
558    pub fn quic_tls_config(&self) -> Option<Arc<ServerConfig>> {
559        self.active.load().quic_tls.clone()
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    /// A 64-byte owner-only key file + `[resumption]` entry in `dir` (shared STEK, ADR 000062).
568    fn resumption_entry(dir: &Path, fill: u8) -> Resumption {
569        let path = dir.join("stek.key");
570        std::fs::write(&path, [fill; crate::stek::STEK_FILE_LEN]).unwrap();
571        #[cfg(unix)]
572        {
573            use std::os::unix::fs::PermissionsExt;
574            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
575        }
576        Resumption {
577            stek_file: path.to_str().unwrap().to_string(),
578            max_age_hours: 24,
579        }
580    }
581
582    /// A fresh self-signed cert written to a temp dir, plus a host-less (default) `[[tls]]` entry.
583    fn default_cert_entry() -> (tempfile::TempDir, TlsCert) {
584        let generated = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
585        let dir = tempfile::tempdir().unwrap();
586        let cert_path = dir.path().join("cert.pem");
587        let key_path = dir.path().join("key.pem");
588        std::fs::write(&cert_path, generated.cert.pem()).unwrap();
589        std::fs::write(&key_path, generated.key_pair.serialize_pem()).unwrap();
590        let entry = TlsCert {
591            host: None,
592            cert_path: cert_path.to_str().unwrap().to_string(),
593            key_path: key_path.to_str().unwrap().to_string(),
594        };
595        (dir, entry)
596    }
597
598    #[test]
599    fn builds_tcp_and_quic_configs_with_distinct_alpn() {
600        let (dir, entry) = default_cert_entry();
601        let configs = build_server_configs(&[entry], None, None, dir.path())
602            .unwrap()
603            .expect("a cert entry yields TCP + QUIC configs");
604        // TCP advertises h2 then http/1.1 (ADR 000015); QUIC advertises only h3 (ADR 000016).
605        assert_eq!(
606            configs.tcp.alpn_protocols,
607            vec![b"h2".to_vec(), b"http/1.1".to_vec()],
608            "TCP ALPN is h2 then http/1.1"
609        );
610        assert_eq!(
611            configs.quic.alpn_protocols,
612            vec![b"h3".to_vec()],
613            "QUIC ALPN is h3 only"
614        );
615    }
616
617    #[test]
618    fn stateless_resumption_invariants() {
619        // ADR 000052's invariants, pinned per path so a rustls default change (or a refactor that
620        // touches only one config) cannot silently reintroduce the stateful cache or 0-RTT.
621        let (dir, entry) = default_cert_entry();
622        let configs = build_server_configs(&[entry], None, None, dir.path())
623            .unwrap()
624            .expect("a cert entry yields TCP + QUIC configs");
625        for (label, config) in [("tcp", &configs.tcp), ("quic", &configs.quic)] {
626            assert!(
627                config.ticketer.enabled(),
628                "{label}: stateless session tickets are issued (ADR 000052)"
629            );
630            assert_eq!(
631                config.ticketer.lifetime(),
632                12 * 60 * 60,
633                "{label}: 12h acceptance window (6h rotation, current + previous key)"
634            );
635            assert!(
636                !config.session_storage.can_cache(),
637                "{label}: no stateful session cache — per-session server memory stays zero"
638            );
639            assert_eq!(
640                config.max_early_data_size, 0,
641                "{label}: 0-RTT stays refused (ADR 000016 / RFC 8470), independent of the \
642                 ticketer-exclusivity rustls happens to enforce today"
643            );
644        }
645    }
646
647    #[test]
648    fn ticket_key_is_process_wide_across_paths_and_builds() {
649        // One ticket producer for TCP + QUIC (a ticket obtained over either resumes on both), and
650        // the SAME producer across rebuilds — a manifest reload must not invalidate outstanding
651        // tickets (ADR 000052: process-lifetime, node-local key).
652        let (dir, entry) = default_cert_entry();
653        let a = build_server_configs(std::slice::from_ref(&entry), None, None, dir.path())
654            .unwrap()
655            .unwrap();
656        let b = build_server_configs(&[entry], None, None, dir.path())
657            .unwrap()
658            .unwrap();
659        assert!(
660            Arc::ptr_eq(&a.tcp.ticketer, &a.quic.ticketer),
661            "TCP and QUIC share one ticket producer"
662        );
663        assert!(
664            Arc::ptr_eq(&a.tcp.ticketer, &b.tcp.ticketer),
665            "a rebuild (reload) keeps the process-lifetime ticket key"
666        );
667    }
668
669    #[test]
670    fn no_tls_entries_yields_none() {
671        assert!(
672            build_server_configs(&[], None, None, std::path::Path::new("."))
673                .unwrap()
674                .is_none(),
675            "no [[tls]] means no TLS/QUIC configs (plain HTTP/1.1, no h3)"
676        );
677    }
678
679    #[test]
680    fn mismatched_cert_and_key_is_rejected() {
681        // a cert paired with a DIFFERENT private key must fail the build (fail-closed), not
682        // go live and fail every TLS handshake at runtime. Cross two self-signed pairs.
683        let a = rcgen::generate_simple_self_signed(vec!["a.example".to_string()]).unwrap();
684        let b = rcgen::generate_simple_self_signed(vec!["b.example".to_string()]).unwrap();
685        let dir = tempfile::tempdir().unwrap();
686        let cert_path = dir.path().join("cert.pem");
687        let key_path = dir.path().join("key.pem");
688        std::fs::write(&cert_path, a.cert.pem()).unwrap(); // cert A
689        std::fs::write(&key_path, b.key_pair.serialize_pem()).unwrap(); // key B (mismatch)
690        let entry = TlsCert {
691            host: None,
692            cert_path: cert_path.to_str().unwrap().to_string(),
693            key_path: key_path.to_str().unwrap().to_string(),
694        };
695        let err = match build_server_configs(&[entry], None, None, dir.path()) {
696            Err(e) => e,
697            Ok(_) => panic!("a mismatched cert/key pair must be rejected at build"),
698        };
699        assert!(matches!(err, ControlError::TlsCert { .. }));
700    }
701
702    // ----- ADR 000062: [resumption] shared STEK -----
703
704    #[test]
705    fn shared_stek_keeps_the_resumption_invariants() {
706        // ADR 000062 (c): opting into the shared ticketer changes the KEY, not the posture —
707        // stateless tickets on, no session cache, 0-RTT refused, on both paths. The lifetime
708        // hint becomes max_age_hours (the acceptance window the key file discipline enforces).
709        let (dir, entry) = default_cert_entry();
710        let resumption = resumption_entry(dir.path(), 7);
711        let configs = build_server_configs(&[entry], Some(&resumption), None, dir.path())
712            .unwrap()
713            .unwrap();
714        for (label, config) in [("tcp", &configs.tcp), ("quic", &configs.quic)] {
715            assert!(config.ticketer.enabled(), "{label}: tickets are issued");
716            assert_eq!(
717                config.ticketer.lifetime(),
718                24 * 60 * 60,
719                "{label}: the hint is max_age_hours, not the per-node 12h"
720            );
721            assert!(!config.session_storage.can_cache(), "{label}: no cache");
722            assert_eq!(
723                config.max_early_data_size, 0,
724                "{label}: 0-RTT stays refused"
725            );
726        }
727        assert!(
728            Arc::ptr_eq(&configs.tcp.ticketer, &configs.quic.ticketer),
729            "TCP and QUIC share the one shared-STEK producer (same cert set → same keys)"
730        );
731    }
732
733    #[test]
734    fn shared_stek_rebuilds_derive_interchangeable_keys() {
735        // The reload story (ADR 000062): unlike the per-node OnceLock, each build constructs a
736        // FRESH ticketer — outstanding tickets survive anyway because the keys are a pure
737        // function of (file, cert set). Pin that: a ticket sealed by build A opens in build B.
738        let (dir, entry) = default_cert_entry();
739        let resumption = resumption_entry(dir.path(), 7);
740        let a = build_server_configs(
741            std::slice::from_ref(&entry),
742            Some(&resumption),
743            None,
744            dir.path(),
745        )
746        .unwrap()
747        .unwrap();
748        let b = build_server_configs(&[entry], Some(&resumption), None, dir.path())
749            .unwrap()
750            .unwrap();
751        assert!(
752            !Arc::ptr_eq(&a.tcp.ticketer, &b.tcp.ticketer),
753            "sanity: rebuilds construct distinct ticketer objects"
754        );
755        let ticket = a.tcp.ticketer.encrypt(b"session-state").unwrap();
756        assert_eq!(
757            b.tcp.ticketer.decrypt(&ticket).as_deref(),
758            Some(&b"session-state"[..]),
759            "a reload (or another replica) re-derives the same keys"
760        );
761    }
762
763    #[test]
764    fn shared_stek_binds_tickets_to_the_cert_set() {
765        // ADR 000062 (a) at the build level: same key file, different cert → the ticket does not
766        // cross (the USENIX'25 cross-listener class, killed in the key schedule).
767        let (dir_a, entry_a) = default_cert_entry();
768        let (dir_b, entry_b) = default_cert_entry(); // a different self-signed key pair
769        let resumption = resumption_entry(dir_a.path(), 7);
770        let a = build_server_configs(&[entry_a], Some(&resumption), None, dir_a.path())
771            .unwrap()
772            .unwrap();
773        let b = build_server_configs(&[entry_b], Some(&resumption), None, dir_b.path())
774            .unwrap()
775            .unwrap();
776        let ticket = a.tcp.ticketer.encrypt(b"session-state").unwrap();
777        assert_eq!(
778            b.tcp.ticketer.decrypt(&ticket),
779            None,
780            "a deployment with a different cert set must not accept the ticket"
781        );
782    }
783
784    #[test]
785    fn resumption_without_tls_is_rejected() {
786        let dir = tempfile::tempdir().unwrap();
787        let resumption = resumption_entry(dir.path(), 7);
788        let err = match build_server_configs(&[], Some(&resumption), None, dir.path()) {
789            Err(e) => e,
790            Ok(_) => panic!("[resumption] with no [[tls]] must fail the build"),
791        };
792        assert!(matches!(err, ControlError::Stek { .. }));
793    }
794
795    #[test]
796    fn shared_stek_bad_file_fails_the_build_closed() {
797        // Wrong length and (on unix) loose permissions abort the build like a bad cert.
798        let (dir, entry) = default_cert_entry();
799        let mut resumption = resumption_entry(dir.path(), 7);
800        std::fs::write(&resumption.stek_file, [7u8; 48]).unwrap();
801        let err = match build_server_configs(
802            std::slice::from_ref(&entry),
803            Some(&resumption),
804            None,
805            dir.path(),
806        ) {
807            Err(e) => e,
808            Ok(_) => panic!("a 48-byte file must be rejected"),
809        };
810        assert!(matches!(err, ControlError::Stek { .. }));
811
812        // Out-of-range max_age_hours is rejected before the file is touched.
813        resumption = resumption_entry(dir.path(), 7);
814        resumption.max_age_hours = 169;
815        let err = match build_server_configs(&[entry], Some(&resumption), None, dir.path()) {
816            Err(e) => e,
817            Ok(_) => panic!("max_age_hours over the RFC 8446 cap must be rejected"),
818        };
819        assert!(matches!(err, ControlError::Stek { .. }));
820    }
821
822    // ----- ADR 000078: mTLS — [listen.client_auth] / [upstream.tls] client identity -----
823
824    /// A `[listen.client_auth]` whose `ca_path` file holds `ca_pem`, written into `dir`.
825    fn client_auth_entry(dir: &Path, ca_pem: &[u8]) -> ClientAuth {
826        let path = dir.join("client-ca.pem");
827        std::fs::write(&path, ca_pem).unwrap();
828        ClientAuth {
829            ca_path: path.to_str().unwrap().to_string(),
830        }
831    }
832
833    /// A PEM trust anchor a client_auth test can point `ca_path` at.
834    fn some_ca_pem() -> Vec<u8> {
835        rcgen::generate_simple_self_signed(vec!["client-ca".to_string()])
836            .unwrap()
837            .cert
838            .pem()
839            .into_bytes()
840    }
841
842    /// A fresh self-signed client identity (cert + owner-only key PEM) for `[upstream.tls]`.
843    fn upstream_client_identity(dir: &Path) -> (String, String) {
844        let generated = rcgen::generate_simple_self_signed(vec!["plecto".to_string()]).unwrap();
845        let cert_path = dir.join("client-cert.pem");
846        let key_path = dir.join("client-key.pem");
847        std::fs::write(&cert_path, generated.cert.pem()).unwrap();
848        std::fs::write(&key_path, generated.key_pair.serialize_pem()).unwrap();
849        #[cfg(unix)]
850        {
851            use std::os::unix::fs::PermissionsExt;
852            std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
853        }
854        (
855            cert_path.to_str().unwrap().to_string(),
856            key_path.to_str().unwrap().to_string(),
857        )
858    }
859
860    fn upstream_tls_with_identity(
861        cert: Option<String>,
862        key: Option<String>,
863    ) -> crate::manifest::UpstreamTls {
864        crate::manifest::UpstreamTls {
865            client_cert_path: cert,
866            client_key_path: key,
867            ..Default::default()
868        }
869    }
870
871    #[test]
872    fn upstream_client_cert_without_key_fails_closed() {
873        let dir = tempfile::tempdir().unwrap();
874        let (cert, _key) = upstream_client_identity(dir.path());
875        let tls = upstream_tls_with_identity(Some(cert), None);
876        let err = match build_upstream_client_config("u", &tls, dir.path()) {
877            Err(e) => e,
878            Ok(_) => panic!("client_cert_path without client_key_path must fail the build"),
879        };
880        assert!(matches!(err, ControlError::UpstreamClientCert { .. }));
881    }
882
883    #[test]
884    fn upstream_client_key_without_cert_fails_closed() {
885        let dir = tempfile::tempdir().unwrap();
886        let (_cert, key) = upstream_client_identity(dir.path());
887        let tls = upstream_tls_with_identity(None, Some(key));
888        let err = match build_upstream_client_config("u", &tls, dir.path()) {
889            Err(e) => e,
890            Ok(_) => panic!("client_key_path without client_cert_path must fail the build"),
891        };
892        assert!(matches!(err, ControlError::UpstreamClientCert { .. }));
893    }
894
895    #[test]
896    fn upstream_client_identity_is_loaded_into_the_config() {
897        let dir = tempfile::tempdir().unwrap();
898        let (cert, key) = upstream_client_identity(dir.path());
899        let tls = upstream_tls_with_identity(Some(cert), Some(key));
900        let config = build_upstream_client_config("u", &tls, dir.path()).unwrap();
901        assert!(
902            config.client_auth_cert_resolver.has_certs(),
903            "the declared client identity must be presented when the upstream requests one"
904        );
905    }
906
907    #[cfg(unix)]
908    #[test]
909    fn upstream_client_key_readable_by_group_fails_closed() {
910        use std::os::unix::fs::PermissionsExt;
911        let dir = tempfile::tempdir().unwrap();
912        let (cert, key) = upstream_client_identity(dir.path());
913        std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o640)).unwrap();
914        let tls = upstream_tls_with_identity(Some(cert), Some(key));
915        let err = match build_upstream_client_config("u", &tls, dir.path()) {
916            Err(e) => e,
917            Ok(_) => panic!("a group-readable client key must fail the build (ADR 000062 (d))"),
918        };
919        assert!(matches!(err, ControlError::UpstreamClientCert { .. }));
920    }
921
922    #[test]
923    fn client_auth_ticketer_is_isolated_and_rotates_with_the_ca() {
924        // mTLS builds must not share the process-wide anonymous ticketer: enabling client_auth
925        // after anonymous tickets were issued would otherwise let those tickets resume without a
926        // CertificateRequest (RFC 8446 PSK). Within one CA generation tickets survive unrelated
927        // reloads (no full-handshake stampede); rotating the CA bytes re-keys, so no ticket
928        // outlives the trust roots that authenticated its peer.
929        let (dir, entry) = default_cert_entry();
930        let without = build_server_configs(std::slice::from_ref(&entry), None, None, dir.path())
931            .unwrap()
932            .unwrap();
933        let ca = some_ca_pem();
934        let auth = client_auth_entry(dir.path(), &ca);
935        let with = build_server_configs(
936            std::slice::from_ref(&entry),
937            None,
938            Some((&auth, ca.as_slice())),
939            dir.path(),
940        )
941        .unwrap()
942        .unwrap();
943        assert!(
944            !Arc::ptr_eq(&without.tcp.ticketer, &with.tcp.ticketer),
945            "client_auth must not reuse the anonymous process-wide ticketer"
946        );
947        let again = build_server_configs(
948            std::slice::from_ref(&entry),
949            None,
950            Some((&auth, ca.as_slice())),
951            dir.path(),
952        )
953        .unwrap()
954        .unwrap();
955        assert!(
956            Arc::ptr_eq(&with.tcp.ticketer, &again.tcp.ticketer),
957            "an unchanged CA keeps tickets valid across rebuilds (reload continuity)"
958        );
959        let rotated_ca = some_ca_pem();
960        let rotated = build_server_configs(
961            &[entry],
962            None,
963            Some((&auth, rotated_ca.as_slice())),
964            dir.path(),
965        )
966        .unwrap()
967        .unwrap();
968        assert!(
969            !Arc::ptr_eq(&with.tcp.ticketer, &rotated.tcp.ticketer),
970            "a CA rotation re-keys the ticketer (tickets cannot outlive the verifier's roots)"
971        );
972        assert!(
973            Arc::ptr_eq(&with.tcp.ticketer, &with.quic.ticketer),
974            "TCP and QUIC still share one ticketer within a single build"
975        );
976    }
977
978    #[test]
979    fn client_auth_with_shared_stek_fails_closed() {
980        // ADR 000062 (b) / 000078: shared STEK × client auth amplifies resume-without-reverify
981        // across replicas — the combination is refused.
982        let (dir, entry) = default_cert_entry();
983        let resumption = resumption_entry(dir.path(), 7);
984        let ca = some_ca_pem();
985        let auth = client_auth_entry(dir.path(), &ca);
986        let err = match build_server_configs(
987            &[entry],
988            Some(&resumption),
989            Some((&auth, ca.as_slice())),
990            dir.path(),
991        ) {
992            Err(e) => e,
993            Ok(_) => {
994                panic!("[listen.client_auth] with [resumption] shared STEK must fail the build")
995            }
996        };
997        assert!(matches!(err, ControlError::Stek { .. }));
998    }
999
1000    /// Pump TLS records between a client and server until neither side wants I/O.
1001    fn pump_tls(client: &mut rustls::ClientConnection, server: &mut rustls::ServerConnection) {
1002        loop {
1003            let mut progressed = false;
1004            while client.wants_write() {
1005                let mut buf = Vec::new();
1006                let n = client.write_tls(&mut buf).unwrap();
1007                if n == 0 {
1008                    break;
1009                }
1010                server.read_tls(&mut &buf[..]).unwrap();
1011                server.process_new_packets().unwrap();
1012                progressed = true;
1013            }
1014            while server.wants_write() {
1015                let mut buf = Vec::new();
1016                let n = server.write_tls(&mut buf).unwrap();
1017                if n == 0 {
1018                    break;
1019                }
1020                client.read_tls(&mut &buf[..]).unwrap();
1021                client.process_new_packets().unwrap();
1022                progressed = true;
1023            }
1024            if !progressed {
1025                break;
1026            }
1027        }
1028    }
1029
1030    /// Grill 確定 4 load-bearing claim: with OUR `ServerConfig` (required client auth + per-node
1031    /// ticketer), a resumed handshake restores `peer_certificates` from the ticket — identity is
1032    /// preserved even though CertificateRequest is not re-sent (RFC 8446 / 9846 under PSK).
1033    #[test]
1034    fn client_auth_peer_certificates_survive_per_node_resumption() {
1035        let (dir, entry) = default_cert_entry();
1036        let client_id =
1037            rcgen::generate_simple_self_signed(vec!["plecto-client".to_string()]).unwrap();
1038        let ca = client_id.cert.pem().into_bytes();
1039        let auth = client_auth_entry(dir.path(), &ca);
1040        let configs = build_server_configs(
1041            std::slice::from_ref(&entry),
1042            None,
1043            Some((&auth, ca.as_slice())),
1044            dir.path(),
1045        )
1046        .unwrap()
1047        .expect("client-auth listener yields TCP + QUIC configs");
1048
1049        let server_pem = std::fs::read(&entry.cert_path).unwrap();
1050        let server_certs: Vec<CertificateDer<'static>> =
1051            CertificateDer::pem_slice_iter(&server_pem)
1052                .collect::<Result<Vec<_>, _>>()
1053                .unwrap();
1054        let mut roots = rustls::RootCertStore::empty();
1055        roots.add(server_certs[0].clone()).unwrap();
1056
1057        let client_config = Arc::new(
1058            rustls::ClientConfig::builder_with_provider(Arc::new(provider::default_provider()))
1059                .with_safe_default_protocol_versions()
1060                .unwrap()
1061                .with_root_certificates(roots)
1062                .with_client_auth_cert(
1063                    vec![CertificateDer::from(client_id.cert.der().to_vec())],
1064                    PrivateKeyDer::try_from(client_id.key_pair.serialize_der()).unwrap(),
1065                )
1066                .unwrap(),
1067        );
1068
1069        let server_name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
1070        let mut client =
1071            rustls::ClientConnection::new(client_config.clone(), server_name.clone()).unwrap();
1072        let mut server = rustls::ServerConnection::new(configs.tcp.clone()).unwrap();
1073        for _ in 0..64 {
1074            pump_tls(&mut client, &mut server);
1075            if !client.is_handshaking() && !server.is_handshaking() {
1076                break;
1077            }
1078        }
1079        assert!(
1080            !client.is_handshaking() && !server.is_handshaking(),
1081            "full handshake must complete"
1082        );
1083        // Drain NewSessionTicket into the client's session cache.
1084        for _ in 0..16 {
1085            pump_tls(&mut client, &mut server);
1086        }
1087        let full_chain = server
1088            .peer_certificates()
1089            .expect("full handshake must expose the verified client chain")
1090            .to_vec();
1091        assert!(
1092            !full_chain.is_empty(),
1093            "verified client chain must be non-empty"
1094        );
1095        assert_eq!(
1096            client.handshake_kind(),
1097            Some(rustls::HandshakeKind::Full),
1098            "first connection is a full handshake"
1099        );
1100
1101        let mut client2 = rustls::ClientConnection::new(client_config, server_name).unwrap();
1102        let mut server2 = rustls::ServerConnection::new(configs.tcp).unwrap();
1103        for _ in 0..64 {
1104            pump_tls(&mut client2, &mut server2);
1105            if !client2.is_handshaking() && !server2.is_handshaking() {
1106                break;
1107            }
1108        }
1109        assert_eq!(
1110            client2.handshake_kind(),
1111            Some(rustls::HandshakeKind::Resumed),
1112            "second connection must resume with the per-node ticket"
1113        );
1114        let resumed_chain = server2
1115            .peer_certificates()
1116            .expect("resumed handshake must restore peer_certificates from the ticket")
1117            .to_vec();
1118        assert_eq!(
1119            resumed_chain, full_chain,
1120            "ticket-restored identity must match the originally verified chain"
1121        );
1122    }
1123
1124    #[test]
1125    fn client_auth_without_tls_entries_fails_closed() {
1126        let dir = tempfile::tempdir().unwrap();
1127        let ca = some_ca_pem();
1128        let auth = client_auth_entry(dir.path(), &ca);
1129        let err = match build_server_configs(&[], None, Some((&auth, ca.as_slice())), dir.path()) {
1130            Err(e) => e,
1131            Ok(_) => panic!("[listen.client_auth] with no [[tls]] must fail the build"),
1132        };
1133        assert!(matches!(err, ControlError::ClientAuthCa { .. }));
1134    }
1135
1136    #[test]
1137    fn client_auth_ca_with_no_usable_root_fails_closed() {
1138        let (dir, entry) = default_cert_entry();
1139        let auth = client_auth_entry(dir.path(), b"not a pem at all");
1140        let err = match build_server_configs(
1141            &[entry],
1142            None,
1143            Some((&auth, b"not a pem at all".as_slice())),
1144            dir.path(),
1145        ) {
1146            Err(e) => e,
1147            Ok(_) => panic!("an unusable client-auth CA bundle must fail the build"),
1148        };
1149        assert!(matches!(err, ControlError::ClientAuthCa { .. }));
1150    }
1151}