Skip to content

ext/openssl: add a dtls:// stream transport. Open as Draft. - #22638

Draft
GianfriAur wants to merge 8 commits into
php:masterfrom
GianfriAur:feature/openssl-dtls-stream
Draft

GianfriAur wants to merge 8 commits into
php:masterfrom
GianfriAur:feature/openssl-dtls-stream

Conversation

@GianfriAur

@GianfriAur GianfriAur commented Jul 8, 2026 •

Copy link
Copy Markdown

Adds DTLS to ext/openssl: the dtls://, dtlsv1.2:// and dtlsv1.3:// stream transports
(client and server) and DTLS over udp:// through stream_socket_enable_crypto(). The ssl
context options, Openssl\Session and the stream API work for DTLS as they do for tls://.

// client
$ctx = stream_context_create(['ssl' => ['cafile' => '/path/ca.pem']]);
$c = stream_socket_client('dtls://198.51.100.1:4433', $errno, $errstr, 5,
        STREAM_CLIENT_CONNECT, $ctx);
fwrite($c, $datagram);
$reply = fread($c, 1500);

// server: one socket, any number of peers
$ctx = stream_context_create(['ssl' => ['local_cert' => '/path/server.pem']]);
$s = stream_socket_server('dtls://0.0.0.0:4433', $errno, $errstr,
        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
while ($peer = stream_socket_accept($s, 5, $addr)) {
    fwrite($peer, strtoupper(fread($peer, 1500)));
}

Design

The implementation is split by layer rather than by protocol, so DTLS adds no second copy of the
option handling or of the IO loops:

  • xp_bio.c, the ciphertext transport. OpenSSL never does IO itself. Its BIO serves records
    from a receive queue and appends to a send queue owned by the connection, and the stream moves
    ciphertext between the queues and the transport around each SSL_*() call. No call into OpenSSL
    ever waits, and the socket stays non-blocking once crypto is set up, with the stream's blocking
    mode emulated by waiting with a monotonic deadline. The transport is the stream's socket, or any
    other stream given by the new inner_stream context option (a tcp:// stream or a user wrapper),
    which makes TLS and DTLS over an arbitrary stream possible. DTLS queues whole datagrams with their
    peer address and folds the retransmit timer into every wait.
  • xp_ssl.c, one stream implementation for TLS and DTLS. The datagram schemes select the DTLS
    methods and version range; everything else (verification, SNI, ALPN, PSK, sessions, early data,
    ciphers, security level, meta data) is shared. The old gettimeofday() loops and the switching of
    the descriptor mode are gone.
  • xp_dtls.c, the server port. One UDP socket carries every peer of a server. The port receives
    the datagrams, routes them by peer address to their connection, runs the handshakes of new peers
    with a cookie exchange (SSL_OP_COOKIE_EXCHANGE, timestamped HMAC cookies, a pending cap and
    timeout) and hands the connections that completed to stream_socket_accept() as streams sharing
    the socket. It uses neither DTLSv1_listen() (DTLS 1.2 only, single peer) nor the OpenSSL 4.1
    listener API, so it works with every supported OpenSSL from 1.1.1.

Semantics

  • DTLS behaves like tls://: verify_peer_name is separate from verify_peer, a peer_fingerprint
    check comes on top of verify_peer => false, sessions are captured with session_new_cb, and
    the handshake completes inside stream_socket_accept().
  • A dtls:// client negotiates DTLS 1.2 or 1.3 (OpenSSL 4.1+). A server offers DTLS 1.3 only when
    asked with an explicit crypto_method, because outside the OpenSSL listener a DTLS 1.3 server
    cannot validate the peer address before its first flight.
  • New ssl context options: inner_stream, dtls_link_mtu, dtls_max_pending (default 256),
    dtls_pending_timeout (seconds, default 30), keying_material_label and
    keying_material_length (RFC 5705 export in stream_get_meta_data()['crypto']).
  • New constants STREAM_CRYPTO_METHOD_DTLSv1_2_*, STREAM_CRYPTO_METHOD_DTLSv1_3_* and
    STREAM_CRYPTO_METHOD_DTLS_ANY_*. The stream type of udp:// streams is udp_socket/dtls when
    the extension is loaded, as tcp:// is tcp_socket/ssl.

Tests

ext/openssl/tests/dtls_*: client against openssl s_server, PHP server and client, mutual
authentication, fingerprints, session resumption on both sides, MTU, robustness against bogus
datagrams, a server with several peers on one socket, DTLS 1.3, DTLS over udp://, a non-blocking
handshake; tls_inner_stream*.phpt for TLS over a tcp:// stream and over a user wrapper. Verified
against OpenSSL 1.1.1, 3.0 and the 4.2-dev tree (for DTLS 1.3).

Discussed on internals: https://externals.io/message/131514.

@bukka bukka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm the amount of duplication is quite big. I think this is more a good PoC but it's very far from anything that we could merge. We might need to re-architecture the whole xp_ssl for this. I will need to do more research and thinking to see what would be the best way.

Comment thread ext/openssl/xp_dtls.c Resolved
Comment thread ext/openssl/xp_dtls.c Resolved
@GianfriAur

Copy link
Copy Markdown
Author

Hmm the amount of duplication is quite big. I think this is more a good PoC but it's very far from anything that we could merge. We might need to re-architecture the whole xp_ssl for this. I will need to do more research and thinking to see what would be the best way.

Okay, do you want me to try drafting an xp_common file?

@bukka

bukka commented Jul 13, 2026

Copy link
Copy Markdown
Member

Okay, do you want me to try drafting an xp_common file?

Yeah I think it would be actually better.

@GianfriAur

Copy link
Copy Markdown
Author

Honestly I'm not a big fan of adding is_dgram to php_netstream_data_t, but I couldn't make dtls:// reuse the socket transport the clean way tls:// reuses it for tcp://.

It comes down to an asymmetry in the generic socket transport. tls:// gets the reuse for free because TCP is the default socket type there — the comment in xp_socket.c even spells it out:
/* Note: the test here for php_stream_udp_socket_ops is important, because we want the default to be TCP sockets so that the openssl extension can re-use this code. */

That said, I'm not thrilled with it, if anyone has a cleaner idea, it's very welcome.

@GianfriAur
GianfriAur force-pushed the feature/openssl-dtls-stream branch 2 times, most recently from da9a678 to 29ec86b Compare July 16, 2026 08:00
@GianfriAur

GianfriAur commented Jul 16, 2026 •

Copy link
Copy Markdown
Author

@bukka, I've sketched out an xp_common; let me know what you think and what changes you'd make.

The approach: I moved into xp_common.{c,h} only the machinery that xp_ssl and xp_dtls genuinely share as-is, fingerprint matching, peer-name resolution, the passphrase callback, local cert/key loading, the verify callback + enable/disable peer verification, the cipher metadata helper, and the
session-resumption infrastructure (callbacks struct, validate/allocate, and the SSL_CTX ex-data indices the callbacks use to reach the stream without knowing the transport's netstream type).

I deliberately kept the setup_client_session / setup_server_session orchestration per-transport rather than merging it: DTLS has no cross-connection internal cache (fresh context per accepted peer), so the cache-mode policy actually differs, and unifying it would mean re-introducing those
differences behind flags. My guiding rule was to share the reusable building blocks but not force a common abstraction over code paths that only look similar.

@bukka
bukka force-pushed the feature/openssl-dtls-stream branch from 29ec86b to d4f377d Compare October 4, 2026 11:36
@bukka

bukka commented Oct 4, 2026

Copy link
Copy Markdown
Member

I had a deeper look into this and realised that DTLSv1_listen wasn't really the way forward given that it cannot work with DTLS 1.3. Unfortunately SSL_new_listener for DTLS was just introduced in 4.1 so we couldn't properly support older version. So another solution was needed. As I have been working on IO Hooks, I needed to introduce custom BIO and planned for some rewrite of that. The fact that you also needed user wrapper integration also makes it necessary. This actually gives possibility to create a custom listener and map it to peers ourselves which is exactly what I did. There is one omission in OpenSSL as it ignores SSL_OP_COOKIE_EXCHANGE for DTLS 1.3 which I'm trying to address in openssl/openssl#33093 so will see if it gets to 4.1 as a bug fix. This does not impact client - it's just for server that needs to verify address (default). The current version of this patch will default to DTLS 1.2 for server unless crypto_method explicitly includes DTLS 1.3. We can update it once that OpenSSL is resolved. I also aligned the verify and session options with tls:// logic to make it a bit more consistent.

Comment thread ext/openssl/xp_ssl.c Resolved
@GianfriAur

Copy link
Copy Markdown
Author

@bukka First of all, thank you very much for the beautiful refactor.

That said, I quickly read through the code and pointed out the only thing I noticed at a glance.
I'm waiting for the openssl/33093 to read a bit more in depth.

I also ran some tests and realized there's a small leak issue; in one case, sharing a stream and closing one causes a segfault. I think it's due to 'inner_stream', calling it a "leak" might be a bit of an exaggeration, perhaps, but it’s certainly not the kind of behavior I would expect.

Here is a snippet of code.

$inner = fopen('php://memory', 'w+');
$ctx = stream_context_create(['ssl' => ['inner_stream' => $inner]]);
  
$a = stream_socket_client('tcp://127.0.0.1:1', $e, $es, 1, STREAM_CLIENT_CONNECT, $ctx);
var_dump($a !== false);
fwrite($a, "MY-DATA");
rewind($inner);
var_dump(stream_get_contents($inner)); // MY-DATA

$b = stream_socket_client('tcp://127.0.0.1:1', $e, $es, 1, STREAM_CLIENT_CONNECT, $ctx);
fclose($a);
var_dump(fclose($inner)); // bool(true)
fwrite($b, "after-free");  // segfault

php://memory only makes the first problem visible without a server; any inner stream behaves the same

As for the segfault, it occurs regardless of the stream, even during legitimate operations.
like:

$inner = stream_socket_client('tcp://IP:PORT', $errno, $errstr, 5);
$ctx = stream_context_create(['ssl' => [
    'inner_stream' => $inner,
    'verify_peer' => false,
    'verify_peer_name' => false,
]]);

$first = stream_socket_client('tls://IP:PORT', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $ctx);
$second = stream_socket_client('tls://IP:PORT', $errno, $errstr, 1, STREAM_CLIENT_CONNECT, $ctx);

var_dump(fclose($inner));  // bool(true)
fclose($first);            // segfault

This adds the dtls://, dtlsv1.2:// and dtlsv1.3:// stream transports (client
and server) and DTLS over udp:// through stream_socket_enable_crypto(). The
ssl context options, Openssl\Session and the stream API work for DTLS as they
do for tls://.

The implementation is split by layer so that TLS and DTLS share the option
handling and the IO loops:

- xp_bio.c is the ciphertext transport. The BIO serves records from a receive
  queue and appends to a send queue owned by the connection, and the stream
  moves ciphertext between the queues and the transport around each SSL call.
  The transport is the socket of the stream or any other stream given by the
  new inner_stream context option.
- xp_ssl.c is one stream implementation for TLS and DTLS. The datagram schemes
  select the DTLS methods and version range, everything else is shared.
- xp_dtls.c is the server port. One UDP socket carries every peer of a server.
  The port routes the datagrams by peer address, runs the handshakes of new
  peers with a cookie exchange and hands the completed connections to
  stream_socket_accept(). It uses neither DTLSv1_listen() nor the OpenSSL
  listener API so it works with all supported OpenSSL versions.

New ssl context options are inner_stream, dtls_link_mtu, dtls_max_pending,
dtls_pending_timeout, keying_material_label and keying_material_length. New
constants are STREAM_CRYPTO_METHOD_DTLSv1_2_*, STREAM_CRYPTO_METHOD_DTLSv1_3_*
and STREAM_CRYPTO_METHOD_DTLS_ANY_*.

Closes phpGH-22638

Co-authored-by: Gianfrancesco <[email protected]>
@bukka
bukka force-pushed the feature/openssl-dtls-stream branch from 69d34c0 to 5603f5b Compare November 6, 2026 12:12
@bukka

bukka commented Nov 6, 2026

Copy link
Copy Markdown
Member

Ah good find. Yeah that was a real issue. Should be fixed now. I also squashed and rebased with some other updates as that openssl/openssl#33093 got merged to 4.1.

A zero-length datagram reached openssl as a 0-byte read, which it takes as eof: anyone able to send from the peer's address could end a dtls:// connection with one. Drop them on receive, on the client connection and on the server port, as rfc9147 4.5.2 asks for invalid records.
Handshakes that completed but were not accepted were not counted in dtls_max_pending and never expired, and each peer queued up to 256 datagrams of 18 KB: a remote client could open peers from many source ports until the server ran out of memory. Count them until they are accepted and bound the receive queue by its size instead.
in macOS e FreeBSD max udp datagram size is  9216 byte
The handshake doesn't wait for an internal stream, so an operation with no data to read leaves it unfinished, and the connection returns a stream with encryption disabled: read and write operations are then performed via the cleartext internal stream. A non-asynchronous connection now fails as if the handshake failed
macOS fails to send on a socket still connected with ENOTCONN, which the transport interpreted as an error: the asynchronous TLS connection handshake failed when it was performed before the connection was complete, as in bug48182.phpt. The OpenSSL socket BIO retries, as it does here.
A peer occupied a pending slot on its first ClientHello before returning the cookie and held it until the dtls_pending_timeout: a ClientHello per slot every 30 seconds, from spoofed addresses, prevented all other clients from connecting. When the slots are full, the oldest peer that hasn't yet returned the cookie makes room for the new one.
Windows fails to execute the next recvfrom() call on a UDP socket with WSAECONNRESET after a sent datagram is returned as an ICMP port unreachable, such as a close notification to a client that has already disconnected. The socket is the port of every peer, so accept() fails for the next client. Disabled with SIO_UDP_CONNRESET, since Linux never reports this on an unconnected socket.
@GianfriAur
GianfriAur force-pushed the feature/openssl-dtls-stream branch from 858f9f2 to 17e9629 Compare November 9, 2026 17:28
@GianfriAur

Copy link
Copy Markdown
Author

Thank you so much.

This weekend I carefully read and ran some tests with various edge cases. I found a couple of things.

Today I fixed some of the things I found. They're small fixes and it would have been very complex to describe, so I simply ran the tests and the fixes. Here's a small summary of the issues and fixes:

1) An empty UDP datagram tears down a DTLS connection

If during a communication I send packets with random bytes, these are discarded, as I expect, because they could simply have been spoofed by someone who doesn't know the key, and they don't break anything. If, on the other hand, I send a completely empty packet, I expect them to be discarded for the same reason. Instead, what happens is that the connection is dropped, cutting off the legitimate client as well. This behavior is not explicit, but in my opinion it falls within RFC9147 4.5.2 and RFC6347 4.1.2.7.

Fixed in commit fab9e72

2) Unaccepted DTLS peers can exhaust the server's memory

