Synss / python-mbedtls

Cryptographic library with an mbed TLS back end
MIT License
79 stars 28 forks source link

Server cannot accept multiple clients in multithreading #51

Closed mingssun closed 2 years ago

mingssun commented 2 years ago

NOTE: Please use stackoverflow for support questions. This repository's issues are reserved for feature requests and bug reports.

I am submitting a …

Description

Hi, I try to test socket server with multiple clients, using multi threading on the server side, but only can connect one client after accept per time, until that client socket is closed, then it is possible to accept another client, if two clients are running at same time, the second client is doing handshake either to timeout or succeeded when the last client socket is closed.

Thanks

Current behavior

Expected behavior

Steps to reproduce

  1. run server
  2. run two client at same time (within 10 seconds)

Minimal demo of the problem

DTLS server

import os
from _thread import *
import asyncio
import time
import structlog
from contextlib import suppress
from mbedtls    import tls
import socket
import functools
from mbedtls.tls import *
import struct
from mbedtls.tls import _enable_debug_output, _set_debug_level

def block(cb, *args, **kwargs):
    while True:
        try:
            result = cb(*args, **kwargs)
        except (WantReadError, WantWriteError):
            print(" .", cb.__name__)
        else:
            print(" .", "done", cb.__name__, result)
            return result

srv_conf = tls.DTLSConfiguration(
    ciphers=(
        # PSK Requires the selection PSK ciphers.
        "TLS-ECDHE-PSK-WITH-CHACHA20-POLY1305-SHA256",
        "TLS-RSA-PSK-WITH-CHACHA20-POLY1305-SHA256",
        "TLS-PSK-WITH-CHACHA20-POLY1305-SHA256",
    ),
    pre_shared_key_store={
        "client0": b"a secret",
        "client1": b"other secret",
        "client42": b"the secret",
        "client100": b"yet another one",
    },
)

_enable_debug_output(srv_conf)
_set_debug_level(1)

HOST, PORT = "0.0.0.0", 2883
dtls_srv_ctx = tls.ServerContext(srv_conf)
bindsock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
dtls_srv = dtls_srv_ctx.wrap_socket(bindsock)
dtls_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
dtls_srv.bind((HOST, PORT))
ThreadCount = 0

def threaded_client(connection, address):

    while True:
        data = block(client.recv, 2048)
        block(client.send, data)
        if data == b"\0":
            break
    client.close()

while True:
    print(f"> waiting to accept:")
    cli0, cli_address0 = dtls_srv.accept()
    print(f"> accepted:")
    cli0.setcookieparam(cli_address0[0].encode("ascii"))
    try:
        block(cli0.do_handshake)
    except HelloVerifyRequest:
        print("HVR")

    cli1, cli_address1 = cli0.accept()
    cli0.close()
    cli1.setcookieparam(cli_address1[0].encode("ascii"))
    block(cli1.do_handshake)
    print(" .", "handshake", cli1.negotiated_tls_version())

    client = cli1
    client_address = cli_address1       

    print('Connected to: ' + client_address[0] + ':' + str(client_address[1]))
    start_new_thread(threaded_client, (client, client_address,))
    ThreadCount += 1
    print('Thread Number: ' + str(ThreadCount))
dtls_srv.close()

DTLS client

import socket
import struct
import time
from mbedtls import tls
from mbedtls.x509 import CRT
from mbedtls.tls import *
from mbedtls.tls import _enable_debug_output, _set_debug_level

cli_conf = tls.DTLSConfiguration(pre_shared_key=("client42", b"the secret"))

_enable_debug_output(cli_conf)
_set_debug_level(1)

address = ("127.0.0.1", 2883)
host, port = address

ctx = ClientContext(cli_conf)
cli = ctx.wrap_socket(
    socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP),
    server_hostname="localhost",
)

print(" .", "connect", address)
cli.connect(address)

def block(cb, *args, **kwargs):
    while True:
        try:
            result = cb(*args, **kwargs)
        except (WantReadError, WantWriteError):
            print(" .", cb.__name__)
        else:
            print(" .", "done", cb.__name__, result)
            return result

block(cli.do_handshake)
print(" .", "handshake", cli.negotiated_tls_version())

