summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.travis.yml23
-rw-r--r--README.rst7
-rw-r--r--dev-requirements.txt3
-rw-r--r--paramiko/__init__.py7
-rw-r--r--paramiko/_version.py2
-rw-r--r--paramiko/channel.py38
-rw-r--r--paramiko/client.py13
-rw-r--r--paramiko/ssh_exception.py66
-rw-r--r--paramiko/ssh_gss.py220
-rw-r--r--paramiko/transport.py105
-rw-r--r--setup.py7
-rw-r--r--sites/docs/api/ssh_gss.rst5
-rw-r--r--sites/www/changelog.rst49
-rw-r--r--sites/www/installing.rst45
-rw-r--r--tests/test_channelfile.py31
-rw-r--r--tests/test_client.py44
-rw-r--r--tests/test_gssapi.py87
-rw-r--r--tests/test_kex_gss.py23
-rw-r--r--tests/test_ssh_exception.py42
-rw-r--r--tests/test_ssh_gss.py14
-rw-r--r--tests/test_transport.py67
-rw-r--r--tests/util.py105
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.)
diff --git a/README.rst b/README.rst
index 6cdc4d01..5fb2f77f 100644
--- a/README.rst
+++ b/README.rst
@@ -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 9a7be339..f4f84748 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 6bf479d4..3d8f2dd4 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(
@@ -503,7 +512,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]
diff --git a/setup.py b/setup.py
index e6c4a077..cf063c44 100644
--- a/setup.py
+++ b/setup.py
@@ -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 b83a83df..e664f7d4 100644
--- a/sites/www/changelog.rst
+++ b/sites/www/changelog.rst
@@ -2,6 +2,13 @@
Changelog
=========
+- :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
@@ -9,6 +16,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 3631eb0d..cffdba5f 100644
--- a/sites/www/installing.rst
+++ b/sites/www/installing.rst
@@ -95,22 +95,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))