Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions devel-common/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"types-protobuf>=5.26.0.20240422",
"types-python-dateutil>=2.9.0.20240316",
"types-python-slugify>=8.0.2.20240310",
Expand Down
2 changes: 1 addition & 1 deletion providers/sftp/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
``asyncssh`` ``>=2.12.0; python_version < "3.14"``
``asyncssh`` ``>=2.22.0; python_version >= "3.14"``
========================================== ======================================
Expand Down
7 changes: 7 additions & 0 deletions providers/sftp/docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
Changelog
---------

Breaking changes
~~~~~~~~~~~~~~~~

* ``Bump minimum paramiko to 4.0.0; DSA/DSS keys are no longer supported (#54079)``

This provider requires paramiko 4.0+, which dropped DSS/DSA keys; see `paramiko changelog <https://www.paramiko.org/changelog.html>`__. To migrate: create a non-DSA key pair (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. Host key pinning should use ``ssh-rsa``, ``ssh-ed25519``, or ``ecdsa-sha2-nistp*`` tokens as in ``ssh-keyscan`` output. If you are not ready to migrate keys, stay on a provider release that still pins ``paramiko<4`` until you can switch.

5.8.2
.....

Expand Down
2 changes: 1 addition & 1 deletion providers/sftp/docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
``asyncssh`` ``>=2.12.0; python_version < "3.14"``
``asyncssh`` ``>=2.22.0; python_version >= "3.14"``
========================================== ======================================
Expand Down
3 changes: 1 addition & 2 deletions providers/sftp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ dependencies = [
"apache-airflow>=2.11.0",
"apache-airflow-providers-ssh>=4.0.0",
"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",
"asyncssh>=2.12.0; python_version < '3.14'",
"asyncssh>=2.22.0; python_version >= '3.14'",
]
Expand Down
2 changes: 1 addition & 1 deletion providers/ssh/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
========================================== ==================

The changelog for the provider package can be found in the
Expand Down
7 changes: 7 additions & 0 deletions providers/ssh/docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)``
Comment thread
rawwar marked this conversation as resolved.

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 Ed25519 host key (for example via ``ssh-keyscan``) and replace the value. The same constraints apply to SFTP connections that rely on paramiko via the SSH provider. Until you can migrate, stay on a provider release that still pins ``paramiko<4``.

5.0.3
.....

Expand Down
2 changes: 1 addition & 1 deletion providers/ssh/docs/connections/ssh.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 public key of the host, or ``"<key type> <key data>"`` as in an OpenSSH ``known_hosts`` / ``ssh-keyscan`` line (without the host field). Specifying this allows making the connection if and only if the public key of the endpoint matches this value. Supported key type strings include ``ssh-rsa``, ``ssh-ed25519``, ``ecdsa-sha2-nistp256``, ``ecdsa-sha2-nistp384``, and ``ecdsa-sha2-nistp521``. Examples: ``ssh-rsa AAAA...``, ``ssh-ed25519 AAAA...``, or ``ecdsa-sha2-nistp256 AAAA...``. A bare base64 value (no type prefix) is treated as ``ssh-rsa``. DSA/DSS (``ssh-dss``) host keys are not supported because `paramiko 4.0 removed DSS support <https://www.paramiko.org/changelog.html>`__; generate a new host key and update this field.
* ``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.

Expand Down
2 changes: 1 addition & 1 deletion providers/ssh/docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
========================================== ==================

Downloading official packages
Expand Down
3 changes: 1 addition & 2 deletions providers/ssh/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",

]

Expand Down
115 changes: 97 additions & 18 deletions providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from functools import cached_property
from io import StringIO
from select import select
from typing import Any
from typing import Any, TypeAlias

import paramiko
from paramiko.config import SSH_PORT
Expand All @@ -48,6 +48,10 @@ def is_arg_set(value): # type: ignore[misc,no-redef]
return value is not NOTSET


# Concrete host-key classes accept bytes via ``data=``; base ``PKey`` stubs do not.
_HostKeyConstructor: TypeAlias = type[paramiko.RSAKey] | type[paramiko.ECDSAKey] | type[paramiko.Ed25519Key]


CMD_TIMEOUT = 10