msg = b"hello"
for _ in range(5):
    nn = block(cli.send, msg)
    print(" .", "S", nn, len(msg))
    data, addr = block(cli.recvfrom, 4096)
    print(" .", "R", nn, data)
    time.sleep(2)
else:
    block(cli.send, b"\0")
    block(cli.recvfrom, 4096)

print(cli)
cli.close()

Other information

Synss commented 2 years ago

Hi @P2Uming, I will look into this shortly.

Synss commented 2 years ago

While trying some more, I got some reproducible segmentation faults. There is definitely something wrong here.

mingssun commented 2 years ago

Hi @Synss, Thanks for your effort, Looking forward to the fix!

Krolken commented 2 years ago

I am not sure the solution with a wrapped socket is the best one of a server implementation.

A server need to accept multiple "connection" from many clients. It seems like the solution with a wrapped socket only will support 1 client as it says in the documentation that you need to make a new bind() for new clients. But you can only bind on one port at a time.

A better and maybe simpler solution would maybe be to have a DTLSSessionManager that sits between the socket and application. The manager would store DTLS handshake state for each client that is connecting to it and return the correct responses. As it keeps a record of clients it could also support sleeping clients and clients behind NATs if you would use the CID identifier but resuming the DTLS session. In an IoT environment it is important to keep the number of handshakes to a minimum.

So my proposed solution would be to not wrap the socket. Keep the socket as standard and handle the DTLS state in another class. Since you would use a standard socket it should be simple to integrate into different I/O paradigms. Just use a simple socketserver or use asyncio.

some psuedo-code


sock = make_socket()
appliation_reader, application_writer = get_app_reader_writer()

def on_datagram(data, addr):
   """Our server socket receives a datagram"""
   # If there is a DTLS connection the decrypted datagram will be passed to application_reader.
   # If not the next handshake package will be sent back
   sock.send(DTLSSessionManager.recv(data, addr))

I am just not sure on what parts of the mbedtls I need to interact with to support this setup.

Krolken commented 2 years ago

I tired setting up a small sample using "socketserver"

from socketserver import ThreadingUDPServer, BaseRequestHandler
import logging

from mbedtls import tls

logger = logging.getLogger(__name__)

class SimpleUdpHandler(BaseRequestHandler):
    """
    Simple request handler for UDP that will log incoming data.
    """

    def handle(self):
        srv_conf = tls.DTLSConfiguration(
            ciphers=(  # PSK Requires the selection PSK ciphers.
                "TLS-ECDHE-PSK-WITH-CHACHA20-POLY1305-SHA256",
                "TLS-RSA-PSK-WITH-CHACHA20-POLY1305-SHA256",
                "TLS-PSK-WITH-CHACHA20-POLY1305-SHA256",
            ),
            pre_shared_key_store={
                "client0": b"a secret",
                "client1": b"other secret",
                "client42": b"the secret",
                "client100": b"yet another one",
            },
        )
        dtls_context = tls.ServerContext(srv_conf)
        dtls_buffer = tls.TLSWrappedBuffer(dtls_context)

        data = self.request[0]
        socket = self.request[1]
        # dtls_socket = dtls_context.wrap_socket(socket)
        print(dtls_context._state)
        dtls_buffer._setcookieparam(str((self.client_address[0])).encode())
        dtls_buffer.receive_from_network(data)

        # dtls_socket.do_handshake()
        dtls_buffer._as_bio()
        dtls_buffer.do_handshake()

        print(dtls_context._state)

        print(data)
        print(socket)
        logger.debug(
            f"Received UDP Message",
            extra={
                "data": f"{data!r}",
                "host": self.client_address[0],
                "port": self.client_address[1],
            },
        )

if __name__ == "__main__":
    request_handler = SimpleUdpHandler

    with ThreadingUDPServer(("localhost", 2883), request_handler) as server:
        click.secho(
            f"Staring {server.__class__.__name__} ",
            fg="bright_yellow",
            blink=True,
        )
        server.serve_forever()

If I try to use a wrapped socket I get the error mbedtls.exceptions.TLSError: TLSError([0x004E] 'NET - Sending information through the socket failed')

But I wanted to try and only use the buffer to be able to handle the sockets and threads myself. But the _as_bio() is not availabe on the python object. If I just call dtls_buffer.do_handshake() i get mbedtls.exceptions.TLSError: TLSError([0x7100] 'SSL - Bad input parameters to function')

