Commit 6cdf46f6ebc for php.net

commit 6cdf46f6ebc2a62409f610a1bbfad4c87d463e85
Author: Jakub Zelenka <bukka@php.net>
Date:   Fri May 15 19:39:23 2026 +0200

    openssl: Introduce TLS PSK support

    Add stream context options psk_client_cb and psk_server_cb that
    let clients and servers negotiate pre-shared key authentication
    on both TLS 1.2 and TLS 1.3. Callbacks return an Openssl\Psk
    instance carrying the key and, on clients, the identity, or
    null to refuse PSK.

    A new final Openssl\Psk class is added for that purpose, with
    readonly $psk and $identity properties and MAX_PSK_LEN /
    MAX_IDENTITY_LEN constants.

    Closes GH-22057

diff --git a/NEWS b/NEWS
index 474698303b5..aa5e42b3667 100644
--- a/NEWS
+++ b/NEWS
@@ -107,6 +107,8 @@ PHP                                                                        NEWS
     openssl_x509_parse() output). (StephenWall)
   . Added TLS session resumption support for streams with new context options
     and Openssl\Session class. (Jakub Zelenka)
+  . Added TLS external PSK support for streams with new context options and
+    Openssl\Psk class. (Jakub Zelenka)

 - PCNTL:
   . pcntl_exec() now throws a ValueError if the $args array is not a list
diff --git a/UPGRADING b/UPGRADING
index d9bf1fc79ac..ffdf738828c 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -196,6 +196,8 @@ PHP 8.6 UPGRADE NOTES
     requests, implementing custom server-side session storage, and controlling
     session cache behavior.
     RFC: https://wiki.php.net/rfc/tls_session_resumption
+  . Added TLS external PSK support for streams with new strem context options:
+    psk_client_cb and psk_server_cb. This allows setting and receiving PSK.

 - Phar:
   . Overriding the getMTime() and getPathname() methods of SplFileInfo now
@@ -317,6 +319,7 @@ PHP 8.6 UPGRADE NOTES
   . Openssl\OpensslException
   . Openssl\Session
     RFC: https://wiki.php.net/rfc/tls_session_resumption
+  . Openssl\Psk

 - Standard:
   . enum SortDirection
diff --git a/ext/openssl/openssl.c b/ext/openssl/openssl.c
index c85ca073f44..65e63265dfc 100644
--- a/ext/openssl/openssl.c
+++ b/ext/openssl/openssl.c
@@ -169,7 +169,76 @@ static void php_openssl_pkey_free_obj(zend_object *object)
 	zend_object_std_dtor(&key_object->std);
 }

-/* OpenSSLSession class */
+/* Openssl\Psk class */
+
+zend_class_entry *php_openssl_psk_ce;
+
+static zend_object_handlers php_openssl_psk_object_handlers;
+
+bool php_openssl_is_psk_ce(zval *val)
+{
+	return Z_TYPE_P(val) == IS_OBJECT && Z_OBJCE_P(val) == php_openssl_psk_ce;
+}
+
+zend_string *php_openssl_psk_get_psk(zval *psk_zv)
+{
+	zval rv;
+	zval *prop = zend_read_property(php_openssl_psk_ce, Z_OBJ_P(psk_zv), ZEND_STRL("psk"), 0, &rv);
+	if (UNEXPECTED(Z_TYPE_P(prop) != IS_STRING)) {
+		return NULL;
+	}
+	return Z_STR_P(prop);
+}
+
+zend_string *php_openssl_psk_get_identity(zval *psk_zv)
+{
+	zval rv;
+	zval *prop = zend_read_property(php_openssl_psk_ce, Z_OBJ_P(psk_zv),
+			ZEND_STRL("identity"), 0, &rv);
+	if (Z_TYPE_P(prop) == IS_NULL) {
+		return NULL;
+	}
+	if (UNEXPECTED(Z_TYPE_P(prop) != IS_STRING)) {
+		return NULL;
+	}
+	return Z_STR_P(prop);
+}
+
+PHP_METHOD(Openssl_Psk, __construct)
+{
+	zend_string *psk;
+	zend_string *identity = NULL;
+
+	ZEND_PARSE_PARAMETERS_START(1, 2)
+		Z_PARAM_STR(psk)
+		Z_PARAM_OPTIONAL
+		Z_PARAM_STR_OR_NULL(identity)
+	ZEND_PARSE_PARAMETERS_END();
+
+	if (ZSTR_LEN(psk) == 0) {
+		zend_argument_value_error(1, "must not be empty");
+		RETURN_THROWS();
+	}
+	if (ZSTR_LEN(psk) > PHP_OPENSSL_PSK_MAX_PSK_LEN) {
+		zend_argument_value_error(1, "must not exceed %d bytes", PHP_OPENSSL_PSK_MAX_PSK_LEN);
+		RETURN_THROWS();
+	}
+	if (identity != NULL && ZSTR_LEN(identity) > PHP_OPENSSL_PSK_MAX_IDENTITY_LEN) {
+		zend_argument_value_error(2, "must not exceed %d bytes", PHP_OPENSSL_PSK_MAX_IDENTITY_LEN);
+		RETURN_THROWS();
+	}
+
+	zend_update_property_str(php_openssl_psk_ce, Z_OBJ_P(ZEND_THIS), ZEND_STRL("psk"), psk);
+
+	if (identity != NULL) {
+		zend_update_property_str(php_openssl_psk_ce, Z_OBJ_P(ZEND_THIS),
+				ZEND_STRL("identity"), identity);
+	} else {
+		zend_update_property_null(php_openssl_psk_ce, Z_OBJ_P(ZEND_THIS), ZEND_STRL("identity"));
+	}
+}
+
+/* Openssl\Session class */

 zend_class_entry *php_openssl_session_ce;

