From ea899b7ba97c7dc3d1b13a91ab9943a324fd7777 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Fri, 4 Sep 2026 22:36:32 +0900 Subject: [PATCH] ssl: load hashed capath certificates lazily Retain CA directories on SSLContext, materialize their OpenSSL-style hashed entries when building rustls configurations, and cache parsed DER until directory mtimes change. Publish only the trust anchor used by a completed handshake. Co-authored-by: Ivan Mironov Assisted-by: OpenAI Codex: GPT-5 --- crates/stdlib/src/ssl.rs | 165 ++++++++++++++++++++++------------ crates/stdlib/src/ssl/cert.rs | 63 ++++++++++--- 2 files changed, 157 insertions(+), 71 deletions(-) diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 63af8b0ff1b..360873edbff 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -114,6 +114,14 @@ mod _ssl { /// Certificate and private key pair used in SSL contexts type CertKeyPair = (Arc, PrivateKeyDer<'static>); + type CapathStamp = (String, Option); + type CapathCache = (Vec, Arc>>); + + #[derive(Debug, Default)] + struct CapathState { + directories: Vec, + cache: Option, + } // Constants matching Python ssl module @@ -688,10 +696,10 @@ mod _ssl { // RootCertStore only keeps TrustAnchors, not full certificates #[pytraverse(skip)] ca_certs_der: PyRwLock>>, - // Store CA certificates from capath for lazy loading simulation - // (CPython only returns these in get_ca_certs() after they're used in handshake) + // OpenSSL-style hashed CA directories, materialized when a connection + // needs an immutable rustls configuration. #[pytraverse(skip)] - capath_certs_der: PyRwLock>>, + capath_state: PyRwLock, // Certificate Revocation Lists for CRL checking #[pytraverse(skip)] crls: PyRwLock>>, @@ -829,6 +837,69 @@ mod _ssl { cert::cert_der_to_dict_helper(vm, cert_der) } + fn add_verify_dir(&self, directory: String) { + let mut capath_state = self.capath_state.write(); + if capath_state + .directories + .iter() + .any(|known| known == &directory) + { + return; + } + capath_state.directories.push(directory); + capath_state.cache = None; + drop(capath_state); + *self.server_config.write() = None; + } + + fn capath_certificates(&self) -> Arc>> { + let directories = self.capath_state.read().directories.clone(); + if directories.is_empty() { + return Arc::new(Vec::new()); + } + + let stamps = directories + .iter() + .map(|directory| { + let modified = rustpython_host_env::fs::metadata(directory) + .and_then(|metadata| metadata.modified()) + .ok(); + (directory.clone(), modified) + }) + .collect::>(); + if let Some((cached_stamps, certificates)) = self.capath_state.read().cache.as_ref() + && cached_stamps == &stamps + { + return certificates.clone(); + } + + let mut store = RootCertStore::empty(); + let mut certificates = Vec::new(); + let mut loader = cert::CertLoader::new(&mut store, &mut certificates); + for directory in &directories { + let _ = loader.load_from_dir(directory); + } + let certificates = Arc::new(certificates); + + let mut capath_state = self.capath_state.write(); + if capath_state.directories == directories { + capath_state.cache = Some((stamps, certificates.clone())); + } + certificates + } + + fn verification_roots(&self) -> (RootCertStore, Vec>) { + let mut root_store = self.root_certs.read().clone(); + let mut ca_certs_der = self.ca_certs_der.read().clone(); + for certificate in self.capath_certificates().iter() { + let _ = root_store.add(certificate.clone().into()); + if !ca_certs_der.iter().any(|known| known == certificate) { + ca_certs_der.push(certificate.clone()); + } + } + (root_store, ca_certs_der) + } + #[pygetset] fn check_hostname(&self) -> bool { *self.check_hostname.read() @@ -1259,12 +1330,6 @@ mod _ssl { self.update_cert_stats(stats); } - // Load from directory (don't add to ca_certs_der) - if let Some(ref dir_path) = capath_dir { - let stats = self.load_certs_from_dir_helper(&mut root_store, dir_path, vm)?; - self.update_cert_stats(stats); - } - // Load from bytes or str if let Some((ref data_vec, is_string)) = cadata_parsed { let stats = self.load_certs_from_bytes_helper( @@ -1277,6 +1342,13 @@ mod _ssl { self.update_cert_stats(stats); } + drop(root_store); + drop(ca_certs_der); + if let Some(dir_path) = capath_dir { + self.add_verify_dir(dir_path); + } + *self.server_config.write() = None; + Ok(()) } @@ -1321,14 +1393,10 @@ mod _ssl { } } - let file_cert_count = loaded_certs.len(); if let Ok(cert_dir) = Self::get_env_path(&environ, "SSL_CERT_DIR", vm) && rustpython_host_env::fs::is_dir(&cert_dir) { - let mut loader = cert::CertLoader::new(store, &mut loaded_certs); - if loader.load_from_dir(&cert_dir).is_ok() { - *self.capath_certs_der.write() = loaded_certs.split_off(file_cert_count); - } + self.add_verify_dir(cert_dir); } Ok(loaded_file) @@ -1440,6 +1508,8 @@ mod _ssl { *self.ca_cert_count.write() += webpki_count; } + drop(store); + *self.server_config.write() = None; Ok(()) } @@ -2022,27 +2092,6 @@ mod _ssl { }) } - /// Helper: Load certificates from directory into existing store - fn load_certs_from_dir_helper( - &self, - root_store: &mut RootCertStore, - path: &str, - vm: &VirtualMachine, - ) -> PyResult { - // Load certs and store them in capath_certs_der for lazy loading simulation - // (CPython only returns these in get_ca_certs() after they're used in handshake) - let mut capath_certs = Vec::new(); - let mut loader = cert::CertLoader::new(root_store, &mut capath_certs); - let stats = loader - .load_from_dir(path) - .map_err(|e| e.into_pyexception(vm))?; - - // Store loaded certs for potential tracking after handshake - *self.capath_certs_der.write() = capath_certs; - - Ok(stats) - } - /// Helper: Load certificates from bytes into existing store fn load_certs_from_bytes_helper( &self, @@ -2232,7 +2281,7 @@ mod _ssl { server_config: PyRwLock::new(None), root_certs: PyRwLock::new(RootCertStore::empty()), ca_certs_der: PyRwLock::new(Vec::new()), - capath_certs_der: PyRwLock::new(Vec::new()), + capath_state: PyRwLock::new(CapathState::default()), crls: PyRwLock::new(Vec::new()), cert_keys: PyRwLock::new(Vec::new()), options: PyRwLock::new(default_options), @@ -2499,11 +2548,11 @@ mod _ssl { // Extract capath_certs, releasing context lock quickly let capath_certs = { let context = self.context.read(); - let certs = context.capath_certs_der.read(); + let certs = context.capath_certificates(); if certs.is_empty() { return Ok(()); } - certs.clone() + certs }; // Extract peer certificates, releasing connection lock quickly @@ -2541,6 +2590,8 @@ mod _ssl { let mut ca_certs_der = context.ca_certs_der.write(); if !ca_certs_der.iter().any(|c| c == &ca_der) { ca_certs_der.push(ca_der); + *context.x509_cert_count.write() += 1; + *context.ca_cert_count.write() += 1; } } @@ -3214,7 +3265,7 @@ mod _ssl { // Check if client certificate verification is required let verify_mode = *ctx.verify_mode.read(); - let root_store = ctx.root_certs.read(); + let (root_store, _) = ctx.verification_roots(); let pha_enabled = *ctx.post_handshake_auth.read(); // Check if TLS 1.3 is being used @@ -3309,7 +3360,7 @@ mod _ssl { cert_chain: certs_clone, private_key: key_clone, root_store: if request_initial_cert { - Some(root_store.clone()) + Some(root_store) } else { None }, @@ -3325,31 +3376,28 @@ mod _ssl { ticketer: Some(server_ticketer), }; - drop(root_store); - - // Check if we have a cached ServerConfig - let cached_config_arc = ctx.server_config.read().clone(); + // A capath may gain hashed entries between connections, so only + // cache configurations whose trust store is context-owned. + let cache_server_config = + !use_sni_resolver && ctx.capath_state.read().directories.is_empty(); + let cached_config_arc = if cache_server_config { + ctx.server_config.read().clone() + } else { + None + }; drop(ctx); let config_arc = if let Some(cached) = cached_config_arc { - // Don't use cache when SNI is enabled, because each connection needs - // a fresh SniCertResolver with the correct Arc references - if use_sni_resolver { - let config = - create_server_config(config_options).map_err(|e| vm.new_value_error(e))?; - Arc::new(config) - } else { - cached - } + cached } else { let config = create_server_config(config_options).map_err(|e| vm.new_value_error(e))?; let config_arc = Arc::new(config); - // Cache the ServerConfig for future connections - let ctx = self.context.read(); - *ctx.server_config.write() = Some(config_arc.clone()); - drop(ctx); + if cache_server_config { + let ctx = self.context.read(); + *ctx.server_config.write() = Some(config_arc.clone()); + } config_arc }; @@ -3411,8 +3459,7 @@ mod _ssl { // Clone values we need before building config let verify_mode = *ctx.verify_mode.read(); - let root_store_clone = ctx.root_certs.read().clone(); - let ca_certs_der_clone = ctx.ca_certs_der.read().clone(); + let (root_store_clone, ca_certs_der_clone) = ctx.verification_roots(); // For client mTLS: extract cert_chain and private_key from first cert_key (if any) // Now we store both CertifiedKey and PrivateKeyDer as tuple diff --git a/crates/stdlib/src/ssl/cert.rs b/crates/stdlib/src/ssl/cert.rs index 8c79b8019d4..abe6d244af3 100644 --- a/crates/stdlib/src/ssl/cert.rs +++ b/crates/stdlib/src/ssl/cert.rs @@ -634,6 +634,42 @@ pub(super) struct CertLoader<'a> { seen_certs: HashSet>, } +fn is_capath_hash_name(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let Some((hash, suffix)) = name.split_once('.') else { + return false; + }; + hash.len() == 8 + && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + && !suffix.is_empty() + && suffix.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::is_capath_hash_name; + use std::ffi::OsStr; + + #[test] + fn capath_hash_names_follow_openssl_shape() { + for valid in ["4e1295a3.0", "ABCDEF01.12"] { + assert!(is_capath_hash_name(OsStr::new(valid)), "{valid}"); + } + for invalid in [ + "4e1295a.0", + "4e1295a30.0", + "4e1295ag.0", + "4e1295a3", + "4e1295a3.", + "4e1295a3.pem", + ] { + assert!(!is_capath_hash_name(OsStr::new(invalid)), "{invalid}"); + } + } +} + impl<'a> CertLoader<'a> { /// Create a new CertLoader with references to the store and DER cache pub(super) fn new(store: &'a mut RootCertStore, ca_certs_der: &'a mut Vec>) -> Self { @@ -656,26 +692,29 @@ impl<'a> CertLoader<'a> { /// Load certificates from a directory /// - /// Reads all files in the directory and attempts to parse them as certificates. - /// Invalid files are silently skipped (matches OpenSSL capath behavior). + /// Reads OpenSSL-style hashed entries and attempts to parse them as certificates. + /// Invalid entries are silently skipped (matches OpenSSL capath behavior). pub(super) fn load_from_dir(&mut self, dir_path: &str) -> Result { let entries = rustpython_host_env::fs::read_dir(dir_path)?; let mut stats = CertStats::default(); + let mut paths = Vec::new(); for entry in entries { let entry = entry?; let path = entry.path(); + if is_capath_hash_name(&entry.file_name()) && rustpython_host_env::fs::is_file(&path) { + paths.push(path); + } + } + paths.sort(); - // Skip directories and process all files - // OpenSSL capath uses hash-based naming like "4e1295a3.0" - if rustpython_host_env::fs::is_file(&path) - && let Ok(contents) = rustpython_host_env::fs::read(&path) - { - // Ignore errors for individual files (some may not be certs) - if let Ok(file_stats) = self.load_from_bytes(&contents) { - stats.total_certs += file_stats.total_certs; - stats.ca_certs += file_stats.ca_certs; - } + for path in paths { + let Ok(contents) = rustpython_host_env::fs::read(path) else { + continue; + }; + if let Ok(file_stats) = self.load_from_bytes(&contents) { + stats.total_certs += file_stats.total_certs; + stats.ca_certs += file_stats.ca_certs; } }