Synss commented 2 years ago

Indeed,

It seems like the solution with a wrapped socket only will support 1 client as it says in the documentation that you need to make a new bind() for new clients. But you can only bind on one port at a time.

is entirely correct. The wrapped socket comes from https://www.python.org/dev/peps/pep-0543/#toc-entry-10 so it is not something I will change. However,

the _as_bio() is not availabe on the python object.

the _as_bio() functions should not be necessary, at all. It was a useful shortcut I took in the beginning but it is on my to-do list to remove it and perform the handshake explicitly. That would result in further simplification such as making TLSWrappedSocket pure Python.

If that helps here I might as well do it sooner than later. It should be possible to perform the handshake using the buffer and a regular socket.socket(). However, it is not easy to explain and removing _as_bio() would be the perfect example for this.

Krolken commented 2 years ago

I made a public method calling _as_bio() in my fork and then I could get futher. What I can't find in the mbedtls code is what generates the payloads. I assume I should add the ClientHello in the buffer, then try to move the state. Since there is no cookie we get the HelloVerifyRequeset exception. But where do I get the payload for the HelloVerify? If i use buffer.consume_output I get None.

Synss commented 2 years ago

The handshake does not use the buffer. Be sure to call _as_bio() on TLSWrappedBuffer. Then, you will probably need to jungle between the two buffers. You should probably publicise them as well, or at least some part of them (like their current size) if you want to keep on debugging. I am working on it as well.

Synss commented 2 years ago

Sorry, I realise I misunderstood your question and you are that far already.

But where do I get the payload for the HelloVerify? If i use buffer.consume_output I get None.

The best for the details of the handshake with a raw socket is probably to check upstream:

Synss commented 2 years ago

@Krolken : As it seems you wanted to have a go at it as well I just pushed my dev branch

https://github.com/Synss/python-mbedtls/tree/dev

where the last commit performs a TLS handshake in the TLSWrappedBuffer instead of bypassing it. That means, handshake does not require the TLSWrappedSocket anymore (and actually does not use it).

I only tested it on TLS, DTLS will not work. But the idea is here. I will keep working on it and rebase/rewrite the history of the dev branch, too, so do not build on it: it will keep moving! 😉

Krolken commented 2 years ago

Thanks. Pulled it last night and tried some things. I am not able to get it to raise a HelloVerifyRequest Exception. Also tried rewriting the handshake with sendto(), which did work on returning the response but since it did not raise a HelloVerifyRequest it just sent b'' and the client interpreted it as EOF. Then of course it wouldn't work on the second try with my current setup as it is a threaded server and it would create a new thread for the response.

My current plan is to keep a dict of source_address and port mapping to the context as shared state. Just need to figure out how to initialize the handshake properly.

Now I am doing something like this.

dtls_context = tls.ServerContext(srv_conf)
dtls_buffer = tls.TLSWrappedBuffer(dtls_context)
data = get_udp_data()
dtls_buffer.receive_from_network(data)
dtls_buffer._setcookieparam(self.client_address[0].encode('ascii'))
# Assumed this would raise HelloVerify
dtls_buffer.context._do_handshake_step()
# and assumed that the data to send back would be in the buffer.
print(dtls_buffer.peek_outgoing(1024))
Synss commented 2 years ago

I have not forgotten about this issue but my personal computer is broken.

Krolken commented 2 years ago

No problem. I have been bogged down with other stuff.

Synss commented 2 years ago

Current master should have most of the necessary changes we discussed here.

I also have example client/server under programs.

Synss commented 2 years ago

On master, ClientContext and ServerContext are picklable (and therefore threadable or multiprocessing-friendly) and the contexts can wrap several sockets or buffers.

Now, some changes are still in progress for the handshake but, unless I am overlooking something important, the points above should fix this issue.

Synss commented 2 years ago

Now, I/O and crypto are independent and you can select any strategy you want on the I/O layer. The latest patches exemplify the crypto part without any actual I/O (test_tls).

Note that session tickets are currently not available on master. This is known and I am working on it. I may still release the current code without session handling shortly and add the session handling back later.

There have been small API changes since 1.6-1.7.