Skip to main content

microsandbox_network/tls/
state.rs

1//! Shared TLS state: CA, certificate cache, and upstream connectors.
2
3use 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};
13use time::{Duration, OffsetDateTime};
14use tokio_rustls::TlsConnector;
15
16use super::ca::CertAuthority;
17use super::certgen::{self, DomainCert, DomainCertError};
18use super::config::TlsConfig;
19use crate::secrets::handle::SecretsHandle;
20
21//--------------------------------------------------------------------------------------------------
22// Types
23//--------------------------------------------------------------------------------------------------
24
25/// Shared TLS interception state.
26///
27/// Holds the CA, per-domain certificate cache, upstream TLS connectors,
28/// and configuration. Shared across all TLS proxy tasks via `Arc`.
29pub struct TlsState {
30    /// Interception CA for signing per-domain certs presented to the guest.
31    pub intercept_ca: CertAuthority,
32    /// LRU cache of generated domain certificates.
33    cert_cache: Mutex<LruCache<String, Arc<DomainCert>>>,
34    /// Default TLS connector for upstream (real server) connections.
35    pub connector: TlsConnector,
36    /// Host-scoped TLS connectors for upstream connections.
37    scoped_upstream_connectors: Vec<ScopedUpstreamConnector>,
38    /// TLS configuration.
39    pub config: TlsConfig,
40    /// Live-swappable secrets configuration for placeholder substitution.
41    /// Loaded per connection so live secret updates apply to future traffic.
42    pub secrets: SecretsHandle,
43    /// Pre-computed lowercased bypass patterns for efficient matching.
44    bypass_patterns: Vec<DomainPattern>,
45}
46
47/// A pre-processed domain pattern (avoids per-connection allocations).
48enum DomainPattern {
49    /// Exact domain match (lowercased).
50    Exact(String),
51    /// Wildcard suffix match. `suffix` is the bare suffix, `dotted` is `.suffix`
52    /// (pre-computed to avoid per-connection `format!` allocations).
53    Wildcard { suffix: String, dotted: String },
54}
55
56/// An upstream connector selected only for matching server names.
57struct ScopedUpstreamConnector {
58    pattern: DomainPattern,
59    connector: TlsConnector,
60}
61
62/// Effective upstream TLS settings for one host pattern.
63struct ScopedUpstreamSettings {
64    pattern: String,
65    ca_cert: Vec<PathBuf>,
66    verify_upstream: Option<bool>,
67}
68
69impl ScopedUpstreamSettings {
70    fn new(pattern: &str) -> Self {
71        Self {
72            pattern: pattern.to_string(),
73            ca_cert: Vec::new(),
74            verify_upstream: None,
75        }
76    }
77}
78
79/// A [`ServerCertVerifier`] that accepts all server certificates without
80/// validation. Used when `verify_upstream` is `false`.
81#[derive(Debug)]
82struct NoVerify;
83
84/// Refresh cached leaf certs shortly before expiry so long-lived sandboxes
85/// do not start serving an already-expired intercept certificate.
86const CERT_REFRESH_WINDOW: Duration = Duration::minutes(5);
87
88//--------------------------------------------------------------------------------------------------
89// Methods
90//--------------------------------------------------------------------------------------------------
91
92impl TlsState {
93    /// Create TLS state from configuration.
94    ///
95    /// CA resolution order:
96    /// 1. User-provided paths (`config.intercept_ca.cert_path` + `config.intercept_ca.key_path`)
97    /// 2. Microsandbox home TLS path (`$MSB_HOME/tls` or `~/.microsandbox/tls`)
98    /// 3. Auto-generate and persist to the microsandbox home TLS path
99    pub fn new(config: TlsConfig, secrets: SecretsHandle) -> Self {
100        let ca = load_or_generate_ca(&config);
101
102        let capacity =
103            NonZeroUsize::new(config.cache.capacity).unwrap_or(NonZeroUsize::new(1000).unwrap());
104        let cert_cache = Mutex::new(LruCache::new(capacity));
105
106        let connector = build_upstream_connector(&config, config.verify_upstream, &[]);
107        let scoped_upstream_connectors = build_scoped_upstream_connectors(&config);
108
109        // Pre-compute lowercased bypass patterns to avoid per-connection allocations.
110        let bypass_patterns = config
111            .bypass
112            .iter()
113            .map(|pattern| DomainPattern::new(pattern))
114            .collect();
115
116        Self {
117            intercept_ca: ca,
118            cert_cache,
119            connector,
120            scoped_upstream_connectors,
121            config,
122            secrets,
123            bypass_patterns,
124        }
125    }
126
127    /// Get or generate a certificate for the given domain.
128    pub fn get_or_generate_cert(&self, domain: &str) -> Result<Arc<DomainCert>, DomainCertError> {
129        let mut cache = match self.cert_cache.lock() {
130            Ok(cache) => cache,
131            Err(poisoned) => {
132                tracing::warn!("TLS certificate cache was poisoned; recovering");
133                poisoned.into_inner()
134            }
135        };
136        if let Some(cert) = cache.get(domain)
137            && cert.expires_at > OffsetDateTime::now_utc() + CERT_REFRESH_WINDOW
138        {
139            return Ok(cert.clone());
140        }
141
142        let cert = Arc::new(certgen::generate_domain_cert(
143            domain,
144            &self.intercept_ca,
145            self.config.cache.validity_hours,
146        )?);
147        cache.put(domain.to_string(), cert.clone());
148        Ok(cert)
149    }
150
151    /// Check if a domain should bypass TLS interception.
152    pub fn should_bypass(&self, sni: &str) -> bool {
153        let sni_lower = normalize_domain(sni);
154        self.bypass_patterns
155            .iter()
156            .any(|pattern| pattern.matches_normalized(&sni_lower))
157    }
158
159    /// Select the upstream connector for the given server name.
160    ///
161    /// Falls back to the default connector when no host-scoped connector
162    /// matches; when several match, the most specific pattern wins.
163    pub fn upstream_connector_for(&self, sni: &str) -> &TlsConnector {
164        self.scoped_upstream_connector_for(sni)
165            .map_or(&self.connector, |scoped| &scoped.connector)
166    }
167
168    /// Find the most specific host-scoped upstream connector for `sni`, if any.
169    fn scoped_upstream_connector_for(&self, sni: &str) -> Option<&ScopedUpstreamConnector> {
170        let sni_lower = normalize_domain(sni);
171        self.scoped_upstream_connectors
172            .iter()
173            .filter(|scoped| scoped.pattern.matches_normalized(&sni_lower))
174            .max_by_key(|scoped| scoped.pattern.specificity())
175    }
176
177    /// Get the CA certificate PEM bytes for guest installation.
178    pub fn ca_cert_pem(&self) -> Vec<u8> {
179        self.intercept_ca.cert_pem()
180    }
181}
182
183impl DomainPattern {
184    fn new(pattern: &str) -> Self {
185        let lower = normalize_domain(pattern);
186        if let Some(suffix) = lower.strip_prefix("*.") {
187            let dotted = format!(".{suffix}");
188            DomainPattern::Wildcard {
189                suffix: suffix.to_string(),
190                dotted,
191            }
192        } else {
193            DomainPattern::Exact(lower)
194        }
195    }
196
197    fn matches_normalized(&self, sni_lower: &str) -> bool {
198        match self {
199            DomainPattern::Exact(exact) => sni_lower == exact,
200            DomainPattern::Wildcard { suffix, dotted } => {
201                sni_lower == suffix || sni_lower.ends_with(dotted.as_str())
202            }
203        }
204    }
205
206    fn specificity(&self) -> usize {
207        match self {
208            DomainPattern::Exact(exact) => exact.len() + 1,
209            DomainPattern::Wildcard { suffix, .. } => suffix.len(),
210        }
211    }
212}
213
214//--------------------------------------------------------------------------------------------------
215// Trait Implementations
216//--------------------------------------------------------------------------------------------------
217
218impl ServerCertVerifier for NoVerify {
219    fn verify_server_cert(
220        &self,
221        _end_entity: &CertificateDer<'_>,
222        _intermediates: &[CertificateDer<'_>],
223        _server_name: &ServerName<'_>,
224        _ocsp_response: &[u8],
225        _now: UnixTime,
226    ) -> Result<ServerCertVerified, rustls::Error> {
227        Ok(ServerCertVerified::assertion())
228    }
229
230    fn verify_tls12_signature(
231        &self,
232        _message: &[u8],
233        _cert: &CertificateDer<'_>,
234        _dss: &DigitallySignedStruct,
235    ) -> Result<HandshakeSignatureValid, rustls::Error> {
236        Ok(HandshakeSignatureValid::assertion())
237    }
238
239    fn verify_tls13_signature(
240        &self,
241        _message: &[u8],
242        _cert: &CertificateDer<'_>,
243        _dss: &DigitallySignedStruct,
244    ) -> Result<HandshakeSignatureValid, rustls::Error> {
245        Ok(HandshakeSignatureValid::assertion())
246    }
247
248    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
249        static SCHEMES: std::sync::OnceLock<Vec<rustls::SignatureScheme>> =
250            std::sync::OnceLock::new();
251        SCHEMES
252            .get_or_init(|| {
253                rustls::crypto::ring::default_provider()
254                    .signature_verification_algorithms
255                    .supported_schemes()
256            })
257            .clone()
258    }
259}
260
261//--------------------------------------------------------------------------------------------------
262// Functions
263//--------------------------------------------------------------------------------------------------
264
265/// Build the upstream TLS connector based on configuration.
266///
267/// When `verify_upstream` is true, loads the system's native root certificates.
268/// When false, uses a permissive verifier that accepts all server certificates.
269fn build_upstream_connector(
270    config: &TlsConfig,
271    verify_upstream: bool,
272    scoped_ca_cert: &[PathBuf],
273) -> TlsConnector {
274    let client_config = if verify_upstream {
275        let mut root_store = rustls::RootCertStore::empty();
276        let certs = rustls_native_certs::load_native_certs();
277        if !certs.errors.is_empty() {
278            tracing::warn!(
279                count = certs.errors.len(),
280                "errors loading native certificates"
281            );
282        }
283        let mut added = 0usize;
284        for cert in certs.certs {
285            if root_store.add(cert).is_ok() {
286                added += 1;
287            }
288        }
289        if added == 0 {
290            tracing::error!("no native root certificates loaded — all upstream TLS will fail");
291        }
292
293        load_upstream_ca_certificates(&mut root_store, &config.upstream_ca_cert);
294        load_upstream_ca_certificates(&mut root_store, scoped_ca_cert);
295
296        rustls::ClientConfig::builder()
297            .with_root_certificates(root_store)
298            .with_no_client_auth()
299    } else {
300        rustls::ClientConfig::builder()
301            .dangerous()
302            .with_custom_certificate_verifier(Arc::new(NoVerify))
303            .with_no_client_auth()
304    };
305
306    TlsConnector::from(Arc::new(client_config))
307}
308
309/// Build host-scoped upstream TLS connectors from grouped scoped settings.
310fn build_scoped_upstream_connectors(config: &TlsConfig) -> Vec<ScopedUpstreamConnector> {
311    grouped_scoped_upstream_settings(config)
312        .into_iter()
313        .filter_map(|settings| {
314            let verify_upstream = settings.verify_upstream.unwrap_or(config.verify_upstream);
315            if verify_upstream == config.verify_upstream && settings.ca_cert.is_empty() {
316                return None;
317            }
318
319            Some(ScopedUpstreamConnector {
320                pattern: DomainPattern::new(&settings.pattern),
321                connector: build_upstream_connector(config, verify_upstream, &settings.ca_cert),
322            })
323        })
324        .collect()
325}
326
327/// Group repeated scoped upstream settings by host pattern.
328///
329/// Grouping order is irrelevant: [`TlsState::upstream_connector_for`] selects
330/// by pattern specificity, not declaration order.
331fn grouped_scoped_upstream_settings(config: &TlsConfig) -> Vec<ScopedUpstreamSettings> {
332    let mut grouped = HashMap::<String, ScopedUpstreamSettings>::new();
333
334    for scoped in &config.scoped_upstream_ca_cert {
335        grouped
336            .entry(normalize_domain(&scoped.pattern))
337            .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
338            .ca_cert
339            .push(scoped.path.clone());
340    }
341
342    for scoped in &config.scoped_verify_upstream {
343        grouped
344            .entry(normalize_domain(&scoped.pattern))
345            .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
346            .verify_upstream = Some(scoped.verify);
347    }
348
349    grouped.into_values().collect()
350}
351
352/// Load extra upstream CA certificates into the provided root store.
353fn load_upstream_ca_certificates(root_store: &mut rustls::RootCertStore, paths: &[PathBuf]) {
354    for path in paths {
355        match std::fs::read(path) {
356            Ok(pem_data) => {
357                let mut extra_added = 0usize;
358                for cert in rustls_pemfile::certs(&mut pem_data.as_slice()).flatten() {
359                    if root_store.add(cert).is_ok() {
360                        extra_added += 1;
361                    }
362                }
363                tracing::info!(
364                    path = %path.display(),
365                    count = extra_added,
366                    "loaded upstream CA certificates"
367                );
368            }
369            Err(e) => {
370                tracing::error!(
371                    path = %path.display(),
372                    error = %e,
373                    "failed to read upstream CA certificate file"
374                );
375            }
376        }
377    }
378}
379
380/// Normalize host patterns and SNI names for matching.
381fn normalize_domain(domain: &str) -> String {
382    domain.trim_end_matches('.').to_ascii_lowercase()
383}
384
385/// Load or generate a CA based on the TLS configuration.
386///
387/// Resolution order:
388/// 1. User-provided paths (`cert_path` + `key_path`)
389/// 2. Microsandbox home TLS path (`$MSB_HOME/tls` or `~/.microsandbox/tls`)
390/// 3. Auto-generate and persist to the microsandbox home TLS path
391fn load_or_generate_ca(config: &TlsConfig) -> CertAuthority {
392    // Warn if only one of cert_path/key_path is set (likely a config error).
393    if config.intercept_ca.cert_path.is_some() != config.intercept_ca.key_path.is_some() {
394        tracing::warn!(
395            "incomplete CA config: both cert_path and key_path must be set together, ignoring"
396        );
397    }
398
399    // 1. Try user-provided paths.
400    if let (Some(cert_path), Some(key_path)) = (
401        &config.intercept_ca.cert_path,
402        &config.intercept_ca.key_path,
403    ) {
404        match (std::fs::read(cert_path), std::fs::read(key_path)) {
405            (Ok(cert_pem), Ok(key_pem)) => match CertAuthority::load(&cert_pem, &key_pem) {
406                Ok(ca) => {
407                    tracing::info!("loaded user-provided CA from {:?}", cert_path);
408                    return ca;
409                }
410                Err(e) => {
411                    tracing::error!(
412                        error = %e,
413                        "failed to load user-provided CA, falling back to auto-generate"
414                    );
415                }
416            },
417            _ => {
418                tracing::error!(
419                    "failed to read CA files from {:?} / {:?}, falling back to auto-generate",
420                    cert_path,
421                    key_path,
422                );
423            }
424        }
425    }
426
427    // 2. Try the same microsandbox home root used by cache/db/logs/metrics.
428    let default_dir = default_ca_dir();
429    let cert_path = default_dir.join("ca.crt");
430    let key_path = default_dir.join("ca.key");
431
432    if cert_path.exists()
433        && key_path.exists()
434        && let (Ok(cert_pem), Ok(key_pem)) = (std::fs::read(&cert_path), std::fs::read(&key_path))
435        && let Ok(ca) = CertAuthority::load(&cert_pem, &key_pem)
436    {
437        tracing::debug!("loaded persisted CA from {:?}", cert_path);
438        return ca;
439    }
440
441    // 3. Auto-generate and persist.
442    let ca = CertAuthority::generate();
443    if let Err(e) = std::fs::create_dir_all(&default_dir) {
444        tracing::warn!(error = %e, "failed to create CA directory, CA will not persist");
445    } else {
446        if let Err(e) = std::fs::write(&cert_path, ca.cert_pem()) {
447            tracing::warn!(error = %e, "failed to persist CA certificate");
448        }
449        if let Err(e) = write_key_file(&key_path, &ca.key_pem()) {
450            tracing::warn!(error = %e, "failed to persist CA key");
451        } else {
452            tracing::info!("generated and persisted CA to {:?}", default_dir);
453        }
454    }
455    ca
456}
457
458/// Default CA persistence directory under the resolved microsandbox home.
459fn default_ca_dir() -> PathBuf {
460    default_ca_dir_from_home(microsandbox_utils::resolve_home())
461}
462
463/// Build the CA directory from a known microsandbox home.
464fn default_ca_dir_from_home(home: impl AsRef<Path>) -> PathBuf {
465    home.as_ref().join(TLS_SUBDIR)
466}
467
468/// Write a private key file with restricted permissions (0o600) from creation.
469///
470/// Uses `OpenOptions` with mode set at creation time to avoid the TOCTOU race
471/// of write-then-chmod where the file is briefly world-readable.
472fn write_key_file(path: &Path, data: &[u8]) -> std::io::Result<()> {
473    #[cfg(unix)]
474    {
475        use std::io::Write;
476        use std::os::unix::fs::OpenOptionsExt;
477        let mut file = std::fs::OpenOptions::new()
478            .write(true)
479            .create(true)
480            .truncate(true)
481            .mode(0o600)
482            .open(path)?;
483        file.write_all(data)?;
484    }
485    #[cfg(not(unix))]
486    {
487        std::fs::write(path, data)?;
488    }
489    Ok(())
490}
491
492//--------------------------------------------------------------------------------------------------
493// Tests
494//--------------------------------------------------------------------------------------------------
495
496#[cfg(test)]
497mod tests {
498    use super::super::config::{ScopedUpstreamCaCert, ScopedVerifyUpstream};
499    use super::*;
500
501    use crate::secrets::config::SecretsConfig;
502    use crate::secrets::handle::SecretsHandle;
503
504    #[test]
505    fn regenerates_cached_domain_cert_when_near_expiry() {
506        let _ = rustls::crypto::ring::default_provider().install_default();
507        let state = TlsState::new(
508            TlsConfig::default(),
509            SecretsHandle::new(SecretsConfig::default()),
510        );
511        let first = state.get_or_generate_cert("openrouter.ai").unwrap();
512        let original_expires_at = first.expires_at;
513
514        {
515            let mut cache = state.cert_cache.lock().unwrap();
516            let stale = Arc::new(DomainCert {
517                chain: first.chain.clone(),
518                key: first.key.clone_key(),
519                expires_at: OffsetDateTime::now_utc() + Duration::seconds(30),
520                server_config: first.server_config.clone(),
521            });
522            cache.put("openrouter.ai".to_string(), stale);
523        }
524
525        let refreshed = state.get_or_generate_cert("openrouter.ai").unwrap();
526        assert!(refreshed.expires_at > OffsetDateTime::now_utc() + Duration::hours(23));
527        assert!(refreshed.expires_at > original_expires_at - Duration::minutes(10));
528    }
529
530    #[test]
531    fn invalid_domain_cert_request_does_not_poison_cache() {
532        let _ = rustls::crypto::ring::default_provider().install_default();
533        let state = TlsState::new(
534            TlsConfig::default(),
535            SecretsHandle::new(SecretsConfig::default()),
536        );
537
538        assert!(state.get_or_generate_cert("snowman.☃").is_err());
539        assert!(state.get_or_generate_cert("openrouter.ai").is_ok());
540    }
541
542    #[test]
543    fn default_ca_dir_uses_microsandbox_home_tls_subdir() {
544        let home = PathBuf::from("isolated-msb-home");
545
546        assert_eq!(
547            default_ca_dir_from_home(&home),
548            home.join(microsandbox_utils::TLS_SUBDIR)
549        );
550    }
551
552    #[test]
553    fn domain_patterns_match_exact_and_wildcard_hosts() {
554        let exact = DomainPattern::new("api.internal.");
555        assert!(exact.matches_normalized("api.internal"));
556        assert!(!exact.matches_normalized("other.api.internal"));
557
558        let wildcard = DomainPattern::new("*.internal");
559        assert!(wildcard.matches_normalized("internal"));
560        assert!(wildcard.matches_normalized("api.internal"));
561        assert!(!wildcard.matches_normalized("notinternal"));
562    }
563
564    #[test]
565    fn domain_patterns_score_exact_as_more_specific() {
566        let exact = DomainPattern::new("api.internal");
567        let wildcard = DomainPattern::new("*.internal");
568
569        assert!(exact.specificity() > wildcard.specificity());
570    }
571
572    #[test]
573    fn scoped_upstream_settings_group_ca_and_verify_by_pattern() {
574        let mut config = TlsConfig::default();
575        config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
576            pattern: "*.internal".to_string(),
577            path: PathBuf::from("/tmp/one.pem"),
578        });
579        config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
580            pattern: "*.internal.".to_string(),
581            path: PathBuf::from("/tmp/two.pem"),
582        });
583        config.scoped_verify_upstream.push(ScopedVerifyUpstream {
584            pattern: "*.internal".to_string(),
585            verify: false,
586        });
587
588        let settings = grouped_scoped_upstream_settings(&config);
589
590        assert_eq!(settings.len(), 1);
591        assert_eq!(settings[0].pattern, "*.internal");
592        assert_eq!(
593            settings[0].ca_cert,
594            vec![PathBuf::from("/tmp/one.pem"), PathBuf::from("/tmp/two.pem")]
595        );
596        assert_eq!(settings[0].verify_upstream, Some(false));
597    }
598
599    #[test]
600    fn upstream_connector_for_selects_scoped_connector_for_matching_host() {
601        let _ = rustls::crypto::ring::default_provider().install_default();
602        let mut config = TlsConfig::default();
603        config.scoped_verify_upstream.push(ScopedVerifyUpstream {
604            pattern: "*.internal".to_string(),
605            verify: false,
606        });
607        let state = TlsState::new(config, SecretsHandle::new(SecretsConfig::default()));
608
609        assert!(
610            state
611                .scoped_upstream_connector_for("api.internal")
612                .is_some()
613        );
614        assert!(
615            state
616                .scoped_upstream_connector_for("api.example.com")
617                .is_none()
618        );
619    }
620}