diff options
-rw-r--r-- | dev-requirements.txt | 2 | ||||
-rw-r--r-- | paramiko/__init__.py | 1 | ||||
-rw-r--r-- | paramiko/_version.py | 2 | ||||
-rw-r--r-- | paramiko/agent.py | 16 | ||||
-rw-r--r-- | paramiko/auth_handler.py | 148 | ||||
-rw-r--r-- | paramiko/buffered_pipe.py | 4 | ||||
-rw-r--r-- | paramiko/channel.py | 4 | ||||
-rw-r--r-- | paramiko/common.py | 5 | ||||
-rw-r--r-- | paramiko/dsskey.py | 2 | ||||
-rw-r--r-- | paramiko/ecdsakey.py | 2 | ||||
-rw-r--r-- | paramiko/ed25519key.py | 2 | ||||
-rw-r--r-- | paramiko/kex_curve25519.py | 4 | ||||
-rw-r--r-- | paramiko/kex_ecdh_nist.py | 4 | ||||
-rw-r--r-- | paramiko/kex_gex.py | 4 | ||||
-rw-r--r-- | paramiko/kex_group1.py | 4 | ||||
-rw-r--r-- | paramiko/pkey.py | 15 | ||||
-rw-r--r-- | paramiko/rsakey.py | 32 | ||||
-rw-r--r-- | paramiko/sftp_file.py | 2 | ||||
-rw-r--r-- | paramiko/ssh_exception.py | 15 | ||||
-rw-r--r-- | paramiko/transport.py | 241 | ||||
-rw-r--r-- | paramiko/util.py | 18 | ||||
-rw-r--r-- | sites/www/changelog.rst | 131 | ||||
-rw-r--r-- | tasks.py | 1 | ||||
-rw-r--r-- | tests/blank_rsa.key | 0 | ||||
-rw-r--r-- | tests/loop.py | 2 | ||||
-rw-r--r-- | tests/test_agent.py | 50 | ||||
-rw-r--r-- | tests/test_client.py | 42 | ||||
-rw-r--r-- | tests/test_kex.py | 3 | ||||
-rw-r--r-- | tests/test_pkey.py | 55 | ||||
-rw-r--r-- | tests/test_transport.py | 290 |
30 files changed, 966 insertions, 135 deletions
diff --git a/dev-requirements.txt b/dev-requirements.txt index 3edcc812..3ed9eb40 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,6 @@ # Invocations for common project tasks invoke==1.6.0 -invocations==2.3.0 +invocations==2.6.0 pytest==4.4.2 pytest-relaxed==1.1.5 # pytest-xdist for test dir watching and the inv guard task diff --git a/paramiko/__init__.py b/paramiko/__init__.py index 8642f84a..5318cc9c 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -42,6 +42,7 @@ from paramiko.ssh_exception import ( ChannelException, ConfigParseError, CouldNotCanonicalize, + IncompatiblePeer, PasswordRequiredException, ProxyCommandFailure, SSHException, diff --git a/paramiko/_version.py b/paramiko/_version.py index 0f0c6561..1908d3b0 100644 --- a/paramiko/_version.py +++ b/paramiko/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (2, 8, 1) +__version_info__ = (2, 9, 5) __version__ = ".".join(map(str, __version_info__)) diff --git a/paramiko/agent.py b/paramiko/agent.py index c7c8b7cb..f28bf128 100644 --- a/paramiko/agent.py +++ b/paramiko/agent.py @@ -42,6 +42,18 @@ SSH2_AGENT_IDENTITIES_ANSWER = 12 cSSH2_AGENTC_SIGN_REQUEST = byte_chr(13) SSH2_AGENT_SIGN_RESPONSE = 14 +SSH_AGENT_RSA_SHA2_256 = 2 +SSH_AGENT_RSA_SHA2_512 = 4 +# NOTE: RFC mildly confusing; while these flags are OR'd together, OpenSSH at +# least really treats them like "AND"s, in the sense that if it finds the +# SHA256 flag set it won't continue looking at the SHA512 one; it +# short-circuits right away. +# Thus, we never want to eg submit 6 to say "either's good". +ALGORITHM_FLAG_MAP = { + "rsa-sha2-256": SSH_AGENT_RSA_SHA2_256, + "rsa-sha2-512": SSH_AGENT_RSA_SHA2_512, +} + class AgentSSH(object): def __init__(self): @@ -411,12 +423,12 @@ class AgentKey(PKey): def _fields(self): raise NotImplementedError - 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) msg.add_string(data) - msg.add_int(0) + msg.add_int(ALGORITHM_FLAG_MAP.get(algorithm, 0)) ptype, result = self.agent._send_message(msg) if ptype != SSH2_AGENT_SIGN_RESPONSE: raise SSHException("key cannot be used for signing") diff --git a/paramiko/auth_handler.py b/paramiko/auth_handler.py index 5c7d6be6..42a21a78 100644 --- a/paramiko/auth_handler.py +++ b/paramiko/auth_handler.py @@ -22,6 +22,7 @@ import weakref import time +import re from paramiko.common import ( cMSG_SERVICE_REQUEST, @@ -61,7 +62,7 @@ from paramiko.common import ( 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,7 +207,19 @@ class AuthHandler(object): self.transport._send_message(m) self.transport.close() - def _get_session_blob(self, key, service, username): + def _get_key_type_and_bits(self, key): + """ + Given any key, return its type/algorithm & bits-to-sign. + + Intended for input to or verification of, key signatures. + """ + # Use certificate contents, if available, plain pubkey otherwise + if key.public_blob: + return key.public_blob.key_type, key.public_blob.key_blob + else: + return key.get_name(), key + + def _get_session_blob(self, key, service, username, algorithm): m = Message() m.add_string(self.transport.session_id) m.add_byte(cMSG_USERAUTH_REQUEST) @@ -214,13 +227,9 @@ class AuthHandler(object): 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) + _, bits = self._get_key_type_and_bits(key) + m.add_string(algorithm) + m.add_string(bits) return m.asbytes() def wait_for_response(self, event): @@ -269,9 +278,98 @@ class AuthHandler(object): # dunno this one self._disconnect_service_not_available() + def _generate_key_from_request(self, algorithm, keyblob): + # For use in server mode. + options = self.transport.preferred_pubkeys + if algorithm.replace("-cert-v01@openssh.com", "") not in options: + err = ( + "Auth rejected: pubkey algorithm '{}' unsupported or disabled" + ) + self._log(INFO, err.format(algorithm)) + return None + return self.transport._key_info[algorithm](Message(keyblob)) + + 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 + ), + ) + # NOTE re #2017: When the key is an RSA cert and the remote server is + # OpenSSH 7.7 or earlier, always use ssh-rsa-cert-v01@openssh.com. + # Those versions of the server won't support rsa-sha2 family sig algos + # for certs specifically, and in tandem with various server bugs + # regarding server-sig-algs, it's impossible to fit this into the rest + # of the logic here. + if key_type.endswith("-cert-v01@openssh.com") and re.search( + r"-OpenSSH_(?:[1-6]|7\.[0-7])", self.transport.remote_version + ): + pubkey_algo = "ssh-rsa-cert-v01@openssh.com" + self.transport._agreed_pubkey_algorithm = pubkey_algo + self._log(DEBUG, "OpenSSH<7.8 + RSA cert = forcing ssh-rsa!") + self._log( + DEBUG, "Agreed upon {!r} pubkey algorithm".format(pubkey_algo) + ) + return pubkey_algo + # Normal attempts to handshake follow from here. + # 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_algo_str = u( + self.transport.server_extensions.get("server-sig-algs", b("")) + ) + pubkey_algo = None + if server_algo_str: + server_algos = server_algo_str.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)) + if agreement: + pubkey_algo = agreement[0] + self._log( + DEBUG, + "Agreed upon {!r} pubkey algorithm".format(pubkey_algo), + ) + else: + self._log(DEBUG, "No common pubkey algorithms exist! Dying.") + # TODO: MAY want to use IncompatiblePeer again here but that's + # technically for initial key exchange, not pubkey auth. + err = "Unable to agree on a pubkey algorithm for signing a {!r} key!" # noqa + raise AuthenticationException(err.format(key_type)) + else: + # Fallback: first one in our (possibly tweaked by caller) list + pubkey_algo = my_algos[0] + msg = "Server did not send a server-sig-algs list; defaulting to our first preferred algo ({!r})" # noqa + self._log(DEBUG, msg.format(pubkey_algo)) + self._log( + DEBUG, + "NOTE: you may use the 'disabled_algorithms' SSHClient/Transport init kwarg to disable that or other algorithms if your server does not support them!", # noqa + ) + if key_type.endswith("-cert-v01@openssh.com"): + pubkey_algo += "-cert-v01@openssh.com" + self.transport._agreed_pubkey_algorithm = pubkey_algo + return pubkey_algo + def _parse_service_accept(self, m): service = m.get_text() if service == "ssh-userauth": + # TODO 3.0: this message sucks ass. change it to something more + # obvious. it always appears to mean "we already authed" but no! it + # just means "we are allowed to TRY authing!" self._log(DEBUG, "userauth is OK") m = Message() m.add_byte(cMSG_USERAUTH_REQUEST) @@ -284,18 +382,17 @@ class AuthHandler(object): 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) + key_type, bits = self._get_key_type_and_bits(self.private_key) + algorithm = self._finalize_pubkey_algorithm(key_type) + m.add_string(algorithm) + m.add_string(bits) blob = self._get_session_blob( - self.private_key, "ssh-connection", self.username + self.private_key, + "ssh-connection", + self.username, + algorithm, ) - 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("") @@ -505,10 +602,13 @@ Error Message: {} ) elif method == "publickey": sig_attached = m.get_boolean() - keytype = m.get_text() + # NOTE: server never wants to guess a client's algo, they're + # telling us directly. No need for _finalize_pubkey_algorithm + # anywhere in this flow. + algorithm = m.get_text() keyblob = m.get_binary() try: - key = self.transport._key_info[keytype](Message(keyblob)) + key = self._generate_key_from_request(algorithm, keyblob) except SSHException as e: self._log(INFO, "Auth rejected: public key: {}".format(str(e))) key = None @@ -532,12 +632,14 @@ Error Message: {} # signs anything... send special "ok" message m = Message() m.add_byte(cMSG_USERAUTH_PK_OK) - m.add_string(keytype) + m.add_string(algorithm) m.add_string(keyblob) self.transport._send_message(m) return sig = Message(m.get_binary()) - blob = self._get_session_blob(key, service, username) + blob = self._get_session_blob( + key, service, username, algorithm + ) if not key.verify_ssh_sig(blob, sig): self._log(INFO, "Auth rejected: invalid signature") result = AUTH_FAILED diff --git a/paramiko/buffered_pipe.py b/paramiko/buffered_pipe.py index 69445c97..e8f98714 100644 --- a/paramiko/buffered_pipe.py +++ b/paramiko/buffered_pipe.py @@ -101,7 +101,7 @@ class BufferedPipe(object): if self._event is not None: self._event.set() self._buffer_frombytes(b(data)) - self._cv.notifyAll() + self._cv.notify_all() finally: self._lock.release() @@ -203,7 +203,7 @@ class BufferedPipe(object): self._lock.acquire() try: self._closed = True - self._cv.notifyAll() + self._cv.notify_all() if self._event is not None: self._event.set() finally: diff --git a/paramiko/channel.py b/paramiko/channel.py index 72f65012..5f314361 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -1066,7 +1066,7 @@ class Channel(ClosingContextManager): if self.ultra_debug: self._log(DEBUG, "window up {}".format(nbytes)) self.out_window_size += nbytes - self.out_buffer_cv.notifyAll() + self.out_buffer_cv.notify_all() finally: self.lock.release() @@ -1230,7 +1230,7 @@ class Channel(ClosingContextManager): self.closed = True self.in_buffer.close() self.in_stderr_buffer.close() - self.out_buffer_cv.notifyAll() + self.out_buffer_cv.notify_all() # Notify any waiters that we are closed self.event.set() self.status_event.set() diff --git a/paramiko/common.py b/paramiko/common.py index 7bd0cb10..55dd4bdf 100644 --- a/paramiko/common.py +++ b/paramiko/common.py @@ -29,7 +29,8 @@ from paramiko.py3compat import byte_chr, PY2, long, b 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_UNIMPLEMENTED = byte_chr(MSG_UNIMPLEMENTED) 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_NAMES = { 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 09d6f648..1a0c4797 100644 --- a/paramiko/dsskey.py +++ b/paramiko/dsskey.py @@ -105,7 +105,7 @@ class DSSKey(PKey): 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 b609d130..c4e2b1af 100644 --- a/paramiko/ecdsakey.py +++ b/paramiko/ecdsakey.py @@ -211,7 +211,7 @@ class ECDSAKey(PKey): 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 7b19e352..d322a0c1 100644 --- a/paramiko/ed25519key.py +++ b/paramiko/ed25519key.py @@ -191,7 +191,7 @@ class Ed25519Key(PKey): 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 59710c1a..3420fb4f 100644 --- a/paramiko/kex_curve25519.py +++ b/paramiko/kex_curve25519.py @@ -89,7 +89,9 @@ class KexCurve25519(object): 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 ad5c9c79..19de2431 100644 --- a/paramiko/kex_ecdh_nist.py +++ b/paramiko/kex_ecdh_nist.py @@ -90,7 +90,9 @@ class KexNistp256: 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 fb8f01fd..ab462e6d 100644 --- a/paramiko/kex_gex.py +++ b/paramiko/kex_gex.py @@ -240,7 +240,9 @@ class KexGex(object): 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 dce3fd91..6d548b01 100644 --- a/paramiko/kex_group1.py +++ b/paramiko/kex_group1.py @@ -143,7 +143,9 @@ class KexGroup1(object): 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_REPLY) diff --git a/paramiko/pkey.py b/paramiko/pkey.py index 5bdfb1d4..ff037f71 100644 --- a/paramiko/pkey.py +++ b/paramiko/pkey.py @@ -140,7 +140,7 @@ class PKey(object): return cmp(self.asbytes(), other.asbytes()) # noqa def __eq__(self, other): - return self._fields == other._fields + return isinstance(other, PKey) and self._fields == other._fields def __hash__(self): return hash(self._fields) @@ -196,13 +196,20 @@ class PKey(object): """ 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() @@ -317,6 +324,8 @@ class PKey(object): def _read_private_key(self, tag, f, password=None): lines = f.readlines() + if not lines: + raise SSHException("no lines in {} private key file".format(tag)) # find the BEGIN tag start = 0 diff --git a/paramiko/rsakey.py b/paramiko/rsakey.py index 292d0ccc..2aea84b9 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 @@ class RSAKey(PKey): 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,26 +122,35 @@ class RSAKey(PKey): 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.replace("-cert-v01@openssh.com", "")) 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): key = key.public_key() + # NOTE: pad received signature with leading zeros, key.verify() + # expects a signature of key size (e.g. PuTTY doesn't pad) + sign = msg.get_binary() + diff = key.key_size - len(sign) * 8 + if diff > 0: + sign = b"\x00" * ((diff + 7) // 8) + sign + try: key.verify( - msg.get_binary(), data, padding.PKCS1v15(), hashes.SHA1() + sign, data, padding.PKCS1v15(), self.HASHES[sig_algorithm]() ) except InvalidSignature: return False diff --git a/paramiko/sftp_file.py b/paramiko/sftp_file.py index 0104d857..57e04175 100644 --- a/paramiko/sftp_file.py +++ b/paramiko/sftp_file.py @@ -527,7 +527,7 @@ class SFTPFile(BufferedFile): self._prefetch_done = False t = threading.Thread(target=self._prefetch_thread, args=(chunks,)) - t.setDaemon(True) + t.daemon = True t.start() def _prefetch_thread(self, chunks): diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py index 2789be99..39fcb10d 100644 --- a/paramiko/ssh_exception.py +++ b/paramiko/ssh_exception.py @@ -135,6 +135,21 @@ class BadHostKeyException(SSHException): ) +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 8919043f..c28c3828 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 @@ -168,11 +171,25 @@ class Transport(threading.Thread, ClosingContextManager): "hmac-sha1-96", "hmac-md5-96", ) + # ~= HostKeyAlgorithms in OpenSSH land _preferred_keys = ( "ssh-ed25519", "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 +276,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 +335,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 +398,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 +410,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) @@ -417,7 +450,7 @@ class Transport(threading.Thread, ClosingContextManager): ) # okay, normal socket-ish flow here... threading.Thread.__init__(self) - self.setDaemon(True) + self.daemon = True self.sock = sock # we set the timeout so we can check self.active periodically to # see if we should bail. socket.timeout exception is never propagated. @@ -488,6 +521,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 @@ -518,6 +552,10 @@ class Transport(threading.Thread, ClosingContextManager): 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 +781,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 +1324,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 +2180,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: @@ -2176,7 +2235,7 @@ class Transport(threading.Thread, ClosingContextManager): # Log useful, non-duplicative line re: an agreed-upon algorithm. # Old code implied algorithms could be asymmetrical (different for # inbound vs outbound) so we preserve that possibility. - msg = "{} agreed: ".format(which) + msg = "{}: ".format(which) if local == remote: msg += local else: @@ -2237,7 +2296,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 +2312,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 @@ -2272,16 +2328,29 @@ class Transport(threading.Thread, ClosingContextManager): available_server_keys = list( filter( list(self.server_key_dict.keys()).__contains__, + # TODO: ensure tests will catch if somebody streamlines + # this by mistake - case is the admittedly silly one where + # the only calls to add_server_key() contain keys which + # were filtered out of the below via disabled_algorithms. + # If this is streamlined, we would then be allowing the + # disabled algorithm(s) for hostkey use + # TODO: honestly this prob just wants to get thrown out + # when we make kex configuration more straightforward self.preferred_keys, ) ) 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,50 +2363,74 @@ 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 - self._log( - DEBUG, - "kex algos:" - + str(kex_algo_list) - + " server key:" - + str(server_key_algo_list) - + " client encrypt:" - + str(client_encrypt_algo_list) - + " server encrypt:" - + str(server_encrypt_algo_list) - + " client mac:" - + str(client_mac_algo_list) - + " server mac:" - + str(server_mac_algo_list) - + " client compress:" - + str(client_compress_algo_list) - + " server compress:" - + str(server_compress_algo_list) - + " client lang:" - + str(client_lang_list) - + " server lang:" - + str(server_lang_list) - + " kex follows?" - + str(kex_follows), + 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, "=== Key exchange possibilities ===") + for prefix, value in ( + ("kex algos", kex_algo_list), + ("server key", server_key_algo_list), + # TODO: shouldn't these two lines say "cipher" to match usual + # terminology (including elsewhere in paramiko!)? + ("client encrypt", client_encrypt_algo_list), + ("server encrypt", server_encrypt_algo_list), + ("client mac", client_mac_algo_list), + ("server mac", server_mac_algo_list), + ("client compress", client_compress_algo_list), + ("server compress", server_compress_algo_list), + ("client lang", client_lang_list), + ("server lang", server_lang_list), + ): + if value == [""]: + value = ["<none>"] + value = ", ".join(value) + self._log(DEBUG, "{}: {}".format(prefix, value)) + self._log(DEBUG, "kex follows: {}".format(kex_follows)) + self._log(DEBUG, "=== Key exchange agreements ===") + + # 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,11 +2444,14 @@ class Transport(threading.Thread, ClosingContextManager): filter(kex_algo_list.__contains__, self.preferred_kex) ) if len(agreed_kex) == 0: - raise SSHException( + # TODO: do an auth-overhaul style aggregate exception here? + # TODO: would let us streamline log output & show all failures up + # front + raise IncompatiblePeer( "Incompatible ssh peer (no acceptable kex algorithm)" ) # noqa self.kex_engine = self._kex_info[agreed_kex[0]](self) - self._log(DEBUG, "Kex agreed: {}".format(agreed_kex[0])) + self._log(DEBUG, "Kex: {}".format(agreed_kex[0])) if self.server_mode: available_server_keys = list( @@ -2374,12 +2470,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 +2507,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 +2531,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 +2572,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, @@ -2488,6 +2586,7 @@ class Transport(threading.Thread, ClosingContextManager): local=self.local_compression, remote=self.remote_compression, ) + self._log(DEBUG, "=== End of kex handshake ===") # save for computing hash later... # now wait! openssh has a bug (and others might too) where there are @@ -2573,6 +2672,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 +2701,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 +2982,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, @@ -2877,6 +3005,9 @@ class Transport(threading.Thread, ClosingContextManager): } +# TODO 3.0: drop this, we barely use it ourselves, it badly replicates the +# Transport-internal algorithm management, AND does so in a way which doesn't +# honor newer things like disabled_algorithms! class SecurityOptions(object): """ Simple object containing the security preferences of an ssh transport. diff --git a/paramiko/util.py b/paramiko/util.py index 93970289..a344915c 100644 --- a/paramiko/util.py +++ b/paramiko/util.py @@ -225,24 +225,20 @@ def mod_inverse(x, m): return u2 -_g_thread_ids = {} +_g_thread_data = threading.local() _g_thread_counter = 0 _g_thread_lock = threading.Lock() def get_thread_id(): - global _g_thread_ids, _g_thread_counter, _g_thread_lock - tid = id(threading.currentThread()) + global _g_thread_data, _g_thread_counter, _g_thread_lock try: - return _g_thread_ids[tid] - except KeyError: - _g_thread_lock.acquire() - try: + return _g_thread_data.id + except AttributeError: + with _g_thread_lock: _g_thread_counter += 1 - ret = _g_thread_ids[tid] = _g_thread_counter - finally: - _g_thread_lock.release() - return ret + _g_thread_data.id = _g_thread_counter + return _g_thread_data.id def log_to_file(filename, level=DEBUG): diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index af1cef05..ada0d86c 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -2,12 +2,143 @@ Changelog ========= +- bug:`1637` (via :issue:`1599`) Raise `SSHException` explicitly when blank + private key data is loaded, instead of the natural result of ``IndexError``. + This should help more bits of Paramiko or Paramiko-adjacent codebases to + correctly handle this class of error. Credit: Nicholas Dietz. +- :release:`2.9.5 <2022-05-16>` +- :bug:`1933` Align signature verification algorithm with OpenSSH re: + zero-padding signatures which don't match their nominal size/length. This + shouldn't affect most users, but will help Paramiko-implemented SSH servers + handle poorly behaved clients such as PuTTY. Thanks to Jun Omae for catch & + patch. +- :bug:`2017` OpenSSH 7.7 and older has a bug preventing it from understanding + how to perform SHA2 signature verification for RSA certificates (specifically + certs - not keys), so when we added SHA2 support it broke all clients using + RSA certificates with these servers. This has been fixed in a manner similar + to what OpenSSH's own client does: a version check is performed and the + algorithm used is downgraded if needed. Reported by Adarsh Chauhan, with fix + suggested by Jun Omae. +- :release:`2.9.4 <2022-04-25>` +- :support:`1838 backported` (via :issue:`1870`/:issue:`2028`) Update + ``camelCase`` method calls against the ``threading`` module to be + ``snake_case``; this and related tweaks should fix some deprecation warnings + under Python 3.10. Thanks to Karthikeyan Singaravelan for the report, + ``@Narendra-Neerukonda`` for the patch, and to Thomas Grainger and Jun Omae + for patch workshopping. +- :bug:`1964` (via :issue:`2024` as also reported in :issue:`2023`) + `~paramiko.pkey.PKey` instances' ``__eq__`` did not have the usual safety + guard in place to ensure they were being compared to another ``PKey`` object, + causing occasional spurious ``BadHostKeyException`` (among other things). + This has been fixed. Thanks to Shengdun Hua for the original report/patch and + to Christopher Papke for the final version of the fix. +- :release:`2.9.3 <2022-03-18>` +- :bug:`1963` (via :issue:`1977`) Certificate-based pubkey auth was + inadvertently broken when adding SHA2 support; this has been fixed. Reported + by Erik Forsberg and fixed by Jun Omae. +- :bug:`2002` (via :issue:`2003`) Switch from module-global to thread-local + storage when recording thread IDs for a logging helper; this should avoid one + flavor of memory leak for long-running processes. Catch & patch via Richard + Kojedzinszky. - :support:`1985` Add ``six`` explicitly to install-requires; it snuck into active use at some point but has only been indicated by transitive dependency on ``bcrypt`` until they somewhat-recently dropped it. This will be short-lived until we `drop Python 2 support <https://bitprophet.org/projects/#roadmap>`_. Thanks to Sondre Lillebø Gundersen for catch & patch. +- :release:`2.9.2 <2022-01-08>` +- :bug:`-` Connecting to servers which support ``server-sig-algs`` but which + have no overlap between that list and what a Paramiko client supports, now + raise an exception instead of defaulting to ``rsa-sha2-512`` (since the use + of ``server-sig-algs`` allows us to know what the server supports). +- :bug:`-` Enhanced log output when connecting to servers that do not support + ``server-sig-algs`` extensions, making the new-as-of-2.9 defaulting to SHA2 + pubkey algorithms more obvious when it kicks in. +- :release:`2.9.1 <2021-12-24>` +- :bug:`1955` Server-side support for ``rsa-sha2-256`` and ``ssh-rsa`` wasn't + fully operable after 2.9.0's release (signatures for RSA pubkeys were always + run through ``rsa-sha2-512`` instead). Report and early stab at a fix + courtesy of Jun Omae. +- :release:`2.9.0 <2021-12-23>` +- :feature:`1643` (also :issue:`1925`, :issue:`1644`, :issue:`1326`) 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`). + + .. warning:: + This change is slightly backwards incompatible, insofar as action is + required if your target systems do not support either RSA2 or the + ``server-sig-algs`` protocol extension. + + Specifically, you need to specify ``disabled_algorithms={'keys': + ['rsa-sha2-256', 'rsa-sha2-512']}`` in either `SSHClient + <paramiko.client.SSHClient.__init__>` or `Transport + <paramiko.transport.Transport.__init__>`. See below for details on why. + + 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`` + (specifically, its ``pubkeys`` key); 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. + + - SSH agent support grew the ability to specify algorithm flags when + requesting private key signatures; this is now used to forward SHA2 + algorithms when appropriate. + - 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 <paramiko.pkey.PKey>`: 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. + - :release:`2.8.1 <2021-11-28>` - :bug:`985` (via :issue:`992`) Fix listdir failure when server uses a locale. Now on Python 2.7 `SFTPAttributes <paramiko.sftp_attr.SFTPAttributes>` will @@ -125,7 +125,6 @@ def all_(c, dry_run=False): release_coll["prepare"](c, dry_run=dry_run) publish_(c, dry_run=dry_run) release_coll["push"](c, dry_run=dry_run) - release_coll["tidelift"](c, dry_run=dry_run) # TODO: "replace one task with another" needs a better public API, this is diff --git a/tests/blank_rsa.key b/tests/blank_rsa.key new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/tests/blank_rsa.key diff --git a/tests/loop.py b/tests/loop.py index dd1f5a0c..40179a64 100644 --- a/tests/loop.py +++ b/tests/loop.py @@ -81,7 +81,7 @@ class LoopSocket(object): self.__lock.acquire() try: self.__in_buffer += data - self.__cv.notifyAll() + self.__cv.notify_all() finally: self.__lock.release() diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 00000000..c3973bbb --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,50 @@ +import unittest + +from paramiko.message import Message +from paramiko.agent import ( + SSH2_AGENT_SIGN_RESPONSE, + cSSH2_AGENTC_SIGN_REQUEST, + SSH_AGENT_RSA_SHA2_256, + SSH_AGENT_RSA_SHA2_512, + AgentKey, +) +from paramiko.py3compat import b + + +class ChaosAgent: + def _send_message(self, msg): + self._sent_message = msg + sig = Message() + sig.add_string(b("lol")) + sig.rewind() + return SSH2_AGENT_SIGN_RESPONSE, sig + + +class AgentTests(unittest.TestCase): + def _sign_with_agent(self, kwargs, expectation): + agent = ChaosAgent() + key = AgentKey(agent, b("secret!!!")) + result = key.sign_ssh_data(b("token"), **kwargs) + assert result == b("lol") + msg = agent._sent_message + msg.rewind() + assert msg.get_byte() == cSSH2_AGENTC_SIGN_REQUEST + assert msg.get_string() == b("secret!!!") + assert msg.get_string() == b("token") + assert msg.get_int() == expectation + + def test_agent_signing_defaults_to_0_for_flags_field(self): + # No algorithm kwarg at all + self._sign_with_agent(kwargs=dict(), expectation=0) + + def test_agent_signing_is_2_for_SHA256(self): + self._sign_with_agent( + kwargs=dict(algorithm="rsa-sha2-256"), + expectation=SSH_AGENT_RSA_SHA2_256, + ) + + def test_agent_signing_is_2_for_SHA512(self): + self._sign_with_agent( + kwargs=dict(algorithm="rsa-sha2-512"), + expectation=SSH_AGENT_RSA_SHA2_512, + ) diff --git a/tests/test_client.py b/tests/test_client.py index f14aac23..21694e28 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -152,7 +152,12 @@ class ClientTest(unittest.TestCase): self.sockl.close() def _run( - self, allowed_keys=None, delay=0, public_blob=None, kill_event=None + self, + allowed_keys=None, + delay=0, + public_blob=None, + kill_event=None, + server_name=None, ): if allowed_keys is None: allowed_keys = FINGERPRINTS.keys() @@ -164,6 +169,8 @@ class ClientTest(unittest.TestCase): self.socks.close() return self.ts = paramiko.Transport(self.socks) + if server_name is not None: + self.ts.local_version = server_name keypath = _support("test_rsa.key") host_key = paramiko.RSAKey.from_private_key_file(keypath) self.ts.add_server_key(host_key) @@ -179,11 +186,11 @@ class ClientTest(unittest.TestCase): """ (Most) kwargs get passed directly into SSHClient.connect(). - The exception is ``allowed_keys`` which is stripped and handed to the - ``NullServer`` used for testing. + The exceptions are ``allowed_keys``/``public_blob``/``server_name`` + which are stripped and handed to the ``NullServer`` used for testing. """ run_kwargs = {"kill_event": self.kill_event} - for key in ("allowed_keys", "public_blob"): + for key in ("allowed_keys", "public_blob", "server_name"): run_kwargs[key] = kwargs.pop(key, None) # Server setup threading.Thread(target=self._run, kwargs=run_kwargs).start() @@ -205,7 +212,9 @@ class ClientTest(unittest.TestCase): self.event.wait(1.0) self.assertTrue(self.event.is_set()) self.assertTrue(self.ts.is_active()) - self.assertEqual("slowdive", self.ts.get_username()) + self.assertEqual( + self.connect_kwargs["username"], self.ts.get_username() + ) self.assertEqual(True, self.ts.is_authenticated()) self.assertEqual(False, self.tc.get_transport().gss_kex_used) @@ -336,6 +345,29 @@ class SSHClientTest(ClientTest): ), ) + def _cert_algo_test(self, ver, alg): + # Issue #2017; see auth_handler.py + self.connect_kwargs["username"] = "somecertuser" # neuter pw auth + self._test_connection( + # NOTE: SSHClient is able to take either the key or the cert & will + # set up its internals as needed + key_filename=_support( + os.path.join("cert_support", "test_rsa.key-cert.pub") + ), + server_name="SSH-2.0-OpenSSH_{}".format(ver), + ) + assert ( + self.tc._transport._agreed_pubkey_algorithm + == "{}-cert-v01@openssh.com".format(alg) + ) + + def test_old_openssh_needs_ssh_rsa_for_certs_not_rsa_sha2(self): + self._cert_algo_test(ver="7.7", alg="ssh-rsa") + + def test_newer_openssh_uses_rsa_sha2_for_certs_not_ssh_rsa(self): + # NOTE: 512 happens to be first in our list and is thus chosen + self._cert_algo_test(ver="7.8", alg="rsa-sha2-512") + def test_default_key_locations_trigger_cert_loads_if_found(self): # TODO: what it says on the tin: ~/.ssh/id_rsa tries to load # ~/.ssh/id_rsa-cert.pub. Right now no other tests actually test that diff --git a/tests/test_kex.py b/tests/test_kex.py index c251611a..b73989c2 100644 --- a/tests/test_kex.py +++ b/tests/test_kex.py @@ -76,7 +76,7 @@ class FakeKey(object): 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 94b2492b..6acb9121 100644 --- a/tests/test_pkey.py +++ b/tests/test_pkey.py @@ -63,6 +63,8 @@ FINGER_ECDSA_256 = "256 25:19:eb:55:e6:a1:47:ff:4f:38:d2:75:6f:a5:d5:60" 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" @@ -182,6 +184,11 @@ class KeyTest(unittest.TestCase): with pytest.raises(SSHException, match=str(exception)): RSAKey.from_private_key_file(_support("test_rsa.key")) + def test_loading_empty_keys_errors_usefully(self): + # #1599 - raise SSHException instead of IndexError + with pytest.raises(SSHException, match="no lines"): + RSAKey.from_private_key_file(_support("blank_rsa.key")) + def test_load_rsa_password(self): key = RSAKey.from_private_key_file( _support("test_rsa_password.key"), "television" @@ -238,21 +245,29 @@ class KeyTest(unittest.TestCase): 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")) @@ -612,6 +627,11 @@ class KeyTest(unittest.TestCase): for key1, key2 in self.keys(): assert key1 == key2 + def test_keys_are_not_equal_to_other(self): + for value in [None, True, ""]: + for key1, _ in self.keys(): + assert key1 != value + def test_keys_are_hashable(self): # NOTE: this isn't a great test due to hashseed randomization under # Python 3 preventing use of static values, but it does still prove @@ -686,3 +706,22 @@ class KeyTest(unittest.TestCase): key1.load_certificate, _support("test_rsa.key-cert.pub"), ) + + def test_sign_rsa_with_certificate(self): + data = b"ice weasels" + key_path = _support(os.path.join("cert_support", "test_rsa.key")) + key = RSAKey.from_private_key_file(key_path) + msg = key.sign_ssh_data(data, "rsa-sha2-256") + msg.rewind() + assert "rsa-sha2-256" == msg.get_text() + sign = msg.get_binary() + cert_path = _support( + os.path.join("cert_support", "test_rsa.key-cert.pub") + ) + key.load_certificate(cert_path) + msg = key.sign_ssh_data(data, "rsa-sha2-256-cert-v01@openssh.com") + msg.rewind() + assert "rsa-sha2-256" == msg.get_text() + assert sign == msg.get_binary() + msg.rewind() + assert key.verify_ssh_sig(b"ice weasels", msg) diff --git a/tests/test_transport.py b/tests/test_transport.py index e2174896..737ef705 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -23,6 +23,7 @@ Some unit tests for the ssh2 protocol in Transport. from __future__ import with_statement from binascii import hexlify +from contextlib import contextmanager import select import socket import time @@ -38,6 +39,8 @@ from paramiko import ( 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 @@ class NullServer(ServerInterface): 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 @@ class TransportTest(unittest.TestCase): 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 ): @@ -245,7 +257,7 @@ class TransportTest(unittest.TestCase): self.assertEqual(True, self.tc.is_authenticated()) self.assertEqual(True, self.ts.is_authenticated()) - def testa_long_banner(self): + def test_long_banner(self): """ verify that a long banner doesn't mess up the handshake. """ @@ -339,7 +351,7 @@ class TransportTest(unittest.TestCase): self.assertEqual("This is on stderr.\n", f.readline()) self.assertEqual("", f.readline()) - def testa_channel_can_be_used_as_context_manager(self): + def test_channel_can_be_used_as_context_manager(self): """ verify that exec_command() does something reasonable. """ @@ -744,7 +756,7 @@ class TransportTest(unittest.TestCase): threading.Thread.__init__( self, None, None, self.__class__.__name__ ) - self.setDaemon(True) + self.daemon = True self.chan = chan self.iterations = iterations self.done_event = done_event @@ -768,7 +780,7 @@ class TransportTest(unittest.TestCase): threading.Thread.__init__( self, None, None, self.__class__.__name__ ) - self.setDaemon(True) + self.daemon = True self.chan = chan self.done_event = done_event self.watchdog_event = threading.Event() @@ -1169,3 +1181,273 @@ class AlgorithmDisablingTests(unittest.TestCase): 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" + + def test_sha2_256_when_client_only_enables_256(self): + privkey = RSAKey.from_private_key_file(_support("test_rsa.key")) + with server( + pubkeys=[privkey], + connect=dict(pkey=privkey), + # Client-side only; server still accepts all 3. + client_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" |