From 8f6f2dba5ffe0370f2a94511c4a4c4eaf8464f3e Mon Sep 17 00:00:00 2001 From: Jakub Zelenka Date: Fri, 15 May 2026 19:39:23 +0200 Subject: [PATCH] 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. --- ext/openssl/openssl.c | 76 ++- ext/openssl/openssl.stub.php | 21 + ext/openssl/openssl_arginfo.h | 47 +- ext/openssl/php_openssl.h | 14 +- .../tests/tls_psk_callback_not_callable.phpt | 46 ++ .../tests/tls_psk_callback_wrong_type.phpt | 48 ++ .../tests/tls_psk_client_callback_null.phpt | 45 ++ .../tests/tls_psk_client_callback_throws.phpt | 48 ++ .../tests/tls_psk_client_no_identity.phpt | 49 ++ ext/openssl/tests/tls_psk_mismatch.phpt | 44 ++ ext/openssl/tests/tls_psk_tls12_basic.phpt | 62 +++ ext/openssl/tests/tls_psk_tls13_basic.phpt | 55 +++ .../tests/tls_psk_tls13_unknown_identity.phpt | 44 ++ ext/openssl/xp_ssl.c | 440 +++++++++++++++++- 14 files changed, 1034 insertions(+), 5 deletions(-) create mode 100644 ext/openssl/tests/tls_psk_callback_not_callable.phpt create mode 100644 ext/openssl/tests/tls_psk_callback_wrong_type.phpt create mode 100644 ext/openssl/tests/tls_psk_client_callback_null.phpt create mode 100644 ext/openssl/tests/tls_psk_client_callback_throws.phpt create mode 100644 ext/openssl/tests/tls_psk_client_no_identity.phpt create mode 100644 ext/openssl/tests/tls_psk_mismatch.phpt create mode 100644 ext/openssl/tests/tls_psk_tls12_basic.phpt create mode 100644 ext/openssl/tests/tls_psk_tls13_basic.phpt create mode 100644 ext/openssl/tests/tls_psk_tls13_unknown_identity.phpt diff --git a/ext/openssl/openssl.c b/ext/openssl/openssl.c index c85ca073f446..65e63265dfc6 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 86dcc8f4f556..6080ac323903 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 851ba2e913ba..caf47a256e78 100644 --- a/ext/openssl/openssl_arginfo.h +++ b/ext/openssl/openssl_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit openssl.stub.php instead. - * Stub hash: c0b746f3a9fff06533a7682a35f44f5df951d12f */ + * Stub hash: 4d38e81a2f73bb6dd4bbe7a3e0b8ba86600654e2 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_openssl_x509_export_to_file, 0, 2, _IS_BOOL, 0) ZEND_ARG_OBJ_TYPE_MASK(0, certificate, OpenSSLCertificate, MAY_BE_STRING, NULL) @@ -406,6 +406,11 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_openssl_password_verify, 0, 3, _ ZEND_END_ARG_INFO() #endif +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Openssl_Psk___construct, 0, 0, 1) + ZEND_ARG_TYPE_INFO(0, psk, IS_STRING, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, identity, IS_STRING, 1, "null") +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Openssl_Session_export, 0, 0, IS_STRING, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, format, IS_LONG, 0, "OPENSSL_ENCODING_PEM") ZEND_END_ARG_INFO() @@ -506,6 +511,7 @@ ZEND_FUNCTION(openssl_get_cert_locations); ZEND_FUNCTION(openssl_password_hash); ZEND_FUNCTION(openssl_password_verify); #endif +ZEND_METHOD(Openssl_Psk, __construct); ZEND_METHOD(Openssl_Session, export); ZEND_METHOD(Openssl_Session, import); ZEND_METHOD(Openssl_Session, isResumable); @@ -592,6 +598,11 @@ static const zend_function_entry ext_functions[] = { ZEND_FE_END }; +static const zend_function_entry class_Openssl_Psk_methods[] = { + ZEND_ME(Openssl_Psk, __construct, arginfo_class_Openssl_Psk___construct, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static const zend_function_entry class_Openssl_Session_methods[] = { ZEND_ME(Openssl_Session, export, arginfo_class_Openssl_Session_export, ZEND_ACC_PUBLIC) ZEND_ME(Openssl_Session, import, arginfo_class_Openssl_Session_import, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) @@ -820,6 +831,40 @@ static zend_class_entry *register_class_Openssl_OpensslException(zend_class_entr return class_entry; } +static zend_class_entry *register_class_Openssl_Psk(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Openssl", "Psk", class_Openssl_Psk_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES); + + zval const_MAX_PSK_LEN_value; + ZVAL_LONG(&const_MAX_PSK_LEN_value, PHP_OPENSSL_PSK_MAX_PSK_LEN); + zend_string *const_MAX_PSK_LEN_name = zend_string_init_interned("MAX_PSK_LEN", sizeof("MAX_PSK_LEN") - 1, true); + zend_declare_typed_class_constant(class_entry, const_MAX_PSK_LEN_name, &const_MAX_PSK_LEN_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release_ex(const_MAX_PSK_LEN_name, true); + + zval const_MAX_IDENTITY_LEN_value; + ZVAL_LONG(&const_MAX_IDENTITY_LEN_value, PHP_OPENSSL_PSK_MAX_IDENTITY_LEN); + zend_string *const_MAX_IDENTITY_LEN_name = zend_string_init_interned("MAX_IDENTITY_LEN", sizeof("MAX_IDENTITY_LEN") - 1, true); + zend_declare_typed_class_constant(class_entry, const_MAX_IDENTITY_LEN_name, &const_MAX_IDENTITY_LEN_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release_ex(const_MAX_IDENTITY_LEN_name, true); + + zval property_psk_default_value; + ZVAL_UNDEF(&property_psk_default_value); + zend_string *property_psk_name = zend_string_init("psk", sizeof("psk") - 1, true); + zend_declare_typed_property(class_entry, property_psk_name, &property_psk_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); + zend_string_release_ex(property_psk_name, true); + + zval property_identity_default_value; + ZVAL_UNDEF(&property_identity_default_value); + zend_string *property_identity_name = zend_string_init("identity", sizeof("identity") - 1, true); + zend_declare_typed_property(class_entry, property_identity_name, &property_identity_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL)); + zend_string_release_ex(property_identity_name, true); + + return class_entry; +} + static zend_class_entry *register_class_Openssl_Session(void) { zend_class_entry ce, *class_entry; diff --git a/ext/openssl/php_openssl.h b/ext/openssl/php_openssl.h index 72fd98745e14..a128a0256ee5 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 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 000000000000..29382e2a13ab --- /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-- + +--FILE-- + [ + '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 000000000000..78d950f19974 --- /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-- + +--FILE-- + [ + '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 000000000000..92d970908c8e --- /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-- + +--FILE-- + [ + '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 000000000000..13b2d71476c2 --- /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-- + +--FILE-- + [ + '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 000000000000..bfc8d078ceff --- /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-- + +--FILE-- + [ + '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 000000000000..8fa90d115c5a --- /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-- + +--FILE-- + [ + '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 000000000000..2f2a558c4b40 --- /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-- += 1.1.1 required"); +?> +--FILE-- + [ + '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 000000000000..a7642624252a --- /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-- + +--FILE-- + [ + '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 000000000000..830c02449dc9 --- /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-- + +--FILE-- + [ + '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 ed72ca49677f..4d0dad20a439 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;