Expand Down Expand Up @@ -87,21 +91,34 @@ 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 = {
# Map OpenSSH known_hosts key-type tokens (and legacy short names) to Paramiko key classes.
# DSA/DSS (ssh-dss) is intentionally absent — paramiko 4.0 removed DSS support.
_host_key_mappings: dict[str, _HostKeyConstructor] = {
"ssh-rsa": paramiko.RSAKey,
"rsa": paramiko.RSAKey,
"dss": paramiko.DSSKey,
"ecdsa": paramiko.ECDSAKey,
"ssh-ed25519": paramiko.Ed25519Key,
"ed25519": paramiko.Ed25519Key,
"ecdsa-sha2-nistp256": paramiko.ECDSAKey,
"ecdsa-sha2-nistp384": paramiko.ECDSAKey,
"ecdsa-sha2-nistp521": paramiko.ECDSAKey,
# Legacy short name from older Airflow host_key parsing (ssh-ecdsa -> ecdsa).
"ecdsa": paramiko.ECDSAKey,
"ssh-ecdsa": paramiko.ECDSAKey,
}

_SUPPORTED_HOST_KEY_TYPES_MSG = (
"ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521 "
"(bare base64 is treated as ssh-rsa)"
)

conn_name_attr = "ssh_conn_id"
default_conn_name = "ssh_default"
conn_type = "ssh"
Expand Down Expand Up @@ -227,13 +244,7 @@ def __init__(
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
decoded_host_key = decodebytes(host_key.encode("utf-8"))
self.host_key = key_constructor(data=decoded_host_key)
self.host_key = self._pkey_from_host_key(host_key)
self.no_host_key_check = False

if self.cmd_timeout is NOTSET:
Expand Down Expand Up @@ -402,6 +413,72 @@ def get_tunnel(
logger=self.log,
)

@classmethod
def _pkey_from_host_key(cls, host_key: str) -> paramiko.PKey:
"""
Build a Paramiko public host key from a connection ``host_key`` extra.

Accepts either bare base64 (treated as ``ssh-rsa`` for backward compatibility) or an
OpenSSH ``known_hosts``-style ``"<key type> <base64 data>"`` string.

:param host_key: host key material from the connection extra
:return: Paramiko public key object
:raises ValueError: if the key type is unsupported (including DSA/DSS) or data is invalid
"""
key_constructor, key_data = cls._parse_host_key(host_key)
decoded_host_key = decodebytes(key_data.encode("utf-8"))
return key_constructor(data=decoded_host_key)

@classmethod
def _parse_host_key(cls, host_key: str) -> tuple[_HostKeyConstructor, str]:
"""
Parse a ``host_key`` extra into ``(key class, base64 key data)``.

Typed values use the first whitespace-separated token as the algorithm name. Supported
tokens include ``ssh-rsa``, ``ssh-ed25519``, and ``ecdsa-sha2-nistp*`` (as in OpenSSH
``known_hosts`` / ``ssh-keyscan``). DSA/DSS (``ssh-dss``) is rejected. A single token of
base64 data defaults to RSA.
"""
parts = host_key.split(None)
if len(parts) >= 2:
key_type, key_data = parts[0], parts[1]
if key_type in {"ssh-dss", "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`."
)
# Typed line if it matches a known algorithm or looks like an OpenSSH key-type token.
if (
key_type in cls._host_key_mappings
or key_type.startswith("ssh-")
or key_type.startswith("ecdsa-sha2-")
):
key_constructor = cls._host_key_mappings.get(key_type)
if key_constructor is None:
raise ValueError(
f"Unsupported SSH host key algorithm {key_type!r}. "
f"Supported types are: {cls._SUPPORTED_HOST_KEY_TYPES_MSG}."
)
return key_constructor, key_data

# Bare base64 (or value without a recognized key-type token): historical default is RSA.
return paramiko.RSAKey, host_key

@staticmethod
def _verify_pkey_usable(key: paramiko.PKey) -> None:
"""
Ensure a loaded private key can sign data (guards against wrong-type loads).

Paramiko 5 removed SHA-1 ``ssh-rsa`` as a signing algorithm while keys still report
name ``ssh-rsa``. Prefer an algorithm from ``HASHES`` when the name is absent.
"""
hashes = getattr(key, "HASHES", None) or {}
key_name = key.get_name()
if hashes and key_name not in hashes:
key.sign_ssh_data(b"", algorithm=next(iter(hashes)))
else:
key.sign_ssh_data(b"")

def _pkey_from_private_key(self, private_key: str, passphrase: str | None = None) -> paramiko.PKey:
"""
Create an appropriate Paramiko key for a given private key.
Expand All @@ -418,14 +495,16 @@ def _pkey_from_private_key(self, private_key: str, passphrase: str | None = None
key = pkey_class.from_private_key(StringIO(private_key), password=passphrase)
# Test it actually works. If Paramiko loads an openssh generated key, sometimes it will
# happily load it as the wrong type, only to fail when actually used.
key.sign_ssh_data(b"")
# Paramiko 5+ no longer treats legacy key names (e.g. ssh-rsa) as signing algorithms;
# pass an explicit algorithm from the key's HASHES map when needed.
self._verify_pkey_usable(key)
return key
except (paramiko.ssh_exception.SSHException, ValueError):
except (paramiko.ssh_exception.SSHException, ValueError, KeyError):
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(
Expand Down
74 changes: 74 additions & 0 deletions providers/ssh/tests/unit/ssh/hooks/test_ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ def generate_host_key(pkey: paramiko.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_HOST_KEY_ECDSA_TYPED = f"{TEST_PKEY_ECDSA.get_name()} {TEST_HOST_KEY_ECDSA}"

TEST_TIMEOUT = 20
TEST_CONN_TIMEOUT = 30
Expand Down Expand Up @@ -574,6 +576,78 @@ def test_ssh_connection_with_host_key_extra_with_type(self, ssh_client):
hook.remote_host, "ssh-rsa", hook.host_key
)

@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_unsupported_host_key_algorithm_raises(self, mock_get_connection):
mock_get_connection.return_value = Connection(
conn_id="ssh_fake_alg",
conn_type="ssh",
host="remote_host",
login="user",
extra=json.dumps(
{"host_key": "ssh-fake AAAAB3NzaC1yc2EAAAADAQABAA==", "no_host_key_check": False}
),
)
with pytest.raises(ValueError, match=r"Unsupported SSH host key algorithm 'ssh-fake'"):
SSHHook(ssh_conn_id="ssh_fake_alg")
Comment on lines +579 to +603

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests cover the new failure paths, but there’s no unit test asserting acceptance/selection of supported host key types. Adding a focused test that validates host_key parsing/constructor selection for at least one supported type (notably an ECDSA ecdsa-sha2-nistp256 token) would prevent regressions and would catch the current mis-detection behavior; you can avoid needing real key material by patching the relevant Paramiko key class/constructor and asserting it was chosen.

Copilot uses AI. Check for mistakes.

@pytest.mark.parametrize(
("host_key_value", "expected_key_type"),
[
(f"ssh-rsa {TEST_HOST_KEY}", paramiko.RSAKey),
(TEST_HOST_KEY_ECDSA_TYPED, paramiko.ECDSAKey),
# Bare base64 remains RSA for backward compatibility.
(TEST_HOST_KEY, paramiko.RSAKey),
],
)
@mock.patch.object(SSHHook, "get_connection")
def test_supported_host_key_types_are_parsed(
self, mock_get_connection, host_key_value, expected_key_type
):
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": host_key_value, "no_host_key_check": False}),
)
hook = SSHHook(ssh_conn_id="ssh_typed_host_key")
assert isinstance(hook.host_key, expected_key_type)

@pytest.mark.parametrize(
("host_key_value", "expected_key_class", "expected_data"),
[
(f"ssh-rsa {TEST_HOST_KEY}", paramiko.RSAKey, TEST_HOST_KEY),
(TEST_HOST_KEY_ECDSA_TYPED, paramiko.ECDSAKey, TEST_HOST_KEY_ECDSA),
(
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY=",
paramiko.ECDSAKey,
"AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY=",
),
(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJustAPlaceholderBase64Value",
paramiko.Ed25519Key,
"AAAAC3NzaC1lZDI1NTE5AAAAIJustAPlaceholderBase64Value",
),
(TEST_HOST_KEY, paramiko.RSAKey, TEST_HOST_KEY),
],
)
def test_parse_host_key_selects_constructor(self, host_key_value, expected_key_class, expected_data):
key_class, key_data = SSHHook._parse_host_key(host_key_value)
assert key_class is expected_key_class
assert key_data == expected_data

@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)
Expand Down
Loading
Loading