1use std::collections::HashMap;
4use std::num::NonZeroUsize;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use lru::LruCache;
9use microsandbox_utils::TLS_SUBDIR;
10use rustls::DigitallySignedStruct;
11use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
12use rustls_pki_types::{CertificateDer, ServerName, UnixTime, pem::PemObject};
13use time::{Duration, OffsetDateTime};
14use tokio_rustls::TlsConnector;
15
16use super::ca::CertAuthority;
17use super::certgen::{self, DomainCert, DomainCertError};
18use crate::secrets::handle::SecretsHandle;
19use microsandbox_types::TlsConfig;
20
21pub struct TlsState {
30 pub intercept_ca: CertAuthority,
32 cert_cache: Mutex<LruCache<String, Arc<DomainCert>>>,
34 pub connector: TlsConnector,
36 scoped_upstream_connectors: Vec<ScopedUpstreamConnector>,
38 pub config: TlsConfig,
40 pub secrets: SecretsHandle,
43 bypass_patterns: Vec<DomainPattern>,
45}
46
47#[derive(Debug, thiserror::Error)]
49pub enum TlsStateError {
50 #[error("intercept CA config is incomplete; set both cert_path and key_path")]
52 IncompleteInterceptCaConfig,
53
54 #[error("failed to read intercept CA certificate `{path}`: {source}")]
56 ReadInterceptCaCert {
57 path: PathBuf,
59 #[source]
61 source: std::io::Error,
62 },
63
64 #[error("failed to read intercept CA key `{path}`: {source}")]
66 ReadInterceptCaKey {
67 path: PathBuf,
69 #[source]
71 source: std::io::Error,
72 },
73
74 #[error("failed to load intercept CA `{cert_path}` / `{key_path}`: {reason}")]
76 InvalidInterceptCa {
77 cert_path: PathBuf,
79 key_path: PathBuf,
81 reason: String,
83 },
84}
85
86enum DomainPattern {
88 Exact(String),
90 Wildcard { suffix: String, dotted: String },
93}
94
95struct ScopedUpstreamConnector {
97 pattern: DomainPattern,
98 connector: TlsConnector,
99}
100
101struct ScopedUpstreamSettings {
103 pattern: String,
104 ca_cert: Vec<PathBuf>,
105 verify_upstream: Option<bool>,
106}
107
108impl ScopedUpstreamSettings {
109 fn new(pattern: &str) -> Self {
110 Self {
111 pattern: pattern.to_string(),
112 ca_cert: Vec::new(),
113 verify_upstream: None,
114 }
115 }
116}
117
118#[derive(Debug)]
121struct NoVerify;
122
123const CERT_REFRESH_WINDOW: Duration = Duration::minutes(5);
126
127impl TlsState {
132 pub fn new(config: TlsConfig, secrets: SecretsHandle) -> Result<Self, TlsStateError> {
139 let ca = load_or_generate_ca(&config)?;
140
141 let capacity =
142 NonZeroUsize::new(config.cache.capacity).unwrap_or(NonZeroUsize::new(1000).unwrap());
143 let cert_cache = Mutex::new(LruCache::new(capacity));
144
145 let connector = build_upstream_connector(&config, config.verify_upstream, &[]);
146 let scoped_upstream_connectors = build_scoped_upstream_connectors(&config);
147
148 let bypass_patterns = config
150 .bypass
151 .iter()
152 .map(|pattern| DomainPattern::new(pattern))
153 .collect();
154
155 Ok(Self {
156 intercept_ca: ca,
157 cert_cache,
158 connector,
159 scoped_upstream_connectors,
160 config,
161 secrets,
162 bypass_patterns,
163 })
164 }
165
166 pub fn get_or_generate_cert(&self, domain: &str) -> Result<Arc<DomainCert>, DomainCertError> {
168 let mut cache = match self.cert_cache.lock() {
169 Ok(cache) => cache,
170 Err(poisoned) => {
171 tracing::warn!("TLS certificate cache was poisoned; recovering");
172 poisoned.into_inner()
173 }
174 };
175 if let Some(cert) = cache.get(domain)
176 && cert.expires_at > OffsetDateTime::now_utc() + CERT_REFRESH_WINDOW
177 {
178 return Ok(cert.clone());
179 }
180
181 let cert = Arc::new(certgen::generate_domain_cert(
182 domain,
183 &self.intercept_ca,
184 self.config.cache.validity_hours,
185 )?);
186 cache.put(domain.to_string(), cert.clone());
187 Ok(cert)
188 }
189
190 pub fn should_bypass(&self, sni: &str) -> bool {
192 let sni_lower = normalize_domain(sni);
193 self.bypass_patterns
194 .iter()
195 .any(|pattern| pattern.matches_normalized(&sni_lower))
196 }
197
198 pub fn upstream_connector_for(&self, sni: &str) -> &TlsConnector {
203 self.scoped_upstream_connector_for(sni)
204 .map_or(&self.connector, |scoped| &scoped.connector)
205 }
206
207 fn scoped_upstream_connector_for(&self, sni: &str) -> Option<&ScopedUpstreamConnector> {
209 let sni_lower = normalize_domain(sni);
210 self.scoped_upstream_connectors
211 .iter()
212 .filter(|scoped| scoped.pattern.matches_normalized(&sni_lower))
213 .max_by_key(|scoped| scoped.pattern.specificity())
214 }
215
216 pub fn ca_cert_pem(&self) -> Vec<u8> {
218 self.intercept_ca.cert_pem()
219 }
220}
221
222impl DomainPattern {
223 fn new(pattern: &str) -> Self {
224 let lower = normalize_domain(pattern);
225 if let Some(suffix) = lower.strip_prefix("*.") {
226 let dotted = format!(".{suffix}");
227 DomainPattern::Wildcard {
228 suffix: suffix.to_string(),
229 dotted,
230 }
231 } else {
232 DomainPattern::Exact(lower)
233 }
234 }
235
236 fn matches_normalized(&self, sni_lower: &str) -> bool {
237 match self {
238 DomainPattern::Exact(exact) => sni_lower == exact,
239 DomainPattern::Wildcard { suffix, dotted } => {
240 sni_lower == suffix || sni_lower.ends_with(dotted.as_str())
241 }
242 }
243 }
244
245 fn specificity(&self) -> usize {
246 match self {
247 DomainPattern::Exact(exact) => exact.len() + 1,
248 DomainPattern::Wildcard { suffix, .. } => suffix.len(),
249 }
250 }
251}
252
253impl ServerCertVerifier for NoVerify {
258 fn verify_server_cert(
259 &self,
260 _end_entity: &CertificateDer<'_>,
261 _intermediates: &[CertificateDer<'_>],
262 _server_name: &ServerName<'_>,
263 _ocsp_response: &[u8],
264 _now: UnixTime,
265 ) -> Result<ServerCertVerified, rustls::Error> {
266 Ok(ServerCertVerified::assertion())
267 }
268
269 fn verify_tls12_signature(
270 &self,
271 _message: &[u8],
272 _cert: &CertificateDer<'_>,
273 _dss: &DigitallySignedStruct,
274 ) -> Result<HandshakeSignatureValid, rustls::Error> {
275 Ok(HandshakeSignatureValid::assertion())
276 }
277
278 fn verify_tls13_signature(
279 &self,
280 _message: &[u8],
281 _cert: &CertificateDer<'_>,
282 _dss: &DigitallySignedStruct,
283 ) -> Result<HandshakeSignatureValid, rustls::Error> {
284 Ok(HandshakeSignatureValid::assertion())
285 }
286
287 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
288 static SCHEMES: std::sync::OnceLock<Vec<rustls::SignatureScheme>> =
289 std::sync::OnceLock::new();
290 SCHEMES
291 .get_or_init(|| {
292 rustls::crypto::ring::default_provider()
293 .signature_verification_algorithms
294 .supported_schemes()
295 })
296 .clone()
297 }
298}
299
300fn build_upstream_connector(
309 config: &TlsConfig,
310 verify_upstream: bool,
311 scoped_ca_cert: &[PathBuf],
312) -> TlsConnector {
313 let client_config = if verify_upstream {
314 let mut root_store = rustls::RootCertStore::empty();
315 let certs = rustls_native_certs::load_native_certs();
316 if !certs.errors.is_empty() {
317 tracing::warn!(
318 count = certs.errors.len(),
319 "errors loading native certificates"
320 );
321 }
322 let mut added = 0usize;
323 for cert in certs.certs {
324 if root_store.add(cert).is_ok() {
325 added += 1;
326 }
327 }
328 if added == 0 {
329 tracing::error!("no native root certificates loaded — all upstream TLS will fail");
330 }
331
332 load_upstream_ca_certificates(&mut root_store, &config.upstream_ca_cert);
333 load_upstream_ca_certificates(&mut root_store, scoped_ca_cert);
334
335 rustls::ClientConfig::builder()
336 .with_root_certificates(root_store)
337 .with_no_client_auth()
338 } else {
339 rustls::ClientConfig::builder()
340 .dangerous()
341 .with_custom_certificate_verifier(Arc::new(NoVerify))
342 .with_no_client_auth()
343 };
344
345 TlsConnector::from(Arc::new(client_config))
346}
347
348fn build_scoped_upstream_connectors(config: &TlsConfig) -> Vec<ScopedUpstreamConnector> {
350 grouped_scoped_upstream_settings(config)
351 .into_iter()
352 .filter_map(|settings| {
353 let verify_upstream = settings.verify_upstream.unwrap_or(config.verify_upstream);
354 if verify_upstream == config.verify_upstream && settings.ca_cert.is_empty() {
355 return None;
356 }
357
358 Some(ScopedUpstreamConnector {
359 pattern: DomainPattern::new(&settings.pattern),
360 connector: build_upstream_connector(config, verify_upstream, &settings.ca_cert),
361 })
362 })
363 .collect()
364}
365
366fn grouped_scoped_upstream_settings(config: &TlsConfig) -> Vec<ScopedUpstreamSettings> {
371 let mut grouped = HashMap::<String, ScopedUpstreamSettings>::new();
372
373 for scoped in &config.scoped_upstream_ca_cert {
374 grouped
375 .entry(normalize_domain(&scoped.pattern))
376 .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
377 .ca_cert
378 .push(scoped.path.clone());
379 }
380
381 for scoped in &config.scoped_verify_upstream {
382 grouped
383 .entry(normalize_domain(&scoped.pattern))
384 .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
385 .verify_upstream = Some(scoped.verify);
386 }
387
388 grouped.into_values().collect()
389}
390
391fn load_upstream_ca_certificates(root_store: &mut rustls::RootCertStore, paths: &[PathBuf]) {
393 for path in paths {
394 match std::fs::read(path) {
395 Ok(pem_data) => {
396 let mut extra_added = 0usize;
397 for cert in CertificateDer::pem_slice_iter(&pem_data).flatten() {
398 if root_store.add(cert).is_ok() {
399 extra_added += 1;
400 }
401 }
402 tracing::info!(
403 path = %path.display(),
404 count = extra_added,
405 "loaded upstream CA certificates"
406 );
407 }
408 Err(e) => {
409 tracing::error!(
410 path = %path.display(),
411 error = %e,
412 "failed to read upstream CA certificate file"
413 );
414 }
415 }
416 }
417}
418
419fn normalize_domain(domain: &str) -> String {
421 domain.trim_end_matches('.').to_ascii_lowercase()
422}
423
424fn load_or_generate_ca(config: &TlsConfig) -> Result<CertAuthority, TlsStateError> {
431 match (
432 &config.intercept_ca.cert_path,
433 &config.intercept_ca.key_path,
434 ) {
435 (Some(cert_path), Some(key_path)) => {
436 let cert_pem =
437 std::fs::read(cert_path).map_err(|source| TlsStateError::ReadInterceptCaCert {
438 path: cert_path.clone(),
439 source,
440 })?;
441 let key_pem =
442 std::fs::read(key_path).map_err(|source| TlsStateError::ReadInterceptCaKey {
443 path: key_path.clone(),
444 source,
445 })?;
446 let ca = CertAuthority::load(&cert_pem, &key_pem).map_err(|err| {
447 TlsStateError::InvalidInterceptCa {
448 cert_path: cert_path.clone(),
449 key_path: key_path.clone(),
450 reason: err.to_string(),
451 }
452 })?;
453 tracing::info!("loaded user-provided CA from {:?}", cert_path);
454 return Ok(ca);
455 }
456 (Some(_), None) | (None, Some(_)) => {
457 return Err(TlsStateError::IncompleteInterceptCaConfig);
458 }
459 (None, None) => {}
460 }
461
462 let default_dir = default_ca_dir();
464 let cert_path = default_dir.join("ca.crt");
465 let key_path = default_dir.join("ca.key");
466
467 if cert_path.exists()
468 && key_path.exists()
469 && let (Ok(cert_pem), Ok(key_pem)) = (std::fs::read(&cert_path), std::fs::read(&key_path))
470 && let Ok(ca) = CertAuthority::load(&cert_pem, &key_pem)
471 {
472 tracing::debug!("loaded persisted CA from {:?}", cert_path);
473 return Ok(ca);
474 }
475
476 let ca = CertAuthority::generate();
478 if let Err(e) = std::fs::create_dir_all(&default_dir) {
479 tracing::warn!(error = %e, "failed to create CA directory, CA will not persist");
480 } else {
481 if let Err(e) = std::fs::write(&cert_path, ca.cert_pem()) {
482 tracing::warn!(error = %e, "failed to persist CA certificate");
483 }
484 if let Err(e) = write_key_file(&key_path, &ca.key_pem()) {
485 tracing::warn!(error = %e, "failed to persist CA key");
486 } else {
487 tracing::info!("generated and persisted CA to {:?}", default_dir);
488 }
489 }
490 Ok(ca)
491}
492
493fn default_ca_dir() -> PathBuf {
495 default_ca_dir_from_home(microsandbox_utils::resolve_home())
496}
497
498fn default_ca_dir_from_home(home: impl AsRef<Path>) -> PathBuf {
500 home.as_ref().join(TLS_SUBDIR)
501}
502
503fn write_key_file(path: &Path, data: &[u8]) -> std::io::Result<()> {
508 #[cfg(unix)]
509 {
510 use std::io::Write;
511 use std::os::unix::fs::OpenOptionsExt;
512 let mut file = std::fs::OpenOptions::new()
513 .write(true)
514 .create(true)
515 .truncate(true)
516 .mode(0o600)
517 .open(path)?;
518 file.write_all(data)?;
519 }
520 #[cfg(not(unix))]
521 {
522 std::fs::write(path, data)?;
523 }
524 Ok(())
525}
526
527#[cfg(test)]
532mod tests {
533 use super::*;
534 use microsandbox_types::{ScopedUpstreamCaCert, ScopedVerifyUpstream};
535
536 use crate::secrets::config::SecretsConfig;
537 use crate::secrets::handle::SecretsHandle;
538
539 #[test]
540 fn regenerates_cached_domain_cert_when_near_expiry() {
541 let _ = rustls::crypto::ring::default_provider().install_default();
542 let state = TlsState::new(
543 TlsConfig::default(),
544 SecretsHandle::new(SecretsConfig::default()),
545 )
546 .unwrap();
547 let first = state.get_or_generate_cert("openrouter.ai").unwrap();
548 let original_expires_at = first.expires_at;
549
550 {
551 let mut cache = state.cert_cache.lock().unwrap();
552 let stale = Arc::new(DomainCert {
553 chain: first.chain.clone(),
554 key: first.key.clone_key(),
555 expires_at: OffsetDateTime::now_utc() + Duration::seconds(30),
556 server_config: first.server_config.clone(),
557 });
558 cache.put("openrouter.ai".to_string(), stale);
559 }
560
561 let refreshed = state.get_or_generate_cert("openrouter.ai").unwrap();
562 assert!(refreshed.expires_at > OffsetDateTime::now_utc() + Duration::hours(23));
563 assert!(refreshed.expires_at > original_expires_at - Duration::minutes(10));
564 }
565
566 #[test]
567 fn invalid_domain_cert_request_does_not_poison_cache() {
568 let _ = rustls::crypto::ring::default_provider().install_default();
569 let state = TlsState::new(
570 TlsConfig::default(),
571 SecretsHandle::new(SecretsConfig::default()),
572 )
573 .unwrap();
574
575 assert!(state.get_or_generate_cert("snowman.☃").is_err());
576 assert!(state.get_or_generate_cert("openrouter.ai").is_ok());
577 }
578
579 #[test]
580 fn default_ca_dir_uses_microsandbox_home_tls_subdir() {
581 let home = PathBuf::from("isolated-msb-home");
582
583 assert_eq!(
584 default_ca_dir_from_home(&home),
585 home.join(microsandbox_utils::TLS_SUBDIR)
586 );
587 }
588
589 #[test]
590 fn tls_state_rejects_incomplete_intercept_ca_config() {
591 let mut config = TlsConfig::default();
592 config.intercept_ca.cert_path = Some(PathBuf::from("/tmp/ca.crt"));
593
594 let err = match TlsState::new(config, SecretsHandle::new(SecretsConfig::default())) {
595 Ok(_) => panic!("incomplete CA config should fail"),
596 Err(err) => err,
597 };
598
599 assert!(matches!(err, TlsStateError::IncompleteInterceptCaConfig));
600 }
601
602 #[test]
603 fn tls_state_rejects_invalid_intercept_ca_pair() {
604 let dir = std::env::temp_dir().join(format!(
605 "microsandbox-invalid-intercept-ca-{}",
606 std::process::id()
607 ));
608 std::fs::create_dir_all(&dir).unwrap();
609 let cert_path = dir.join("ca.crt");
610 let key_path = dir.join("ca.key");
611 std::fs::write(&cert_path, b"not a cert").unwrap();
612 std::fs::write(&key_path, b"not a key").unwrap();
613
614 let mut config = TlsConfig::default();
615 config.intercept_ca.cert_path = Some(cert_path);
616 config.intercept_ca.key_path = Some(key_path);
617
618 let err = match TlsState::new(config, SecretsHandle::new(SecretsConfig::default())) {
619 Ok(_) => panic!("invalid CA config should fail"),
620 Err(err) => err,
621 };
622 let _ = std::fs::remove_dir_all(&dir);
623
624 assert!(matches!(err, TlsStateError::InvalidInterceptCa { .. }));
625 }
626
627 #[test]
628 fn domain_patterns_match_exact_and_wildcard_hosts() {
629 let exact = DomainPattern::new("api.internal.");
630 assert!(exact.matches_normalized("api.internal"));
631 assert!(!exact.matches_normalized("other.api.internal"));
632
633 let wildcard = DomainPattern::new("*.internal");
634 assert!(wildcard.matches_normalized("internal"));
635 assert!(wildcard.matches_normalized("api.internal"));
636 assert!(!wildcard.matches_normalized("notinternal"));
637 }
638
639 #[test]
640 fn domain_patterns_score_exact_as_more_specific() {
641 let exact = DomainPattern::new("api.internal");
642 let wildcard = DomainPattern::new("*.internal");
643
644 assert!(exact.specificity() > wildcard.specificity());
645 }
646
647 #[test]
648 fn scoped_upstream_settings_group_ca_and_verify_by_pattern() {
649 let mut config = TlsConfig::default();
650 config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
651 pattern: "*.internal".to_string(),
652 path: PathBuf::from("/tmp/one.pem"),
653 });
654 config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
655 pattern: "*.internal.".to_string(),
656 path: PathBuf::from("/tmp/two.pem"),
657 });
658 config.scoped_verify_upstream.push(ScopedVerifyUpstream {
659 pattern: "*.internal".to_string(),
660 verify: false,
661 });
662
663 let settings = grouped_scoped_upstream_settings(&config);
664
665 assert_eq!(settings.len(), 1);
666 assert_eq!(settings[0].pattern, "*.internal");
667 assert_eq!(
668 settings[0].ca_cert,
669 vec![PathBuf::from("/tmp/one.pem"), PathBuf::from("/tmp/two.pem")]
670 );
671 assert_eq!(settings[0].verify_upstream, Some(false));
672 }
673
674 #[test]
675 fn upstream_connector_for_selects_scoped_connector_for_matching_host() {
676 let _ = rustls::crypto::ring::default_provider().install_default();
677 let mut config = TlsConfig::default();
678 config.scoped_verify_upstream.push(ScopedVerifyUpstream {
679 pattern: "*.internal".to_string(),
680 verify: false,
681 });
682 let state = TlsState::new(config, SecretsHandle::new(SecretsConfig::default())).unwrap();
683
684 assert!(
685 state
686 .scoped_upstream_connector_for("api.internal")
687 .is_some()
688 );
689 assert!(
690 state
691 .scoped_upstream_connector_for("api.example.com")
692 .is_none()
693 );
694 }
695
696 #[test]
697 fn load_upstream_ca_certificates_keeps_certs_and_skips_other_sections() {
698 let ca = crate::tls::ca::CertAuthority::generate();
699 let path =
700 std::env::temp_dir().join(format!("msb-upstream-ca-test-{}.pem", std::process::id()));
701 let mut bundle = ca.key_pem();
704 bundle.extend_from_slice(b"stray text between sections\n");
705 bundle.extend_from_slice(&ca.cert_pem());
706 std::fs::write(&path, &bundle).expect("write CA bundle");
707
708 let mut root_store = rustls::RootCertStore::empty();
709 load_upstream_ca_certificates(
710 &mut root_store,
711 &[path.clone(), PathBuf::from("/nonexistent/upstream-ca.pem")],
712 );
713 std::fs::remove_file(&path).ok();
714
715 assert_eq!(root_store.len(), 1);
716 }
717}