diff --git a/Cargo.lock b/Cargo.lock index 2f99dfac9..b9f9d4996 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3476,7 +3476,10 @@ dependencies = [ "parking_lot", "paste", "rustix", + "rustls", "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki", "rustpython-wtf8", "rustyline", "schannel", @@ -3486,6 +3489,7 @@ dependencies = [ "which", "widestring", "windows-sys 0.61.2", + "x509-parser", ] [[package]] @@ -3626,7 +3630,6 @@ dependencies = [ "indexmap", "insta", "itertools 0.15.0", - "jiff", "libc", "libsqlite3-sys", "malachite-bigint", diff --git a/Lib/ssl.py b/Lib/ssl.py index a78d2a696..8889aff92 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -268,9 +268,6 @@ import warnings socket_error = OSError # keep that public name in module namespace CHANNEL_BINDING_TYPES = ['tls-unique'] -# TODO: RUSTPYTHON; rustls does not expose TLS Finished messages -if "rustls" in OPENSSL_VERSION: - CHANNEL_BINDING_TYPES = [] HAS_NEVER_CHECK_COMMON_NAME = hasattr(_ssl, 'HOSTFLAG_NEVER_CHECK_SUBJECT') diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 356f5b3f0..dd4c657d0 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -3059,7 +3059,6 @@ class ThreadedTests(unittest.TestCase): 'Cannot create a client socket with a PROTOCOL_TLS_SERVER context', str(e.exception)) - @unittest.skip("TODO: RUSTPYTHON; flaky") @unittest.skipUnless(support.Py_GIL_DISABLED, "test is only useful if the GIL is disabled") def test_ssl_in_multiple_threads(self): # See GH-124984: OpenSSL is not thread safe. @@ -5221,7 +5220,6 @@ class TestSSLDebug(unittest.TestCase): with self.assertRaises(TypeError): client_context._msg_callback = object() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ('read', , <_TLSContentType.HANDSHAKE: 22>, <_TLSMessageType.SERVER_KEY_EXCHANGE: 12>) not found in [] def test_msg_callback_tls12(self): client_context, server_context, hostname = testing_context() client_context.maximum_version = ssl.TLSVersion.TLSv1_2 diff --git a/crates/host_env/Cargo.toml b/crates/host_env/Cargo.toml index 165e0f655..38584ed22 100644 --- a/crates/host_env/Cargo.toml +++ b/crates/host_env/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true [features] native-certs = ["dep:rustls-native-certs"] +ssl = ["dep:rustls", "dep:rustls-pemfile", "dep:rustls-webpki", "dep:x509-parser"] [dependencies] rustpython-wtf8 = { workspace = true } @@ -21,6 +22,10 @@ num-traits = { workspace = true } parking_lot = { workspace = true } paste = { workspace = true } widestring = { workspace = true } +rustls = { workspace = true, default-features = false, features = ["std", "tls12"], optional = true } +rustls-pemfile = { workspace = true, optional = true } +rustls-webpki = { workspace = true, optional = true } +x509-parser = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } @@ -89,6 +94,9 @@ windows-sys = { workspace = true, features = [ "Win32_UI_WindowsAndMessaging", ] } +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +rustls = { workspace = true, features = ["aws_lc_rs"] } + [build-dependencies] cc = "1" diff --git a/crates/host_env/src/lib.rs b/crates/host_env/src/lib.rs index c4db4c026..a2e41b490 100644 --- a/crates/host_env/src/lib.rs +++ b/crates/host_env/src/lib.rs @@ -33,6 +33,8 @@ pub mod locale; #[cfg(all(feature = "native-certs", not(target_arch = "wasm32")))] pub mod native_certs; pub mod readline; +#[cfg(feature = "ssl")] +pub mod ssl; #[cfg(windows)] pub mod windows; diff --git a/crates/host_env/src/ssl/bio.rs b/crates/host_env/src/ssl/bio.rs new file mode 100644 index 000000000..9822d0ccc --- /dev/null +++ b/crates/host_env/src/ssl/bio.rs @@ -0,0 +1,112 @@ +//! In-memory encrypted transport used by `SSLObject`. +//! +//! The unread suffix is kept as `(Vec, start)` so a read does not shift the +//! whole buffer. Compaction happens only after a substantial prefix has been +//! consumed. + +/// Write rejected because [`MemoryBio::write_eof`] has already been called. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemoryBioError; + +impl core::fmt::Display for MemoryBioError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("cannot write() after write_eof()") + } +} + +impl core::error::Error for MemoryBioError {} + +/// In-memory BIO used by `MemoryBIO` and `SSLObject`. +#[derive(Debug, Default)] +pub struct MemoryBio { + buffer: Vec, + start: usize, + eof_written: bool, +} + +impl MemoryBio { + /// Create an empty BIO. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Number of unread bytes. + #[must_use] + pub fn pending(&self) -> usize { + self.buffer.len() - self.start + } + + /// True once [`write_eof`] has been called and every unread byte has been + /// consumed. + /// + /// [`write_eof`]: MemoryBio::write_eof + #[must_use] + pub fn eof(&self) -> bool { + self.eof_written && self.pending() == 0 + } + + /// True after [`write_eof`], even if unread bytes remain. + /// + /// [`write_eof`]: MemoryBio::write_eof + #[must_use] + pub fn eof_written(&self) -> bool { + self.eof_written + } + + /// Take up to `size` unread bytes. + pub fn read(&mut self, size: usize) -> Vec { + let count = size.min(self.pending()); + let end = self.start + count; + let out = self.buffer[self.start..end].to_vec(); + self.start = end; + self.compact(); + out + } + + /// Append `data`. Fails after [`write_eof`]. + /// + /// [`write_eof`]: MemoryBio::write_eof + pub fn write(&mut self, data: &[u8]) -> Result { + if self.eof_written { + return Err(MemoryBioError); + } + self.compact(); + self.buffer.extend_from_slice(data); + Ok(data.len()) + } + + /// Mark that no further writes will arrive. + pub fn write_eof(&mut self) { + self.eof_written = true; + } + + fn compact(&mut self) { + if self.start == self.buffer.len() { + self.buffer.clear(); + self.start = 0; + } else if self.start >= 4096 && self.start * 2 >= self.buffer.len() { + self.buffer.copy_within(self.start.., 0); + self.buffer.truncate(self.buffer.len() - self.start); + self.start = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_write_and_eof() { + let mut bio = MemoryBio::new(); + assert_eq!(bio.write(b"abcd").unwrap(), 4); + assert_eq!(bio.read(2), b"ab"); + assert_eq!(bio.pending(), 2); + bio.write_eof(); + assert!(!bio.eof()); + assert_eq!(bio.read(8), b"cd"); + assert!(bio.eof()); + assert_eq!(bio.write(b"x"), Err(MemoryBioError)); + } +} diff --git a/crates/host_env/src/ssl/cert.rs b/crates/host_env/src/ssl/cert.rs new file mode 100644 index 000000000..afbaa9698 --- /dev/null +++ b/crates/host_env/src/ssl/cert.rs @@ -0,0 +1,252 @@ +//! X.509 decode helpers used by `_ssl.Certificate` and `get_ca_certs()`. + +use x509_parser::prelude::*; + +/// One RDN, as a list of `(attribute name, value)` pairs. +pub type DistinguishedName = Vec>; + +/// One subjectAltName entry after the names `_ssl` publishes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectAlternativeName { + pub kind: &'static str, + pub value: String, + pub directory_name: DistinguishedName, +} + +/// Owned projection of the X.509 fields exposed by `_test_decode_cert()` +/// and by `SSLContext.get_ca_certs()`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedCertificate { + pub issuer: DistinguishedName, + pub subject: DistinguishedName, + pub not_after: String, + pub not_before: String, + pub serial_number: String, + pub version: i32, + pub ocsp: Vec, + pub ca_issuers: Vec, + pub crl_distribution_points: Vec, + pub subject_alt_names: Vec, +} + +/// Map a distinguished-name OID to the attribute name `_ssl` reports. +#[must_use] +pub fn oid_to_attribute_name(oid_str: &str) -> &str { + match oid_str { + "2.5.4.3" => "commonName", + "2.5.4.6" => "countryName", + "2.5.4.7" => "localityName", + "2.5.4.8" => "stateOrProvinceName", + "2.5.4.10" => "organizationName", + "2.5.4.11" => "organizationalUnitName", + "1.2.840.113549.1.9.1" => "emailAddress", + _ => oid_str, + } +} + +/// Format raw IP address bytes the way `getpeercert()` prints them. +#[must_use] +pub fn format_ip_address(ip: &[u8]) -> String { + if ip.len() == 4 { + format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]) + } else if ip.len() == 16 { + let segments = [ + u16::from_be_bytes([ip[0], ip[1]]), + u16::from_be_bytes([ip[2], ip[3]]), + u16::from_be_bytes([ip[4], ip[5]]), + u16::from_be_bytes([ip[6], ip[7]]), + u16::from_be_bytes([ip[8], ip[9]]), + u16::from_be_bytes([ip[10], ip[11]]), + u16::from_be_bytes([ip[12], ip[13]]), + u16::from_be_bytes([ip[14], ip[15]]), + ]; + format!( + "{:X}:{:X}:{:X}:{:X}:{:X}:{:X}:{:X}:{:X}", + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7] + ) + } else { + format!("{ip:?}") + } +} + +fn decode_name(name: &x509_parser::x509::X509Name<'_>) -> DistinguishedName { + name.iter() + .map(|rdn| { + rdn.iter() + .map(|attribute| { + let oid = attribute.attr_type().to_id_string(); + let value = attribute.attr_value().as_str().map_or_else( + |_| match core::str::from_utf8(attribute.attr_value().data) { + Ok(s) => s.to_string(), + Err(_) => { + String::from_utf8_lossy(attribute.attr_value().data).into_owned() + } + }, + str::to_string, + ); + (oid_to_attribute_name(&oid).to_string(), value) + }) + .collect() + }) + .collect() +} + +fn format_certificate_time(value: &x509_parser::time::ASN1Time) -> String { + let date = value.to_datetime(); + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + format!( + "{} {:>2} {:02}:{:02}:{:02} {:04} GMT", + MONTHS[u8::from(date.month()) as usize - 1], + date.day(), + date.hour(), + date.minute(), + date.second(), + date.year(), + ) +} + +/// Decode a DER certificate into the fields `_ssl` publishes. +pub fn decode_certificate(der: &[u8]) -> Result { + use x509_parser::extensions::{DistributionPointName, GeneralName, ParsedExtension}; + use x509_parser::oid_registry::{ + OID_PKIX_AUTHORITY_INFO_ACCESS, OID_X509_EXT_CRL_DISTRIBUTION_POINTS, + }; + + let (_, cert) = x509_parser::parse_x509_certificate(der) + .map_err(|error| format!("Failed to parse certificate: {error}"))?; + + let mut serial = cert.serial.to_str_radix(16).to_uppercase(); + if serial.len() % 2 == 1 { + serial.insert(0, '0'); + } + + let mut decoded = DecodedCertificate { + issuer: decode_name(cert.issuer()), + subject: decode_name(cert.subject()), + not_after: format_certificate_time(&cert.validity().not_after), + not_before: format_certificate_time(&cert.validity().not_before), + serial_number: serial, + version: cert.version().0 as i32 + 1, + ocsp: Vec::new(), + ca_issuers: Vec::new(), + crl_distribution_points: Vec::new(), + subject_alt_names: Vec::new(), + }; + + if let Ok(extensions) = cert.tbs_certificate.extensions_map() { + if let Some(extension) = extensions.get(&OID_PKIX_AUTHORITY_INFO_ACCESS) + && let ParsedExtension::AuthorityInfoAccess(access) = extension.parsed_extension() + { + for description in &access.accessdescs { + if let GeneralName::URI(uri) = &description.access_location { + match description.access_method.to_id_string().as_str() { + "1.3.6.1.5.5.7.48.1" => decoded.ocsp.push((*uri).to_string()), + "1.3.6.1.5.5.7.48.2" => decoded.ca_issuers.push((*uri).to_string()), + _ => {} + } + } + } + } + if let Some(extension) = extensions.get(&OID_X509_EXT_CRL_DISTRIBUTION_POINTS) + && let ParsedExtension::CRLDistributionPoints(points) = extension.parsed_extension() + { + for point in &points.points { + if let Some(DistributionPointName::FullName(names)) = &point.distribution_point { + for name in names { + if let GeneralName::URI(uri) = name { + decoded.crl_distribution_points.push((*uri).to_string()); + } + } + } + } + } + } + + if let Ok(Some(extension)) = cert.subject_alternative_name() { + for name in &extension.value.general_names { + let entry = match name { + GeneralName::DNSName(value) => SubjectAlternativeName { + kind: "DNS", + value: (*value).to_string(), + directory_name: Vec::new(), + }, + GeneralName::IPAddress(value) => SubjectAlternativeName { + kind: "IP Address", + value: format_ip_address(value), + directory_name: Vec::new(), + }, + GeneralName::RFC822Name(value) => SubjectAlternativeName { + kind: "email", + value: (*value).to_string(), + directory_name: Vec::new(), + }, + GeneralName::URI(value) => SubjectAlternativeName { + kind: "URI", + value: (*value).to_string(), + directory_name: Vec::new(), + }, + GeneralName::OtherName(_, _) => SubjectAlternativeName { + kind: "othername", + value: "".to_string(), + directory_name: Vec::new(), + }, + GeneralName::DirectoryName(value) => SubjectAlternativeName { + kind: "DirName", + value: String::new(), + directory_name: decode_name(value), + }, + GeneralName::RegisteredID(value) => SubjectAlternativeName { + kind: "Registered ID", + value: value.to_id_string(), + directory_name: Vec::new(), + }, + _ => continue, + }; + decoded.subject_alt_names.push(entry); + } + } + + Ok(decoded) +} + +/// True when Basic Constraints mark the certificate as a CA, or when a +/// self-issued X.509v1 certificate has no extensions. +#[must_use] +pub fn is_ca_certificate(cert_der: &[u8]) -> bool { + let Ok((_, cert)) = X509Certificate::from_der(cert_der) else { + return false; + }; + + if let Ok(Some(ext)) = cert.basic_constraints() { + return ext.value.ca; + } + + cert.version().0 == 0 && cert.subject() == cert.issuer() +} + +/// Parse PEM or a single DER certificate into DER bodies. +pub fn read_certificates(data: &[u8]) -> Result>, String> { + if data + .windows(b"-----BEGIN ".len()) + .any(|w| w == b"-----BEGIN ") + { + let mut input = data; + rustls_pemfile::certs(&mut input) + .map(|cert| cert.map(|der| der.as_ref().to_vec())) + .collect::, _>>() + .map_err(|error| format!("Failed to parse PEM certificate: {error}")) + } else { + x509_parser::parse_x509_certificate(data) + .map_err(|error| format!("Failed to parse certificate: {error}"))?; + Ok(vec![data.to_vec()]) + } +} diff --git a/crates/stdlib/src/ssl/chain.rs b/crates/host_env/src/ssl/chain.rs similarity index 95% rename from crates/stdlib/src/ssl/chain.rs rename to crates/host_env/src/ssl/chain.rs index a99c7b816..6aaf37d00 100644 --- a/crates/stdlib/src/ssl/chain.rs +++ b/crates/host_env/src/ssl/chain.rs @@ -9,10 +9,10 @@ use rustls::{ pki_types::{CertificateDer, CertificateRevocationListDer, UnixTime}, }; -use super::_ssl::VERIFY_X509_PARTIAL_CHAIN; +use super::constants::VERIFY_X509_PARTIAL_CHAIN; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum Purpose { +pub enum Purpose { Unverified, ServerAuth, ClientAuth, @@ -22,7 +22,7 @@ pub(super) enum Purpose { /// connection. Reading a mutable SSLContext after the handshake can select a /// different root, and issuer names alone do not establish a verified path. #[derive(Debug)] -pub(super) struct VerifiedChainBuilder { +pub struct VerifiedChainBuilder { pub purpose: Purpose, pub roots: RootCertStore, pub root_der: Vec>, @@ -33,15 +33,12 @@ pub(super) struct VerifiedChainBuilder { pub verify_flags: i32, } -pub(super) type ServerConfig = (Arc, Arc); -pub(super) type ClientConfig = (Arc, Arc); +pub type ServerConfig = (Arc, Arc); +pub type ClientConfig = (Arc, Arc); impl VerifiedChainBuilder { - pub(super) fn build( - &self, - peer_chain: &[CertificateDer<'_>], - now: UnixTime, - ) -> Option>> { + #[must_use] + pub fn build(&self, peer_chain: &[CertificateDer<'_>], now: UnixTime) -> Option>> { self.build_path(peer_chain, now).or_else(|| { // OpenSSL still attempts to build a chain under CERT_NONE; a // verification failure does not prevent returning the peer chain. diff --git a/crates/stdlib/src/ssl/cipher.rs b/crates/host_env/src/ssl/cipher.rs similarity index 98% rename from crates/stdlib/src/ssl/cipher.rs rename to crates/host_env/src/ssl/cipher.rs index ec5d639bb..34c50f15d 100644 --- a/crates/stdlib/src/ssl/cipher.rs +++ b/crates/host_env/src/ssl/cipher.rs @@ -15,14 +15,14 @@ use core::str::FromStr; use rustls::{CipherSuite, SupportedCipherSuite, crypto::SupportedKxGroup}; -use rustpython_common::lock::LazyLock; +use std::sync::LazyLock; use super::providers::CryptoExt; // See `man SSL_CTX_set_security_level` for details. const SECURITY_LEVEL_TO_MIN_BITS: &[u16] = &[0, 80, 112, 128, 192, 256]; -pub(super) struct CipherList<'a> { +pub struct CipherList<'a> { ops: Vec>, } @@ -148,7 +148,7 @@ impl SuiteBType { } impl<'a> CipherList<'a> { - pub(super) fn parse_to_rustls( + pub fn parse_to_rustls( s: &'a str, ) -> Result>, &'static str> { Self::parse(s)?.to_rustls() @@ -421,7 +421,7 @@ impl<'a> CipherFilterSubOpList<'a> { } } -pub(super) fn kx_group_by_name( +pub fn kx_group_by_name( name: rustls::NamedGroup, error_name: &'static str, ) -> Result<&'static dyn SupportedKxGroup, &'static str> { @@ -433,7 +433,8 @@ pub(super) fn kx_group_by_name( .ok_or(error_name) } -pub(super) fn kx_group_by_openssl_name(name: &str) -> Option<&'static dyn SupportedKxGroup> { +#[must_use] +pub fn kx_group_by_openssl_name(name: &str) -> Option<&'static dyn SupportedKxGroup> { CryptoExt::get_ext() .all_kx_or_default() .iter() @@ -531,7 +532,7 @@ impl<'a> CipherFilterSubOp<'a> { /// What `SSLContext.get_ciphers()` reports about one suite, laid out the way /// `SSL_CIPHER_description` lays it out, padding included. -pub(super) struct CipherDescription { +pub struct CipherDescription { pub id: u32, pub name: &'static str, pub protocol: &'static str, @@ -539,7 +540,7 @@ pub(super) struct CipherDescription { pub description: String, } -pub(super) fn describe(suite: &SupportedCipherSuite) -> CipherDescription { +pub fn describe(suite: &SupportedCipherSuite) -> CipherDescription { let entry = CIPHER_MAPPINGS.entry(suite.suite().into()); let tls13 = suite.tls13().is_some(); let protocol = if tls13 { "TLSv1.3" } else { "TLSv1.2" }; @@ -802,11 +803,12 @@ impl CipherMappings { } } -pub(super) fn default_cipher_string() -> String { +pub fn default_cipher_string() -> String { CIPHER_MAPPINGS.default_cipher_string() } -pub(super) fn restore_default_tls13( +#[must_use] +pub fn restore_default_tls13( mut selected: Vec, defaults: &[SupportedCipherSuite], ) -> Vec { diff --git a/crates/host_env/src/ssl/constants.rs b/crates/host_env/src/ssl/constants.rs new file mode 100644 index 000000000..768f021aa --- /dev/null +++ b/crates/host_env/src/ssl/constants.rs @@ -0,0 +1,21 @@ +//! Protocol, verification, and option values published by `_ssl`. + +pub const PROTOCOL_TLS: i32 = 2; +pub const PROTOCOL_TLS_CLIENT: i32 = 16; +pub const PROTOCOL_TLS_SERVER: i32 = 17; +pub const PROTOCOL_TLSV1: i32 = 3; +pub const PROTOCOL_TLSV1_1: i32 = 4; +pub const PROTOCOL_TLSV1_2: i32 = 5; +pub const PROTOCOL_TLSV1_3: i32 = 6; + +pub const CERT_NONE: i32 = 0; +pub const CERT_OPTIONAL: i32 = 1; +pub const CERT_REQUIRED: i32 = 2; + +pub const VERIFY_DEFAULT: i32 = 0; +pub const VERIFY_CRL_CHECK_LEAF: i32 = 4; +pub const VERIFY_CRL_CHECK_CHAIN: i32 = 12; +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; diff --git a/crates/stdlib/rustls-data/obj_mac.num b/crates/host_env/src/ssl/data/obj_mac.num similarity index 100% rename from crates/stdlib/rustls-data/obj_mac.num rename to crates/host_env/src/ssl/data/obj_mac.num diff --git a/crates/stdlib/rustls-data/objects.txt b/crates/host_env/src/ssl/data/objects.txt similarity index 100% rename from crates/stdlib/rustls-data/objects.txt rename to crates/host_env/src/ssl/data/objects.txt diff --git a/crates/host_env/src/ssl/mod.rs b/crates/host_env/src/ssl/mod.rs new file mode 100644 index 000000000..c933b8242 --- /dev/null +++ b/crates/host_env/src/ssl/mod.rs @@ -0,0 +1,20 @@ +//! Host rustls `_ssl` engine. +//! +//! TLS helpers live here because they share the host trust store, default +//! verify paths, and certificate files with the rest of `host_env`. The +//! engine reports plain Rust results so interpreter layers can provide their +//! own object and exception adapters. + +pub mod bio; +pub mod cert; +pub mod chain; +pub mod cipher; +pub mod constants; +pub mod msg; +pub mod oid; +pub mod providers; + +pub use bio::{MemoryBio, MemoryBioError}; +pub use cert::{DecodedCertificate, decode_certificate, is_ca_certificate}; +pub use constants::*; +pub use providers::CryptoExt; diff --git a/crates/host_env/src/ssl/msg.rs b/crates/host_env/src/ssl/msg.rs new file mode 100644 index 000000000..bb0d6369b --- /dev/null +++ b/crates/host_env/src/ssl/msg.rs @@ -0,0 +1,204 @@ +//! TLS record observation for `_msg_callback` and tls-unique. + +use rustls::SupportedCipherSuite; + +pub const SSL3_RT_CHANGE_CIPHER_SPEC: i32 = 20; +pub const SSL3_RT_ALERT: i32 = 21; +pub const SSL3_RT_HANDSHAKE: i32 = 22; +pub const SSL3_RT_APPLICATION_DATA: u8 = 23; +pub const SSL3_RT_HEADER: i32 = 256; +pub const SSL3_MT_CHANGE_CIPHER_SPEC: i32 = 0x0101; +const TLS_RECORD_HEADER_SIZE: usize = 5; + +/// Per-connection assembler for TLS records and handshake messages. +#[derive(Debug, Default)] +pub struct MsgState { + incoming_record: Vec, + outgoing_record: Vec, + incoming_hs: Vec, + outgoing_hs: Vec, + transcript: Vec, + saw_ccs: bool, +} + +/// One `_msg_callback` event reconstructed from a TLS record. +#[derive(Debug)] +pub struct MsgEvent { + pub version: i32, + pub content_type: i32, + pub msg_type: i32, + pub data: Vec, +} + +impl MsgState { + /// Handshake bytes observed before ChangeCipherSpec. + #[must_use] + pub fn transcript(&self) -> &[u8] { + &self.transcript + } + + /// Consume ciphertext and return the records and handshake messages it + /// completed. + pub fn observe(&mut self, write: bool, bytes: &[u8]) -> Vec { + let (record_buf, hs_buf) = if write { + (&mut self.outgoing_record, &mut self.outgoing_hs) + } else { + (&mut self.incoming_record, &mut self.incoming_hs) + }; + record_buf.extend_from_slice(bytes); + let mut events = Vec::new(); + for (content_type, version, payload) in take_records(record_buf) { + let mut header = Vec::with_capacity(TLS_RECORD_HEADER_SIZE); + header.push(content_type); + header.extend_from_slice(&version.to_be_bytes()); + header.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + events.push(MsgEvent { + version: version as i32, + content_type: SSL3_RT_HEADER, + msg_type: content_type as i32, + data: header, + }); + match content_type { + x if x == SSL3_RT_CHANGE_CIPHER_SPEC as u8 => { + events.push(MsgEvent { + version: version as i32, + content_type: SSL3_RT_CHANGE_CIPHER_SPEC, + msg_type: SSL3_MT_CHANGE_CIPHER_SPEC, + data: payload, + }); + hs_buf.clear(); + self.saw_ccs = true; + } + x if x == SSL3_RT_ALERT as u8 => { + let msg_type = payload.get(1).copied().map_or(-1, |b| b as i32); + events.push(MsgEvent { + version: version as i32, + content_type: SSL3_RT_ALERT, + msg_type, + data: payload, + }); + } + x if x == SSL3_RT_HANDSHAKE as u8 => { + if self.saw_ccs { + continue; + } + hs_buf.extend_from_slice(&payload); + for message in take_handshake_messages(hs_buf) { + self.transcript.extend_from_slice(&message); + let msg_type = message.first().copied().map_or(-1, |b| b as i32); + events.push(MsgEvent { + version: version as i32, + content_type: SSL3_RT_HANDSHAKE, + msg_type, + data: message, + }); + } + } + SSL3_RT_APPLICATION_DATA => {} + _ => events.push(MsgEvent { + version: version as i32, + content_type: content_type as i32, + msg_type: -1, + data: payload, + }), + } + } + events + } +} + +fn take_records(buf: &mut Vec) -> Vec<(u8, u16, Vec)> { + let mut records = Vec::new(); + loop { + if buf.len() < TLS_RECORD_HEADER_SIZE { + break; + } + let len = u16::from_be_bytes([buf[3], buf[4]]) as usize; + if buf.len() < TLS_RECORD_HEADER_SIZE + len { + break; + } + let content_type = buf[0]; + let version = u16::from_be_bytes([buf[1], buf[2]]); + let payload = buf[TLS_RECORD_HEADER_SIZE..TLS_RECORD_HEADER_SIZE + len].to_vec(); + buf.drain(..TLS_RECORD_HEADER_SIZE + len); + records.push((content_type, version, payload)); + } + records +} + +fn take_handshake_messages(buf: &mut Vec) -> Vec> { + let mut messages = Vec::new(); + loop { + if buf.len() < 4 { + break; + } + let len = u32::from_be_bytes([0, buf[1], buf[2], buf[3]]) as usize; + if buf.len() < 4 + len { + break; + } + messages.push(buf[..4 + len].to_vec()); + buf.drain(..4 + len); + } + messages +} + +/// TLS 1.2 `tls-unique` binding from the master secret and handshake transcript. +#[must_use] +pub fn tls12_unique( + suite: SupportedCipherSuite, + master_secret: &[u8], + transcript: &[u8], + server_side: bool, + session_reused: bool, +) -> Option> { + let SupportedCipherSuite::Tls12(suite) = suite else { + return None; + }; + if master_secret.is_empty() || transcript.is_empty() { + return None; + } + let hash = suite.common.hash_provider.hash(transcript); + let is_client = !server_side; + let ours = session_reused ^ is_client; + let label: &[u8] = match (ours, server_side) { + (true, false) | (false, true) => b"client finished", + (true, true) | (false, false) => b"server finished", + }; + let mut out = vec![0u8; 12]; + suite + .prf_provider + .for_secret(&mut out, master_secret, label, hash.as_ref()); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_complete_records_and_handshake_messages() { + let mut state = MsgState::default(); + let mut record = vec![22, 3, 3, 0, 8]; + record.extend_from_slice(&[12, 0, 0, 4, 1, 2, 3, 4]); + let events = state.observe(false, &record); + assert_eq!(events.len(), 2); + assert_eq!(events[0].content_type, SSL3_RT_HEADER); + assert_eq!(events[1].content_type, SSL3_RT_HANDSHAKE); + assert_eq!(events[1].msg_type, 12); + assert_eq!(state.transcript(), [12, 0, 0, 4, 1, 2, 3, 4]); + } + + #[test] + fn ccs_stops_transcript() { + let mut state = MsgState::default(); + let mut hs = vec![22, 3, 3, 0, 4]; + hs.extend_from_slice(&[1, 0, 0, 0]); + state.observe(true, &hs); + state.observe(true, &[20, 3, 3, 0, 1, 1]); + let before = state.transcript().to_vec(); + let mut finished = vec![22, 3, 3, 0, 4]; + finished.extend_from_slice(&[20, 0, 0, 0]); + state.observe(true, &finished); + assert_eq!(state.transcript(), before); + } +} diff --git a/crates/stdlib/src/ssl/oid.rs b/crates/host_env/src/ssl/oid.rs similarity index 94% rename from crates/stdlib/src/ssl/oid.rs rename to crates/host_env/src/ssl/oid.rs index 847c3c412..1322d1556 100644 --- a/crates/stdlib/src/ssl/oid.rs +++ b/crates/host_env/src/ssl/oid.rs @@ -17,7 +17,7 @@ use std::collections::HashMap; /// OID entry with openssl-compatible metadata #[derive(Debug, Clone)] -pub(super) struct OidEntry { +pub struct OidEntry { /// NID (OpenSSL Numerical Identifier) - must match CPython/OpenSSL values pub nid: i32, /// Short name (e.g., "CN", "serverAuth") @@ -32,13 +32,14 @@ pub(super) struct OidEntry { impl OidEntry { /// Get OID as string (e.g., "2.5.4.3"), if this object has one - pub(super) fn oid_string(&self) -> Option<&str> { + #[must_use] + pub fn oid_string(&self) -> Option<&str> { self.oid.as_deref() } } /// OID table with multiple indices for fast lookup -pub(super) struct OidTable { +pub struct OidTable { /// All entries entries: Vec, /// NID -> index mapping @@ -79,17 +80,20 @@ impl OidTable { } } - pub(super) fn find_by_nid(&self, nid: i32) -> Option<&OidEntry> { + #[must_use] + pub fn find_by_nid(&self, nid: i32) -> Option<&OidEntry> { self.nid_to_idx.get(&nid).map(|&idx| &self.entries[idx]) } - pub(super) fn find_by_oid_string(&self, oid_str: &str) -> Option<&OidEntry> { + #[must_use] + pub fn find_by_oid_string(&self, oid_str: &str) -> Option<&OidEntry> { self.oid_str_to_idx .get(canonical_oid(oid_str)?.as_str()) .map(|&idx| &self.entries[idx]) } - pub(super) fn find_by_name(&self, name: &str) -> Option<&OidEntry> { + #[must_use] + pub fn find_by_name(&self, name: &str) -> Option<&OidEntry> { // OpenSSL object names are case-sensitive. Try the short name first, // as OBJ_txt2obj does when a short and long name collide. self.short_name_to_idx @@ -125,16 +129,15 @@ fn canonical_oid(oid: &str) -> Option { } /// Global OID table -static OID_TABLE: rustpython_common::lock::LazyLock = - rustpython_common::lock::LazyLock::new(OidTable::build); +static OID_TABLE: std::sync::LazyLock = std::sync::LazyLock::new(OidTable::build); /// OpenSSL's NID assignments: one `identifiernid` line per object. -static OBJ_MAC_NUM: &str = include_str!("../../rustls-data/obj_mac.num"); +static OBJ_MAC_NUM: &str = include_str!("data/obj_mac.num"); /// OpenSSL's object database: the numeric OID, short name and description of /// each object, with `!Alias` / `!Cname` / `!module` / `!global` directives /// between them. -static OBJECTS_TXT: &str = include_str!("../../rustls-data/objects.txt"); +static OBJECTS_TXT: &str = include_str!("data/objects.txt"); /// The name `objects.pl` derives for an object or alias: qualified by the /// enclosing `!module`, with `-` spelled `_`. @@ -269,17 +272,20 @@ fn build_oid_entries() -> Vec { // Public API Functions /// Find OID entry by NID -pub(super) fn find_by_nid(nid: i32) -> Option<&'static OidEntry> { +#[must_use] +pub fn find_by_nid(nid: i32) -> Option<&'static OidEntry> { OID_TABLE.find_by_nid(nid) } /// Find OID entry by OID string (e.g., "2.5.4.3") -pub(super) fn find_by_oid_string(oid_str: &str) -> Option<&'static OidEntry> { +#[must_use] +pub fn find_by_oid_string(oid_str: &str) -> Option<&'static OidEntry> { OID_TABLE.find_by_oid_string(oid_str) } /// Find OID entry by name (short or long name) -pub(super) fn find_by_name(name: &str) -> Option<&'static OidEntry> { +#[must_use] +pub fn find_by_name(name: &str) -> Option<&'static OidEntry> { OID_TABLE.find_by_name(name) } diff --git a/crates/stdlib/src/ssl/providers.rs b/crates/host_env/src/ssl/providers.rs similarity index 100% rename from crates/stdlib/src/ssl/providers.rs rename to crates/host_env/src/ssl/providers.rs diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index 0a424895f..72d0637e8 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -24,7 +24,7 @@ ssl-openssl-vendor = ["ssl-openssl", "openssl/vendored"] tkinter = ["dep:tk-sys", "dep:tcl-sys", "dep:widestring"] flame-it = ["flame"] -__ssl-rustls = ["ssl", "rustpython-host_env/native-certs", "rustls", "rustls-pemfile", "rustls-platform-verifier", "rustls-webpki", "x509-cert", "x509-parser", "der", "pem-rfc7468", "webpki-roots", "oid-registry", "pkcs8"] +__ssl-rustls = ["ssl", "rustpython-host_env/ssl", "rustpython-host_env/native-certs", "rustls", "rustls-pemfile", "rustls-platform-verifier", "rustls-webpki", "x509-cert", "x509-parser", "der", "pem-rfc7468", "webpki-roots", "oid-registry", "pkcs8"] [dependencies] # rustpython crates @@ -72,7 +72,6 @@ constant_time_eq = { workspace = true } adler32 = { workspace = true } # tkinter -jiff = { workspace = true } tk-sys = { workspace = true, optional = true } tcl-sys = { workspace = true, optional = true } widestring = { workspace = true, optional = true } diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 4c1606f1a..8b78dd864 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -13,17 +13,9 @@ //! //! Warning: This library contains AI-generated code and comments. Do not trust any code or comment without verification. Please have a qualified expert review the code and remove this notice after review. -// OID (Object Identifier) management module -mod oid; - // Certificate operations module (parsing, validation, conversion) mod cert; -mod chain; - -/// OpenSSL cipher string parsing for `set_ciphers` -mod cipher; - // OpenSSL compatibility layer (abstracts rustls operations) mod compat; @@ -32,9 +24,9 @@ mod error; mod handshake; mod keylog; +mod msg; -// Utilities for setting a Rustls cryptography provider. -pub mod providers; +pub use rustpython_host_env::ssl::{chain, cipher, oid, providers}; pub(crate) use _ssl::module_def; @@ -105,11 +97,11 @@ mod _ssl { // Import certificate operations module use super::cert; - use super::chain::{self, VerifiedChainBuilder}; - - // Import OID module - use super::cipher; - use super::oid; + use rustpython_host_env::ssl::{ + chain::{self, VerifiedChainBuilder}, + cipher, oid, + providers::CryptoExt, + }; // Import compat module (OpenSSL compatibility layer) use super::compat::{ @@ -119,7 +111,6 @@ mod _ssl { }; use super::handshake::TlsState; - use super::providers::CryptoExt; // Type aliases for better readability // Additional type alias for certificate/key pairs (SessionCache defined below) @@ -139,24 +130,24 @@ mod _ssl { // SSL/TLS Protocol versions #[pyattr] - const PROTOCOL_TLS: i32 = 2; // Auto-negotiate best version + const PROTOCOL_TLS: i32 = rustpython_host_env::ssl::PROTOCOL_TLS; // Auto-negotiate best version #[pyattr] const PROTOCOL_SSLv23: i32 = PROTOCOL_TLS; // Alias for PROTOCOL_TLS #[pyattr] - const PROTOCOL_TLS_CLIENT: i32 = 16; + const PROTOCOL_TLS_CLIENT: i32 = rustpython_host_env::ssl::PROTOCOL_TLS_CLIENT; #[pyattr] - const PROTOCOL_TLS_SERVER: i32 = 17; + const PROTOCOL_TLS_SERVER: i32 = rustpython_host_env::ssl::PROTOCOL_TLS_SERVER; // Note: rustls doesn't support TLS 1.0/1.1 for security reasons // These are defined for API compatibility but will raise errors if used #[pyattr] - const PROTOCOL_TLSv1: i32 = 3; + const PROTOCOL_TLSv1: i32 = rustpython_host_env::ssl::PROTOCOL_TLSV1; #[pyattr] - const PROTOCOL_TLSv1_1: i32 = 4; + const PROTOCOL_TLSv1_1: i32 = rustpython_host_env::ssl::PROTOCOL_TLSV1_1; #[pyattr] - const PROTOCOL_TLSv1_2: i32 = 5; + const PROTOCOL_TLSv1_2: i32 = rustpython_host_env::ssl::PROTOCOL_TLSV1_2; #[pyattr] - const PROTOCOL_TLSv1_3: i32 = 6; + const PROTOCOL_TLSv1_3: i32 = rustpython_host_env::ssl::PROTOCOL_TLSV1_3; static NEXT_SSL_SESSION_NONCE: AtomicUsize = AtomicUsize::new(1); @@ -213,32 +204,33 @@ mod _ssl { // Certificate verification modes #[pyattr] - const CERT_NONE: i32 = 0; + const CERT_NONE: i32 = rustpython_host_env::ssl::CERT_NONE; #[pyattr] - const CERT_OPTIONAL: i32 = 1; + const CERT_OPTIONAL: i32 = rustpython_host_env::ssl::CERT_OPTIONAL; #[pyattr] - const CERT_REQUIRED: i32 = 2; + const CERT_REQUIRED: i32 = rustpython_host_env::ssl::CERT_REQUIRED; // SSL Verification Flags / Certificate requirements #[pyattr] - const VERIFY_DEFAULT: i32 = 0; + const VERIFY_DEFAULT: i32 = rustpython_host_env::ssl::VERIFY_DEFAULT; #[pyattr] - const VERIFY_CRL_CHECK_LEAF: i32 = 4; + const VERIFY_CRL_CHECK_LEAF: i32 = rustpython_host_env::ssl::VERIFY_CRL_CHECK_LEAF; #[pyattr] - const VERIFY_CRL_CHECK_CHAIN: i32 = 12; + const VERIFY_CRL_CHECK_CHAIN: i32 = rustpython_host_env::ssl::VERIFY_CRL_CHECK_CHAIN; /// VERIFY_X509_STRICT flag for RFC 5280 strict compliance /// When set, performs additional validation including AKI extension checks #[pyattr] - pub(crate) const VERIFY_X509_STRICT: i32 = 32; + pub(crate) const VERIFY_X509_STRICT: i32 = rustpython_host_env::ssl::VERIFY_X509_STRICT; #[pyattr] - const VERIFY_ALLOW_PROXY_CERTS: i32 = 64; + const VERIFY_ALLOW_PROXY_CERTS: i32 = rustpython_host_env::ssl::VERIFY_ALLOW_PROXY_CERTS; #[pyattr] - const VERIFY_X509_TRUSTED_FIRST: i32 = 32768; + const VERIFY_X509_TRUSTED_FIRST: i32 = rustpython_host_env::ssl::VERIFY_X509_TRUSTED_FIRST; /// VERIFY_X509_PARTIAL_CHAIN flag for partial chain validation /// When set, accept certificates if any certificate in the chain is in the trust store /// (not just root CAs). This matches OpenSSL's X509_V_FLAG_PARTIAL_CHAIN behavior. #[pyattr] - pub(crate) const VERIFY_X509_PARTIAL_CHAIN: i32 = 0x80000; + pub(crate) const VERIFY_X509_PARTIAL_CHAIN: i32 = + rustpython_host_env::ssl::VERIFY_X509_PARTIAL_CHAIN; // Options (OpenSSL-compatible flags, mostly no-op in rustls) #[pyattr] @@ -1929,6 +1921,8 @@ mod _ssl { 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)), }; @@ -2017,6 +2011,8 @@ mod _ssl { 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)), }; @@ -2444,6 +2440,9 @@ mod _ssl { // 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>, // Deferred client certificate verification error (for TLS 1.3) // Stores error message if client cert verification failed during handshake // Error is raised on first I/O operation after handshake @@ -2477,6 +2476,49 @@ mod _ssl { &self.connection } + pub(crate) fn observe_tls(&self, write: bool, bytes: &[u8], vm: &VirtualMachine) { + if bytes.is_empty() { + return; + } + let events = self.msg_state.lock().observe(write, bytes); + if events.is_empty() { + return; + } + let callback = self.context.read().msg_callback.read().clone(); + let Some(callback) = callback else { + return; + }; + let conn = self + .owner + .read() + .as_ref() + .and_then(|owner| owner.upgrade()) + .unwrap_or_else(|| vm.ctx.none()); + for event in events { + let result = callback.call( + ( + conn.clone(), + vm.ctx.new_str(if write { "write" } else { "read" }), + vm.ctx.new_int(event.version), + vm.ctx.new_int(event.content_type), + vm.ctx.new_int(event.msg_type), + vm.ctx.new_bytes(event.data), + ), + vm, + ); + if let Err(exc) = result { + let mut pending = self.pending_msg_exc.lock(); + if pending.is_none() { + *pending = Some(exc); + } + } + } + } + + pub(crate) fn take_msg_exc(&self) -> Option { + self.pending_msg_exc.lock().take() + } + // Check for deferred certificate verification errors (TLS 1.3) // If an error exists, raise it and clear it from storage fn check_deferred_cert_error(&self, vm: &VirtualMachine) -> PyResult<()> { @@ -2748,7 +2790,13 @@ mod _ssl { ) } - fn reject_connection(&self, error: PyBaseExceptionRef, bytes: Vec) { + fn reject_connection( + &self, + error: PyBaseExceptionRef, + bytes: Vec, + vm: &VirtualMachine, + ) { + self.observe_tls(true, &bytes, vm); self.pending_tls_output.lock().extend_from_slice(&bytes); *self.state.lock() = TlsState::SendingAlert { error }; } @@ -2771,6 +2819,7 @@ mod _ssl { self.reject_connection( error, super::handshake::sni_alert(description), + vm, ); continue; } @@ -2785,7 +2834,7 @@ mod _ssl { } else { SslError::from_rustls(error) }; - self.reject_connection(error.into_py_err(vm), bytes); + self.reject_connection(error.into_py_err(vm), bytes, vm); continue; } Ok(None) => {} @@ -2816,6 +2865,7 @@ mod _ssl { }, ); } + self.observe_tls(false, bytes.as_bytes(), vm); super::handshake::feed_acceptor(&mut acceptor, bytes.as_bytes()) .map_err(|e| e.into_pyexception(vm))?; Ok(()) @@ -3258,7 +3308,7 @@ mod _ssl { alert .write_all(&mut bytes) .map_err(|e| e.into_pyexception(vm))?; - self.reject_connection(SslError::from_rustls(error).into_py_err(vm), bytes); + self.reject_connection(SslError::from_rustls(error).into_py_err(vm), bytes, vm); self.accept_client_hello(vm)?; } } @@ -3289,7 +3339,7 @@ mod _ssl { if matches!(*self.state.lock(), TlsState::SendingAlert { .. }) { return Err(error); } - self.reject_connection(error, super::handshake::sni_alert(40)); + self.reject_connection(error, super::handshake::sni_alert(40), vm); return self.accept_client_hello(vm).map(|_| ()); } } else { @@ -3411,10 +3461,13 @@ mod _ssl { if let Some(conn) = self.connection.lock().as_mut() { let _ = conn.write_tls(&mut bytes); } - self.reject_connection(error.into_py_err(vm), bytes); + self.reject_connection(error.into_py_err(vm), bytes, vm); return self.accept_client_hello(vm).map(|_| ()); } handshake_result.map_err(|e| e.into_py_err(vm))?; + if let Some(exc) = self.take_msg_exc() { + return Err(exc); + } self.complete_handshake(vm); Ok(()) } @@ -3510,6 +3563,9 @@ mod _ssl { // Check for deferred certificate verification errors (TLS 1.3) // Must be checked AFTER ssl_read, as the error is set during I/O self.check_deferred_cert_error(vm)?; + if let Some(exc) = self.take_msg_exc() { + return Err(exc); + } buf.truncate(n); return_data(buf, &buffer, vm) } @@ -3650,6 +3706,9 @@ mod _ssl { match result { Ok(n) => { self.check_deferred_cert_error(vm)?; + if let Some(exc) = self.take_msg_exc() { + return Err(exc); + } Ok(n) } Err(crate::ssl::compat::SslError::WantRead) => { @@ -4038,7 +4097,7 @@ mod _ssl { if let Some(conn) = self.connection.lock().as_mut() { let _ = conn.write_tls(&mut bytes); } - self.reject_connection(error.clone(), bytes); + self.reject_connection(error.clone(), bytes, vm); return self .accept_client_hello(vm) .map(|_| self.io.socket_object(vm)); @@ -4150,25 +4209,35 @@ mod _ssl { vm: &VirtualMachine, ) -> PyResult> { let cb_type_str = cb_type.as_ref().map_or("tls-unique", |s| s.as_str()); - - // rustls doesn't support channel binding (tls-unique, tls-server-end-point, etc.) - // This is because: - // 1. tls-unique requires access to TLS Finished messages, which rustls doesn't expose - // 2. tls-server-end-point requires the server certificate, which we don't track here - // 3. TLS 1.3 deprecated tls-unique anyway - // - // For compatibility, we'll return None (no channel binding available) - // rather than raising an error - if cb_type_str != "tls-unique" { - return Err(vm.new_value_error(format!( - "Unsupported channel binding type '{cb_type_str}'", - ))); + return Err(super::msg::unknown_binding_type_error(cb_type_str, vm)); } - - // Return None to indicate channel binding is not available - // This matches the behavior when the handshake hasn't completed yet - Ok(None) + if !self.handshake_completed() { + return Ok(None); + } + let master_secret = self.key_log.master_secret(); + let transcript = self.msg_state.lock().transcript().to_vec(); + let session_reused = *self.session_was_reused.lock(); + let suite = { + let conn_guard = self.connection.lock(); + conn_guard + .as_ref() + .and_then(|conn| conn.negotiated_cipher_suite()) + }; + let Some(master_secret) = master_secret else { + return Ok(None); + }; + let Some(suite) = suite else { + return Ok(None); + }; + Ok(super::msg::tls12_unique( + suite, + &master_secret, + &transcript, + self.server_side, + session_reused, + ) + .map(|bytes| vm.ctx.new_bytes(bytes))) } } @@ -4212,35 +4281,24 @@ mod _ssl { #[pyclass(name = "MemoryBIO", module = "ssl")] #[derive(Debug, PyPayload)] struct PyMemoryBIO { - // Internal buffer - buffer: PyMutex>, - // EOF flag - eof: PyRwLock, + inner: PyMutex, } #[pyclass(with(Constructor), flags(BASETYPE))] impl PyMemoryBIO { #[pymethod] fn read(&self, len: OptionalArg, vm: &VirtualMachine) -> PyResult { - let mut buffer = self.buffer.lock(); - - if buffer.is_empty() && *self.eof.read() { - // Return empty bytes at EOF - return Ok(vm.ctx.new_bytes(vec![])); - } + let mut bio = self.inner.lock(); let read_len = match len { OptionalArg::Present(n) if n >= 0 => n as usize, OptionalArg::Present(n) => { return Err(vm.new_value_error(format!("negative read length: {n}"))); } - OptionalArg::Missing => buffer.len(), // Read all available + OptionalArg::Missing => bio.pending(), }; - let actual_len = read_len.min(buffer.len()); - let data = buffer.drain(..actual_len).collect::>(); - - Ok(vm.ctx.new_bytes(data)) + Ok(vm.ctx.new_bytes(bio.read(read_len))) } #[pymethod] @@ -4257,29 +4315,29 @@ mod _ssl { // Convert to bytes-like object let bytes_like = ArgBytesLike::try_from_object(vm, buf)?; let data = bytes_like.borrow_buf(); - let len = data.len(); - - let mut buffer = self.buffer.lock(); - buffer.extend_from_slice(&data); - - Ok(len) + self.inner.lock().write(&data).map_err(|err| { + vm.new_os_subtype_error( + PySSLError::class(&vm.ctx).to_owned(), + None, + err.to_string(), + ) + .upcast() + }) } #[pymethod] fn write_eof(&self, _vm: &VirtualMachine) { - *self.eof.write() = true; + self.inner.lock().write_eof(); } #[pygetset] fn pending(&self) -> i32 { - self.buffer.lock().len() as i32 + self.inner.lock().pending() as i32 } #[pygetset] fn eof(&self) -> bool { - // EOF is true only when buffer is empty AND write_eof has been called - let pending = self.buffer.lock().len(); - pending == 0 && *self.eof.read() + self.inner.lock().eof() } } @@ -4295,8 +4353,7 @@ mod _ssl { fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { Ok(Self { - buffer: PyMutex::new(Vec::new()), - eof: PyRwLock::new(false), + inner: PyMutex::new(rustpython_host_env::ssl::MemoryBio::new()), }) } } diff --git a/crates/stdlib/src/ssl/cert.rs b/crates/stdlib/src/ssl/cert.rs index d10a652c6..b36723171 100644 --- a/crates/stdlib/src/ssl/cert.rs +++ b/crates/stdlib/src/ssl/cert.rs @@ -10,7 +10,6 @@ //! - Loading certificates from files, directories, and bytes use alloc::sync::Arc; -use jiff::{Timestamp, Zoned, tz::TimeZone}; use parking_lot::RwLock as ParkingRwLock; use rustls::{ DigitallySignedStruct, RootCertStore, SignatureScheme, @@ -22,8 +21,9 @@ use rustpython_vm::{PyObjectRef, PyResult, VirtualMachine}; use std::collections::HashSet; use x509_parser::prelude::*; -use super::{ - _ssl::{VERIFY_X509_PARTIAL_CHAIN, VERIFY_X509_STRICT}, +use rustpython_host_env::ssl::{ + cert as ssl_cert, + constants::{VERIFY_X509_PARTIAL_CHAIN, VERIFY_X509_STRICT}, providers::CryptoExt, }; @@ -138,87 +138,7 @@ mod cert_error { } } -// Helper Functions for Certificate Parsing - -/// Map X.509 OID to human-readable attribute name -/// -/// Converts common X.509 Distinguished Name OIDs to their standard names. -/// Returns the OID string itself if not recognized. -fn oid_to_attribute_name(oid_str: &str) -> &str { - match oid_str { - "2.5.4.3" => "commonName", - "2.5.4.6" => "countryName", - "2.5.4.7" => "localityName", - "2.5.4.8" => "stateOrProvinceName", - "2.5.4.10" => "organizationName", - "2.5.4.11" => "organizationalUnitName", - "1.2.840.113549.1.9.1" => "emailAddress", - _ => oid_str, - } -} - -/// Format IP address (IPv4 or IPv6) to string -/// -/// Formats raw IP address bytes according to standard notation: -/// - IPv4: dotted decimal (e.g., "192.0.2.1") -/// - IPv6: colon-separated hex (e.g., "2001:DB8:0:0:0:0:0:1") -fn format_ip_address(ip: &[u8]) -> String { - if ip.len() == 4 { - // IPv4 - format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]) - } else if ip.len() == 16 { - // IPv6 - format in full form without compression (uppercase) - // CPython returns IPv6 in full form: 2001:DB8:0:0:0:0:0:1 (not 2001:db8::1) - let segments = [ - u16::from_be_bytes([ip[0], ip[1]]), - u16::from_be_bytes([ip[2], ip[3]]), - u16::from_be_bytes([ip[4], ip[5]]), - u16::from_be_bytes([ip[6], ip[7]]), - u16::from_be_bytes([ip[8], ip[9]]), - u16::from_be_bytes([ip[10], ip[11]]), - u16::from_be_bytes([ip[12], ip[13]]), - u16::from_be_bytes([ip[14], ip[15]]), - ]; - format!( - "{:X}:{:X}:{:X}:{:X}:{:X}:{:X}:{:X}:{:X}", - segments[0], - segments[1], - segments[2], - segments[3], - segments[4], - segments[5], - segments[6], - segments[7] - ) - } else { - // Unknown format - return as debug string - format!("{ip:?}") - } -} - -/// Format ASN.1 time to string -/// -/// Formats certificate validity dates in the format: -/// "Mon DD HH:MM:SS YYYY GMT" -fn format_asn1_time(time: &x509_parser::time::ASN1Time) -> String { - let timestamp = - Timestamp::from_second(time.timestamp()).expect("ASN1Time must be valid timestamp"); - Zoned::new(timestamp, TimeZone::UTC) - .strftime("%b %e %H:%M:%S %Y GMT") - .to_string() -} - -/// Format certificate serial number to hexadecimal string with even padding -/// -/// Converts a BigUint serial number to uppercase hex string, ensuring -/// even length by prepending '0' if necessary. -fn format_serial_number(serial: &num_bigint::BigUint) -> String { - let mut serial_str = serial.to_str_radix(16).to_uppercase(); - if serial_str.len() % 2 == 1 { - serial_str.insert(0, '0'); - } - serial_str -} +pub(super) use rustpython_host_env::ssl::cert::is_ca_certificate; /// Normalize wildcard hostname by stripping "*." prefix /// @@ -228,33 +148,6 @@ fn normalize_wildcard_hostname(hostname: &str) -> &str { hostname.strip_prefix("*.").unwrap_or(hostname) } -// Certificate Validation and Parsing - -/// Check if a certificate is a CA certificate by examining the Basic Constraints extension -/// -/// Returns `true` if the certificate has Basic Constraints with CA=true, -/// `false` otherwise (including parse errors or missing extension). -/// This matches OpenSSL's X509_check_ca() behavior. -pub(super) fn is_ca_certificate(cert_der: &[u8]) -> bool { - // Parse the certificate - let Ok((_, cert)) = X509Certificate::from_der(cert_der) else { - return false; - }; - - // Check Basic Constraints extension - // If extension exists and CA=true, it's a CA certificate - // Otherwise (no extension or CA=false), it's NOT a CA certificate - if let Ok(Some(ext)) = cert.basic_constraints() { - return ext.value.ca; - } - - // X509_check_ca() also retains OpenSSL's legacy trust-anchor rule: a - // self-issued X.509v1 certificate has no extensions at all, but is still - // classified as a CA. CPython's test CA at capath/4e1295a3.0 exercises - // precisely this case. - cert.version().0 == 0 && cert.subject() == cert.issuer() -} - /// Convert DER-encoded certificate to Python dict. /// /// Includes OCSP, caIssuers, and crlDistributionPoints when present, and uses @@ -263,173 +156,70 @@ pub(super) fn cert_der_to_dict_helper( vm: &VirtualMachine, cert_der: &[u8], ) -> PyResult { - // Parse the certificate using x509-parser - let (_, cert) = x509_parser::parse_x509_certificate(cert_der) - .map_err(|e| vm.new_value_error(format!("Failed to parse certificate: {e}")))?; + let decoded = ssl_cert::decode_certificate(cert_der).map_err(|e| vm.new_value_error(e))?; - // Helper to convert X509Name to nested tuple format - let name_to_tuple = |name: &x509_parser::x509::X509Name<'_>| -> PyResult { + let name_to_tuple = |name: &ssl_cert::DistinguishedName| -> PyObjectRef { let mut entries = Vec::new(); - for rdn in name.iter() { - for attr in rdn.iter() { - let oid_str = attr.attr_type().to_id_string(); - - // Get value as bytes and convert to string - let value_str = if let Ok(s) = attr.attr_value().as_str() { - s.to_string() - } else { - let value_bytes = attr.attr_value().data; - match core::str::from_utf8(value_bytes) { - Ok(s) => s.to_string(), - Err(_) => String::from_utf8_lossy(value_bytes).into_owned(), - } - }; - - let key = oid_to_attribute_name(&oid_str); - + for rdn in name { + for (key, value) in rdn { let entry = - vm.new_tuple((vm.ctx.new_str(key.to_string()), vm.ctx.new_str(value_str))); + vm.new_tuple((vm.ctx.new_str(key.as_str()), vm.ctx.new_str(value.as_str()))); entries.push(vm.new_tuple((entry,)).into()); } } - Ok(vm.ctx.new_tuple(entries).into()) + vm.ctx.new_tuple(entries).into() }; let dict = vm.ctx.new_dict(); - - // CPython ordering: issuer, notAfter, notBefore, serialNumber, subject, version - dict.set_item("issuer", name_to_tuple(cert.issuer())?, vm)?; - - // Validity - format with GMT using jiff + dict.set_item("issuer", name_to_tuple(&decoded.issuer), vm)?; + dict.set_item("notAfter", vm.ctx.new_str(decoded.not_after).into(), vm)?; + dict.set_item("notBefore", vm.ctx.new_str(decoded.not_before).into(), vm)?; dict.set_item( - "notAfter", - vm.ctx - .new_str(format_asn1_time(&cert.validity().not_after)) - .into(), - vm, - )?; - dict.set_item( - "notBefore", - vm.ctx - .new_str(format_asn1_time(&cert.validity().not_before)) - .into(), + "serialNumber", + vm.ctx.new_str(decoded.serial_number).into(), vm, )?; + dict.set_item("subject", name_to_tuple(&decoded.subject), vm)?; + dict.set_item("version", vm.ctx.new_int(decoded.version).into(), vm)?; - // Serial number - hex format with even length - let serial = format_serial_number(&cert.serial); - dict.set_item("serialNumber", vm.ctx.new_str(serial).into(), vm)?; - - dict.set_item("subject", name_to_tuple(cert.subject())?, vm)?; - - // Version - dict.set_item( - "version", - vm.ctx.new_int(cert.version().0 as i32 + 1).into(), - vm, - )?; - - // Authority Information Access (OCSP and caIssuers) - use x509-parser's extensions_map - let mut ocsp_urls = Vec::new(); - let mut ca_issuer_urls = Vec::new(); - let mut crl_urls = Vec::new(); - - if let Ok(ext_map) = cert.tbs_certificate.extensions_map() { - use x509_parser::extensions::{GeneralName, ParsedExtension}; - use x509_parser::oid_registry::{ - OID_PKIX_AUTHORITY_INFO_ACCESS, OID_X509_EXT_CRL_DISTRIBUTION_POINTS, - }; - - // Authority Information Access - if let Some(ext) = ext_map.get(&OID_PKIX_AUTHORITY_INFO_ACCESS) - && let ParsedExtension::AuthorityInfoAccess(aia) = &ext.parsed_extension() - { - for desc in &aia.accessdescs { - if let GeneralName::URI(uri) = &desc.access_location { - let method_str = desc.access_method.to_id_string(); - if method_str == "1.3.6.1.5.5.7.48.1" { - // OCSP - ocsp_urls.push(vm.ctx.new_str(uri.to_string()).into()); - } else if method_str == "1.3.6.1.5.5.7.48.2" { - // caIssuers - ca_issuer_urls.push(vm.ctx.new_str(uri.to_string()).into()); - } - } - } - } - - // CRL Distribution Points - if let Some(ext) = ext_map.get(&OID_X509_EXT_CRL_DISTRIBUTION_POINTS) - && let ParsedExtension::CRLDistributionPoints(cdp) = &ext.parsed_extension() - { - for dp in &cdp.points { - if let Some(dist_point) = &dp.distribution_point { - use x509_parser::extensions::DistributionPointName; - if let DistributionPointName::FullName(names) = dist_point { - for name in names { - if let GeneralName::URI(uri) = name { - crl_urls.push(vm.ctx.new_str(uri.to_string()).into()); - } - } - } - } - } - } + if !decoded.ocsp.is_empty() { + let urls = decoded + .ocsp + .into_iter() + .map(|url| vm.ctx.new_str(url).into()) + .collect(); + dict.set_item("OCSP", vm.ctx.new_tuple(urls).into(), vm)?; + } + if !decoded.ca_issuers.is_empty() { + let urls = decoded + .ca_issuers + .into_iter() + .map(|url| vm.ctx.new_str(url).into()) + .collect(); + dict.set_item("caIssuers", vm.ctx.new_tuple(urls).into(), vm)?; + } + if !decoded.crl_distribution_points.is_empty() { + let urls = decoded + .crl_distribution_points + .into_iter() + .map(|url| vm.ctx.new_str(url).into()) + .collect(); + dict.set_item("crlDistributionPoints", vm.ctx.new_tuple(urls).into(), vm)?; } - if !ocsp_urls.is_empty() { - dict.set_item("OCSP", vm.ctx.new_tuple(ocsp_urls).into(), vm)?; - } - if !ca_issuer_urls.is_empty() { - dict.set_item("caIssuers", vm.ctx.new_tuple(ca_issuer_urls).into(), vm)?; - } - if !crl_urls.is_empty() { - dict.set_item( - "crlDistributionPoints", - vm.ctx.new_tuple(crl_urls).into(), - vm, - )?; - } - - // Subject Alternative Names - if let Ok(Some(san_ext)) = cert.subject_alternative_name() { + if !decoded.subject_alt_names.is_empty() { let mut san_entries = Vec::new(); - for name in &san_ext.value.general_names { - use x509_parser::extensions::GeneralName; - match name { - GeneralName::DNSName(dns) => { - san_entries.push(vm.new_tuple(("DNS", *dns)).into()); - } - GeneralName::IPAddress(ip) => { - let ip_str = format_ip_address(ip); - san_entries.push(vm.new_tuple(("IP Address", ip_str)).into()); - } - GeneralName::RFC822Name(email) => { - san_entries.push(vm.new_tuple(("email", *email)).into()); - } - GeneralName::URI(uri) => { - san_entries.push(vm.new_tuple(("URI", *uri)).into()); - } - GeneralName::OtherName(_oid, _data) => { - // OtherName is not fully supported, mark as unsupported - san_entries.push(vm.new_tuple(("othername", "")).into()); - } - GeneralName::DirectoryName(name) => { - // Convert X509Name to nested tuple format - let dir_tuple = name_to_tuple(name)?; - san_entries.push(vm.new_tuple(("DirName", dir_tuple)).into()); - } - GeneralName::RegisteredID(oid) => { - // Convert OID to string representation - let oid_str = oid.to_id_string(); - san_entries.push(vm.new_tuple(("Registered ID", oid_str)).into()); - } - _ => {} + for name in decoded.subject_alt_names { + if name.kind == "DirName" { + san_entries.push( + vm.new_tuple(("DirName", name_to_tuple(&name.directory_name))) + .into(), + ); + } else { + san_entries.push(vm.new_tuple((name.kind, name.value)).into()); } } - if !san_entries.is_empty() { - dict.set_item("subjectAltName", vm.ctx.new_tuple(san_entries).into(), vm)?; - } + dict.set_item("subjectAltName", vm.ctx.new_tuple(san_entries).into(), vm)?; } Ok(dict.into()) diff --git a/crates/stdlib/src/ssl/compat.rs b/crates/stdlib/src/ssl/compat.rs index b97631f47..e34747758 100644 --- a/crates/stdlib/src/ssl/compat.rs +++ b/crates/stdlib/src/ssl/compat.rs @@ -32,8 +32,10 @@ use rustpython_vm::function::ArgBytesLike; use rustpython_vm::{AsObject, Py, PyObjectRef, PyPayload, PyResult, TryFromObject}; use std::io::Read; -use super::chain::{self, Purpose, VerifiedChainBuilder}; -use super::providers::CryptoExt; +use rustpython_host_env::ssl::{ + chain::{self, Purpose, VerifiedChainBuilder}, + providers::CryptoExt, +}; // Import PySSLSocket from parent module use super::_ssl::{ @@ -897,6 +899,7 @@ pub(super) fn send_all_bytes( deadline: Option, ) -> SslResult<()> { // Retain newly drained records before a fallible flush of earlier output. + socket.observe_tls(true, &buf, vm); socket.pending_tls_output.lock().extend_from_slice(&buf); socket .flush_pending_tls_output(vm, deadline) @@ -969,6 +972,7 @@ pub(super) fn recv_at_most_one_tls_record( SslError::Py(e) } })?; + socket.observe_tls(false, bytes.as_bytes(), vm); if bytes.is_empty() { Err(if socket.is_bio_mode() && !socket.transport_eof() { SslError::WantRead @@ -1689,6 +1693,12 @@ fn ssl_ensure_data_available(socket: &PySSLSocket, vm: &VirtualMachine) -> SslRe return Err(SslError::Eof); } + if is_bio { + let bytes = ArgBytesLike::try_from_object(vm, data.clone()) + .map_err(|_| SslError::Syscall("Expected bytes-like object".to_string()))?; + socket.observe_tls(false, bytes.borrow_buf().as_ref(), vm); + } + // Feed data to rustls and process packets with_conn_mut(socket, vm, |conn| { ssl_read_tls_records(conn, data, is_bio, vm)?; @@ -1754,7 +1764,7 @@ impl ResolvesServerCert for MultiCertResolver { pub(super) fn curve_name_to_kx_group( curve: &str, ) -> Result, String> { - super::cipher::kx_group_by_openssl_name(curve) + rustpython_host_env::ssl::cipher::kx_group_by_openssl_name(curve) .map(|group| vec![group]) .ok_or_else(|| format!("unknown curve name '{curve}'")) } diff --git a/crates/stdlib/src/ssl/keylog.rs b/crates/stdlib/src/ssl/keylog.rs index beceab094..467936d7b 100644 --- a/crates/stdlib/src/ssl/keylog.rs +++ b/crates/stdlib/src/ssl/keylog.rs @@ -11,25 +11,38 @@ pub(super) struct KeyLog(pub AppendLog); /// Each connection follows its current Python context. This routing state must /// not be shared through the context's config cache or an explicit SSLSession. #[derive(Debug)] -pub(super) struct ConnectionKeyLog(parking_lot::RwLock>); +pub(super) struct ConnectionKeyLog { + sink: parking_lot::RwLock>, + master_secret: parking_lot::Mutex>>, +} impl ConnectionKeyLog { pub(super) fn new(sink: Arc) -> Self { - Self(parking_lot::RwLock::new(sink)) + Self { + sink: parking_lot::RwLock::new(sink), + master_secret: parking_lot::Mutex::new(None), + } } pub(super) fn set_sink(&self, sink: Arc) { - *self.0.write() = sink; + *self.sink.write() = sink; + } + + pub(super) fn master_secret(&self) -> Option> { + self.master_secret.lock().clone() } } impl rustls::KeyLog for ConnectionKeyLog { fn log(&self, label: &str, client_random: &[u8], secret: &[u8]) { - self.0.read().log(label, client_random, secret); + if label == "CLIENT_RANDOM" { + *self.master_secret.lock() = Some(secret.to_vec()); + } + self.sink.read().log(label, client_random, secret); } fn will_log(&self, label: &str) -> bool { - self.0.read().will_log(label) + label == "CLIENT_RANDOM" || self.sink.read().will_log(label) } } diff --git a/crates/stdlib/src/ssl/msg.rs b/crates/stdlib/src/ssl/msg.rs new file mode 100644 index 000000000..35bf865e5 --- /dev/null +++ b/crates/stdlib/src/ssl/msg.rs @@ -0,0 +1,9 @@ +// TLS record observation adapter. The engine lives in rustpython-common. + +use rustpython_vm::{VirtualMachine, builtins::PyBaseExceptionRef}; + +pub(super) use rustpython_host_env::ssl::msg::{MsgState, tls12_unique}; + +pub(super) fn unknown_binding_type_error(cb_type: &str, vm: &VirtualMachine) -> PyBaseExceptionRef { + vm.new_value_error(format!("'{cb_type}' channel binding type not implemented")) +}