nono_proxy/tls_intercept/
cert_cache.rs1use crate::error::{ProxyError, Result};
25use crate::tls_intercept::ca::EphemeralCa;
26use rcgen::{
27 CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_ECDSA_P256_SHA256, SanType,
28};
29use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
30use rustls::server::{ClientHello, ResolvesServerCert};
31use rustls::sign::CertifiedKey;
32use std::collections::HashMap;
33use std::sync::{Arc, Mutex};
34use std::time::SystemTime;
35use time::OffsetDateTime;
36use tracing::{debug, warn};
37
38pub struct CertCache {
40 ca: Arc<EphemeralCa>,
41 cache: Mutex<HashMap<String, Arc<CertifiedKey>>>,
44}
45
46impl CertCache {
47 #[must_use]
49 pub fn new(ca: Arc<EphemeralCa>) -> Self {
50 Self {
51 ca,
52 cache: Mutex::new(HashMap::new()),
53 }
54 }
55
56 #[cfg(test)]
58 fn len(&self) -> usize {
59 self.cache
60 .lock()
61 .map(|guard| guard.len())
62 .unwrap_or_default()
63 }
64
65 pub fn get_or_mint(&self, hostname: &str) -> Result<Arc<CertifiedKey>> {
69 if hostname.is_empty() {
72 return Err(ProxyError::Config(
73 "cannot mint leaf certificate for empty hostname".to_string(),
74 ));
75 }
76
77 let mut cache = self.cache.lock().map_err(|_| {
78 ProxyError::Config("tls_intercept cert cache mutex poisoned".to_string())
79 })?;
80 if let Some(existing) = cache.get(hostname) {
81 return Ok(Arc::clone(existing));
82 }
83 let minted = mint_leaf(self.ca.as_ref(), hostname)?;
84 cache.insert(hostname.to_string(), Arc::clone(&minted));
85 debug!("tls_intercept: minted leaf certificate for {}", hostname);
86 Ok(minted)
87 }
88}
89
90impl std::fmt::Debug for CertCache {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 let len = self.cache.lock().map(|g| g.len()).unwrap_or(0);
93 f.debug_struct("CertCache")
94 .field("entries", &len)
95 .field("ca", &self.ca)
96 .finish()
97 }
98}
99
100impl ResolvesServerCert for CertCache {
101 fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
108 let hostname = client_hello.server_name()?;
109 match self.get_or_mint(hostname) {
110 Ok(ck) => Some(ck),
111 Err(e) => {
112 warn!(
113 "tls_intercept: failed to mint leaf for SNI '{}': {}",
114 hostname, e
115 );
116 None
117 }
118 }
119 }
120}
121
122fn mint_leaf(ca: &EphemeralCa, hostname: &str) -> Result<Arc<CertifiedKey>> {
129 let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
132 .map_err(|e| ProxyError::Config(format!("failed to generate leaf key pair: {}", e)))?;
133 let leaf_key_der = leaf_key.serialize_der();
134
135 let mut params = CertificateParams::default();
136 params.subject_alt_names = vec![dns_san(hostname)?];
137 params.use_authority_key_identifier_extension = true;
142
143 let now = SystemTime::now();
144 let ca_not_after = ca.not_after();
145 if ca_not_after <= now {
146 return Err(ProxyError::Config(format!(
147 "CA certificate has expired; cannot mint leaf for '{hostname}'"
148 )));
149 }
150 params.not_before = system_time_to_offset(now)?;
151 params.not_after = system_time_to_offset(ca_not_after)?;
152
153 let mut dn = DistinguishedName::new();
154 dn.push(DnType::CommonName, hostname);
155 params.distinguished_name = dn;
156
157 let cert = params
158 .signed_by(&leaf_key, ca.issuer())
159 .map_err(|e| ProxyError::Config(format!("failed to sign leaf certificate: {}", e)))?;
160 let leaf_der = cert.der().clone();
161 let ca_der = CertificateDer::from(ca.cert_der().to_vec());
162
163 let private_key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(leaf_key_der));
164 let signing_key = rustls::crypto::ring::sign::any_supported_type(&private_key_der)
165 .map_err(|e| ProxyError::Config(format!("rustls rejected minted leaf key: {}", e)))?;
166
167 Ok(Arc::new(CertifiedKey::new(
168 vec![leaf_der, ca_der],
169 signing_key,
170 )))
171}
172
173fn dns_san(hostname: &str) -> Result<SanType> {
177 if !is_plausible_dns_name(hostname) {
178 return Err(ProxyError::Config(format!(
179 "tls_intercept: refusing to mint leaf for non-DNS hostname '{}'",
180 hostname
181 )));
182 }
183 Ok(SanType::DnsName(hostname.to_string().try_into().map_err(
184 |e| ProxyError::Config(format!("invalid DNS name '{}': {}", hostname, e)),
185 )?))
186}
187
188fn is_plausible_dns_name(s: &str) -> bool {
192 if s.is_empty() || s.len() > 253 {
193 return false;
194 }
195 s.chars()
196 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
197 && s.contains(|c: char| c.is_ascii_alphabetic())
198}
199
200fn system_time_to_offset(t: SystemTime) -> Result<OffsetDateTime> {
201 OffsetDateTime::from_unix_timestamp(
202 t.duration_since(SystemTime::UNIX_EPOCH)
203 .map_err(|e| ProxyError::Config(format!("system time before unix epoch: {}", e)))?
204 .as_secs()
205 .try_into()
206 .map_err(|_| ProxyError::Config("system time exceeds i64::MAX".to_string()))?,
207 )
208 .map_err(|e| ProxyError::Config(format!("invalid system time for cert validity: {}", e)))
209}
210
211#[cfg(test)]
212#[allow(clippy::unwrap_used)]
213mod tests {
214 use super::*;
215
216 fn fresh_cache() -> CertCache {
217 CertCache::new(Arc::new(EphemeralCa::generate().unwrap()))
218 }
219
220 #[test]
221 fn mint_returns_well_formed_cert() {
222 let cache = fresh_cache();
223 let ck = cache.get_or_mint("api.openai.com").unwrap();
224 assert_eq!(ck.cert.len(), 2, "chain should be [leaf, CA]");
225 assert!(
226 !ck.cert[0].as_ref().is_empty(),
227 "leaf DER body must be non-empty"
228 );
229 assert!(
230 !ck.cert[1].as_ref().is_empty(),
231 "CA DER body must be non-empty"
232 );
233 assert_eq!(ck.cert[0].as_ref()[0], 0x30);
237 assert_eq!(ck.cert[1].as_ref()[0], 0x30);
238 }
239
240 #[test]
241 fn minted_leaf_carries_authority_key_identifier() {
242 let cache = fresh_cache();
247 let ck = cache.get_or_mint("api.example.com").unwrap();
248 let der = ck.cert[0].as_ref();
249 let aki_oid = [0x06, 0x03, 0x55, 0x1d, 0x23];
250 assert!(
251 der.windows(aki_oid.len()).any(|w| w == aki_oid),
252 "minted leaf must include Authority Key Identifier (OID 2.5.29.35)"
253 );
254 }
255
256 #[test]
257 fn cache_hits_on_repeated_lookup() {
258 let cache = fresh_cache();
259 let a = cache.get_or_mint("api.example.com").unwrap();
260 let b = cache.get_or_mint("api.example.com").unwrap();
261 assert!(Arc::ptr_eq(&a, &b), "second lookup should be a cache hit");
262 assert_eq!(cache.len(), 1);
263 }
264
265 #[test]
266 fn distinct_hostnames_get_distinct_certs() {
267 let cache = fresh_cache();
268 let a = cache.get_or_mint("api.openai.com").unwrap();
269 let b = cache.get_or_mint("api.anthropic.com").unwrap();
270 assert!(!Arc::ptr_eq(&a, &b));
271 assert_ne!(a.cert[0].as_ref(), b.cert[0].as_ref());
272 assert_eq!(cache.len(), 2);
273 }
274
275 #[test]
276 fn empty_hostname_rejected() {
277 let cache = fresh_cache();
278 assert!(cache.get_or_mint("").is_err());
279 }
280
281 #[test]
282 fn ip_literal_rejected() {
283 let cache = fresh_cache();
286 assert!(cache.get_or_mint("127.0.0.1").is_err());
287 assert!(cache.get_or_mint("::1").is_err());
288 }
289
290 #[test]
291 fn plausible_dns_name_filter() {
292 assert!(is_plausible_dns_name("api.openai.com"));
293 assert!(is_plausible_dns_name("internal-service.corp"));
294 assert!(!is_plausible_dns_name(""));
295 assert!(!is_plausible_dns_name("127.0.0.1")); assert!(!is_plausible_dns_name("evil host"));
297 assert!(!is_plausible_dns_name(&"a".repeat(254)));
298 }
299}