diff options
-rw-r--r-- | .travis.yml | 23 | ||||
-rw-r--r-- | README.rst | 7 | ||||
-rw-r--r-- | dev-requirements.txt | 3 | ||||
-rw-r--r-- | paramiko/__init__.py | 7 | ||||
-rw-r--r-- | paramiko/_version.py | 2 | ||||
-rw-r--r-- | paramiko/channel.py | 38 | ||||
-rw-r--r-- | paramiko/client.py | 13 | ||||
-rw-r--r-- | paramiko/ssh_exception.py | 66 | ||||
-rw-r--r-- | paramiko/ssh_gss.py | 220 | ||||
-rw-r--r-- | paramiko/transport.py | 105 | ||||
-rw-r--r-- | setup.py | 7 | ||||
-rw-r--r-- | sites/docs/api/ssh_gss.rst | 5 | ||||
-rw-r--r-- | sites/www/changelog.rst | 49 | ||||
-rw-r--r-- | sites/www/installing.rst | 45 | ||||
-rw-r--r-- | tests/test_channelfile.py | 31 | ||||
-rw-r--r-- | tests/test_client.py | 44 | ||||
-rw-r--r-- | tests/test_gssapi.py | 87 | ||||
-rw-r--r-- | tests/test_kex_gss.py | 23 | ||||
-rw-r--r-- | tests/test_ssh_exception.py | 42 | ||||
-rw-r--r-- | tests/test_ssh_gss.py | 14 | ||||
-rw-r--r-- | tests/test_transport.py | 67 | ||||
-rw-r--r-- | tests/util.py | 105 |
22 files changed, 859 insertions, 144 deletions
diff --git a/.travis.yml b/.travis.yml index 41c074bb..84b73bd6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,6 +23,10 @@ matrix: env: "OLDEST_CRYPTO=2.5" - python: 3.7 env: "OLDEST_CRYPTO=2.5" + - python: 2.7 + env: "USE_K5TEST=yes" + - python: 3.7 + env: "USE_K5TEST=yes" install: # Ensure modern pip/etc to avoid some issues w/ older worker environs - pip install pip==9.0.1 setuptools==36.6.0 @@ -38,6 +42,25 @@ install: # TODO: use pipenv + whatever contexty-type stuff it has - pip install codecov # For codecov specifically - pip install -r dev-requirements.txt + - | + if [[ -n "$USE_K5TEST" ]]; then + # we need a few commands and libraries + # Debian/Ubuntu package: commands used by package k5test + # libkrb5-dev: krb5-config + # krb5-kdc: kdb5_util, krb5kdc + # krb5-admin-server: kadmin.local, kprop, kadmind + # krb5-user: kinit, klist + # + # krb5-multidev: required to build gssapi + sudo apt-get -y install libkrb5-dev krb5-admin-server \ + krb5-kdc krb5-user krb5-multidev && \ + pip install k5test gssapi pyasn1 + fi + # In case of problems uncomment the following to get the krb environment + # - | + # if [[ -n "$USE_K5TEST" ]]; then + # python -c 'from tests.util import k5shell; k5shell()' env | sort + # fi script: # Fast syntax check failures for more rapid feedback to submitters # (Travis-oriented metatask that version checks Python, installs, runs.) @@ -136,3 +136,10 @@ There are also unit tests here:: $ pytest Which will verify that most of the core components are working correctly. + +To test Kerberos/GSSAPI, you need a Kerberos environment. On UNIX you can +use the package k5test to setup a Kerberos environment on the fly:: + + $ pip install -r dev-requirements.txt + $ pip install k5test gssapi pyasn1 + $ pytest diff --git a/dev-requirements.txt b/dev-requirements.txt index 6e1e0e76..22ac76ad 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,8 +1,7 @@ # Invocations for common project tasks invoke>=1.0,<2.0 invocations>=1.2.0,<2.0 -# NOTE: pytest-relaxed currently only works with pytest >=3, <3.3 -pytest==4.6.3 +pytest==4.4.2 pytest-relaxed==1.1.5 # pytest-xdist for test dir watching and the inv guard task pytest-xdist==1.28.0 diff --git a/paramiko/__init__.py b/paramiko/__init__.py index 6afc7a61..8ac52579 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -29,7 +29,12 @@ from paramiko.client import ( ) from paramiko.auth_handler import AuthHandler from paramiko.ssh_gss import GSSAuth, GSS_AUTH_AVAILABLE, GSS_EXCEPTIONS -from paramiko.channel import Channel, ChannelFile, ChannelStderrFile +from paramiko.channel import ( + Channel, + ChannelFile, + ChannelStderrFile, + ChannelStdinFile, +) from paramiko.ssh_exception import ( SSHException, PasswordRequiredException, diff --git a/paramiko/_version.py b/paramiko/_version.py index d5dbcad8..2d128dd7 100644 --- a/paramiko/_version.py +++ b/paramiko/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (2, 5, 1) +__version_info__ = (2, 6, 0) __version__ = ".".join(map(str, __version_info__)) diff --git a/paramiko/channel.py b/paramiko/channel.py index f2aaf26a..72f65012 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -889,12 +889,30 @@ class Channel(ClosingContextManager): client, it only makes sense to open this file for reading. For a server, it only makes sense to open this file for writing. - :return: `.ChannelFile` object which can be used for Python file I/O. + :returns: + `.ChannelStderrFile` object which can be used for Python file I/O. .. versionadded:: 1.1 """ return ChannelStderrFile(*([self] + list(params))) + def makefile_stdin(self, *params): + """ + Return a file-like object associated with this channel's stdin + stream. + + The optional ``mode`` and ``bufsize`` arguments are interpreted the + same way as by the built-in ``file()`` function in Python. For a + client, it only makes sense to open this file for writing. For a + server, it only makes sense to open this file for reading. + + :returns: + `.ChannelStdinFile` object which can be used for Python file I/O. + + .. versionadded:: 2.6 + """ + return ChannelStdinFile(*([self] + list(params))) + def fileno(self): """ Returns an OS-level file descriptor which can be used for polling, but @@ -1348,9 +1366,27 @@ class ChannelFile(BufferedFile): class ChannelStderrFile(ChannelFile): + """ + A file-like wrapper around `.Channel` stderr. + + See `Channel.makefile_stderr` for details. + """ + def _read(self, size): return self.channel.recv_stderr(size) def _write(self, data): self.channel.sendall_stderr(data) return len(data) + + +class ChannelStdinFile(ChannelFile): + """ + A file-like wrapper around `.Channel` stdin. + + See `Channel.makefile_stdin` for details. + """ + + def close(self): + super(ChannelStdinFile, self).close() + self.channel.shutdown_write() diff --git a/paramiko/client.py b/paramiko/client.py index 6d1636be..80c956cd 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -236,6 +236,7 @@ class SSHClient(ClosingContextManager): auth_timeout=None, gss_trust_dns=True, passphrase=None, + disabled_algorithms=None, ): """ Connect to an SSH server and authenticate to it. The server's host key @@ -310,6 +311,9 @@ class SSHClient(ClosingContextManager): for the SSH banner to be presented. :param float auth_timeout: an optional timeout (in seconds) to wait for an authentication response. + :param dict disabled_algorithms: + an optional dict passed directly to `.Transport` and its keyword + argument of the same name. :raises: `.BadHostKeyException` -- if the server's host key could not be @@ -327,6 +331,8 @@ class SSHClient(ClosingContextManager): Added the ``gss_trust_dns`` argument. .. versionchanged:: 2.4 Added the ``passphrase`` argument. + .. versionchanged:: 2.6 + Added the ``disabled_algorithms`` argument. """ if not sock: errors = {} @@ -362,7 +368,10 @@ class SSHClient(ClosingContextManager): raise NoValidConnectionsError(errors) t = self._transport = Transport( - sock, gss_kex=gss_kex, gss_deleg_creds=gss_deleg_creds + sock, + gss_kex=gss_kex, + gss_deleg_creds=gss_deleg_creds, + disabled_algorithms=disabled_algorithms, ) t.use_compression(compress=compress) t.set_gss_host( @@ -505,7 +514,7 @@ class SSHClient(ClosingContextManager): if environment: chan.update_environment(environment) chan.exec_command(command) - stdin = chan.makefile("wb", bufsize) + stdin = chan.makefile_stdin("wb", bufsize) stdout = chan.makefile("r", bufsize) stderr = chan.makefile_stderr("r", bufsize) return stdin, stdout, stderr diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py index 52bb23be..b525468a 100644 --- a/paramiko/ssh_exception.py +++ b/paramiko/ssh_exception.py @@ -56,19 +56,19 @@ class BadAuthenticationType(AuthenticationException): .. versionadded:: 1.1 """ - #: list of allowed authentication types provided by the server (possible - #: values are: ``"none"``, ``"password"``, and ``"publickey"``). allowed_types = [] + # TODO 3.0: remove explanation kwarg def __init__(self, explanation, types): - AuthenticationException.__init__(self, explanation) + # TODO 3.0: remove this supercall unless it's actually required for + # pickling (after fixing pickling) + AuthenticationException.__init__(self, explanation, types) + self.explanation = explanation self.allowed_types = types - # for unpickling - self.args = (explanation, types) def __str__(self): - return "{} (allowed_types={!r})".format( - SSHException.__str__(self), self.allowed_types + return "{}; allowed types: {!r}".format( + self.explanation, self.allowed_types ) @@ -80,10 +80,13 @@ class PartialAuthentication(AuthenticationException): allowed_types = [] def __init__(self, types): - AuthenticationException.__init__(self, "partial authentication") + AuthenticationException.__init__(self, types) self.allowed_types = types - # for unpickling - self.args = (types,) + + def __str__(self): + return "Partial authentication; allowed types: {!r}".format( + self.allowed_types + ) class ChannelException(SSHException): @@ -96,10 +99,12 @@ class ChannelException(SSHException): """ def __init__(self, code, text): - SSHException.__init__(self, text) + SSHException.__init__(self, code, text) self.code = code - # for unpickling - self.args = (code, text) + self.text = text + + def __str__(self): + return "ChannelException({!r}, {!r})".format(self.code, self.text) class BadHostKeyException(SSHException): @@ -114,18 +119,20 @@ class BadHostKeyException(SSHException): """ def __init__(self, hostname, got_key, expected_key): - message = ( - "Host key for server {} does not match: got {}, expected {}" - ) # noqa - message = message.format( - hostname, got_key.get_base64(), expected_key.get_base64() - ) - SSHException.__init__(self, message) + SSHException.__init__(self, hostname, got_key, expected_key) self.hostname = hostname self.key = got_key self.expected_key = expected_key - # for unpickling - self.args = (hostname, got_key, expected_key) + + def __str__(self): + msg = ( + "Host key for server '{}' does not match: got '{}', expected '{}'" + ) # noqa + return msg.format( + self.hostname, + self.key.get_base64(), + self.expected_key.get_base64(), + ) class ProxyCommandFailure(SSHException): @@ -137,15 +144,14 @@ class ProxyCommandFailure(SSHException): """ def __init__(self, command, error): - SSHException.__init__( - self, - '"ProxyCommand ({})" returned non-zero exit status: {}'.format( - command, error - ), - ) + SSHException.__init__(self, command, error) + self.command = command self.error = error - # for unpickling - self.args = (command, error) + + def __str__(self): + return 'ProxyCommand("{}") returned nonzero exit status: {}'.format( + self.command, self.error + ) class NoValidConnectionsError(socket.error): diff --git a/paramiko/ssh_gss.py b/paramiko/ssh_gss.py index 3f299aee..eaafe94b 100644 --- a/paramiko/ssh_gss.py +++ b/paramiko/ssh_gss.py @@ -43,12 +43,21 @@ GSS_EXCEPTIONS = () #: :var str _API: Constraint for the used API -_API = "MIT" +_API = None try: import gssapi - GSS_EXCEPTIONS = (gssapi.GSSException,) + if hasattr(gssapi, "__title__") and gssapi.__title__ == "python-gssapi": + # old, unmaintained python-gssapi package + _API = "MIT" # keep this for compatibility + GSS_EXCEPTIONS = (gssapi.GSSException,) + else: + _API = "PYTHON-GSSAPI-NEW" + GSS_EXCEPTIONS = ( + gssapi.exceptions.GeneralError, + gssapi.raw.misc.GSSError, + ) except (ImportError, OSError): try: import pywintypes @@ -63,6 +72,7 @@ except (ImportError, OSError): from paramiko.common import MSG_USERAUTH_REQUEST from paramiko.ssh_exception import SSHException +from paramiko._version import __version_info__ def GSSAuth(auth_method, gss_deleg_creds=True): @@ -73,21 +83,24 @@ def GSSAuth(auth_method, gss_deleg_creds=True): (gssapi-with-mic or gss-keyex) :param bool gss_deleg_creds: Delegate client credentials or not. We delegate credentials by default. - :return: Either an `._SSH_GSSAPI` (Unix) object or an - `_SSH_SSPI` (Windows) object + :return: Either an `._SSH_GSSAPI_OLD` or `._SSH_GSSAPI_NEW` (Unix) + object or an `_SSH_SSPI` (Windows) object + :rtype: object :raises: ``ImportError`` -- If no GSS-API / SSPI module could be imported. :see: `RFC 4462 <http://www.ietf.org/rfc/rfc4462.txt>`_ - :note: Check for the available API and return either an `._SSH_GSSAPI` - (MIT GSSAPI) object or an `._SSH_SSPI` (MS SSPI) object. If you - get python-gssapi working on Windows, python-gssapi - will be used and a `._SSH_GSSAPI` object will be returned. + :note: Check for the available API and return either an `._SSH_GSSAPI_OLD` + (MIT GSSAPI using python-gssapi package) object, an + `._SSH_GSSAPI_NEW` (MIT GSSAPI using gssapi package) object + or an `._SSH_SSPI` (MS SSPI) object. If there is no supported API available, ``None`` will be returned. """ if _API == "MIT": - return _SSH_GSSAPI(auth_method, gss_deleg_creds) + return _SSH_GSSAPI_OLD(auth_method, gss_deleg_creds) + elif _API == "PYTHON-GSSAPI-NEW": + return _SSH_GSSAPI_NEW(auth_method, gss_deleg_creds) elif _API == "SSPI" and os.name == "nt": return _SSH_SSPI(auth_method, gss_deleg_creds) else: @@ -96,8 +109,8 @@ def GSSAuth(auth_method, gss_deleg_creds=True): class _SSH_GSSAuth(object): """ - Contains the shared variables and methods of `._SSH_GSSAPI` and - `._SSH_SSPI`. + Contains the shared variables and methods of `._SSH_GSSAPI_OLD`, + `._SSH_GSSAPI_NEW` and `._SSH_SSPI`. """ def __init__(self, auth_method, gss_deleg_creds): @@ -223,9 +236,10 @@ class _SSH_GSSAuth(object): return mic -class _SSH_GSSAPI(_SSH_GSSAuth): +class _SSH_GSSAPI_OLD(_SSH_GSSAuth): """ - Implementation of the GSS-API MIT Kerberos Authentication for SSH2. + Implementation of the GSS-API MIT Kerberos Authentication for SSH2, + using the older (unmaintained) python-gssapi package. :see: `.GSSAuth` """ @@ -402,6 +416,186 @@ class _SSH_GSSAPI(_SSH_GSSAuth): raise NotImplementedError +if __version_info__ < (2, 5): + # provide the old name for strict backward compatibility + _SSH_GSSAPI = _SSH_GSSAPI_OLD + + +class _SSH_GSSAPI_NEW(_SSH_GSSAuth): + """ + Implementation of the GSS-API MIT Kerberos Authentication for SSH2, + using the newer, currently maintained gssapi package. + + :see: `.GSSAuth` + """ + + def __init__(self, auth_method, gss_deleg_creds): + """ + :param str auth_method: The name of the SSH authentication mechanism + (gssapi-with-mic or gss-keyex) + :param bool gss_deleg_creds: Delegate client credentials or not + """ + _SSH_GSSAuth.__init__(self, auth_method, gss_deleg_creds) + + if self._gss_deleg_creds: + self._gss_flags = ( + gssapi.RequirementFlag.protection_ready, + gssapi.RequirementFlag.integrity, + gssapi.RequirementFlag.mutual_authentication, + gssapi.RequirementFlag.delegate_to_peer, + ) + else: + self._gss_flags = ( + gssapi.RequirementFlag.protection_ready, + gssapi.RequirementFlag.integrity, + gssapi.RequirementFlag.mutual_authentication, + ) + + def ssh_init_sec_context( + self, target, desired_mech=None, username=None, recv_token=None + ): + """ + Initialize a GSS-API context. + + :param str username: The name of the user who attempts to login + :param str target: The hostname of the target to connect to + :param str desired_mech: The negotiated GSS-API mechanism + ("pseudo negotiated" mechanism, because we + support just the krb5 mechanism :-)) + :param str recv_token: The GSS-API token received from the Server + :raises: `.SSHException` -- Is raised if the desired mechanism of the + client is not supported + :raises: ``gssapi.exceptions.GSSError`` if there is an error signaled + by the GSS-API implementation + :return: A ``String`` if the GSS-API has returned a token or ``None`` + if no token was returned + """ + from pyasn1.codec.der import decoder + + self._username = username + self._gss_host = target + targ_name = gssapi.Name( + "host@" + self._gss_host, + name_type=gssapi.NameType.hostbased_service, + ) + if desired_mech is not None: + mech, __ = decoder.decode(desired_mech) + if mech.__str__() != self._krb5_mech: + raise SSHException("Unsupported mechanism OID.") + krb5_mech = gssapi.MechType.kerberos + token = None + if recv_token is None: + self._gss_ctxt = gssapi.SecurityContext( + name=targ_name, + flags=self._gss_flags, + mech=krb5_mech, + usage="initiate", + ) + token = self._gss_ctxt.step(token) + else: + token = self._gss_ctxt.step(recv_token) + self._gss_ctxt_status = self._gss_ctxt.complete + return token + + def ssh_get_mic(self, session_id, gss_kex=False): + """ + Create the MIC token for a SSH2 message. + + :param str session_id: The SSH session ID + :param bool gss_kex: Generate the MIC for GSS-API Key Exchange or not + :return: gssapi-with-mic: + Returns the MIC token from GSS-API for the message we created + with ``_ssh_build_mic``. + gssapi-keyex: + Returns the MIC token from GSS-API with the SSH session ID as + message. + :rtype: str + """ + self._session_id = session_id + if not gss_kex: + mic_field = self._ssh_build_mic( + self._session_id, + self._username, + self._service, + self._auth_method, + ) + mic_token = self._gss_ctxt.get_signature(mic_field) + else: + # for key exchange with gssapi-keyex + mic_token = self._gss_srv_ctxt.get_signature(self._session_id) + return mic_token + + def ssh_accept_sec_context(self, hostname, recv_token, username=None): + """ + Accept a GSS-API context (server mode). + + :param str hostname: The servers hostname + :param str username: The name of the user who attempts to login + :param str recv_token: The GSS-API Token received from the server, + if it's not the initial call. + :return: A ``String`` if the GSS-API has returned a token or ``None`` + if no token was returned + """ + # hostname and username are not required for GSSAPI, but for SSPI + self._gss_host = hostname + self._username = username + if self._gss_srv_ctxt is None: + self._gss_srv_ctxt = gssapi.SecurityContext(usage="accept") + token = self._gss_srv_ctxt.step(recv_token) + self._gss_srv_ctxt_status = self._gss_srv_ctxt.complete + return token + + def ssh_check_mic(self, mic_token, session_id, username=None): + """ + Verify the MIC token for a SSH2 message. + + :param str mic_token: The MIC token received from the client + :param str session_id: The SSH session ID + :param str username: The name of the user who attempts to login + :return: None if the MIC check was successful + :raises: ``gssapi.exceptions.GSSError`` -- if the MIC check failed + """ + self._session_id = session_id + self._username = username + if self._username is not None: + # server mode + mic_field = self._ssh_build_mic( + self._session_id, + self._username, + self._service, + self._auth_method, + ) + self._gss_srv_ctxt.verify_signature(mic_field, mic_token) + else: + # for key exchange with gssapi-keyex + # client mode + self._gss_ctxt.verify_signature(self._session_id, mic_token) + + @property + def credentials_delegated(self): + """ + Checks if credentials are delegated (server mode). + + :return: ``True`` if credentials are delegated, otherwise ``False`` + :rtype: bool + """ + if self._gss_srv_ctxt.delegated_creds is not None: + return True + return False + + def save_client_creds(self, client_token): + """ + Save the Client token in a file. This is used by the SSH server + to store the client credentials if credentials are delegated + (server mode). + + :param str client_token: The GSS-API token received form the client + :raises: ``NotImplementedError`` -- Credential delegation is currently + not supported in server mode + """ + raise NotImplementedError + + class _SSH_SSPI(_SSH_GSSAuth): """ Implementation of the Microsoft SSPI Kerberos Authentication for SSH2. diff --git a/paramiko/transport.py b/paramiko/transport.py index bd145c1e..8919043f 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -145,6 +145,9 @@ class Transport(threading.Thread, ClosingContextManager): # These tuples of algorithm identifiers are in preference order; do not # reorder without reason! + # NOTE: if you need to modify these, we suggest leveraging the + # `disabled_algorithms` constructor argument (also available in SSHClient) + # instead of monkeypatching or subclassing. _preferred_ciphers = ( "aes128-ctr", "aes192-ctr", @@ -306,6 +309,7 @@ class Transport(threading.Thread, ClosingContextManager): default_max_packet_size=DEFAULT_MAX_PACKET_SIZE, gss_kex=False, gss_deleg_creds=True, + disabled_algorithms=None, ): """ Create a new SSH session over an existing socket, or socket-like @@ -352,12 +356,30 @@ class Transport(threading.Thread, ClosingContextManager): :param bool gss_deleg_creds: Whether to enable GSSAPI credential delegation when GSSAPI is in play. Default: ``True``. + :param dict disabled_algorithms: + If given, must be a dictionary mapping algorithm type to an + iterable of algorithm identifiers, which will be disabled for the + lifetime of the transport. + + Keys should match the last word in the class' builtin algorithm + tuple attributes, such as ``"ciphers"`` to disable names within + ``_preferred_ciphers``; or ``"kex"`` to disable something defined + inside ``_preferred_kex``. Values should exactly match members of + the matching attribute. + + For example, if you need to disable + ``diffie-hellman-group16-sha512`` key exchange (perhaps because + your code talks to a server which implements it differently from + Paramiko), specify ``disabled_algorithms={"kex": + ["diffie-hellman-group16-sha512"]}``. .. versionchanged:: 1.15 Added the ``default_window_size`` and ``default_max_packet_size`` arguments. .. versionchanged:: 1.15 Added the ``gss_kex`` and ``gss_deleg_creds`` kwargs. + .. versionchanged:: 2.6 + Added the ``disabled_algorithms`` kwarg. """ self.active = False self.hostname = None @@ -465,6 +487,7 @@ class Transport(threading.Thread, ClosingContextManager): self.handshake_timeout = 15 # how long (seconds) to wait for the auth response. self.auth_timeout = 30 + self.disabled_algorithms = disabled_algorithms or {} # server mode: self.server_mode = False @@ -474,6 +497,34 @@ class Transport(threading.Thread, ClosingContextManager): self.server_accept_cv = threading.Condition(self.lock) self.subsystem_table = {} + def _filter_algorithm(self, type_): + default = getattr(self, "_preferred_{}".format(type_)) + return tuple( + x + for x in default + if x not in self.disabled_algorithms.get(type_, []) + ) + + @property + def preferred_ciphers(self): + return self._filter_algorithm("ciphers") + + @property + def preferred_macs(self): + return self._filter_algorithm("macs") + + @property + def preferred_keys(self): + return self._filter_algorithm("keys") + + @property + def preferred_kex(self): + return self._filter_algorithm("kex") + + @property + def preferred_compression(self): + return self._filter_algorithm("compression") + def __repr__(self): """ Returns a string representation of this object, for debugging. @@ -2206,7 +2257,7 @@ class Transport(threading.Thread, ClosingContextManager): mp_required_prefix = "diffie-hellman-group-exchange-sha" kex_mp = [ k - for k in self._preferred_kex + for k in self.preferred_kex if k.startswith(mp_required_prefix) ] if (self._modulus_pack is None) and (len(kex_mp) > 0): @@ -2221,23 +2272,23 @@ class Transport(threading.Thread, ClosingContextManager): available_server_keys = list( filter( list(self.server_key_dict.keys()).__contains__, - self._preferred_keys, + self.preferred_keys, ) ) else: - available_server_keys = self._preferred_keys + available_server_keys = self.preferred_keys m = Message() m.add_byte(cMSG_KEXINIT) m.add_bytes(os.urandom(16)) - m.add_list(self._preferred_kex) + m.add_list(self.preferred_kex) m.add_list(available_server_keys) - m.add_list(self._preferred_ciphers) - m.add_list(self._preferred_ciphers) - m.add_list(self._preferred_macs) - m.add_list(self._preferred_macs) - m.add_list(self._preferred_compression) - m.add_list(self._preferred_compression) + m.add_list(self.preferred_ciphers) + m.add_list(self.preferred_ciphers) + m.add_list(self.preferred_macs) + m.add_list(self.preferred_macs) + m.add_list(self.preferred_compression) + m.add_list(self.preferred_compression) m.add_string(bytes()) m.add_string(bytes()) m.add_boolean(False) @@ -2293,11 +2344,11 @@ class Transport(threading.Thread, ClosingContextManager): # supports. if self.server_mode: agreed_kex = list( - filter(self._preferred_kex.__contains__, kex_algo_list) + filter(self.preferred_kex.__contains__, kex_algo_list) ) else: agreed_kex = list( - filter(kex_algo_list.__contains__, self._preferred_kex) + filter(kex_algo_list.__contains__, self.preferred_kex) ) if len(agreed_kex) == 0: raise SSHException( @@ -2310,7 +2361,7 @@ class Transport(threading.Thread, ClosingContextManager): available_server_keys = list( filter( list(self.server_key_dict.keys()).__contains__, - self._preferred_keys, + self.preferred_keys, ) ) agreed_keys = list( @@ -2320,7 +2371,7 @@ class Transport(threading.Thread, ClosingContextManager): ) else: agreed_keys = list( - filter(server_key_algo_list.__contains__, self._preferred_keys) + filter(server_key_algo_list.__contains__, self.preferred_keys) ) if len(agreed_keys) == 0: raise SSHException( @@ -2336,13 +2387,13 @@ class Transport(threading.Thread, ClosingContextManager): if self.server_mode: agreed_local_ciphers = list( filter( - self._preferred_ciphers.__contains__, + self.preferred_ciphers.__contains__, server_encrypt_algo_list, ) ) agreed_remote_ciphers = list( filter( - self._preferred_ciphers.__contains__, + self.preferred_ciphers.__contains__, client_encrypt_algo_list, ) ) @@ -2350,13 +2401,13 @@ class Transport(threading.Thread, ClosingContextManager): agreed_local_ciphers = list( filter( client_encrypt_algo_list.__contains__, - self._preferred_ciphers, + self.preferred_ciphers, ) ) agreed_remote_ciphers = list( filter( server_encrypt_algo_list.__contains__, - self._preferred_ciphers, + self.preferred_ciphers, ) ) if len(agreed_local_ciphers) == 0 or len(agreed_remote_ciphers) == 0: @@ -2371,17 +2422,17 @@ class Transport(threading.Thread, ClosingContextManager): if self.server_mode: agreed_remote_macs = list( - filter(self._preferred_macs.__contains__, client_mac_algo_list) + filter(self.preferred_macs.__contains__, client_mac_algo_list) ) agreed_local_macs = list( - filter(self._preferred_macs.__contains__, server_mac_algo_list) + filter(self.preferred_macs.__contains__, server_mac_algo_list) ) else: agreed_local_macs = list( - filter(client_mac_algo_list.__contains__, self._preferred_macs) + filter(client_mac_algo_list.__contains__, self.preferred_macs) ) agreed_remote_macs = list( - filter(server_mac_algo_list.__contains__, self._preferred_macs) + 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)") @@ -2394,13 +2445,13 @@ class Transport(threading.Thread, ClosingContextManager): if self.server_mode: agreed_remote_compression = list( filter( - self._preferred_compression.__contains__, + self.preferred_compression.__contains__, client_compress_algo_list, ) ) agreed_local_compression = list( filter( - self._preferred_compression.__contains__, + self.preferred_compression.__contains__, server_compress_algo_list, ) ) @@ -2408,13 +2459,13 @@ class Transport(threading.Thread, ClosingContextManager): agreed_local_compression = list( filter( client_compress_algo_list.__contains__, - self._preferred_compression, + self.preferred_compression, ) ) agreed_remote_compression = list( filter( server_compress_algo_list.__contains__, - self._preferred_compression, + self.preferred_compression, ) ) if ( @@ -2427,7 +2478,7 @@ class Transport(threading.Thread, ClosingContextManager): msg.format( agreed_local_compression, agreed_remote_compression, - self._preferred_compression, + self.preferred_compression, ) ) self.local_compression = agreed_local_compression[0] @@ -74,4 +74,11 @@ setup( "Programming Language :: Python :: 3.8", ], install_requires=["bcrypt>=3.1.3", "cryptography>=2.5", "pynacl>=1.0.1"], + extras_require={ + "gssapi": [ + "pyasn1>=0.1.7", + 'gssapi>=1.4.1;platform_system!="Windows"', + 'pywin32>=2.1.8;platform_system=="Windows"', + ] + }, ) diff --git a/sites/docs/api/ssh_gss.rst b/sites/docs/api/ssh_gss.rst index 7a687e11..155fcfff 100644 --- a/sites/docs/api/ssh_gss.rst +++ b/sites/docs/api/ssh_gss.rst @@ -7,7 +7,10 @@ GSS-API authentication .. autoclass:: _SSH_GSSAuth :member-order: bysource -.. autoclass:: _SSH_GSSAPI +.. autoclass:: _SSH_GSSAPI_OLD + :member-order: bysource + +.. autoclass:: _SSH_GSSAPI_NEW :member-order: bysource .. autoclass:: _SSH_SSPI diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index dea7f205..02e48e92 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -8,6 +8,13 @@ Changelog to a bug causing them to ignore the ``hash_algo`` class attribute. This has been corrected. Big thanks to ``@miverson`` for the report and to Benno Rice for the patch. +- :release:`2.6.0 <2019-06-23>` +- :feature:`1463` Add a new keyword argument to `SSHClient.connect + <paramiko.client.SSHClient.connect>` and `~paramiko.transport.Transport`, + ``disabled_algorithms``, which allows selectively disabling one or more + kex/key/cipher/etc algorithms. This can be useful when disabling algorithms + your target server (or client) does not support cleanly, or to work around + unpatched bugs in Paramiko's own implementation thereof. - :release:`2.5.1 <2019-06-23>` - :release:`2.4.3 <2019-06-23>` - :bug:`1306` (via :issue:`1400`) Fix Ed25519 key handling so certain key @@ -15,6 +22,48 @@ Changelog technically a bug in how padding, or lack thereof, is calculated/interpreted). Thanks to ``@parke`` for the bug report & Pierce Lopez for the patch. +- :support:`1440` (with initial fixes via :issue:`1460`) Tweak many exception + classes so their string representations are more human-friendly; this also + includes incidental changes to some ``super()`` calls. + + The definitions of exceptions' ``__init__`` methods have *not* changed, nor + have any log messages been altered, so this should be backwards compatible + for everything except the actual exceptions' ``__str__()`` outputs. + + Thanks to Fabian Büchler for original report & Pierce Lopez for the + foundational patch. +- :support:`1311` (for :issue:`584`, replacing :issue:`1166`) Add + backwards-compatible support for the ``gssapi`` GSSAPI library, as the + previous backend (``python-gssapi``) has since become defunct. This change + also includes tests for the GSSAPI functionality. + + Big thanks to Anselm Kruis for the patch and to Sebastian Deiß (author of our + initial GSSAPI functionality) for review. + + .. note:: + This feature also adds ``setup.py`` 'extras' support for installing + Paramiko as ``paramiko[gssapi]``, which pulls in the optional + dependencies you had to get by hand previously. + + .. note:: + To be very clear, this patch **does not** remove support for the older + ``python-gssapi`` library. We *may* remove that support in a later release, + but for now, either library will work. Please upgrade to ``gssapi`` when + you can, however, as ``python-gssapi`` is no longer maintained upstream. + +- :bug:`322 major` `SSHClient.exec_command + <paramiko.client.SSHClient.exec_command>` previously returned a naive + `~paramiko.channel.ChannelFile` object for its ``stdin`` value; such objects + don't know to properly shut down the remote end's stdin when they + ``.close()``. This lead to issues (such as hangs) when running remote + commands that read from stdin. + + A new subclass, `~paramiko.channel.ChannelStdinFile`, has been created which + closes remote stdin when it itself is closed. + `~paramiko.client.SSHClient.exec_command` has been updated to use that class + for its ``stdin`` return value. + + Thanks to Brandon Rhodes for the report & steps to reproduce. - :release:`2.5.0 <2019-06-09>` - :feature:`1233` (also :issue:`1229`, :issue:`1332`) Add support for encrypt-then-MAC (ETM) schemes (``hmac-sha2-256-etm@openssh.com``, diff --git a/sites/www/installing.rst b/sites/www/installing.rst index 1a77bbe4..2e2f639c 100644 --- a/sites/www/installing.rst +++ b/sites/www/installing.rst @@ -101,22 +101,41 @@ In general, you'll need one of the following setups: Optional dependencies for GSS-API / SSPI / Kerberos =================================================== -In order to use GSS-API/Kerberos & related functionality, a couple of -additional dependencies are required (these are not listed in our ``setup.py`` -due to their infrequent utility & non-platform-agnostic requirements): - -* It hopefully goes without saying but **all platforms** need **a working - installation of GSS-API itself**, e.g. Heimdal. -* **Unix** needs `python-gssapi <https://pypi.org/project/python-gssapi/>`_ - ``0.6.1`` or better. - - .. note:: This library appears to only function on Python 2.7 and up. - -* **Windows** needs `pywin32 <https://pypi.python.org/pypi/pywin32>`_ ``2.1.8`` - or better. +In order to use GSS-API/Kerberos & related functionality, additional +dependencies are required. It hopefully goes without saying but **all +platforms** need **a working installation of GSS-API itself**, e.g. Heimdal. .. note:: If you use Microsoft SSPI for kerberos authentication and credential delegation, make sure that the target host is trusted for delegation in the active directory configuration. For details see: http://technet.microsoft.com/en-us/library/cc738491%28v=ws.10%29.aspx + +The ``gssapi`` "extra" install flavor +------------------------------------- + +If you're installing via ``pip`` (recommended), you should be able to get the +optional Python package requirements by changing your installation to refer to +``paramiko[gssapi]`` (from simply ``paramiko``), e.g.:: + + pip install "paramiko[gssapi]" + +(Or update your ``requirements.txt``, or etc.) + +Manual dependency installation +------------------------------ + +If you're not using ``pip`` or your ``pip`` is too old to support the "extras" +functionality, the optional dependencies are as follows: + +* All platforms need `pyasn1 <https://pypi.org/project/pyasn1/>`_ ``0.1.7`` or + later. +* **Unix** needs: `gssapi <https://pypi.org/project/gssapi/>`__ ``1.4.1`` or better. + + * An alternative is the `python-gssapi + <https://pypi.org/project/python-gssapi/>`_ library (``0.6.1`` or above), + though it is no longer maintained upstream, and Paramiko's support for + its API may eventually become deprecated. + +* **Windows** needs `pywin32 <https://pypi.python.org/pypi/pywin32>`_ ``2.1.8`` + or better. diff --git a/tests/test_channelfile.py b/tests/test_channelfile.py index ffcbea3f..4448fdfb 100644 --- a/tests/test_channelfile.py +++ b/tests/test_channelfile.py @@ -1,32 +1,36 @@ from mock import patch, MagicMock -from paramiko import Channel, ChannelFile, ChannelStderrFile +from paramiko import Channel, ChannelFile, ChannelStderrFile, ChannelStdinFile -class TestChannelFile(object): +class ChannelFileBase(object): @patch("paramiko.channel.ChannelFile._set_mode") def test_defaults_to_unbuffered_reading(self, setmode): - ChannelFile(Channel(None)) + self.klass(Channel(None)) setmode.assert_called_once_with("r", -1) @patch("paramiko.channel.ChannelFile._set_mode") def test_can_override_mode_and_bufsize(self, setmode): - ChannelFile(Channel(None), mode="w", bufsize=25) + self.klass(Channel(None), mode="w", bufsize=25) setmode.assert_called_once_with("w", 25) def test_read_recvs_from_channel(self): chan = MagicMock() - cf = ChannelFile(chan) + cf = self.klass(chan) cf.read(100) chan.recv.assert_called_once_with(100) def test_write_calls_channel_sendall(self): chan = MagicMock() - cf = ChannelFile(chan, mode="w") + cf = self.klass(chan, mode="w") cf.write("ohai") chan.sendall.assert_called_once_with(b"ohai") +class TestChannelFile(ChannelFileBase): + klass = ChannelFile + + class TestChannelStderrFile(object): def test_read_calls_channel_recv_stderr(self): chan = MagicMock() @@ -39,3 +43,18 @@ class TestChannelStderrFile(object): cf = ChannelStderrFile(chan, mode="w") cf.write("ohai") chan.sendall_stderr.assert_called_once_with(b"ohai") + + +class TestChannelStdinFile(ChannelFileBase): + klass = ChannelStdinFile + + def test_close_calls_channel_shutdown_write(self): + chan = MagicMock() + cf = ChannelStdinFile(chan, mode="wb") + cf.flush = MagicMock() + cf.close() + # Sanity check that we still call BufferedFile.close() + cf.flush.assert_called_once_with() + assert cf._closed is True + # Actual point of test + chan.shutdown_write.assert_called_once_with() diff --git a/tests/test_client.py b/tests/test_client.py index 26de2d37..ad5c36ad 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -34,8 +34,10 @@ import weakref from tempfile import mkstemp from pytest_relaxed import raises +from mock import patch, Mock import paramiko +from paramiko import SSHClient from paramiko.pkey import PublicBlob from paramiko.ssh_exception import SSHException, AuthenticationException @@ -191,7 +193,7 @@ class ClientTest(unittest.TestCase): public_host_key = paramiko.RSAKey(data=host_key.asbytes()) # Client setup - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.get_host_keys().add( "[%s]:%d" % (self.addr, self.port), "ssh-rsa", public_host_key ) @@ -213,7 +215,7 @@ class ClientTest(unittest.TestCase): # Nobody else tests the API of exec_command so let's do it here for # now. :weary: - assert isinstance(stdin, paramiko.ChannelFile) + assert isinstance(stdin, paramiko.ChannelStdinFile) assert isinstance(stdout, paramiko.ChannelFile) assert isinstance(stderr, paramiko.ChannelStderrFile) @@ -349,7 +351,7 @@ class SSHClientTest(ClientTest): key_file = _support("test_ecdsa_256.key") public_host_key = paramiko.ECDSAKey.from_private_key_file(key_file) - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.assertEqual(0, len(self.tc.get_host_keys())) self.tc.connect(password="pygmalion", **self.connect_kwargs) @@ -376,7 +378,7 @@ class SSHClientTest(ClientTest): fd, localname = mkstemp() os.close(fd) - client = paramiko.SSHClient() + client = SSHClient() assert len(client.get_host_keys()) == 0 host_id = "[%s]:%d" % (self.addr, self.port) @@ -403,7 +405,7 @@ class SSHClientTest(ClientTest): threading.Thread(target=self._run).start() - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.assertEqual(0, len(self.tc.get_host_keys())) self.tc.connect(**dict(self.connect_kwargs, password="pygmalion")) @@ -431,7 +433,7 @@ class SSHClientTest(ClientTest): """ threading.Thread(target=self._run).start() - with paramiko.SSHClient() as tc: + with SSHClient() as tc: self.tc = tc self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy()) assert len(self.tc.get_host_keys()) == 0 @@ -456,7 +458,7 @@ class SSHClientTest(ClientTest): ) public_host_key = paramiko.RSAKey(data=host_key.asbytes()) - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.get_host_keys().add( "[%s]:%d" % (self.addr, self.port), "ssh-rsa", public_host_key ) @@ -519,7 +521,7 @@ class SSHClientTest(ClientTest): """ threading.Thread(target=self._run).start() - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.set_missing_host_key_policy(paramiko.RejectPolicy()) self.assertEqual(0, len(self.tc.get_host_keys())) self.assertRaises( @@ -539,7 +541,7 @@ class SSHClientTest(ClientTest): # 2017-08-01 threading.Thread(target=self._run).start() - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.set_missing_host_key_policy(paramiko.RejectPolicy()) self.assertEqual(0, len(self.tc.get_host_keys())) self.assertRaises( @@ -554,7 +556,7 @@ class SSHClientTest(ClientTest): threading.Thread(target=self._run).start() hostname = "[%s]:%d" % (self.addr, self.port) - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.set_missing_host_key_policy(paramiko.WarningPolicy()) known_hosts = self.tc.get_host_keys() known_hosts.add(hostname, host_key.get_name(), host_key) @@ -570,7 +572,7 @@ class SSHClientTest(ClientTest): threading.Thread(target=self._run).start() hostname = "[%s]:%d" % (self.addr, self.port) - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.set_missing_host_key_policy(paramiko.RejectPolicy()) host_key = ktype.from_private_key_file(_support(kfile)) known_hosts = self.tc.get_host_keys() @@ -599,7 +601,7 @@ class SSHClientTest(ClientTest): def _setup_for_env(self): threading.Thread(target=self._run).start() - self.tc = paramiko.SSHClient() + self.tc = SSHClient() self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.assertEqual(0, len(self.tc.get_host_keys())) self.tc.connect( @@ -643,7 +645,7 @@ class SSHClientTest(ClientTest): """ # AN ACTUAL UNIT TEST?! GOOD LORD # (But then we have to test a private API...meh.) - client = paramiko.SSHClient() + client = SSHClient() # Default assert isinstance(client._policy, paramiko.RejectPolicy) # Hand in an instance (classic behavior) @@ -653,6 +655,22 @@ class SSHClientTest(ClientTest): client.set_missing_host_key_policy(paramiko.AutoAddPolicy) assert isinstance(client._policy, paramiko.AutoAddPolicy) + @patch("paramiko.client.Transport") + def test_disabled_algorithms_defaults_to_None(self, Transport): + SSHClient().connect("host", sock=Mock(), password="no") + assert Transport.call_args[1]["disabled_algorithms"] is None + + @patch("paramiko.client.Transport") + def test_disabled_algorithms_passed_directly_if_given(self, Transport): + SSHClient().connect( + "host", + sock=Mock(), + password="no", + disabled_algorithms={"keys": ["ssh-dss"]}, + ) + call_arg = Transport.call_args[1]["disabled_algorithms"] + assert call_arg == {"keys": ["ssh-dss"]} + class PasswordPassphraseTests(ClientTest): # TODO: most of these could reasonably be set up to use mocks/assertions diff --git a/tests/test_gssapi.py b/tests/test_gssapi.py index 46d5bbd1..30ffb56d 100644 --- a/tests/test_gssapi.py +++ b/tests/test_gssapi.py @@ -22,20 +22,21 @@ Test the used APIs for GSS-API / SSPI authentication """ -import unittest import socket -from .util import needs_gssapi +from .util import needs_gssapi, KerberosTestCase, update_env @needs_gssapi -class GSSAPITest(unittest.TestCase): - def setup(self): +class GSSAPITest(KerberosTestCase): + def setUp(self): + super(GSSAPITest, self).setUp() # TODO: these vars should all come from os.environ or whatever the # approved pytest method is for runtime-configuring test data. self.krb5_mech = "1.2.840.113554.1.2.2" - self.targ_name = "hostname" + self.targ_name = self.realm.hostname self.server_mode = False + update_env(self, self.realm.env) def test_pyasn1(self): """ @@ -48,13 +49,20 @@ class GSSAPITest(unittest.TestCase): mech, __ = decoder.decode(oid) self.assertEquals(self.krb5_mech, mech.__str__()) - def test_gssapi_sspi(self): + def _gssapi_sspi_test(self): """ Test the used methods of python-gssapi or sspi, sspicon from pywin32. """ - _API = "MIT" try: import gssapi + + if ( + hasattr(gssapi, "__title__") + and gssapi.__title__ == "python-gssapi" + ): + _API = "PYTHON-GSSAPI-OLD" + else: + _API = "PYTHON-GSSAPI-NEW" except ImportError: import sspicon import sspi @@ -65,7 +73,7 @@ class GSSAPITest(unittest.TestCase): gss_ctxt_status = False mic_msg = b"G'day Mate!" - if _API == "MIT": + if _API == "PYTHON-GSSAPI-OLD": if self.server_mode: gss_flags = ( gssapi.C_PROT_READY_FLAG, @@ -113,6 +121,56 @@ class GSSAPITest(unittest.TestCase): # Check MIC status = gss_srv_ctxt.verify_mic(mic_msg, mic_token) self.assertEquals(0, status) + elif _API == "PYTHON-GSSAPI-NEW": + if self.server_mode: + gss_flags = ( + gssapi.RequirementFlag.protection_ready, + gssapi.RequirementFlag.integrity, + gssapi.RequirementFlag.mutual_authentication, + gssapi.RequirementFlag.delegate_to_peer, + ) + else: + gss_flags = ( + gssapi.RequirementFlag.protection_ready, + gssapi.RequirementFlag.integrity, + gssapi.RequirementFlag.delegate_to_peer, + ) + # Initialize a GSS-API context. + krb5_oid = gssapi.MechType.kerberos + target_name = gssapi.Name( + "host@" + self.targ_name, + name_type=gssapi.NameType.hostbased_service, + ) + gss_ctxt = gssapi.SecurityContext( + name=target_name, + flags=gss_flags, + mech=krb5_oid, + usage="initiate", + ) + if self.server_mode: + c_token = gss_ctxt.step(c_token) + gss_ctxt_status = gss_ctxt.complete + self.assertEquals(False, gss_ctxt_status) + # Accept a GSS-API context. + gss_srv_ctxt = gssapi.SecurityContext(usage="accept") + s_token = gss_srv_ctxt.step(c_token) + gss_ctxt_status = gss_srv_ctxt.complete + self.assertNotEquals(None, s_token) + self.assertEquals(True, gss_ctxt_status) + # Establish the client context + c_token = gss_ctxt.step(s_token) + self.assertEquals(None, c_token) + else: + while not gss_ctxt.complete: + c_token = gss_ctxt.step(c_token) + self.assertNotEquals(None, c_token) + # Build MIC + mic_token = gss_ctxt.get_signature(mic_msg) + + if self.server_mode: + # Check MIC + status = gss_srv_ctxt.verify_signature(mic_msg, mic_token) + self.assertEquals(0, status) else: gss_flags = ( sspicon.ISC_REQ_INTEGRITY @@ -145,3 +203,16 @@ class GSSAPITest(unittest.TestCase): error, token = gss_ctxt.authorize(c_token) c_token = token[0].Buffer self.assertNotEquals(0, error) + + def test_2_gssapi_sspi_client(self): + """ + Test the used methods of python-gssapi or sspi, sspicon from pywin32. + """ + self._gssapi_sspi_test() + + def test_3_gssapi_sspi_server(self): + """ + Test the used methods of python-gssapi or sspi, sspicon from pywin32. + """ + self.server_mode = True + self._gssapi_sspi_test() diff --git a/tests/test_kex_gss.py b/tests/test_kex_gss.py index 42e0a101..6f5625dc 100644 --- a/tests/test_kex_gss.py +++ b/tests/test_kex_gss.py @@ -31,7 +31,7 @@ import unittest import paramiko -from .util import needs_gssapi +from .util import needs_gssapi, KerberosTestCase, update_env class NullServer(paramiko.ServerInterface): @@ -53,27 +53,22 @@ class NullServer(paramiko.ServerInterface): return paramiko.OPEN_SUCCEEDED def check_channel_exec_request(self, channel, command): - if command != "yes": + if command != b"yes": return False return True @needs_gssapi -class GSSKexTest(unittest.TestCase): - @staticmethod - def init(username, hostname): - global krb5_principal, targ_name - krb5_principal = username - targ_name = hostname - +class GSSKexTest(KerberosTestCase): def setUp(self): - self.username = krb5_principal - self.hostname = socket.getfqdn(targ_name) + self.username = self.realm.user_princ + self.hostname = socket.getfqdn(self.realm.hostname) self.sockl = socket.socket() - self.sockl.bind((targ_name, 0)) + self.sockl.bind((self.realm.hostname, 0)) self.sockl.listen(1) self.addr, self.port = self.sockl.getsockname() self.event = threading.Event() + update_env(self, self.realm.env) thread = threading.Thread(target=self._run) thread.start() @@ -87,7 +82,7 @@ class GSSKexTest(unittest.TestCase): self.ts = paramiko.Transport(self.socks, gss_kex=True) host_key = paramiko.RSAKey.from_private_key_file("tests/test_rsa.key") self.ts.add_server_key(host_key) - self.ts.set_gss_host(targ_name) + self.ts.set_gss_host(self.realm.hostname) try: self.ts.load_server_moduli() except: @@ -150,6 +145,8 @@ class GSSKexTest(unittest.TestCase): """ self._test_gsskex_and_auth(gss_host=None) + # To be investigated, see https://github.com/paramiko/paramiko/issues/1312 + @unittest.expectedFailure def test_gsskex_and_auth_rekey(self): """ Verify that Paramiko can rekey. diff --git a/tests/test_ssh_exception.py b/tests/test_ssh_exception.py index d9e0bd22..1628986a 100644 --- a/tests/test_ssh_exception.py +++ b/tests/test_ssh_exception.py @@ -1,7 +1,15 @@ import pickle import unittest -from paramiko.ssh_exception import NoValidConnectionsError +from paramiko import RSAKey +from paramiko.ssh_exception import ( + NoValidConnectionsError, + BadAuthenticationType, + PartialAuthentication, + ChannelException, + BadHostKeyException, + ProxyCommandFailure, +) class NoValidConnectionsErrorTest(unittest.TestCase): @@ -33,3 +41,35 @@ class NoValidConnectionsErrorTest(unittest.TestCase): ) exp = "Unable to connect to port 22 on 10.0.0.42, 127.0.0.1 or ::1" assert exp in str(exc) + + +class ExceptionStringDisplayTest(unittest.TestCase): + def test_BadAuthenticationType(self): + exc = BadAuthenticationType( + "Bad authentication type", ["ok", "also-ok"] + ) + expected = "Bad authentication type; allowed types: ['ok', 'also-ok']" + assert str(exc) == expected + + def test_PartialAuthentication(self): + exc = PartialAuthentication(["ok", "also-ok"]) + expected = "Partial authentication; allowed types: ['ok', 'also-ok']" + assert str(exc) == expected + + def test_BadHostKeyException(self): + got_key = RSAKey.generate(2048) + wanted_key = RSAKey.generate(2048) + exc = BadHostKeyException("myhost", got_key, wanted_key) + expected = "Host key for server 'myhost' does not match: got '{}', expected '{}'" # noqa + assert str(exc) == expected.format( + got_key.get_base64(), wanted_key.get_base64() + ) + + def test_ProxyCommandFailure(self): + exc = ProxyCommandFailure("man squid", 7) + expected = 'ProxyCommand("man squid") returned nonzero exit status: 7' + assert str(exc) == expected + + def test_ChannelException(self): + exc = ChannelException(17, "whatever") + assert str(exc) == "ChannelException(17, 'whatever')" diff --git a/tests/test_ssh_gss.py b/tests/test_ssh_gss.py index d8c151f9..92801c20 100644 --- a/tests/test_ssh_gss.py +++ b/tests/test_ssh_gss.py @@ -25,11 +25,10 @@ Unit Tests for the GSS-API / SSPI SSHv2 Authentication (gssapi-with-mic) import socket import threading -import unittest import paramiko -from .util import _support, needs_gssapi +from .util import _support, needs_gssapi, KerberosTestCase, update_env from .test_client import FINGERPRINTS @@ -61,23 +60,24 @@ class NullServer(paramiko.ServerInterface): return paramiko.OPEN_SUCCEEDED def check_channel_exec_request(self, channel, command): - if command != "yes": + if command != b"yes": return False return True @needs_gssapi -class GSSAuthTest(unittest.TestCase): +class GSSAuthTest(KerberosTestCase): def setUp(self): # TODO: username and targ_name should come from os.environ or whatever # the approved pytest method is for runtime-configuring test data. - self.username = "krb5_principal" - self.hostname = socket.getfqdn("targ_name") + self.username = self.realm.user_princ + self.hostname = socket.getfqdn(self.realm.hostname) self.sockl = socket.socket() - self.sockl.bind(("targ_name", 0)) + self.sockl.bind((self.realm.hostname, 0)) self.sockl.listen(1) self.addr, self.port = self.sockl.getsockname() self.event = threading.Event() + update_env(self, self.realm.env) thread = threading.Thread(target=self._run) thread.start() diff --git a/tests/test_transport.py b/tests/test_transport.py index ad267e28..e2174896 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1102,3 +1102,70 @@ class TransportTest(unittest.TestCase): assert not self.ts.auth_handler.authenticated # Real fix's behavior self._expect_unimplemented() + + +class AlgorithmDisablingTests(unittest.TestCase): + def test_preferred_lists_default_to_private_attribute_contents(self): + t = Transport(sock=Mock()) + assert t.preferred_ciphers == t._preferred_ciphers + assert t.preferred_macs == t._preferred_macs + assert t.preferred_keys == t._preferred_keys + assert t.preferred_kex == t._preferred_kex + + def test_preferred_lists_filter_disabled_algorithms(self): + t = Transport( + sock=Mock(), + disabled_algorithms={ + "ciphers": ["aes128-cbc"], + "macs": ["hmac-md5"], + "keys": ["ssh-dss"], + "kex": ["diffie-hellman-group14-sha256"], + }, + ) + assert "aes128-cbc" in t._preferred_ciphers + assert "aes128-cbc" not in t.preferred_ciphers + assert "hmac-md5" in t._preferred_macs + assert "hmac-md5" not in t.preferred_macs + assert "ssh-dss" in t._preferred_keys + assert "ssh-dss" not in t.preferred_keys + assert "diffie-hellman-group14-sha256" in t._preferred_kex + assert "diffie-hellman-group14-sha256" not in t.preferred_kex + + def test_implementation_refers_to_public_algo_lists(self): + t = Transport( + sock=Mock(), + disabled_algorithms={ + "ciphers": ["aes128-cbc"], + "macs": ["hmac-md5"], + "keys": ["ssh-dss"], + "kex": ["diffie-hellman-group14-sha256"], + "compression": ["zlib"], + }, + ) + # Enable compression cuz otherwise disabling one option for it makes no + # sense... + t.use_compression(True) + # Effectively a random spot check, but kex init touches most/all of the + # algorithm lists so it's a good spot. + t._send_message = Mock() + t._send_kex_init() + # Cribbed from Transport._parse_kex_init, which didn't feel worth + # refactoring given all the vars involved :( + m = t._send_message.call_args[0][0] + m.rewind() + m.get_byte() # the msg type + m.get_bytes(16) # cookie, discarded + kexen = m.get_list() + server_keys = m.get_list() + ciphers = m.get_list() + m.get_list() + macs = m.get_list() + m.get_list() + compressions = m.get_list() + # OK, now we can actually check that our disabled algos were not + # included (as this message includes the full lists) + assert "aes128-cbc" not in ciphers + assert "hmac-md5" not in macs + assert "ssh-dss" not in server_keys + assert "diffie-hellman-group14-sha256" not in kexen + assert "zlib" not in compressions diff --git a/tests/util.py b/tests/util.py index 4ca02374..cdc835c9 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,19 +1,21 @@ from os.path import dirname, realpath, join +import os +import sys +import unittest import pytest from paramiko.py3compat import builtins +from paramiko.ssh_gss import GSS_AUTH_AVAILABLE def _support(filename): return join(dirname(realpath(__file__)), filename) -# TODO: consider using pytest.importorskip('gssapi') instead? We presumably -# still need CLI configurability for the Kerberos parameters, though, so can't -# JUST key off presence of GSSAPI optional dependency... -# TODO: anyway, s/True/os.environ.get('RUN_GSSAPI', False)/ or something. -needs_gssapi = pytest.mark.skipif(True, reason="No GSSAPI to test") +needs_gssapi = pytest.mark.skipif( + not GSS_AUTH_AVAILABLE, reason="No GSSAPI to test" +) def needs_builtin(name): @@ -25,3 +27,96 @@ def needs_builtin(name): slow = pytest.mark.slow + +# GSSAPI / Kerberos related tests need a working Kerberos environment. +# The class `KerberosTestCase` provides such an environment or skips all tests. +# There are 3 distinct cases: +# +# - A Kerberos environment has already been created and the environment +# contains the required information. +# +# - We can use the package 'k5test' to setup an working kerberos environment on +# the fly. +# +# - We skip all tests. +# +# ToDo: add a Windows specific implementation? + +if ( + os.environ.get("K5TEST_USER_PRINC", None) + and os.environ.get("K5TEST_HOSTNAME", None) + and os.environ.get("KRB5_KTNAME", None) +): # add other vars as needed + + # The environment provides the required information + class DummyK5Realm(object): + def __init__(self): + for k in os.environ: + if not k.startswith("K5TEST_"): + continue + setattr(self, k[7:].lower(), os.environ[k]) + self.env = {} + + class KerberosTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.realm = DummyK5Realm() + + @classmethod + def tearDownClass(cls): + del cls.realm + + +else: + try: + # Try to setup a kerberos environment + from k5test import KerberosTestCase + except Exception: + # Use a dummy, that skips all tests + class KerberosTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + raise unittest.SkipTest( + "Missing extension package k5test. " + 'Please run "pip install k5test" ' + "to install it." + ) + + +def update_env(testcase, mapping, env=os.environ): + """Modify os.environ during a test case and restore during cleanup.""" + saved_env = env.copy() + + def replace(target, source): + target.update(source) + for k in list(target): + if k not in source: + target.pop(k, None) + + testcase.addCleanup(replace, env, saved_env) + env.update(mapping) + return testcase + + +def k5shell(args=None): + """Create a shell with an kerberos environment + + This can be used to debug paramiko or to test the old GSSAPI. + To test a different GSSAPI, simply activate a suitable venv + within the shell. + """ + import k5test + import atexit + import subprocess + + k5 = k5test.K5Realm() + atexit.register(k5.stop) + os.environ.update(k5.env) + for n in ("realm", "user_princ", "hostname"): + os.environ["K5TEST_" + n.upper()] = getattr(k5, n) + + if not args: + args = sys.argv[1:] + if not args: + args = [os.environ.get("SHELL", "bash")] + sys.exit(subprocess.call(args)) |