@@ -716,6 +785,11 @@ PHP_MINIT_FUNCTION(openssl)
 	php_openssl_pkey_object_handlers.clone_obj = NULL;
 	php_openssl_pkey_object_handlers.compare = zend_objects_not_comparable;

+	php_openssl_psk_ce = register_class_Openssl_Psk();
+	php_openssl_psk_ce->default_object_handlers = &php_openssl_psk_object_handlers;
+
+	memcpy(&php_openssl_psk_object_handlers, &std_object_handlers, sizeof(zend_object_handlers));
+
 	php_openssl_session_ce = register_class_Openssl_Session();
 	php_openssl_session_ce->create_object = php_openssl_session_create_object;
 	php_openssl_session_ce->default_object_handlers = &php_openssl_session_object_handlers;
diff --git a/ext/openssl/openssl.stub.php b/ext/openssl/openssl.stub.php
index 86dcc8f4f55..6080ac32390 100644
--- a/ext/openssl/openssl.stub.php
+++ b/ext/openssl/openssl.stub.php
@@ -8,6 +8,27 @@ class OpensslException extends Exception
     {
     }

+    /**
+     * @strict-properties
+     */
+    final class Psk
+    {
+        /**
+         * @cvalue PHP_OPENSSL_PSK_MAX_PSK_LEN
+         */
+        public const int MAX_PSK_LEN = UNKNOWN;
+
+        /**
+         * @cvalue PHP_OPENSSL_PSK_MAX_IDENTITY_LEN
+         */
+        public const int MAX_IDENTITY_LEN = UNKNOWN;
+
+        public readonly string $psk;
+        public readonly ?string $identity;
+
+        public function __construct(string $psk, ?string $identity = null) {}
+    }
+
     /**
      * @strict-properties
      */
