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}