PyOpenSSL set timeout on do_handshake()

Viewed 740

With PyOpenSSL ( version 20.0.0 ), is it possible to set a timeout on do_handshake() call when socket.setblocking(False)?

I have a hostname that responds to a socket ( so you get an IP address). But when I call do_handshake() the server never answers the Client Hello. I don't control the server.

If I set the sock.setblocking(True) the do_handshake() works great with normal endpoints. But then I hit my bad host ( good socket, no TLS ). It fails after ~ 35 seconds with OpenSSL.SSL.Error. Makes sense. The error is ('SSL routines', 'ssl3_get_record', 'wrong version number').

I tried setting the timeout in the OpenSSL.Context:

context = Context(TLSv1_2_METHOD)
context.set_timeout(5)

That didn't work. I tried setting a timeout on the socket:

sock.setblocking(False)
sock.settimeout(3.0)

I think that could work. But, that always fires OpenSSL.SSL.WantReadError when I call do_handshake():

tls_client = Connection(verifier.context, s.sock)
tls_client.set_tlsext_host_name(bytes(s.host, 'utf-8'))
tls_client.set_connect_state()        # set to work in client mode
try:
   tls_client.do_handshake()
except WantReadError:
    print("[!]WantReadError.  Only generated with sock.setblocking(False)")

So I am stuck waiting for ~35 seconds with the following settings to avoid the WantReadError:

self.sock.setblocking(True)
#self.sock.settimeout(3.0)

openssl s_client shows the debug flow:

openssl s_client -CApath ${CERTS} -state -nbio -connect foo.bar.com:443            
CONNECTED(00000007)
Turned on non blocking io
SSL_connect:before SSL initialization
SSL_connect:SSLv3/TLS write client hello
SSL_connect:error in SSLv3/TLS write client hello
write R BLOCK
2 Answers

I met the exact problem (no comments yet). Mine is getting reset after 300 seconds.

After struggling for a few days, I use standard ssl lib to handle timeout before PyOpenSSL code:

import ssl
import socket

_ssl_ctx = ssl.SSLContext()
_ssl_ctx.verify_mode = ssl.CERT_NONE
_ssl_ctx.check_hostname = False
_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_sock.settimeout(1)
_ssl_sock = _ssl_ctx.wrap_socket(_sock, do_handshake_on_connect=False)
try:
    _ssl_sock.connect(("host", 443))
    _ssl_sock.do_handshake()
except Exception as e:
    print(str(e))
finally:
    _ssl_sock.close()

#
ssl_context = SSL.Context(SSL.SSLv23_METHOD)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
conn = SSL.Connection(ssl_context, sock)
conn.settimeout(1)  # socket timeout for establishing connection
conn.connect(("host", 443))
conn.setblocking(True)
conn.do_handshake()
conn.get_peer_cert_chain()

You should set timeout on a socket and not on a context.

You should consider two possible cases:

  1. TCP connection hangs
  2. SSL connection hangs

For scenario #1 you can just set timeout on a TCP socket. When you set a timeout on a socket then it becomes non-blocking. This means that whenever OpenSSL requires more data instead of blocking it will throw an error indicating that operation should be retried. So OpenSSL timeout will be number of retries * delay between retries.

Example client showing how this should be implemented (this is python3 code and to run it locally you need to install pyopenssl and retry):

from socket import socket

from OpenSSL import SSL
from retry import retry

def connect(host, port):
    tcp_socket = get_tcp_socket()
    ssl_socket = get_ssl_socket(tcp_socket)
    ssl_socket.connect((host, port))
    do_hanshake(ssl_socket)
    print("Successfully connected to: ", host, port)

def get_tcp_socket():
    tcp_socket = socket()
    # by setting timeout to 2 seconds we
    # make the socket non-blocking
    tcp_socket.settimeout(2)

    return tcp_socket

def get_ssl_socket(tcp_socket):
    ssl_context = SSL.Context(SSL.TLSv1_2_METHOD)
    # since we are using non-blocking TCP socket
    # this makes SSL socket also non-blocking
    ssl_socket = SSL.Connection(ssl_context, tcp_socket)
    # indicate that socket should operate in client mode
    ssl_socket.set_connect_state()
    
    return ssl_socket 

@retry((SSL.WantReadError), tries=25, delay=0.1)
def do_hanshake(ssl_socket):
    """
    If the SSL socket is non-blocking we have to do retries
    since SSL.WantReadError indicates that OpenSSL requires
    more data to complete the operation.
    """
    print("Retry")
    ssl_socket.do_handshake()


"""
Connection will succeed.
SSL.WantReadError might be thrown couple of times.
"""
connect("google.com", 443)


"""
We will get a TCP level timeout since port 81 is not open.

Traceback (most recent call last):
File "tmp.py", line 52, in <module>
    connect_but_fail_with_timeout()
File "tmp.py", line 19, in connect_but_fail_with_timeout
    ssl_socket.connect(host_and_port)
File ~/.virtualenvs/tmp/lib/python3.8/site-packages/OpenSSL/SSL.py", line 1859, in connect
    return self._socket.connect(addr)
socket.timeout: timed out
"""
connect("google.com", 81)

"""
TCP connection is accepted but SSL data exchange is very slow

Retry
....
Retry
Traceback (most recent call last):
  File "client.py", line 65, in <module>
    connect("localhost", 4430)
  File "client.py", line 10, in connect
    do_hanshake(ssl_socket)
  File "~/.virtualenvs/tmp/lib/python3.8/site-packages/decorator.py", line 232, in fun
    return caller(func, *(extras + args), **kw)
  File "~/.virtualenvs/tmp/lib/python3.8/site-packages/retry/api.py", line 73, in retry_decorator
    return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter,
  File "~/.virtualenvs/tmp/lib/python3.8/site-packages/retry/api.py", line 33, in __retry_internal
    return f()
  File "client.py", line 39, in do_hanshake
    ssl_socket.do_handshake()
  File "~/.virtualenvs/tmp/lib/python3.8/site-packages/OpenSSL/SSL.py", line 1828, in do_handshake
    self._raise_ssl_error(self._ssl, result)
  File "~/.virtualenvs/tmp/lib/python3.8/site-packages/OpenSSL/SSL.py", line 1541, in _raise_ssl_error
    raise WantReadError()
OpenSSL.SSL.WantReadError

"""
connect("localhost", 4430)

Last example uses TCP server that is slow to exchange data:

import socket
import time

# create a TCP/IP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# set server port
server_address = ("", 4430)
sock.bind(server_address)
print("Starting server on %s port %s" % sock.getsockname())
# configure server to accept single connection
sock.listen(1)

while True:
    print("Waiting for a connection")
    tcp_socket, client_address = sock.accept()
    try:
        print("Client connected:", client_address)
        # slowly send irelevant data
        while True:
            # wait for 2 second
            time.sleep(2)
            client_data = tcp_socket.recv(5)
            print("received:", client_data)
            if client_data:
                tcp_socket.sendall(b"abc")
            else:
                break
    finally:
        tcp_socket.close()
Related