[pecl-networking-ssh2] master: Merge branch 'master' into fix/ssh2-shell-environment-handling
| From: | Casper Langemeijer via GitHub | Date: | Wed, 14 Oct 2026 22:14:25 +0000 |
| Subject: | [pecl-networking-ssh2] master: Merge branch 'master' into fix/ssh2-shell-environment-handling | ||
| Groups: | php.pecl.cvs | ||
| Request: | Send a blank email to pecl-cvs+get-28701@lists.php.net to get a copy of this message | ||
Author: Casper Langemeijer (langemeijer)
Committer: GitHub (web-flow)
Pusher: langemeijer
Date: 2026-10-15T00:00:13+02:00
Commit: https://github.com/php/pecl-networking-ssh2/commit/7f6c7211ad42cad0a1e7fd5e3cb8af5e7d5baa22
Raw diff: https://github.com/php/pecl-networking-ssh2/commit/7f6c7211ad42cad0a1e7fd5e3cb8af5e7d5baa22.diff
Merge branch 'master' into fix/ssh2-shell-environment-handling
Changed paths:
A tests/ssh2_close_timeout.phpt
A tests/ssh2_hostkey.phpt
A tests/ssh2_sensitive_auth_parameters.phpt
M config.m4
M config.w32
M package.xml
M php_ssh2.h
M ssh2.c
M ssh2_fopen_wrappers.c
Diff:
diff --git a/config.m4 b/config.m4
index 18b8caa..87b43d8 100644
--- a/config.m4
+++ b/config.m4
@@ -25,12 +25,27 @@ if test "$PHP_SSH2" != "no"; then
PHP_ADD_INCLUDE($SSH2_DIR/include)
+ saved_CPPFLAGS=$CPPFLAGS
+ CPPFLAGS="-I$SSH2_DIR/include $CPPFLAGS"
+ AC_MSG_CHECKING([for libssh2 version >= 1.9.0])
+ AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[
+ #include <libssh2.h>
+ #if LIBSSH2_VERSION_NUM < 0x010900
+ #error libssh2 1.9.0 or newer is required
+ #endif
+ ]], [[]])], [
+ AC_MSG_RESULT([yes])
+ ], [
+ AC_MSG_ERROR([libssh2 version >= 1.9.0 not found])
+ ])
+ CPPFLAGS=$saved_CPPFLAGS
+
PHP_CHECK_LIBRARY(ssh2,libssh2_session_hostkey,
[
PHP_ADD_LIBRARY_WITH_PATH(ssh2, $SSH2_DIR/lib, SSH2_SHARED_LIBADD)
AC_DEFINE(HAVE_SSH2LIB,1,[Have libssh2])
],[
- AC_MSG_ERROR([libssh2 version >= 1.2 not found])
+ AC_MSG_ERROR([libssh2 library not found or too old; version 1.9.0 or newer is required])
],[
-L$SSH2_DIR/lib -lm
])
@@ -39,7 +54,7 @@ if test "$PHP_SSH2" != "no"; then
[
AC_DEFINE(PHP_SSH2_AGENT_AUTH, 1, [Have libssh2 with ssh-agent support])
],[
- AC_MSG_WARN([libssh2 <= 1.2.3, ssh-agent subsystem support not enabled])
+ AC_MSG_ERROR([libssh2_agent_init not found; ensure the linked libssh2 matches the 1.9.0 or
newer headers])
],[
-L$SSH2_DIR/lib -lm
])
@@ -48,7 +63,7 @@ if test "$PHP_SSH2" != "no"; then
[
AC_DEFINE(PHP_SSH2_SESSION_TIMEOUT, 1, [Have libssh2 with session timeout support])
],[
- AC_MSG_WARN([libssh2 < 1.2.9, session timeout support not enabled])
+ AC_MSG_ERROR([libssh2_session_set_timeout not found; ensure the linked libssh2 matches the
1.9.0 or newer headers])
],[
-L$SSH2_DIR/lib -lm
])
@@ -57,7 +72,7 @@ if test "$PHP_SSH2" != "no"; then
[
AC_DEFINE(PHP_SSH2_KEEPALIVE, 1, [Have libssh2 with keepalive support])
],[
- AC_MSG_WARN([libssh2 keepalive support not available])
+ AC_MSG_ERROR([libssh2_keepalive_config not found; ensure the linked libssh2 matches the 1.9.0
or newer headers])
],[
-L$SSH2_DIR/lib -lm
])
@@ -66,7 +81,7 @@ if test "$PHP_SSH2" != "no"; then
[
AC_DEFINE(PHP_SSH2_CHANNEL_SIGNAL, 1, [Have libssh2 with channel signal support])
],[
- AC_MSG_WARN([libssh2 < 1.9.0, channel signal support not available])
+ AC_MSG_ERROR([libssh2_channel_signal_ex not found; ensure the linked libssh2 matches the 1.9.0
or newer headers])
],[
-L$SSH2_DIR/lib -lm
])
diff --git a/config.w32 b/config.w32
index 760ea12..b1164f4 100644
--- a/config.w32
+++ b/config.w32
@@ -39,7 +39,9 @@ if (PHP_SSH2 != "no") {
AC_DEFINE('PHP_SSH2_AGENT_AUTH', 1);
AC_DEFINE('PHP_SSH2_SESSION_TIMEOUT', 1);
AC_DEFINE('PHP_SSH2_KEEPALIVE', 1);
- AC_DEFINE('PHP_SSH2_CHANNEL_SIGNAL', 1);
+ if (CHECK_FUNC_IN_HEADER("libssh2.h", "libssh2_channel_signal_ex",
"ssh2")) {
+ AC_DEFINE('PHP_SSH2_CHANNEL_SIGNAL', 1);
+ }
ADD_EXTENSION_DEP('ssh2', 'zlib')
ADD_EXTENSION_DEP('ssh2', 'openssl')
}
diff --git a/package.xml b/package.xml
index 2faf69f..f37ca54 100644
--- a/package.xml
+++ b/package.xml
@@ -75,6 +75,7 @@
<file role="test" name="ssh2_auth_pubkey_file.phpt"/>
<file role="test" name="ssh2_connect.phpt"/>
<file role="test" name="ssh2_exec.phpt"/>
+ <file role="test" name="ssh2_hostkey.phpt"/>
<file role="test" name="ssh2_keepalive.phpt"/>
<file role="test" name="ssh2_poll.phpt"/>
<file role="test" name="ssh2_send_eof.phpt"/>
diff --git a/php_ssh2.h b/php_ssh2.h
index c27e002..34e2185 100644
--- a/php_ssh2.h
+++ b/php_ssh2.h
@@ -24,6 +24,10 @@
#include "ext/standard/url.h"
#include "main/php_network.h"
+#if LIBSSH2_VERSION_NUM < 0x010900
+#error "The ssh2 extension requires libssh2 1.9.0 or newer"
+#endif
+
#define PHP_SSH2_VERSION "1.5.0"
#define PHP_SSH2_DEFAULT_PORT 22
@@ -32,6 +36,7 @@
#define PHP_SSH2_FINGERPRINT_SHA1 0x0001
#define PHP_SSH2_FINGERPRINT_HEX 0x0000
#define PHP_SSH2_FINGERPRINT_RAW 0x0002
+#define PHP_SSH2_FINGERPRINT_SHA256 0x0004
#define PHP_SSH2_TERM_UNIT_CHARS 0x0000
#define PHP_SSH2_TERM_UNIT_PIXELS 0x0001
@@ -66,6 +71,10 @@ typedef struct _php_ssh2_session_data {
zval *disconnect_cb;
php_socket_t socket;
+
+ /* Session-local state prevents concurrent authentication attempts from sharing a password. */
+ const char *kbd_password;
+ size_t kbd_password_len;
} php_ssh2_session_data;
typedef struct _php_ssh2_sftp_data {
diff --git a/ssh2.c b/ssh2.c
index 0bb7ce6..97c34e6 100644
--- a/ssh2.c
+++ b/ssh2.c
@@ -21,6 +21,7 @@
#endif
#include "php.h"
+#include "Zend/zend_attributes.h"
#include "ext/standard/info.h"
#include "ext/standard/file.h"
#include "php_ssh2.h"
@@ -39,6 +40,10 @@
#define MD5_DIGEST_LENGTH 16
#endif
+#ifndef SHA256_DIGEST_LENGTH
+#define SHA256_DIGEST_LENGTH 32
+#endif
+
/* True global resources - no need for thread safety here */
int le_ssh2_session;
int le_ssh2_listener;
@@ -158,7 +163,7 @@ LIBSSH2_MACERROR_FUNC(php_ssh2_macerror_cb)
if (FAILURE == call_user_function(NULL, NULL, data->macerror_cb, &zretval, 1, args)) {
php_error_docref(NULL, E_WARNING, "Failure calling macerror callback");
} else {
- retval = zval_is_true(&zretval) ? 0 : -1;
+ retval = zend_is_true(&zretval) ? 0 : -1;
}
if (Z_TYPE_P(&zretval) != IS_UNDEF) {
zval_ptr_dtor(&zretval);
@@ -591,18 +596,28 @@ PHP_FUNCTION(ssh2_fingerprint)
zval *zsession;
const char *fingerprint;
zend_long flags = 0;
- int i, fingerprint_len;
+ size_t i, fingerprint_len;
+ int hash_type;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "r|l", &zsession, &flags) == FAILURE)
{
return;
}
- fingerprint_len = (flags & PHP_SSH2_FINGERPRINT_SHA1) ? SHA_DIGEST_LENGTH : MD5_DIGEST_LENGTH;
+ if (flags & PHP_SSH2_FINGERPRINT_SHA256) {
+ fingerprint_len = SHA256_DIGEST_LENGTH;
+ hash_type = LIBSSH2_HOSTKEY_HASH_SHA256;
+ } else if (flags & PHP_SSH2_FINGERPRINT_SHA1) {
+ fingerprint_len = SHA_DIGEST_LENGTH;
+ hash_type = LIBSSH2_HOSTKEY_HASH_SHA1;
+ } else {
+ fingerprint_len = MD5_DIGEST_LENGTH;
+ hash_type = LIBSSH2_HOSTKEY_HASH_MD5;
+ }
if ((session = (LIBSSH2_SESSION *)zend_fetch_resource(Z_RES_P(zsession),
PHP_SSH2_SESSION_RES_NAME, le_ssh2_session)) == NULL) {
RETURN_FALSE;
}
- fingerprint = (char*)libssh2_hostkey_hash(session, (flags & PHP_SSH2_FINGERPRINT_SHA1) ?
LIBSSH2_HOSTKEY_HASH_SHA1 : LIBSSH2_HOSTKEY_HASH_MD5);
+ fingerprint = (char*)libssh2_hostkey_hash(session, hash_type);
if (!fingerprint) {
php_error_docref(NULL, E_WARNING, "Unable to retrieve fingerprint from specified
session");
RETURN_FALSE;
@@ -631,6 +646,36 @@ PHP_FUNCTION(ssh2_fingerprint)
}
/* }}} */
+/* {{{ proto array ssh2_hostkey(resource session)
+ * Returns the server's raw host key and type
+ */
+PHP_FUNCTION(ssh2_hostkey)
+{
+ LIBSSH2_SESSION *session;
+ zval *zsession;
+ const char *hostkey;
+ size_t hostkey_len;
+ int type;
+
+ if (zend_parse_parameters(ZEND_NUM_ARGS(), "r", &zsession) == FAILURE) {
+ return;
+ }
+
+ if ((session = (LIBSSH2_SESSION *)zend_fetch_resource(Z_RES_P(zsession),
PHP_SSH2_SESSION_RES_NAME, le_ssh2_session)) == NULL) {
+ RETURN_FALSE;
+ }
+
+ hostkey = libssh2_session_hostkey(session, &hostkey_len, &type);
+ if (!hostkey) {
+ RETURN_FALSE;
+ }
+
+ array_init(return_value);
+ add_assoc_stringl(return_value, "key", (char *)hostkey, hostkey_len);
+ add_assoc_long(return_value, "type", type);
+}
+/* }}} */
+
/* {{{ proto array ssh2_auth_none(resource session, string username)
* Attempt "none" authentication, returns a list of allowed methods on failed
authentication,
* false on utter failure, or true on success
@@ -669,8 +714,6 @@ PHP_FUNCTION(ssh2_auth_none)
}
/* }}} */
-char *password_for_kbd_callback;
-
static void kbd_callback(const char *name, int name_len,
const char *instruction, int instruction_len,
int num_prompts,
@@ -678,16 +721,31 @@ static void kbd_callback(const char *name, int name_len,
LIBSSH2_USERAUTH_KBDINT_RESPONSE *responses,
void **abstract)
{
+ php_ssh2_session_data *data;
+ int i;
+
(void)name;
(void)name_len;
(void)instruction;
(void)instruction_len;
- if (num_prompts == 1) {
- responses[0].text = strdup(password_for_kbd_callback);
- responses[0].length = responses[0].text ? strlen(password_for_kbd_callback) : 0;
- }
(void)prompts;
- (void)abstract;
+
+ data = abstract ? (php_ssh2_session_data*)*abstract : NULL;
+
+ for (i = 0; i < num_prompts; i++) {
+ responses[i].text = NULL;
+ responses[i].length = 0;
+ }
+
+ if (num_prompts >= 1 && data && data->kbd_password) {
+ /* libssh2 releases response text through the session's system free() callback. */
+ responses[0].text = malloc(data->kbd_password_len + 1);
+ if (responses[0].text) {
+ memcpy(responses[0].text, data->kbd_password, data->kbd_password_len);
+ responses[0].text[data->kbd_password_len] = '\0';
+ responses[0].length = (unsigned int)data->kbd_password_len;
+ }
+ }
}
/* {{{ proto bool ssh2_auth_password(resource session, string username, string password)
@@ -699,6 +757,8 @@ PHP_FUNCTION(ssh2_auth_password)
zval *zsession;
zend_string *username, *password;
char *userauthlist;
+ php_ssh2_session_data *data;
+ int authenticated;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "rSS", &zsession, &username,
&password) == FAILURE) {
return;
@@ -709,9 +769,17 @@ PHP_FUNCTION(ssh2_auth_password)
userauthlist = libssh2_userauth_list(session, username->val, username->len);
if (userauthlist != NULL) {
- password_for_kbd_callback = password->val;
if (strstr(userauthlist, "keyboard-interactive") != NULL) {
- if (libssh2_userauth_keyboard_interactive(session, username->val, &kbd_callback) == 0) {
+ data = *(php_ssh2_session_data**)libssh2_session_abstract(session);
+ data->kbd_password = password->val;
+ data->kbd_password_len = password->len;
+
+ authenticated = libssh2_userauth_keyboard_interactive(session, username->val,
&kbd_callback) == 0;
+
+ data->kbd_password = NULL;
+ data->kbd_password_len = 0;
+
+ if (authenticated) {
RETURN_TRUE;
}
}
@@ -1413,10 +1481,38 @@ PHP_MINIT_FUNCTION(ssh2)
le_ssh2_sftp = zend_register_list_destructors_ex(php_ssh2_sftp_dtor, NULL,
PHP_SSH2_SFTP_RES_NAME, module_number);
le_ssh2_pkey_subsys = zend_register_list_destructors_ex(php_ssh2_pkey_subsys_dtor, NULL,
PHP_SSH2_PKEY_SUBSYS_RES_NAME, module_number);
+#if PHP_VERSION_ID >= 80200
+ /* This extension keeps hand-written arginfo, so sensitive attributes are registered here. */
+ zend_add_parameter_attribute(
+ zend_hash_str_find_ptr(CG(function_table), "ssh2_auth_password",
sizeof("ssh2_auth_password") - 1),
+ 2, ZSTR_KNOWN(ZEND_STR_SENSITIVEPARAMETER), 0);
+ zend_add_parameter_attribute(
+ zend_hash_str_find_ptr(CG(function_table), "ssh2_auth_pubkey_file",
sizeof("ssh2_auth_pubkey_file") - 1),
+ 4, ZSTR_KNOWN(ZEND_STR_SENSITIVEPARAMETER), 0);
+ zend_add_parameter_attribute(
+ zend_hash_str_find_ptr(CG(function_table), "ssh2_auth_pubkey",
sizeof("ssh2_auth_pubkey") - 1),
+ 3, ZSTR_KNOWN(ZEND_STR_SENSITIVEPARAMETER), 0);
+ zend_add_parameter_attribute(
+ zend_hash_str_find_ptr(CG(function_table), "ssh2_auth_pubkey",
sizeof("ssh2_auth_pubkey") - 1),
+ 4, ZSTR_KNOWN(ZEND_STR_SENSITIVEPARAMETER), 0);
+ zend_add_parameter_attribute(
+ zend_hash_str_find_ptr(CG(function_table), "ssh2_auth_hostbased_file",
sizeof("ssh2_auth_hostbased_file") - 1),
+ 5, ZSTR_KNOWN(ZEND_STR_SENSITIVEPARAMETER), 0);
+#endif
+
REGISTER_LONG_CONSTANT("SSH2_FINGERPRINT_MD5", PHP_SSH2_FINGERPRINT_MD5, CONST_CS |
CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SSH2_FINGERPRINT_SHA1", PHP_SSH2_FINGERPRINT_SHA1, CONST_CS |
CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SSH2_FINGERPRINT_HEX", PHP_SSH2_FINGERPRINT_HEX, CONST_CS |
CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SSH2_FINGERPRINT_RAW", PHP_SSH2_FINGERPRINT_RAW, CONST_CS |
CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SSH2_FINGERPRINT_SHA256", PHP_SSH2_FINGERPRINT_SHA256, CONST_CS
| CONST_PERSISTENT);
+
+ REGISTER_LONG_CONSTANT("SSH2_HOSTKEY_TYPE_UNKNOWN", LIBSSH2_HOSTKEY_TYPE_UNKNOWN, CONST_CS
| CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SSH2_HOSTKEY_TYPE_RSA", LIBSSH2_HOSTKEY_TYPE_RSA, CONST_CS |
CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SSH2_HOSTKEY_TYPE_DSS", LIBSSH2_HOSTKEY_TYPE_DSS, CONST_CS |
CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SSH2_HOSTKEY_TYPE_ECDSA_256", LIBSSH2_HOSTKEY_TYPE_ECDSA_256, CONST_CS
| CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SSH2_HOSTKEY_TYPE_ECDSA_384", LIBSSH2_HOSTKEY_TYPE_ECDSA_384, CONST_CS
| CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SSH2_HOSTKEY_TYPE_ECDSA_521", LIBSSH2_HOSTKEY_TYPE_ECDSA_521, CONST_CS
| CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SSH2_HOSTKEY_TYPE_ED25519", LIBSSH2_HOSTKEY_TYPE_ED25519, CONST_CS
| CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SSH2_TERM_UNIT_CHARS", PHP_SSH2_TERM_UNIT_CHARS, CONST_CS |
CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SSH2_TERM_UNIT_PIXELS", PHP_SSH2_TERM_UNIT_PIXELS, CONST_CS |
CONST_PERSISTENT);
@@ -1513,6 +1609,10 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_ssh2_fingerprint, 0, 0, 1)
ZEND_ARG_INFO(0, flags)
ZEND_END_ARG_INFO()
+ZEND_BEGIN_ARG_INFO(arginfo_ssh2_hostkey, 1)
+ ZEND_ARG_INFO(0, session)
+ZEND_END_ARG_INFO()
+
ZEND_BEGIN_ARG_INFO(arginfo_ssh2_auth_none, 2)
ZEND_ARG_INFO(0, session)
ZEND_ARG_INFO(0, username)
@@ -1727,6 +1827,7 @@ zend_function_entry ssh2_functions[] = {
#endif
PHP_FE(ssh2_methods_negotiated, arginfo_ssh2_methods_negotiated)
PHP_FE(ssh2_fingerprint, arginfo_ssh2_fingerprint)
+ PHP_FE(ssh2_hostkey, arginfo_ssh2_hostkey)
PHP_FE(ssh2_auth_none, arginfo_ssh2_auth_none)
PHP_FE(ssh2_auth_password, arginfo_ssh2_auth_password)
diff --git a/ssh2_fopen_wrappers.c b/ssh2_fopen_wrappers.c
index 76585ec..c11623d 100644
--- a/ssh2_fopen_wrappers.c
+++ b/ssh2_fopen_wrappers.c
@@ -141,8 +141,31 @@ static int php_ssh2_channel_stream_close(php_stream *stream, int close_handle)
if (abstract->refcount) {
efree(abstract->refcount);
}
+#ifdef PHP_SSH2_SESSION_TIMEOUT
+ /* Apply session timeout during channel close to prevent blocking
+ * indefinitely when the remote command hasn't finished or the
+ * connection has been lost. libssh2_channel_free() calls
+ * libssh2_channel_close() internally which blocks waiting for
+ * SSH_MSG_CHANNEL_CLOSE from the remote end. */
+ if (abstract->timeout > 0) {
+ LIBSSH2_SESSION *session;
+ session = (LIBSSH2_SESSION *)zend_fetch_resource(abstract->session_rsrc,
PHP_SSH2_SESSION_RES_NAME, le_ssh2_session);
+ if (session) {
+ libssh2_session_set_timeout(session, abstract->timeout);
+ }
+ }
+#endif
libssh2_channel_eof(abstract->channel);
libssh2_channel_free(abstract->channel);
+#ifdef PHP_SSH2_SESSION_TIMEOUT
+ if (abstract->timeout > 0) {
+ LIBSSH2_SESSION *session;
+ session = (LIBSSH2_SESSION *)zend_fetch_resource(abstract->session_rsrc,
PHP_SSH2_SESSION_RES_NAME, le_ssh2_session);
+ if (session) {
+ libssh2_session_set_timeout(session, 0);
+ }
+ }
+#endif
zend_list_delete(abstract->session_rsrc);
}
efree(abstract);
@@ -551,7 +574,7 @@ static php_stream *php_ssh2_shell_open(LIBSSH2_SESSION *session, zend_resource *
if (libssh2_channel_setenv_ex(channel, key->val, key->len, Z_STRVAL(copyval),
Z_STRLEN(copyval))) {
php_error_docref(NULL, E_WARNING, "Failed setting environment variable %s on remote
end", ZSTR_VAL(key));
}
- zval_dtor(©val);
+ zval_ptr_dtor_nogc(©val);
} else {
php_error_docref(NULL, E_NOTICE, "Skipping numeric index in environment array");
}
@@ -817,7 +840,7 @@ static php_stream *php_ssh2_exec_command(LIBSSH2_SESSION *session, zend_resource
if (libssh2_channel_setenv_ex(channel, key->val, key->len, Z_STRVAL(copyval),
Z_STRLEN(copyval))) {
php_error_docref(NULL, E_WARNING, "Failed setting environment variable %s on remote
end", ZSTR_VAL(key));
}
- zval_dtor(©val);
+ zval_ptr_dtor_nogc(©val);
} else {
php_error_docref(NULL, E_NOTICE, "Skipping numeric index in environment array");
}
diff --git a/tests/ssh2_close_timeout.phpt b/tests/ssh2_close_timeout.phpt
new file mode 100644
index 0000000..e8079cb
--- /dev/null
+++ b/tests/ssh2_close_timeout.phpt
@@ -0,0 +1,34 @@
+--TEST--
+ssh2_exec() channel close respects stream timeout
+--SKIPIF--
+<?php
+require('ssh2_skip.inc');
+if (!function_exists('ssh2_set_timeout')) {
+ die("skip ssh2_set_timeout not available");
+}
+?>
+--FILE--
+<?php require('ssh2_test.inc');
+
+$ssh = ssh2_connect(TEST_SSH2_HOSTNAME, TEST_SSH2_PORT);
+ssh2_auth_pubkey_file($ssh, TEST_SSH2_USER, TEST_SSH2_PUB_KEY, TEST_SSH2_PRIV_KEY);
+
+$start = microtime(true);
+
+/* Run a command that takes much longer than our timeout */
+$stream = ssh2_exec($ssh, 'sleep 60');
+stream_set_timeout($stream, 3);
+
+/* Without the fix, fclose() blocks for 60s waiting for
+ * SSH_MSG_CHANNEL_CLOSE. With the fix, it respects the
+ * stream timeout and returns in ~3s. */
+fclose($stream);
+
+$elapsed = microtime(true) - $start;
+
+/* Should complete well under 10s. Without the fix this would take 60s. */
+var_dump($elapsed < 10);
+printf("Completed in %.1fs\n", $elapsed);
+--EXPECTF--
+bool(true)
+Completed in %fs
diff --git a/tests/ssh2_hostkey.phpt b/tests/ssh2_hostkey.phpt
new file mode 100644
index 0000000..51afefd
--- /dev/null
+++ b/tests/ssh2_hostkey.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Raw host-key metadata and SHA-256 fingerprints describe the same server key
+--SKIPIF--
+<?php require('ssh2_skip.inc'); ?>
+--FILE--
+<?php
+require('ssh2_test.inc');
+
+$ssh = ssh2_connect(TEST_SSH2_HOSTNAME, TEST_SSH2_PORT);
+$hostkey = ssh2_hostkey($ssh);
+$fingerprint = ssh2_fingerprint($ssh, SSH2_FINGERPRINT_SHA256 | SSH2_FINGERPRINT_RAW);
+
+var_dump(is_array($hostkey));
+var_dump(is_string($hostkey['key']));
+var_dump($hostkey['key'] !== '');
+var_dump(is_int($hostkey['type']));
+var_dump($hostkey['type'] !== SSH2_HOSTKEY_TYPE_UNKNOWN);
+var_dump(strlen($fingerprint));
+var_dump($fingerprint === hash('sha256', $hostkey['key'], true));
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+int(32)
+bool(true)
diff --git a/tests/ssh2_sensitive_auth_parameters.phpt b/tests/ssh2_sensitive_auth_parameters.phpt
new file mode 100644
index 0000000..71461f2
--- /dev/null
+++ b/tests/ssh2_sensitive_auth_parameters.phpt
@@ -0,0 +1,76 @@
+--TEST--
+SSH2 authentication parameters publish sensitive metadata and redact traces
+--SKIPIF--
+<?php
+if (PHP_VERSION_ID < 80200) {
+ echo 'skip SensitiveParameter requires PHP 8.2 or newer';
+}
+if (!extension_loaded('ssh2')) {
+ echo 'skip extension not loaded';
+}
+?>
+--INI--
+zend.exception_ignore_args=0
+--FILE--
+<?php
+$sensitiveParameters = [
+ ['ssh2_auth_password', 'password'],
+ ['ssh2_auth_pubkey_file', 'passphrase'],
+ ['ssh2_auth_pubkey', 'privkey'],
+ ['ssh2_auth_pubkey', 'passphrase'],
+ ['ssh2_auth_hostbased_file', 'passphrase'],
+];
+
+foreach ($sensitiveParameters as [$function, $parameter]) {
+ $reflectionParameter = new ReflectionParameter($function, $parameter);
+
+ printf(
+ "%s:%s sensitive: %s\n",
+ $function,
+ $parameter,
+ count($reflectionParameter->getAttributes(SensitiveParameter::class)) === 1 ?
'true' : 'false',
+ );
+}
+
+$ordinaryParameters = [
+ ['ssh2_auth_password', 'username'],
+ ['ssh2_auth_pubkey', 'pubkey'],
+ ['ssh2_auth_pubkey_file', 'privkeyfile'],
+ ['ssh2_auth_hostbased_file', 'privkeyfile'],
+];
+
+foreach ($ordinaryParameters as [$function, $parameter]) {
+ $reflectionParameter = new ReflectionParameter($function, $parameter);
+
+ printf(
+ "%s:%s sensitive: %s\n",
+ $function,
+ $parameter,
+ $reflectionParameter->getAttributes(SensitiveParameter::class) === [] ?
'false' : 'true',
+ );
+}
+
+try {
+ ssh2_auth_password(null, 'trace-user', 'trace-secret');
+} catch (Throwable $exception) {
+ $arguments = $exception->getTrace()[0]['args'];
+ $trace = print_r($exception->getTrace(), true);
+
+ printf("trace username: %s\n", $arguments[1]);
+ printf("trace password wrapped: %s\n", $arguments[2] instanceof
SensitiveParameterValue ? 'true' : 'false');
+ printf("trace contains secret: %s\n", str_contains($trace, 'trace-secret')
? 'true' : 'false');
+}
+?>
+--EXPECT--
+ssh2_auth_password:password sensitive: true
+ssh2_auth_pubkey_file:passphrase sensitive: true
+ssh2_auth_pubkey:privkey sensitive: true
+ssh2_auth_pubkey:passphrase sensitive: true
+ssh2_auth_hostbased_file:passphrase sensitive: true
+ssh2_auth_password:username sensitive: false
+ssh2_auth_pubkey:pubkey sensitive: false
+ssh2_auth_pubkey_file:privkeyfile sensitive: false
+ssh2_auth_hostbased_file:privkeyfile sensitive: false
+trace username: trace-user
+trace password wrapped: true
+trace contains secret: false