From 979576e2e45e709e66c4c636a2f5815e9cfff222 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:04:16 +0900 Subject: [PATCH] ssl: move TlsConnection and session store to host_env (#8678) TlsConnection, RecordCursor, rustls error mapping, hostname/ALPN/version helpers, and the capturing client session store now live in host_env. stdlib _ssl keeps socket/MemoryBIO I/O and Python objects. Remove unused stdlib sha2 and leftover workspace TLS crates. Assisted-by: Grok:grok-4.6 --- Cargo.lock | 2 +- crates/host_env/Cargo.toml | 3 +- crates/host_env/src/ssl/connection.rs | 254 +++++++++++++ crates/host_env/src/ssl/constants.rs | 5 + crates/host_env/src/ssl/error.rs | 68 ++++ crates/host_env/src/ssl/mod.rs | 14 + crates/host_env/src/ssl/protocol.rs | 150 ++++++++ crates/host_env/src/ssl/session.rs | 151 ++++++++ crates/stdlib/Cargo.toml | 1 - crates/stdlib/src/ssl.rs | 512 +++++--------------------- crates/stdlib/src/ssl/compat.rs | 264 +++---------- 11 files changed, 781 insertions(+), 643 deletions(-) create mode 100644 crates/host_env/src/ssl/connection.rs create mode 100644 crates/host_env/src/ssl/error.rs create mode 100644 crates/host_env/src/ssl/protocol.rs create mode 100644 crates/host_env/src/ssl/session.rs diff --git a/Cargo.lock b/Cargo.lock index 7473fe16a..fd2b54bd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3365,6 +3365,7 @@ dependencies = [ "rustpython-wtf8", "rustyline", "schannel", + "sha2", "socket2", "system-configuration", "termios", @@ -3542,7 +3543,6 @@ dependencies = [ "rustpython-unicode", "rustpython-vm", "scopeguard", - "sha2", "tcl-sys", "tk-sys", "uuid", diff --git a/crates/host_env/Cargo.toml b/crates/host_env/Cargo.toml index 090eba6aa..857d9a80e 100644 --- a/crates/host_env/Cargo.toml +++ b/crates/host_env/Cargo.toml @@ -10,7 +10,7 @@ license.workspace = true [features] native-certs = ["dep:rustls-native-certs"] -ssl = ["dep:rustls", "dep:rustls-pemfile", "dep:rustls-webpki", "dep:x509-parser", "dep:der", "dep:pkcs8"] +ssl = ["dep:rustls", "dep:rustls-pemfile", "dep:rustls-webpki", "dep:x509-parser", "dep:der", "dep:pkcs8", "dep:sha2"] [dependencies] rustpython-wtf8 = { workspace = true } @@ -28,6 +28,7 @@ rustls-webpki = { workspace = true, optional = true } x509-parser = { workspace = true, optional = true } der = { workspace = true, optional = true } pkcs8 = { workspace = true, features = ["encryption", "pkcs5", "pem"], optional = true } +sha2 = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } diff --git a/crates/host_env/src/ssl/connection.rs b/crates/host_env/src/ssl/connection.rs new file mode 100644 index 000000000..88a865b5c --- /dev/null +++ b/crates/host_env/src/ssl/connection.rs @@ -0,0 +1,254 @@ +//! Sans-I/O rustls connection plus one-record transport helpers. + +use std::io::{Read, Write}; + +use rustls::{ + Connection, HandshakeKind, IoState, ProtocolVersion, SupportedCipherSuite, + pki_types::CertificateDer, +}; + +use super::error::TlsError; + +pub const TLS_RECORD_HEADER_SIZE: usize = 5; +const SSL3_RT_MAX_MD_SIZE: usize = 64; +const SSL3_RT_MAX_PLAIN_LENGTH: usize = 16384; +const SSL3_RT_MAX_COMPRESSED_OVERHEAD: usize = 1024; +const SSL3_RT_MAX_ENCRYPTED_OVERHEAD: usize = 256 + SSL3_RT_MAX_MD_SIZE; +const SSL3_RT_MAX_COMPRESSED_LENGTH: usize = + SSL3_RT_MAX_PLAIN_LENGTH + SSL3_RT_MAX_COMPRESSED_OVERHEAD; +const SSL3_RT_MAX_ENCRYPTED_LENGTH: usize = + SSL3_RT_MAX_ENCRYPTED_OVERHEAD + SSL3_RT_MAX_COMPRESSED_LENGTH; +pub const SSL3_RT_MAX_PACKET_SIZE: usize = SSL3_RT_MAX_ENCRYPTED_LENGTH + TLS_RECORD_HEADER_SIZE; + +/// One TLS record's header and remaining body, so a transport read never +/// lifts more than the record currently being parsed. +#[derive(Debug, Default)] +pub struct RecordCursor { + header: [u8; TLS_RECORD_HEADER_SIZE], + header_read: usize, + body_left: usize, +} + +impl RecordCursor { + /// Bytes still owed before the next record boundary. + #[must_use] + pub fn want(&self) -> usize { + if self.body_left > 0 { + self.body_left + } else { + TLS_RECORD_HEADER_SIZE - self.header_read + } + } + + /// True while the 5-byte header is still incomplete, or after a + /// zero-length body (the next read starts a new header). + #[must_use] + pub fn in_header(&self) -> bool { + self.body_left == 0 + } + + /// Account for `data`, which must be no longer than [`want`]. + pub fn consume(&mut self, data: &[u8]) { + if self.body_left > 0 { + self.body_left -= data.len(); + return; + } + self.header[self.header_read..self.header_read + data.len()].copy_from_slice(data); + self.header_read += data.len(); + if self.header_read == TLS_RECORD_HEADER_SIZE { + self.body_left = u16::from_be_bytes([self.header[3], self.header[4]]) as usize; + self.header_read = 0; + } + } +} + +/// rustls connection plus write-retry state. Unsent TLS stays on the +/// interpreter object so the rustls lock is not held across socket I/O. +#[derive(Debug)] +pub struct TlsConnection { + inner: Connection, + write_buffered_len: usize, +} + +impl TlsConnection { + #[must_use] + pub fn new(inner: Connection) -> Self { + Self { + inner, + write_buffered_len: 0, + } + } + + #[must_use] + pub fn inner(&self) -> &Connection { + &self.inner + } + + pub fn inner_mut(&mut self) -> &mut Connection { + &mut self.inner + } + + #[must_use] + pub fn wants_read(&self) -> bool { + self.inner.wants_read() + } + + #[must_use] + pub fn wants_write(&self) -> bool { + self.inner.wants_write() + } + + #[must_use] + pub fn is_handshaking(&self) -> bool { + self.inner.is_handshaking() + } + + #[must_use] + pub fn protocol_version(&self) -> Option { + self.inner.protocol_version() + } + + #[must_use] + pub fn negotiated_cipher_suite(&self) -> Option { + self.inner.negotiated_cipher_suite() + } + + #[must_use] + pub fn alpn_protocol(&self) -> Option<&[u8]> { + self.inner.alpn_protocol() + } + + #[must_use] + pub fn peer_certificates(&self) -> Option<&[CertificateDer<'static>]> { + self.inner.peer_certificates() + } + + #[must_use] + pub fn handshake_kind(&self) -> Option { + self.inner.handshake_kind() + } + + #[must_use] + pub fn write_buffered_len(&self) -> usize { + self.write_buffered_len + } + + pub fn set_write_buffered_len(&mut self, n: usize) { + self.write_buffered_len = n; + } + + #[must_use] + pub fn pending_plaintext(&mut self) -> usize { + use std::io::BufRead; + self.inner.reader().fill_buf().map_or(0, |buf| buf.len()) + } + + pub fn send_close_notify(&mut self) { + self.inner.send_close_notify(); + } + + pub fn process_packets(&mut self) -> Result { + self.inner + .process_new_packets() + .map_err(TlsError::from_rustls) + } + + pub fn peer_has_closed(&mut self) -> Result { + Ok(self.process_packets()?.peer_has_closed()) + } + + /// Drain every record rustls has queued for the peer. + pub fn drain_tls(&mut self) -> Result, TlsError> { + let mut buf = Vec::new(); + let n = self.inner.write_tls(&mut buf).map_err(TlsError::Io)?; + if n > 0 { Ok(buf) } else { Ok(Vec::new()) } + } + + /// Feed ciphertext. An empty slice means the transport returned EOF. + pub fn feed_tls(&mut self, data: &[u8]) -> Result<(), TlsError> { + if data.is_empty() { + return if self.peer_has_closed()? { + Err(TlsError::ZeroReturn) + } else { + Err(TlsError::Eof) + }; + } + + let mut offset = 0; + while offset < data.len() { + let remaining = &data[offset..]; + let mut cursor = std::io::Cursor::new(remaining); + match self.inner.read_tls(&mut cursor) { + Ok(0) => { + self.process_packets()?; + let mut retry = std::io::Cursor::new(remaining); + match self.inner.read_tls(&mut retry) { + Ok(0) => break, + Ok(n) => { + offset += n; + if offset < data.len() { + self.process_packets()?; + } + } + Err(error) => return Err(TlsError::Io(error)), + } + } + Ok(read) => { + offset += read; + if offset < data.len() { + self.process_packets()?; + } + } + Err(error) => return Err(TlsError::Io(error)), + } + } + Ok(()) + } + + /// Read application data. `None` means rustls has no plaintext yet. + pub fn read_plaintext(&mut self, buf: &mut [u8]) -> Result, TlsError> { + match self.inner.reader().read(buf) { + Ok(n) => Ok(Some(n)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None), + Err(error) => Err(TlsError::Io(error)), + } + } + + /// Write application data. `is_bio` turns a full buffer into `WantWrite`. + pub fn write_plaintext(&mut self, data: &[u8], is_bio: bool) -> Result { + match self.inner.writer().write(data) { + Ok(0) if !data.is_empty() => { + if is_bio { + Err(TlsError::WantWrite) + } else { + Err(TlsError::Syscall("Write failed: buffer full".to_string())) + } + } + Ok(n) => Ok(n), + Err(error) => { + if is_bio { + Err(TlsError::WantWrite) + } else { + Err(TlsError::Syscall(format!("Write failed: {error}"))) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_cursor_splits_header_and_body() { + let mut cursor = RecordCursor::default(); + assert_eq!(cursor.want(), 5); + cursor.consume(&[22, 3, 3]); + assert_eq!(cursor.want(), 2); + cursor.consume(&[0, 4]); + assert_eq!(cursor.want(), 4); + cursor.consume(&[1, 2, 3, 4]); + assert_eq!(cursor.want(), 5); + } +} diff --git a/crates/host_env/src/ssl/constants.rs b/crates/host_env/src/ssl/constants.rs index 768f021aa..58f146f58 100644 --- a/crates/host_env/src/ssl/constants.rs +++ b/crates/host_env/src/ssl/constants.rs @@ -19,3 +19,8 @@ pub const VERIFY_X509_STRICT: i32 = 32; pub const VERIFY_ALLOW_PROXY_CERTS: i32 = 64; pub const VERIFY_X509_TRUSTED_FIRST: i32 = 32768; pub const VERIFY_X509_PARTIAL_CHAIN: i32 = 0x80000; + +pub const PROTO_TLSV1_2: i32 = 0x0303; +pub const PROTO_TLSV1_3: i32 = 0x0304; +pub const OP_NO_TLSV1_2: i32 = 0x0800_0000; +pub const OP_NO_TLSV1_3: i32 = 0x2000_0000; diff --git a/crates/host_env/src/ssl/error.rs b/crates/host_env/src/ssl/error.rs new file mode 100644 index 000000000..cec73e49f --- /dev/null +++ b/crates/host_env/src/ssl/error.rs @@ -0,0 +1,68 @@ +//! rustls errors mapped to the `_ssl` error space, without Python exceptions. + +use rustls::CertificateError; + +const ERR_LIB_SSL: i32 = 20; + +/// TLS engine error before the interpreter builds an exception. +#[derive(Debug)] +pub enum TlsError { + WantRead, + WantWrite, + Syscall(String), + Ssl(String), + ZeroReturn, + Eof, + PreauthData, + CertVerification(CertificateError), + Io(std::io::Error), + Timeout(String), + AlertReceived { lib: i32, reason: i32 }, + NoCipherSuites, +} + +impl TlsError { + fn alert_to_openssl_reason(alert: rustls::AlertDescription) -> i32 { + 1000 + i32::from(u8::from(alert)) + } + + /// Map a rustls error into the `_ssl` error space. + #[must_use] + pub fn from_rustls(err: rustls::Error) -> Self { + match err { + rustls::Error::InvalidCertificate(cert_err) => Self::CertVerification(cert_err), + rustls::Error::AlertReceived(alert_desc) => match alert_desc { + rustls::AlertDescription::CloseNotify => Self::ZeroReturn, + _ => Self::AlertReceived { + lib: ERR_LIB_SSL, + reason: Self::alert_to_openssl_reason(alert_desc), + }, + }, + rustls::Error::InvalidMessage(_) => Self::Eof, + rustls::Error::PeerIncompatible(peer_err) => { + use rustls::PeerIncompatible; + match peer_err { + PeerIncompatible::NoCipherSuitesInCommon => Self::NoCipherSuites, + _ => Self::Eof, + } + } + other => Self::Ssl(format!("{other}")), + } + } + + #[must_use] + pub fn is_eof(&self) -> bool { + matches!(self, Self::Eof) + } + + #[must_use] + pub fn is_zero_return(&self) -> bool { + matches!(self, Self::ZeroReturn) + } +} + +impl From for TlsError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} diff --git a/crates/host_env/src/ssl/mod.rs b/crates/host_env/src/ssl/mod.rs index 423e85c42..433b295e1 100644 --- a/crates/host_env/src/ssl/mod.rs +++ b/crates/host_env/src/ssl/mod.rs @@ -10,16 +10,30 @@ pub mod cert; pub mod chain; pub mod cipher; pub mod config; +pub mod connection; pub mod constants; +pub mod error; pub mod handshake; pub mod keylog; pub mod msg; pub mod oid; +pub mod protocol; pub mod providers; +pub mod session; pub mod verify; pub mod x509; pub use bio::{MemoryBio, MemoryBioError}; pub use cert::{DecodedCertificate, decode_certificate, is_ca_certificate}; +pub use connection::{ + RecordCursor, SSL3_RT_MAX_PACKET_SIZE, TLS_RECORD_HEADER_SIZE, TlsConnection, +}; pub use constants::*; +pub use error::TlsError; +pub use protocol::{ + AlpnError, HostnameError, parse_length_prefixed_alpn, rustls_versions, validate_hostname, +}; pub use providers::CryptoExt; +pub use session::{ + CapturingClientSessionStore, ClientSessionKind, SESSION_CACHE_SIZE, SessionCache, SessionData, +}; diff --git a/crates/host_env/src/ssl/protocol.rs b/crates/host_env/src/ssl/protocol.rs new file mode 100644 index 000000000..fd4310e01 --- /dev/null +++ b/crates/host_env/src/ssl/protocol.rs @@ -0,0 +1,150 @@ +//! Protocol version, ALPN, and hostname helpers. + +use rustls::version::{TLS12, TLS13}; + +use super::constants::{OP_NO_TLSV1_2, OP_NO_TLSV1_3, PROTO_TLSV1_2, PROTO_TLSV1_3}; + +/// Why `server_hostname` was rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostnameError { + Empty, + LeadingDot, + EmbeddedNul, + TooLong, +} + +impl HostnameError { + #[must_use] + pub fn message(&self) -> &'static str { + match self { + Self::Empty => "server_hostname cannot be an empty string", + Self::LeadingDot => "server_hostname cannot start with a dot", + Self::EmbeddedNul => "embedded null character", + Self::TooLong => "server_hostname is too long (maximum 253 characters)", + } + } +} + +/// Validate `server_hostname` the way `_SSLSocket` does. +pub fn validate_hostname(hostname: &str) -> Result<(), HostnameError> { + if hostname.is_empty() { + return Err(HostnameError::Empty); + } + if hostname.starts_with('.') { + return Err(HostnameError::LeadingDot); + } + if hostname.as_bytes().contains(&0) { + return Err(HostnameError::EmbeddedNul); + } + if hostname.len() > 253 { + return Err(HostnameError::TooLong); + } + Ok(()) +} + +/// Convert PROTO/OP bits into the rustls version slice. +#[must_use] +pub fn rustls_versions( + minimum: i32, + maximum: i32, + options: i32, +) -> &'static [&'static rustls::SupportedProtocolVersion] { + static TLS12_ONLY: &[&rustls::SupportedProtocolVersion] = &[&TLS12]; + static TLS13_ONLY: &[&rustls::SupportedProtocolVersion] = &[&TLS13]; + + let min = if minimum == -2 { + PROTO_TLSV1_2 + } else { + minimum + }; + let max = if maximum == -1 { + PROTO_TLSV1_3 + } else { + maximum + }; + + let tls12_disabled = options & OP_NO_TLSV1_2 != 0; + let tls13_disabled = options & OP_NO_TLSV1_3 != 0; + let want_tls12 = + (min == 0 || min <= PROTO_TLSV1_2) && (max == 0 || max >= PROTO_TLSV1_2) && !tls12_disabled; + let want_tls13 = + (min == 0 || min <= PROTO_TLSV1_3) && (max == 0 || max >= PROTO_TLSV1_3) && !tls13_disabled; + + match (want_tls12, want_tls13) { + (true, true) | (false, false) => rustls::DEFAULT_VERSIONS, + (true, false) => TLS12_ONLY, + (false, true) => TLS13_ONLY, + } +} + +/// Why a length-prefixed ALPN list was rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlpnError(pub String); + +/// Parse `[len][proto]...` the way `ssl.py` hands it to `_set_alpn_protocols`. +pub fn parse_length_prefixed_alpn(bytes: &[u8]) -> Result>, AlpnError> { + let mut alpn_list = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + if offset + 1 > bytes.len() { + return Err(AlpnError(format!( + "Invalid ALPN protocol data: unexpected end at offset {offset}" + ))); + } + let proto_len = bytes[offset] as usize; + offset += 1; + if proto_len == 0 { + return Err(AlpnError(format!( + "Invalid ALPN protocol data: protocol length cannot be 0 at offset {}", + offset - 1 + ))); + } + if offset + proto_len > bytes.len() { + return Err(AlpnError(format!( + "Invalid ALPN protocol data: expected {} bytes at offset {}, but only {} bytes remain", + proto_len, + offset, + bytes.len() - offset + ))); + } + alpn_list.push(bytes[offset..offset + proto_len].to_vec()); + offset += proto_len; + } + Ok(alpn_list) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostname_rejects_empty_dot_nul_and_overlong() { + assert_eq!(validate_hostname(""), Err(HostnameError::Empty)); + assert_eq!( + validate_hostname(".example"), + Err(HostnameError::LeadingDot) + ); + assert_eq!( + validate_hostname("ex\0ample"), + Err(HostnameError::EmbeddedNul) + ); + assert_eq!( + validate_hostname(&"a".repeat(254)), + Err(HostnameError::TooLong) + ); + assert!(validate_hostname("localhost").is_ok()); + } + + #[test] + fn alpn_reads_length_prefixed_protocols() { + assert_eq!( + parse_length_prefixed_alpn(&[ + 2, b'h', b'2', 8, b'h', b't', b't', b'p', b'/', b'1', b'.', b'1' + ]) + .unwrap(), + [b"h2".to_vec(), b"http/1.1".to_vec()] + ); + assert!(parse_length_prefixed_alpn(&[0]).is_err()); + assert!(parse_length_prefixed_alpn(&[3, b'h', b'2']).is_err()); + } +} diff --git a/crates/host_env/src/ssl/session.rs b/crates/host_env/src/ssl/session.rs new file mode 100644 index 000000000..ea43c10b5 --- /dev/null +++ b/crates/host_env/src/ssl/session.rs @@ -0,0 +1,151 @@ +//! rustls client session store plus synthetic session metadata. + +use alloc::sync::Arc; +use core::sync::atomic::{AtomicUsize, Ordering}; +use std::{collections::HashMap, time::SystemTime}; + +use parking_lot::{Mutex, RwLock}; +use rustls::{ + NamedGroup, + client::{ + ClientSessionMemoryCache, ClientSessionStore, Tls12ClientSessionValue, + Tls13ClientSessionValue, + }, + pki_types::ServerName, +}; +use sha2::{Digest, Sha256}; + +pub const SESSION_CACHE_SIZE: usize = 256; +static NEXT_SESSION_NONCE: AtomicUsize = AtomicUsize::new(1); + +/// Synthetic session metadata. rustls does not expose ticket bytes. +#[derive(Debug, Clone)] +pub struct SessionData { + pub server_name: String, + pub session_id: Vec, + pub creation_time: SystemTime, + pub lifetime: u64, +} + +impl SessionData { + #[must_use] + pub fn new(server_name: &str, lifetime: u64) -> Self { + let creation_time = SystemTime::now(); + let nonce = NEXT_SESSION_NONCE.fetch_add(1, Ordering::Relaxed); + let mut hasher = Sha256::new(); + hasher.update(server_name.as_bytes()); + hasher.update( + creation_time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(), + ); + hasher.update(nonce.to_le_bytes()); + Self { + server_name: server_name.to_owned(), + session_id: hasher.finalize()[..16].to_vec(), + creation_time, + lifetime, + } + } +} + +pub type SessionCache = Arc, Arc>>>>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClientSessionKind { + Tls12, + Tls13, +} + +/// rustls session store that also records metadata for `SSLSession`. +#[derive(Debug)] +pub struct CapturingClientSessionStore { + inner: Arc, + session_cache: SessionCache, +} + +impl CapturingClientSessionStore { + #[must_use] + pub fn new(session_cache: SessionCache) -> Self { + Self { + inner: Arc::new(ClientSessionMemoryCache::new(SESSION_CACHE_SIZE)), + session_cache, + } + } + + pub fn transfer_session( + &self, + target: &Self, + server_name: &ServerName<'static>, + kind: ClientSessionKind, + ) { + if let Some(group) = self.kx_hint(server_name) { + target.set_kx_hint(server_name.clone(), group); + } + match kind { + ClientSessionKind::Tls12 => { + if let Some(session) = self.tls12_session(server_name) { + target.set_tls12_session(server_name.clone(), session); + } + } + ClientSessionKind::Tls13 => { + if let Some(ticket) = self.take_tls13_ticket(server_name) { + target.insert_tls13_ticket(server_name.clone(), ticket); + } + } + } + } + + fn record_metadata(&self, server_name: &ServerName<'static>) { + let server_name_str = server_name.to_str(); + let session_data = SessionData::new(server_name_str.as_ref(), 7200); + self.session_cache.write().insert( + server_name_str.as_bytes().to_vec(), + Arc::new(Mutex::new(session_data)), + ); + } +} + +impl ClientSessionStore for CapturingClientSessionStore { + fn set_kx_hint(&self, server_name: ServerName<'static>, group: NamedGroup) { + self.inner.set_kx_hint(server_name, group); + } + + fn kx_hint(&self, server_name: &ServerName<'_>) -> Option { + self.inner.kx_hint(server_name) + } + + fn set_tls12_session(&self, server_name: ServerName<'static>, value: Tls12ClientSessionValue) { + self.inner.set_tls12_session(server_name.clone(), value); + self.record_metadata(&server_name); + } + + fn tls12_session(&self, server_name: &ServerName<'_>) -> Option { + self.inner.tls12_session(server_name) + } + + fn remove_tls12_session(&self, server_name: &ServerName<'static>) { + self.inner.remove_tls12_session(server_name); + self.session_cache + .write() + .remove(server_name.to_str().as_bytes()); + } + + fn insert_tls13_ticket( + &self, + server_name: ServerName<'static>, + value: Tls13ClientSessionValue, + ) { + self.inner.insert_tls13_ticket(server_name.clone(), value); + self.record_metadata(&server_name); + } + + fn take_tls13_ticket( + &self, + server_name: &ServerName<'static>, + ) -> Option { + self.inner.take_tls13_ticket(server_name) + } +} diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index 734621dce..9ce40be0a 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -65,7 +65,6 @@ rand = { workspace = true } mt19937 = { workspace = true } # Crypto: -sha2 = { workspace = true } constant_time_eq = { workspace = true } # compression diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 8b78dd864..4603d3985 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -5,9 +5,8 @@ //! This module provides SSL/TLS support without requiring C dependencies. //! It implements the Python ssl module API using: //! - rustls: TLS protocol implementation -//! - x509-parser/x509-cert: Certificate parsing +//! - x509-parser: Certificate parsing //! - ring: Cryptographic primitives -//! - rustls-platform-verifier: Platform-native certificate verification //! //! DO NOT add openssl dependency here. //! @@ -44,8 +43,8 @@ mod _ssl { AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, builtins::{ - PyBaseExceptionRef, PyByteArray, PyBytesRef, PyListRef, PyStrRef, PyType, - PyTypeRef, PyUtf8StrRef, PyWeak, + PyBaseExceptionRef, PyBytesRef, PyListRef, PyStrRef, PyType, PyTypeRef, + PyUtf8StrRef, PyWeak, }, convert::IntoPyException, function::{ @@ -69,27 +68,26 @@ mod _ssl { sync::atomic::{AtomicUsize, Ordering}, time::Duration, }; - use memchr::memchr; use rustpython_vm::exceptions; use std::{ collections::{HashMap, hash_map::DefaultHasher}, - io::BufRead, time::SystemTime, }; // Rustls imports - use parking_lot::{Mutex as ParkingMutex, RwLock as ParkingRwLock}; + use parking_lot::RwLock as ParkingRwLock; use pem_rfc7468::{LineEnding, encode_string}; use rustls::{ ClientConnection, Connection, HandshakeKind, RootCertStore, - client::{ClientSessionMemoryCache, ClientSessionStore}, crypto::SupportedKxGroup, pki_types::{CertificateDer, CertificateRevocationListDer, PrivateKeyDer, ServerName}, server::{Accepted, ResolvesServerCert}, sign::CertifiedKey, - version::{TLS12, TLS13}, }; - use sha2::{Digest, Sha256}; + use rustpython_host_env::ssl::{ + CapturingClientSessionStore, ClientSessionKind, RecordCursor, SessionCache, SessionData, + TlsConnection, + }; /// Caps unsent TLS so BIO writes stay inside asyncio's 64 KiB /// socket-transport high-water mark when SO_SNDBUF is small. @@ -149,8 +147,6 @@ mod _ssl { #[pyattr] const PROTOCOL_TLSv1_3: i32 = rustpython_host_env::ssl::PROTOCOL_TLSV1_3; - static NEXT_SSL_SESSION_NONCE: AtomicUsize = AtomicUsize::new(1); - // Protocol version constants for TLSVersion enum #[pyattr] const PROTO_SSLv3: i32 = 0x0300; @@ -159,9 +155,9 @@ mod _ssl { #[pyattr] const PROTO_TLSv1_1: i32 = 0x0302; #[pyattr] - const PROTO_TLSv1_2: i32 = 0x0303; + const PROTO_TLSv1_2: i32 = rustpython_host_env::ssl::PROTO_TLSV1_2; #[pyattr] - const PROTO_TLSv1_3: i32 = 0x0304; + const PROTO_TLSv1_3: i32 = rustpython_host_env::ssl::PROTO_TLSV1_3; // Minimum and maximum supported protocol versions for rustls // Use special values -2 and -1 to avoid enum name conflicts @@ -177,30 +173,9 @@ mod _ssl { // Buffer sizes and limits (OpenSSL/CPython compatibility) const PEM_BUFSIZE: usize = 1024; - - // OpenSSL: ssl/ssl_local.h - const SSL3_RT_HEADER_LENGTH: usize = 5; - // This is the maximum MAC (digest) size used by the SSL library. Currently - // maximum of 20 is used by SHA1, but we reserve for future extension for - // 512-bit hashes. - const SSL3_RT_MAX_MD_SIZE: usize = 64; - // Maximum plaintext length: defined by SSL/TLS standards - const SSL3_RT_MAX_PLAIN_LENGTH: usize = 16384; - // Maximum compression overhead: defined by SSL/TLS standards - const SSL3_RT_MAX_COMPRESSED_OVERHEAD: usize = 1024; - // The standards give a maximum encryption overhead of 1024 bytes. In - // practice the value is lower than this. The overhead is the maximum number - // of padding bytes (256) plus the mac size. - const SSL3_RT_MAX_ENCRYPTED_OVERHEAD: usize = 256 + SSL3_RT_MAX_MD_SIZE; - const SSL3_RT_MAX_COMPRESSED_LENGTH: usize = - SSL3_RT_MAX_PLAIN_LENGTH + SSL3_RT_MAX_COMPRESSED_OVERHEAD; - const SSL3_RT_MAX_ENCRYPTED_LENGTH: usize = - SSL3_RT_MAX_ENCRYPTED_OVERHEAD + SSL3_RT_MAX_COMPRESSED_LENGTH; pub(crate) const SSL3_RT_MAX_PACKET_SIZE: usize = - SSL3_RT_MAX_ENCRYPTED_LENGTH + SSL3_RT_HEADER_LENGTH; - - // SSL session cache size (common practice, similar to OpenSSL defaults) - const SSL_SESSION_CACHE_SIZE: usize = 256; + rustpython_host_env::ssl::SSL3_RT_MAX_PACKET_SIZE; + const SSL_SESSION_CACHE_SIZE: usize = rustpython_host_env::ssl::SESSION_CACHE_SIZE; // Certificate verification modes #[pyattr] @@ -242,9 +217,9 @@ mod _ssl { #[pyattr] const OP_NO_TLSv1_1: i32 = 0x10000000; #[pyattr] - const OP_NO_TLSv1_2: i32 = 0x08000000; + const OP_NO_TLSv1_2: i32 = rustpython_host_env::ssl::OP_NO_TLSV1_2; #[pyattr] - const OP_NO_TLSv1_3: i32 = 0x20000000; + const OP_NO_TLSv1_3: i32 = rustpython_host_env::ssl::OP_NO_TLSV1_3; #[pyattr] const OP_NO_COMPRESSION: i32 = 0x00020000; #[pyattr] @@ -389,263 +364,23 @@ mod _ssl { #[pyattr] const ENCODING_PEM_AUX: i32 = 0x101; // PEM + 0x100 - /// Validate server hostname for TLS SNI - /// - /// Checks that the hostname: - /// - Is not empty - /// - Does not start with a dot - /// - Is not an IP address (SNI requires DNS names) - /// - Does not contain null bytes - /// - Does not exceed 253 characters (DNS limit) - /// - /// Returns Ok(()) if validation passes, or an appropriate error. + /// Validate server hostname for TLS SNI. fn validate_hostname(hostname: &str, vm: &VirtualMachine) -> PyResult<()> { - if hostname.is_empty() { - return Err(vm.new_value_error("server_hostname cannot be an empty string")); - } - - if hostname.starts_with('.') { - return Err(vm.new_value_error("server_hostname cannot start with a dot")); - } - - // IP addresses are allowed as server_hostname - // SNI will not be sent for IP addresses - - if memchr(b'\0', hostname.as_bytes()).is_some() { - cold_path(); - return Err(exceptions::nul_char_type_error(vm)); - } - - if hostname.len() > 253 { - return Err(vm.new_value_error("server_hostname is too long (maximum 253 characters)")); - } - - Ok(()) - } - - // Session data structure for tracking TLS sessions - #[derive(Debug, Clone)] - struct SessionData { - _server_name: String, - session_id: Vec, - creation_time: SystemTime, - lifetime: u64, - } - - impl SessionData { - // NOTE: This is NOT the actual TLS session ID, just a unique identifier. - fn new(server_name: &str, lifetime: u64) -> Self { - let creation_time = SystemTime::now(); - let nonce = NEXT_SSL_SESSION_NONCE.fetch_add(1, Ordering::Relaxed); - let mut hasher = Sha256::new(); - hasher.update(server_name.as_bytes()); - hasher.update( - creation_time - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - .to_le_bytes(), - ); - hasher.update(nonce.to_le_bytes()); - - Self { - _server_name: server_name.to_owned(), - session_id: hasher.finalize()[..16].to_vec(), - creation_time, - lifetime, + use rustpython_host_env::ssl::HostnameError; + match rustpython_host_env::ssl::validate_hostname(hostname) { + Ok(()) => Ok(()), + Err(HostnameError::EmbeddedNul) => { + cold_path(); + Err(exceptions::nul_char_type_error(vm)) } + Err(error) => Err(vm.new_value_error(error.message().to_owned())), } } - // Type alias to simplify complex session cache type - type SessionCache = Arc, Arc>>>>; - - // SESSION EMULATION IMPLEMENTATION - // - // IMPORTANT: This is an EMULATION of CPython's SSL session management. - // Rustls 0.23 does NOT expose session data (ticket bytes, session IDs, etc.) - // through public APIs. All session value fields are private. - // - // LIMITATIONS: - // - Session IDs are generated from metadata (server name + timestamp hash) - // NOT actual TLS session IDs - // - Ticket data is not stored (Rustls keeps it internally) - // - Session resumption works (via Rustls's automatic mechanism) - // but we can't access the actual session state - // - // This implementation provides: - // ✓ session.id - synthetic ID based on metadata - // ✓ session.time - creation timestamp - // ✓ session.timeout - default lifetime value - // ✓ session.has_ticket - always True when session exists - // ✓ session_reused - tracked via handshake_kind() - // ✗ Actual TLS session ID/ticket data - NOT ACCESSIBLE - - // Custom ClientSessionStore that tracks session metadata for Python access - // NOTE: This wraps ClientSessionMemoryCache and records metadata when sessions are stored - #[derive(Debug)] - struct PythonClientSessionStore { - inner: Arc, - session_cache: SessionCache, - } - - impl PythonClientSessionStore { - fn new(session_cache: SessionCache) -> Self { - Self { - inner: Arc::new(ClientSessionMemoryCache::new(SSL_SESSION_CACHE_SIZE)), - session_cache, - } - } - - fn transfer_session( - &self, - target: &Self, - server_name: &ServerName<'static>, - kind: ClientSessionKind, - ) { - if let Some(group) = self.kx_hint(server_name) { - target.set_kx_hint(server_name.clone(), group); - } - - match kind { - ClientSessionKind::Tls12 => { - if let Some(session) = self.tls12_session(server_name) { - target.set_tls12_session(server_name.clone(), session); - } - } - ClientSessionKind::Tls13 => { - if let Some(ticket) = self.take_tls13_ticket(server_name) { - target.insert_tls13_ticket(server_name.clone(), ticket); - } - } - } - } - } - - impl ClientSessionStore for PythonClientSessionStore { - fn set_kx_hint(&self, server_name: ServerName<'static>, group: rustls::NamedGroup) { - self.inner.set_kx_hint(server_name, group); - } - - fn kx_hint(&self, server_name: &ServerName<'_>) -> Option { - self.inner.kx_hint(server_name) - } - - fn set_tls12_session( - &self, - server_name: ServerName<'static>, - value: rustls::client::Tls12ClientSessionValue, - ) { - // Store in inner cache for actual resumption (Rustls handles this) - self.inner.set_tls12_session(server_name.clone(), value); - - // Record metadata in Python-accessible cache - // NOTE: We can't access value.session_id or value.ticket (private fields) - // So we generate a synthetic ID from metadata - let server_name_str = server_name.to_str(); - let session_data = SessionData::new(server_name_str.as_ref(), 7200); - - let key = server_name_str.as_bytes().to_vec(); - self.session_cache - .write() - .insert(key, Arc::new(ParkingMutex::new(session_data))); - } - - fn tls12_session( - &self, - server_name: &ServerName<'_>, - ) -> Option { - self.inner.tls12_session(server_name) - } - - fn remove_tls12_session(&self, server_name: &ServerName<'static>) { - self.inner.remove_tls12_session(server_name); - - // Also remove from Python cache - let key = server_name.to_str().as_bytes().to_vec(); - self.session_cache.write().remove(&key); - } - - fn insert_tls13_ticket( - &self, - server_name: ServerName<'static>, - value: rustls::client::Tls13ClientSessionValue, - ) { - // Store in inner cache for actual resumption (Rustls handles this) - self.inner.insert_tls13_ticket(server_name.clone(), value); - - // Record metadata in Python-accessible cache - // NOTE: We can't access value.ticket or value.lifetime_secs (private fields) - // So we use default values - let server_name_str = server_name.to_str(); - let session_data = SessionData::new(server_name_str.as_ref(), 7200); - - let key = server_name_str.as_bytes().to_vec(); - self.session_cache - .write() - .insert(key, Arc::new(ParkingMutex::new(session_data))); - } - - fn take_tls13_ticket( - &self, - server_name: &ServerName<'static>, - ) -> Option { - self.inner.take_tls13_ticket(server_name) - } - } - - /// Parse length-prefixed ALPN protocol list - /// - /// Format: [len1, proto1..., len2, proto2..., ...] - /// - /// This is the wire format used by Python's ssl.py when calling _set_alpn_protocols(). - /// Each protocol is prefixed with a single byte indicating its length. - /// - /// # Arguments - /// * `bytes` - The length-prefixed protocol data - /// * `vm` - VirtualMachine for error creation - /// - /// # Returns - /// * `Ok(Vec>)` - List of protocol names as byte vectors - /// * `Err(PyBaseExceptionRef)` - ValueError with detailed error message + /// Parse `[len][proto]...` the way `ssl.py` hands it to `_set_alpn_protocols`. fn parse_length_prefixed_alpn(bytes: &[u8], vm: &VirtualMachine) -> PyResult>> { - let mut alpn_list = Vec::new(); - let mut offset = 0; - - while offset < bytes.len() { - // Check if we can read the length byte - if offset + 1 > bytes.len() { - return Err(vm.new_value_error(format!( - "Invalid ALPN protocol data: unexpected end at offset {offset}", - ))); - } - - let proto_len = bytes[offset] as usize; - offset += 1; - - // Validate protocol length - if proto_len == 0 { - return Err(vm.new_value_error(format!( - "Invalid ALPN protocol data: protocol length cannot be 0 at offset {}", - offset - 1 - ))); - } - - // Check if we have enough bytes for the protocol data - if offset + proto_len > bytes.len() { - return Err(vm.new_value_error(format!( - "Invalid ALPN protocol data: expected {} bytes at offset {}, but only {} bytes remain", - proto_len, offset, bytes.len() - offset - ))); - } - - // Extract protocol bytes - let proto = bytes[offset..offset + proto_len].to_vec(); - alpn_list.push(proto); - offset += proto_len; - } - - Ok(alpn_list) + rustpython_host_env::ssl::parse_length_prefixed_alpn(bytes) + .map_err(|error| vm.new_value_error(error.0)) } // SSLContext - manages TLS configuration @@ -1897,10 +1632,7 @@ mod _ssl { send: socket_class.get_attr("send", vm)?, recv: socket_class.get_attr("recv", vm)?, }, - tls_record_header_buf: vm - .ctx - .new_bytearray(Vec::with_capacity(TLS_RECORD_HEADER_SIZE)) - .into(), + record_cursor: PyMutex::new(RecordCursor::default()), key_log: Arc::new(super::keylog::ConnectionKeyLog::new(zelf.key_log.clone())), context: PyRwLock::new(zelf), server_side: args.server_side, @@ -1920,7 +1652,6 @@ mod _ssl { verified_chain: PyRwLock::new(None), client_session_store: PyRwLock::new(None), pending_tls_output: PyMutex::new(Vec::new()), - write_buffered_len: PyMutex::new(0), msg_state: PyMutex::new(super::msg::MsgState::default()), pending_msg_exc: PyMutex::new(None), deferred_cert_error: Arc::new(ParkingRwLock::new(None)), @@ -1987,10 +1718,7 @@ mod _ssl { outgoing: args.outgoing, }, - tls_record_header_buf: vm - .ctx - .new_bytearray(Vec::with_capacity(TLS_RECORD_HEADER_SIZE)) - .into(), + record_cursor: PyMutex::new(RecordCursor::default()), key_log: Arc::new(super::keylog::ConnectionKeyLog::new(zelf.key_log.clone())), context: PyRwLock::new(zelf), server_side, @@ -2010,7 +1738,6 @@ mod _ssl { verified_chain: PyRwLock::new(None), client_session_store: PyRwLock::new(None), pending_tls_output: PyMutex::new(Vec::new()), - write_buffered_len: PyMutex::new(0), msg_state: PyMutex::new(super::msg::MsgState::default()), pending_msg_exc: PyMutex::new(None), deferred_cert_error: Arc::new(ParkingRwLock::new(None)), @@ -2394,9 +2121,8 @@ mod _ssl { #[derive(Debug, PyPayload)] pub(crate) struct PySSLSocket { io: SocketOrBio, - // Header of currently read TLS record. #[pytraverse(skip)] - tls_record_header_buf: PyObjectRef, + record_cursor: PyMutex, // SSL context context: PyRwLock>, #[pytraverse(skip)] @@ -2409,7 +2135,7 @@ mod _ssl { server_hostname: PyRwLock>, // TLS connection state #[pytraverse(skip)] - connection: PyMutex>, + connection: PyMutex>, // Includes the saved exception while a rejected handshake sends its alert. state: PyMutex, // Session was reused (for session resumption tracking) @@ -2429,17 +2155,13 @@ mod _ssl { verified_chain: PyRwLock>>>, // Per-connection store containing the session selected by this connection. #[pytraverse(skip)] - client_session_store: PyRwLock>>, + client_session_store: PyRwLock>>, // Pending TLS output buffer for non-blocking sockets // Stores unsent TLS bytes when sock_send() would block // This prevents data loss when write_tls() drains rustls' internal buffer // but the socket cannot accept all the data immediately #[pytraverse(skip)] pub(crate) pending_tls_output: PyMutex>, - // Tracks bytes already buffered in rustls for the current write operation - // Prevents duplicate writes when retrying after WantWrite/WantRead - #[pytraverse(skip)] - pub(crate) write_buffered_len: PyMutex, #[pytraverse(skip)] msg_state: PyMutex, pending_msg_exc: PyMutex>, @@ -2451,9 +2173,6 @@ mod _ssl { deferred_cert_error: Arc>>, } - /// TLS record header size (content_type + version + length). - const TLS_RECORD_HEADER_SIZE: usize = 5; - #[pyclass(with(Constructor, Representable), flags(BASETYPE))] impl PySSLSocket { // Check if this is BIO mode @@ -2472,10 +2191,23 @@ mod _ssl { /// rustls connection lock. Hold only around rustls operations, never /// across socket or MemoryBIO I/O. - pub(crate) fn connection(&self) -> &PyMutex> { + pub(crate) fn connection(&self) -> &PyMutex> { &self.connection } + pub(crate) fn write_buffered_len(&self) -> usize { + self.connection + .lock() + .as_ref() + .map_or(0, TlsConnection::write_buffered_len) + } + + pub(crate) fn set_write_buffered_len(&self, n: usize) { + if let Some(conn) = self.connection.lock().as_mut() { + conn.set_write_buffered_len(n); + } + } + pub(crate) fn observe_tls(&self, write: bool, bytes: &[u8], vm: &VirtualMachine) { if bytes.is_empty() { return; @@ -2605,7 +2337,7 @@ mod _ssl { .as_ref() .and_then(|session| session.downcast_ref::()) .map_or(cached_session_data, |session| SessionData { - _server_name: server_name.clone().unwrap_or_default(), + server_name: server_name.clone().unwrap_or_default(), session_id: session.session_id.clone(), creation_time: session.creation_time, lifetime: session.lifetime, @@ -2970,31 +2702,22 @@ mod _ssl { .map_err(|_| vm.new_os_error("Expected bytes from recv")) }; - let tls_record_header_buf = self - .tls_record_header_buf - .clone() - .downcast::() - .expect("BUG: tls_record_header_buf is not PyByteArray"); - - let buf_len = tls_record_header_buf.borrow_buf().len(); - let (mut collected, remaining) = if buf_len < TLS_RECORD_HEADER_SIZE { - let bytes = obj_to_bytes(self.sock_recv(TLS_RECORD_HEADER_SIZE - buf_len, vm)?)?; - let mut header = tls_record_header_buf.borrow_buf_mut(); - header.extend_from_slice(bytes.as_bytes()); - if header.len() < TLS_RECORD_HEADER_SIZE { + let (mut collected, remaining) = if self.record_cursor.lock().in_header() { + let want = self.record_cursor.lock().want(); + let bytes = obj_to_bytes(self.sock_recv(want, vm)?)?; + self.record_cursor.lock().consume(bytes.as_bytes()); + if self.record_cursor.lock().in_header() { return Ok(bytes); } - let remaining = u16::from_be_bytes([header[3], header[4]]); + let remaining = self.record_cursor.lock().want(); if remaining == 0 { - header.clear(); return Ok(bytes); } (Some(bytes.as_bytes().to_vec()), remaining) } else { - let header = tls_record_header_buf.borrow_buf(); - (None, u16::from_be_bytes([header[3], header[4]])) + (None, self.record_cursor.lock().want()) }; - let bytes = match self.sock_recv(remaining as usize, vm) { + let bytes = match self.sock_recv(remaining, vm) { Ok(bytes) => obj_to_bytes(bytes)?, Err(error) => { // Header bytes already consumed must reach the TLS parser, @@ -3008,13 +2731,7 @@ mod _ssl { return Err(error); } }; - let mut header = tls_record_header_buf.borrow_buf_mut(); - let remaining = remaining - bytes.len() as u16; - if remaining == 0 { - header.clear(); - } else { - header[3..5].copy_from_slice(&remaining.to_be_bytes()); - } + self.record_cursor.lock().consume(bytes.as_bytes()); if let Some(collected) = collected.as_mut() { collected.extend_from_slice(bytes.as_bytes()); } @@ -3066,59 +2783,13 @@ mod _ssl { Ok(()) } - // Helper function to convert Python PROTO_* constants to rustls versions - fn get_rustls_versions( - minimum: i32, - maximum: i32, - options: i32, - ) -> &'static [&'static rustls::SupportedProtocolVersion] { - // Rustls only supports TLS 1.2 and 1.3 - // PROTO_TLSv1_2 = 0x0303, PROTO_TLSv1_3 = 0x0304 - // PROTO_MINIMUM_SUPPORTED = -2, PROTO_MAXIMUM_SUPPORTED = -1 - // If minimum and maximum are 0, use default (both TLS 1.2 and 1.3) - - // Static arrays for single-version configurations - static TLS12_ONLY: &[&rustls::SupportedProtocolVersion] = &[&TLS12]; - static TLS13_ONLY: &[&rustls::SupportedProtocolVersion] = &[&TLS13]; - - // Normalize special values: -2 (MINIMUM_SUPPORTED) → TLS 1.2, -1 (MAXIMUM_SUPPORTED) → TLS 1.3 - let min = if minimum == -2 { - PROTO_TLSv1_2 - } else { - minimum - }; - let max = if maximum == -1 { - PROTO_TLSv1_3 - } else { - maximum - }; - - // Check if versions are disabled by options - let tls12_disabled = (options & OP_NO_TLSv1_2) != 0; - let tls13_disabled = (options & OP_NO_TLSv1_3) != 0; - - let want_tls12 = (min == 0 || min <= PROTO_TLSv1_2) - && (max == 0 || max >= PROTO_TLSv1_2) - && !tls12_disabled; - let want_tls13 = (min == 0 || min <= PROTO_TLSv1_3) - && (max == 0 || max >= PROTO_TLSv1_3) - && !tls13_disabled; - - match (want_tls12, want_tls13) { - (true, true) => rustls::DEFAULT_VERSIONS, // Both TLS 1.2 and 1.3 - (true, false) => TLS12_ONLY, // Only TLS 1.2 - (false, true) => TLS13_ONLY, // Only TLS 1.3 - (false, false) => rustls::DEFAULT_VERSIONS, // Fallback to default - } - } - /// Helper: Prepare TLS versions from context settings fn prepare_tls_versions(&self) -> &'static [&'static rustls::SupportedProtocolVersion] { let ctx = self.context.read(); let min_ver = *ctx.minimum_version.read(); let max_ver = *ctx.maximum_version.read(); let options = *ctx.options.read(); - Self::get_rustls_versions(min_ver, max_ver, options) + rustpython_host_env::ssl::rustls_versions(min_ver, max_ver, options) } /// Helper: Prepare KX groups (ECDH curve) from context settings @@ -3171,7 +2842,7 @@ mod _ssl { /// Continue the accepted ClientHello using the context selected by SNI. fn initialize_server_connection( &self, - conn_guard: &mut Option, + conn_guard: &mut Option, accepted: Accepted, vm: &VirtualMachine, ) -> PyResult<()> { @@ -3300,7 +2971,7 @@ mod _ssl { match accepted.into_connection(config_arc) { Ok(mut conn) => { conn.set_buffer_limit(TLS_IO_BUFFER_LIMIT); - *conn_guard = Some(Connection::Server(conn)); + *conn_guard = Some(TlsConnection::new(Connection::Server(conn))); *self.state.lock() = TlsState::Handshaking; } Err((error, mut alert)) => { @@ -3396,7 +3067,7 @@ mod _ssl { }; let explicit_session = self.session.read().clone(); - let session_store = Arc::new(PythonClientSessionStore::new(session_cache)); + let session_store = Arc::new(CapturingClientSessionStore::new(session_cache)); let (mut config, chain_builder) = if let Some(session) = explicit_session { let session = session .downcast_ref::() @@ -3447,7 +3118,7 @@ mod _ssl { })?; conn.set_buffer_limit(TLS_IO_BUFFER_LIMIT); - *conn_guard = Some(Connection::Client(conn)); + *conn_guard = Some(TlsConnection::new(Connection::Client(conn))); } } drop(conn_guard); @@ -3457,10 +3128,12 @@ mod _ssl { if let Err(error @ (SslError::Rustls(_) | SslError::PreauthData)) = handshake_result { // rustls queued the fatal alert while processing the failing // record. Retain it and the original error across output retries. - let mut bytes = Vec::new(); - if let Some(conn) = self.connection.lock().as_mut() { - let _ = conn.write_tls(&mut bytes); - } + let bytes = self + .connection + .lock() + .as_mut() + .and_then(|conn| conn.drain_tls().ok()) + .unwrap_or_default(); self.reject_connection(error.into_py_err(vm), bytes, vm); return self.accept_client_hello(vm).map(|_| ()); } @@ -3577,9 +3250,7 @@ mod _ssl { Some(conn) => conn, None => return Err(create_ssl_eof_error(vm).upcast()), }; - - let mut reader = conn.reader(); - reader.fill_buf().map_or(0, |buf| buf.len()) + conn.pending_plaintext() }; if pending > 0 { let mut buf = vec![0u8; pending.min(len)]; @@ -3600,9 +3271,7 @@ mod _ssl { Some(conn) => conn, None => return Err(create_ssl_zero_return_error(vm).upcast()), }; - - let mut reader = conn.reader(); - reader.fill_buf().map_or(0, |buf| buf.len()) + conn.pending_plaintext() }; if pending > 0 { let mut buf = vec![0u8; pending.min(len)]; @@ -3657,19 +3326,7 @@ mod _ssl { Some(c) => c, None => return 0, // No connection established yet }; - - // Use rustls Reader's fill_buf() to check buffered plaintext - // fill_buf() returns a reference to buffered data without consuming it - // This matches OpenSSL's SSL_pending() behavior - let mut reader = conn.reader(); - match reader.fill_buf() { - Ok(buf) => buf.len(), - Err(_) => { - // WouldBlock or other errors mean no data available - // Return 0 like OpenSSL does when buffer is empty - 0 - } - } + conn.pending_plaintext() } #[pymethod] @@ -4026,15 +3683,14 @@ mod _ssl { } let result = (|| loop { - let mut bytes = Vec::new(); - { + let bytes = { let mut conn_guard = self.connection.lock(); let conn = conn_guard .as_mut() .ok_or_else(|| vm.new_value_error("Connection not established"))?; - conn.write_tls(&mut bytes) - .map_err(|e| e.into_pyexception(vm))?; - } + conn.drain_tls() + .map_err(|e| SslError::from(e).into_py_err(vm))? + }; super::compat::send_all_bytes(self, bytes, vm, deadline) .map_err(|e| e.into_py_err(vm))?; @@ -4043,8 +3699,8 @@ mod _ssl { let conn = conn_guard .as_mut() .ok_or_else(|| vm.new_value_error("Connection not established"))?; - conn.process_new_packets() - .map_err(|e| SslError::from_rustls(e).into_py_err(vm))? + conn.process_packets() + .map_err(|e| SslError::from(e).into_py_err(vm))? }; if io_state.plaintext_bytes_to_read() != 0 { return Err(SslError::create_ssl_error_with_reason( @@ -4093,10 +3749,12 @@ mod _ssl { { // A protocol failure cannot resume as a normal shutdown. Keep // any fatal alert rustls queued and preserve the original error. - let mut bytes = Vec::new(); - if let Some(conn) = self.connection.lock().as_mut() { - let _ = conn.write_tls(&mut bytes); - } + let bytes = self + .connection + .lock() + .as_mut() + .and_then(|conn| conn.drain_tls().ok()) + .unwrap_or_default(); self.reject_connection(error.clone(), bytes, vm); return self .accept_client_hello(vm) @@ -4156,7 +3814,7 @@ mod _ssl { // Check connection exists and protocol version let conn_guard = self.connection.lock(); if let Some(conn) = conn_guard.as_ref() { - let version = match conn { + let version = match conn.inner() { Connection::Client(_) => { return Err(vm.new_value_error( "Post-handshake authentication requires server socket", @@ -4360,12 +4018,6 @@ mod _ssl { // SSLSession - represents a cached SSL session // NOTE: This is an EMULATION - actual session data is managed by Rustls internally - #[derive(Debug, Clone, Copy)] - enum ClientSessionKind { - Tls12, - Tls13, - } - #[pyattr] #[pyclass(name = "SSLSession", module = "ssl")] #[derive(Debug, PyPayload)] @@ -4373,7 +4025,7 @@ mod _ssl { context_identity: Arc<()>, client_config: Arc, chain_builder: Arc, - session_store: Arc, + session_store: Arc, server_name: Option>, kind: ClientSessionKind, // Session ID - synthetic ID generated from metadata (NOT actual TLS session ID) diff --git a/crates/stdlib/src/ssl/compat.rs b/crates/stdlib/src/ssl/compat.rs index e82d92472..de620e87e 100644 --- a/crates/stdlib/src/ssl/compat.rs +++ b/crates/stdlib/src/ssl/compat.rs @@ -17,13 +17,11 @@ mod ssl_data; use crate::socket::{SockWaitKind, timeout_error_msg}; use crate::vm::VirtualMachine; -use rustls::Connection; +use rustpython_host_env::ssl::{TlsConnection, TlsError}; use rustpython_vm::builtins::{PyBaseException, PyBaseExceptionRef}; use rustpython_vm::convert::IntoPyException; use rustpython_vm::function::ArgBytesLike; use rustpython_vm::{AsObject, Py, PyObjectRef, PyPayload, PyResult, TryFromObject}; -use std::io::Read; - // Import PySSLSocket from parent module use super::_ssl::{PySSLSocket, SSL3_RT_MAX_PACKET_SIZE}; @@ -130,13 +128,6 @@ pub(super) enum SslError { } impl SslError { - /// Convert TLS alert code to OpenSSL error reason code - /// OpenSSL uses reason = 1000 + alert_code for TLS alerts - fn alert_to_openssl_reason(alert: rustls::AlertDescription) -> i32 { - // AlertDescription can be converted to u8 via as u8 cast - 1000 + (u8::from(alert) as i32) - } - /// Convert rustls error to SslError pub(super) fn from_rustls(err: rustls::Error) -> Self { Self::Rustls(err) @@ -144,67 +135,22 @@ impl SslError { pub(super) fn is_eof(&self) -> bool { match self { - Self::Rustls(error) => matches!(Self::map_rustls(error.clone()), Self::Eof), + Self::Rustls(error) => TlsError::from_rustls(error.clone()).is_eof(), Self::Eof => true, _ => false, } } pub(super) fn is_zero_return(&self) -> bool { - matches!( - self, - Self::ZeroReturn - | Self::Rustls(rustls::Error::AlertReceived( - rustls::AlertDescription::CloseNotify - )) - ) + match self { + Self::Rustls(error) => TlsError::from_rustls(error.clone()).is_zero_return(), + Self::ZeroReturn => true, + _ => false, + } } fn map_rustls(err: rustls::Error) -> Self { - match err { - rustls::Error::InvalidCertificate(cert_err) => Self::CertVerification(cert_err), - rustls::Error::AlertReceived(alert_desc) => { - // Map TLS alerts to OpenSSL-compatible error codes - // lib = 20 (ERR_LIB_SSL), reason = 1000 + alert_code - match alert_desc { - rustls::AlertDescription::CloseNotify => { - // Special case: close_notify is handled as ZeroReturn - Self::ZeroReturn - } - _ => { - // All other alerts: convert to OpenSSL error code - // This includes InternalError (80 -> reason 1080) - Self::AlertReceived { - lib: ERR_LIB_SSL, - reason: Self::alert_to_openssl_reason(alert_desc), - } - } - } - } - // OpenSSL 3.0 changed transport EOF from SSL_ERROR_SYSCALL with - // zero return value to SSL_ERROR_SSL with SSL_R_UNEXPECTED_EOF_WHILE_READING. - // In rustls, these cases correspond to unexpected connection closure: - rustls::Error::InvalidMessage(_) => { - // UnexpectedMessage, CorruptMessage, etc. → SSLEOFError - // Matches CPython's "EOF occurred in violation of protocol" - Self::Eof - } - rustls::Error::PeerIncompatible(peer_err) => { - // Check for specific incompatibility types - use rustls::PeerIncompatible; - match peer_err { - PeerIncompatible::NoCipherSuitesInCommon => { - // Maps to OpenSSL SSL_R_NO_SHARED_CIPHER (lib=20, reason=193) - Self::NoCipherSuites - } - _ => { - // Other protocol incompatibilities → SSLEOFError - Self::Eof - } - } - } - _ => Self::Ssl(format!("{err}")), - } + TlsError::from_rustls(err).into() } /// Create SSLError with library and reason from string values @@ -344,6 +290,25 @@ impl SslError { } } +impl From for SslError { + fn from(err: TlsError) -> Self { + match err { + TlsError::WantRead => Self::WantRead, + TlsError::WantWrite => Self::WantWrite, + TlsError::Syscall(msg) => Self::Syscall(msg), + TlsError::Ssl(msg) => Self::Ssl(msg), + TlsError::ZeroReturn => Self::ZeroReturn, + TlsError::Eof => Self::Eof, + TlsError::PreauthData => Self::PreauthData, + TlsError::CertVerification(cert_err) => Self::CertVerification(cert_err), + TlsError::Io(err) => Self::Io(err), + TlsError::Timeout(msg) => Self::Timeout(msg), + TlsError::AlertReceived { lib, reason } => Self::AlertReceived { lib, reason }, + TlsError::NoCipherSuites => Self::NoCipherSuites, + } + } +} + pub(super) type SslResult = Result; pub(super) use rustpython_host_env::ssl::config::{ ClientConfigOptions, MultiCertResolver, ProtocolSettings, ServerConfigOptions, @@ -363,7 +328,7 @@ pub(super) fn is_blocking_io_error(err: &Py, vm: &VirtualMachin fn with_conn_mut( socket: &PySSLSocket, vm: &VirtualMachine, - f: impl FnOnce(&mut Connection) -> SslResult, + f: impl FnOnce(&mut TlsConnection) -> SslResult, ) -> SslResult { let mut guard = socket.connection().lock(); let conn = guard @@ -480,14 +445,14 @@ fn recv_at_most_one_tls_record_for_data( Ok(data) => Ok(data), Err(SslError::Eof) => { with_conn_mut(socket, vm, |conn| { - conn.process_new_packets().map_err(SslError::from_rustls)?; + conn.process_packets().map_err(SslError::from)?; Ok(()) })?; Ok(vm.ctx.new_bytes(vec![]).into()) } Err(SslError::Py(e)) => { with_conn_mut(socket, vm, |conn| { - conn.process_new_packets().map_err(SslError::from_rustls)?; + conn.process_packets().map_err(SslError::from)?; Ok(()) })?; if is_connection_closed_error(&e, vm) { @@ -518,26 +483,8 @@ fn handshake_read_data(socket: &PySSLSocket, vm: &VirtualMachine) -> SslResult<( /// /// Returns Ok(Some(n)) if n bytes were read, Ok(None) if would block, /// or Err on real errors. -fn try_read_plaintext(conn: &mut Connection, buf: &mut [u8]) -> SslResult> { - let mut reader = conn.reader(); - match reader.read(buf) { - Ok(0) => { - // EOF from TLS connection - Ok(Some(0)) - } - Ok(n) => { - // Successfully read n bytes - Ok(Some(n)) - } - Err(e) if e.kind() != std::io::ErrorKind::WouldBlock => { - // Real error - Err(SslError::Io(e)) - } - Err(_) => { - // WouldBlock - no plaintext available - Ok(None) - } - } +fn try_read_plaintext(conn: &mut TlsConnection, buf: &mut [u8]) -> SslResult> { + conn.read_plaintext(buf).map_err(SslError::from) } /// Equivalent to OpenSSL's SSL_do_handshake() @@ -565,7 +512,7 @@ pub(super) fn ssl_do_handshake(socket: &PySSLSocket, vm: &VirtualMachine) -> Ssl } handshake_read_data(socket, vm)?; with_conn_mut(socket, vm, |conn| { - if let Err(error) = conn.process_new_packets() { + if let Err(error) = conn.inner_mut().process_new_packets() { return Err(if matches!(error, rustls::Error::InvalidMessage(_)) { SslError::PreauthData } else { @@ -714,9 +661,7 @@ pub(super) fn ssl_read( // This is critical for asyncore-based applications that rely on recv() returning // 0 or raising SSL_ERROR_ZERO_RETURN to detect connection close. let peer_closed = with_conn_mut(socket, vm, |conn| { - conn.process_new_packets() - .map(|io_state| io_state.peer_has_closed()) - .map_err(SslError::from_rustls) + conn.peer_has_closed().map_err(SslError::from) })?; if peer_closed { return Err(SslError::ZeroReturn); @@ -739,9 +684,7 @@ pub(super) fn ssl_read( // If close_notify was already received, return ZeroReturn (clean closure) // Otherwise, return Eof (unexpected EOF) let peer_closed = with_conn_mut(socket, vm, |conn| { - conn.process_new_packets() - .map(|io_state| io_state.peer_has_closed()) - .map_err(SslError::from_rustls) + conn.peer_has_closed().map_err(SslError::from) })?; if peer_closed { return Err(SslError::ZeroReturn); @@ -752,7 +695,7 @@ pub(super) fn ssl_read( // Feed data to rustls and process with_conn_mut(socket, vm, |conn| { ssl_read_tls_records(conn, data, false, vm)?; - conn.process_new_packets().map_err(SslError::from_rustls)?; + conn.process_packets().map_err(SslError::from)?; Ok(()) })?; @@ -822,7 +765,7 @@ pub(super) fn ssl_write( // Check if we already have data buffered from a previous retry // (prevents duplicate writes when retrying after WantWrite/WantRead) - let already_buffered = *socket.write_buffered_len.lock(); + let already_buffered = socket.write_buffered_len(); // Only write plaintext if not already buffered // Track how much we wrote for partial write handling @@ -831,38 +774,14 @@ pub(super) fn ssl_write( if already_buffered == 0 { // Write plaintext to rustls (= SSL_write_ex internal buffer write) bytes_written_to_rustls = with_conn_mut(socket, vm, |conn| { - let mut writer = conn.writer(); - use std::io::Write; - // Use write() instead of write_all() to support partial writes. - // In BIO mode (asyncio), when the internal buffer is full, - // we want to write as much as possible and return that count, - // rather than failing completely. - match writer.write(data) { - Ok(0) if !data.is_empty() => { - // Buffer is full and nothing could be written. - // In BIO mode, return WantWrite so the caller can - // drain the outgoing BIO and retry. - if is_bio { - return Err(SslError::WantWrite); - } - Err(SslError::Syscall("Write failed: buffer full".to_string())) - } - Ok(n) => Ok(n), - Err(e) => { - if is_bio { - // In BIO mode, treat write errors as WantWrite - return Err(SslError::WantWrite); - } - Err(SslError::Syscall(format!("Write failed: {e}"))) - } - } + conn.write_plaintext(data, is_bio).map_err(SslError::from) })?; // Mark data as buffered (only the portion we actually wrote) - *socket.write_buffered_len.lock() = bytes_written_to_rustls; + socket.set_write_buffered_len(bytes_written_to_rustls); } else if already_buffered != data.len() { // Caller is retrying with different data - this is a protocol error // Clear the buffer state and return an SSL error (bad write retry) - *socket.write_buffered_len.lock() = 0; + socket.set_write_buffered_len(0); return Err(SslError::Ssl("bad write retry".to_string())); } // else: already_buffered == data.len(), this is a valid retry @@ -902,7 +821,7 @@ pub(super) fn ssl_write( // If we had a partial write to rustls, return partial success // instead of error to match OpenSSL partial-write semantics if bytes_written_to_rustls > 0 && bytes_written_to_rustls < data.len() { - *socket.write_buffered_len.lock() = 0; + socket.set_write_buffered_len(0); return Ok(bytes_written_to_rustls); } // Keep write_buffered_len set so we don't re-buffer on retry @@ -913,7 +832,7 @@ pub(super) fn ssl_write( if is_bio { // If we had a partial write to rustls, return partial success if bytes_written_to_rustls > 0 && bytes_written_to_rustls < data.len() { - *socket.write_buffered_len.lock() = 0; + socket.set_write_buffered_len(0); return Ok(bytes_written_to_rustls); } // Keep write_buffered_len set so we don't re-buffer on retry @@ -923,7 +842,7 @@ pub(super) fn ssl_write( let recv_result = recv_at_most_one_tls_record_for_data(socket, vm)?; with_conn_mut(socket, vm, |conn| { ssl_read_tls_records(conn, recv_result, false, vm)?; - conn.process_new_packets().map_err(SslError::from_rustls)?; + conn.process_packets().map_err(SslError::from)?; Ok(()) })?; // Continue loop @@ -931,7 +850,7 @@ pub(super) fn ssl_write( Err(e @ SslError::Timeout(_)) => { // If we had a partial write to rustls, return partial success if bytes_written_to_rustls > 0 && bytes_written_to_rustls < data.len() { - *socket.write_buffered_len.lock() = 0; + socket.set_write_buffered_len(0); return Ok(bytes_written_to_rustls); } // Preserve buffered state so retry doesn't duplicate data @@ -940,7 +859,7 @@ pub(super) fn ssl_write( } Err(e) => { // Clear buffer state on error - *socket.write_buffered_len.lock() = 0; + socket.set_write_buffered_len(0); return Err(e); } } @@ -965,7 +884,7 @@ pub(super) fn ssl_write( }; // Write completed successfully - clear buffer state - *socket.write_buffered_len.lock() = 0; + socket.set_write_buffered_len(0); Ok(actual_written) } @@ -973,18 +892,13 @@ pub(super) fn ssl_write( // Helper functions (private-ish, used by public SSL functions) /// Write TLS records from rustls to socket -fn ssl_write_tls_records(conn: &mut Connection) -> SslResult> { - let mut buf = Vec::new(); - let n = conn - .write_tls(&mut buf as &mut dyn std::io::Write) - .map_err(SslError::Io)?; - - if n > 0 { Ok(buf) } else { Ok(Vec::new()) } +fn ssl_write_tls_records(conn: &mut TlsConnection) -> SslResult> { + conn.drain_tls().map_err(SslError::from) } /// Read TLS records from socket to rustls pub(super) fn ssl_read_tls_records( - conn: &mut Connection, + conn: &mut TlsConnection, data: PyObjectRef, is_bio: bool, vm: &VirtualMachine, @@ -995,81 +909,11 @@ pub(super) fn ssl_read_tls_records( let bytes_data = bytes.borrow_buf(); - if bytes_data.is_empty() { - // different error for BIO vs socket mode - if is_bio { - // In BIO mode, no data means WANT_READ - return Err(SslError::WantRead); - } - // In socket mode, empty recv() means TCP EOF (FIN received) - // Need to distinguish: - // 1. Clean shutdown: received TLS close_notify → return ZeroReturn (0 bytes) - // 2. Unexpected EOF: no close_notify → return Eof (SSLEOFError) - // - // SSL_ERROR_ZERO_RETURN vs SSL_ERROR_EOF logic - // CPython checks SSL_get_shutdown() & SSL_RECEIVED_SHUTDOWN - // - // Process any buffered TLS records (may contain close_notify) - match conn.process_new_packets() { - Ok(io_state) => { - if io_state.peer_has_closed() { - // Received close_notify - normal SSL closure (SSL_ERROR_ZERO_RETURN) - return Err(SslError::ZeroReturn); - } - // No close_notify - ragged EOF (SSL_ERROR_EOF → SSLEOFError) - // CPython raises SSLEOFError here, which SSLSocket.read() handles - // based on suppress_ragged_eofs setting - return Err(SslError::Eof); - } - Err(e) => return Err(SslError::from_rustls(e)), - } + if bytes_data.is_empty() && is_bio { + return Err(SslError::WantRead); } - // Feed all received data to read_tls - loop to consume all data - // read_tls may not consume all data in one call, and buffer may become full - let mut offset = 0; - while offset < bytes_data.len() { - let remaining = &bytes_data[offset..]; - let mut cursor = std::io::Cursor::new(remaining); - - match conn.read_tls(&mut cursor) { - Ok(read_bytes) => { - if read_bytes == 0 { - // Buffer is full - process existing packets to make room - conn.process_new_packets().map_err(SslError::from_rustls)?; - - // Try again - if we still can't consume, break - let mut retry_cursor = std::io::Cursor::new(remaining); - match conn.read_tls(&mut retry_cursor) { - Ok(0) => { - // Still can't consume - break to avoid infinite loop - break; - } - Ok(n) => { - offset += n; - if offset < bytes_data.len() { - conn.process_new_packets().map_err(SslError::from_rustls)?; - } - } - Err(e) => { - return Err(SslError::Io(e)); - } - } - } else { - offset += read_bytes; - if offset < bytes_data.len() { - conn.process_new_packets().map_err(SslError::from_rustls)?; - } - } - } - Err(e) => { - // Real error - propagate it - return Err(SslError::Io(e)); - } - } - } - - Ok(()) + conn.feed_tls(bytes_data.as_ref()).map_err(SslError::from) } /// Check if an exception is a connection closed error @@ -1142,7 +986,7 @@ fn ssl_ensure_data_available(socket: &PySSLSocket, vm: &VirtualMachine) -> SslRe return Err(SslError::WantRead); } with_conn_mut(socket, vm, |conn| { - conn.process_new_packets().map_err(SslError::from_rustls)?; + conn.process_packets().map_err(SslError::from)?; Ok(()) })?; if is_connection_closed_error(&e, vm) { @@ -1188,7 +1032,7 @@ fn ssl_ensure_data_available(socket: &PySSLSocket, vm: &VirtualMachine) -> SslRe // Feed data to rustls and process packets with_conn_mut(socket, vm, |conn| { ssl_read_tls_records(conn, data, is_bio, vm)?; - conn.process_new_packets().map_err(SslError::from_rustls)?; + conn.process_packets().map_err(SslError::from)?; Ok(()) })?;