diff --git a/backport-0001-CVE-2023-48795.patch b/backport-0001-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..7af91fe493da81fb348f40c2bb16d99fb7c15ffc --- /dev/null +++ b/backport-0001-CVE-2023-48795.patch @@ -0,0 +1,1271 @@ +From 363a28d94cada17f012c1604a3c99c71a2bda003 Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Mon, 13 Dec 2021 15:55:36 -0500 +Subject: [PATCH] Add support for RSA SHA2 host and public keys + +Includes a handful of refactors and new semiprivate +attributes on Transport and AuthHandler for better +test visibility. +--- + paramiko/__init__.py | 1 + + paramiko/agent.py | 2 +- + paramiko/auth_handler.py | 81 ++++++++--- + paramiko/common.py | 5 +- + paramiko/dsskey.py | 2 +- + paramiko/ecdsakey.py | 2 +- + paramiko/ed25519key.py | 2 +- + paramiko/kex_curve25519.py | 4 +- + paramiko/kex_ecdh_nist.py | 4 +- + paramiko/kex_gex.py | 4 +- + paramiko/kex_group1.py | 4 +- + paramiko/pkey.py | 11 +- + paramiko/rsakey.py | 28 +++- + paramiko/ssh_exception.py | 15 +++ + paramiko/transport.py | 179 +++++++++++++++++++----- + sites/www/changelog.rst | 64 +++++++++ + tests/test_kex.py | 3 +- + tests/test_pkey.py | 26 ++-- + tests/test_transport.py | 269 +++++++++++++++++++++++++++++++++++++ + 19 files changed, 631 insertions(+), 75 deletions(-) + +diff --git a/paramiko/__init__.py b/paramiko/__init__.py +index 8642f84af..5318cc9c5 100644 +--- a/paramiko/__init__.py ++++ b/paramiko/__init__.py +@@ -42,6 +42,7 @@ + ChannelException, + ConfigParseError, + CouldNotCanonicalize, ++ IncompatiblePeer, + PasswordRequiredException, + ProxyCommandFailure, + SSHException, +diff --git a/paramiko/agent.py b/paramiko/agent.py +index 622b95e..27b9629 100644 +--- a/paramiko/agent.py ++++ b/paramiko/agent.py +@@ -407,7 +407,7 @@ class AgentKey(PKey): + def get_name(self): + return self.name + +- def sign_ssh_data(self, data): ++ def sign_ssh_data(self, data, algorithm=None): + msg = Message() + msg.add_byte(cSSH2_AGENTC_SIGN_REQUEST) + msg.add_string(self.blob) +diff --git a/paramiko/auth_handler.py b/paramiko/auth_handler.py +index 011e57f32..845b9143e 100644 +--- a/paramiko/auth_handler.py ++++ b/paramiko/auth_handler.py +@@ -61,7 +61,7 @@ + cMSG_USERAUTH_BANNER, + ) + from paramiko.message import Message +-from paramiko.py3compat import b ++from paramiko.py3compat import b, u + from paramiko.ssh_exception import ( + SSHException, + AuthenticationException, +@@ -206,6 +206,23 @@ def _disconnect_no_more_auth(self): + self.transport._send_message(m) + self.transport.close() + ++ def _get_algorithm_and_bits(self, key): ++ """ ++ Given any key, return appropriate signing algorithm & bits-to-sign. ++ ++ Intended for input to or verification of, key signatures. ++ """ ++ key_type, bits = None, None ++ # Use certificate contents, if available, plain pubkey otherwise ++ if key.public_blob: ++ key_type = key.public_blob.key_type ++ bits = key.public_blob.key_blob ++ else: ++ key_type = key.get_name() ++ bits = key ++ algorithm = self._finalize_pubkey_algorithm(key_type) ++ return algorithm, bits ++ + def _get_session_blob(self, key, service, username): + m = Message() + m.add_string(self.transport.session_id) +@@ -214,13 +231,9 @@ def _get_session_blob(self, key, service, username): + m.add_string(service) + m.add_string("publickey") + m.add_boolean(True) +- # Use certificate contents, if available, plain pubkey otherwise +- if key.public_blob: +- m.add_string(key.public_blob.key_type) +- m.add_string(key.public_blob.key_blob) +- else: +- m.add_string(key.get_name()) +- m.add_string(key) ++ algorithm, bits = self._get_algorithm_and_bits(key) ++ m.add_string(algorithm) ++ m.add_string(bits) + return m.asbytes() + + def wait_for_response(self, event): +@@ -269,6 +282,39 @@ def _parse_service_request(self, m): + # dunno this one + self._disconnect_service_not_available() + ++ def _finalize_pubkey_algorithm(self, key_type): ++ # Short-circuit for non-RSA keys ++ if "rsa" not in key_type: ++ return key_type ++ self._log( ++ DEBUG, ++ "Finalizing pubkey algorithm for key of type {!r}".format( ++ key_type ++ ), ++ ) ++ # Only consider RSA algos from our list, lest we agree on another! ++ my_algos = [x for x in self.transport.preferred_pubkeys if "rsa" in x] ++ self._log(DEBUG, "Our pubkey algorithm list: {}".format(my_algos)) ++ # Short-circuit negatively if user disabled all RSA algos (heh) ++ if not my_algos: ++ raise SSHException( ++ "An RSA key was specified, but no RSA pubkey algorithms are configured!" # noqa ++ ) ++ # Check for server-sig-algs if supported & sent ++ server_algos = u( ++ self.transport.server_extensions.get("server-sig-algs", b("")) ++ ).split(",") ++ self._log(DEBUG, "Server-side algorithm list: {}".format(server_algos)) ++ # Only use algos from our list that the server likes, in our own ++ # preference order. (NOTE: purposefully using same style as in ++ # Transport...expect to refactor later) ++ agreement = list(filter(server_algos.__contains__, my_algos)) ++ # Fallback: first one in our (possibly tweaked by caller) list ++ final = agreement[0] if agreement else my_algos[0] ++ self.transport._agreed_pubkey_algorithm = final ++ self._log(DEBUG, "Agreed upon {!r} pubkey algorithm".format(final)) ++ return final ++ + def _parse_service_accept(self, m): + service = m.get_text() + if service == "ssh-userauth": +@@ -287,18 +333,15 @@ def _parse_service_accept(self, m): + m.add_string(password) + elif self.auth_method == "publickey": + m.add_boolean(True) +- # Use certificate contents, if available, plain pubkey +- # otherwise +- if self.private_key.public_blob: +- m.add_string(self.private_key.public_blob.key_type) +- m.add_string(self.private_key.public_blob.key_blob) +- else: +- m.add_string(self.private_key.get_name()) +- m.add_string(self.private_key) ++ algorithm, bits = self._get_algorithm_and_bits( ++ self.private_key ++ ) ++ m.add_string(algorithm) ++ m.add_string(bits) + blob = self._get_session_blob( + self.private_key, "ssh-connection", self.username + ) +- sig = self.private_key.sign_ssh_data(blob) ++ sig = self.private_key.sign_ssh_data(blob, algorithm) + m.add_string(sig) + elif self.auth_method == "keyboard-interactive": + m.add_string("") +@@ -529,13 +572,15 @@ def _parse_userauth_request(self, m): + username, key + ) + if result != AUTH_FAILED: ++ sig_algo = self._finalize_pubkey_algorithm(keytype) + # key is okay, verify it + if not sig_attached: + # client wants to know if this key is acceptable, before it + # signs anything... send special "ok" message + m = Message() + m.add_byte(cMSG_USERAUTH_PK_OK) +- m.add_string(keytype) ++ # TODO: suspect we're not testing this ++ m.add_string(sig_algo) + m.add_string(keyblob) + self.transport._send_message(m) + return +diff --git a/paramiko/common.py b/paramiko/common.py +index 7bd0cb104..55dd4bdf2 100644 +--- a/paramiko/common.py ++++ b/paramiko/common.py +@@ -29,7 +29,8 @@ + MSG_DEBUG, + MSG_SERVICE_REQUEST, + MSG_SERVICE_ACCEPT, +-) = range(1, 7) ++ MSG_EXT_INFO, ++) = range(1, 8) + (MSG_KEXINIT, MSG_NEWKEYS) = range(20, 22) + ( + MSG_USERAUTH_REQUEST, +@@ -68,6 +69,7 @@ + cMSG_DEBUG = byte_chr(MSG_DEBUG) + cMSG_SERVICE_REQUEST = byte_chr(MSG_SERVICE_REQUEST) + cMSG_SERVICE_ACCEPT = byte_chr(MSG_SERVICE_ACCEPT) ++cMSG_EXT_INFO = byte_chr(MSG_EXT_INFO) + cMSG_KEXINIT = byte_chr(MSG_KEXINIT) + cMSG_NEWKEYS = byte_chr(MSG_NEWKEYS) + cMSG_USERAUTH_REQUEST = byte_chr(MSG_USERAUTH_REQUEST) +@@ -109,6 +111,7 @@ + MSG_SERVICE_REQUEST: "service-request", + MSG_SERVICE_ACCEPT: "service-accept", + MSG_KEXINIT: "kexinit", ++ MSG_EXT_INFO: "ext-info", + MSG_NEWKEYS: "newkeys", + 30: "kex30", + 31: "kex31", +diff --git a/paramiko/dsskey.py b/paramiko/dsskey.py +index 09d6f6483..1a0c47978 100644 +--- a/paramiko/dsskey.py ++++ b/paramiko/dsskey.py +@@ -105,7 +105,7 @@ def get_bits(self): + def can_sign(self): + return self.x is not None + +- def sign_ssh_data(self, data): ++ def sign_ssh_data(self, data, algorithm=None): + key = dsa.DSAPrivateNumbers( + x=self.x, + public_numbers=dsa.DSAPublicNumbers( +diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py +index b609d1302..c4e2b1aff 100644 +--- a/paramiko/ecdsakey.py ++++ b/paramiko/ecdsakey.py +@@ -211,7 +211,7 @@ def get_bits(self): + def can_sign(self): + return self.signing_key is not None + +- def sign_ssh_data(self, data): ++ def sign_ssh_data(self, data, algorithm=None): + ecdsa = ec.ECDSA(self.ecdsa_curve.hash_object()) + sig = self.signing_key.sign(data, ecdsa) + r, s = decode_dss_signature(sig) +diff --git a/paramiko/ed25519key.py b/paramiko/ed25519key.py +index 7b19e3528..d322a0c13 100644 +--- a/paramiko/ed25519key.py ++++ b/paramiko/ed25519key.py +@@ -191,7 +191,7 @@ def get_bits(self): + def can_sign(self): + return self._signing_key is not None + +- def sign_ssh_data(self, data): ++ def sign_ssh_data(self, data, algorithm=None): + m = Message() + m.add_string("ssh-ed25519") + m.add_string(self._signing_key.sign(data).signature) +diff --git a/paramiko/kex_curve25519.py b/paramiko/kex_curve25519.py +index 59710c1aa..3420fb4f2 100644 +--- a/paramiko/kex_curve25519.py ++++ b/paramiko/kex_curve25519.py +@@ -89,7 +89,9 @@ def _parse_kexecdh_init(self, m): + hm.add_mpint(K) + H = self.hash_algo(hm.asbytes()).digest() + self.transport._set_K_H(K, H) +- sig = self.transport.get_server_key().sign_ssh_data(H) ++ sig = self.transport.get_server_key().sign_ssh_data( ++ H, self.transport.host_key_type ++ ) + # construct reply + m = Message() + m.add_byte(c_MSG_KEXECDH_REPLY) +diff --git a/paramiko/kex_ecdh_nist.py b/paramiko/kex_ecdh_nist.py +index ad5c9c79d..19de24313 100644 +--- a/paramiko/kex_ecdh_nist.py ++++ b/paramiko/kex_ecdh_nist.py +@@ -90,7 +90,9 @@ def _parse_kexecdh_init(self, m): + hm.add_mpint(long(K)) + H = self.hash_algo(hm.asbytes()).digest() + self.transport._set_K_H(K, H) +- sig = self.transport.get_server_key().sign_ssh_data(H) ++ sig = self.transport.get_server_key().sign_ssh_data( ++ H, self.transport.host_key_type ++ ) + # construct reply + m = Message() + m.add_byte(c_MSG_KEXECDH_REPLY) +diff --git a/paramiko/kex_gex.py b/paramiko/kex_gex.py +index fb8f01fdc..ab462e6d7 100644 +--- a/paramiko/kex_gex.py ++++ b/paramiko/kex_gex.py +@@ -240,7 +240,9 @@ def _parse_kexdh_gex_init(self, m): + H = self.hash_algo(hm.asbytes()).digest() + self.transport._set_K_H(K, H) + # sign it +- sig = self.transport.get_server_key().sign_ssh_data(H) ++ sig = self.transport.get_server_key().sign_ssh_data( ++ H, self.transport.host_key_type ++ ) + # send reply + m = Message() + m.add_byte(c_MSG_KEXDH_GEX_REPLY) +diff --git a/paramiko/kex_group1.py b/paramiko/kex_group1.py +index 5131e89..2484f5f 100644 +--- a/paramiko/kex_group1.py ++++ b/paramiko/kex_group1.py +@@ -143,7 +143,9 @@ class KexGroup1(object): + H = sha1(hm.asbytes()).digest() + self.transport._set_K_H(K, H) + # sign it +- sig = self.transport.get_server_key().sign_ssh_data(H) ++ sig = self.transport.get_server_key().sign_ssh_data( ++ H, self.transport.host_key_type ++ ) + # send reply + m = Message() + m.add_byte(c_MSG_KEXDH_REPLY) +diff --git a/paramiko/pkey.py b/paramiko/pkey.py +index 5bdfb1d43..7865a6eae 100644 +--- a/paramiko/pkey.py ++++ b/paramiko/pkey.py +@@ -196,13 +196,20 @@ def get_base64(self): + """ + return u(encodebytes(self.asbytes())).replace("\n", "") + +- def sign_ssh_data(self, data): ++ def sign_ssh_data(self, data, algorithm=None): + """ + Sign a blob of data with this private key, and return a `.Message` + representing an SSH signature message. + +- :param str data: the data to sign. ++ :param str data: ++ the data to sign. ++ :param str algorithm: ++ the signature algorithm to use, if different from the key's ++ internal name. Default: ``None``. + :return: an SSH signature `message <.Message>`. ++ ++ .. versionchanged:: 2.9 ++ Added the ``algorithm`` kwarg. + """ + return bytes() + +diff --git a/paramiko/rsakey.py b/paramiko/rsakey.py +index 292d0ccc5..26c5313cc 100644 +--- a/paramiko/rsakey.py ++++ b/paramiko/rsakey.py +@@ -37,6 +37,15 @@ class RSAKey(PKey): + data. + """ + ++ HASHES = { ++ "ssh-rsa": hashes.SHA1, ++ "ssh-rsa-cert-v01@openssh.com": hashes.SHA1, ++ "rsa-sha2-256": hashes.SHA256, ++ "rsa-sha2-256-cert-v01@openssh.com": hashes.SHA256, ++ "rsa-sha2-512": hashes.SHA512, ++ "rsa-sha2-512-cert-v01@openssh.com": hashes.SHA512, ++ } ++ + def __init__( + self, + msg=None, +@@ -61,6 +70,8 @@ def __init__( + else: + self._check_type_and_load_cert( + msg=msg, ++ # NOTE: this does NOT change when using rsa2 signatures; it's ++ # purely about key loading, not exchange or verification + key_type="ssh-rsa", + cert_type="ssh-rsa-cert-v01@openssh.com", + ) +@@ -111,18 +122,20 @@ def get_bits(self): + def can_sign(self): + return isinstance(self.key, rsa.RSAPrivateKey) + +- def sign_ssh_data(self, data): ++ def sign_ssh_data(self, data, algorithm="ssh-rsa"): + sig = self.key.sign( +- data, padding=padding.PKCS1v15(), algorithm=hashes.SHA1() ++ data, ++ padding=padding.PKCS1v15(), ++ algorithm=self.HASHES[algorithm](), + ) +- + m = Message() +- m.add_string("ssh-rsa") ++ m.add_string(algorithm) + m.add_string(sig) + return m + + def verify_ssh_sig(self, data, msg): +- if msg.get_text() != "ssh-rsa": ++ sig_algorithm = msg.get_text() ++ if sig_algorithm not in self.HASHES: + return False + key = self.key + if isinstance(key, rsa.RSAPrivateKey): +@@ -130,7 +143,10 @@ def verify_ssh_sig(self, data, msg): + + try: + key.verify( +- msg.get_binary(), data, padding.PKCS1v15(), hashes.SHA1() ++ msg.get_binary(), ++ data, ++ padding.PKCS1v15(), ++ self.HASHES[sig_algorithm](), + ) + except InvalidSignature: + return False +diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py +index 2789be997..39fcb10dd 100644 +--- a/paramiko/ssh_exception.py ++++ b/paramiko/ssh_exception.py +@@ -135,6 +135,21 @@ def __str__(self): + ) + + ++class IncompatiblePeer(SSHException): ++ """ ++ A disagreement arose regarding an algorithm required for key exchange. ++ ++ .. versionadded:: 2.9 ++ """ ++ ++ # TODO 3.0: consider making this annotate w/ 1..N 'missing' algorithms, ++ # either just the first one that would halt kex, or even updating the ++ # Transport logic so we record /all/ that /could/ halt kex. ++ # TODO: update docstrings where this may end up raised so they are more ++ # specific. ++ pass ++ ++ + class ProxyCommandFailure(SSHException): + """ + The "ProxyCommand" found in the .ssh/config file returned an error. +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 8919043..0e291c8 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -84,6 +84,8 @@ from paramiko.common import ( + HIGHEST_USERAUTH_MESSAGE_ID, + MSG_UNIMPLEMENTED, + MSG_NAMES, ++ MSG_EXT_INFO, ++ cMSG_EXT_INFO, + ) + from paramiko.compress import ZlibCompressor, ZlibDecompressor + from paramiko.dsskey import DSSKey +@@ -107,6 +109,7 @@ from paramiko.ssh_exception import ( + SSHException, + BadAuthenticationType, + ChannelException, ++ IncompatiblePeer, + ProxyCommandFailure, + ) + from paramiko.util import retry_on_signal, ClosingContextManager, clamp_value +@@ -173,6 +176,19 @@ class Transport(threading.Thread, ClosingContextManager): + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", ++ "rsa-sha2-512", ++ "rsa-sha2-256", ++ "ssh-rsa", ++ "ssh-dss", ++ ) ++ # ~= PubKeyAcceptedAlgorithms ++ _preferred_pubkeys = ( ++ "ssh-ed25519", ++ "ecdsa-sha2-nistp256", ++ "ecdsa-sha2-nistp384", ++ "ecdsa-sha2-nistp521", ++ "rsa-sha2-512", ++ "rsa-sha2-256", + "ssh-rsa", + "ssh-dss", + ) +@@ -259,8 +275,16 @@ class Transport(threading.Thread, ClosingContextManager): + } + + _key_info = { ++ # TODO: at some point we will want to drop this as it's no longer ++ # considered secure due to using SHA-1 for signatures. OpenSSH 8.8 no ++ # longer supports it. Question becomes at what point do we want to ++ # prevent users with older setups from using this? + "ssh-rsa": RSAKey, + "ssh-rsa-cert-v01@openssh.com": RSAKey, ++ "rsa-sha2-256": RSAKey, ++ "rsa-sha2-256-cert-v01@openssh.com": RSAKey, ++ "rsa-sha2-512": RSAKey, ++ "rsa-sha2-512-cert-v01@openssh.com": RSAKey, + "ssh-dss": DSSKey, + "ssh-dss-cert-v01@openssh.com": DSSKey, + "ecdsa-sha2-nistp256": ECDSAKey, +@@ -310,6 +334,7 @@ class Transport(threading.Thread, ClosingContextManager): + gss_kex=False, + gss_deleg_creds=True, + disabled_algorithms=None, ++ server_sig_algs=True, + ): + """ + Create a new SSH session over an existing socket, or socket-like +@@ -372,6 +397,10 @@ class Transport(threading.Thread, ClosingContextManager): + your code talks to a server which implements it differently from + Paramiko), specify ``disabled_algorithms={"kex": + ["diffie-hellman-group16-sha512"]}``. ++ :param bool server_sig_algs: ++ Whether to send an extra message to compatible clients, in server ++ mode, with a list of supported pubkey algorithms. Default: ++ ``True``. + + .. versionchanged:: 1.15 + Added the ``default_window_size`` and ``default_max_packet_size`` +@@ -380,9 +409,12 @@ class Transport(threading.Thread, ClosingContextManager): + Added the ``gss_kex`` and ``gss_deleg_creds`` kwargs. + .. versionchanged:: 2.6 + Added the ``disabled_algorithms`` kwarg. ++ .. versionchanged:: 2.9 ++ Added the ``server_sig_algs`` kwarg. + """ + self.active = False + self.hostname = None ++ self.server_extensions = {} + + if isinstance(sock, string_types): + # convert "host:port" into (host, port) +@@ -488,6 +520,7 @@ class Transport(threading.Thread, ClosingContextManager): + # how long (seconds) to wait for the auth response. + self.auth_timeout = 30 + self.disabled_algorithms = disabled_algorithms or {} ++ self.server_sig_algs = server_sig_algs + + # server mode: + self.server_mode = False +@@ -517,6 +550,10 @@ class Transport(threading.Thread, ClosingContextManager): + def preferred_keys(self): + return self._filter_algorithm("keys") + ++ @property ++ def preferred_pubkeys(self): ++ return self._filter_algorithm("pubkeys") ++ + @property + def preferred_kex(self): + return self._filter_algorithm("kex") +@@ -743,6 +780,12 @@ class Transport(threading.Thread, ClosingContextManager): + the host key to add, usually an `.RSAKey` or `.DSSKey`. + """ + self.server_key_dict[key.get_name()] = key ++ # Handle SHA-2 extensions for RSA by ensuring that lookups into ++ # self.server_key_dict will yield this key for any of the algorithm ++ # names. ++ if isinstance(key, RSAKey): ++ self.server_key_dict["rsa-sha2-256"] = key ++ self.server_key_dict["rsa-sha2-512"] = key + + def get_server_key(self): + """ +@@ -1280,7 +1323,17 @@ class Transport(threading.Thread, ClosingContextManager): + Added the ``gss_trust_dns`` argument. + """ + if hostkey is not None: +- self._preferred_keys = [hostkey.get_name()] ++ # TODO: a more robust implementation would be to ask each key class ++ # for its nameS plural, and just use that. ++ # TODO: that could be used in a bunch of other spots too ++ if isinstance(hostkey, RSAKey): ++ self._preferred_keys = [ ++ "rsa-sha2-512", ++ "rsa-sha2-256", ++ "ssh-rsa", ++ ] ++ else: ++ self._preferred_keys = [hostkey.get_name()] + + self.set_gss_host( + gss_host=gss_host, +@@ -2126,7 +2179,12 @@ class Transport(threading.Thread, ClosingContextManager): + self._send_message(msg) + self.packetizer.complete_handshake() + except SSHException as e: +- self._log(ERROR, "Exception: " + str(e)) ++ self._log( ++ ERROR, ++ "Exception ({}): {}".format( ++ "server" if self.server_mode else "client", e ++ ), ++ ) + self._log(ERROR, util.tb_strings()) + self.saved_exception = e + except EOFError as e: +@@ -2237,7 +2295,7 @@ class Transport(threading.Thread, ClosingContextManager): + client = segs[2] + if version != "1.99" and version != "2.0": + msg = "Incompatible version ({} instead of 2.0)" +- raise SSHException(msg.format(version)) ++ raise IncompatiblePeer(msg.format(version)) + msg = "Connected (version {}, client {})".format(version, client) + self._log(INFO, msg) + +@@ -2253,13 +2311,10 @@ class Transport(threading.Thread, ClosingContextManager): + self.clear_to_send_lock.release() + self.gss_kex_used = False + self.in_kex = True ++ kex_algos = list(self.preferred_kex) + if self.server_mode: + mp_required_prefix = "diffie-hellman-group-exchange-sha" +- kex_mp = [ +- k +- for k in self.preferred_kex +- if k.startswith(mp_required_prefix) +- ] ++ kex_mp = [k for k in kex_algos if k.startswith(mp_required_prefix)] + if (self._modulus_pack is None) and (len(kex_mp) > 0): + # can't do group-exchange if we don't have a pack of potential + # primes +@@ -2277,11 +2332,16 @@ class Transport(threading.Thread, ClosingContextManager): + ) + else: + available_server_keys = self.preferred_keys ++ # Signal support for MSG_EXT_INFO. ++ # NOTE: doing this here handily means we don't even consider this ++ # value when agreeing on real kex algo to use (which is a common ++ # pitfall when adding this apparently). ++ kex_algos.append("ext-info-c") + + m = Message() + m.add_byte(cMSG_KEXINIT) + m.add_bytes(os.urandom(16)) +- m.add_list(self.preferred_kex) ++ m.add_list(kex_algos) + m.add_list(available_server_keys) + m.add_list(self.preferred_ciphers) + m.add_list(self.preferred_ciphers) +@@ -2294,23 +2354,46 @@ class Transport(threading.Thread, ClosingContextManager): + m.add_boolean(False) + m.add_int(0) + # save a copy for later (needed to compute a hash) +- self.local_kex_init = m.asbytes() ++ self.local_kex_init = self._latest_kex_init = m.asbytes() + self._send_message(m) + +- def _parse_kex_init(self, m): ++ def _really_parse_kex_init(self, m, ignore_first_byte=False): ++ parsed = {} ++ if ignore_first_byte: ++ m.get_byte() + m.get_bytes(16) # cookie, discarded +- kex_algo_list = m.get_list() +- server_key_algo_list = m.get_list() +- client_encrypt_algo_list = m.get_list() +- server_encrypt_algo_list = m.get_list() +- client_mac_algo_list = m.get_list() +- server_mac_algo_list = m.get_list() +- client_compress_algo_list = m.get_list() +- server_compress_algo_list = m.get_list() +- client_lang_list = m.get_list() +- server_lang_list = m.get_list() +- kex_follows = m.get_boolean() ++ parsed["kex_algo_list"] = m.get_list() ++ parsed["server_key_algo_list"] = m.get_list() ++ parsed["client_encrypt_algo_list"] = m.get_list() ++ parsed["server_encrypt_algo_list"] = m.get_list() ++ parsed["client_mac_algo_list"] = m.get_list() ++ parsed["server_mac_algo_list"] = m.get_list() ++ parsed["client_compress_algo_list"] = m.get_list() ++ parsed["server_compress_algo_list"] = m.get_list() ++ parsed["client_lang_list"] = m.get_list() ++ parsed["server_lang_list"] = m.get_list() ++ parsed["kex_follows"] = m.get_boolean() + m.get_int() # unused ++ return parsed ++ ++ def _get_latest_kex_init(self): ++ return self._really_parse_kex_init( ++ Message(self._latest_kex_init), ignore_first_byte=True ++ ) ++ ++ def _parse_kex_init(self, m): ++ parsed = self._really_parse_kex_init(m) ++ kex_algo_list = parsed["kex_algo_list"] ++ server_key_algo_list = parsed["server_key_algo_list"] ++ client_encrypt_algo_list = parsed["client_encrypt_algo_list"] ++ server_encrypt_algo_list = parsed["server_encrypt_algo_list"] ++ client_mac_algo_list = parsed["client_mac_algo_list"] ++ server_mac_algo_list = parsed["server_mac_algo_list"] ++ client_compress_algo_list = parsed["client_compress_algo_list"] ++ server_compress_algo_list = parsed["server_compress_algo_list"] ++ client_lang_list = parsed["client_lang_list"] ++ server_lang_list = parsed["server_lang_list"] ++ kex_follows = parsed["kex_follows"] + + self._log( + DEBUG, +@@ -2338,6 +2421,11 @@ class Transport(threading.Thread, ClosingContextManager): + + str(kex_follows), + ) + ++ # Strip out ext-info "kex algo" ++ self._remote_ext_info = None ++ if kex_algo_list[-1].startswith("ext-info-"): ++ self._remote_ext_info = kex_algo_list.pop() ++ + # as a server, we pick the first item in the client's list that we + # support. + # as a client, we pick the first item in our list that the server +@@ -2351,7 +2439,7 @@ class Transport(threading.Thread, ClosingContextManager): + filter(kex_algo_list.__contains__, self.preferred_kex) + ) + if len(agreed_kex) == 0: +- raise SSHException( ++ raise IncompatiblePeer( + "Incompatible ssh peer (no acceptable kex algorithm)" + ) # noqa + self.kex_engine = self._kex_info[agreed_kex[0]](self) +@@ -2374,12 +2462,12 @@ class Transport(threading.Thread, ClosingContextManager): + filter(server_key_algo_list.__contains__, self.preferred_keys) + ) + if len(agreed_keys) == 0: +- raise SSHException( ++ raise IncompatiblePeer( + "Incompatible ssh peer (no acceptable host key)" + ) # noqa + self.host_key_type = agreed_keys[0] + if self.server_mode and (self.get_server_key() is None): +- raise SSHException( ++ raise IncompatiblePeer( + "Incompatible ssh peer (can't match requested host key type)" + ) # noqa + self._log_agreement("HostKey", agreed_keys[0], agreed_keys[0]) +@@ -2411,7 +2499,7 @@ class Transport(threading.Thread, ClosingContextManager): + ) + ) + if len(agreed_local_ciphers) == 0 or len(agreed_remote_ciphers) == 0: +- raise SSHException( ++ raise IncompatiblePeer( + "Incompatible ssh server (no acceptable ciphers)" + ) # noqa + self.local_cipher = agreed_local_ciphers[0] +@@ -2435,7 +2523,9 @@ class Transport(threading.Thread, ClosingContextManager): + filter(server_mac_algo_list.__contains__, self.preferred_macs) + ) + if (len(agreed_local_macs) == 0) or (len(agreed_remote_macs) == 0): +- raise SSHException("Incompatible ssh server (no acceptable macs)") ++ raise IncompatiblePeer( ++ "Incompatible ssh server (no acceptable macs)" ++ ) + self.local_mac = agreed_local_macs[0] + self.remote_mac = agreed_remote_macs[0] + self._log_agreement( +@@ -2474,7 +2564,7 @@ class Transport(threading.Thread, ClosingContextManager): + ): + msg = "Incompatible ssh server (no acceptable compression)" + msg += " {!r} {!r} {!r}" +- raise SSHException( ++ raise IncompatiblePeer( + msg.format( + agreed_local_compression, + agreed_remote_compression, +@@ -2573,6 +2663,20 @@ class Transport(threading.Thread, ClosingContextManager): + self.packetizer.set_outbound_compressor(compress_out()) + if not self.packetizer.need_rekey(): + self.in_kex = False ++ # If client indicated extension support, send that packet immediately ++ if ( ++ self.server_mode ++ and self.server_sig_algs ++ and self._remote_ext_info == "ext-info-c" ++ ): ++ extensions = {"server-sig-algs": ",".join(self.preferred_pubkeys)} ++ m = Message() ++ m.add_byte(cMSG_EXT_INFO) ++ m.add_int(len(extensions)) ++ for name, value in sorted(extensions.items()): ++ m.add_string(name) ++ m.add_string(value) ++ self._send_message(m) + # we always expect to receive NEWKEYS now + self._expect_packet(MSG_NEWKEYS) + +@@ -2588,6 +2692,20 @@ class Transport(threading.Thread, ClosingContextManager): + self._log(DEBUG, "Switching on inbound compression ...") + self.packetizer.set_inbound_compressor(compress_in()) + ++ def _parse_ext_info(self, msg): ++ # Packet is a count followed by that many key-string to possibly-bytes ++ # pairs. ++ extensions = {} ++ for _ in range(msg.get_int()): ++ name = msg.get_text() ++ value = msg.get_string() ++ extensions[name] = value ++ self._log(DEBUG, "Got EXT_INFO: {}".format(extensions)) ++ # NOTE: this should work ok in cases where a server sends /two/ such ++ # messages; the RFC explicitly states a 2nd one should overwrite the ++ # 1st. ++ self.server_extensions = extensions ++ + def _parse_newkeys(self, m): + self._log(DEBUG, "Switch to new keys ...") + self._activate_inbound() +@@ -2855,6 +2973,7 @@ class Transport(threading.Thread, ClosingContextManager): + self.lock.release() + + _handler_table = { ++ MSG_EXT_INFO: _parse_ext_info, + MSG_NEWKEYS: _parse_newkeys, + MSG_GLOBAL_REQUEST: _parse_global_request, + MSG_REQUEST_SUCCESS: _parse_request_success, +diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst +index 1c6537f..de0d8b8 100644 +--- a/sites/www/changelog.rst ++++ b/sites/www/changelog.rst +@@ -1,6 +1,69 @@ + ========= + Changelog + ========= ++- :feature:`1643` Add support for SHA-2 variants of RSA key verification ++ algorithms (as described in :rfc:`8332`) as well as limited SSH extension ++ negotiation (:rfc:`8308`). How SSH servers/clients decide when and how to use ++ this functionality can be complicated; Paramiko's support is as follows: ++ ++ - Client verification of server host key during key exchange will now prefer ++ ``rsa-sha2-512``, ``rsa-sha2-256``, and legacy ``ssh-rsa`` algorithms, in ++ that order, instead of just ``ssh-rsa``. ++ ++ - Note that the preference order of other algorithm families such as ++ ``ed25519`` and ``ecdsa`` has not changed; for example, those two ++ groups are still preferred over RSA. ++ ++ - Server mode will now offer all 3 RSA algorithms for host key verification ++ during key exchange, similar to client mode, if it has been configured with ++ an RSA host key. ++ - Client mode key exchange now sends the ``ext-info-c`` flag signaling ++ support for ``MSG_EXT_INFO``, and support for parsing the latter ++ (specifically, its ``server-sig-algs`` flag) has been added. ++ - Client mode, when performing public key authentication with an RSA key or ++ cert, will act as follows: ++ ++ - In all cases, the list of algorithms to consider is based on the new ++ ``preferred_pubkeys`` list (see below) and ``disabled_algorithms``; this ++ list, like with host keys, prefers SHA2-512, SHA2-256 and SHA1, in that ++ order. ++ - When the server does not send ``server-sig-algs``, Paramiko will attempt ++ the first algorithm in the above list. Clients connecting to legacy ++ servers should thus use ``disabled_algorithms`` to turn off SHA2. ++ - When the server does send ``server-sig-algs``, the first algorithm ++ supported by both ends is used, or if there is none, it falls back to the ++ previous behavior. ++ ++ - Server mode is now capable of pubkey auth involving SHA-2 signatures from ++ clients, provided one's server implementation actually provides for doing ++ so. ++ ++ - This includes basic support for sending ``MSG_EXT_INFO`` (containing ++ ``server-sig-algs`` only) to clients advertising ``ext-info-c`` in their ++ key exchange list. ++ ++ In order to implement the above, the following API additions were made: ++ ++ - `PKey.sign_ssh_data `: Grew an extra, optional ++ ``algorithm`` keyword argument (defaulting to ``None`` for most subclasses, ++ and to ``"ssh-rsa"`` for `~paramiko.rsakey.RSAKey`). ++ - A new `~paramiko.ssh_exception.SSHException` subclass was added, ++ `~paramiko.ssh_exception.IncompatiblePeer`, and is raised in all spots ++ where key exchange aborts due to algorithmic incompatibility. ++ ++ - Like all other exceptions in that module, it inherits from ++ ``SSHException``, and as we did not change anything else about the raising ++ (i.e. the attributes and message text are the same) this change is ++ backwards compatible. ++ ++ - `~paramiko.transport.Transport` grew a ``_preferred_pubkeys`` attribute and ++ matching ``preferred_pubkeys`` property to match the other, kex-focused, ++ such members. This allows client pubkey authentication to honor the ++ ``disabled_algorithms`` feature. ++ ++ Thanks to Krisztián Kovács for the report and an early stab at a patch, as ++ well as the numerous users who submitted feedback on the issue, including but ++ not limited to: Christopher Rabotin, Sam Bull, and Manfred Kaiser. + + - :bug:`2001` Fix Python 2 compatibility breakage introduced in 2.10.1. Spotted + by Christian Hammond. +diff --git a/tests/test_kex.py b/tests/test_kex.py +index c251611ac..b73989c20 100644 +--- a/tests/test_kex.py ++++ b/tests/test_kex.py +@@ -76,7 +76,7 @@ def __str__(self): + def asbytes(self): + return b"fake-key" + +- def sign_ssh_data(self, H): ++ def sign_ssh_data(self, H, algorithm): + return b"fake-sig" + + +@@ -93,6 +93,7 @@ class FakeTransport(object): + remote_version = "SSH-2.0-lame" + local_kex_init = "local-kex-init" + remote_kex_init = "remote-kex-init" ++ host_key_type = "fake-key" + + def _send_message(self, m): + self._message = m +diff --git a/tests/test_pkey.py b/tests/test_pkey.py +index 94b2492bb..0cc201330 100644 +--- a/tests/test_pkey.py ++++ b/tests/test_pkey.py +@@ -63,6 +63,8 @@ + FINGER_ECDSA_384 = "384 c1:8d:a0:59:09:47:41:8e:a8:a6:07:01:29:23:b4:65" + FINGER_ECDSA_521 = "521 44:58:22:52:12:33:16:0e:ce:0e:be:2c:7c:7e:cc:1e" + SIGNED_RSA = "20:d7:8a:31:21:cb:f7:92:12:f2:a4:89:37:f5:78:af:e6:16:b6:25:b9:97:3d:a2:cd:5f:ca:20:21:73:4c:ad:34:73:8f:20:77:28:e2:94:15:08:d8:91:40:7a:85:83:bf:18:37:95:dc:54:1a:9b:88:29:6c:73:ca:38:b4:04:f1:56:b9:f2:42:9d:52:1b:29:29:b4:4f:fd:c9:2d:af:47:d2:40:76:30:f3:63:45:0c:d9:1d:43:86:0f:1c:70:e2:93:12:34:f3:ac:c5:0a:2f:14:50:66:59:f1:88:ee:c1:4a:e9:d1:9c:4e:46:f0:0e:47:6f:38:74:f1:44:a8" # noqa ++SIGNED_RSA_256 = "cc:6:60:e0:0:2c:ac:9e:26:bc:d5:68:64:3f:9f:a7:e5:aa:41:eb:88:4a:25:5:9c:93:84:66:ef:ef:60:f4:34:fb:f4:c8:3d:55:33:6a:77:bd:b2:ee:83:f:71:27:41:7e:f5:7:5:0:a9:4c:7:80:6f:be:76:67:cb:58:35:b9:2b:f3:c2:d3:3c:ee:e1:3f:59:e0:fa:e4:5c:92:ed:ae:74:de:d:d6:27:16:8f:84:a3:86:68:c:94:90:7d:6e:cc:81:12:d8:b6:ad:aa:31:a8:13:3d:63:81:3e:bb:5:b6:38:4d:2:d:1b:5b:70:de:83:cc:3a:cb:31" # noqa ++SIGNED_RSA_512 = "87:46:8b:75:92:33:78:a0:22:35:32:39:23:c6:ab:e1:6:92:ad:bc:7f:6e:ab:19:32:e4:78:b2:2c:8f:1d:c:65:da:fc:a5:7:ca:b6:55:55:31:83:b1:a0:af:d1:95:c5:2e:af:56:ba:f5:41:64:f:39:9d:af:82:43:22:8f:90:52:9d:89:e7:45:97:df:f3:f2:bc:7b:3a:db:89:e:34:fd:18:62:25:1b:ef:77:aa:c6:6c:99:36:3a:84:d6:9c:2a:34:8c:7f:f4:bb:c9:a5:9a:6c:11:f2:cf:da:51:5e:1e:7f:90:27:34:de:b2:f3:15:4f:db:47:32:6b:a7" # noqa + FINGER_RSA_2K_OPENSSH = "2048 68:d1:72:01:bf:c0:0c:66:97:78:df:ce:75:74:46:d6" + FINGER_DSS_1K_OPENSSH = "1024 cf:1d:eb:d7:61:d3:12:94:c6:c0:c6:54:35:35:b0:82" + FINGER_EC_384_OPENSSH = "384 72:14:df:c1:9a:c3:e6:0e:11:29:d6:32:18:7b:ea:9b" +@@ -238,21 +240,29 @@ def test_compare_dss(self): + self.assertTrue(not pub.can_sign()) + self.assertEqual(key, pub) + +- def test_sign_rsa(self): +- # verify that the rsa private key can sign and verify ++ def _sign_and_verify_rsa(self, algorithm, saved_sig): + key = RSAKey.from_private_key_file(_support("test_rsa.key")) +- msg = key.sign_ssh_data(b"ice weasels") +- self.assertTrue(type(msg) is Message) ++ msg = key.sign_ssh_data(b"ice weasels", algorithm) ++ assert isinstance(msg, Message) + msg.rewind() +- self.assertEqual("ssh-rsa", msg.get_text()) +- sig = bytes().join( +- [byte_chr(int(x, 16)) for x in SIGNED_RSA.split(":")] ++ assert msg.get_text() == algorithm ++ expected = bytes().join( ++ [byte_chr(int(x, 16)) for x in saved_sig.split(":")] + ) +- self.assertEqual(sig, msg.get_binary()) ++ assert msg.get_binary() == expected + msg.rewind() + pub = RSAKey(data=key.asbytes()) + self.assertTrue(pub.verify_ssh_sig(b"ice weasels", msg)) + ++ def test_sign_and_verify_ssh_rsa(self): ++ self._sign_and_verify_rsa("ssh-rsa", SIGNED_RSA) ++ ++ def test_sign_and_verify_rsa_sha2_512(self): ++ self._sign_and_verify_rsa("rsa-sha2-512", SIGNED_RSA_512) ++ ++ def test_sign_and_verify_rsa_sha2_256(self): ++ self._sign_and_verify_rsa("rsa-sha2-256", SIGNED_RSA_256) ++ + def test_sign_dss(self): + # verify that the dss private key can sign and verify + key = DSSKey.from_private_key_file(_support("test_dss.key")) +diff --git a/tests/test_transport.py b/tests/test_transport.py +index e1e37e47e..6145e5cb6 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -23,6 +23,7 @@ + from __future__ import with_statement + + from binascii import hexlify ++from contextlib import contextmanager + import select + import socket + import time +@@ -38,6 +39,8 @@ + Packetizer, + RSAKey, + SSHException, ++ AuthenticationException, ++ IncompatiblePeer, + SecurityOptions, + ServerInterface, + Transport, +@@ -80,6 +83,9 @@ class NullServer(ServerInterface): + paranoid_did_public_key = False + paranoid_key = DSSKey.from_private_key_file(_support("test_dss.key")) + ++ def __init__(self, allowed_keys=None): ++ self.allowed_keys = allowed_keys if allowed_keys is not None else [] ++ + def get_allowed_auths(self, username): + if username == "slowdive": + return "publickey,password" +@@ -90,6 +96,11 @@ def check_auth_password(self, username, password): + return AUTH_SUCCESSFUL + return AUTH_FAILED + ++ def check_auth_publickey(self, username, key): ++ if key in self.allowed_keys: ++ return AUTH_SUCCESSFUL ++ return AUTH_FAILED ++ + def check_channel_request(self, kind, chanid): + if kind == "bogus": + return OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED +@@ -154,6 +165,7 @@ def tearDown(self): + self.socks.close() + self.sockc.close() + ++ # TODO: unify with newer contextmanager + def setup_test_server( + self, client_options=None, server_options=None, connect_kwargs=None + ): +@@ -1169,3 +1181,260 @@ def test_implementation_refers_to_public_algo_lists(self): + assert "ssh-dss" not in server_keys + assert "diffie-hellman-group14-sha256" not in kexen + assert "zlib" not in compressions ++ ++ ++@contextmanager ++def server( ++ hostkey=None, ++ init=None, ++ server_init=None, ++ client_init=None, ++ connect=None, ++ pubkeys=None, ++ catch_error=False, ++): ++ """ ++ SSH server contextmanager for testing. ++ ++ :param hostkey: ++ Host key to use for the server; if None, loads ++ ``test_rsa.key``. ++ :param init: ++ Default `Transport` constructor kwargs to use for both sides. ++ :param server_init: ++ Extends and/or overrides ``init`` for server transport only. ++ :param client_init: ++ Extends and/or overrides ``init`` for client transport only. ++ :param connect: ++ Kwargs to use for ``connect()`` on the client. ++ :param pubkeys: ++ List of public keys for auth. ++ :param catch_error: ++ Whether to capture connection errors & yield from contextmanager. ++ Necessary for connection_time exception testing. ++ """ ++ if init is None: ++ init = {} ++ if server_init is None: ++ server_init = {} ++ if client_init is None: ++ client_init = {} ++ if connect is None: ++ connect = dict(username="slowdive", password="pygmalion") ++ socks = LoopSocket() ++ sockc = LoopSocket() ++ sockc.link(socks) ++ tc = Transport(sockc, **dict(init, **client_init)) ++ ts = Transport(socks, **dict(init, **server_init)) ++ ++ if hostkey is None: ++ hostkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ ts.add_server_key(hostkey) ++ event = threading.Event() ++ server = NullServer(allowed_keys=pubkeys) ++ assert not event.is_set() ++ assert not ts.is_active() ++ assert tc.get_username() is None ++ assert ts.get_username() is None ++ assert not tc.is_authenticated() ++ assert not ts.is_authenticated() ++ ++ err = None ++ # Trap errors and yield instead of raising right away; otherwise callers ++ # cannot usefully deal with problems at connect time which stem from errors ++ # in the server side. ++ try: ++ ts.start_server(event, server) ++ tc.connect(**connect) ++ ++ event.wait(1.0) ++ assert event.is_set() ++ assert ts.is_active() ++ assert tc.is_active() ++ ++ except Exception as e: ++ if not catch_error: ++ raise ++ err = e ++ ++ yield (tc, ts, err) if catch_error else (tc, ts) ++ ++ tc.close() ++ ts.close() ++ socks.close() ++ sockc.close() ++ ++ ++_disable_sha2 = dict( ++ disabled_algorithms=dict(keys=["rsa-sha2-256", "rsa-sha2-512"]) ++) ++_disable_sha1 = dict(disabled_algorithms=dict(keys=["ssh-rsa"])) ++_disable_sha2_pubkey = dict( ++ disabled_algorithms=dict(pubkeys=["rsa-sha2-256", "rsa-sha2-512"]) ++) ++_disable_sha1_pubkey = dict(disabled_algorithms=dict(pubkeys=["ssh-rsa"])) ++ ++ ++class TestSHA2SignatureKeyExchange(unittest.TestCase): ++ # NOTE: these all rely on the default server() hostkey being RSA ++ # NOTE: these rely on both sides being properly implemented re: agreed-upon ++ # hostkey during kex being what's actually used. Truly proving that eg ++ # SHA512 was used, is quite difficult w/o super gross hacks. However, there ++ # are new tests in test_pkey.py which use known signature blobs to prove ++ # the SHA2 family was in fact used! ++ ++ def test_base_case_ssh_rsa_still_used_as_fallback(self): ++ # Prove that ssh-rsa is used if either, or both, participants have SHA2 ++ # algorithms disabled ++ for which in ("init", "client_init", "server_init"): ++ with server(**{which: _disable_sha2}) as (tc, _): ++ assert tc.host_key_type == "ssh-rsa" ++ ++ def test_kex_with_sha2_512(self): ++ # It's the default! ++ with server() as (tc, _): ++ assert tc.host_key_type == "rsa-sha2-512" ++ ++ def test_kex_with_sha2_256(self): ++ # No 512 -> you get 256 ++ with server( ++ init=dict(disabled_algorithms=dict(keys=["rsa-sha2-512"])) ++ ) as (tc, _): ++ assert tc.host_key_type == "rsa-sha2-256" ++ ++ def _incompatible_peers(self, client_init, server_init): ++ with server( ++ client_init=client_init, server_init=server_init, catch_error=True ++ ) as (tc, ts, err): ++ # If neither side blew up then that's bad! ++ assert err is not None ++ # If client side blew up first, it'll be straightforward ++ if isinstance(err, IncompatiblePeer): ++ pass ++ # If server side blew up first, client sees EOF & we need to check ++ # the server transport for its saved error (otherwise it can only ++ # appear in log output) ++ elif isinstance(err, EOFError): ++ assert ts.saved_exception is not None ++ assert isinstance(ts.saved_exception, IncompatiblePeer) ++ # If it was something else, welp ++ else: ++ raise err ++ ++ def test_client_sha2_disabled_server_sha1_disabled_no_match(self): ++ self._incompatible_peers( ++ client_init=_disable_sha2, server_init=_disable_sha1 ++ ) ++ ++ def test_client_sha1_disabled_server_sha2_disabled_no_match(self): ++ self._incompatible_peers( ++ client_init=_disable_sha1, server_init=_disable_sha2 ++ ) ++ ++ def test_explicit_client_hostkey_not_limited(self): ++ # Be very explicit about the hostkey on BOTH ends, ++ # and ensure it still ends up choosing sha2-512. ++ # (This is a regression test vs previous implementation which overwrote ++ # the entire preferred-hostkeys structure when given an explicit key as ++ # a client.) ++ hostkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ with server(hostkey=hostkey, connect=dict(hostkey=hostkey)) as (tc, _): ++ assert tc.host_key_type == "rsa-sha2-512" ++ ++ ++class TestExtInfo(unittest.TestCase): ++ def test_ext_info_handshake(self): ++ with server() as (tc, _): ++ kex = tc._get_latest_kex_init() ++ assert kex["kex_algo_list"][-1] == "ext-info-c" ++ assert tc.server_extensions == { ++ "server-sig-algs": b"ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256,ssh-rsa,ssh-dss" # noqa ++ } ++ ++ def test_client_uses_server_sig_algs_for_pubkey_auth(self): ++ privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ with server( ++ pubkeys=[privkey], ++ connect=dict(pkey=privkey), ++ server_init=dict( ++ disabled_algorithms=dict(pubkeys=["rsa-sha2-512"]) ++ ), ++ ) as (tc, _): ++ assert tc.is_authenticated() ++ # Client settled on 256 despite itself not having 512 disabled ++ assert tc._agreed_pubkey_algorithm == "rsa-sha2-256" ++ ++ ++# TODO: these could move into test_auth.py but that badly needs refactoring ++# with this module anyways... ++class TestSHA2SignaturePubkeys(unittest.TestCase): ++ def test_pubkey_auth_honors_disabled_algorithms(self): ++ privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ with server( ++ pubkeys=[privkey], ++ connect=dict(pkey=privkey), ++ init=dict( ++ disabled_algorithms=dict( ++ pubkeys=["ssh-rsa", "rsa-sha2-256", "rsa-sha2-512"] ++ ) ++ ), ++ catch_error=True, ++ ) as (_, _, err): ++ assert isinstance(err, SSHException) ++ assert "no RSA pubkey algorithms" in str(err) ++ ++ def test_client_sha2_disabled_server_sha1_disabled_no_match(self): ++ privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ with server( ++ pubkeys=[privkey], ++ connect=dict(pkey=privkey), ++ client_init=_disable_sha2_pubkey, ++ server_init=_disable_sha1_pubkey, ++ catch_error=True, ++ ) as (tc, ts, err): ++ assert isinstance(err, AuthenticationException) ++ ++ def test_client_sha1_disabled_server_sha2_disabled_no_match(self): ++ privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ with server( ++ pubkeys=[privkey], ++ connect=dict(pkey=privkey), ++ client_init=_disable_sha1_pubkey, ++ server_init=_disable_sha2_pubkey, ++ catch_error=True, ++ ) as (tc, ts, err): ++ assert isinstance(err, AuthenticationException) ++ ++ def test_ssh_rsa_still_used_when_sha2_disabled(self): ++ privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ # NOTE: this works because key obj comparison uses public bytes ++ # TODO: would be nice for PKey to grow a legit "give me another obj of ++ # same class but just the public bits" using asbytes() ++ with server( ++ pubkeys=[privkey], connect=dict(pkey=privkey), init=_disable_sha2 ++ ) as (tc, _): ++ assert tc.is_authenticated() ++ ++ def test_sha2_512(self): ++ privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ with server( ++ pubkeys=[privkey], ++ connect=dict(pkey=privkey), ++ init=dict( ++ disabled_algorithms=dict(pubkeys=["ssh-rsa", "rsa-sha2-256"]) ++ ), ++ ) as (tc, ts): ++ assert tc.is_authenticated() ++ assert tc._agreed_pubkey_algorithm == "rsa-sha2-512" ++ ++ def test_sha2_256(self): ++ privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) ++ with server( ++ pubkeys=[privkey], ++ connect=dict(pkey=privkey), ++ init=dict( ++ disabled_algorithms=dict(pubkeys=["ssh-rsa", "rsa-sha2-512"]) ++ ), ++ ) as (tc, ts): ++ assert tc.is_authenticated() ++ assert tc._agreed_pubkey_algorithm == "rsa-sha2-256" diff --git a/backport-0002-CVE-2023-48795.patch b/backport-0002-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..f871091b6a6f9007a1f0dc3811f31c058992ec1e --- /dev/null +++ b/backport-0002-CVE-2023-48795.patch @@ -0,0 +1,66 @@ +From be3ffc18cc466e0b0a877d716721353c12561bcc Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Fri, 15 Dec 2023 22:14:48 -0500 +Subject: [PATCH] Make ext-info faux-KexAlgorithm detection more robust + +--- + paramiko/transport.py | 5 +++-- + sites/www/changelog.rst | 4 ++++ + tests/test_transport.py | 8 ++++++-- + 3 files changed, 13 insertions(+), 4 deletions(-) + +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 0e291c8..863bfb6 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -2423,8 +2423,9 @@ class Transport(threading.Thread, ClosingContextManager): + + # Strip out ext-info "kex algo" + self._remote_ext_info = None +- if kex_algo_list[-1].startswith("ext-info-"): +- self._remote_ext_info = kex_algo_list.pop() ++ for i, algo in enumerate(kex_algo_list): ++ if algo.startswith("ext-info-"): ++ self._remote_ext_info = kex_algo_list.pop(i) + + # as a server, we pick the first item in the client's list that we + # support. +diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst +index de0d8b8..c37bb69 100644 +--- a/sites/www/changelog.rst ++++ b/sites/www/changelog.rst +@@ -1,6 +1,10 @@ + ========= + Changelog + ========= ++ ++- :bug:`-` Tweak ``ext-info-(c|s)`` detection during KEXINIT protocol phase; ++ the original implementation made assumptions based on an OpenSSH ++ implementation detail. + - :feature:`1643` Add support for SHA-2 variants of RSA key verification + algorithms (as described in :rfc:`8332`) as well as limited SSH extension + negotiation (:rfc:`8308`). How SSH servers/clients decide when and how to use +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 894addb..fec5980 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -1343,10 +1343,14 @@ class TestSHA2SignatureKeyExchange(unittest.TestCase): + + + class TestExtInfo(unittest.TestCase): +- def test_ext_info_handshake(self): ++ def test_ext_info_handshake_exposed_in_client_kexinit(self): + with server() as (tc, _): ++ # NOTE: this is latest KEXINIT /sent by us/ (Transport retains it) + kex = tc._get_latest_kex_init() +- assert kex["kex_algo_list"][-1] == "ext-info-c" ++ # flag in KexAlgorithms list ++ assert "ext-info-c" in kex["kex_algo_list"] ++ # data stored on Transport after hearing back from a compatible ++ # server (such as ourselves in server mode) + assert tc.server_extensions == { + "server-sig-algs": b"ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256,ssh-rsa,ssh-dss" # noqa + } +-- +2.33.0 + diff --git a/backport-0003-CVE-2023-48795.patch b/backport-0003-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..819bd5d909437b5272c4a8d7209e1ccc566021e6 --- /dev/null +++ b/backport-0003-CVE-2023-48795.patch @@ -0,0 +1,167 @@ +From 773a174fb1e40e1d18dbe2625e16337ea401119e Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Fri, 15 Dec 2023 23:59:12 -0500 +Subject: [PATCH] Basic strict-kex-mode agreement mechanics work + +--- + paramiko/transport.py | 38 +++++++++++++++++++++++++++++++++--- + tests/test_transport.py | 43 +++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 78 insertions(+), 3 deletions(-) + +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 863bfb6..4f801af 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -335,6 +335,7 @@ class Transport(threading.Thread, ClosingContextManager): + gss_deleg_creds=True, + disabled_algorithms=None, + server_sig_algs=True, ++ strict_kex=True, + ): + """ + Create a new SSH session over an existing socket, or socket-like +@@ -401,6 +402,10 @@ class Transport(threading.Thread, ClosingContextManager): + Whether to send an extra message to compatible clients, in server + mode, with a list of supported pubkey algorithms. Default: + ``True``. ++ :param bool strict_kex: ++ Whether to advertise (and implement, if client also advertises ++ support for) a "strict kex" mode for safer handshaking. Default: ++ ``True``. + + .. versionchanged:: 1.15 + Added the ``default_window_size`` and ``default_max_packet_size`` +@@ -411,10 +416,14 @@ class Transport(threading.Thread, ClosingContextManager): + Added the ``disabled_algorithms`` kwarg. + .. versionchanged:: 2.9 + Added the ``server_sig_algs`` kwarg. ++ .. versionchanged:: 3.4 ++ Added the ``strict_kex`` kwarg. + """ + self.active = False + self.hostname = None + self.server_extensions = {} ++ self.advertise_strict_kex = strict_kex ++ self.agreed_on_strict_kex = False + + if isinstance(sock, string_types): + # convert "host:port" into (host, port) +@@ -2332,12 +2341,18 @@ class Transport(threading.Thread, ClosingContextManager): + ) + else: + available_server_keys = self.preferred_keys +- # Signal support for MSG_EXT_INFO. ++ # Signal support for MSG_EXT_INFO so server will send it to us. + # NOTE: doing this here handily means we don't even consider this + # value when agreeing on real kex algo to use (which is a common + # pitfall when adding this apparently). + kex_algos.append("ext-info-c") + ++ # Similar to ext-info, but used in both server modes, so done outside ++ # of above if/else. ++ if self.advertise_strict_kex: ++ which = "s" if self.server_mode else "c" ++ kex_algos.append(f"kex-strict-{which}-v00@openssh.com") ++ + m = Message() + m.add_byte(cMSG_KEXINIT) + m.add_bytes(os.urandom(16)) +@@ -2421,11 +2436,28 @@ class Transport(threading.Thread, ClosingContextManager): + + str(kex_follows), + ) + +- # Strip out ext-info "kex algo" ++ # Record, and strip out, ext-info and/or strict-kex non-algorithms + self._remote_ext_info = None ++ self._remote_strict_kex = None ++ to_pop = [] + for i, algo in enumerate(kex_algo_list): + if algo.startswith("ext-info-"): +- self._remote_ext_info = kex_algo_list.pop(i) ++ self._remote_ext_info = algo ++ to_pop.insert(0, i) ++ elif algo.startswith("kex-strict-"): ++ # NOTE: this is what we are expecting from the /remote/ end. ++ which = "c" if self.server_mode else "s" ++ expected = f"kex-strict-{which}-v00@openssh.com" ++ # Set strict mode if agreed. ++ self.agreed_on_strict_kex = ( ++ algo == expected and self.advertise_strict_kex ++ ) ++ self._log( ++ DEBUG, f"Strict kex mode: {self.agreed_on_strict_kex}" ++ ) ++ to_pop.insert(0, i) ++ for i in to_pop: ++ kex_algo_list.pop(i) + + # as a server, we pick the first item in the client's list that we + # support. +diff --git a/tests/test_transport.py b/tests/test_transport.py +index fec5980..d8d0d65 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -24,6 +24,7 @@ from __future__ import with_statement + + from binascii import hexlify + from contextlib import contextmanager ++import itertools + import select + import socket + import time +@@ -63,6 +64,7 @@ from paramiko.message import Message + + from .util import needs_builtin, _support, slow + from .loop import LoopSocket ++from pytest import skip, mark + + + LONG_BANNER = """\ +@@ -1442,3 +1444,44 @@ class TestSHA2SignaturePubkeys(unittest.TestCase): + ) as (tc, ts): + assert tc.is_authenticated() + assert tc._agreed_pubkey_algorithm == "rsa-sha2-256" ++ ++ ++class TestStrictKex: ++ def test_kex_algos_includes_kex_strict_c(self): ++ with server() as (tc, _): ++ kex = tc._get_latest_kex_init() ++ assert "kex-strict-c-v00@openssh.com" in kex["kex_algo_list"] ++ ++ @mark.parametrize( ++ "server_active,client_active", ++ itertools.product([True, False], repeat=2), ++ ) ++ def test_mode_agreement(self, server_active, client_active): ++ with server( ++ server_init=dict(strict_kex=server_active), ++ client_init=dict(strict_kex=client_active), ++ ) as (tc, ts): ++ if server_active and client_active: ++ assert tc.agreed_on_strict_kex is True ++ assert ts.agreed_on_strict_kex is True ++ else: ++ assert tc.agreed_on_strict_kex is False ++ assert ts.agreed_on_strict_kex is False ++ ++ def test_mode_advertised_by_default(self): ++ # NOTE: no explicit strict_kex overrides... ++ with server() as (tc, ts): ++ assert all( ++ ( ++ tc.advertise_strict_kex, ++ tc.agreed_on_strict_kex, ++ ts.advertise_strict_kex, ++ ts.agreed_on_strict_kex, ++ ) ++ ) ++ ++ def test_sequence_numbers_reset_on_newkeys(self): ++ skip() ++ ++ def test_error_raised_on_out_of_order_handshakes(self): ++ skip() +-- +2.33.0 + diff --git a/backport-0004-CVE-2023-48795.patch b/backport-0004-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..9c01a69f22c1bf23d1c01a6bf6b2953e579db50d --- /dev/null +++ b/backport-0004-CVE-2023-48795.patch @@ -0,0 +1,112 @@ +From f4dedacb9040d27d9844f51c81c28e0247d3e4a3 Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sat, 16 Dec 2023 13:02:05 -0500 +Subject: [PATCH] Raise new exception type when unexpected messages appear + +--- + paramiko/__init__.py | 1 + + paramiko/ssh_exception.py | 9 +++++++++ + paramiko/transport.py | 6 +++++- + tests/test_transport.py | 22 +++++++++++++++++++--- + 4 files changed, 34 insertions(+), 4 deletions(-) + +diff --git a/paramiko/__init__.py b/paramiko/__init__.py +index 5318cc9..f7a226c 100644 +--- a/paramiko/__init__.py ++++ b/paramiko/__init__.py +@@ -43,6 +43,7 @@ from paramiko.ssh_exception import ( + ConfigParseError, + CouldNotCanonicalize, + IncompatiblePeer, ++ MessageOrderError, + PasswordRequiredException, + ProxyCommandFailure, + SSHException, +diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py +index 39fcb10..7628a60 100644 +--- a/paramiko/ssh_exception.py ++++ b/paramiko/ssh_exception.py +@@ -235,3 +235,12 @@ class ConfigParseError(SSHException): + """ + + pass ++ ++ ++class MessageOrderError(SSHException): ++ """ ++ Out-of-order protocol messages were received, violating "strict kex" mode. ++ .. versionadded:: 3.4 ++ """ ++ ++ pass +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 4f801af..7845710 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -110,6 +110,7 @@ from paramiko.ssh_exception import ( + BadAuthenticationType, + ChannelException, + IncompatiblePeer, ++ MessageOrderError, + ProxyCommandFailure, + ) + from paramiko.util import retry_on_signal, ClosingContextManager, clamp_value +@@ -2127,7 +2128,10 @@ class Transport(threading.Thread, ClosingContextManager): + continue + if len(self._expected_packet) > 0: + if ptype not in self._expected_packet: +- raise SSHException( ++ exc_class = SSHException ++ if self.agreed_on_strict_kex: ++ exc_class = MessageOrderError ++ raise exc_class( + "Expecting packet from {!r}, got {:d}".format( + self._expected_packet, ptype + ) +diff --git a/tests/test_transport.py b/tests/test_transport.py +index d8d0d65..1ce3e17 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -42,6 +42,7 @@ from paramiko import ( + SSHException, + AuthenticationException, + IncompatiblePeer, ++ MessageOrderError, + SecurityOptions, + ServerInterface, + Transport, +@@ -64,7 +65,7 @@ from paramiko.message import Message + + from .util import needs_builtin, _support, slow + from .loop import LoopSocket +-from pytest import skip, mark ++from pytest import skip, mark, raises + + + LONG_BANNER = """\ +@@ -1483,5 +1484,20 @@ class TestStrictKex: + def test_sequence_numbers_reset_on_newkeys(self): + skip() + +- def test_error_raised_on_out_of_order_handshakes(self): +- skip() ++ def test_MessageOrderError_raised_on_out_of_order_messages(self): ++ with raises(MessageOrderError): ++ with server() as (tc, _): ++ # A bit artificial as it's outside kexinit/handshake, but much ++ # easier to trigger and still in line with behavior under test ++ tc._expect_packet(MSG_KEXINIT) ++ tc.open_session() ++ ++ def test_SSHException_raised_on_out_of_order_messages_when_not_strict(self): ++ # This is kind of dumb (either situation is still fatal!) but whatever, ++ # may as well be strict with our new strict flag... ++ with raises(SSHException) as info: # would be true either way, but ++ with server(client_init=dict(strict_kex=False), ++ ) as (tc, _): ++ tc._expect_packet(MSG_KEXINIT) ++ tc.open_session() ++ assert info.type is SSHException # NOT MessageOrderError! +-- +2.33.0 + diff --git a/backport-0005-CVE-2023-48795.patch b/backport-0005-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..9ca48baba1b859c5ef6caad799b36c8b2fe4f4f5 --- /dev/null +++ b/backport-0005-CVE-2023-48795.patch @@ -0,0 +1,188 @@ +From 75e311d3c0845a316b6e7b3fae2488d86ad5a270 Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sat, 16 Dec 2023 16:17:58 -0500 +Subject: [PATCH] Enforce zero seqno on kexinit + +--- + paramiko/transport.py | 18 ++++++++++-- + sites/www/changelog.rst | 3 ++ + tests/test_transport.py | 61 +++++++++++++++++++++++++++++++++++++---- + 3 files changed, 74 insertions(+), 8 deletions(-) + +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 7845710..7baadba 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -337,6 +337,7 @@ class Transport(threading.Thread, ClosingContextManager): + disabled_algorithms=None, + server_sig_algs=True, + strict_kex=True, ++ packetizer_class=None, + ): + """ + Create a new SSH session over an existing socket, or socket-like +@@ -407,6 +408,9 @@ class Transport(threading.Thread, ClosingContextManager): + Whether to advertise (and implement, if client also advertises + support for) a "strict kex" mode for safer handshaking. Default: + ``True``. ++ :param packetizer_class: ++ Which class to use for instantiating the internal packet handler. ++ Default: ``None`` (i.e.: use `Packetizer` as normal). + + .. versionchanged:: 1.15 + Added the ``default_window_size`` and ``default_max_packet_size`` +@@ -419,6 +423,8 @@ class Transport(threading.Thread, ClosingContextManager): + Added the ``server_sig_algs`` kwarg. + .. versionchanged:: 3.4 + Added the ``strict_kex`` kwarg. ++ .. versionchanged:: 3.4 ++ Added the ``packetizer_class`` kwarg. + """ + self.active = False + self.hostname = None +@@ -466,7 +472,7 @@ class Transport(threading.Thread, ClosingContextManager): + self.sock.settimeout(self._active_check_timeout) + + # negotiated crypto parameters +- self.packetizer = Packetizer(sock) ++ self.packetizer = (packetizer_class or Packetizer)(sock) + self.local_version = "SSH-" + self._PROTO_ID + "-" + self._CLIENT_ID + self.remote_version = "" + self.local_cipher = self.remote_cipher = "" +@@ -2397,7 +2403,8 @@ class Transport(threading.Thread, ClosingContextManager): + + def _get_latest_kex_init(self): + return self._really_parse_kex_init( +- Message(self._latest_kex_init), ignore_first_byte=True ++ Message(self._latest_kex_init), ++ ignore_first_byte=True, + ) + + def _parse_kex_init(self, m): +@@ -2463,6 +2470,13 @@ class Transport(threading.Thread, ClosingContextManager): + for i in to_pop: + kex_algo_list.pop(i) + ++ # CVE mitigation: expect zeroed-out seqno anytime we are performing kex ++ # init phase, if strict mode was negotiated. ++ if self.agreed_on_strict_kex and m.seqno != 0: ++ raise MessageOrderError( ++ f"Got nonzero seqno ({m.seqno}) during strict KEXINIT!" ++ ) ++ + # as a server, we pick the first item in the client's list that we + # support. + # as a client, we pick the first item in our list that the server +diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst +index c37bb69..ba557ef 100644 +--- a/sites/www/changelog.rst ++++ b/sites/www/changelog.rst +@@ -2,6 +2,9 @@ + Changelog + ========= + ++- :feature:`-` `Transport` grew a new ``packetizer_class`` kwarg for overriding ++ the packet-handler class used internally. Mostly for testing, but advanced ++ users may find this useful when doing deep hacks. + - :bug:`-` Tweak ``ext-info-(c|s)`` detection during KEXINIT protocol phase; + the original implementation made assumptions based on an OpenSSH + implementation detail. +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 1ce3e17..c5a480f 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -1118,6 +1118,16 @@ class TransportTest(unittest.TestCase): + # Real fix's behavior + self._expect_unimplemented() + ++ def test_can_override_packetizer_used(self): ++ class MyPacketizer(Packetizer): ++ pass ++ ++ # control case ++ assert Transport(sock=LoopSocket()).packetizer.__class__ is Packetizer ++ # overridden case ++ tweaked = Transport(sock=LoopSocket(), packetizer_class=MyPacketizer) ++ assert tweaked.packetizer.__class__ is MyPacketizer ++ + + class AlgorithmDisablingTests(unittest.TestCase): + def test_preferred_lists_default_to_private_attribute_contents(self): +@@ -1446,6 +1456,19 @@ class TestSHA2SignaturePubkeys(unittest.TestCase): + assert tc.is_authenticated() + assert tc._agreed_pubkey_algorithm == "rsa-sha2-256" + ++class BadSeqPacketizer(Packetizer): ++ def read_message(self): ++ cmd, msg = super().read_message() ++ # Only mess w/ seqno if kexinit. ++ if cmd is MSG_KEXINIT: ++ # NOTE: this is /only/ the copy of the seqno which gets ++ # transmitted up from Packetizer; it's not modifying ++ # Packetizer's own internal seqno. For these tests, ++ # modifying the latter isn't required, and is also harder ++ # to do w/o triggering MAC mismatches. ++ msg.seqno = 17 # arbitrary nonzero int ++ return cmd, msg ++ + + class TestStrictKex: + def test_kex_algos_includes_kex_strict_c(self): +@@ -1481,9 +1504,6 @@ class TestStrictKex: + ) + ) + +- def test_sequence_numbers_reset_on_newkeys(self): +- skip() +- + def test_MessageOrderError_raised_on_out_of_order_messages(self): + with raises(MessageOrderError): + with server() as (tc, _): +@@ -1492,12 +1512,41 @@ class TestStrictKex: + tc._expect_packet(MSG_KEXINIT) + tc.open_session() + +- def test_SSHException_raised_on_out_of_order_messages_when_not_strict(self): ++ def est_SSHException_raised_on_out_of_order_messages_when_not_strict( ++ self, ++ ): + # This is kind of dumb (either situation is still fatal!) but whatever, + # may as well be strict with our new strict flag... + with raises(SSHException) as info: # would be true either way, but +- with server(client_init=dict(strict_kex=False), +- ) as (tc, _): ++ with server( ++ client_init=dict(strict_kex=False), ++ ) as (tc, _): + tc._expect_packet(MSG_KEXINIT) + tc.open_session() + assert info.type is SSHException # NOT MessageOrderError! ++ ++ def test_error_not_raised_when_kexinit_not_seq_0_but_unstrict(self): ++ with server( ++ client_init=dict( ++ # Disable strict kex ++ strict_kex=False, ++ # Give our clientside a packetizer that sets all kexinit ++ # Message objects to have .seqno==17, which would trigger the ++ # new logic if we'd forgotten to wrap it in strict-kex check ++ packetizer_class=BadSeqPacketizer, ++ ), ++ ): ++ pass # kexinit happens at connect... ++ ++ def test_MessageOrderError_raised_when_kexinit_not_seq_0_and_strict(self): ++ with raises(MessageOrderError): ++ with server( ++ # Give our clientside a packetizer that sets all kexinit ++ # Message objects to have .seqno==17, which should trigger the ++ # new logic (given we are NOT disabling strict-mode) ++ client_init=dict(packetizer_class=BadSeqPacketizer), ++ ): ++ pass # kexinit happens at connect... ++ ++ def test_sequence_numbers_reset_on_newkeys(self): ++ skip() +-- +2.33.0 + diff --git a/backport-0006-CVE-2023-48795.patch b/backport-0006-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..2203fd58ec57b46ec320f8bf7a86e9e6a60195b7 --- /dev/null +++ b/backport-0006-CVE-2023-48795.patch @@ -0,0 +1,112 @@ +From fa46de7feeeb8a01dc471581a0258252ce4f2db6 Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sat, 16 Dec 2023 17:12:42 -0500 +Subject: [PATCH] Reset sequence numbers on rekey + +--- + paramiko/packet.py | 6 ++++++ + paramiko/transport.py | 22 ++++++++++++++++++++-- + tests/test_transport.py | 25 +++++++++++++++++++++++-- + 3 files changed, 49 insertions(+), 4 deletions(-) + +diff --git a/paramiko/packet.py b/paramiko/packet.py +index 1266316..1fc06d9 100644 +--- a/paramiko/packet.py ++++ b/paramiko/packet.py +@@ -130,6 +130,12 @@ class Packetizer(object): + def closed(self): + return self.__closed + ++ def reset_seqno_out(self): ++ self.__sequence_number_out = 0 ++ ++ def reset_seqno_in(self): ++ self.__sequence_number_in = 0 ++ + def set_log(self, log): + """ + Set the Python log object to use for logging. +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 7baadba..356e738 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -2472,9 +2472,13 @@ class Transport(threading.Thread, ClosingContextManager): + + # CVE mitigation: expect zeroed-out seqno anytime we are performing kex + # init phase, if strict mode was negotiated. +- if self.agreed_on_strict_kex and m.seqno != 0: ++ if ( ++ self.agreed_on_strict_kex ++ and not self.initial_kex_done ++ and m.seqno != 0 ++ ): + raise MessageOrderError( +- f"Got nonzero seqno ({m.seqno}) during strict KEXINIT!" ++ "In strict-kex mode, but KEXINIT was not the first packet!" + ) + + # as a server, we pick the first item in the client's list that we +@@ -2672,6 +2676,13 @@ class Transport(threading.Thread, ClosingContextManager): + ): + self._log(DEBUG, "Switching on inbound compression ...") + self.packetizer.set_inbound_compressor(compress_in()) ++ # Reset inbound sequence number if strict mode. ++ if self.agreed_on_strict_kex: ++ self._log( ++ DEBUG, ++ f"Resetting inbound seqno after NEWKEYS due to strict mode", ++ ) ++ self.packetizer.reset_seqno_in() + + def _activate_outbound(self): + """switch on newly negotiated encryption parameters for +@@ -2679,6 +2690,13 @@ class Transport(threading.Thread, ClosingContextManager): + m = Message() + m.add_byte(cMSG_NEWKEYS) + self._send_message(m) ++ # Reset outbound sequence number if strict mode. ++ if self.agreed_on_strict_kex: ++ self._log( ++ DEBUG, ++ f"Resetting outbound sequence number after NEWKEYS due to strict mode", ++ ) ++ self.packetizer.reset_seqno_out() + block_size = self._cipher_info[self.local_cipher]["block-size"] + if self.server_mode: + IV_out = self._compute_key("B", block_size) +diff --git a/tests/test_transport.py b/tests/test_transport.py +index c5a480f..db34d01 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -1548,5 +1548,26 @@ class TestStrictKex: + ): + pass # kexinit happens at connect... + +- def test_sequence_numbers_reset_on_newkeys(self): +- skip() ++ def test_sequence_numbers_reset_on_newkeys_when_strict(self): ++ with server(defer=True) as (tc, ts): ++ # When in strict mode, these should all be zero or close to it ++ # (post-kexinit, pre-auth). ++ # Server->client will be 1 (EXT_INFO got sent after NEWKEYS) ++ assert tc.packetizer._Packetizer__sequence_number_in == 1 ++ assert ts.packetizer._Packetizer__sequence_number_out == 1 ++ # Client->server will be 0 ++ assert tc.packetizer._Packetizer__sequence_number_out == 0 ++ assert ts.packetizer._Packetizer__sequence_number_in == 0 ++ ++ def test_sequence_numbers_not_reset_on_newkeys_when_not_strict(self): ++ with server(defer=True, client_init=dict(strict_kex=False)) as ( ++ tc, ++ ts, ++ ): ++ # When not in strict mode, these will all be ~3-4 or so ++ # (post-kexinit, pre-auth). Not encoding exact values as it will ++ # change anytime we mess with the test harness... ++ assert tc.packetizer._Packetizer__sequence_number_in != 0 ++ assert tc.packetizer._Packetizer__sequence_number_out != 0 ++ assert ts.packetizer._Packetizer__sequence_number_in != 0 ++ assert ts.packetizer._Packetizer__sequence_number_out != 0 +-- +2.33.0 + diff --git a/backport-0007-CVE-2023-48795.patch b/backport-0007-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..ffe8f51b21b3b97458b230e88097ebf2e3b0b401 --- /dev/null +++ b/backport-0007-CVE-2023-48795.patch @@ -0,0 +1,159 @@ +From 96db1e2be856eac66631761bae41167a1ebd2b4e Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sun, 17 Dec 2023 17:13:53 -0500 +Subject: [PATCH] Raise exception when sequence numbers rollover during initial + kex + +--- + paramiko/packet.py | 17 +++++++++++++---- + paramiko/transport.py | 4 +++- + sites/www/changelog.rst | 28 ++++++++++++++++++++++++++++ + tests/test_transport.py | 32 ++++++++++++++++++++++++++++++++ + 4 files changed, 76 insertions(+), 5 deletions(-) + +diff --git a/paramiko/packet.py b/paramiko/packet.py +index 1fc06d9..8b9e6d6 100644 +--- a/paramiko/packet.py ++++ b/paramiko/packet.py +@@ -86,6 +86,7 @@ class Packetizer(object): + self.__need_rekey = False + self.__init_count = 0 + self.__remainder = bytes() ++ self._initial_kex_done = False + + # used for noticing when to re-key: + self.__sent_bytes = 0 +@@ -431,9 +432,12 @@ class Packetizer(object): + out += compute_hmac( + self.__mac_key_out, payload, self.__mac_engine_out + )[: self.__mac_size_out] +- self.__sequence_number_out = ( +- self.__sequence_number_out + 1 +- ) & xffffffff ++ next_seq = (self.__sequence_number_out + 1) & xffffffff ++ if next_seq == 0 and not self._initial_kex_done: ++ raise SSHException( ++ "Sequence number rolled over during initial kex!" ++ ) ++ self.__sequence_number_out = next_seq + self.write_all(out) + + self.__sent_bytes += len(out) +@@ -537,7 +541,12 @@ class Packetizer(object): + + msg = Message(payload[1:]) + msg.seqno = self.__sequence_number_in +- self.__sequence_number_in = (self.__sequence_number_in + 1) & xffffffff ++ next_seq = (self.__sequence_number_in + 1) & xffffffff ++ if next_seq == 0 and not self._initial_kex_done: ++ raise SSHException( ++ "Sequence number rolled over during initial kex!" ++ ) ++ self.__sequence_number_in = next_seq + + # check for rekey + raw_packet_size = packet_size + self.__mac_size_in + 4 +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 356e738..83837a0 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -2787,7 +2787,9 @@ class Transport(threading.Thread, ClosingContextManager): + self.auth_handler = AuthHandler(self) + if not self.initial_kex_done: + # this was the first key exchange +- self.initial_kex_done = True ++ # (also signal to packetizer as it sometimes wants to know this ++ # staus as well, eg when seqnos rollover) ++ self.initial_kex_done = self.packetizer._initial_kex_done = True + # send an event? + if self.completion_event is not None: + self.completion_event.set() +diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst +index ba557ef..30a985a 100644 +--- a/sites/www/changelog.rst ++++ b/sites/www/changelog.rst +@@ -2,6 +2,34 @@ + Changelog + ========= + ++- :bug:`-` Address `CVE 2023-48795`_ (aka the ++ "Terrapin Attack", a vulnerability found in the SSH protocol re: treatment of ++ packet sequence numbers) as follows: ++ - The vulnerability only impacts encrypt-then-MAC digest algorithms in ++ tandem with CBC ciphers, and ChaCha20-poly1305; of these, Paramiko ++ currently only implements ``hmac-sha2-(256|512)-etm`` in tandem with ++ ``AES-CBC``. If you are unable to upgrade to Paramiko versions containing ++ the below fixes right away, you may instead use the ++ ``disabled_algorithms`` connection option to disable the ETM MACs and/or ++ the CBC ciphers (this option is present in Paramiko >=2.6). ++ - As the fix for the vulnerability requires both ends of the connection to ++ cooperate, the below changes will only take effect when the remote end is ++ OpenSSH >= TK (or equivalent, such as Paramiko in server mode, as of this ++ patch version) and configured to use the new "strict kex" mode. Paramiko ++ will always attempt to use "strict kex" mode if offered by the server, ++ unless you override this by specifying ``strict_kex=False`` in ++ `Transport.__init__`. ++ - Paramiko will now raise an `SSHException` subclass (`MessageOrderError`) ++ when protocol messages are received in unexpected order. (This is not ++ *really* a change in behavior, as most such cases already raised vanilla ++ `SSHException` anyways.) ++ - Key (re)negotiation -- i.e. ``MSG_NEWKEYS``, whenever it is encountered ++ -- now resets packet sequence numbers. (This should be invisible to users ++ during normal operation, only causing exceptions if the exploit is ++ encountered, which will usually result in, again, `MessageOrderError`.) ++ - Sequence number rollover will now raise `SSHException` if it occurs ++ during initial key exchange (regardless of strict mode status). ++ + - :feature:`-` `Transport` grew a new ``packetizer_class`` kwarg for overriding + the packet-handler class used internally. Mostly for testing, but advanced + users may find this useful when doing deep hacks. +diff --git a/tests/test_transport.py b/tests/test_transport.py +index db34d01..8dbac43 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -30,6 +30,7 @@ import socket + import time + import threading + import random ++import sys + import unittest + from mock import Mock + +@@ -1571,3 +1572,34 @@ class TestStrictKex: + assert tc.packetizer._Packetizer__sequence_number_out != 0 + assert ts.packetizer._Packetizer__sequence_number_in != 0 + assert ts.packetizer._Packetizer__sequence_number_out != 0 ++ ++ def test_sequence_number_rollover_detected(self): ++ class RolloverTransport(Transport): ++ def __init__(self, *args, **kwargs): ++ super().__init__(*args, **kwargs) ++ # Induce an about-to-rollover seqno, such that it rolls over ++ # during initial kex. ++ setattr( ++ self.packetizer, ++ f"_Packetizer__sequence_number_in", ++ sys.maxsize, ++ ) ++ setattr( ++ self.packetizer, ++ f"_Packetizer__sequence_number_out", ++ sys.maxsize, ++ ) ++ ++ with raises( ++ SSHException, ++ match=r"Sequence number rolled over during initial kex!", ++ ): ++ with server( ++ client_init=dict( ++ # Disable strict kex - this should happen always ++ strict_kex=False, ++ ), ++ # Transport which tickles its packetizer seqno's ++ transport_factory=RolloverTransport, ++ ): ++ pass # kexinit happens at connect... +-- +2.33.0 + diff --git a/backport-0008-CVE-2023-48795.patch b/backport-0008-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..ecd2d7067ab1ce5150e2ca46fcc44efc3d7497dd --- /dev/null +++ b/backport-0008-CVE-2023-48795.patch @@ -0,0 +1,285 @@ +From e22c5ea330814801d8487dc3da347f987bafe5ec Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Thu, 4 May 2023 13:52:40 -0400 +Subject: [PATCH] Start consolidating test server nonsense + +Reference:https://github.com/paramiko/paramiko/commit/e22c5ea330814801d8487dc3da347f987bafe5ec +Conflict:Currently, _util.py does not exist due to different versions. Therefore, the reconstruction code of test_transport.py is still stored in this file + The key name must be the same as the current one. + +--- + tests/test_transport.py | 198 ++++++++++++++++++++++++++++++++++++---- + 1 file changed, 181 insertions(+), 17 deletions(-) + +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 4ed712e..6cdbfd6 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -33,6 +33,7 @@ import random + import sys + import unittest + from mock import Mock ++from time import sleep + + from paramiko import ( + AuthHandler, +@@ -1196,6 +1197,146 @@ class AlgorithmDisablingTests(unittest.TestCase): + assert "diffie-hellman-group14-sha256" not in kexen + assert "zlib" not in compressions + ++_disable_sha2 = dict( ++ disabled_algorithms=dict(keys=["rsa-sha2-256", "rsa-sha2-512"]) ++) ++_disable_sha1 = dict(disabled_algorithms=dict(keys=["ssh-rsa"])) ++_disable_sha2_pubkey = dict( ++ disabled_algorithms=dict(pubkeys=["rsa-sha2-256", "rsa-sha2-512"]) ++) ++_disable_sha1_pubkey = dict(disabled_algorithms=dict(pubkeys=["ssh-rsa"])) ++ ++ ++unicodey = "\u2022" ++ ++ ++class TestServer(ServerInterface): ++ paranoid_did_password = False ++ paranoid_did_public_key = False ++ # TODO: make this ed25519 or something else modern? (_is_ this used??) ++ paranoid_key = DSSKey.from_private_key_file(_support("test_dss.key")) ++ ++ def __init__(self, allowed_keys=None): ++ self.allowed_keys = allowed_keys if allowed_keys is not None else [] ++ ++ def check_channel_request(self, kind, chanid): ++ if kind == "bogus": ++ return OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED ++ return OPEN_SUCCEEDED ++ ++ def check_channel_exec_request(self, channel, command): ++ if command != b"yes": ++ return False ++ return True ++ ++ def check_channel_shell_request(self, channel): ++ return True ++ ++ def check_global_request(self, kind, msg): ++ self._global_request = kind ++ # NOTE: for w/e reason, older impl of this returned False always, even ++ # tho that's only supposed to occur if the request cannot be served. ++ # For now, leaving that the default unless test supplies specific ++ # 'acceptable' request kind ++ return kind == "acceptable" ++ ++ def check_channel_x11_request( ++ self, ++ channel, ++ single_connection, ++ auth_protocol, ++ auth_cookie, ++ screen_number, ++ ): ++ self._x11_single_connection = single_connection ++ self._x11_auth_protocol = auth_protocol ++ self._x11_auth_cookie = auth_cookie ++ self._x11_screen_number = screen_number ++ return True ++ ++ def check_port_forward_request(self, addr, port): ++ self._listen = socket.socket() ++ self._listen.bind(("127.0.0.1", 0)) ++ self._listen.listen(1) ++ return self._listen.getsockname()[1] ++ ++ def cancel_port_forward_request(self, addr, port): ++ self._listen.close() ++ self._listen = None ++ ++ def check_channel_direct_tcpip_request(self, chanid, origin, destination): ++ self._tcpip_dest = destination ++ return OPEN_SUCCEEDED ++ ++ def get_allowed_auths(self, username): ++ if username == "slowdive": ++ return "publickey,password" ++ if username == "paranoid": ++ if ( ++ not self.paranoid_did_password ++ and not self.paranoid_did_public_key ++ ): ++ return "publickey,password" ++ elif self.paranoid_did_password: ++ return "publickey" ++ else: ++ return "password" ++ if username == "commie": ++ return "keyboard-interactive" ++ if username == "utf8": ++ return "password" ++ if username == "non-utf8": ++ return "password" ++ return "publickey" ++ ++ def check_auth_password(self, username, password): ++ if (username == "slowdive") and (password == "pygmalion"): ++ return AUTH_SUCCESSFUL ++ if (username == "paranoid") and (password == "paranoid"): ++ # 2-part auth (even openssh doesn't support this) ++ self.paranoid_did_password = True ++ if self.paranoid_did_public_key: ++ return AUTH_SUCCESSFUL ++ return AUTH_PARTIALLY_SUCCESSFUL ++ if (username == "utf8") and (password == unicodey): ++ return AUTH_SUCCESSFUL ++ if (username == "non-utf8") and (password == "\xff"): ++ return AUTH_SUCCESSFUL ++ if username == "bad-server": ++ raise Exception("Ack!") ++ if username == "unresponsive-server": ++ time.sleep(5) ++ return AUTH_SUCCESSFUL ++ return AUTH_FAILED ++ ++ def check_auth_publickey(self, username, key): ++ if (username == "paranoid") and (key == self.paranoid_key): ++ # 2-part auth ++ self.paranoid_did_public_key = True ++ if self.paranoid_did_password: ++ return AUTH_SUCCESSFUL ++ return AUTH_PARTIALLY_SUCCESSFUL ++ # TODO: make sure all tests incidentally using this to pass, _without ++ # sending a username oops_, get updated somehow - probably via server() ++ # default always injecting a username ++ elif key in self.allowed_keys: ++ return AUTH_SUCCESSFUL ++ return AUTH_FAILED ++ ++ def check_auth_interactive(self, username, submethods): ++ if username == "commie": ++ self.username = username ++ return InteractiveQuery( ++ "password", "Please enter a password.", ("Password", False) ++ ) ++ return AUTH_FAILED ++ ++ def check_auth_interactive_response(self, responses): ++ if self.username == "commie": ++ if (len(responses) == 1) and (responses[0] == "cat"): ++ return AUTH_SUCCESSFUL ++ return AUTH_FAILED ++ + + @contextmanager + def server( +@@ -1206,13 +1347,20 @@ def server( + connect=None, + pubkeys=None, + catch_error=False, ++ transport_factory=None, ++ server_transport_factory=None, ++ defer=False, ++ skip_verify=False, + ): + """ + SSH server contextmanager for testing. + ++ Yields a tuple of ``(tc, ts)`` (client- and server-side `Transport` ++ objects), or ``(tc, ts, err)`` when ``catch_error==True``. ++ + :param hostkey: + Host key to use for the server; if None, loads +- ``test_rsa.key``. ++ ``rsa.key``. + :param init: + Default `Transport` constructor kwargs to use for both sides. + :param server_init: +@@ -1226,6 +1374,17 @@ def server( + :param catch_error: + Whether to capture connection errors & yield from contextmanager. + Necessary for connection_time exception testing. ++ :param transport_factory: ++ Like the same-named param in SSHClient: which Transport class to use. ++ :param server_transport_factory: ++ Like ``transport_factory``, but only impacts the server transport. ++ :param bool defer: ++ Whether to defer authentication during connecting. ++ ++ This is really just shorthand for ``connect={}`` which would do roughly ++ the same thing. Also: this implies skip_verify=True automatically! ++ :param bool skip_verify: ++ Whether NOT to do the default "make sure auth passed" check. + """ + if init is None: + init = {} +@@ -1234,18 +1393,27 @@ def server( + if client_init is None: + client_init = {} + if connect is None: +- connect = dict(username="slowdive", password="pygmalion") ++ # No auth at all please ++ if defer: ++ connect = dict() ++ # Default username based auth ++ else: ++ connect = dict(username="slowdive", password="pygmalion") + socks = LoopSocket() + sockc = LoopSocket() + sockc.link(socks) +- tc = Transport(sockc, **dict(init, **client_init)) +- ts = Transport(socks, **dict(init, **server_init)) ++ if transport_factory is None: ++ transport_factory = Transport ++ if server_transport_factory is None: ++ server_transport_factory = transport_factory ++ tc = transport_factory(sockc, **dict(init, **client_init)) ++ ts = server_transport_factory(socks, **dict(init, **server_init)) + + if hostkey is None: + hostkey = RSAKey.from_private_key_file(_support("test_rsa.key")) + ts.add_server_key(hostkey) + event = threading.Event() +- server = NullServer(allowed_keys=pubkeys) ++ server = TestServer(allowed_keys=pubkeys) + assert not event.is_set() + assert not ts.is_active() + assert tc.get_username() is None +@@ -1273,22 +1441,15 @@ def server( + + yield (tc, ts, err) if catch_error else (tc, ts) + ++ if not (catch_error or skip_verify or defer): ++ assert ts.is_authenticated() ++ assert tc.is_authenticated() ++ + tc.close() + ts.close() + socks.close() + sockc.close() + +- +-_disable_sha2 = dict( +- disabled_algorithms=dict(keys=["rsa-sha2-256", "rsa-sha2-512"]) +-) +-_disable_sha1 = dict(disabled_algorithms=dict(keys=["ssh-rsa"])) +-_disable_sha2_pubkey = dict( +- disabled_algorithms=dict(pubkeys=["rsa-sha2-256", "rsa-sha2-512"]) +-) +-_disable_sha1_pubkey = dict(disabled_algorithms=dict(pubkeys=["ssh-rsa"])) +- +- + class TestSHA2SignatureKeyExchange(unittest.TestCase): + # NOTE: these all rely on the default server() hostkey being RSA + # NOTE: these rely on both sides being properly implemented re: agreed-upon +@@ -1352,7 +1513,10 @@ class TestSHA2SignatureKeyExchange(unittest.TestCase): + # the entire preferred-hostkeys structure when given an explicit key as + # a client.) + hostkey = RSAKey.from_private_key_file(_support("test_rsa.key")) +- with server(hostkey=hostkey, connect=dict(hostkey=hostkey)) as (tc, _): ++ connect = dict( ++ hostkey=hostkey, username="slowdive", password="pygmalion" ++ ) ++ with server(hostkey=hostkey, connect=connect) as (tc, _): + assert tc.host_key_type == "rsa-sha2-512" + + +-- +2.33.0 diff --git a/python-paramiko.spec b/python-paramiko.spec index 5e4e477a7d475563eae4995cb3c119a41f545e02..3dacd2f424591cbfa94af72bb7da0f80c76e5344 100644 --- a/python-paramiko.spec +++ b/python-paramiko.spec @@ -1,6 +1,6 @@ Name: python-paramiko Version: 2.7.2 -Release: 2 +Release: 3 Summary: Python SSH module License: LGPLv2+ URL: https://github.com/paramiko/paramiko @@ -10,6 +10,14 @@ Patch0: paramiko-2.7.2-drop-pytest-relaxed.patch Patch6000: backport-CVE-2022-24302.patch Patch6001: backport-Use-args-not-kwargs-to-retain-py2-compat-for-now.patch +Patch6002: backport-0001-CVE-2023-48795.patch +Patch6003: backport-0002-CVE-2023-48795.patch +Patch6004: backport-0003-CVE-2023-48795.patch +Patch6005: backport-0004-CVE-2023-48795.patch +Patch6006: backport-0005-CVE-2023-48795.patch +Patch6007: backport-0006-CVE-2023-48795.patch +Patch6008: backport-0007-CVE-2023-48795.patch +Patch6009: backport-0008-CVE-2023-48795.patch BuildArch: noarch @@ -69,6 +77,9 @@ PYTHONPATH=%{buildroot}%{python3_sitelib} pytest-%{python3_version} %doc html/ demos/ NEWS README.rst %changelog +* Thu Jan 11 2024 zhangpan - 2.7.2-3 +- fix CVE-2023-48795 + * Mon Mar 28 2022 dongyuzhen - 2.7.2-2 - fix CVE-2022-24302 and the rear patch of CVE-2022-24302