Skip to content

DTLS_Implementation_Notes

Bill Fenner edited this page Aug 30, 2018 · 1 revision

This page documents the experience of implementing SNMP over DTLS as described by documents being developed for the ISMS working group. A large section of this is relevant only to SNMP developers (Net-SNMP or otherwise) and some of this is relevant to anyone who is implementing a DTLS solution using OpenSSL. In particular, there are a number of tricks that need to be employed to make an OpenSSL based DTLS server properly handle multiple clients.

(The Net-SNMP DTLS implementation and this document were done by Wes Hardaker)

Net-SNMP Background

Net-SNMP implements the transports over which SNMP messages can be sent using a pluggable architecture. This architecture defines hooks that allow implemented transports to handle opening, sending and receiving packets through "something or other". The "something or other" can be pretty much anything and Net-SNMP has support for IPv4/UDP, IPv6/TCP, UnixDomain Sockets, IPX, and internal pipe()s to name just a few of the diverse examples.

To implement DTLS support within Net-SNMP a new transport had to be written that was responsible for sending and receiving packets. This new file (snmplib/snmpDTLSUDPDomain.c) is functional and has passed a number of tests. It is still experimental in nature but will be in the Net-SNMP 5.5 release.

OpenSSL Background

OpenSSL's internal implementation architecture is well designed from a modular point of view. To some extent, however, this will come back to bite us as we'll see later on.

TLS/DTLS

Internally the TLS and DTLS implementations merely process the data they receive through "anything" and send responses back through the configured mechanism. These sending and receiving mechanisms are entirely separated from the TLS/DTLS implementations by a modular data sending and receiving framework called BIOs.

BIOs

BIOs in OpenSSL are merely code that knows how to send and receive data. You could easily think of them as a buffering layer between a data producer and consumer and where-ever-the-data-needs-to-go. They act in a very similar fashion to the way transports separate the Net-SNMP packet processing from the sending/receiving framework (but OpenSSL has many more BIO implementations than Net-SNMP has transport implementations).

BIOs exist in OpenSSL to handle sending and receiving from many different directions. Probably the most common BIO is the one that attaches to a network socket like the TCP BIO. There are also BIOs that wrap around stdout, and there are memory BIOs that merely store data in a buffer. Starting with some version of OpenSSL there also was a datagram BIO that was designed to be used with DTLS and UDP. The datagram BIO is merely a wrapper around UDP sockets in the same way that the TCP BIO wrapped around TCP sockets.

TCP vs UDP Background

There is a fundamental difference, of course, between how UDP and TCP works. The biggest difference is in receiving packets. Normally for TCP you have to call accept() to allow a new connection to come through and get a new OS socket for sending and receiving on that socket which is then bound to just that peer. You may have no idea yet how important that last statement is, so let me repeat it. In bold. TCP implementations provide consumers with one socket per connection.

Within the OpenSSL framework, when a new connection is requested from a new client the OpenSSL stack also creates a new BIO which is then attached to the new socket.