dtls_max_pending only counted peers whose handshake was still in progress, and RX_LIMIT counted the number of datagrams in the queue, not their size. Therefore, a remote client can easily force the server to exceed its memory limit.
A practical example: a client completes the handshake on a port (anyone can do it, because with the default configuration, a client certificate isn't required) and immediately sends 256 large datagrams, which the server queues for that peer. Then it repeats on another port, and so on. Until the script accepts them, these peers don't fall within any limit, and each holds about 4.7 MB in memory. This can cause the server to run out of memory.

Fixed in commit c1f46d7 + 642ec03

3) TLS on an internal stream, the connection returns a plain text stream

During the connect, if the internal stream has no data to read (because it's non-blocking, or because it's a user wrapper over an asynchronous tunnel), the handshake stops and crypto_enable returns 0, meaning "not finished yet." The current check only considers a value < 0 to be an error, so stream_socket_client() returns a stream with encryption turned off. From there, fread() and fwrite() go through the !ssl_active branch and read and write to the internal stream in cleartext. The script believes it's using TLS, but the data travels unencrypted and without server verification.

Fixed in commit: e4f2a71

Fix: a synchronous connect fails with "Failed to enable crypto" if the handshake fails, as already happens on master with a blocking tls:// connect. With STREAM_CLIENT_ASYNC_CONNECT, nothing changes: the stream is returned and the caller completes the handshake with stream_socket_enable_crypto(). I've verified that this works even with a non-blocking internal stream.

