Commit f465d35334b for php.net
commit f465d35334bbf61e709e67a346f371fd5c6f2de5
Author: Jakub Zelenka <bukka@php.net>
Date: Sat Jul 25 23:56:15 2026 +0200
Add OpenSSL TLS 1.3 early data (0-RTT) support (#22802)
This builds on the session resumption support to let clients send and
servers accept TLS 1.3 early data through the stream SSL context.
Clients set the early_data context option (a string) to send 0-RTT data,
which is only sent when the resumed session allows it. Servers enable
acceptance with max_early_data and receive the data through the
early_data_cb callback, so nothing is buffered on the stream. The outcome
is reported as 'accepted', 'rejected' or 'not_sent' in the early_data key
of the crypto stream_get_meta_data() array.
Enabling early data also shares the server SSL_CTX across accepted
connections and keeps the session cache on, both of which 0-RTT
resumption requires.
diff --git a/NEWS b/NEWS
index 484e462698d..dfcbac91519 100644
--- a/NEWS
+++ b/NEWS
@@ -366,6 +366,8 @@ PHP NEWS
and Openssl\Session class. (Jakub Zelenka)
. Added TLS external PSK support for streams with new context options and
Openssl\Psk class. (Jakub Zelenka)
+ . Added TLS 1.3 early data (0-RTT) support for streams with new context
+ options early_data, max_early_data and early_data_cb. (Jakub Zelenka)
. Added stream crypto status for exposing OpenSSL WANT_READ / WANT_WRITE.
(Jakub Zelenka)
diff --git a/UPGRADING b/UPGRADING
index 93245f87f42..34817c10974 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -299,6 +299,11 @@ PHP 8.6 UPGRADE NOTES
RFC: https://wiki.php.net/rfc/tls_session_resumption
. Added TLS external PSK support for streams with new stream context options:
psk_client_cb and psk_server_cb. This allows setting and receiving PSK.
+ . Added TLS 1.3 early data (0-RTT) support for streams. Clients send early
+ data with the early_data context option; servers accept it with
+ max_early_data and receive it through the early_data_cb callback. The
+ outcome is reported as 'accepted', 'rejected' or 'not_sent' in the
+ early_data key of the crypto stream_get_meta_data() array.
- Phar:
. Overriding the getMTime() and getPathname() methods of SplFileInfo now
diff --git a/ext/openssl/tests/tls_early_data_basic.phpt b/ext/openssl/tests/tls_early_data_basic.phpt
new file mode 100644
index 00000000000..9e469119270
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_basic.phpt
@@ -0,0 +1,89 @@
+--TEST--
+TLS 1.3 early data (0-RTT) basic client/server round-trip
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_basic.pem.tmp';
+
+$serverCode = <<<'CODE'
+ $early = '';
+ $ctx = stream_context_create(['ssl' => [
+ 'local_cert' => '%s',
+ 'session_id_context' => 'early-data-test',
+ 'max_early_data' => 16384,
+ 'early_data_cb' => function ($stream, string $data) use (&$early) {
+ $early .= $data;
+ },
+ ]]);
+
+ $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+ STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+ phpt_notify_server_start($server);
+
+ for ($i = 0; $i < 2; $i++) {
+ $early = '';
+ $client = @stream_socket_accept($server, 30);
+ if ($client) {
+ $meta = stream_get_meta_data($client);
+ $status = $meta['crypto']['early_data'] ?? 'none';
+ fwrite($client, "early=[$early] status=$status\n");
+ fclose($client);
+ }
+ }
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+ $sessionData = null;
+
+ $ctx = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'session_new_cb' => function ($stream, $session) use (&$sessionData) {
+ $sessionData = $session;
+ },
+ ]]);
+
+ /* First connection: full handshake, capture the resumable session */
+ $client1 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+ 30, STREAM_CLIENT_CONNECT, $ctx);
+ echo trim(fgets($client1)) . "\n";
+ fclose($client1);
+
+ /* Second connection: resume and send 0-RTT early data */
+ $ctx2 = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'session_data' => $sessionData,
+ 'early_data' => 'ping-0rtt',
+ ]]);
+
+ $client2 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+ 30, STREAM_CLIENT_CONNECT, $ctx2);
+ echo trim(fgets($client2)) . "\n";
+ $meta2 = stream_get_meta_data($client2);
+ echo "client early_data: " . ($meta2['crypto']['early_data'] ?? 'none') . "\n";
+ fclose($client2);
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_basic', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_basic.pem.tmp');
+?>
+--EXPECT--
+early=[] status=not_sent
+early=[ping-0rtt] status=accepted
+client early_data: accepted
diff --git a/ext/openssl/tests/tls_early_data_invalid.phpt b/ext/openssl/tests/tls_early_data_invalid.phpt
new file mode 100644
index 00000000000..2989f5481ce
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_invalid.phpt
@@ -0,0 +1,50 @@
+--TEST--
+TLS 1.3 early data with an invalid early_data option raises TypeError
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_invalid.pem.tmp';
+
+$serverCode = <<<'CODE'
+ $ctx = stream_context_create(['ssl' => ['local_cert' => '%s']]);
+ $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+ STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+ phpt_notify_server_start($server);
+ @stream_socket_accept($server, 3);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+ $ctx = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'early_data' => 123,
+ ]]);
+ try {
+ @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+ 5, STREAM_CLIENT_CONNECT, $ctx);
+ echo "no exception\n";
+ } catch (TypeError $e) {
+ echo "caught: ", $e->getMessage(), "\n";
+ }
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_invalid', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_invalid.pem.tmp');
+?>
+--EXPECT--
+caught: early_data must be a string
diff --git a/ext/openssl/tests/tls_early_data_large.phpt b/ext/openssl/tests/tls_early_data_large.phpt
new file mode 100644
index 00000000000..15940f866d7
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_large.phpt
@@ -0,0 +1,80 @@
+--TEST--
+TLS 1.3 early data (0-RTT) larger than one read chunk
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_large.pem.tmp';
+
+$serverCode = <<<'CODE'
+ $early = '';
+ $ctx = stream_context_create(['ssl' => [
+ 'local_cert' => '%s',
+ 'session_id_context' => 'early-data-large',
+ 'max_early_data' => 65536,
+ 'early_data_cb' => function ($stream, string $data) use (&$early) {
+ $early .= $data;
+ },
+ ]]);
+ $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+ STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+ phpt_notify_server_start($server);
+ for ($i = 0; $i < 2; $i++) {
+ $early = '';
+ $client = @stream_socket_accept($server, 30);
+ if ($client) {
+ $meta = stream_get_meta_data($client);
+ $status = $meta['crypto']['early_data'] ?? 'none';
+ fwrite($client, "len=" . strlen($early) . " ok=" .
+ (int) ($early === str_repeat('A', 40000)) . " status=$status\n");
+ fclose($client);
+ }
+ }
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+ $sessionData = null;
+ $ctx = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'session_new_cb' => function ($stream, $session) use (&$sessionData) {
+ $sessionData = $session;
+ },
+ ]]);
+ $client1 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+ 30, STREAM_CLIENT_CONNECT, $ctx);
+ echo trim(fgets($client1)) . "\n";
+ fclose($client1);
+
+ $ctx2 = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'session_data' => $sessionData,
+ 'early_data' => str_repeat('A', 40000),
+ ]]);
+ $client2 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+ 30, STREAM_CLIENT_CONNECT, $ctx2);
+ echo trim(fgets($client2)) . "\n";
+ fclose($client2);
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_large', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_large.pem.tmp');
+?>
+--EXPECT--
+len=0 ok=0 status=not_sent
+len=40000 ok=1 status=accepted
diff --git a/ext/openssl/tests/tls_early_data_unsupported.phpt b/ext/openssl/tests/tls_early_data_unsupported.phpt
new file mode 100644
index 00000000000..8d121dc9f48
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_unsupported.phpt
@@ -0,0 +1,79 @@
+--TEST--
+TLS 1.3 early data option is ignored when the session does not allow 0-RTT
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_unsupported.pem.tmp';
+
+/* Server does NOT enable early data, so a resumed session must not carry a
+ * 0-RTT limit and the client must fall back to a normal handshake. */
+$serverCode = <<<'CODE'
+ $ctx = stream_context_create(['ssl' => [
+ 'local_cert' => '%s',
+ 'session_cache' => true,
+ 'session_id_context' => 'early-data-off',
+ ]]);
+ $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+ STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+ phpt_notify_server_start($server);
+ for ($i = 0; $i < 2; $i++) {
+ $client = @stream_socket_accept($server, 30);
+ if ($client) {
+ fwrite($client, "hello\n");
+ fclose($client);
+ }
+ }
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+ $sessionData = null;
+ $ctx = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'session_new_cb' => function ($stream, $session) use (&$sessionData) {
+ $sessionData = $session;
+ },
+ ]]);
+ $client1 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+ 30, STREAM_CLIENT_CONNECT, $ctx);
+ echo trim(fgets($client1)) . "\n";
+ fclose($client1);
+
+ $ctx2 = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'session_data' => $sessionData,
+ 'early_data' => 'ping-0rtt',
+ ]]);
+ $client2 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+ 30, STREAM_CLIENT_CONNECT, $ctx2);
+ echo trim(fgets($client2)) . "\n";
+ $meta2 = stream_get_meta_data($client2);
+ echo "reused: " . ($meta2['crypto']['session_reused'] ? 'yes' : 'no') . "\n";
+ echo "early_data key present: " . (array_key_exists('early_data', $meta2['crypto']) ? 'yes' : 'no') . "\n";
+ fclose($client2);
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_unsupported', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_unsupported.pem.tmp');
+?>
+--EXPECT--
+hello
+hello
+reused: yes
+early_data key present: no
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index f2adfc49075..a8105a15c43 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -188,6 +188,24 @@ typedef struct _php_openssl_psk_callbacks_t {
zend_fcall_info_cache server_cb;
} php_openssl_psk_callbacks_t;
+#ifdef HAVE_TLS13
+/* TLS 1.3 early data (0-RTT) handshake phase */
+typedef enum {
+ PHP_OPENSSL_EARLY_DATA_NONE = 0,
+ PHP_OPENSSL_EARLY_DATA_ACTIVE,
+ PHP_OPENSSL_EARLY_DATA_DONE,
+} php_openssl_early_data_state_t;
+
+/* Size of the buffer used to drain server-side early data chunk by chunk */
+#define PHP_OPENSSL_EARLY_DATA_CHUNK 16384
+
+/* Holds the server early data callback */
+typedef struct _php_openssl_early_data_callbacks_t {
+ int refcount;
+ zend_fcall_info_cache read_cb;
+} php_openssl_early_data_callbacks_t;
+#endif
+
/* Holds session callback */
typedef struct _php_openssl_session_callbacks_t {
int refcount;
@@ -222,6 +240,14 @@ typedef struct _php_openssl_netstream_data_t {
* psk_use_session_cb call but OpenSSL doesn't free it, so we own it. */
unsigned char *psk_identity_buf;
size_t psk_identity_len;
+#ifdef HAVE_TLS13
+ /* TLS 1.3 early data (0-RTT) */
+ php_openssl_early_data_callbacks_t *early_data_callbacks;
+ /* Client payload to send as early data, borrowed for the handshake */
+ zend_string *early_data_send;
+ size_t early_data_offset;
+ php_openssl_early_data_state_t early_data_state;
+#endif
char *url_name;
unsigned state_set:1;
unsigned _spare:31;
@@ -1657,6 +1683,14 @@ static SSL_SESSION *php_openssl_psk_build_session(SSL *ssl,
return NULL;
}
+#ifdef HAVE_TLS13
+ /* Allow 0-RTT for PSK sessions (0 on the client, so a no-op there) */
+ uint32_t max_early_data = SSL_get_max_early_data(ssl);
+ if (max_early_data > 0) {
+ SSL_SESSION_set_max_early_data(sess, max_early_data);
+ }
+#endif
+
return sess;
}
@@ -2029,6 +2063,100 @@ static zend_result php_openssl_setup_server_psk(php_stream *stream,
return SUCCESS;
}
+#ifdef HAVE_TLS13
+/* Stash the client early data (0-RTT) payload to be sent during the handshake. */
+static zend_result php_openssl_setup_client_early_data(php_stream *stream,
+ php_openssl_netstream_data_t *sslsock)
+{
+ zval *val;
+
+ if (!GET_VER_OPT("early_data")) {
+ return SUCCESS;
+ }
+
+ if (Z_TYPE_P(val) != IS_STRING) {
+ zend_type_error("early_data must be a string");
+ return FAILURE;
+ }
+
+ if (Z_STRLEN_P(val) > 0) {
+ sslsock->early_data_send = zend_string_dup(Z_STR_P(val), php_stream_is_persistent(stream));
+ }
+
+ return SUCCESS;
+}
+
+/* Configure server acceptance of early data (0-RTT) and the receive callback. */
+static zend_result php_openssl_setup_server_early_data(php_stream *stream,
+ php_openssl_netstream_data_t *sslsock)
+{
+ zval *val;
+ zend_long max_early_data = 0;
+
+ if (GET_VER_OPT("max_early_data")) {
+ max_early_data = zval_get_long(val);
+ if (max_early_data < 0 || (uint64_t) max_early_data > UINT32_MAX) {
+ zend_value_error("max_early_data must be between 0 and %u", UINT32_MAX);
+ return FAILURE;
+ }
+ }
+
+ if (GET_VER_OPT("early_data_cb")) {
+ if (php_stream_is_persistent(stream)) {
+ php_stream_warn(stream, PersistentNotSupported,
+ "early_data_cb is not supported for persistent streams");
+ return FAILURE;
+ }
+
+ char *is_callable_error = NULL;
+ zend_fcall_info_cache fcc = {0};
+ if (!zend_is_callable_ex(val, NULL, 0, NULL, &fcc, &is_callable_error)) {
+ if (is_callable_error) {
+ zend_type_error("early_data_cb must be a valid callback, %s", is_callable_error);
+ efree(is_callable_error);
+ } else {
+ zend_type_error("early_data_cb must be a valid callback");
+ }
+ return FAILURE;
+ }
+
+ sslsock->early_data_callbacks = pecalloc(1, sizeof(php_openssl_early_data_callbacks_t), 0);
+ sslsock->early_data_callbacks->refcount = 1;
+ zend_fcc_addref(&fcc);
+ sslsock->early_data_callbacks->read_cb = fcc;
+
+ /* A callback without an explicit limit still enables 0-RTT */
+ if (max_early_data == 0) {
+ max_early_data = PHP_OPENSSL_EARLY_DATA_CHUNK;
+ }
+ }
+
+ if (max_early_data > 0) {
+ SSL_CTX_set_max_early_data(sslsock->ctx, (uint32_t) max_early_data);
+ /* Accept as much as we advertise (recv limit defaults to 16 KB otherwise) */
+ SSL_CTX_set_recv_max_early_data(sslsock->ctx, (uint32_t) max_early_data);
+ /* 0-RTT requires resumption, so ensure the server cache is not left off */
+ if (SSL_CTX_get_session_cache_mode(sslsock->ctx) == SSL_SESS_CACHE_OFF) {
+ SSL_CTX_set_session_cache_mode(sslsock->ctx, SSL_SESS_CACHE_SERVER);
+ }
+ }
+
+ return SUCCESS;
+}
+
+/* Whether the server context enables early data (0-RTT). */
+static bool php_openssl_is_server_early_data_enabled(php_stream *stream)
+{
+ zval *val;
+
+ if (GET_VER_OPT("early_data_cb")) {
+ return true;
+ }
+
+ return GET_VER_OPT("max_early_data") && zval_get_long(val) > 0;
+}
+#endif
+
/**
* OpenSSL new session callback - called when a new session is established
*/
@@ -2558,6 +2686,11 @@ static zend_result php_openssl_create_server_ctx(php_stream *stream,
if (FAILURE == php_openssl_setup_client_psk(stream, sslsock)) {
return FAILURE;
}
+#ifdef HAVE_TLS13
+ if (FAILURE == php_openssl_setup_client_early_data(stream, sslsock)) {
+ return FAILURE;
+ }
+#endif
} else if (PHP_STREAM_CONTEXT(stream)) {
if (FAILURE == php_openssl_setup_server_session(stream, sslsock)) {
return FAILURE;
@@ -2565,6 +2698,11 @@ static zend_result php_openssl_create_server_ctx(php_stream *stream,
if (FAILURE == php_openssl_setup_server_psk(stream, sslsock)) {
return FAILURE;
}
+#ifdef HAVE_TLS13
+ if (FAILURE == php_openssl_setup_server_early_data(stream, sslsock)) {
+ return FAILURE;
+ }
+#endif
if (FAILURE == php_openssl_set_server_specific_opts(stream, sslsock->ctx)) {
return FAILURE;
}
@@ -2621,6 +2759,12 @@ static zend_result php_openssl_setup_crypto(php_stream *stream,
parent_sslsock->psk_callbacks->refcount++;
sslsock->psk_callbacks = parent_sslsock->psk_callbacks;
}
+#ifdef HAVE_TLS13
+ if (parent_sslsock->early_data_callbacks) {
+ parent_sslsock->early_data_callbacks->refcount++;
+ sslsock->early_data_callbacks = parent_sslsock->early_data_callbacks;
+ }
+#endif
sslsock->ssl_handle = SSL_new(sslsock->ctx);
if (!sslsock->ssl_handle) {
@@ -2739,6 +2883,80 @@ static zend_result php_openssl_set_blocking(php_openssl_netstream_data_t *sslsoc
return result;
}
+#ifdef HAVE_TLS13
+/* Send pending client early data (0-RTT), then connect. Returns like SSL_connect(). */
+static int php_openssl_handshake_client_early_data(php_openssl_netstream_data_t *sslsock)
+{
+ while (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_ACTIVE) {
+ size_t written = 0;
+ int ret = SSL_write_early_data(sslsock->ssl_handle,
+ ZSTR_VAL(sslsock->early_data_send) + sslsock->early_data_offset,
+ ZSTR_LEN(sslsock->early_data_send) - sslsock->early_data_offset,
+ &written);
+ if (ret <= 0) {
+ return ret;
+ }
+ sslsock->early_data_offset += written;
+ if (sslsock->early_data_offset >= ZSTR_LEN(sslsock->early_data_send)) {
+ sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_DONE;
+ }
+ }
+
+ return SSL_connect(sslsock->ssl_handle);
+}
+
+/* Receive server early data (0-RTT) chunk by chunk, then accept. Returns like SSL_accept(). */
+static int php_openssl_handshake_server_early_data(php_stream *stream,
+ php_openssl_netstream_data_t *sslsock)
+{
+ unsigned char buf[PHP_OPENSSL_EARLY_DATA_CHUNK];
+
+ while (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_ACTIVE) {
+ size_t readbytes = 0;
+ int ret = SSL_read_early_data(sslsock->ssl_handle, buf, sizeof(buf), &readbytes);
+
+ if (ret == SSL_READ_EARLY_DATA_ERROR) {
+ /* Caller inspects SSL_get_error() for WANT_READ/WRITE or error */
+ return 0;
+ }
+
+ if (ret == SSL_READ_EARLY_DATA_SUCCESS) {
+ if (readbytes > 0 && sslsock->early_data_callbacks
+ && ZEND_FCC_INITIALIZED(sslsock->early_data_callbacks->read_cb)) {
+ zval args[2];
+ ZVAL_RES(&args[0], stream->res);
+ ZVAL_STRINGL(&args[1], (char *) buf, readbytes);
+ zend_call_known_fcc(&sslsock->early_data_callbacks->read_cb,
+ NULL, 2, args, NULL);
+ zval_ptr_dtor(&args[1]);
+ }
+ continue;
+ }
+
+ /* SSL_READ_EARLY_DATA_FINISH */
+ sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_DONE;
+ }
+
+ return SSL_accept(sslsock->ssl_handle);
+}
+#endif
+
+/* Perform one handshake step, processing early data (0-RTT) first when active. */
+static int php_openssl_do_handshake(php_stream *stream, php_openssl_netstream_data_t *sslsock)
+{
+#ifdef HAVE_TLS13
+ if (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_ACTIVE) {
+ return sslsock->is_client
+ ? php_openssl_handshake_client_early_data(sslsock)
+ : php_openssl_handshake_server_early_data(stream, sslsock);
+ }
+#endif
+
+ return sslsock->is_client
+ ? SSL_connect(sslsock->ssl_handle)
+ : SSL_accept(sslsock->ssl_handle);
+}
+
static int php_openssl_enable_crypto(php_stream *stream,
php_openssl_netstream_data_t *sslsock,
php_stream_xport_crypto_param *cparam) /* {{{ */
@@ -2769,9 +2987,23 @@ static int php_openssl_enable_crypto(php_stream *stream,
php_openssl_enable_client_sni(stream, sslsock);
#endif
SSL_set_connect_state(sslsock->ssl_handle);
+#ifdef HAVE_TLS13
+ /* Only send early data if the resumed session allows it */
+ if (sslsock->early_data_send != NULL) {
+ SSL_SESSION *sess = SSL_get_session(sslsock->ssl_handle);
+ if (sess != NULL && SSL_SESSION_get_max_early_data(sess) > 0) {
+ sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_ACTIVE;
+ }
+ }
+#endif
} else {
php_openssl_init_server_reneg_limit(stream, sslsock);
SSL_set_accept_state(sslsock->ssl_handle);
+#ifdef HAVE_TLS13
+ if (SSL_get_max_early_data(sslsock->ssl_handle) > 0) {
+ sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_ACTIVE;
+ }
+#endif
}
sslsock->state_set = 1;
}
@@ -2795,11 +3027,7 @@ static int php_openssl_enable_crypto(php_stream *stream,
struct timeval cur_time, elapsed_time;
ERR_clear_error();
- if (sslsock->is_client) {
- n = SSL_connect(sslsock->ssl_handle);
- } else {
- n = SSL_accept(sslsock->ssl_handle);
- }
+ n = php_openssl_do_handshake(stream, sslsock);
if (has_timeout) {
gettimeofday(&cur_time, NULL);
@@ -3198,6 +3426,19 @@ static int php_openssl_sockop_close(php_stream *stream, int close_handle) /* {{{
efree(sslsock->psk_identity_buf);
}
+#ifdef HAVE_TLS13
+ if (sslsock->early_data_callbacks && --sslsock->early_data_callbacks->refcount == 0) {
+ if (ZEND_FCC_INITIALIZED(sslsock->early_data_callbacks->read_cb)) {
+ zend_fcc_dtor(&sslsock->early_data_callbacks->read_cb);
+ }
+ pefree(sslsock->early_data_callbacks, php_stream_is_persistent(stream));
+ }
+
+ if (sslsock->early_data_send) {
+ zend_string_release(sslsock->early_data_send);
+ }
+#endif
+
pefree(sslsock, php_stream_is_persistent(stream));
return 0;
@@ -3335,6 +3576,25 @@ static int php_openssl_sockop_set_option(php_stream *stream, int option, int val
add_assoc_string(&tmp, "cipher_version", SSL_CIPHER_get_version(cipher));
add_assoc_bool(&tmp, "session_reused", SSL_session_reused(sslsock->ssl_handle));
+#ifdef HAVE_TLS13
+ /* Report early data (0-RTT) outcome only when it was attempted */
+ if (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_DONE) {
+ const char *early_data_status;
+ switch (SSL_get_early_data_status(sslsock->ssl_handle)) {
+ case SSL_EARLY_DATA_ACCEPTED:
+ early_data_status = "accepted";
+ break;
+ case SSL_EARLY_DATA_REJECTED:
+ early_data_status = "rejected";
+ break;
+ default:
+ early_data_status = "not_sent";
+ break;
+ }
+ add_assoc_string(&tmp, "early_data", early_data_status);
+ }
+#endif
+
#ifdef HAVE_TLS_ALPN
{
const unsigned char *alpn_proto = NULL;
@@ -3558,8 +3818,12 @@ static int php_openssl_sockop_set_option(php_stream *stream, int option, int val
stream, option, value, ptrparam);
if (xparam->outputs.returncode == 0 && sslsock->enable_on_connect) {
- /* Check if we should create SSL_CTX early for session resumption */
- if (php_openssl_is_session_cache_enabled(stream, false)) {
+ /* Create SSL_CTX early to share it for session resumption and early data */
+ if (php_openssl_is_session_cache_enabled(stream, false)
+#ifdef HAVE_TLS13
+ || php_openssl_is_server_early_data_enabled(stream)
+#endif
+ ) {
if (FAILURE == php_openssl_create_server_ctx(stream, sslsock, sslsock->method)) {
xparam->outputs.returncode = -1;
}