diff --git a/ext/openssl/openssl_arginfo.h b/ext/openssl/openssl_arginfo.h
index 851ba2e913b..caf47a256e7 100644
Binary files a/ext/openssl/openssl_arginfo.h and b/ext/openssl/openssl_arginfo.h differ
diff --git a/ext/openssl/php_openssl.h b/ext/openssl/php_openssl.h
index ed47d885084..8b597e6ec8f 100644
--- a/ext/openssl/php_openssl.h
+++ b/ext/openssl/php_openssl.h
@@ -203,7 +203,19 @@ static inline php_openssl_pkey_object *php_openssl_pkey_from_obj(zend_object *ob
 bool php_openssl_is_pkey_ce(zval *val);
 void php_openssl_pkey_object_init(zval *zv, EVP_PKEY *pkey, bool is_private);

-/* OpenSSLSession class */
+/* Openssl\Psk class */
+
+/* Matches OpenSSL's PSK_MAX_PSK_LEN and PSK_MAX_IDENTITY_LEN */
+#define PHP_OPENSSL_PSK_MAX_PSK_LEN      256
+#define PHP_OPENSSL_PSK_MAX_IDENTITY_LEN 128
+
+extern zend_class_entry *php_openssl_psk_ce;
+
+bool php_openssl_is_psk_ce(zval *val);
+zend_string *php_openssl_psk_get_psk(zval *psk_zv);
+zend_string *php_openssl_psk_get_identity(zval *psk_zv);
+
+/* Openssl\Session class */

 #include <openssl/ssl.h>

diff --git a/ext/openssl/tests/tls_psk_callback_not_callable.phpt b/ext/openssl/tests/tls_psk_callback_not_callable.phpt
new file mode 100644
index 00000000000..29382e2a13a
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_callback_not_callable.phpt
@@ -0,0 +1,46 @@
+--TEST--
+TLS PSK callback option must be a valid callable
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER,
+        'ciphers' => 'PSK',
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            return new Openssl\Psk("k", "id");
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
+        'ciphers' => 'PSK',
+        'verify_peer' => false,
+        'psk_client_cb' => 'php_openssl_no_such_function',
+    ]]);
+    try {
+        @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+            5, STREAM_CLIENT_CONNECT, $clientCtx);
+        echo "no exception\n";
+    } catch (TypeError $e) {
+        echo "caught: ", $e->getMessage(), "\n";
+    }
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECTF--
+caught: psk_client_cb must be a valid callback, %s
+
diff --git a/ext/openssl/tests/tls_psk_callback_wrong_type.phpt b/ext/openssl/tests/tls_psk_callback_wrong_type.phpt
new file mode 100644
index 00000000000..78d950f1997
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_callback_wrong_type.phpt
@@ -0,0 +1,48 @@
+--TEST--
+TLS PSK client callback returning wrong type raises TypeError
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER,
+        'ciphers' => 'PSK',
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            return new Openssl\Psk("k", "id");
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
+        'ciphers' => 'PSK',
+        'verify_peer' => false,
+        'psk_client_cb' => function ($stream) {
+            /* Returning a string instead of Openssl\Psk|null. */
+            return "not a Psk object";
+        },
+    ]]);
+    try {
+        @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+            5, STREAM_CLIENT_CONNECT, $clientCtx);
+        echo "no exception\n";
+    } catch (TypeError $e) {
+        echo "caught: ", $e->getMessage(), "\n";
+    }
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+caught: PSK callback must return Openssl\Psk or null
diff --git a/ext/openssl/tests/tls_psk_client_callback_null.phpt b/ext/openssl/tests/tls_psk_client_callback_null.phpt
new file mode 100644
index 00000000000..92d970908c8
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_client_callback_null.phpt
@@ -0,0 +1,45 @@
+--TEST--
+TLS 1.2 PSK: client callback returning null aborts handshake (no shared cipher)
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER,
+        'ciphers' => 'PSK',
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            return new Openssl\Psk("doesnotmatter", null);
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
+        'ciphers' => 'PSK',
+        'verify_peer' => false,
+        'psk_client_cb' => function ($stream): ?Openssl\Psk {
+            /* Reject: no PSK to use, no other ciphers offered. */
+            return null;
+        },
+    ]]);
+    $client = @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+        5, STREAM_CLIENT_CONNECT, $clientCtx);
+    var_dump($client);
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+bool(false)
+
diff --git a/ext/openssl/tests/tls_psk_client_callback_throws.phpt b/ext/openssl/tests/tls_psk_client_callback_throws.phpt
new file mode 100644
index 00000000000..13b2d71476c
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_client_callback_throws.phpt
@@ -0,0 +1,48 @@
+--TEST--
+TLS PSK client callback throwing exception propagates to stream_socket_client caller
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER,
+        'ciphers' => 'PSK',
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            return new Openssl\Psk("k", "id");
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
+        'ciphers' => 'PSK',
+        'verify_peer' => false,
+        'psk_client_cb' => function ($stream): ?Openssl\Psk {
+            throw new RuntimeException('callback boom');
+        },
+    ]]);
+    try {
+        @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+            5, STREAM_CLIENT_CONNECT, $clientCtx);
+        echo "no exception\n";
+    } catch (RuntimeException $e) {
+        echo "caught: ", $e->getMessage(), "\n";
+    }
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+caught: callback boom
+
diff --git a/ext/openssl/tests/tls_psk_client_no_identity.phpt b/ext/openssl/tests/tls_psk_client_no_identity.phpt
new file mode 100644
index 00000000000..bfc8d078cef
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_client_no_identity.phpt
@@ -0,0 +1,49 @@
+--TEST--
+TLS PSK client callback must return Openssl\Psk with a non-null identity
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER,
+        'ciphers' => 'PSK',
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            return new Openssl\Psk("k", "id");
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
+        'ciphers' => 'PSK',
+        'verify_peer' => false,
+        'psk_client_cb' => function ($stream): ?Openssl\Psk {
+            /* No identity supplied. */
+            return new Openssl\Psk(str_repeat("\x42", 16));
+        },
+    ]]);
+    try {
+        @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+            5, STREAM_CLIENT_CONNECT, $clientCtx);
+        echo "no exception\n";
+    } catch (ValueError $e) {
+        echo "caught: ", $e->getMessage(), "\n";
+    }
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+caught: Client PSK callback must return Openssl\Psk with a non-null identity
+
diff --git a/ext/openssl/tests/tls_psk_mismatch.phpt b/ext/openssl/tests/tls_psk_mismatch.phpt
new file mode 100644
index 00000000000..8fa90d115c5
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_mismatch.phpt
@@ -0,0 +1,44 @@
+--TEST--
+TLS 1.2 PSK: mismatching key material aborts handshake
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+?>
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER,
+        'ciphers' => 'PSK',
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            return new Openssl\Psk(str_repeat("\x11", 16), $identity);
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
+        'ciphers' => 'PSK',
+        'verify_peer' => false,
+        'psk_client_cb' => function ($stream): ?Openssl\Psk {
+            return new Openssl\Psk(str_repeat("\x22", 16), 'id');
+        },
+    ]]);
+    $client = @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+        5, STREAM_CLIENT_CONNECT, $clientCtx);
+    var_dump($client);
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+bool(false)
+
diff --git a/ext/openssl/tests/tls_psk_tls12_basic.phpt b/ext/openssl/tests/tls_psk_tls12_basic.phpt
new file mode 100644
index 00000000000..2f2a558c4b4
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_tls12_basic.phpt
@@ -0,0 +1,62 @@
+--TEST--
+TLS 1.2 PSK basic client/server round-trip
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (OPENSSL_VERSION_NUMBER < 0x10101000) die("skip OpenSSL >= 1.1.1 required");
+?>
+--FILE--
+<?php
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER,
+        'ciphers' => 'PSK',
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            if ($identity === 'client_id') {
+                return new Openssl\Psk("\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff");
+            }
+            return null;
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    $client = stream_socket_accept($server, 30);
+    fwrite($client, "hello-from-server");
+    fread($client, 1024);
+    fclose($client);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
+        'ciphers' => 'PSK',
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'psk_client_cb' => function ($stream): ?Openssl\Psk {
+            return new Openssl\Psk(
+                "\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff",
+                'client_id'
+            );
+        },
+    ]]);
+    $client = stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $clientCtx);
+    var_dump($client !== false);
+    var_dump(fread($client, 1024));
+    fwrite($client, "hello-from-client");
+    /* Verify PSK was used (no peer cert) */
+    $params = stream_context_get_params($client);
+    var_dump(isset($params['options']['ssl']['peer_certificate']));
+    fclose($client);
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+bool(true)
+string(17) "hello-from-server"
+bool(false)
diff --git a/ext/openssl/tests/tls_psk_tls13_basic.phpt b/ext/openssl/tests/tls_psk_tls13_basic.phpt
new file mode 100644
index 00000000000..a7642624252
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_tls13_basic.phpt
@@ -0,0 +1,55 @@
+--TEST--
+TLS 1.3 PSK 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
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_3_SERVER,
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            if ($identity === 'tls13-client') {
+                return new Openssl\Psk(str_repeat("\x42", 32));
+            }
+            return null;
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    $client = stream_socket_accept($server, 30);
+    fwrite($client, "tls13-psk-ok");
+    fclose($client);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT,
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'psk_client_cb' => function ($stream): ?Openssl\Psk {
+            return new Openssl\Psk(str_repeat("\x42", 32), 'tls13-client');
+        },
+    ]]);
+    $client = stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $clientCtx);
+    var_dump($client !== false);
+    var_dump(fread($client, 1024));
+    /* TLS 1.3 PSK: no peer cert */
+    $params = stream_context_get_params($client);
+    var_dump(isset($params['options']['ssl']['peer_certificate']));
+    fclose($client);
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+bool(true)
+string(12) "tls13-psk-ok"
+bool(false)
diff --git a/ext/openssl/tests/tls_psk_tls13_unknown_identity.phpt b/ext/openssl/tests/tls_psk_tls13_unknown_identity.phpt
new file mode 100644
index 00000000000..830c02449dc
--- /dev/null
+++ b/ext/openssl/tests/tls_psk_tls13_unknown_identity.phpt
@@ -0,0 +1,44 @@
+--TEST--
+TLS 1.3 PSK: server rejects unknown identity (no cert fallback)
+--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
+$serverCode = <<<'CODE'
+    $serverCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_3_SERVER,
+        'psk_server_cb' => function ($stream, string $identity): ?Openssl\Psk {
+            return null;
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $serverCtx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+
+$clientCode = <<<'CODE'
+    $clientCtx = stream_context_create(['ssl' => [
+        'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT,
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'psk_client_cb' => function ($stream): ?Openssl\Psk {
+            return new Openssl\Psk(str_repeat("\x42", 32), 'unknown-id');
+        },
+    ]]);
+    $client = @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+        5, STREAM_CLIENT_CONNECT, $clientCtx);
+    var_dump($client);
+CODE;
+
+include __DIR__ . '/ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--EXPECT--
+bool(false)
+
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index ed72ca49677..4d0dad20a43 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -176,6 +176,17 @@ typedef struct _php_openssl_alpn_ctx_t {
 } php_openssl_alpn_ctx;
 #endif