4) Peers that do not return the cookie occupy the waiting slots

The server creates the peer at the first ClientHello, before verifying the cookie, and holds it for dtls_pending_timeout (30 s). With 256 ClientHellos every 30 seconds, even from spoofed addresses, the dtls_max_pending slots remain full and no new clients connect.

Fixed in commit: a98fee3

Fix: When the cookie is valid, the peer is marked as verified. When slots are full, the oldest unverified peer is freed instead of discarding the newest one.

segfault not resolved

There are also some problems with segfault, I'm attaching some code examples that trigger them

shared part of the corde common.php

$cert = sys_get_temp_dir() . '/bugs.pem';
$pk = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
openssl_x509_export(openssl_csr_sign(openssl_csr_new(['commonName' => 'x'], $pk), null, $pk, 1), $pem);
openssl_pkey_export($pk, $key);
file_put_contents($cert, $pem . $key);

// Runs PHP code in a child process and returns the first line it prints
function child(string $code, string ...$args): string
{   
    global $children;
    $children[] = proc_open([PHP_BINARY, '-n', '-r', $code, ...$args], [1 => ['pipe', 'w']], $pipes);
    return trim((string) fgets($pipes[1]));
}

// A TLS 1.3 server: completes the handshake, then waits without reading
function tls_server(): string
{   
    global $cert;
    return child('$s = stream_socket_server("tls://127.0.0.1:0", $e, $es,
        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, stream_context_create(["ssl" => ["local_cert" => $argv[1]]]));
        echo stream_socket_get_name($s, false), "\n"; $p = stream_socket_accept($s); sleep(5);', $cert);
}

