1use 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#[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 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
58fn 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 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
86fn 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
111pub(crate) struct TlsConfigs {
115 pub(crate) tcp: Arc<ServerConfig>,
117 pub(crate) quic: Arc<ServerConfig>,
119}
120
121pub(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 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 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 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 let resolver = Arc::new(SniResolver { by_host, default });
201 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 let client_verifier = client_auth
221 .map(|(auth, ca_bytes)| build_client_verifier(auth, ca_bytes))
222 .transpose()?;
223
224 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 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 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
268fn 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
302pub(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 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
393fn 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
419pub(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
436fn 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
477fn 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
486fn 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 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 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 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 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 pub fn tls_config(&self) -> Option<Arc<ServerConfig>> {
551 self.active.load().tls.clone()
552 }
553
554 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 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 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 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 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 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 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(); std::fs::write(&key_path, b.key_pair.serialize_pem()).unwrap(); 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 #[test]
705 fn shared_stek_keeps_the_resumption_invariants() {
706 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 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 let (dir_a, entry_a) = default_cert_entry();
768 let (dir_b, entry_b) = default_cert_entry(); 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 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 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 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 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 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 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 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 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 #[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 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}