Commit 88161995562 for php.net

commit 881619955620811c57ea31a9fd34cb04467d065a
Author: Jakub Zelenka <bukka@php.net>
Date:   Thu Jun 4 19:02:11 2026 +0200

    Add stream crypto status for exposing OSSL WANT_READ / WANT_WRITE (#22193)

    On a non-blocking stream, stream_socket_enable_crypto() returns 0 and
    fread()/fwrite() return an empty result when the TLS engine needs more
    I/O, but there was no way to tell whether OpenSSL was waiting to read or
    to write. Callers therefore could not reliably decide which direction to
    poll for with stream_select(), which is required to drive a non-blocking
    handshake or renegotiation correctly (e.g. SSL_read() wanting a write).

    This tracks the last SSL_ERROR_WANT_READ/WANT_WRITE on the stream and
    exposes it via a new stream_socket_get_crypto_status() function and three
    constants:

      STREAM_CRYPTO_STATUS_NONE
      STREAM_CRYPTO_STATUS_WANT_READ
      STREAM_CRYPTO_STATUS_WANT_WRITE

    The status is updated during the handshake (php_openssl_enable_crypto())
    and during reads/writes (php_openssl_sockop_io()), reset to NONE before
    each operation, and retrieved through a new STREAM_XPORT_CRYPTO_OP_GET_STATUS
    transport op. It is meaningful immediately after an operation that
    returned 0/false on a non-blocking stream; a completed operation reports
    NONE.

    Tests cover the status during a non-blocking handshake, a non-blocking
    read with no application data pending, and the constant values.

diff --git a/NEWS b/NEWS
index 8e5da57950a..496b61ef81b 100644
--- a/NEWS
+++ b/NEWS
@@ -113,6 +113,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 stream crypto status for exposing OpenSSL WANT_READ / WANT_WRITE.
+    (Jakub Zelenka)

 - PCNTL:
   . pcntl_exec() now throws a ValueError if the $args array is not a list
diff --git a/UPGRADING b/UPGRADING
index 35f8276adc3..679fc0b552d 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -323,6 +323,7 @@ PHP 8.6 UPGRADE NOTES
     RFC: https://wiki.php.net/rfc/clamp_v2
   . `stream_last_errors()` and `stream_clear_errors()`.
     RFC: https://wiki.php.net/rfc/stream_errors
+  . stream_socket_get_crypto_status()

 - Zip:
   . Added ZipArchive::openString() method.
@@ -389,6 +390,9 @@ PHP 8.6 UPGRADE NOTES

 - Standard
   . ARRAY_FILTER_USE_VALUE.
+  . STREAM_CRYPTO_STATUS_NONE
+  . STREAM_CRYPTO_STATUS_WANT_READ
+  . STREAM_CRYPTO_STATUS_WANT_WRITE

 ========================================
 11. Changes to INI File Handling
diff --git a/ext/openssl/tests/stream_socket_get_crypto_status_basic.phpt b/ext/openssl/tests/stream_socket_get_crypto_status_basic.phpt
new file mode 100644
index 00000000000..b659eee79e7
--- /dev/null
+++ b/ext/openssl/tests/stream_socket_get_crypto_status_basic.phpt
@@ -0,0 +1,22 @@
+--TEST--
+stream_socket_get_crypto_status(): constants and behavior on a non-crypto stream
+--EXTENSIONS--
+openssl
+--FILE--
+<?php
+/* The status constants. */
+var_dump(STREAM_CRYPTO_STATUS_NONE);
+var_dump(STREAM_CRYPTO_STATUS_WANT_READ);
+var_dump(STREAM_CRYPTO_STATUS_WANT_WRITE);
+
+/* A plain (non-SSL) socket has no pending crypto operation, so the status
+ * is STREAM_CRYPTO_STATUS_NONE. */
+$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
+var_dump(@stream_socket_get_crypto_status($server) === STREAM_CRYPTO_STATUS_NONE);
+fclose($server);
+?>
+--EXPECT--
+int(0)
+int(1)
+int(2)
+bool(true)
diff --git a/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt b/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt
new file mode 100644
index 00000000000..2a1c554a79d
--- /dev/null
+++ b/ext/openssl/tests/stream_socket_get_crypto_status_handshake.phpt
@@ -0,0 +1,97 @@
+--TEST--
+stream_socket_get_crypto_status(): reports WANT_READ/WANT_WRITE during a non-blocking handshake
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'crypto_status_handshake.pem.tmp';
+$peerName = 'crypto-status-handshake';
+
+/* Plain blocking TLS server. */
+$serverCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => ['local_cert' => '%s']]);
+    $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN;
+    $server = stream_socket_server("tls://127.0.0.1:0", $errno, $errstr, $flags, $ctx);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server, 30);
+    if ($conn) {
+        fwrite($conn, "ok\n");
+        phpt_wait();
+        fclose($conn);
+    }
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+/* Client connects over plain TCP, then completes the TLS handshake in non-blocking mode, using
+ *  the reported crypto status to select the right direction to wait on. */
+$clientCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'peer_name' => '%s',
+    ]]);
+
+    $client = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $ctx);
+    stream_set_blocking($client, false);
+
+    $sawWant = false;
+    $pendingAlwaysWant = true;
+
+    do {
+        $r = stream_socket_enable_crypto($client, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
+        if ($r === 0) {
+            $status = stream_socket_get_crypto_status($client);
+            if ($status === STREAM_CRYPTO_STATUS_WANT_READ
+                    || $status === STREAM_CRYPTO_STATUS_WANT_WRITE) {
+                $sawWant = true;
+            } else {
+                /* must never be NONE while the handshake is still pending */
+                $pendingAlwaysWant = false;
+            }
+
+            /* Wait on the direction the engine actually asked for. */
+            $read = $write = $except = null;
+            if ($status === STREAM_CRYPTO_STATUS_WANT_WRITE) {
+                $write = [$client];
+            } else {
+                $read = [$client];
+            }
+            stream_select($read, $write, $except, 1);
+        }
+    } while ($r === 0);
+
+    var_dump($r);
+    var_dump($sawWant);
+    var_dump($pendingAlwaysWant);
+    /* After a completed handshake the status is reset to NONE. */
+    var_dump(stream_socket_get_crypto_status($client) === STREAM_CRYPTO_STATUS_NONE);
+
+    stream_set_blocking($client, true);
+    echo trim(fgets($client)), "\n";
+    phpt_notify();
+    fclose($client);
+CODE;
+$clientCode = sprintf($clientCode, $peerName);
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey($peerName, $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'crypto_status_handshake.pem.tmp');
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+ok
diff --git a/ext/openssl/tests/stream_socket_get_crypto_status_read.phpt b/ext/openssl/tests/stream_socket_get_crypto_status_read.phpt
new file mode 100644
index 00000000000..308950a8ef0
--- /dev/null
+++ b/ext/openssl/tests/stream_socket_get_crypto_status_read.phpt
@@ -0,0 +1,95 @@
+--TEST--
+stream_socket_get_crypto_status(): reports WANT_READ on a non-blocking read with no application data
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'crypto_status_read.pem.tmp';
+$peerName = 'crypto-status-read';
+
+$serverCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => ['local_cert' => '%s']]);
+    $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN;
+    $server = stream_socket_server("tls://127.0.0.1:0", $errno, $errstr, $flags, $ctx);
+    phpt_notify_server_start($server);
+
+    $conn = stream_socket_accept($server, 30);
+
+    /* Do not send anything until the client has performed its first read, so that the read is
+     * guaranteed to find no application data. */
+    phpt_wait();
+    fwrite($conn, "hello\n");
+
+    phpt_wait();
+    fclose($conn);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'peer_name' => '%s',
+    ]]);
+
+    $client = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $ctx);
+    stream_set_blocking($client, false);
+
+    /* No application data has been sent yet - a non-blocking read returns nothing and the crypto
+     * status reflects that the OpenSSL wants to read. */
+    $data = fread($client, 100);
+    var_dump($data === '' || $data === false);
+    var_dump(stream_socket_get_crypto_status($client) === STREAM_CRYPTO_STATUS_WANT_READ);
+
+    /* Now let the server send. */
+    phpt_notify();
+
+    $buf = '';
+    $read = [$client];
+    $write = $except = null;
+    while (stream_select($read, $write, $except, 5)) {
+        $chunk = fread($client, 100);
+        if ($chunk === '' || $chunk === false) {
+            /* A non-application record (e.g. a TLS 1.3 session ticket) may arrive first. */
+            if (feof($client)) {
+                break;
+            }
+        } else {
+            $buf .= $chunk;
+            if (strlen($buf) >= 6) {
+                break;
+            }
+        }
+        $read = [$client];
+        $write = $except = null;
+    }
+
+    echo trim($buf), "\n";
+    /* A successful read clears the pending status back to NONE. */
+    var_dump(stream_socket_get_crypto_status($client) === STREAM_CRYPTO_STATUS_NONE);
+
+    phpt_notify();
+    fclose($client);
+CODE;
+$clientCode = sprintf($clientCode, $peerName);
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey($peerName, $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'crypto_status_read.pem.tmp');
+?>
+--EXPECT--
+bool(true)
+bool(true)
+hello
+bool(true)
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index 9486513fab7..a154aa4572f 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -207,6 +207,7 @@ typedef struct _php_openssl_netstream_data_t {
 	int enable_on_connect;
 	int is_client;
 	int ssl_active;
+	int last_status;
 	php_stream_xport_crypt_method_t method;
 	php_openssl_handshake_bucket_t *reneg;
 	php_openssl_sni_cert_t *sni_certs;
@@ -271,6 +272,10 @@ static int php_openssl_handle_ssl_error(php_stream *stream, int nr_bytes, bool i
 			 * packets: retry in next iteration */
 			errno = EAGAIN;
 			retry = is_init ? true : sslsock->s.is_blocked;
+			if (!retry) {
+				sslsock->last_status = err == SSL_ERROR_WANT_READ ?
+						STREAM_CRYPTO_STATUS_WANT_READ : STREAM_CRYPTO_STATUS_WANT_WRITE;
+			}
 			break;
 		case SSL_ERROR_SYSCALL:
 			if (ERR_peek_error() == 0) {
@@ -2701,6 +2706,8 @@ static int php_openssl_enable_crypto(php_stream *stream,
 	int cert_captured = 0;
 	X509 *peer_cert;

+	sslsock->last_status = STREAM_CRYPTO_STATUS_NONE;
+
 	if (cparam->inputs.activate && !sslsock->ssl_active) {
 		struct timeval start_time, *timeout;
 		bool blocked = sslsock->s.is_blocked, has_timeout = false;
@@ -2900,6 +2907,7 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si

 			/* Now, do the IO operation. Don't block if we can't complete... */
 			ERR_clear_error();
+			sslsock->last_status = STREAM_CRYPTO_STATUS_NONE;
 			if (read) {
 				nr_bytes = SSL_read(sslsock->ssl_handle, buf, (int)count);

@@ -2974,6 +2982,10 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si
 						php_pollfd_for(sslsock->s.socket, (err == SSL_ERROR_WANT_READ) ?
 							(POLLIN|POLLPRI) : (POLLOUT|POLLPRI), has_timeout ? &left_time : NULL);
 					}
+				} else if (err == SSL_ERROR_WANT_READ) {
+					sslsock->last_status = STREAM_CRYPTO_STATUS_WANT_READ;
+				} else if (err == SSL_ERROR_WANT_WRITE) {
+					sslsock->last_status = STREAM_CRYPTO_STATUS_WANT_WRITE;
 				}
 			}

@@ -3445,6 +3457,9 @@ static int php_openssl_sockop_set_option(php_stream *stream, int option, int val
 				case STREAM_XPORT_CRYPTO_OP_ENABLE:
 					cparam->outputs.returncode = php_openssl_enable_crypto(stream, sslsock, cparam);
 					return PHP_STREAM_OPTION_RETURN_OK;
+				case STREAM_XPORT_CRYPTO_OP_GET_STATUS:
+					cparam->outputs.returncode = sslsock->last_status;
+					return PHP_STREAM_OPTION_RETURN_OK;
 				default:
 					/* fall through */
 					break;
diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php
index 7f7e0de0f65..7fc31e7d80c 100644
--- a/ext/standard/basic_functions.stub.php
+++ b/ext/standard/basic_functions.stub.php
@@ -3484,6 +3484,11 @@ function stream_socket_sendto($socket, string $data, int $flags = 0, string $add
  */
 function stream_socket_enable_crypto($stream, bool $enable, ?int $crypto_method = null, $session_stream = null): int|bool {}

+/**
+ * @param resource $stream
+ */
+function stream_socket_get_crypto_status($stream): int {}
+
 #ifdef HAVE_SHUTDOWN
 /** @param resource $stream */
 function stream_socket_shutdown($stream, int $mode): bool {}
diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h
index a17efc6961d..c5266c5a877 100644
Binary files a/ext/standard/basic_functions_arginfo.h and b/ext/standard/basic_functions_arginfo.h differ
diff --git a/ext/standard/basic_functions_decl.h b/ext/standard/basic_functions_decl.h
index 067959c8b5f..630a4b7e656 100644
Binary files a/ext/standard/basic_functions_decl.h and b/ext/standard/basic_functions_decl.h differ
diff --git a/ext/standard/file.stub.php b/ext/standard/file.stub.php
index 5e12c43f397..d7b1fef17cd 100644
--- a/ext/standard/file.stub.php
+++ b/ext/standard/file.stub.php
@@ -256,6 +256,23 @@
  */
 const STREAM_CRYPTO_PROTO_TLSv1_3 = UNKNOWN;

+/**
+ * @var int
+ * @cvalue STREAM_CRYPTO_STATUS_NONE
+ */
+const STREAM_CRYPTO_STATUS_NONE = UNKNOWN;
+/**
+ * @var int
+ * @cvalue STREAM_CRYPTO_STATUS_WANT_READ
+ */
+const STREAM_CRYPTO_STATUS_WANT_READ = UNKNOWN;
+/**
+ * @var int
+ * @cvalue STREAM_CRYPTO_STATUS_WANT_WRITE
+ */
+const STREAM_CRYPTO_STATUS_WANT_WRITE = UNKNOWN;
+
+
 /**
  * @var int
  * @cvalue STREAM_SHUT_RD
diff --git a/ext/standard/file_arginfo.h b/ext/standard/file_arginfo.h
index b3888925ee9..24e3722cd86 100644
Binary files a/ext/standard/file_arginfo.h and b/ext/standard/file_arginfo.h differ
diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c
index 3609ee1f3f2..bad645f8668 100644
--- a/ext/standard/streamsfuncs.c
+++ b/ext/standard/streamsfuncs.c
@@ -1638,6 +1638,18 @@ PHP_FUNCTION(stream_socket_enable_crypto)
 }
 /* }}} */

+/* Get crypto status */
+PHP_FUNCTION(stream_socket_get_crypto_status)
+{
+	php_stream *stream;
+
+	ZEND_PARSE_PARAMETERS_START(1, 1)
+		PHP_Z_PARAM_STREAM(stream)
+	ZEND_PARSE_PARAMETERS_END();
+
+	RETURN_LONG(php_stream_xport_crypto_get_status(stream));
+}
+
 /* {{{ Determine what file will be opened by calls to fopen() with a relative path */
 PHP_FUNCTION(stream_resolve_include_path)
 {
diff --git a/main/streams/php_stream_transport.h b/main/streams/php_stream_transport.h
index 0125035aaa6..60bea8e9e1f 100644
--- a/main/streams/php_stream_transport.h
+++ b/main/streams/php_stream_transport.h
@@ -186,11 +186,17 @@ typedef enum {
 	STREAM_CRYPTO_METHOD_ANY_SERVER = ((1 << 1) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 5) | (1 << 6))
 } php_stream_xport_crypt_method_t;

+/* Flags for crypto status */
+#define STREAM_CRYPTO_STATUS_NONE       0
+#define STREAM_CRYPTO_STATUS_WANT_READ  1
+#define STREAM_CRYPTO_STATUS_WANT_WRITE 2
+
 /* These functions provide crypto support on the underlying transport */

 BEGIN_EXTERN_C()
 PHPAPI int php_stream_xport_crypto_setup(php_stream *stream, php_stream_xport_crypt_method_t crypto_method, php_stream *session_stream);
 PHPAPI int php_stream_xport_crypto_enable(php_stream *stream, int activate);
+PHPAPI int php_stream_xport_crypto_get_status(php_stream *stream);
 END_EXTERN_C()

 typedef struct _php_stream_xport_crypto_param {
@@ -204,7 +210,8 @@ typedef struct _php_stream_xport_crypto_param {
 	} outputs;
 	enum {
 		STREAM_XPORT_CRYPTO_OP_SETUP,
-		STREAM_XPORT_CRYPTO_OP_ENABLE
+		STREAM_XPORT_CRYPTO_OP_ENABLE,
+		STREAM_XPORT_CRYPTO_OP_GET_STATUS
 	} op;
 } php_stream_xport_crypto_param;

diff --git a/main/streams/transports.c b/main/streams/transports.c
index 3231670dd9a..c01c5ada43c 100644
--- a/main/streams/transports.c
+++ b/main/streams/transports.c
@@ -397,6 +397,25 @@ PHPAPI int php_stream_xport_crypto_enable(php_stream *stream, int activate)
 	return ret;
 }

+PHPAPI int php_stream_xport_crypto_get_status(php_stream *stream)
+{
+	php_stream_xport_crypto_param param;
+	int ret;
+
+	memset(&param, 0, sizeof(param));
+	param.op = STREAM_XPORT_CRYPTO_OP_GET_STATUS;
+
+	ret = php_stream_set_option(stream, PHP_STREAM_OPTION_CRYPTO_API, 0, &param);
+
+	if (ret == PHP_STREAM_OPTION_RETURN_OK) {
+		return param.outputs.returncode;
+	}
+
+	php_error_docref("streams.crypto", E_WARNING, "This stream does not support SSL/crypto");
+
+	return STREAM_CRYPTO_STATUS_NONE;
+}
+
 /* Similar to recv() system call; read data from the stream, optionally
  * peeking, optionally retrieving OOB data */
 PHPAPI int php_stream_xport_recvfrom(php_stream *stream, char *buf, size_t buflen,