// A DTLS client that connects to $addr
function dtls_client(string $addr): void
{
    child('echo "\n"; @stream_socket_client("dtls://" . $argv[1], $e, $es, 3, STREAM_CLIENT_CONNECT,
        stream_context_create(["ssl" => ["verify_peer" => false, "verify_peer_name" => false]]));', $addr);
}

// A user wrapper over tcp://, running Tun::$hook once inside its stream_write()
class Tun
{   
    public $context, $s;
    static $hook;
    function stream_open($path) { $this->s = stream_socket_client('tcp://' . substr($path, 6)); return true; }
    function stream_read($n) { return fread($this->s, $n); }
    function stream_write($d) { if ($h = self::$hook) { self::$hook = null; $h(); } return fwrite($this->s, $d); }
    function stream_eof() { return false; }
}
stream_wrapper_register('tun', 'Tun');

// tls:// to $addr over a tun:// inner stream
function tls_over_tun(string $addr)
{   
    global $inner;
    $inner = fopen("tun://$addr", 'r+');
    return stream_socket_client("tls://$addr", $e, $es, 5, STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => [
        'verify_peer' => false, 'verify_peer_name' => false, 'inner_stream' => $inner,
    ]]));
}

// Reuses the memory just freed, so that a use after free reads garbage instead of the old data
function reuse_freed(): void
{       
    for ($i = 0; $i < 4000; $i++) {
        $GLOBALS['junk'][] = str_repeat("\xff", $i % 2048);
    }
}