With UDP there is only one socket available to send and receive from (well, you could create multiple sockets with a different socket per client using a different UDP port per socket but you'd have to convince all your clients to send the traffic to the newly opened port just for them...). With UDP a receiving server needs to check each packet that it's getting data from for the source address. If it needs to respond to the packet then it needs to make sure that it remembers the address so it can send the response back to the right place. (TCP implementations, on the other hand remember it for you so there is less to do).

OpenSSL Context Background

OpenSSL needs to keep state with respect to every TLS or DTLS session that it has established. It does this through the use of a SSL * pointer which is then attached to the sending and receiving BIO * pointer using a call as follows:

   SSL_set_bio(SSL *ptr, BIO *read_from_bio, BIO *write_to_bio);

Whenever stuff needs to be done (be it sending data or negotiating cryptography) then the OpenSSL internal TLS implementation can use these two "tied" BIOs for reading or writing. Normally, as I mentioned earlier, these BIOs are wrappers around TCP sockets for use with TLS. But wait, there's more...

Where the problem comes in

Problem 1: UDP is a single socket

The first problem is that UDP uses only a single socket for sending and receiving. But when writing an application that needs to communicate with multiple remote peers (like a server would certainly need to do) then OpenSSL has to be able to send to and receive from these multiple peers. IE, it needs multiple SSL * / BIO * pairs. I thought, originally, that maybe I could trick OpenSSL and manually tell it which SSL * pointer to use for each packet (and each SSL * pointer would still be bound to a single BIO * and thus to a single socktet). I tried do a PEEK at the incoming packet and based on where it was coming from (the remote source address and source port) and then I could pick the right SSL * pointer and tell OpenSSL to process the incoming packet. That way I could have just one DGRAM based BIO (like all the OpenSSL examples use) and still handle multiple connections.

This actually almost worked, except for the next problem got in the way:

Problem 2: Packets Pile Up

When OpenSSL is operating, particularly when starting up a new TLS or DTLS session, it needs to read and write multiple packets in order to complete negotiations. Internally OpenSSL will attempt to read as much as it can from the BIO it was given in order to process as much as possible before returning control to the application (with or without data for it).

In the case of TLS over TCP, this is perfectly safe since the the BIO is attached to a socket where all the data coming through the socket should be destined for that one TLS connection.

However! This concept of reading as much as you want fails completely when using UDP. The first packet may certainly be destined for the current SSL * session (especially if the application handed it carefully to OpenSSL as described above). But there is no guarantee that the next packet will be from the same client or the same (D)TLS session.

So, when OpenSSL is trying to be as proactive as it can by reading and processing as much as it can from a socket before returning data to the calling application, it may actually try to read multiple packets from the BIO attached to the UDP socket. It assumes they're all destined for the SSL * session that it's currently handling. This is a completely invalid thing to do, unfortunately. In fact when I launched two DTLS instrumented versions of snmpget against a single DTLS instrumented snmpd server then one of the clients failed because it was suddenly trying to read too much data from the UDP socket and ended up reading packets for one SSL * session that were, in fact, destined for the other.

The Solution That Works

Note: make sure you read to the end of this article, as other people's solutions are documented below and may be better than the one described here.

So, the solution that I've found that works is to take the sockets completely away from OpenSSL's view. Instead, I use memory BIOs to communicate with OpenSSL and my code personally copies stuff between the network's UDP socket and the input and output memory buffer BIOs. This works fairly well but requires a lot more processing by the client/server code that OpenSSL really should take care of for us.

The general set up is described in the following sub-sections.

THIS IS HALF PSEUDO-CODE AND WILL NOT WORK AS IS

Initialize things

   SSL_library_init();    SSL_load_error_strings();    ERR_load_BIO_strings();    OpenSSL_add_all_algorithms();        /* Open UDP socket */    int sock = socket(PF_INET, SOCK_DGRAM, 0);

Receiving a Packet

After select() notices that there is new traffic on the socket, you should be able to do something like:

   struct sockaddr_in frombuf;    struct sockaddr *from = &frombuf;    size_t fromlen = sizeof(frombuf);    rc = recvfrom(sock, buf, size, MSG_DONTWAIT, from, &fromlen);    

Now, if this is a new connection (i.e., from a new remote address and remote port) then we won't have anything in our cache and we need to create a new SSL * connection context for it and a new set of memory BIOs for handing stuff in and out.

So, only if it's from a new address/port:

   /* this assumes you have a valid SSL_CTX`` ``*ctx already created and available */    SSL *con = SSL_new(ctx);        /* set up the memory-buffer BIOs */    BIO *for_reading = BIO_new(BIO_s_mem());    BIO *for_writing = BIO_new(BIO_s_mem());    BIO_set_mem_eof_return(for_reading, -1);    BIO_set_mem_eof_return(for_writing, -1);        /* bind them together */    SSL_set_bio(con, for_reading, for_writing);    /* if on the client: SSL_set_connect_state(con); */    SSL_set_accept_state(cachep->con);

Remembering the con, for_reading and for_writing pointers and associating them with a remote address/port combination is left as an exercise for the reader. You can look at the caching system that is used within the snmpDTLSUDPDomain.c file.

Once the two BIOs and the SSL pointers are known (regardless of whether they are remembered from a cache or created fresh (like above) then we continue on and actually read the data from the buf:

   /* write the received buffer from the UDP socket to the memory-based input bio */    BIO_write(for_reading, buf, rc);        /* Tell openssl to process the packet now stored in the memory bio */    rc = SSL_read(con, buf, size);        /* at this point buf will store the results (with a length of rc) */

What if OpenSSL is in a negotiation phase? If so, it'll have an outgoing packet to send and it will have queued it into the for_writing buffer. So you need to check whether the buffer contains data, and if it does then send it out.

   /* this should be done even if rc == -1 and no consumable data was received*/    if (BIO_ctrl_pending(for_writing) > 0) {         /* Read the data out of the for_writing bio */         outsize = BIO_read(for_writing, outbuf, sizeof(outbuf));             /* send it out the udp port */         rc2 = sendto(sock, outbuf, outsize, 0, &sockaddr, sizeof(struct sockaddr));    }

Sending a Packet

When sending a packet, we need to see if we have an open connection. If we don't have one (ie, it's a new destination) then we do something similar as we did above so I won't repeat it here: create an input and output memory bio and attach them to a new SSL * pointer. For a client, we don't technically need to do it this way because it would be easy to use a new DGRAM bio per server to talk to (each with its own fresh socket). It's not as tricky as the server side where we can't do that as easily because there is only one socket. But for completeness, here is the process using memory BIOs again:

   /* call OpenSSL to write out our buffer of data. */    /* reminder: this actually writes it out to a memory bio */    rc = SSL_write(con, buf, size);        /* Read the actual packet to be sent out of the for_writing bio */    rc = BIO_read(for_writing, outbuf, sizeof(outbuf));        /* and send it out the udp socket */    rc = sendto(sock, outbuf, rc, 0, &sockaddr, sizeof(struct sockaddr));

Caveats

There are some downsides that haven't been mentioned yet:

DTLS Cookie Handling

This completely breaks cookie handling within DTLS. Ok, it doesn't break it but it defeats the purpose. Now we're keeping state for OpenSSL but the whole point of DTLS cookies is to not keep any state at all until the client proves themselves by sufficiently responding to the cookie request. We're now caching data before the client has done this.

Memory Leak?

I have a memory leak occuring within my current implementation that I haven't traced down yet. I think it must in my code, not in any of my OpenSSL usage... I'll delete this note when I've found and removed the problem from my code (or replace it with a new description of the problem that I apparently couldn't fix).

