Skip to content

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

Draft
GianfriAur wants to merge 1 commit into
php:masterfrom
GianfriAur:feature/openssl-dtls-stream
Draft

GianfriAur wants to merge 1 commit 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.

TODO before removing Draft status

  • Windows build and tests
  • UPGRADING and NEWS
  • php.net documentation
  • Coding standards pass and squash of the history

@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

@bukka
bukka force-pushed the feature/openssl-dtls-stream branch from 69d34c0 to 5603f5b Compare November 6, 2026 12:12
bukka added a commit to GianfriAur/php-src that referenced this pull request Nov 6, 2026
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 <gianfri.aur@gmail.com>
@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.

@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()

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.

User code that runs inside an SSL call (a session, PSK or early data callback,
or a user wrapper used as the inner stream) cannot close or use the stream the
call belongs to, nor the listener of a dtls:// server.

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 <gianfri.aur@gmail.com>
@bukka
bukka force-pushed the feature/openssl-dtls-stream branch from 17e9629 to 3170c1d Compare November 11, 2026 17:07
@bukka

bukka commented Nov 11, 2026

Copy link
Copy Markdown
Member

Nice good finding and the fixes also looked good. I just needed to modify that ENOTCONN handling as mapping it to EAGAIN makes a handshake on a socket that was never connected spin forever. It is now waited for once, which still covers the connect in progress. Also fixed that failing test. It's all squashed to a single commit so you can check it out again.

@GianfriAur

GianfriAur commented Nov 12, 2026 •

Copy link
Copy Markdown
Author

Hi, little new discovery

On a dtls:// server, fread() on the listener reads straight from the UDP socket shared with the peers, so it takes datagrams that belong to the port. With tls:// is rejected.

foreach (['tls', 'dtls'] as $scheme) {
    $server = stream_socket_server("$scheme://127.0.0.1:0", $e, $es, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);

    // a client in another process connects, then sends "ping"

    $peer = stream_socket_accept($server, 5);

    // stream_set_timeout($server, 1); // skip timeout
    // stream_set_timeout($peer, 1); // skip timeout

    var_dump(strlen((string) fread($server, 2048)), fread($peer, 2048));
}   
      
// tls://   int(0)  string(4) "ping"
// dtls://  int(41) bool(false)

The listener gets the encrypted "ping" and the accepted peer gets nothing. Before accept() the listener takes the ClientHello the same way; the handshake only completes because the client retransmits it. fwrite() and stream_socket_sendto() on the listener also reach the socket.

It only happens if the server code reads from the listener, for example a loop that calls fread() on every stream stream_select() returns. No crash or memory issue.

Obviously I realize that it is not a real problem and that it is only caused by an unwary dev, but I believe that a behavior different from tls:// and a rather subtle and difficult to debug error should be known.

I tried to fix it by making read/write and recvfrom/sendto fail in xp_ssl.when sslsock->port != NULL and the stream has no active crypto. That stops the datagrams being taken, but it introduces another problem: feof() on the listener stays false and every fread() now returns immediately, so a loop like while (!feof($server)) fread($server, ...) spins at 100% CPU. On a tls:// listener the same loop waits for the stream timeout on every read. obviously it wasn't a good fix and I didn't go any further because no capillary change would have come out.

What do you think? Is this something that should be addressed?

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