segfault 1

 <?php
require __DIR__ . '/common.php';

// The inner stream writes to the TLS stream while the TLS stream writes to it
$tls = tls_over_tun(tls_server());
Tun::$hook = fn() => fwrite($tls, str_repeat('B', 20000));
fwrite($tls, str_repeat('A', 20000)); // segfault: out-of-bounds read in php_openssl_conn_flush()

segfault 2

require __DIR__ . '/common.php';

// The inner stream closes the TLS stream while the TLS stream writes to it
$tls = tls_over_tun(tls_server());
Tun::$hook = function () use ($tls) {
    fclose($tls);
    reuse_freed();
};
fwrite($tls, 'ping'); // segfault: php_openssl_conn_flush() goes on with the freed connection

segfault 3

require __DIR__ . '/common.php';
  
// session_new_cb, run inside SSL_accept() of a DTLS peer, closes the listener
$listener = stream_socket_server('dtls://127.0.0.1:0', $e, $es, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
    stream_context_create(['ssl' => [
        'local_cert' => $cert,
        'session_id_context' => 'x',
        'session_get_cb' => fn() => null,
        'session_new_cb' => function ($stream) {
            fclose($stream);
            reuse_freed();
        },
    ]])
);
dtls_client(stream_socket_get_name($listener, false));
stream_socket_accept($listener, 5); // segfault: the port freed the peer and its SSL under SSL_accept()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants