This is an automated email from the ASF dual-hosted git repository.

eladkal pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 8b057a74747 Remove DSS key support from SSH provider for Paramiko 4 
(#69669)
8b057a74747 is described below

commit 8b057a74747c9aa2b2bc23703c1ec2f712d8f228
Author: Shivam Rastogi <[email protected]>
AuthorDate: Thu Jul 9 19:55:59 2026 -0700

    Remove DSS key support from SSH provider for Paramiko 4 (#69669)
    
    * Remove DSS key support for Paramiko 4
    
    Paramiko 4 removed DSS/DSA key handling, so the SSH provider needs to stop 
loading or documenting those keys while giving users a clear migration 
path.\n\nThis also teaches host_key parsing to understand real OpenSSH 
known_hosts key type tokens for RSA, ECDSA, and Ed25519 values.
    
    * Fix SSH host key constructor typing
    
    * Harden SSH host key parsing
    
    * Raise SFTP SSH provider dependency floor
    
    * Use next-version marker for SFTP SSH dependency
    
    * Fix SFTP changelog spellcheck
    
    * Preserve host key pub comment handling
    
    Drop trailing comments from typed SSH/SFTP host_key values so pasted .pub 
lines like ssh-ed25519 <key> user@host keep working. Align async SFTP host-key 
parsing with async SSH by stripping values and rejecting ssh-dss.
---
 devel-common/pyproject.toml                        |   3 +-
 providers/sftp/README.rst                          |   2 +-
 providers/sftp/docs/changelog.rst                  |   7 ++
 providers/sftp/docs/index.rst                      |   2 +-
 providers/sftp/pyproject.toml                      |   5 +-
 .../sftp/src/airflow/providers/sftp/hooks/sftp.py  |   9 ++
 providers/sftp/tests/unit/sftp/hooks/test_sftp.py  |  21 +++-
 providers/ssh/README.rst                           |   2 +-
 providers/ssh/docs/changelog.rst                   |   7 ++
 providers/ssh/docs/connections/ssh.rst             |   2 +-
 providers/ssh/docs/index.rst                       |   2 +-
 providers/ssh/pyproject.toml                       |   3 +-
 .../ssh/src/airflow/providers/ssh/hooks/ssh.py     |  65 +++++++++---
 providers/ssh/tests/unit/ssh/hooks/test_ssh.py     | 118 +++++++++++++++++++++
 .../ssh/tests/unit/ssh/hooks/test_ssh_async.py     |  12 ++-
 uv.lock                                            |  28 +++--
 16 files changed, 250 insertions(+), 38 deletions(-)

diff --git a/devel-common/pyproject.toml b/devel-common/pyproject.toml
index 4dee7e82e26..c41b325414f 100644
--- a/devel-common/pyproject.toml
+++ b/devel-common/pyproject.toml
@@ -118,8 +118,7 @@ dependencies = [
     "types-certifi>=2021.10.8.3",
     "types-croniter>=2.0.0.20240423",
     "types-docutils>=0.21.0.20240704",
-    # TODO: Bump to >= 4.0.0 once 
https://github.com/apache/airflow/issues/54079
-    "types-paramiko>=3.4.0.20240423,<4.0.0",
+    "types-paramiko>=4.0.0.20260402,<5.0.0",
     "types-protobuf>=5.26.0.20240422",
     "types-python-dateutil>=2.9.0.20240316",
     "types-python-slugify>=8.0.2.20240310",
diff --git a/providers/sftp/README.rst b/providers/sftp/README.rst
index d22393492f2..701c74d3398 100644
--- a/providers/sftp/README.rst
+++ b/providers/sftp/README.rst
@@ -57,7 +57,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-ssh``            ``>=4.0.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
-``paramiko``                                ``>=3.5.1,<4.0.0``
+``paramiko``                                ``>=4.0.0,<5.0.0``
 ``asyncssh``                                ``>=2.12.0; python_version < 
"3.14"``
 ``asyncssh``                                ``>=2.22.0; python_version >= 
"3.14"``
 ==========================================  
======================================
diff --git a/providers/sftp/docs/changelog.rst 
b/providers/sftp/docs/changelog.rst
index 44a1c54cbb3..31169cc28fc 100644
--- a/providers/sftp/docs/changelog.rst
+++ b/providers/sftp/docs/changelog.rst
@@ -27,6 +27,13 @@
 Changelog
 ---------
 
+Breaking changes
+~~~~~~~~~~~~~~~~
+
+* ``Bump minimum paramiko to 4.0.0; DSA/DSS keys are no longer supported 
(#54079)``
+
+  This provider depends on paramiko 4.0+, which dropped ``DSS``/``DSA`` keys; 
see `paramiko changelog <https://www.paramiko.org/changelog.html>`__. To 
migrate: create a key pair that does not use ``DSA`` (Ed25519 or RSA are 
typical, e.g. ``ssh-keygen -t ed25519``), add the public key to the SFTP 
server, then update your Airflow SFTP (or shared SSH) connection so 
``key_file`` or the ``private_key`` extra uses the new key, and ensure any 
``host_key`` extra is not in ``ssh-dss`` form. If yo [...]
+
 5.8.2
 .....
 
diff --git a/providers/sftp/docs/index.rst b/providers/sftp/docs/index.rst
index d760850d1a6..1ef36f0bd42 100644
--- a/providers/sftp/docs/index.rst
+++ b/providers/sftp/docs/index.rst
@@ -104,7 +104,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-ssh``            ``>=4.0.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
-``paramiko``                                ``>=3.5.1,<4.0.0``
+``paramiko``                                ``>=4.0.0,<5.0.0``
 ``asyncssh``                                ``>=2.12.0; python_version < 
"3.14"``
 ``asyncssh``                                ``>=2.22.0; python_version >= 
"3.14"``
 ==========================================  
======================================
diff --git a/providers/sftp/pyproject.toml b/providers/sftp/pyproject.toml
index e56b35bbb6f..09e181bf237 100644
--- a/providers/sftp/pyproject.toml
+++ b/providers/sftp/pyproject.toml
@@ -61,10 +61,9 @@ requires-python = ">=3.10"
 dependencies = [
     "aiofiles>=23.2.0",
     "apache-airflow>=2.11.0",
-    "apache-airflow-providers-ssh>=4.0.0",
+    "apache-airflow-providers-ssh>=4.0.0",  # use next version
     "apache-airflow-providers-common-compat>=1.12.0",
-    # TODO: Bump to >= 4.0.0 once 
https://github.com/apache/airflow/issues/54079 is handled
-    "paramiko>=3.5.1,<4.0.0",
+    "paramiko>=4.0.0,<5.0.0",
     "asyncssh>=2.12.0; python_version < '3.14'",
     "asyncssh>=2.22.0; python_version >= '3.14'",
 ]
diff --git a/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py 
b/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py
index bb272348974..cbb4254c8a3 100644
--- a/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py
+++ b/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py
@@ -802,6 +802,15 @@ class SFTPHookAsync(BaseHook):
             self.log.warning("No Host Key Verification. This won't protect 
against Man-In-The-Middle attacks")
             self.known_hosts = "none"
         elif host_key is not None:
+            host_key = host_key.strip()
+            host_key_parts = host_key.split()
+            if host_key_parts and host_key_parts[0] == "ssh-dss":
+                raise ValueError(
+                    "DSA/DSS host keys are not supported. Paramiko 4.0 removed 
DSS support; "
+                    "use an RSA, ECDSA, or Ed25519 host key and update the 
connection `host_key`."
+                )
+            if len(host_key_parts) >= 2:
+                host_key = " ".join(host_key_parts[:2])
             self.known_hosts = f"{conn.host} {host_key}".encode()
 
     async def _get_conn(self) -> asyncssh.SSHClientConnection:
diff --git a/providers/sftp/tests/unit/sftp/hooks/test_sftp.py 
b/providers/sftp/tests/unit/sftp/hooks/test_sftp.py
index 41ab0878ec4..bd4c2f05252 100644
--- a/providers/sftp/tests/unit/sftp/hooks/test_sftp.py
+++ b/providers/sftp/tests/unit/sftp/hooks/test_sftp.py
@@ -817,6 +817,10 @@ class TestSFTPHookAsync:
         ("mock_port", "mock_host_key"),
         [
             (22, "ssh-ed25519 
AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY"),
+            (
+                22,
+                "ssh-ed25519 
AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY user@host",
+            ),
             (2222, 
"AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY"),
             (
                 2222,
@@ -848,7 +852,22 @@ class TestSFTPHookAsync:
         hook = SFTPHookAsync()
         await hook._get_conn()
 
-        assert hook.known_hosts == f"localhost {mock_host_key}".encode()
+        host_key_parts = mock_host_key.split()
+        expected_host_key = " ".join(host_key_parts[:2]) if 
len(host_key_parts) >= 2 else mock_host_key
+        assert hook.known_hosts == f"localhost {expected_host_key}".encode()
+
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def test_parse_extras_dss_host_key_raises(self, mock_get_connection, 
mock_connect):
+        """Test that ssh-dss host_key is rejected."""
+        mock_get_connection.return_value = MockAirflowConnectionWithHostKey(
+            host_key="ssh-dss\tAAAAB3...", no_host_key_check=False
+        )
+
+        hook = SFTPHookAsync()
+        with pytest.raises(ValueError, match="DSA/DSS host keys"):
+            await hook._get_conn()
 
     @patch("asyncssh.connect", new_callable=AsyncMock)
     @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
diff --git a/providers/ssh/README.rst b/providers/ssh/README.rst
index 0225f32ec68..9886eedea4a 100644
--- a/providers/ssh/README.rst
+++ b/providers/ssh/README.rst
@@ -56,7 +56,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
 ``asyncssh``                                ``>=2.12.0``
-``paramiko``                                ``>=3.5.1,<4.0.0``
+``paramiko``                                ``>=4.0.0,<5.0.0``
 ==========================================  ==================
 
 The changelog for the provider package can be found in the
diff --git a/providers/ssh/docs/changelog.rst b/providers/ssh/docs/changelog.rst
index 0e391e0aa08..94236ac76e4 100644
--- a/providers/ssh/docs/changelog.rst
+++ b/providers/ssh/docs/changelog.rst
@@ -27,6 +27,13 @@
 Changelog
 ---------
 
+Breaking changes
+~~~~~~~~~~~~~~~~
+
+* ``Bump minimum paramiko to 4.0.0; DSA/DSS private keys and ssh-dss host keys 
are no longer supported (#54079)``
+
+  Paramiko 4.0 removed DSS/DSA support; see `paramiko changelog 
<https://www.paramiko.org/changelog.html>`__ for upstream details. If you use a 
DSA private key in an SSH connection, generate a new key (for example 
``ssh-keygen -t ed25519`` or ``-t rsa``), install the public key on the server, 
and point your Airflow connection at the new key file or ``private_key`` extra. 
If you pin the remote host with a ``host_key`` extra in ``ssh-dss`` form, 
obtain the server's current RSA, ECDSA, or E [...]
+
 5.0.4
 .....
 
diff --git a/providers/ssh/docs/connections/ssh.rst 
b/providers/ssh/docs/connections/ssh.rst
index a248a60477c..9ca7ce0dedf 100644
--- a/providers/ssh/docs/connections/ssh.rst
+++ b/providers/ssh/docs/connections/ssh.rst
@@ -54,7 +54,7 @@ Extra (optional)
     * ``no_host_key_check`` - Set to ``false`` to restrict connecting to hosts 
with no entries in ``~/.ssh/known_hosts`` (Hosts file). This provides maximum 
protection against trojan horse attacks, but can be troublesome when the 
``/etc/ssh/ssh_known_hosts`` file is poorly maintained or connections to new 
hosts are frequently made. This option forces the user to manually add all new 
hosts. Default is ``true``, ssh will automatically add new host keys to the 
user known hosts files.
     * ``allow_host_key_change`` - Set to ``true`` if you want to allow 
connecting to hosts that has host key changed or when you get 'REMOTE HOST 
IDENTIFICATION HAS CHANGED' error.  This won't protect against 
Man-In-The-Middle attacks. Other possible solution is to remove the host entry 
from ``~/.ssh/known_hosts`` file. Default is ``false``.
     * ``look_for_keys`` - Set to ``false`` if you want to disable searching 
for discoverable private key files in ``~/.ssh/``
-    * ``host_key`` - The base64 encoded ssh-rsa public key of the host or 
"ssh-<key type> <key data>" (as you would find in the ``known_hosts`` file). 
Specifying this allows making the connection if and only if the public key of 
the endpoint matches this value.
+    * ``host_key`` - The base64 encoded ssh-rsa public key of the host or 
``"<key type> <key data>"`` (as you would find in the ``known_hosts`` file). 
Specifying this allows making the connection if and only if the public key of 
the endpoint matches this value. Supported key type strings are ``ssh-rsa``, 
``ecdsa-sha2-nistp256``, ``ecdsa-sha2-nistp384``, ``ecdsa-sha2-nistp521``, and 
``ssh-ed25519``; the legacy ``ssh-ecdsa`` form is also accepted for 
compatibility. DSA/DSS (``ssh-dss``) ho [...]
     * ``disabled_algorithms`` - A dictionary mapping algorithm type to an 
iterable of algorithm identifiers, which will be disabled for the lifetime of 
the transport.
     * ``ciphers`` - A list of ciphers to use in order of preference.
 
diff --git a/providers/ssh/docs/index.rst b/providers/ssh/docs/index.rst
index fd5f5b1ca8d..ecd5f426061 100644
--- a/providers/ssh/docs/index.rst
+++ b/providers/ssh/docs/index.rst
@@ -95,7 +95,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
 ``asyncssh``                                ``>=2.12.0``
-``paramiko``                                ``>=3.5.1,<4.0.0``
+``paramiko``                                ``>=4.0.0,<5.0.0``
 ==========================================  ==================
 
 Downloading official packages
diff --git a/providers/ssh/pyproject.toml b/providers/ssh/pyproject.toml
index abff218a422..59114e621db 100644
--- a/providers/ssh/pyproject.toml
+++ b/providers/ssh/pyproject.toml
@@ -62,8 +62,7 @@ dependencies = [
     "apache-airflow>=2.11.0",
     "apache-airflow-providers-common-compat>=1.12.0",
     "asyncssh>=2.12.0",
-    # TODO: Bump to >= 4.0.0 once 
https://github.com/apache/airflow/issues/54079 is handled
-    "paramiko>=3.5.1,<4.0.0",
+    "paramiko>=4.0.0,<5.0.0",
 
 ]
 
diff --git a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py 
b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
index 54b7edf6008..ec8e37d67e3 100644
--- a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
+++ b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
@@ -50,6 +50,16 @@ except ImportError:
 
 CMD_TIMEOUT = 10
 
+_HostKeyConstructor = type[paramiko.RSAKey] | type[paramiko.ECDSAKey] | 
type[paramiko.Ed25519Key]
+_SUPPORTED_HOST_KEY_TYPES = (
+    "ssh-rsa",
+    "ssh-ecdsa",
+    "ecdsa-sha2-nistp256",
+    "ecdsa-sha2-nistp384",
+    "ecdsa-sha2-nistp521",
+    "ssh-ed25519",
+)
+
 
 class SSHHook(BaseHook):
     """
@@ -87,19 +97,21 @@ class SSHHook(BaseHook):
         once and some connections are transiently refused (e.g. ``sshd`` 
``MaxStartups`` throttling).
     """
 
-    # List of classes to try loading private keys as, ordered (roughly) by 
most common to least common
+    # List of classes to try loading private keys as, ordered (roughly) by 
most common to least common.
+    # DSA/DSS keys are not supported (removed in paramiko 4.0).
     _pkey_loaders: Sequence[type[paramiko.PKey]] = (
         paramiko.RSAKey,
         paramiko.ECDSAKey,
         paramiko.Ed25519Key,
-        paramiko.DSSKey,
     )
 
-    _host_key_mappings = {
-        "rsa": paramiko.RSAKey,
-        "dss": paramiko.DSSKey,
-        "ecdsa": paramiko.ECDSAKey,
-        "ed25519": paramiko.Ed25519Key,
+    _host_key_mappings: dict[str, _HostKeyConstructor] = {
+        "ssh-rsa": paramiko.RSAKey,
+        "ssh-ecdsa": paramiko.ECDSAKey,
+        "ecdsa-sha2-nistp256": paramiko.ECDSAKey,
+        "ecdsa-sha2-nistp384": paramiko.ECDSAKey,
+        "ecdsa-sha2-nistp521": paramiko.ECDSAKey,
+        "ssh-ed25519": paramiko.Ed25519Key,
     }
 
     conn_name_attr = "ssh_conn_id"
@@ -227,11 +239,25 @@ class SSHHook(BaseHook):
                     self.ciphers = extra_options.get("ciphers")
 
                 if host_key is not None:
-                    if host_key.startswith("ssh-"):
-                        key_type, host_key = host_key.split(None)[:2]
-                        key_constructor = self._host_key_mappings[key_type[4:]]
-                    else:
-                        key_constructor = paramiko.RSAKey
+                    host_key = host_key.strip()
+                    host_key_parts = host_key.split()
+                    key_constructor: _HostKeyConstructor = paramiko.RSAKey
+                    if len(host_key_parts) >= 2:
+                        key_type, host_key = host_key_parts[:2]
+                        if key_type == "ssh-dss":
+                            raise ValueError(
+                                "DSA/DSS host keys are not supported. Paramiko 
4.0 removed DSS support; "
+                                "use an RSA, ECDSA, or Ed25519 host key and 
update the connection `host_key`."
+                            )
+                        key_constructor_for_type = 
self._host_key_mappings.get(key_type)
+                        if key_constructor_for_type is None:
+                            raise ValueError(
+                                f"Unsupported SSH host key algorithm 
{key_type!r}. "
+                                f"Supported types are: {', 
'.join(_SUPPORTED_HOST_KEY_TYPES)}."
+                            )
+                        key_constructor = key_constructor_for_type
+                    elif host_key in self._host_key_mappings or host_key == 
"ssh-dss":
+                        raise ValueError(f"SSH host key {host_key!r} is 
missing key data.")
                     decoded_host_key = decodebytes(host_key.encode("utf-8"))
                     self.host_key = key_constructor(data=decoded_host_key)
                     self.no_host_key_check = False
@@ -423,9 +449,9 @@ class SSHHook(BaseHook):
             except (paramiko.ssh_exception.SSHException, ValueError):
                 continue
         raise AirflowException(
-            "Private key provided cannot be read by paramiko."
-            "Ensure key provided is valid for one of the following"
-            "key formats: RSA, DSS, ECDSA, or Ed25519"
+            "Private key provided cannot be read by paramiko. "
+            "Ensure key provided is valid for one of the following "
+            "key formats: RSA, ECDSA, or Ed25519."
         )
 
     def exec_ssh_client_command(
@@ -599,6 +625,15 @@ class SSHHookAsync(BaseHook):
             self.log.warning("No Host Key Verification. This won't protect 
against Man-In-The-Middle attacks")
             self.known_hosts = "none"
         elif host_key is not None:
+            host_key = host_key.strip()
+            host_key_parts = host_key.split()
+            if host_key_parts and host_key_parts[0] == "ssh-dss":
+                raise ValueError(
+                    "DSA/DSS host keys are not supported. Paramiko 4.0 removed 
DSS support; "
+                    "use an RSA, ECDSA, or Ed25519 host key and update the 
connection `host_key`."
+                )
+            if len(host_key_parts) >= 2:
+                host_key = " ".join(host_key_parts[:2])
             self.known_hosts = f"{conn.host} {host_key}".encode()
 
     async def _get_conn(self):
diff --git a/providers/ssh/tests/unit/ssh/hooks/test_ssh.py 
b/providers/ssh/tests/unit/ssh/hooks/test_ssh.py
index e277c1dc3f6..baf1847ec9a 100644
--- a/providers/ssh/tests/unit/ssh/hooks/test_ssh.py
+++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh.py
@@ -26,6 +26,8 @@ from unittest import mock
 
 import paramiko
 import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ec, ed25519
 
 from airflow.models import Connection
 from airflow.providers.common.compat.sdk import AirflowException
@@ -63,12 +65,28 @@ def generate_host_key(pkey: paramiko.PKey):
     return key_obj.get_base64()
 
 
+def generate_ed25519_host_key():
+    private_key = ed25519.Ed25519PrivateKey.generate()
+    private_key_text = private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.OpenSSH,
+        encryption_algorithm=serialization.NoEncryption(),
+    ).decode()
+    return 
paramiko.Ed25519Key(file_obj=StringIO(private_key_text)).get_base64()
+
+
 TEST_PKEY = paramiko.RSAKey.generate(4096)
 TEST_PRIVATE_KEY = generate_key_string(pkey=TEST_PKEY)
 TEST_HOST_KEY = generate_host_key(pkey=TEST_PKEY)
 
 TEST_PKEY_ECDSA = paramiko.ECDSAKey.generate()
 TEST_PRIVATE_KEY_ECDSA = generate_key_string(pkey=TEST_PKEY_ECDSA)
+TEST_HOST_KEY_ECDSA = TEST_PKEY_ECDSA.get_base64()
+TEST_PKEY_ECDSA_P384 = paramiko.ECDSAKey.generate(curve=ec.SECP384R1())
+TEST_HOST_KEY_ECDSA_P384 = TEST_PKEY_ECDSA_P384.get_base64()
+TEST_PKEY_ECDSA_P521 = paramiko.ECDSAKey.generate(curve=ec.SECP521R1())
+TEST_HOST_KEY_ECDSA_P521 = TEST_PKEY_ECDSA_P521.get_base64()
+TEST_HOST_KEY_ED25519 = generate_ed25519_host_key()
 
 TEST_TIMEOUT = 20
 TEST_CONN_TIMEOUT = 30
@@ -574,6 +592,106 @@ class TestSSHHook:
                 hook.remote_host, "ssh-rsa", hook.host_key
             )
 
+    @pytest.mark.parametrize(
+        ("key_type", "host_key", "expected_cls", "expected_name"),
+        [
+            ("ecdsa-sha2-nistp256", TEST_HOST_KEY_ECDSA, paramiko.ECDSAKey, 
"ecdsa-sha2-nistp256"),
+            ("ssh-ecdsa", TEST_HOST_KEY_ECDSA, paramiko.ECDSAKey, 
"ecdsa-sha2-nistp256"),
+            ("ecdsa-sha2-nistp384", TEST_HOST_KEY_ECDSA_P384, 
paramiko.ECDSAKey, "ecdsa-sha2-nistp384"),
+            ("ecdsa-sha2-nistp521", TEST_HOST_KEY_ECDSA_P521, 
paramiko.ECDSAKey, "ecdsa-sha2-nistp521"),
+            ("ssh-ed25519", TEST_HOST_KEY_ED25519, paramiko.Ed25519Key, 
"ssh-ed25519"),
+            (
+                "ssh-ed25519",
+                f"{TEST_HOST_KEY_ED25519} user@host",
+                paramiko.Ed25519Key,
+                "ssh-ed25519",
+            ),
+        ],
+    )
+    @mock.patch.object(SSHHook, "get_connection")
+    def test_typed_host_key_in_connection_extra_is_supported(
+        self, mock_get_connection, key_type, host_key, expected_cls, 
expected_name
+    ):
+        mock_get_connection.return_value = Connection(
+            conn_id="ssh_typed_host_key",
+            conn_type="ssh",
+            host="remote_host",
+            login="user",
+            extra=json.dumps(
+                {
+                    "host_key": f"{key_type} {host_key}",
+                    "no_host_key_check": False,
+                }
+            ),
+        )
+        hook = SSHHook(ssh_conn_id="ssh_typed_host_key")
+
+        assert isinstance(hook.host_key, expected_cls)
+        assert hook.host_key.get_name() == expected_name
+
+    @pytest.mark.parametrize(
+        ("host_key", "expected_name"),
+        [
+            (f"  {TEST_HOST_KEY}  ", "ssh-rsa"),
+            (f"ssh-rsa\t{TEST_HOST_KEY}", "ssh-rsa"),
+            (f"ssh-ecdsa\t{TEST_HOST_KEY_ECDSA}", "ecdsa-sha2-nistp256"),
+        ],
+    )
+    @mock.patch.object(SSHHook, "get_connection")
+    def test_host_key_extra_accepts_legacy_and_copy_paste_whitespace(
+        self, mock_get_connection, host_key, expected_name
+    ):
+        mock_get_connection.return_value = Connection(
+            conn_id="ssh_host_key_with_whitespace",
+            conn_type="ssh",
+            host="remote_host",
+            login="user",
+            extra=json.dumps({"host_key": host_key, "no_host_key_check": 
False}),
+        )
+        hook = SSHHook(ssh_conn_id="ssh_host_key_with_whitespace")
+
+        assert hook.host_key is not None
+        assert hook.host_key.get_name() == expected_name
+
+    @mock.patch.object(SSHHook, "get_connection")
+    def test_dss_host_key_in_connection_extra_raises(self, 
mock_get_connection):
+        mock_get_connection.return_value = Connection(
+            conn_id="ssh_dss_host_key",
+            conn_type="ssh",
+            host="remote_host",
+            login="user",
+            extra=json.dumps({"host_key": "ssh-dss AAAAB3NzaC1kc3MAAA==", 
"no_host_key_check": False}),
+        )
+        with pytest.raises(ValueError, match="DSA/DSS host keys"):
+            SSHHook(ssh_conn_id="ssh_dss_host_key")
+
+    @mock.patch.object(SSHHook, "get_connection")
+    def test_dss_host_key_with_tab_in_connection_extra_raises(self, 
mock_get_connection):
+        mock_get_connection.return_value = Connection(
+            conn_id="ssh_dss_host_key",
+            conn_type="ssh",
+            host="remote_host",
+            login="user",
+            extra=json.dumps({"host_key": "ssh-dss\tAAAAB3NzaC1kc3MAAA==", 
"no_host_key_check": False}),
+        )
+        with pytest.raises(ValueError, match="DSA/DSS host keys"):
+            SSHHook(ssh_conn_id="ssh_dss_host_key")
+
+    @pytest.mark.parametrize("key_type", ["ssh-fake", "ecdsa-sha2-nistp999"])
+    @mock.patch.object(SSHHook, "get_connection")
+    def test_unsupported_host_key_algorithm_raises(self, mock_get_connection, 
key_type):
+        mock_get_connection.return_value = Connection(
+            conn_id="ssh_fake_alg",
+            conn_type="ssh",
+            host="remote_host",
+            login="user",
+            extra=json.dumps(
+                {"host_key": f"{key_type} AAAAB3NzaC1yc2EAAAADAQABAA==", 
"no_host_key_check": False}
+            ),
+        )
+        with pytest.raises(ValueError, match=rf"Unsupported SSH host key 
algorithm '{key_type}'"):
+            SSHHook(ssh_conn_id="ssh_fake_alg")
+
     @mock.patch("airflow.providers.ssh.hooks.ssh.paramiko.SSHClient")
     def 
test_ssh_connection_with_no_host_key_where_no_host_key_check_is_false(self, 
ssh_client):
         hook = 
SSHHook(ssh_conn_id=self.CONN_SSH_WITH_NO_HOST_KEY_AND_NO_HOST_KEY_CHECK_FALSE)
diff --git a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py 
b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
index 1e56fb4c372..9001bf3ca7f 100644
--- a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
+++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
@@ -75,12 +75,22 @@ class TestSSHHookAsync:
         """Test parsing host_key from connection extras."""
         hook = SSHHookAsync(ssh_conn_id="test_conn")
         mock_conn = mock.MagicMock()
-        mock_conn.extra_dejson = {"host_key": "ssh-rsa AAAAB3...", 
"no_host_key_check": "false"}
+        mock_conn.extra_dejson = {"host_key": " ssh-rsa AAAAB3... user@host ", 
"no_host_key_check": "false"}
         mock_conn.host = "test.host"
 
         hook._parse_extras(mock_conn)
         assert hook.known_hosts == b"test.host ssh-rsa AAAAB3..."
 
+    def test_parse_extras_dss_host_key_raises(self):
+        """Test that ssh-dss host_key is rejected."""
+        hook = SSHHookAsync(ssh_conn_id="test_conn")
+        mock_conn = mock.MagicMock()
+        mock_conn.extra_dejson = {"host_key": "ssh-dss\tAAAAB3...", 
"no_host_key_check": "false"}
+        mock_conn.host = "test.host"
+
+        with pytest.raises(ValueError, match="DSA/DSS host keys"):
+            hook._parse_extras(mock_conn)
+
     def test_parse_extras_host_key_with_no_check_raises(self):
         """Test that host_key with no_host_key_check raises error."""
         hook = SSHHookAsync(ssh_conn_id="test_conn")
diff --git a/uv.lock b/uv.lock
index f08cb4583d8..43b6db586d2 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2714,7 +2714,7 @@ requires-dist = [
     { name = "types-deprecated", marker = "extra == 'mypy'", specifier = 
">=1.2.9.20240311" },
     { name = "types-docutils", marker = "extra == 'mypy'", specifier = 
">=0.21.0.20240704" },
     { name = "types-markdown", marker = "extra == 'mypy'", specifier = 
">=3.6.0.20240316" },
-    { name = "types-paramiko", marker = "extra == 'mypy'", specifier = 
">=3.4.0.20240423,<4.0.0" },
+    { name = "types-paramiko", marker = "extra == 'mypy'", specifier = 
">=4.0.0.20260402,<5.0.0" },
     { name = "types-protobuf", marker = "extra == 'mypy'", specifier = 
">=5.26.0.20240422" },
     { name = "types-pymysql", marker = "extra == 'mypy'", specifier = 
">=1.1.0.20240425" },
     { name = "types-python-dateutil", marker = "extra == 'mypy'", specifier = 
">=2.9.0.20240316" },
@@ -7606,7 +7606,7 @@ requires-dist = [
     { name = "apache-airflow-providers-ssh", editable = "providers/ssh" },
     { name = "asyncssh", marker = "python_full_version < '3.14'", specifier = 
">=2.12.0" },
     { name = "asyncssh", marker = "python_full_version >= '3.14'", specifier = 
">=2.22.0" },
-    { name = "paramiko", specifier = ">=3.5.1,<4.0.0" },
+    { name = "paramiko", specifier = ">=4.0.0,<5.0.0" },
     { name = "sshfs", marker = "extra == 'sshfs'", specifier = ">=2023.1.0" },
 ]
 provides-extras = ["openlineage", "sshfs"]
@@ -7883,7 +7883,7 @@ requires-dist = [
     { name = "apache-airflow", editable = "." },
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },
     { name = "asyncssh", specifier = ">=2.12.0" },
-    { name = "paramiko", specifier = ">=3.5.1,<4.0.0" },
+    { name = "paramiko", specifier = ">=4.0.0,<5.0.0" },
 ]
 
 [package.metadata.requires-dev]
@@ -14579,6 +14579,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/97/9c/1646ca469bc2dc299ac393c8d31136c6c22a35ca1e373fa462ac01100d37/inputimeout-1.0.4-py3-none-any.whl";,
 hash = 
"sha256:f4e23d27753cfc25268eefc8d52a3edc46280ad831d226617c51882423475a43", size 
= 4639, upload-time = "2018-03-02T14:28:06.903Z" },
 ]
 
+[[package]]
+name = "invoke"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz";,
 hash = 
"sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size 
= 343419, upload-time = "2026-04-07T15:17:48.307Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl";,
 hash = 
"sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size 
= 160958, upload-time = "2026-04-07T15:17:46.875Z" },
+]
+
 [[package]]
 name = "ipdb"
 version = "0.13.13"
@@ -18140,16 +18149,17 @@ all = [
 
 [[package]]
 name = "paramiko"
-version = "3.5.1"
+version = "4.0.0"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
     { name = "bcrypt" },
     { name = "cryptography" },
+    { name = "invoke" },
     { name = "pynacl" },
 ]
-sdist = { url = 
"https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz";,
 hash = 
"sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size 
= 1566110, upload-time = "2025-02-04T02:37:59.783Z" }
+sdist = { url = 
"https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz";,
 hash = 
"sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size 
= 1630743, upload-time = "2025-08-04T01:02:03.711Z" }
 wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl";,
 hash = 
"sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size 
= 227298, upload-time = "2025-02-04T02:37:57.672Z" },
+    { url = 
"https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl";,
 hash = 
"sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size 
= 223932, upload-time = "2025-08-04T01:02:02.029Z" },
 ]
 
 [[package]]
@@ -23607,14 +23617,14 @@ wheels = [
 
 [[package]]
 name = "types-paramiko"
-version = "3.5.0.20250801"
+version = "4.0.0.20260518"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
     { name = "cryptography" },
 ]
-sdist = { url = 
"https://files.pythonhosted.org/packages/d5/c4/4df427c835e9c795662241410732ab936c6f58be798ec25ce7015771c448/types_paramiko-3.5.0.20250801.tar.gz";,
 hash = 
"sha256:e79ff84eaf44f2a5ad811743edef5d9c0cd6632a264a526f00405b87db1ec99b", size 
= 28838, upload-time = "2025-08-01T03:48:52.777Z" }
+sdist = { url = 
"https://files.pythonhosted.org/packages/e4/b6/4cdb11fb6006be6309844dc5f88b6efef3f5ea5352ade42a1e9308570e28/types_paramiko-4.0.0.20260518.tar.gz";,
 hash = 
"sha256:286f6830945cba63797eedf375ed87138d93198121253afe66c5d6dbcf91318d", size 
= 29193, upload-time = "2026-05-18T06:06:36.776Z" }
 wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/bd/5b/0f0bb1e45f7547d081ae9d490a88c6f6031d0f2c97459236e0a2a8b27207/types_paramiko-3.5.0.20250801-py3-none-any.whl";,
 hash = 
"sha256:3e02a0fcf2b7e7b213e0cd569f7223ff9af417052a4d149d84172ebaa6fd742e", size 
= 39705, upload-time = "2025-08-01T03:48:51.855Z" },
+    { url = 
"https://files.pythonhosted.org/packages/44/a2/1a54b77758c9c175526bd0448de353f0563e71ba1ddc8bd4ac0c835deafd/types_paramiko-4.0.0.20260518-py3-none-any.whl";,
 hash = 
"sha256:0ffaf1a6eb796833a49653cba4c7be13af51c8269d75234972d6239763dda270", size 
= 38791, upload-time = "2026-05-18T06:06:35.771Z" },
 ]
 
 [[package]]

Reply via email to