Skip to main content

nono_proxy/tls_intercept/
cert_cache.rs

1//! Per-hostname leaf certificate minting and cache.
2//!
3//! The cache lives for the duration of one proxy session. Each entry is a
4//! freshly minted ECDSA P-256 leaf certificate signed by the session's
5//! [`EphemeralCa`] and matched against the SNI hostname presented by the
6//! agent during the inner TLS handshake.
7//!
8//! ## Why no LRU eviction
9//!
10//! Typical agent workloads hit a handful of distinct hosts (`api.openai.com`,
11//! `api.anthropic.com`, `api.github.com`, …). The cache is naturally bounded
12//! by the per-session host set and is dropped — along with the CA — when the
13//! proxy shuts down. An LRU policy would add complexity without payoff.
14//!
15//! ## Failure mode
16//!
17//! When `resolve()` is invoked by rustls during a handshake and minting
18//! fails, the resolver returns `None`. rustls then fails the handshake,
19//! the agent sees a TLS error, and the proxy's CONNECT handler records
20//! the failure as a denied audit event. This matches the design constraint
21//! "hard fail on cert pinning": we never silently fall back to a transparent
22//! tunnel for a route that asked for L7 visibility.
23
24use 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
38/// Per-hostname leaf certificate cache backed by the session's [`EphemeralCa`].
39pub struct CertCache {
40    ca: Arc<EphemeralCa>,
41    /// Hostname → minted leaf. Kept behind a `Mutex` because rustls' cert
42    /// resolver is invoked from sync handshake context.
43    cache: Mutex<HashMap<String, Arc<CertifiedKey>>>,
44}
45
46impl CertCache {
47    /// Construct a new cache backed by `ca`.
48    #[must_use]
49    pub fn new(ca: Arc<EphemeralCa>) -> Self {
50        Self {
51            ca,
52            cache: Mutex::new(HashMap::new()),
53        }
54    }
55
56    /// Number of cached entries (test-only visibility).
57    #[cfg(test)]
58    fn len(&self) -> usize {
59        self.cache
60            .lock()
61            .map(|guard| guard.len())
62            .unwrap_or_default()
63    }
64
65    /// Look up or mint a leaf certificate for `hostname`.
66    ///
67    /// Used by tests; production code goes through [`ResolvesServerCert`].
68    pub fn get_or_mint(&self, hostname: &str) -> Result<Arc<CertifiedKey>> {
69        // Reject empty hostnames defensively. rustls already validates SNI
70        // shape, but we don't trust upstream invariants for a key path.
71        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    /// rustls invokes this synchronously during the server-side handshake.
102    /// We extract the SNI hostname, look up (or mint) a leaf, and return it.
103    /// On failure — empty SNI, mint error, mutex poison — we return `None`,
104    /// causing rustls to fail the handshake. That's what we want: the agent
105    /// sees a TLS error, the CONNECT handler records the failure, no
106    /// silent fallback occurs.
107    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
122/// Mint a fresh leaf certificate for `hostname`, signed by `ca`.
123///
124/// The returned `CertifiedKey` contains a two-cert chain: [leaf, CA].
125/// Go's TLS verifier (via `SecTrustEvaluateWithError`) requires the full
126/// chain to be presented by the server even when the CA is in the user
127/// trust store.
128fn mint_leaf(ca: &EphemeralCa, hostname: &str) -> Result<Arc<CertifiedKey>> {
129    // Generate a new key pair for this leaf. Distinct from the CA key:
130    // we never expose the CA's signing key in any TLS handshake.
131    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    // RFC 5280 §4.2.1.1 requires Authority Key Identifier on certs issued
138    // by a CA; rcgen defaults the flag to false. Stricter verifiers
139    // (OpenSSL 3.6+, BoringSSL) reject leaves without AKI with
140    // "Missing Authority Key Identifier".
141    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
173/// Build a Subject Alternative Name entry for `hostname`. Reject anything
174/// that isn't a plausible DNS name to avoid emitting bogus certs for
175/// IP-literal or malformed CONNECT targets.
176fn 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
188/// Lightweight DNS-name shape check. Not a full RFC 1035 validator —
189/// rustls will reject syntactically malformed certs at handshake time —
190/// but keeps obvious garbage out of the cache key.
191fn 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        // The first byte of an X.509 certificate's DER encoding is 0x30
234        // (SEQUENCE). A trivial sanity check that we produced something
235        // shaped like a certificate.
236        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        // OpenSSL 3.6+ (Python 3.14) rejects issued certs without AKI with
243        // "Missing Authority Key Identifier". rcgen defaults the flag off,
244        // so we set it explicitly in `mint_leaf`. Verify the extension OID
245        // 2.5.29.35 (DER bytes 06 03 55 1d 23) is present in the leaf DER.
246        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        // We refuse to mint for IP-literal CONNECT targets — the SNI shape
284        // would be wrong and the agent would reject the cert anyway.
285        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")); // no alphabetic
296        assert!(!is_plausible_dns_name("evil host"));
297        assert!(!is_plausible_dns_name(&"a".repeat(254)));
298    }
299}