+/* TLS 1.3 PSK ciphersuite IDs */
+static const unsigned char php_openssl_tls13_aes128gcmsha256_id[] = { 0x13, 0x01 };
+static const unsigned char php_openssl_tls13_aes256gcmsha384_id[] = { 0x13, 0x02 };
+
+/* Holds PSK callbacks */
+typedef struct _php_openssl_psk_callbacks_t {
+	int refcount;
+	zval client_cb;
+	zval server_cb;
+} php_openssl_psk_callbacks_t;
+
 /* Holds session callback */
 typedef struct _php_openssl_session_callbacks_t {
 	int refcount;
@@ -204,6 +215,11 @@ typedef struct _php_openssl_netstream_data_t {
 	php_openssl_alpn_ctx alpn_ctx;
 #endif
 	php_openssl_session_callbacks_t *session_callbacks;
+	php_openssl_psk_callbacks_t *psk_callbacks;
+	/* Identity buffer for TLS 1.3 client PSK whose lifetime outlives the
+	 * 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;
 	char *url_name;
 	unsigned state_set:1;
 	unsigned _spare:31;
@@ -1565,6 +1581,407 @@ static int php_openssl_get_ctx_stream_data_index(void)
 	return ctx_data_index;
 }

+/**
+ * Build a SSL_SESSION suitable for use as an external PSK in TLS 1.3.
+ */
+static SSL_SESSION *php_openssl_psk_build_session(SSL *ssl,
+		const unsigned char *psk, size_t psk_len, const EVP_MD *md)
+{
+	const SSL_CIPHER *cipher;
+
+	cipher = SSL_CIPHER_find(ssl, php_openssl_tls13_aes128gcmsha256_id);
+	if (cipher == NULL) {
+		return NULL;
+	}
+
+	if (md != NULL && SSL_CIPHER_get_handshake_digest(cipher) != md) {
+		/* Fallback to SHA384 if SHA256 doesn't match */
+		cipher = SSL_CIPHER_find(ssl, php_openssl_tls13_aes256gcmsha384_id);
+		if (cipher == NULL || SSL_CIPHER_get_handshake_digest(cipher) != md) {
+			return NULL;
+		}
+	}
+
+	SSL_SESSION *sess = SSL_SESSION_new();
+	if (sess == NULL) {
+		return NULL;
+	}
+
+	if (!SSL_SESSION_set1_master_key(sess, psk, psk_len)
+			|| !SSL_SESSION_set_cipher(sess, cipher)
+			|| !SSL_SESSION_set_protocol_version(sess, TLS1_3_VERSION)) {
+		SSL_SESSION_free(sess);
+		return NULL;
+	}
+
+	return sess;
+}
+
+
+/**
+ * Invoke a user PHP callback (psk_client_cb or psk_server_cb).
+ */
+static zend_result php_openssl_call_psk_cb(php_stream *stream, zval *cb,
+		const unsigned char *identity, size_t identity_len,
+		zval *result)
+{
+	zval args[2];
+	zval retval;
+	int argc;
+
+	ZVAL_RES(&args[0], stream->res);
+
+	if (identity != NULL) {
+		ZVAL_STRINGL(&args[1], (const char *)identity, identity_len);
+		argc = 2;
+	} else {
+		argc = 1;
+	}
+
+	ZVAL_UNDEF(&retval);
+	call_user_function(NULL, NULL, cb, &retval, argc, args);
+
+	if (identity != NULL) {
+		zval_ptr_dtor(&args[1]);
+	}
+
+	if (EG(exception)) {
+		ZVAL_UNDEF(result);
+		return FAILURE;
+	}
+
+	if (Z_TYPE(retval) == IS_NULL) {
+		ZVAL_NULL(result);
+		return SUCCESS;
+	}
+
+	if (!php_openssl_is_psk_ce(&retval)) {
+		zval_ptr_dtor(&retval);
+		zend_type_error("PSK callback must return Openssl\\Psk or null");
+		ZVAL_UNDEF(result);
+		return FAILURE;
+	}
+
+	ZVAL_COPY_VALUE(result, &retval);
+	return SUCCESS;
+}
+
+#ifndef OPENSSL_NO_PSK
+/* TLS 1.2 (and below) PSK callbacks. */
+
+static unsigned int php_openssl_psk_client_cb(SSL *ssl, const char *hint,
+		char *identity_out, unsigned int max_identity_len,
+		unsigned char *psk_out, unsigned int max_psk_len)
+{
+	(void)hint; /* identity hint deliberately not exposed as it is useless */
+	(void)max_identity_len;
+	(void)max_psk_len;
+
+	php_stream *stream = (php_stream *)SSL_get_ex_data(ssl,
+			php_openssl_get_ssl_stream_data_index());
+	if (stream == NULL) {
+		return 0;
+	}
+
+	php_openssl_netstream_data_t *sslsock =
+			(php_openssl_netstream_data_t *)stream->abstract;
+	if (sslsock == NULL || sslsock->psk_callbacks == NULL
+			|| Z_ISUNDEF(sslsock->psk_callbacks->client_cb)) {
+		return 0;
+	}
+
+	zval result;
+	if (php_openssl_call_psk_cb(stream, &sslsock->psk_callbacks->client_cb,
+			NULL, 0, &result) != SUCCESS) {
+		return 0;
+	}
+
+	if (Z_TYPE(result) == IS_NULL) {
+		return 0;
+	}
+
+	zend_string *psk_str = php_openssl_psk_get_psk(&result);
+	zend_string *identity_str = php_openssl_psk_get_identity(&result);
+
+	if (psk_str == NULL) {
+		zval_ptr_dtor(&result);
+		return 0;
+	}
+
+	if (identity_str == NULL) {
+		zval_ptr_dtor(&result);
+		zend_value_error("Client PSK callback must return Openssl\\Psk with a non-null identity");
+		return 0;
+	}
+
+	memcpy(identity_out, ZSTR_VAL(identity_str), ZSTR_LEN(identity_str));
+	identity_out[ZSTR_LEN(identity_str)] = '\0';
+	memcpy(psk_out, ZSTR_VAL(psk_str), ZSTR_LEN(psk_str));
+
+	unsigned int psk_len = (unsigned int)ZSTR_LEN(psk_str);
+	zval_ptr_dtor(&result);
+	return psk_len;
+}
+
+static unsigned int php_openssl_psk_server_cb(SSL *ssl, const char *identity,
+		unsigned char *psk_out, unsigned int max_psk_len)
+{
+	(void)max_psk_len;
+
+	php_stream *stream = (php_stream *)SSL_get_ex_data(ssl,
+			php_openssl_get_ssl_stream_data_index());
+	if (stream == NULL) {
+		return 0;
+	}
+
+	php_openssl_netstream_data_t *sslsock =
+			(php_openssl_netstream_data_t *)stream->abstract;
+	if (sslsock == NULL || sslsock->psk_callbacks == NULL
+			|| Z_ISUNDEF(sslsock->psk_callbacks->server_cb)) {
+		return 0;
+	}
+
+	if (SSL_version(ssl) >= TLS1_3_VERSION) {
+		return 0;
+	}
+
+	if (identity == NULL) {
+		return 0;
+	}
+
+	size_t identity_len = strlen(identity);
+
+	zval result;
+	if (php_openssl_call_psk_cb(stream, &sslsock->psk_callbacks->server_cb,
+			(const unsigned char *)identity, identity_len, &result) != SUCCESS) {
+		return 0;
+	}
+
+	if (Z_TYPE(result) == IS_NULL) {
+		return 0;
+	}
+
+	zend_string *psk_str = php_openssl_psk_get_psk(&result);
+	if (psk_str == NULL) {
+		zval_ptr_dtor(&result);
+		return 0;
+	}
+
+	memcpy(psk_out, ZSTR_VAL(psk_str), ZSTR_LEN(psk_str));
+	unsigned int psk_len = (unsigned int)ZSTR_LEN(psk_str);
+
+	zval_ptr_dtor(&result);
+	return psk_len;
+}
+#endif /* OPENSSL_NO_PSK */
+
+/* TLS 1.3 PSK callbacks */
+
+static int php_openssl_psk_use_session_cb(SSL *ssl, const EVP_MD *md,
+		const unsigned char **id, size_t *idlen, SSL_SESSION **sess)
+{
+	*id = NULL;
+	*idlen = 0;
+	*sess = NULL;
+
+	php_stream *stream = (php_stream *)SSL_get_ex_data(ssl,
+			php_openssl_get_ssl_stream_data_index());
+	if (stream == NULL) {
+		return 1;
+	}
+
+	php_openssl_netstream_data_t *sslsock =
+			(php_openssl_netstream_data_t *)stream->abstract;
+	if (sslsock == NULL || sslsock->psk_callbacks == NULL
+			|| Z_ISUNDEF(sslsock->psk_callbacks->client_cb)) {
+		return 1;
+	}
+
+	zval result;
+	if (php_openssl_call_psk_cb(stream, &sslsock->psk_callbacks->client_cb,
+			NULL, 0, &result) != SUCCESS) {
+		return 0;
+	}
+
+	if (Z_TYPE(result) == IS_NULL) {
+		return 1; /* user rejected, continue without PSK */
+	}
+
+	zend_string *psk_str = php_openssl_psk_get_psk(&result);
+	zend_string *identity_str = php_openssl_psk_get_identity(&result);
+
+	if (psk_str == NULL) {
+		zval_ptr_dtor(&result);
+		return 0;
+	}
+
+	if (identity_str == NULL) {
+		zval_ptr_dtor(&result);
+		zend_value_error("Client PSK callback must return Openssl\\Psk with a non-null identity");
+		return 0;
+	}
+
+	SSL_SESSION *session = php_openssl_psk_build_session(ssl,
+			(const unsigned char *)ZSTR_VAL(psk_str), ZSTR_LEN(psk_str), md);
+	if (session == NULL) {
+		zval_ptr_dtor(&result);
+		if (md != NULL) {
+			/* Could not satisfy the server's chosen digest */
+			return 1;
+		}
+		return 0;
+	}
+
+	/* Identity buffer must outlive this callback. */
+	if (sslsock->psk_identity_buf == NULL) {
+		sslsock->psk_identity_buf = emalloc(PHP_OPENSSL_PSK_MAX_IDENTITY_LEN);
+	}
+	memcpy(sslsock->psk_identity_buf, ZSTR_VAL(identity_str), ZSTR_LEN(identity_str));
+	sslsock->psk_identity_len = ZSTR_LEN(identity_str);
+
+	*id = sslsock->psk_identity_buf;
+	*idlen = sslsock->psk_identity_len;
+	*sess = session;
+
+	zval_ptr_dtor(&result);
+	return 1;
+}
+
+static int php_openssl_psk_find_session_cb(SSL *ssl, const unsigned char *identity,
+		size_t identity_len, SSL_SESSION **sess)
+{
+	*sess = NULL;
+
+	php_stream *stream = (php_stream *)SSL_get_ex_data(ssl,
+			php_openssl_get_ssl_stream_data_index());
+	if (stream == NULL) {
+		return 1;
+	}
+
+	php_openssl_netstream_data_t *sslsock = (php_openssl_netstream_data_t *)stream->abstract;
+	if (sslsock == NULL || sslsock->psk_callbacks == NULL
+			|| Z_ISUNDEF(sslsock->psk_callbacks->server_cb)) {
+		return 1;
+	}
+
+	zval result;
+	if (php_openssl_call_psk_cb(stream, &sslsock->psk_callbacks->server_cb,
+			identity, identity_len, &result) != SUCCESS) {
+		return 0;
+	}
+
+	if (Z_TYPE(result) == IS_NULL) {
+		return 1; /* identity unknown - let handshake fall through */
+	}
+
+	zend_string *psk_str = php_openssl_psk_get_psk(&result);
+	if (psk_str == NULL) {
+		zval_ptr_dtor(&result);
+		return 0;
+	}
+
+	SSL_SESSION *session = php_openssl_psk_build_session(ssl,
+			(const unsigned char *)ZSTR_VAL(psk_str), ZSTR_LEN(psk_str), NULL);
+
+	zval_ptr_dtor(&result);
+
+	if (session == NULL) {
+		return 0;
+	}
+
+	*sess = session;
+	return 1;
+}
+
+/* PSK setup */
+
+static zend_result php_openssl_validate_and_allocate_psk_callback(
+		php_openssl_netstream_data_t *sslsock, zval *callable,
+		const char *callback_name, bool is_persistent)
+{
+	if (is_persistent) {
+		php_error_docref(NULL, E_WARNING,
+				"%s is not supported for persistent streams", callback_name);
+		return FAILURE;
+	}
+
+	char *is_callable_error = NULL;
+	if (!zend_is_callable_ex(callable, NULL, 0, NULL, NULL, &is_callable_error)) {
+		if (is_callable_error) {
+			zend_type_error("%s must be a valid callback, %s",
+					callback_name, is_callable_error);
+			efree(is_callable_error);
+		} else {
+			zend_type_error("%s must be a valid callback", callback_name);
+		}
+		return FAILURE;
+	}
+
+	if (!sslsock->psk_callbacks) {
+		sslsock->psk_callbacks = (php_openssl_psk_callbacks_t *)pemalloc(
+				sizeof(php_openssl_psk_callbacks_t), is_persistent);
+		ZVAL_UNDEF(&sslsock->psk_callbacks->client_cb);
+		ZVAL_UNDEF(&sslsock->psk_callbacks->server_cb);
+		sslsock->psk_callbacks->refcount = 1;
+	}
+
+	return SUCCESS;
+}
+
+static zend_result php_openssl_setup_client_psk(php_stream *stream,
+		php_openssl_netstream_data_t *sslsock)
+{
+	zval *val;
+
+	if (!GET_VER_OPT("psk_client_cb")) {
+		return SUCCESS;
+	}
+
+	if (FAILURE == php_openssl_validate_and_allocate_psk_callback(
+			sslsock, val, "psk_client_cb", php_stream_is_persistent(stream))) {
+		return FAILURE;
+	}
+
+	ZVAL_COPY(&sslsock->psk_callbacks->client_cb, val);
+
+#ifndef OPENSSL_NO_PSK
+	SSL_CTX_set_psk_client_callback(sslsock->ctx, php_openssl_psk_client_cb);
+#endif
+	SSL_CTX_set_psk_use_session_callback(sslsock->ctx, php_openssl_psk_use_session_cb);
+
+	return SUCCESS;
+}
+
+static zend_result php_openssl_setup_server_psk(php_stream *stream,
+		php_openssl_netstream_data_t *sslsock)
+{
+	zval *val;
+
+	if (!GET_VER_OPT("psk_server_cb")) {
+		return SUCCESS;
+	}
+
+	if (FAILURE == php_openssl_validate_and_allocate_psk_callback(
+			sslsock, val, "psk_server_cb", php_stream_is_persistent(stream))) {
+		return FAILURE;
+	}
+
+	ZVAL_COPY(&sslsock->psk_callbacks->server_cb, val);
+
+#ifndef OPENSSL_NO_PSK
+	SSL_CTX_set_psk_server_callback(sslsock->ctx, php_openssl_psk_server_cb);
+#endif
+	SSL_CTX_set_psk_find_session_callback(sslsock->ctx, php_openssl_psk_find_session_cb);
+
+	if (!GET_VER_OPT("session_id_context")) {
+		static const unsigned char default_psk_sid_ctx[] = "PHP_PSK";
+		SSL_CTX_set_session_id_context(sslsock->ctx, default_psk_sid_ctx,
+				sizeof(default_psk_sid_ctx) - 1);
+	}
+
+	return SUCCESS;
+}
+
 /**
  * OpenSSL new session callback - called when a new session is established
  */
@@ -2069,16 +2486,21 @@ static zend_result php_openssl_create_server_ctx(php_stream *stream,
 	SSL_CTX_set_min_proto_version(sslsock->ctx, php_openssl_get_min_proto_version(method_flags));
 	SSL_CTX_set_max_proto_version(sslsock->ctx, php_openssl_get_max_proto_version(method_flags));

+
 	if (sslsock->is_client) {
-		/* Setup client session resumption */
 		if (FAILURE == php_openssl_setup_client_session(stream, sslsock)) {
 			return FAILURE;
 		}
+		if (FAILURE == php_openssl_setup_client_psk(stream, sslsock)) {
+			return FAILURE;
+		}
 	} else if (PHP_STREAM_CONTEXT(stream)) {
 		if (FAILURE == php_openssl_setup_server_session(stream, sslsock)) {
 			return FAILURE;
 		}
-		/* Original server-specific setup */
+		if (FAILURE == php_openssl_setup_server_psk(stream, sslsock)) {
+			return FAILURE;
+		}
 		if (FAILURE == php_openssl_set_server_specific_opts(stream, sslsock->ctx)) {
 			return FAILURE;
 		}
@@ -2131,6 +2553,10 @@ static zend_result php_openssl_setup_crypto(php_stream *stream,
 				parent_sslsock->session_callbacks->refcount++;
 				sslsock->session_callbacks = parent_sslsock->session_callbacks;
 			}
+			if (parent_sslsock->psk_callbacks) {
+				parent_sslsock->psk_callbacks->refcount++;
+				sslsock->psk_callbacks = parent_sslsock->psk_callbacks;
+			}

 			sslsock->ssl_handle = SSL_new(sslsock->ctx);
 			if (!sslsock->ssl_handle) {
@@ -2671,6 +3097,16 @@ static int php_openssl_sockop_close(php_stream *stream, int close_handle) /* {{{
 		pefree(sslsock->session_callbacks, php_stream_is_persistent(stream));
 	}

+	if (sslsock->psk_callbacks && --sslsock->psk_callbacks->refcount == 0) {
+		zval_ptr_dtor(&sslsock->psk_callbacks->client_cb);
+		zval_ptr_dtor(&sslsock->psk_callbacks->server_cb);
+		pefree(sslsock->psk_callbacks, php_stream_is_persistent(stream));
+	}
+
+	if (sslsock->psk_identity_buf) {
+		efree(sslsock->psk_identity_buf);
+	}
+
 	pefree(sslsock, php_stream_is_persistent(stream));

 	return 0;