ssl: unify handshake, transport and shutdown state (#8656)

* ssl: accept ClientHello before selecting the SNI context

Replace the provisional server connection and replay flags with explicit handshake states. Retain fatal alerts across nonblocking retries and preserve fragmented input on timeout. Enable the three SNI alert regressions.

Assisted-by: Codex:GPT-5

* ssl: share socket and MemoryBIO transport progress

Queue drained TLS records before fallible output and use one handshake record loop for both transports.

Assisted-by: Codex:GPT-5

* ssl: unify shutdown and fatal alert lifecycle

Track close-notify progress in TlsState, preserve pending alerts across retries, and share record-boundary shutdown for sockets and MemoryBIO. Retain rustls errors until Python exception conversion.

Assisted-by: Codex:GPT-5

* ssl: emit the full peer certificate dict

Use the same certificate dict helper for getpeercert() and
Certificate.get_info() so OCSP, caIssuers, and CRL URLs are present.
Clear CHANNEL_BINDING_TYPES on the rustls backend, and add a MemoryBIO
leftover-plaintext snippet.

Assisted-by: Claude
This commit is contained in:
Jeong, YunWon authored and GitHub committed 2026-09-06 07:54:59 +09:00
1 parent 7f9fa32d6e
commit 531cf2b378
8 files changed
+703 -1465

No files matched your search

Vendored
+3
View File
@@ -268,6 +268,9 @@ 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')
-1
View File
@@ -1289,7 +1289,6 @@ class EventLoopTestsMixin:
server.close()
self.loop.run_until_complete(proto.done)
@unittest.expectedFailure # TODO: RUSTPYTHON; - SSL peer certificate format differs
@unittest.skipIf(ssl is None, 'No ssl module')
def test_create_server_ssl_verified(self):
proto = MyProto(loop=self.loop)
-4
View File
@@ -4192,7 +4192,6 @@ class ThreadedTests(unittest.TestCase):
s.connect((HOST, server.port))
self.assertIn("ECDH", s.cipher()[0])
@unittest.expectedFailure # TODO: RUSTPYTHON
@unittest.skipUnless("tls-unique" in ssl.CHANNEL_BINDING_TYPES,
"'tls-unique' channel binding not available")
def test_tls_unique_channel_binding(self):
@@ -4455,7 +4454,6 @@ class ThreadedTests(unittest.TestCase):
self.check_common_name(stats, SIGNED_CERTFILE_HOSTNAME)
self.assertEqual(calls, [])
@unittest.expectedFailure # TODO: RUSTPYTHON; + TLSV1_ALERT_ACCESS_DENIED
def test_sni_callback_alert(self):
# Returning a TLS alert is reflected to the connecting client
server_context, other_context, client_context = self.sni_contexts()
@@ -4469,7 +4467,6 @@ class ThreadedTests(unittest.TestCase):
sni_name='supermessage')
self.assertEqual(cm.exception.reason, 'TLSV1_ALERT_ACCESS_DENIED')
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_sni_callback_raising(self):
# Raising fails the connection with a TLS handshake failure alert.
server_context, other_context, client_context = self.sni_contexts()
@@ -4489,7 +4486,6 @@ class ThreadedTests(unittest.TestCase):
self.assertRegex(cm.exception.reason, regex)
self.assertEqual(catch.unraisable.exc_type, ZeroDivisionError)
@unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'SSLEOFError' object has no attribute 'reason'
def test_sni_callback_wrong_return_type(self):
# Returning the wrong return type terminates the TLS connection
# with an internal error alert.
+460 -962
View File
File diff suppressed because it is too large. Load diff
+3 -119
View File
@@ -228,45 +228,6 @@ fn normalize_wildcard_hostname(hostname: &str) -> &str {
hostname.strip_prefix("*.").unwrap_or(hostname)
}
/// Process Subject Alternative Name (SAN) general names into Python tuples
///
/// Converts X.509 GeneralName entries into Python tuple format.
/// Returns a vector of PyObjectRef tuples in the format: (type, value)
fn process_san_general_names(
vm: &VirtualMachine,
general_names: &[GeneralName<'_>],
) -> Vec<PyObjectRef> {
general_names
.iter()
.filter_map(|name| match name {
GeneralName::DNSName(dns) => Some(vm.new_tuple(("DNS", *dns)).into()),
GeneralName::IPAddress(ip) => {
let ip_str = format_ip_address(ip);
Some(vm.new_tuple(("IP Address", ip_str)).into())
}
GeneralName::RFC822Name(email) => Some(vm.new_tuple(("email", *email)).into()),
GeneralName::URI(uri) => Some(vm.new_tuple(("URI", *uri)).into()),
GeneralName::DirectoryName(dn) => {
let dn_str = format!("{dn}");
Some(vm.new_tuple(("DirName", dn_str)).into())
}
GeneralName::RegisteredID(oid) => {
let oid_str = oid.to_string();
Some(vm.new_tuple(("Registered ID", oid_str)).into())
}
GeneralName::OtherName(oid, value) => {
let oid_str = oid.to_string();
let value_str = format!("{value:?}");
Some(
vm.new_tuple(("othername", format!("{oid_str}:{value_str}")))
.into(),
)
}
_ => None,
})
.collect()
}
// Certificate Validation and Parsing
/// Check if a certificate is a CA certificate by examining the Basic Constraints extension
@@ -294,87 +255,10 @@ pub(super) fn is_ca_certificate(cert_der: &[u8]) -> bool {
cert.version().0 == 0 && cert.subject() == cert.issuer()
}
/// Convert an X509Name to Python nested tuple format for SSL certificate dicts
/// Convert DER-encoded certificate to Python dict.
///
/// Format: ((('CN', 'example.com'),), (('O', 'Example Org'),), ...)
fn name_to_py(vm: &VirtualMachine, name: &x509_parser::x509::X509Name<'_>) -> PyObjectRef {
let list = name
.iter()
.flat_map(|rdn| {
// Each RDN can have multiple attributes
rdn.iter()
.map(|attr| {
let oid_str = attr.attr_type().to_id_string();
let value_str = attr.attr_value().as_str().unwrap_or("").to_string();
let key = oid_to_attribute_name(&oid_str);
vm.new_tuple((vm.new_tuple((vm.ctx.new_str(key), vm.ctx.new_str(value_str))),))
.into()
})
.collect::<Vec<_>>()
})
.collect::<Vec<PyObjectRef>>();
vm.ctx.new_tuple(list).into()
}
/// Convert DER-encoded certificate to Python dict (for getpeercert with binary_form=False)
///
/// Returns a dict with fields: subject, issuer, version, serialNumber,
/// notBefore, notAfter, subjectAltName (if present)
pub(super) fn cert_to_dict(
vm: &VirtualMachine,
cert: &x509_parser::certificate::X509Certificate<'_>,
) -> PyResult {
let dict = vm.ctx.new_dict();
// Subject and Issuer
dict.set_item("subject", name_to_py(vm, cert.subject()), vm)?;
dict.set_item("issuer", name_to_py(vm, cert.issuer()), vm)?;
// Version (X.509 v3 = version 2 in the cert, but Python uses 3)
dict.set_item(
"version",
vm.ctx.new_int(cert.version().0 as i32 + 1).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)?;
// Validity dates - format with GMT using jiff
dict.set_item(
"notBefore",
vm.ctx
.new_str(format_asn1_time(&cert.validity().not_before))
.into(),
vm,
)?;
dict.set_item(
"notAfter",
vm.ctx
.new_str(format_asn1_time(&cert.validity().not_after))
.into(),
vm,
)?;
// Subject Alternative Names (if present)
if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
let san_list = process_san_general_names(vm, &san_ext.value.general_names);
if !san_list.is_empty() {
dict.set_item("subjectAltName", vm.ctx.new_tuple(san_list).into(), vm)?;
}
}
Ok(dict.into())
}
/// Convert DER-encoded certificate to Python dict (for get_ca_certs)
///
/// Similar to cert_to_dict but includes additional fields like crlDistributionPoints
/// and uses CPython's specific ordering: issuer, notAfter, notBefore, serialNumber, subject, version
/// Includes OCSP, caIssuers, and crlDistributionPoints when present, and uses
/// the field order: issuer, notAfter, notBefore, serialNumber, subject, version
pub(super) fn cert_der_to_dict_helper(
vm: &VirtualMachine,
cert_der: &[u8],
+71 -379
View File
@@ -42,8 +42,9 @@ use super::_ssl::{
// Import error types and helper functions from error module
use super::error::{
PySSLCertVerificationError, PySSLError, create_ssl_eof_error, create_ssl_syscall_error,
create_ssl_want_read_error, create_ssl_want_write_error, create_ssl_zero_return_error,
PySSLCertVerificationError, PySSLError, PySSLWantWriteError, create_ssl_eof_error,
create_ssl_syscall_error, create_ssl_want_read_error, create_ssl_want_write_error,
create_ssl_zero_return_error,
};
// OpenSSL Constants:
@@ -198,10 +199,10 @@ pub(super) enum SslError {
Io(std::io::Error),
/// Timeout error (socket.timeout)
Timeout(String),
/// SNI callback triggered - need to restart handshake
SniCallbackRestart,
/// Python exception (pass through directly)
Py(PyBaseExceptionRef),
/// Preserve the TLS error until the Python exception boundary.
Rustls(rustls::Error),
/// TLS alert received with OpenSSL-compatible error code
AlertReceived { lib: i32, reason: i32 },
/// NO_SHARED_CIPHER error (OpenSSL SSL_R_NO_SHARED_CIPHER)
@@ -218,6 +219,28 @@ impl SslError {
/// Convert rustls error to SslError
pub(super) fn from_rustls(err: rustls::Error) -> Self {
Self::Rustls(err)
}
pub(super) fn is_eof(&self) -> bool {
match self {
Self::Rustls(error) => matches!(Self::map_rustls(error.clone()), Self::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
))
)
}
fn map_rustls(err: rustls::Error) -> Self {
match err {
rustls::Error::InvalidCertificate(cert_err) => Self::CertVerification(cert_err),
rustls::Error::AlertReceived(alert_desc) => {
@@ -347,6 +370,7 @@ impl SslError {
/// Convert to Python exception
pub(super) fn into_py_err(self, vm: &VirtualMachine) -> PyBaseExceptionRef {
match self {
Self::Rustls(error) => Self::map_rustls(error).into_py_err(vm),
Self::WantRead => create_ssl_want_read_error(vm).upcast(),
Self::WantWrite => create_ssl_want_write_error(vm).upcast(),
Self::Timeout(msg) => timeout_error_msg(vm, msg).upcast(),
@@ -387,10 +411,7 @@ impl SslError {
)
.upcast(),
Self::Io(err) => err.into_pyexception(vm),
Self::SniCallbackRestart => {
// This should be handled at PySSLSocket level
unreachable!("SniCallbackRestart should not reach Python layer")
}
Self::Py(exc) => exc,
Self::AlertReceived { lib, reason } => {
Self::create_ssl_error_from_codes(vm, lib, reason)
@@ -854,100 +875,25 @@ pub(super) fn is_blocking_io_error(err: &Py<PyBaseException>, vm: &VirtualMachin
/// until all data is sent. For non-blocking sockets, returns WantWrite
/// if no progress can be made.
/// Optional deadline parameter allows respecting a read deadline during flush.
fn send_all_bytes(
pub(super) fn send_all_bytes(
socket: &PySSLSocket,
buf: Vec<u8>,
vm: &VirtualMachine,
deadline: Option<std::time::Instant>,
) -> SslResult<()> {
// First, flush any previously pending TLS data with deadline
// Retain newly drained records before a fallible flush of earlier output.
socket.pending_tls_output.lock().extend_from_slice(&buf);
socket
.flush_pending_tls_output(vm, deadline)
.map_err(SslError::Py)?;
if buf.is_empty() {
return Ok(());
}
let mut sent_total = 0;
while sent_total < buf.len() {
// Check deadline before each send attempt
if let Some(dl) = deadline
&& std::time::Instant::now() >= dl
{
socket
.pending_tls_output
.lock()
.extend_from_slice(&buf[sent_total..]);
return Err(SslError::Timeout("The operation timed out".to_string()));
}
// Wait for socket to be writable before sending
let timed_out = if let Some(dl) = deadline {
let now = std::time::Instant::now();
if now >= dl {
socket
.pending_tls_output
.lock()
.extend_from_slice(&buf[sent_total..]);
return Err(SslError::Timeout(
"The write operation timed out".to_string(),
));
.map_err(|error| {
if error.fast_isinstance(PySSLWantWriteError::class(&vm.ctx)) {
SslError::WantWrite
} else if error.fast_isinstance(vm.ctx.exceptions.timeout_error) {
SslError::Timeout("The write operation timed out".to_owned())
} else {
SslError::Py(error)
}
socket
.sock_wait_for_io_with_timeout(SockWaitKind::Write, Some(dl - now), vm)
.map_err(SslError::Py)?
} else {
socket
.sock_wait_for_io_impl(SockWaitKind::Write, vm)
.map_err(SslError::Py)?
};
if timed_out {
socket
.pending_tls_output
.lock()
.extend_from_slice(&buf[sent_total..]);
return Err(SslError::Timeout(
"The write operation timed out".to_string(),
));
}
match socket.sock_send(&buf[sent_total..], vm) {
Ok(result) => {
let sent: usize = result
.try_to_value::<isize>(vm)
.map_err(SslError::Py)?
.try_into()
.map_err(|_| SslError::Syscall("Invalid send return value".to_string()))?;
if sent == 0 {
// No progress - save unsent bytes to pending buffer
socket
.pending_tls_output
.lock()
.extend_from_slice(&buf[sent_total..]);
return Err(SslError::WantWrite);
}
sent_total += sent;
}
Err(e) => {
if is_blocking_io_error(&e, vm) {
// Save unsent bytes to pending buffer
socket
.pending_tls_output
.lock()
.extend_from_slice(&buf[sent_total..]);
return Err(SslError::WantWrite);
}
// For other errors, also save unsent bytes
socket
.pending_tls_output
.lock()
.extend_from_slice(&buf[sent_total..]);
return Err(SslError::Py(e));
}
}
}
Ok(())
})
}
// Handshake Helper Functions
@@ -959,7 +905,6 @@ fn send_all_bytes(
fn handshake_write_loop(
conn: &mut Connection,
socket: &PySSLSocket,
force_initial_write: bool,
vm: &VirtualMachine,
) -> SslResult<bool> {
let mut made_progress = false;
@@ -970,12 +915,7 @@ fn handshake_write_loop(
.flush_pending_tls_output(vm, None)
.map_err(SslError::Py)?;
while conn.wants_write() || force_initial_write {
if force_initial_write && !conn.wants_write() {
// No data to write on first iteration - break to avoid infinite loop
break;
}
while conn.wants_write() {
let mut buf = Vec::new();
let written = conn
.write_tls(&mut buf as &mut dyn std::io::Write)
@@ -1016,7 +956,7 @@ fn handshake_write_loop(
/// Fix: peek at the TCP buffer to find the first complete TLS record boundary
/// and recv() only that many bytes. Any remaining data stays in the kernel
/// buffer and remains visible to select().
fn recv_at_most_one_tls_record(
pub(super) fn recv_at_most_one_tls_record(
socket: &PySSLSocket,
vm: &VirtualMachine,
) -> SslResult<PyObjectRef> {
@@ -1028,7 +968,11 @@ fn recv_at_most_one_tls_record(
}
})?;
if bytes.is_empty() {
Err(SslError::Eof)
Err(if socket.is_bio_mode() && !socket.transport_eof() {
SslError::WantRead
} else {
SslError::Eof
})
} else {
Ok(bytes.into())
}
@@ -1065,143 +1009,18 @@ fn recv_at_most_one_tls_record_for_data(
fn handshake_read_data(
conn: &mut Connection,
socket: &PySSLSocket,
is_bio: bool,
is_server: bool,
vm: &VirtualMachine,
) -> SslResult<(bool, bool)> {
if !conn.wants_read() {
return Ok((false, false));
) -> SslResult<()> {
if socket
.sock_wait_for_io_impl(SockWaitKind::Read, vm)
.map_err(SslError::Py)?
{
return Err(SslError::Timeout(
"The handshake operation timed out".to_owned(),
));
}
// SERVER-SPECIFIC: Check if this is before the SNI callback.
// sock_recv() may return only part of a TLS record, so keep capturing
// ClientHello fragments until process_new_packets() has produced a response.
let is_first_sni_read = is_server && socket.has_sni_callback() && socket.is_first_sni_read();
// Wait for data in socket mode
if !is_bio {
let timed_out = socket
.sock_wait_for_io_impl(SockWaitKind::Read, vm)
.map_err(SslError::Py)?;
if timed_out {
// This should rarely happen now - only if socket itself has a timeout
// and we're waiting for required handshake data
return Err(SslError::Timeout("timed out".to_string()));
}
}
let data_obj = if !is_bio {
// In socket mode, read one TLS record at a time to avoid consuming
// application data that may arrive alongside the final handshake
// record. This matches OpenSSL's default (no read-ahead) behaviour
// and keeps remaining data in the kernel buffer where select() can
// detect it.
recv_at_most_one_tls_record(socket, vm)?
} else {
match socket.sock_recv(SSL3_RT_MAX_PACKET_SIZE, vm) {
Ok(d) => d,
Err(e) => {
if is_blocking_io_error(&e, vm) {
return Err(SslError::WantRead);
}
if !conn.wants_write() && e.fast_isinstance(vm.ctx.exceptions.timeout_error) {
return Ok((false, false));
}
return Err(SslError::Py(e));
}
}
};
// SERVER-SPECIFIC: Save ClientHello fragments for potential connection recreation.
if is_first_sni_read {
// Extract bytes from PyObjectRef
use rustpython_vm::builtins::PyBytes;
if let Some(bytes_obj) = data_obj.downcast_ref::<PyBytes>() {
socket.save_client_hello_from_bytes(bytes_obj.as_bytes());
}
}
// Feed data to rustls
ssl_read_tls_records(conn, data_obj, is_bio, vm)?;
Ok((true, is_first_sni_read))
}
/// Handle handshake completion for server-side TLS 1.3
///
/// Tries to send NewSessionTicket in non-blocking mode to avoid deadlocks.
/// Returns true if handshake is complete and we should exit.
fn handle_handshake_complete(
conn: &mut Connection,
socket: &PySSLSocket,
_is_server: bool,
vm: &VirtualMachine,
) -> SslResult<bool> {
if conn.is_handshaking() {
return Ok(false); // Not complete yet
}
// Handshake is complete!
//
// Different behavior for BIO mode vs socket mode:
//
// BIO mode (CPython-compatible):
// - Python code calls outgoing.read() to get pending data
// - We just return here and let Python handle the data
//
// Socket mode (rustls-specific):
// - OpenSSL automatically writes to socket in SSL_do_handshake()
// - We must explicitly call write_tls() to send pending data
// - Without this, client hangs waiting for server's NewSessionTicket
if socket.is_bio_mode() {
// BIO mode: Write pending data to outgoing BIO (one-time drain)
// Python's ssl_io_loop will read from outgoing BIO
if conn.wants_write() {
// Call write_tls ONCE to drain pending data
// Do NOT loop on wants_write() - avoid infinite loop/deadlock
let tls_data = ssl_write_tls_records(conn)?;
if !tls_data.is_empty() {
send_all_bytes(socket, tls_data, vm, None)?;
}
// IMPORTANT: Don't check wants_write() again!
// Python's ssl_io_loop will call do_handshake() again if needed
}
} else if conn.wants_write() {
// Send all pending data (e.g., TLS 1.3 NewSessionTicket) to socket
// Must drain ALL rustls buffer - don't break on WantWrite
while conn.wants_write() {
let tls_data = ssl_write_tls_records(conn)?;
if tls_data.is_empty() {
break;
}
match send_all_bytes(socket, tls_data, vm, None) {
Ok(()) => {}
Err(SslError::WantWrite) => {
// Socket buffer full, data saved to pending_tls_output
// Flush pending and continue draining rustls buffer
socket
.blocking_flush_all_pending(vm)
.map_err(SslError::Py)?;
}
Err(e) => return Err(e),
}
}
}
// CRITICAL: Ensure all pending TLS data is sent before returning
// TLS 1.3 Finished must reach server before handshake is considered complete
// Without this, server may not process application data
if !socket.is_bio_mode() {
// Flush pending_tls_output to ensure all TLS data reaches the server
socket
.blocking_flush_all_pending(vm)
.map_err(SslError::Py)?;
}
Ok(true)
let data = recv_at_most_one_tls_record(socket, vm)?;
ssl_read_tls_records(conn, data, socket.is_bio_mode(), vm)
}
/// Try to read plaintext data from TLS connection buffer
@@ -1241,151 +1060,24 @@ pub(super) fn ssl_do_handshake(
socket: &PySSLSocket,
vm: &VirtualMachine,
) -> SslResult<()> {
// Check if handshake is already done
if !conn.is_handshaking() {
return Ok(());
}
let is_bio = socket.is_bio_mode();
let is_server = matches!(conn, Connection::Server(_));
let mut first_iteration = true; // Track if this is the first loop iteration
loop {
let mut made_progress = false;
// IMPORTANT: In BIO mode, force initial write even if wants_write() is false
// rustls requires write_tls() to be called to generate ClientHello/ServerHello
let force_initial_write = is_bio && first_iteration;
// Write TLS handshake data to socket/BIO
let write_progress = handshake_write_loop(conn, socket, force_initial_write, vm)?;
made_progress |= write_progress;
// Read TLS handshake data from socket/BIO
let (read_progress, is_first_sni_read) =
handshake_read_data(conn, socket, is_bio, is_server, vm)?;
made_progress |= read_progress;
// Process TLS packets (state machine)
if let Err(e) = conn.process_new_packets() {
// Send close_notify on error
if !is_bio {
conn.send_close_notify();
// Flush any pending TLS data before sending close_notify
let _ = socket.flush_pending_tls_output(vm, None);
// Actually send the close_notify alert using send_all_bytes
// for proper partial send handling and retry logic
if let Ok(alert_data) = ssl_write_tls_records(conn)
&& !alert_data.is_empty()
{
let _ = send_all_bytes(socket, alert_data, vm, None);
}
}
// InvalidMessage during handshake means non-TLS data was received
// before the handshake completed (e.g., HTTP request to TLS server)
if matches!(e, rustls::Error::InvalidMessage(_)) {
return Err(SslError::PreauthData);
}
// Certificate verification errors are already handled by from_rustls
return Err(SslError::from_rustls(e));
}
// SERVER-SPECIFIC: Check SNI callback after processing packets.
// A partial TLS record can be read without producing any handshake
// response. Wait until rustls has processed a complete ClientHello.
if is_server && is_first_sni_read && socket.has_sni_callback() && conn.wants_write() {
// IMPORTANT: Do NOT call the callback here!
// The connection lock is still held, which would cause deadlock.
// Return SniCallbackRestart to signal do_handshake to:
// 1. Drop conn_guard
// 2. Call the callback (with Some(name) or None)
// 3. Restart handshake
return Err(SslError::SniCallbackRestart);
}
// Check if handshake is complete and handle post-handshake processing
// CRITICAL: We do NOT check wants_read() - this matches CPython/OpenSSL behavior!
if handle_handshake_complete(conn, socket, is_server, vm)? {
// Both transports drain writes first and feed complete/partial records
// through the same path. An empty BIO naturally returns WantRead.
handshake_write_loop(conn, socket, vm)?;
if !conn.is_handshaking() {
return Ok(());
}
// In BIO mode, stop after one iteration
if is_bio {
// Before returning WANT error, write any pending TLS data to BIO
// This is critical: if wants_write is true after process_new_packets,
// we need to write that data to the outgoing BIO before returning
if conn.wants_write() {
// Write all pending TLS data to outgoing BIO
loop {
let mut buf = vec![0u8; SSL3_RT_MAX_PACKET_SIZE];
let n = match conn.write_tls(&mut buf.as_mut_slice()) {
Ok(n) => n,
Err(_) => break,
};
if n == 0 {
break;
}
// Send to outgoing BIO
send_all_bytes(socket, buf[..n].to_vec(), vm, None)?;
// Check if there's more to write
if !conn.wants_write() {
break;
}
}
// After writing, check if we still want more
// If all data was written, wants_write may now be false
if conn.wants_write() {
// Still need more - return WANT_WRITE
return Err(SslError::WantWrite);
}
// Otherwise fall through to check wants_read
}
// Check if we need to read
if conn.wants_read() {
return Err(SslError::WantRead);
}
break;
}
// Mark that we've completed the first iteration
first_iteration = false;
// Improved loop termination logic:
// Continue looping if:
// 1. Rustls wants more I/O (wants_read or wants_write), OR
// 2. We made progress in this iteration
//
// This is more robust than just checking made_progress, because:
// - Rustls may need multiple iterations to process TLS state machine
// - Network delays may cause temporary "no progress" situations
// - wants_read/wants_write accurately reflect Rustls internal state
let should_continue = conn.wants_read() || conn.wants_write() || made_progress;
if !should_continue {
break;
}
}
// If we exit the loop without completing handshake, return appropriate error
if conn.is_handshaking() {
// For non-blocking sockets, return WantRead/WantWrite to signal caller
// should retry when socket is ready. This matches OpenSSL behavior.
if conn.wants_write() {
return Err(SslError::WantWrite);
}
if conn.wants_read() {
if !conn.wants_read() {
return Err(SslError::WantRead);
}
// Neither wants_read nor wants_write - this is a real error
Err(SslError::Syscall(
"SSL handshake failed: incomplete handshake".to_string(),
))
} else {
// Handshake completed successfully (shouldn't reach here normally)
Ok(())
handshake_read_data(conn, socket, vm)?;
if let Err(error) = conn.process_new_packets() {
return Err(if matches!(error, rustls::Error::InvalidMessage(_)) {
SslError::PreauthData
} else {
SslError::from_rustls(error)
});
}
}
}
@@ -1780,7 +1472,7 @@ fn ssl_write_tls_records(conn: &mut Connection) -> SslResult<Vec<u8>> {
}
/// Read TLS records from socket to rustls
fn ssl_read_tls_records(
pub(super) fn ssl_read_tls_records(
conn: &mut Connection,
data: PyObjectRef,
is_bio: bool,
+120
View File
@@ -0,0 +1,120 @@
use core::fmt;
use rustls::server::Acceptor;
use rustpython_vm::{
builtins::PyBaseExceptionRef,
object::{Traverse, TraverseFn},
};
/// Server configuration is selected only after receiving ClientHello and
/// invoking SNI. A failed connection stays terminal while its alert drains.
/// ShuttingDown means our close_notify is queued and must not be sent twice.
pub(super) enum TlsState {
WaitingForClientHello(Box<Acceptor>),
InProgress,
Handshaking,
Connected,
ShuttingDown,
ShutDown,
SendingAlert { error: PyBaseExceptionRef },
}
impl TlsState {
pub(super) fn new(server_side: bool) -> Self {
if server_side {
Self::WaitingForClientHello(Box::default())
} else {
Self::Handshaking
}
}
}
impl fmt::Debug for TlsState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::WaitingForClientHello(_) => "WaitingForClientHello",
Self::InProgress => "InProgress",
Self::Handshaking => "Handshaking",
Self::Connected => "Connected",
Self::ShuttingDown => "ShuttingDown",
Self::ShutDown => "ShutDown",
Self::SendingAlert { .. } => "SendingAlert",
})
}
}
// Only the saved Python exception can contain GC references. Acceptor contains
// Rust-owned protocol data; encoded alerts use the shared pending output queue.
unsafe impl Traverse for TlsState {
fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) {
if let Self::SendingAlert { error, .. } = self {
error.traverse(tracer_fn);
}
}
}
/// SNI rejection happens before ServerHello, so the fatal alert is plaintext
/// even when the ClientHello offers TLS 1.3 (RFC 8446 section 5.1).
pub(super) fn sni_alert(description: u8) -> Vec<u8> {
vec![21, 3, 3, 0, 2, 2, description]
}
pub(super) fn feed_acceptor(acceptor: &mut Acceptor, bytes: &[u8]) -> std::io::Result<()> {
let mut reader = std::io::Cursor::new(bytes);
while reader.position() < bytes.len() as u64 {
if acceptor.read_tls(&mut reader)? == 0 {
return Err(std::io::ErrorKind::UnexpectedEof.into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::sync::Arc;
fn large_client_hello() -> Vec<u8> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let mut config = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(rustls::RootCertStore::empty())
.with_no_client_auth();
config.alpn_protocols = (0..70).map(|i| format!("{i:0100}").into_bytes()).collect();
let mut client =
rustls::ClientConnection::new(Arc::new(config), "localhost".try_into().unwrap())
.unwrap();
let mut bytes = Vec::new();
client.write_tls(&mut bytes).unwrap();
assert!(bytes.len() > 4096);
bytes
}
#[test]
fn consumes_large_client_hello_completely() {
let mut acceptor = Acceptor::default();
feed_acceptor(&mut acceptor, &large_client_hello()).unwrap();
let accepted = acceptor.accept().unwrap().unwrap();
assert_eq!(accepted.client_hello().server_name(), Some("localhost"));
assert_eq!(accepted.client_hello().alpn().unwrap().count(), 70);
}
#[test]
fn waits_for_every_fragment_of_client_hello() {
let hello = large_client_hello();
let mut acceptor = Acceptor::default();
for byte in &hello[..hello.len() - 1] {
feed_acceptor(&mut acceptor, &[*byte]).unwrap();
assert!(acceptor.accept().unwrap().is_none());
}
feed_acceptor(&mut acceptor, &hello[hello.len() - 1..]).unwrap();
assert!(acceptor.accept().unwrap().is_some());
}
#[test]
fn fatal_sni_alert_is_one_plaintext_record() {
assert_eq!(sni_alert(49), [21, 3, 3, 0, 2, 2, 49]);
assert_eq!(sni_alert(40), [21, 3, 3, 0, 2, 2, 40]);
assert_eq!(sni_alert(80), [21, 3, 3, 0, 2, 2, 80]);
}
}
+46
View File
@@ -0,0 +1,46 @@
import pathlib
import ssl
CERT = (
pathlib.Path(__file__).resolve().parent.parent.parent
/ "Lib"
/ "test"
/ "certdata"
/ "keycert.pem"
)
TAIL = b"tail"
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain(str(CERT))
client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client_context.check_hostname = False
client_context.verify_mode = ssl.CERT_NONE
server_context.maximum_version = client_context.maximum_version = ssl.TLSVersion.TLSv1_2
client_in, client_out = ssl.MemoryBIO(), ssl.MemoryBIO()
server_in, server_out = ssl.MemoryBIO(), ssl.MemoryBIO()
client = client_context.wrap_bio(client_in, client_out)
server = server_context.wrap_bio(server_in, server_out, server_side=True)
for _ in range(5):
try:
client.do_handshake()
except ssl.SSLWantReadError:
pass
server_in.write(client_out.read())
try:
server.do_handshake()
except ssl.SSLWantReadError:
pass
client_in.write(server_out.read())
client.do_handshake()
server.do_handshake()
try:
server.unwrap()
except ssl.SSLWantReadError:
pass
client_in.write(server_out.read() + TAIL)
client.unwrap()
assert client_in.read() == TAIL