Other possible solutions

There are a few other ways to potentially get around the OpenSSL DTLS issues. They all require changes to OpenSSL itself.

Writing a new UDP BIO

In theory, a new dgram BIO could be written that would fake an accept call and auto-fork a new BIO for new incoming "connections" (defined just like above using a unique combination of remote IP address and port number). The new BIO could still read and write from a single socket, but each BIO would have to do a PEEK on the socket to make sure the next incoming dgram message was from the expected source/port pair (or else don't read from it). This can be easily accomplished through the kernel using connected sockets, however, and shouldn't require application code once the patch to OpenSSL is completed.

Robin Seggelmann has implemented a bunch of patches to OpenSSL for supporting DTLS in a better way within OpenSSL itself. The ones known to date are listed below:

Ok... I stopped looking at this point because the number of outstanding patches/bugs is huge for DTLS. The good news is that means there is interest in it which means hopefully future versions will have this problem fixed.

Supposedly the patches have now been applied to the OpenSSL trunk and will be in the next release.

Modify the internal OpenSSL Architecture

I'm sure by completely modifying how OpenSSL looks at BIOs would probably help. But most likely some major changes might be needed and even guessing as to what is far beyond my knowledge level of OpenSSL.

Campgnol VPN's solution

The below are the notes from Florent Bondoux on his experiencese with DTLS and OpenSSL:

I'm the current author/maintainer of Campgnol VPN which is mentioned later in the thread.

Campagnol is a small Layer 3 tunneling VPN over UDP/DTLS. One of its design objective was to use some NAT traversal technique. So it uses UDP hole punching to establish the new connections and therefore uses a single UDP socket. I'll try to explain how Campagnol handles this multiple peers per socket issue.

So the objective is to efficiently uncouple the DTLS reading operation from the socket. There is no problem for the writing operation, just use the Datagram BIO. For the reading operation, I actually first tried with MSG_PEEK, hit the same problem than you, and then tried with the mem BIO. But if you use this BIO instead of the datagram BIO, you will lose the possibility to retransmit handshake messages. Moreover I think this BIO is only suited to store a single packet which may not be easy since it has no blocking facility. So I preferred to create a new BIO more suited to my needs.

This is the relevant parts of the design of Campagnol :

  • For each connection:
    • one structure, holding its VPN context and its SSL object
    • one worker thread
  • For each SSL object:
    • the writing BIO is a datagram BIO (possibly chained to a custom BIO doing some traffic shaping)
    • the reading BIO is a custom BIO implementing a FIFO queue.
  • The FIFO BIO:
    • offers blocking I/O when the queue is empty or full
    • has an optional timeout for the reading operation so that OpenSSL can use the timeout condition to (try to) retransmit handshake message.
    • provides the same BIO controls than the Datagram BIO, so that the DTLS code can check the timeout condition.
  • one thread is reading the socket in loop:
    • if the sender is a connected peer, it pushes the packet in the proper FIFO BIO.
    • otherwise it will try to open a new connection (hole punching), create the VPN context, start a new worker thread, etc. before pushing the packet.
  • each worker thread:
    • does the DTLS handshake
    • loops over SSL_read which blocks until the socket thread pushes a packet
    • does the shutdown operation and die.

So this FIFO BIO does the trick. Since it provides the same interface than the datagram BIO, timeout and retransmission are possible. Moreover I think it's especially interesting on slow machines where it

  • seems* to give better throughput than a simple buffer. Last but not

least, I prefer this solution since I did not modified anything from OpenSSL :)

It's source code is available here

Clone this wiki locally