nono_proxy/tls_intercept/
cert_cache.rs1use crate::error::{ProxyError, Result};
25use crate::tls_intercept::ca::EphemeralCa;
26use rcgen::{
27 CertificateParams, DistinguishedName, DnType, KeyPair, SanType, PKCS_ECDSA_P256_SHA256,
28};
29use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
30use rustls::server::{ClientHello, ResolvesServerCert};
31use rustls::sign::CertifiedKey;
32use std::collections::HashMap;
33use std::sync::{Arc, Mutex};
34use std::time::{Duration, SystemTime};
35use time::OffsetDateTime;
36use tracing::{debug, warn};
37
38const LEAF_VALIDITY: Duration = Duration::from_secs(60 * 60);
42
43pub struct CertCache {
45 ca: Arc<EphemeralCa>,
46 cache: Mutex<HashMap<String, Arc<CertifiedKey>>>,
49}
50
51impl CertCache {
52 #[must_use]
54 pub fn new(ca: Arc<EphemeralCa>) -> Self {
55 Self {
56 ca,
57 cache: Mutex::new(HashMap::new()),
58 }
59 }
60
61 #[cfg(test)]
63 fn len(&self) -> usize {
64 self.cache
65 .lock()
66 .map(|guard| guard.len())
67 .unwrap_or_default()
68 }
69
70 pub fn get_or_mint(&self, hostname: &str) -> Result<Arc<CertifiedKey>> {
74 if hostname.is_empty() {
77 return Err(ProxyError::Config(
78 "cannot mint leaf certificate for empty hostname".to_string(),
79 ));
80 }
81
82 let mut cache = self.cache.lock().map_err(|_| {
83 ProxyError::Config("tls_intercept cert cache mutex poisoned".to_string())
84 })?;
85 if let Some(existing) = cache.get(hostname) {
86 return Ok(Arc::clone(existing));
87 }
88 let minted = mint_leaf(self.ca.as_ref(), hostname)?;
89 cache.insert(hostname.to_string(), Arc::clone(&minted));
90 debug!("tls_intercept: minted leaf certificate for {}", hostname);
91 Ok(minted)
92 }
93}
94
95impl std::fmt::Debug for CertCache {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 let len = self.cache.lock().map(|g| g.len()).unwrap_or(0);
98 f.debug_struct("CertCache")
99 .field("entries", &len)
100 .field("ca", &self.ca)
101 .finish()
102 }
103}
104
105impl ResolvesServerCert for CertCache {
106 fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
113 let hostname = client_hello.server_name()?;
114 match self.get_or_mint(hostname) {
115 Ok(ck) => Some(ck),
116 Err(e) => {
117 warn!(
118 "tls_intercept: failed to mint leaf for SNI '{}': {}",
119 hostname, e
120 );
121 None
122 }
123 }
124 }
125}
126
127fn 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 params.not_before = system_time_to_offset(now)?;
145 params.not_after = system_time_to_offset(now + LEAF_VALIDITY)?;
146
147 let mut dn = DistinguishedName::new();
148 dn.push(DnType::CommonName, hostname);
149 params.distinguished_name = dn;
150
151 let cert = params
152 .signed_by(&leaf_key, ca.ca_cert(), ca.key_pair())
153 .map_err(|e| ProxyError::Config(format!("failed to sign leaf certificate: {}", e)))?;
154 let leaf_der = cert.der().clone();
155
156 let private_key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(leaf_key_der));
157 let signing_key = rustls::crypto::ring::sign::any_supported_type(&private_key_der)
158 .map_err(|e| ProxyError::Config(format!("rustls rejected minted leaf key: {}", e)))?;
159
160 Ok(Arc::new(CertifiedKey::new(vec![leaf_der], signing_key)))
161}
162
163fn dns_san(hostname: &str) -> Result<SanType> {
167 if !is_plausible_dns_name(hostname) {
168 return Err(ProxyError::Config(format!(
169 "tls_intercept: refusing to mint leaf for non-DNS hostname '{}'",
170 hostname
171 )));
172 }
173 Ok(SanType::DnsName(hostname.to_string().try_into().map_err(
174 |e| ProxyError::Config(format!("invalid DNS name '{}': {}", hostname, e)),
175 )?))
176}
177
178fn is_plausible_dns_name(s: &str) -> bool {
182 if s.is_empty() || s.len() > 253 {
183 return false;
184 }
185 s.chars()
186 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
187 && s.contains(|c: char| c.is_ascii_alphabetic())
188}
189
190fn system_time_to_offset(t: SystemTime) -> Result<OffsetDateTime> {
191 OffsetDateTime::from_unix_timestamp(
192 t.duration_since(SystemTime::UNIX_EPOCH)
193 .map_err(|e| ProxyError::Config(format!("system time before unix epoch: {}", e)))?
194 .as_secs()
195 .try_into()
196 .map_err(|_| ProxyError::Config("system time exceeds i64::MAX".to_string()))?,
197 )
198 .map_err(|e| ProxyError::Config(format!("invalid system time for cert validity: {}", e)))
199}
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used)]
203mod tests {
204 use super::*;
205
206 fn fresh_cache() -> CertCache {
207 CertCache::new(Arc::new(EphemeralCa::generate().unwrap()))
208 }
209
210 #[test]
211 fn mint_returns_well_formed_cert() {
212 let cache = fresh_cache();
213 let ck = cache.get_or_mint("api.openai.com").unwrap();
214 assert_eq!(ck.cert.len(), 1, "should be a single-cert chain");
215 assert!(
216 !ck.cert[0].as_ref().is_empty(),
217 "DER body must be non-empty"
218 );
219 assert_eq!(ck.cert[0].as_ref()[0], 0x30);
223 }
224
225 #[test]
226 fn minted_leaf_carries_authority_key_identifier() {
227 let cache = fresh_cache();
232 let ck = cache.get_or_mint("api.example.com").unwrap();
233 let der = ck.cert[0].as_ref();
234 let aki_oid = [0x06, 0x03, 0x55, 0x1d, 0x23];
235 assert!(
236 der.windows(aki_oid.len()).any(|w| w == aki_oid),
237 "minted leaf must include Authority Key Identifier (OID 2.5.29.35)"
238 );
239 }
240
241 #[test]
242 fn cache_hits_on_repeated_lookup() {
243 let cache = fresh_cache();
244 let a = cache.get_or_mint("api.example.com").unwrap();
245 let b = cache.get_or_mint("api.example.com").unwrap();
246 assert!(Arc::ptr_eq(&a, &b), "second lookup should be a cache hit");
247 assert_eq!(cache.len(), 1);
248 }
249
250 #[test]
251 fn distinct_hostnames_get_distinct_certs() {
252 let cache = fresh_cache();
253 let a = cache.get_or_mint("api.openai.com").unwrap();
254 let b = cache.get_or_mint("api.anthropic.com").unwrap();
255 assert!(!Arc::ptr_eq(&a, &b));
256 assert_ne!(a.cert[0].as_ref(), b.cert[0].as_ref());
257 assert_eq!(cache.len(), 2);
258 }
259
260 #[test]
261 fn empty_hostname_rejected() {
262 let cache = fresh_cache();
263 assert!(cache.get_or_mint("").is_err());
264 }
265
266 #[test]
267 fn ip_literal_rejected() {
268 let cache = fresh_cache();
271 assert!(cache.get_or_mint("127.0.0.1").is_err());
272 assert!(cache.get_or_mint("::1").is_err());
273 }
274
275 #[test]
276 fn plausible_dns_name_filter() {
277 assert!(is_plausible_dns_name("api.openai.com"));
278 assert!(is_plausible_dns_name("internal-service.corp"));
279 assert!(!is_plausible_dns_name(""));
280 assert!(!is_plausible_dns_name("127.0.0.1")); assert!(!is_plausible_dns_name("evil host"));
282 assert!(!is_plausible_dns_name(&"a".repeat(254)));
283 }
284}