Skip to main content

netget/server/proxy/
cert_cache.rs

1//! Certificate cache for MITM proxy
2//!
3//! Generates and caches per-domain leaf certificates signed by a CA certificate.
4//! This allows the proxy to present valid-looking certificates for any domain
5//! when performing TLS Man-in-the-Middle interception.
6//!
7//! # Where the CA comes from, and what touches the disk
8//!
9//! The CA is generated fresh in memory every time a proxy server is started
10//! (`CertificateMode::Generate`). There is no fixed or hardcoded key, nothing is
11//! read from a well-known path, and neither the CA key, the per-domain leaf keys,
12//! nor any intercepted plaintext is ever written to disk by this module. Stopping
13//! the server discards the CA, so a client that trusted one run's CA will reject
14//! the next run's.
15//!
16//! The only way a CA certificate reaches the filesystem is when the operator
17//! explicitly passes the `ca_export_path` startup parameter, which writes the CA
18//! *certificate* (public, safe to distribute) and never the private key.
19//!
20//! Interception only works for clients that have been told to trust that CA, so
21//! this cannot silently intercept an unmodified client: without the trust grant
22//! the TLS handshake fails at the client with an unknown-issuer error.
23
24use anyhow::{Context, Result};
25use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, KeyPair, SanType};
26use rustls::pki_types::{CertificateDer, PrivateKeyDer};
27use std::collections::HashMap;
28use std::sync::Arc;
29use time::{Duration, OffsetDateTime};
30use tokio::sync::RwLock;
31use tracing::{debug, info, trace};
32
33/// Bound retained leaf certificates and private keys independently of connection count.
34/// One peer can request arbitrarily many distinct hosts over the cache's 24-hour TTL.
35/// Eviction only affects future handshakes; active sessions own their certificate copy.
36pub const MAX_CACHED_CERTIFICATES: usize = 1024;
37
38/// Certificate cache entry.
39///
40/// Both halves of the identity are stored together: a leaf certificate is only
41/// usable with the exact key whose public half it certifies, so caching one
42/// without the other (or regenerating one of them on a cache hit) produces a
43/// certificate/key pair that rustls rejects.
44struct CachedCert {
45    /// DER encoding of the leaf certificate
46    cert_der: Vec<u8>,
47    /// DER encoding of the private key that this certificate certifies
48    key_der: Vec<u8>,
49    /// When this certificate was generated
50    generated_at: crate::utils::clock::Instant,
51}
52
53/// Certificate cache for dynamically generated leaf certificates
54pub struct CertificateCache {
55    /// Root CA certificate (used to sign leaf certificates)
56    ca_cert: Arc<Certificate>,
57    /// Root CA private key
58    ca_key_pair: Arc<KeyPair>,
59    /// The parameters the CA certificate was actually built from.
60    ///
61    /// Leaf certificates must name this exact issuer, so these are the CA's own
62    /// params rather than a reconstruction: a reconstructed distinguished name
63    /// that differs from the CA's real subject yields a chain no client can
64    /// verify.
65    ca_params: Arc<CertificateParams>,
66    /// Cache of per-domain certificates (domain -> certificate + matching key)
67    cache: Arc<RwLock<HashMap<String, CachedCert>>>,
68    /// Certificate TTL in seconds (default: 24 hours)
69    cert_ttl_secs: u64,
70}
71
72impl CertificateCache {
73    /// Create a new certificate cache with a CA certificate.
74    ///
75    /// `ca_params` must be the parameters `ca_cert` was generated from so that
76    /// leaf certificates are issued under the CA's real distinguished name.
77    pub fn new(ca_cert: Certificate, ca_key_pair: KeyPair, ca_params: CertificateParams) -> Self {
78        Self {
79            ca_cert: Arc::new(ca_cert),
80            ca_key_pair: Arc::new(ca_key_pair),
81            ca_params: Arc::new(ca_params),
82            cache: Arc::new(RwLock::new(HashMap::new())),
83            cert_ttl_secs: 24 * 60 * 60, // 24 hours
84        }
85    }
86
87    /// Get or generate a certificate for a specific domain.
88    ///
89    /// Returns a rustls-ready certificate chain and the private key that matches
90    /// it. On a cache hit the stored certificate and its own key are returned
91    /// together; nothing is regenerated, because a fresh certificate would
92    /// certify a fresh key and no longer match the cached one.
93    pub async fn get_or_generate(
94        &self,
95        domain: &str,
96    ) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
97        // Normalize domain (lowercase, trim)
98        let domain_normalized = domain.trim().to_lowercase();
99
100        // Check cache first
101        {
102            let cache = self.cache.read().await;
103            if let Some(cached) = cache.get(&domain_normalized) {
104                let age = cached.generated_at.elapsed().as_secs();
105                if age < self.cert_ttl_secs {
106                    trace!(
107                        "Certificate cache HIT for domain '{}' (age: {}s)",
108                        domain_normalized,
109                        age
110                    );
111                    return Self::to_rustls(&cached.cert_der, &cached.key_der);
112                } else {
113                    debug!(
114                        "Certificate cache EXPIRED for domain '{}' (age: {}s > {}s)",
115                        domain_normalized, age, self.cert_ttl_secs
116                    );
117                }
118            } else {
119                debug!("Certificate cache MISS for domain '{}'", domain_normalized);
120            }
121        }
122
123        // Generate new certificate
124        info!(
125            "Generating new leaf certificate for domain '{}'",
126            domain_normalized
127        );
128        let (cert, key_pair) = self.generate_leaf_cert(&domain_normalized)?;
129
130        let cert_der = cert.der().to_vec();
131        let key_der = key_pair.serialize_der();
132
133        // Cache the certificate together with the key it certifies
134        {
135            let mut cache = self.cache.write().await;
136            if !cache.contains_key(&domain_normalized) && cache.len() >= MAX_CACHED_CERTIFICATES {
137                // The oldest generated certificate also expires first. Hold the write
138                // lock across eviction and insertion so concurrent misses cannot exceed
139                // the cap. A replacement for the same host consumes no additional slot.
140                let oldest = cache
141                    .iter()
142                    .min_by_key(|(_, entry)| entry.generated_at)
143                    .map(|(domain, _)| domain.clone());
144                if let Some(oldest) = oldest {
145                    cache.remove(&oldest);
146                }
147            }
148            cache.insert(
149                domain_normalized.clone(),
150                CachedCert {
151                    cert_der: cert_der.clone(),
152                    key_der: key_der.clone(),
153                    generated_at: crate::utils::clock::Instant::now(),
154                },
155            );
156            debug!(
157                "Cached certificate for domain '{}' (cache size: {})",
158                domain_normalized,
159                cache.len()
160            );
161        }
162
163        Self::to_rustls(&cert_der, &key_der)
164    }
165
166    /// Generate a leaf certificate for a specific domain, signed by the CA
167    fn generate_leaf_cert(&self, domain: &str) -> Result<(Certificate, KeyPair)> {
168        let mut params = CertificateParams::default();
169
170        // Set distinguished name with the domain as CN
171        let mut dn = DistinguishedName::new();
172        dn.push(DnType::CommonName, domain);
173        dn.push(DnType::OrganizationName, "NetGet MITM Proxy");
174        params.distinguished_name = dn;
175
176        // Add Subject Alternative Names (both the domain and wildcard)
177        params.subject_alt_names = vec![SanType::DnsName(
178            domain
179                .to_string()
180                .try_into()
181                .context("Invalid domain name")?,
182        )];
183
184        // If domain doesn't start with wildcard, also add wildcard version
185        if !domain.starts_with("*.") && !domain.starts_with("www.") {
186            // Add wildcard for subdomains (e.g., for example.com, add *.example.com)
187            let wildcard_domain = format!("*.{}", domain);
188            if let Ok(wildcard_san) = wildcard_domain.try_into() {
189                params
190                    .subject_alt_names
191                    .push(SanType::DnsName(wildcard_san));
192            }
193        }
194
195        // Add www variant if applicable
196        if !domain.starts_with("www.") {
197            let www_domain = format!("www.{}", domain);
198            if let Ok(www_san) = www_domain.try_into() {
199                params.subject_alt_names.push(SanType::DnsName(www_san));
200            }
201        }
202
203        // Set validity period (24 hours to match cache TTL)
204        let now = OffsetDateTime::now_utc();
205        params.not_before = now;
206        params.not_after = now + Duration::days(1);
207
208        // Mark as NOT a CA (this is a leaf certificate)
209        params.is_ca = rcgen::IsCa::NoCa;
210
211        // Generate key pair for this certificate
212        let key_pair =
213            KeyPair::generate().context("Failed to generate key pair for leaf certificate")?;
214
215        // Create an Issuer from the CA's own params and key
216        let issuer = rcgen::Issuer::new((*self.ca_params).clone(), self.ca_key_pair.as_ref());
217
218        // Sign this certificate with the CA
219        let cert = params
220            .signed_by(&key_pair, &issuer)
221            .context("Failed to sign leaf certificate with CA")?;
222
223        info!(
224            "Successfully generated leaf certificate for domain '{}' (valid for 24h, {} SANs)",
225            domain,
226            params.subject_alt_names.len()
227        );
228        trace!("Leaf certificate SANs: {:?}", params.subject_alt_names);
229
230        Ok((cert, key_pair))
231    }
232
233    /// Get the CA certificate (for exporting to users)
234    pub fn get_ca_cert(&self) -> &Certificate {
235        &self.ca_cert
236    }
237
238    /// PEM encoding of the CA certificate.
239    ///
240    /// This is the public certificate only. The CA private key is never exposed
241    /// through this API and never written anywhere.
242    pub fn ca_cert_pem(&self) -> String {
243        self.ca_cert.pem()
244    }
245
246    /// Clear expired certificates from the cache
247    pub async fn cleanup_expired(&self) {
248        let mut cache = self.cache.write().await;
249        let initial_size = cache.len();
250
251        cache.retain(|domain, cached| {
252            let age = cached.generated_at.elapsed().as_secs();
253            if age >= self.cert_ttl_secs {
254                debug!(
255                    "Removing expired certificate for domain '{}' (age: {}s)",
256                    domain, age
257                );
258                false
259            } else {
260                true
261            }
262        });
263
264        let removed = initial_size - cache.len();
265        if removed > 0 {
266            info!(
267                "Cleaned up {} expired certificates from cache (remaining: {})",
268                removed,
269                cache.len()
270            );
271        }
272    }
273
274    /// Get cache statistics
275    pub async fn get_stats(&self) -> CacheStats {
276        let cache = self.cache.read().await;
277        let total_certs = cache.len();
278
279        let mut expired_count = 0;
280        for cached in cache.values() {
281            let age = cached.generated_at.elapsed().as_secs();
282            if age >= self.cert_ttl_secs {
283                expired_count += 1;
284            }
285        }
286
287        CacheStats {
288            total_certificates: total_certs,
289            expired_certificates: expired_count,
290            valid_certificates: total_certs - expired_count,
291        }
292    }
293
294    /// Convert cached DER bytes into the owned types rustls wants
295    fn to_rustls(
296        cert_der: &[u8],
297        key_der: &[u8],
298    ) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
299        let cert = CertificateDer::from(cert_der.to_vec());
300        let key = PrivateKeyDer::try_from(key_der.to_vec())
301            .map_err(|e| anyhow::anyhow!("Failed to parse private key DER: {}", e))?;
302        Ok((vec![cert], key))
303    }
304}
305
306/// Certificate cache statistics
307#[derive(Debug, Clone)]
308pub struct CacheStats {
309    pub total_certificates: usize,
310    pub expired_certificates: usize,
311    pub valid_certificates: usize,
312}