diff options
100 files changed, 3887 insertions, 1152 deletions
diff --git a/.travis.yml b/.travis.yml index 772ff5be..c16632b7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ +dist: xenial language: python sudo: false cache: @@ -8,33 +9,61 @@ python: - "3.4" - "3.5" - "3.6" - - "3.7-dev" - - "pypy-5.6.0" + - "3.7" + - "3.8-dev" + - "pypy" + - "pypy3" matrix: allow_failures: - - python: "3.7-dev" + - python: "3.8-dev" # Explicitly test against our oldest supported cryptography.io, in addition # to whatever the latest default is. include: - python: 2.7 - env: "CRYPTO_BEFORE=1.6" - - python: 3.6 - env: "CRYPTO_BEFORE=1.6" + 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 # Grab a specific version of Cryptography if desired. Doing this before other # installations ensures we don't have to do any downgrading/overriding. - | - if [[ -n "$CRYPTO_BEFORE" ]]; then - pip install "cryptography<${CRYPTO_BEFORE}" + if [[ -n "$OLDEST_CRYPTO" ]]; then + pip install "cryptography==${OLDEST_CRYPTO}" fi - # Self-install for setup.py-driven deps - - pip install -e . + # Self-install for setup.py-driven deps (plus additional + # safe-enough-for-all-matrix-cells optional deps) + # TODO: additional matrices or test steps to test all the entrypoints + - pip install -e ".[ed25519,invoke]" # Dev (doc/test running) requirements - # TODO: use pipenv + whatever contexty-type stuff it has + # TODO: use poetry + whatever contexty-type stuff it has, should be more than + # just prod/dev split. Also apply to the above re: extras_require. - 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.) @@ -11,7 +11,7 @@ Paramiko :Paramiko: Python SSH module :Copyright: Copyright (c) 2003-2009 Robey Pointer <robeypointer@gmail.com> -:Copyright: Copyright (c) 2013-2018 Jeff Forcier <jeff@bitprophet.org> +:Copyright: Copyright (c) 2013-2019 Jeff Forcier <jeff@bitprophet.org> :License: `LGPL <https://www.gnu.org/copyleft/lesser.html>`_ :Homepage: http://www.paramiko.org/ :API docs: http://docs.paramiko.org @@ -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 88df2794..f4f84748 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,14 +1,13 @@ # 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>=3.2,<3.3 -pytest-relaxed==1.1.4 +pytest==4.4.2 +pytest-relaxed==1.1.5 # pytest-xdist for test dir watching and the inv guard task -pytest-xdist>=1.22,<2.0 +pytest-xdist==1.28.0 mock==2.0.0 # Linting! -flake8==2.4.0 +flake8==3.6.0 # Coverage! coverage==3.7.1 codecov==1.6.3 diff --git a/paramiko/__init__.py b/paramiko/__init__.py index ebfa72a8..8642f84a 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -29,15 +29,22 @@ 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 +from paramiko.channel import ( + Channel, + ChannelFile, + ChannelStderrFile, + ChannelStdinFile, +) from paramiko.ssh_exception import ( - SSHException, - PasswordRequiredException, + AuthenticationException, BadAuthenticationType, - ChannelException, BadHostKeyException, - AuthenticationException, + ChannelException, + ConfigParseError, + CouldNotCanonicalize, + PasswordRequiredException, ProxyCommandFailure, + SSHException, ) from paramiko.server import ServerInterface, SubsystemHandler, InteractiveQuery from paramiko.rsakey import RSAKey @@ -57,7 +64,7 @@ from paramiko.file import BufferedFile from paramiko.agent import Agent, AgentKey from paramiko.pkey import PKey, PublicBlob from paramiko.hostkeys import HostKeys -from paramiko.config import SSHConfig +from paramiko.config import SSHConfig, SSHConfigDict from paramiko.proxy import ProxyCommand from paramiko.common import ( @@ -99,6 +106,8 @@ __all__ = [ "BufferedFile", "Channel", "ChannelException", + "ConfigParseError", + "CouldNotCanonicalize", "DSSKey", "ECDSAKey", "Ed25519Key", @@ -121,6 +130,7 @@ __all__ = [ "SFTPServerInterface", "SSHClient", "SSHConfig", + "SSHConfigDict", "SSHException", "SecurityOptions", "ServerInterface", diff --git a/paramiko/_version.py b/paramiko/_version.py index 2e797d40..1ea09a77 100644 --- a/paramiko/_version.py +++ b/paramiko/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (2, 4, 2) +__version_info__ = (2, 7, 0) __version__ = ".".join(map(str, __version_info__)) diff --git a/paramiko/agent.py b/paramiko/agent.py index 0630ebf3..622b95e4 100644 --- a/paramiko/agent.py +++ b/paramiko/agent.py @@ -80,8 +80,8 @@ class AgentSSH(object): def _send_message(self, msg): msg = asbytes(msg) self._conn.send(struct.pack(">I", len(msg)) + msg) - l = self._read_all(4) - msg = Message(self._read_all(struct.unpack(">I", l)[0])) + data = self._read_all(4) + msg = Message(self._read_all(struct.unpack(">I", data)[0])) return ord(msg.get_byte()), msg def _read_all(self, wanted): diff --git a/paramiko/channel.py b/paramiko/channel.py index 41b18958..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,8 +1366,11 @@ class ChannelFile(BufferedFile): class ChannelStderrFile(ChannelFile): - def __init__(self, channel, mode="r", bufsize=-1): - ChannelFile.__init__(self, channel, mode, bufsize) + """ + A file-like wrapper around `.Channel` stderr. + + See `Channel.makefile_stderr` for details. + """ def _read(self, size): return self.channel.recv_stderr(size) @@ -1359,4 +1380,13 @@ class ChannelStderrFile(ChannelFile): return len(data) -# vim: set shiftwidth=4 expandtab : +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/config.py b/paramiko/config.py index aeb59593..e6877d01 100644 --- a/paramiko/config.py +++ b/paramiko/config.py @@ -22,10 +22,23 @@ Configuration file (aka ``ssh_config``) support. """ import fnmatch +import getpass import os import re import shlex import socket +from functools import partial + +from .py3compat import StringIO + +invoke, invoke_import_error = None, None +try: + import invoke +except ImportError as e: + invoke_import_error = e + +from .ssh_exception import CouldNotCanonicalize, ConfigParseError + SSH_PORT = 22 @@ -43,40 +56,113 @@ class SSHConfig(object): SETTINGS_REGEX = re.compile(r"(\w+)(?:\s*=\s*|\s+)(.+)") + # TODO: do a full scan of ssh.c & friends to make sure we're fully + # compatible across the board, e.g. OpenSSH 8.1 added %n to ProxyCommand. + TOKENS_BY_CONFIG_KEY = { + "controlpath": ["%h", "%l", "%L", "%n", "%p", "%r", "%u"], + "hostname": ["%h"], + "identityfile": ["~", "%d", "%h", "%l", "%u", "%r"], + "proxycommand": ["~", "%h", "%p", "%r"], + # Doesn't seem worth making this 'special' for now, it will fit well + # enough (no actual match-exec config key to be confused with). + "match-exec": ["%d", "%h", "%L", "%l", "%n", "%p", "%r", "%u"], + } + def __init__(self): """ Create a new OpenSSH config object. + + Note: the newer alternate constructors `from_path`, `from_file` and + `from_text` are simpler to use, as they parse on instantiation. For + example, instead of:: + + config = SSHConfig() + config.parse(open("some-path.config") + + you could:: + + config = SSHConfig.from_file(open("some-path.config")) + # Or more directly: + config = SSHConfig.from_path("some-path.config") + # Or if you have arbitrary ssh_config text from some other source: + config = SSHConfig.from_text("Host foo\\n\\tUser bar") """ self._config = [] + @classmethod + def from_text(cls, text): + """ + Create a new, parsed `SSHConfig` from ``text`` string. + + .. versionadded:: 2.7 + """ + return cls.from_file(StringIO(text)) + + @classmethod + def from_path(cls, path): + """ + Create a new, parsed `SSHConfig` from the file found at ``path``. + + .. versionadded:: 2.7 + """ + with open(path) as flo: + return cls.from_file(flo) + + @classmethod + def from_file(cls, flo): + """ + Create a new, parsed `SSHConfig` from file-like object ``flo``. + + .. versionadded:: 2.7 + """ + obj = cls() + obj.parse(flo) + return obj + def parse(self, file_obj): """ Read an OpenSSH config from the given file object. :param file_obj: a file-like object to read the config file from """ - host = {"host": ["*"], "config": {}} + # Start out w/ implicit/anonymous global host-like block to hold + # anything not contained by an explicit one. + context = {"host": ["*"], "config": {}} for line in file_obj: # Strip any leading or trailing whitespace from the line. # Refer to https://github.com/paramiko/paramiko/issues/499 line = line.strip() + # Skip blanks, comments if not line or line.startswith("#"): continue + # Parse line into key, value match = re.match(self.SETTINGS_REGEX, line) if not match: - raise Exception("Unparsable line {}".format(line)) + raise ConfigParseError("Unparsable line {}".format(line)) key = match.group(1).lower() value = match.group(2) - if key == "host": - self._config.append(host) - host = {"host": self._get_hosts(value), "config": {}} + # Host keyword triggers switch to new block/context + if key in ("host", "match"): + self._config.append(context) + context = {"config": {}} + if key == "host": + # TODO 3.0: make these real objects or at least name this + # "hosts" to acknowledge it's an iterable. (Doing so prior + # to 3.0, despite it being a private API, feels bad - + # surely such an old codebase has folks actually relying on + # these keys.) + context["host"] = self._get_hosts(value) + else: + context["matches"] = self._get_matches(value) + # Special-case for noop ProxyCommands elif key == "proxycommand" and value.lower() == "none": # Store 'none' as None; prior to 3.x, it will get stripped out # at the end (for compatibility with issue #415). After 3.x, it # will simply not get stripped, leaving a nice explicit marker. - host["config"][key] = None + context["config"][key] = None + # All other keywords get stored, directly or via append else: if value.startswith('"') and value.endswith('"'): value = value[1:-1] @@ -85,13 +171,14 @@ class SSHConfig(object): # cases, since they are allowed to be specified multiple times # and they should be tried in order of specification. if key in ["identityfile", "localforward", "remoteforward"]: - if key in host["config"]: - host["config"][key].append(value) + if key in context["config"]: + context["config"][key].append(value) else: - host["config"][key] = [value] - elif key not in host["config"]: - host["config"][key] = value - self._config.append(host) + context["config"][key] = [value] + elif key not in context["config"]: + context["config"][key] = value + # Store last 'open' block and we're done + self._config.append(context) def lookup(self, hostname): """ @@ -99,9 +186,9 @@ class SSHConfig(object): The host-matching rules of OpenSSH's ``ssh_config`` man page are used: For each parameter, the first obtained value will be used. The - configuration files contain sections separated by ``Host`` - specifications, and that section is only applied for hosts that match - one of the patterns given in the specification. + configuration files contain sections separated by ``Host`` and/or + ``Match`` specifications, and that section is only applied for hosts + which match the given patterns or keywords Since the first obtained value for each parameter is used, more host- specific declarations should be given near the beginning of the file, @@ -118,33 +205,112 @@ class SSHConfig(object): assert conf['passwordauthentication'] == 'yes' assert conf.as_bool('passwordauthentication') is True + .. note:: + If there is no explicitly configured ``HostName`` value, it will be + set to the being-looked-up hostname, which is as close as we can + get to OpenSSH's behavior around that particular option. + :param str hostname: the hostname to lookup .. versionchanged:: 2.5 Returns `SSHConfigDict` objects instead of dict literals. + .. versionchanged:: 2.7 + Added canonicalization support. + .. versionchanged:: 2.7 + Added ``Match`` support. """ - matches = [ - config - for config in self._config - if self._allowed(config["host"], hostname) - ] - - ret = SSHConfigDict() - for match in matches: - for key, value in match["config"].items(): - if key not in ret: + # First pass + options = self._lookup(hostname=hostname) + # Inject HostName if it was not set (this used to be done incidentally + # during tokenization, for some reason). + if "hostname" not in options: + options["hostname"] = hostname + # Handle canonicalization + canon = options.get("canonicalizehostname", None) in ("yes", "always") + maxdots = int(options.get("canonicalizemaxdots", 1)) + if canon and hostname.count(".") <= maxdots: + # NOTE: OpenSSH manpage does not explicitly state this, but its + # implementation for CanonicalDomains is 'split on any whitespace'. + domains = options["canonicaldomains"].split() + hostname = self.canonicalize(hostname, options, domains) + # Overwrite HostName again here (this is also what OpenSSH does) + options["hostname"] = hostname + options = self._lookup(hostname, options, canonical=True) + return options + + def _lookup(self, hostname, options=None, canonical=False): + # Init + if options is None: + options = SSHConfigDict() + # Iterate all stanzas, applying any that match, in turn (so that things + # like Match can reference currently understood state) + for context in self._config: + if not ( + self._pattern_matches(context.get("host", []), hostname) + or self._does_match( + context.get("matches", []), hostname, canonical, options + ) + ): + continue + for key, value in context["config"].items(): + if key not in options: # Create a copy of the original value, # else it will reference the original list # in self._config and update that value too # when the extend() is being called. - ret[key] = value[:] if value is not None else value + options[key] = value[:] if value is not None else value elif key == "identityfile": - ret[key].extend(value) - ret = self._expand_variables(ret, hostname) + options[key].extend( + x for x in value if x not in options[key] + ) + # Expand variables in resulting values (besides 'Match exec' which was + # already handled above) + options = self._expand_variables(options, hostname) # TODO: remove in 3.x re #670 - if "proxycommand" in ret and ret["proxycommand"] is None: - del ret["proxycommand"] - return ret + if "proxycommand" in options and options["proxycommand"] is None: + del options["proxycommand"] + return options + + def canonicalize(self, hostname, options, domains): + """ + Return canonicalized version of ``hostname``. + + :param str hostname: Target hostname. + :param options: An `SSHConfigDict` from a previous lookup pass. + :param domains: List of domains (e.g. ``["paramiko.org"]``). + + :returns: A canonicalized hostname if one was found, else ``None``. + + .. versionadded:: 2.7 + """ + found = False + for domain in domains: + candidate = "{}.{}".format(hostname, domain) + family_specific = _addressfamily_host_lookup(candidate, options) + if family_specific is not None: + # TODO: would we want to dig deeper into other results? e.g. to + # find something that satisfies PermittedCNAMEs when that is + # implemented? + found = family_specific[0] + else: + # TODO: what does ssh use here and is there a reason to use + # that instead of gethostbyname? + try: + found = socket.gethostbyname(candidate) + except socket.gaierror: + pass + if found: + # TODO: follow CNAME (implied by found != candidate?) if + # CanonicalizePermittedCNAMEs allows it + return candidate + # If we got here, it means canonicalization failed. + # When CanonicalizeFallbackLocal is undefined or 'yes', we just spit + # back the original hostname. + if options.get("canonicalizefallbacklocal", "yes") == "yes": + return hostname + # And here, we failed AND fallback was set to a non-yes value, so we + # need to get mad. + raise CouldNotCanonicalize(hostname) def get_hostnames(self): """ @@ -156,86 +322,173 @@ class SSHConfig(object): hosts.update(entry["host"]) return hosts - def _allowed(self, hosts, hostname): + def _pattern_matches(self, patterns, target): + # Convenience auto-splitter if not already a list + if hasattr(patterns, "split"): + patterns = patterns.split(",") match = False - for host in hosts: - if host.startswith("!") and fnmatch.fnmatch(hostname, host[1:]): + for pattern in patterns: + # Short-circuit if target matches a negated pattern + if pattern.startswith("!") and fnmatch.fnmatch( + target, pattern[1:] + ): return False - elif fnmatch.fnmatch(hostname, host): + # Flag a match, but continue (in case of later negation) if regular + # match occurs + elif fnmatch.fnmatch(target, pattern): match = True return match - def _expand_variables(self, config, hostname): - """ - Return a dict of config options with expanded substitutions - for a given hostname. + # TODO 3.0: remove entirely (is now unused internally) + def _allowed(self, hosts, hostname): + return self._pattern_matches(hosts, hostname) + + def _does_match(self, match_list, target_hostname, canonical, options): + matched = [] + candidates = match_list[:] + local_username = getpass.getuser() + while candidates: + candidate = candidates.pop(0) + passed = None + # Obtain latest host/user value every loop, so later Match may + # reference values assigned within a prior Match. + configured_host = options.get("hostname", None) + configured_user = options.get("user", None) + type_, param = candidate["type"], candidate["param"] + # Canonical is a hard pass/fail based on whether this is a + # canonicalized re-lookup. + if type_ == "canonical": + if self._should_fail(canonical, candidate): + return False + # The parse step ensures we only see this by itself or after + # canonical, so it's also an easy hard pass. (No negation here as + # that would be uh, pretty weird?) + elif type_ == "all": + return True + # From here, we are testing various non-hard criteria, + # short-circuiting only on fail + elif type_ == "host": + hostval = configured_host or target_hostname + passed = self._pattern_matches(param, hostval) + elif type_ == "originalhost": + passed = self._pattern_matches(param, target_hostname) + elif type_ == "user": + user = configured_user or local_username + passed = self._pattern_matches(param, user) + elif type_ == "localuser": + passed = self._pattern_matches(param, local_username) + elif type_ == "exec": + exec_cmd = self._tokenize( + options, target_hostname, "match-exec", param + ) + # This is the laziest spot in which we can get mad about an + # inability to import Invoke. + if invoke is None: + raise invoke_import_error + # Like OpenSSH, we 'redirect' stdout but let stderr bubble up + passed = invoke.run(exec_cmd, hide="stdout", warn=True).ok + # Tackle any 'passed, but was negated' results from above + if passed is not None and self._should_fail(passed, candidate): + return False + # Made it all the way here? Everything matched! + matched.append(candidate) + # Did anything match? (To be treated as bool, usually.) + return matched - Please refer to man ``ssh_config`` for the parameters that - are replaced. + def _should_fail(self, would_pass, candidate): + return would_pass if candidate["negate"] else not would_pass - :param dict config: the config for the hostname - :param str hostname: the hostname that the config belongs to + def _tokenize(self, config, target_hostname, key, value): """ + Tokenize a string based on current config/hostname data. - if "hostname" in config: - config["hostname"] = config["hostname"].replace("%h", hostname) - else: - config["hostname"] = hostname + :param config: Current config data. + :param target_hostname: Original target connection hostname. + :param key: Config key being tokenized (used to filter token list). + :param value: Config value being tokenized. + :returns: The tokenized version of the input ``value`` string. + """ + allowed_tokens = self._allowed_tokens(key) + # Short-circuit if no tokenization possible + if not allowed_tokens: + return value + # Obtain potentially configured hostname, for use with %h. + # Special-case where we are tokenizing the hostname itself, to avoid + # replacing %h with a %h-bearing value, etc. + configured_hostname = target_hostname + if key != "hostname": + configured_hostname = config.get("hostname", configured_hostname) + # Ditto the rest of the source values if "port" in config: port = config["port"] else: port = SSH_PORT - - user = os.getenv("USER") + user = getpass.getuser() if "user" in config: remoteuser = config["user"] else: remoteuser = user - - host = socket.gethostname().split(".")[0] - fqdn = LazyFqdn(config, host) + local_hostname = socket.gethostname().split(".")[0] + local_fqdn = LazyFqdn(config, local_hostname) homedir = os.path.expanduser("~") + # The actual tokens! replacements = { - "controlpath": [ - ("%h", config["hostname"]), - ("%l", fqdn), - ("%L", host), - ("%n", hostname), - ("%p", port), - ("%r", remoteuser), - ("%u", user), - ], - "identityfile": [ - ("~", homedir), - ("%d", homedir), - ("%h", config["hostname"]), - ("%l", fqdn), - ("%u", user), - ("%r", remoteuser), - ], - "proxycommand": [ - ("~", homedir), - ("%h", config["hostname"]), - ("%p", port), - ("%r", remoteuser), - ], + # TODO: %%??? + # TODO: %C? + "%d": homedir, + "%h": configured_hostname, + # TODO: %i? + "%L": local_hostname, + "%l": local_fqdn, + # also this is pseudo buggy when not in Match exec mode so document + # that. also WHY is that the case?? don't we do all of this late? + "%n": target_hostname, + "%p": port, + "%r": remoteuser, + # TODO: %T? don't believe this is possible however + "%u": user, + "~": homedir, } + # Do the thing with the stuff + tokenized = value + for find, replace in replacements.items(): + if find not in allowed_tokens: + continue + tokenized = tokenized.replace(find, str(replace)) + # TODO: log? eg that value -> tokenized + return tokenized + + def _allowed_tokens(self, key): + """ + Given config ``key``, return list of token strings to tokenize. + + .. note:: + This feels like it wants to eventually go away, but is used to + preserve as-strict-as-possible compatibility with OpenSSH, which + for whatever reason only applies some tokens to some config keys. + """ + return self.TOKENS_BY_CONFIG_KEY.get(key, []) + + def _expand_variables(self, config, target_hostname): + """ + Return a dict of config options with expanded substitutions + for a given original & current target hostname. + + Please refer to :doc:`/api/config` for details. + :param dict config: the currently parsed config + :param str hostname: the hostname whose config is being looked up + """ for k in config: if config[k] is None: continue - if k in replacements: - for find, replace in replacements[k]: - if isinstance(config[k], list): - for item in range(len(config[k])): - if find in config[k][item]: - config[k][item] = config[k][item].replace( - find, str(replace) - ) - else: - if find in config[k]: - config[k] = config[k].replace(find, str(replace)) + tokenizer = partial(self._tokenize, config, target_hostname, k) + if isinstance(config[k], list): + for i, value in enumerate(config[k]): + config[k][i] = tokenizer(value) + else: + config[k] = tokenizer(config[k]) return config def _get_hosts(self, host): @@ -245,7 +498,88 @@ class SSHConfig(object): try: return shlex.split(host) except ValueError: - raise Exception("Unparsable host {}".format(host)) + raise ConfigParseError("Unparsable host {}".format(host)) + + def _get_matches(self, match): + """ + Parse a specific Match config line into a list-of-dicts for its values. + + Performs some parse-time validation as well. + """ + matches = [] + tokens = shlex.split(match) + while tokens: + match = {"type": None, "param": None, "negate": False} + type_ = tokens.pop(0) + # Handle per-keyword negation + if type_.startswith("!"): + match["negate"] = True + type_ = type_[1:] + match["type"] = type_ + # all/canonical have no params (everything else does) + if type_ in ("all", "canonical"): + matches.append(match) + continue + if not tokens: + raise ConfigParseError( + "Missing parameter to Match '{}' keyword".format(type_) + ) + match["param"] = tokens.pop(0) + matches.append(match) + # Perform some (easier to do now than in the middle) validation that is + # better handled here than at lookup time. + keywords = [x["type"] for x in matches] + if "all" in keywords: + allowable = ("all", "canonical") + ok, bad = ( + list(filter(lambda x: x in allowable, keywords)), + list(filter(lambda x: x not in allowable, keywords)), + ) + err = None + if any(bad): + err = "Match does not allow 'all' mixed with anything but 'canonical'" # noqa + elif "canonical" in ok and ok.index("canonical") > ok.index("all"): + err = "Match does not allow 'all' before 'canonical'" + if err is not None: + raise ConfigParseError(err) + return matches + + +def _addressfamily_host_lookup(hostname, options): + """ + Try looking up ``hostname`` in an IPv4 or IPv6 specific manner. + + This is an odd duck due to needing use in two divergent use cases. It looks + up ``AddressFamily`` in ``options`` and if it is ``inet`` or ``inet6``, + this function uses `socket.getaddrinfo` to perform a family-specific + lookup, returning the result if successful. + + In any other situation -- lookup failure, or ``AddressFamily`` being + unspecified or ``any`` -- ``None`` is returned instead and the caller is + expected to do something situation-appropriate like calling + `socket.gethostbyname`. + + :param str hostname: Hostname to look up. + :param options: `SSHConfigDict` instance w/ parsed options. + :returns: ``getaddrinfo``-style tuples, or ``None``, depending. + """ + address_family = options.get("addressfamily", "any").lower() + if address_family == "any": + return + try: + family = socket.AF_INET6 + if address_family == "inet": + family = socket.AF_INET + return socket.getaddrinfo( + hostname, + None, + family, + socket.SOCK_DGRAM, + socket.IPPROTO_IP, + socket.AI_CANONNAME, + ) + except socket.gaierror: + pass class LazyFqdn(object): @@ -271,31 +605,14 @@ class LazyFqdn(object): # Handle specific option fqdn = None - address_family = self.config.get("addressfamily", "any").lower() - if address_family != "any": - try: - family = socket.AF_INET6 - if address_family == "inet": - socket.AF_INET - results = socket.getaddrinfo( - self.host, - None, - family, - socket.SOCK_DGRAM, - socket.IPPROTO_IP, - socket.AI_CANONNAME, - ) - for res in results: - af, socktype, proto, canonname, sa = res - if canonname and "." in canonname: - fqdn = canonname - break - # giaerror -> socket.getaddrinfo() can't resolve self.host - # (which is from socket.gethostname()). Fall back to the - # getfqdn() call below. - except socket.gaierror: - pass - # Handle 'any' / unspecified + results = _addressfamily_host_lookup(self.host, self.config) + if results is not None: + for res in results: + af, socktype, proto, canonname, sa = res + if canonname and "." in canonname: + fqdn = canonname + break + # Handle 'any' / unspecified / lookup failure if fqdn is None: fqdn = socket.getfqdn() # Cache diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py index 5f39867a..4c84a293 100644 --- a/paramiko/ecdsakey.py +++ b/paramiko/ecdsakey.py @@ -160,12 +160,12 @@ class ECDSAKey(PKey): pointinfo = msg.get_binary() try: - numbers = ec.EllipticCurvePublicNumbers.from_encoded_point( + key = ec.EllipticCurvePublicKey.from_encoded_point( self.ecdsa_curve.curve_class(), pointinfo ) + self.verifying_key = key except ValueError: raise SSHException("Invalid public key") - self.verifying_key = numbers.public_key(backend=default_backend()) @classmethod def supported_key_format_identifiers(cls): diff --git a/paramiko/ed25519key.py b/paramiko/ed25519key.py index 3bd0ff10..96cff7d0 100644 --- a/paramiko/ed25519key.py +++ b/paramiko/ed25519key.py @@ -37,10 +37,12 @@ def unpad(data): # really ought to be made constant time (possibly by upstreaming this logic # into pyca/cryptography). padding_length = six.indexbytes(data, -1) - if padding_length > 16: + if 0x20 <= padding_length < 0x7f: + return data # no padding, last byte part comment (printable ascii) + if padding_length > 15: raise SSHException("Invalid key") - for i in range(1, padding_length + 1): - if six.indexbytes(data, -i) != (padding_length - i + 1): + for i in range(padding_length): + if six.indexbytes(data, i - padding_length) != i + 1: raise SSHException("Invalid key") return data[:-padding_length] diff --git a/paramiko/hostkeys.py b/paramiko/hostkeys.py index f31b8819..d0660cc8 100644 --- a/paramiko/hostkeys.py +++ b/paramiko/hostkeys.py @@ -19,8 +19,12 @@ import binascii import os +import sys -from collections import MutableMapping +if sys.version_info[:2] >= (3, 3): + from collections.abc import MutableMapping +else: + from collections import MutableMapping from hashlib import sha1 from hmac import HMAC diff --git a/paramiko/kex_curve25519.py b/paramiko/kex_curve25519.py new file mode 100644 index 00000000..59710c1a --- /dev/null +++ b/paramiko/kex_curve25519.py @@ -0,0 +1,129 @@ +import binascii +import hashlib + +from cryptography.exceptions import UnsupportedAlgorithm +from cryptography.hazmat.primitives import constant_time, serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, +) + +from paramiko.message import Message +from paramiko.py3compat import byte_chr, long +from paramiko.ssh_exception import SSHException + + +_MSG_KEXECDH_INIT, _MSG_KEXECDH_REPLY = range(30, 32) +c_MSG_KEXECDH_INIT, c_MSG_KEXECDH_REPLY = [byte_chr(c) for c in range(30, 32)] + + +class KexCurve25519(object): + hash_algo = hashlib.sha256 + + def __init__(self, transport): + self.transport = transport + self.key = None + + @classmethod + def is_available(cls): + try: + X25519PrivateKey.generate() + except UnsupportedAlgorithm: + return False + else: + return True + + def _perform_exchange(self, peer_key): + secret = self.key.exchange(peer_key) + if constant_time.bytes_eq(secret, b"\x00" * 32): + raise SSHException( + "peer's curve25519 public value has wrong order" + ) + return secret + + def start_kex(self): + self.key = X25519PrivateKey.generate() + if self.transport.server_mode: + self.transport._expect_packet(_MSG_KEXECDH_INIT) + return + + m = Message() + m.add_byte(c_MSG_KEXECDH_INIT) + m.add_string( + self.key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + ) + self.transport._send_message(m) + self.transport._expect_packet(_MSG_KEXECDH_REPLY) + + def parse_next(self, ptype, m): + if self.transport.server_mode and (ptype == _MSG_KEXECDH_INIT): + return self._parse_kexecdh_init(m) + elif not self.transport.server_mode and (ptype == _MSG_KEXECDH_REPLY): + return self._parse_kexecdh_reply(m) + raise SSHException( + "KexCurve25519 asked to handle packet type {:d}".format(ptype) + ) + + def _parse_kexecdh_init(self, m): + peer_key_bytes = m.get_string() + peer_key = X25519PublicKey.from_public_bytes(peer_key_bytes) + K = self._perform_exchange(peer_key) + K = long(binascii.hexlify(K), 16) + # compute exchange hash + hm = Message() + hm.add( + self.transport.remote_version, + self.transport.local_version, + self.transport.remote_kex_init, + self.transport.local_kex_init, + ) + server_key_bytes = self.transport.get_server_key().asbytes() + exchange_key_bytes = self.key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + hm.add_string(server_key_bytes) + hm.add_string(peer_key_bytes) + hm.add_string(exchange_key_bytes) + hm.add_mpint(K) + H = self.hash_algo(hm.asbytes()).digest() + self.transport._set_K_H(K, H) + sig = self.transport.get_server_key().sign_ssh_data(H) + # construct reply + m = Message() + m.add_byte(c_MSG_KEXECDH_REPLY) + m.add_string(server_key_bytes) + m.add_string(exchange_key_bytes) + m.add_string(sig) + self.transport._send_message(m) + self.transport._activate_outbound() + + def _parse_kexecdh_reply(self, m): + peer_host_key_bytes = m.get_string() + peer_key_bytes = m.get_string() + sig = m.get_binary() + + peer_key = X25519PublicKey.from_public_bytes(peer_key_bytes) + + K = self._perform_exchange(peer_key) + K = long(binascii.hexlify(K), 16) + # compute exchange hash and verify signature + hm = Message() + hm.add( + self.transport.local_version, + self.transport.remote_version, + self.transport.local_kex_init, + self.transport.remote_kex_init, + ) + hm.add_string(peer_host_key_bytes) + hm.add_string( + self.key.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + ) + hm.add_string(peer_key_bytes) + hm.add_mpint(K) + self.transport._set_K_H(K, self.hash_algo(hm.asbytes()).digest()) + self.transport._verify_key(peer_host_key_bytes, sig) + self.transport._activate_outbound() diff --git a/paramiko/kex_ecdh_nist.py b/paramiko/kex_ecdh_nist.py index 1d87442a..ad5c9c79 100644 --- a/paramiko/kex_ecdh_nist.py +++ b/paramiko/kex_ecdh_nist.py @@ -9,6 +9,7 @@ from paramiko.py3compat import byte_chr, long from paramiko.ssh_exception import SSHException from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization from binascii import hexlify _MSG_KEXECDH_INIT, _MSG_KEXECDH_REPLY = range(30, 32) @@ -36,7 +37,12 @@ class KexNistp256: m = Message() m.add_byte(c_MSG_KEXECDH_INIT) # SEC1: V2.0 2.3.3 Elliptic-Curve-Point-to-Octet-String Conversion - m.add_string(self.Q_C.public_numbers().encode_point()) + m.add_string( + self.Q_C.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + ) self.transport._send_message(m) self.transport._expect_packet(_MSG_KEXECDH_REPLY) @@ -58,11 +64,11 @@ class KexNistp256: def _parse_kexecdh_init(self, m): Q_C_bytes = m.get_string() - self.Q_C = ec.EllipticCurvePublicNumbers.from_encoded_point( + self.Q_C = ec.EllipticCurvePublicKey.from_encoded_point( self.curve, Q_C_bytes ) K_S = self.transport.get_server_key().asbytes() - K = self.P.exchange(ec.ECDH(), self.Q_C.public_key(default_backend())) + K = self.P.exchange(ec.ECDH(), self.Q_C) K = long(hexlify(K), 16) # compute exchange hash hm = Message() @@ -75,7 +81,12 @@ class KexNistp256: hm.add_string(K_S) hm.add_string(Q_C_bytes) # SEC1: V2.0 2.3.3 Elliptic-Curve-Point-to-Octet-String Conversion - hm.add_string(self.Q_S.public_numbers().encode_point()) + hm.add_string( + self.Q_S.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + ) hm.add_mpint(long(K)) H = self.hash_algo(hm.asbytes()).digest() self.transport._set_K_H(K, H) @@ -84,7 +95,12 @@ class KexNistp256: m = Message() m.add_byte(c_MSG_KEXECDH_REPLY) m.add_string(K_S) - m.add_string(self.Q_S.public_numbers().encode_point()) + m.add_string( + self.Q_S.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + ) m.add_string(sig) self.transport._send_message(m) self.transport._activate_outbound() @@ -92,11 +108,11 @@ class KexNistp256: def _parse_kexecdh_reply(self, m): K_S = m.get_string() Q_S_bytes = m.get_string() - self.Q_S = ec.EllipticCurvePublicNumbers.from_encoded_point( + self.Q_S = ec.EllipticCurvePublicKey.from_encoded_point( self.curve, Q_S_bytes ) sig = m.get_binary() - K = self.P.exchange(ec.ECDH(), self.Q_S.public_key(default_backend())) + K = self.P.exchange(ec.ECDH(), self.Q_S) K = long(hexlify(K), 16) # compute exchange hash and verify signature hm = Message() @@ -108,7 +124,12 @@ class KexNistp256: ) hm.add_string(K_S) # SEC1: V2.0 2.3.3 Elliptic-Curve-Point-to-Octet-String Conversion - hm.add_string(self.Q_C.public_numbers().encode_point()) + hm.add_string( + self.Q_C.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + ) hm.add_string(Q_S_bytes) hm.add_mpint(K) self.transport._set_K_H(K, self.hash_algo(hm.asbytes()).digest()) diff --git a/paramiko/kex_group1.py b/paramiko/kex_group1.py index 904835d7..5131e895 100644 --- a/paramiko/kex_group1.py +++ b/paramiko/kex_group1.py @@ -116,7 +116,7 @@ class KexGroup1(object): hm.add_mpint(self.e) hm.add_mpint(self.f) hm.add_mpint(K) - self.transport._set_K_H(K, sha1(hm.asbytes()).digest()) + self.transport._set_K_H(K, self.hash_algo(hm.asbytes()).digest()) self.transport._verify_key(host_key, sig) self.transport._activate_outbound() diff --git a/paramiko/kex_group14.py b/paramiko/kex_group14.py index 0df302e3..a620c1a3 100644 --- a/paramiko/kex_group14.py +++ b/paramiko/kex_group14.py @@ -22,7 +22,7 @@ Standard SSH key exchange ("kex" if you wanna sound cool). Diffie-Hellman of """ from paramiko.kex_group1 import KexGroup1 -from hashlib import sha1 +from hashlib import sha1, sha256 class KexGroup14(KexGroup1): @@ -33,3 +33,8 @@ class KexGroup14(KexGroup1): name = "diffie-hellman-group14-sha1" hash_algo = sha1 + + +class KexGroup14SHA256(KexGroup14): + name = "diffie-hellman-group14-sha256" + hash_algo = sha256 diff --git a/paramiko/kex_group16.py b/paramiko/kex_group16.py new file mode 100644 index 00000000..15b0acfe --- /dev/null +++ b/paramiko/kex_group16.py @@ -0,0 +1,35 @@ +# Copyright (C) 2019 Edgar Sousa <https://github.com/edgsousa> +# +# This file is part of paramiko. +# +# Paramiko is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# Paramiko is distrubuted in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Paramiko; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. + +""" +Standard SSH key exchange ("kex" if you wanna sound cool). Diffie-Hellman of +4096 bit key halves, using a known "p" prime and "g" generator. +""" + +from paramiko.kex_group1 import KexGroup1 +from hashlib import sha512 + + +class KexGroup16SHA512(KexGroup1): + name = "diffie-hellman-group16-sha512" + # http://tools.ietf.org/html/rfc3526#section-5 + P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA993B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199FFFFFFFFFFFFFFFF # noqa + G = 2 + + name = "diffie-hellman-group16-sha512" + hash_algo = sha512 diff --git a/paramiko/packet.py b/paramiko/packet.py index cb46835e..12663168 100644 --- a/paramiko/packet.py +++ b/paramiko/packet.py @@ -111,6 +111,8 @@ class Packetizer(object): self.__compress_engine_in = None self.__sequence_number_out = 0 self.__sequence_number_in = 0 + self.__etm_out = False + self.__etm_in = False # lock around outbound writes (packet computation) self.__write_lock = threading.RLock() @@ -142,9 +144,11 @@ class Packetizer(object): mac_size, mac_key, sdctr=False, + etm=False, ): """ Switch outbound data cipher. + :param etm: Set encrypt-then-mac from OpenSSH """ self.__block_engine_out = block_engine self.__sdctr_out = sdctr @@ -154,6 +158,7 @@ class Packetizer(object): self.__mac_key_out = mac_key self.__sent_bytes = 0 self.__sent_packets = 0 + self.__etm_out = etm # wait until the reset happens in both directions before clearing # rekey flag self.__init_count |= 1 @@ -162,10 +167,17 @@ class Packetizer(object): self.__need_rekey = False def set_inbound_cipher( - self, block_engine, block_size, mac_engine, mac_size, mac_key + self, + block_engine, + block_size, + mac_engine, + mac_size, + mac_key, + etm=False, ): """ Switch inbound data cipher. + :param etm: Set encrypt-then-mac from OpenSSH """ self.__block_engine_in = block_engine self.__block_size_in = block_size @@ -176,6 +188,7 @@ class Packetizer(object): self.__received_packets = 0 self.__received_bytes_overflow = 0 self.__received_packets_overflow = 0 + self.__etm_in = etm # wait until the reset happens in both directions before clearing # rekey flag self.__init_count |= 2 @@ -396,14 +409,19 @@ class Packetizer(object): ) self._log(DEBUG, util.format_binary(packet, "OUT: ")) if self.__block_engine_out is not None: - out = self.__block_engine_out.update(packet) + if self.__etm_out: + # packet length is not encrypted in EtM + out = packet[0:4] + self.__block_engine_out.update( + packet[4:] + ) + else: + out = self.__block_engine_out.update(packet) else: out = packet # + mac if self.__block_engine_out is not None: - payload = ( - struct.pack(">I", self.__sequence_number_out) + packet - ) + packed = struct.pack(">I", self.__sequence_number_out) + payload = packed + (out if self.__etm_out else packet) out += compute_hmac( self.__mac_key_out, payload, self.__mac_engine_out )[: self.__mac_size_out] @@ -439,26 +457,54 @@ class Packetizer(object): :raises: `.NeedRekeyException` -- if the transport should rekey """ header = self.read_all(self.__block_size_in, check_rekey=True) + if self.__etm_in: + packet_size = struct.unpack(">I", header[:4])[0] + remaining = packet_size - self.__block_size_in + 4 + packet = header[4:] + self.read_all(remaining, check_rekey=False) + mac = self.read_all(self.__mac_size_in, check_rekey=False) + mac_payload = ( + struct.pack(">II", self.__sequence_number_in, packet_size) + + packet + ) + my_mac = compute_hmac( + self.__mac_key_in, mac_payload, self.__mac_engine_in + )[: self.__mac_size_in] + if not util.constant_time_bytes_eq(my_mac, mac): + raise SSHException("Mismatched MAC") + header = packet + if self.__block_engine_in is not None: header = self.__block_engine_in.update(header) if self.__dump_packets: self._log(DEBUG, util.format_binary(header, "IN: ")) - packet_size = struct.unpack(">I", header[:4])[0] - # leftover contains decrypted bytes from the first block (after the - # length field) - leftover = header[4:] - if (packet_size - len(leftover)) % self.__block_size_in != 0: - raise SSHException("Invalid packet blocking") - buf = self.read_all(packet_size + self.__mac_size_in - len(leftover)) - packet = buf[: packet_size - len(leftover)] - post_packet = buf[packet_size - len(leftover) :] - if self.__block_engine_in is not None: - packet = self.__block_engine_in.update(packet) + + # When ETM is in play, we've already read the packet size & decrypted + # everything, so just set the packet back to the header we obtained. + if self.__etm_in: + packet = header + # Otherwise, use the older non-ETM logic + else: + packet_size = struct.unpack(">I", header[:4])[0] + + # leftover contains decrypted bytes from the first block (after the + # length field) + leftover = header[4:] + if (packet_size - len(leftover)) % self.__block_size_in != 0: + raise SSHException("Invalid packet blocking") + buf = self.read_all( + packet_size + self.__mac_size_in - len(leftover) + ) + packet = buf[: packet_size - len(leftover)] + post_packet = buf[packet_size - len(leftover) :] + + if self.__block_engine_in is not None: + packet = self.__block_engine_in.update(packet) + packet = leftover + packet + if self.__dump_packets: self._log(DEBUG, util.format_binary(packet, "IN: ")) - packet = leftover + packet - if self.__mac_size_in > 0: + if self.__mac_size_in > 0 and not self.__etm_in: mac = post_packet[: self.__mac_size_in] mac_payload = ( struct.pack(">II", self.__sequence_number_in, packet_size) @@ -579,7 +625,10 @@ class Packetizer(object): def _build_packet(self, payload): # pad up at least 4 bytes, to nearest block-size (usually 8) bsize = self.__block_size_out - padding = 3 + bsize - ((len(payload) + 8) % bsize) + # do not include payload length in computations for padding in EtM mode + # (payload length won't be encrypted) + addlen = 4 if self.__etm_out else 8 + padding = 3 + bsize - ((len(payload) + addlen) % bsize) packet = struct.pack(">IB", len(payload) + padding + 1, padding) packet += payload if self.__sdctr_out or self.__block_engine_out is None: diff --git a/paramiko/proxy.py b/paramiko/proxy.py index 444c47b6..077e8e35 100644 --- a/paramiko/proxy.py +++ b/paramiko/proxy.py @@ -18,12 +18,20 @@ import os -from shlex import split as shlsplit +import shlex import signal from select import select import socket import time +# Try-and-ignore import so platforms w/o subprocess (eg Google App Engine) can +# still import paramiko. +subprocess, subprocess_import_error = None, None +try: + import subprocess +except ImportError as e: + subprocess_import_error = e + from paramiko.ssh_exception import ProxyCommandFailure from paramiko.util import ClosingContextManager @@ -48,13 +56,15 @@ class ProxyCommand(ClosingContextManager): :param str command_line: the command that should be executed and used as the proxy. """ - # NOTE: subprocess import done lazily so platforms without it (e.g. - # GAE) can still import us during overall Paramiko load. - from subprocess import Popen, PIPE - - self.cmd = shlsplit(command_line) - self.process = Popen( - self.cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=0 + if subprocess is None: + raise subprocess_import_error + self.cmd = shlex.split(command_line) + self.process = subprocess.Popen( + self.cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, ) self.timeout = None diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py index 52bb23be..2789be99 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): @@ -190,3 +196,27 @@ class NoValidConnectionsError(socket.error): def __reduce__(self): return (self.__class__, (self.errors,)) + + +class CouldNotCanonicalize(SSHException): + """ + Raised when hostname canonicalization fails & fallback is disabled. + + .. versionadded:: 2.7 + """ + + pass + + +class ConfigParseError(SSHException): + """ + A fatal error was encountered trying to parse SSH config data. + + Typically this means a config file violated the ``ssh_config`` + specification in a manner that requires exiting immediately, such as not + matching ``key = value`` syntax or misusing certain ``Match`` keywords. + + .. versionadded:: 2.7 + """ + + pass 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 f72eebaf..8919043f 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -88,9 +88,11 @@ from paramiko.common import ( from paramiko.compress import ZlibCompressor, ZlibDecompressor from paramiko.dsskey import DSSKey from paramiko.ed25519key import Ed25519Key +from paramiko.kex_curve25519 import KexCurve25519 from paramiko.kex_gex import KexGex, KexGexSHA256 from paramiko.kex_group1 import KexGroup1 -from paramiko.kex_group14 import KexGroup14 +from paramiko.kex_group14 import KexGroup14, KexGroup14SHA256 +from paramiko.kex_group16 import KexGroup16SHA512 from paramiko.kex_ecdh_nist import KexNistp256, KexNistp384, KexNistp521 from paramiko.kex_gss import KexGSSGex, KexGSSGroup1, KexGSSGroup14 from paramiko.message import Message @@ -143,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", @@ -156,6 +161,8 @@ class Transport(threading.Thread, ClosingContextManager): _preferred_macs = ( "hmac-sha2-256", "hmac-sha2-512", + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", "hmac-sha1", "hmac-md5", "hmac-sha1-96", @@ -173,11 +180,15 @@ class Transport(threading.Thread, ClosingContextManager): "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", + "diffie-hellman-group16-sha512", "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha256", "diffie-hellman-group-exchange-sha1", "diffie-hellman-group14-sha1", "diffie-hellman-group1-sha1", ) + if KexCurve25519.is_available(): + _preferred_kex = ("curve25519-sha256@libssh.org",) + _preferred_kex _preferred_gsskex = ( "gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==", "gss-group14-sha1-toWM5Slw5Ew8Mqkay+al2g==", @@ -240,7 +251,9 @@ class Transport(threading.Thread, ClosingContextManager): "hmac-sha1": {"class": sha1, "size": 20}, "hmac-sha1-96": {"class": sha1, "size": 12}, "hmac-sha2-256": {"class": sha256, "size": 32}, + "hmac-sha2-256-etm@openssh.com": {"class": sha256, "size": 32}, "hmac-sha2-512": {"class": sha512, "size": 64}, + "hmac-sha2-512-etm@openssh.com": {"class": sha512, "size": 64}, "hmac-md5": {"class": md5, "size": 16}, "hmac-md5-96": {"class": md5, "size": 12}, } @@ -265,6 +278,8 @@ class Transport(threading.Thread, ClosingContextManager): "diffie-hellman-group14-sha1": KexGroup14, "diffie-hellman-group-exchange-sha1": KexGex, "diffie-hellman-group-exchange-sha256": KexGexSHA256, + "diffie-hellman-group14-sha256": KexGroup14SHA256, + "diffie-hellman-group16-sha512": KexGroup16SHA512, "gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==": KexGSSGroup1, "gss-group14-sha1-toWM5Slw5Ew8Mqkay+al2g==": KexGSSGroup14, "gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==": KexGSSGex, @@ -272,6 +287,8 @@ class Transport(threading.Thread, ClosingContextManager): "ecdh-sha2-nistp384": KexNistp384, "ecdh-sha2-nistp521": KexNistp521, } + if KexCurve25519.is_available(): + _kex_info["curve25519-sha256@libssh.org"] = KexCurve25519 _compression_info = { # zlib@openssh.com is just zlib, but only turned on after a successful @@ -292,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 @@ -332,10 +350,36 @@ class Transport(threading.Thread, ClosingContextManager): :param int default_max_packet_size: sets the default max packet size on the transport. (defaults to 32768) + :param bool gss_kex: + Whether to enable GSSAPI key exchange when GSSAPI is in play. + Default: ``False``. + :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 @@ -443,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 @@ -452,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. @@ -2184,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): @@ -2199,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) @@ -2271,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( @@ -2288,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( @@ -2298,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( @@ -2314,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, ) ) @@ -2328,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: @@ -2349,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)") @@ -2372,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, ) ) @@ -2386,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 ( @@ -2405,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] @@ -2440,6 +2513,7 @@ class Transport(threading.Thread, ClosingContextManager): engine = self._get_cipher( self.remote_cipher, key_in, IV_in, self._DECRYPT ) + etm = "etm@openssh.com" in self.remote_mac mac_size = self._mac_info[self.remote_mac]["size"] mac_engine = self._mac_info[self.remote_mac]["class"] # initial mac keys are done in the hash's natural size (not the @@ -2449,7 +2523,7 @@ class Transport(threading.Thread, ClosingContextManager): else: mac_key = self._compute_key("F", mac_engine().digest_size) self.packetizer.set_inbound_cipher( - engine, block_size, mac_engine, mac_size, mac_key + engine, block_size, mac_engine, mac_size, mac_key, etm=etm ) compress_in = self._compression_info[self.remote_compression][1] if compress_in is not None and ( @@ -2478,6 +2552,7 @@ class Transport(threading.Thread, ClosingContextManager): engine = self._get_cipher( self.local_cipher, key_out, IV_out, self._ENCRYPT ) + etm = "etm@openssh.com" in self.local_mac mac_size = self._mac_info[self.local_mac]["size"] mac_engine = self._mac_info[self.local_mac]["class"] # initial mac keys are done in the hash's natural size (not the @@ -2488,7 +2563,7 @@ class Transport(threading.Thread, ClosingContextManager): mac_key = self._compute_key("E", mac_engine().digest_size) sdctr = self.local_cipher.endswith("-ctr") self.packetizer.set_outbound_cipher( - engine, block_size, mac_engine, mac_size, mac_key, sdctr + engine, block_size, mac_engine, mac_size, mac_key, sdctr, etm=etm ) compress_out = self._compression_info[self.local_compression][0] if compress_out is not None and ( diff --git a/paramiko/util.py b/paramiko/util.py index de4a5647..93970289 100644 --- a/paramiko/util.py +++ b/paramiko/util.py @@ -194,6 +194,9 @@ def load_host_keys(filename): def parse_ssh_config(file_obj): """ Provided only as a backward-compatible wrapper around `.SSHConfig`. + + .. deprecated:: 2.7 + Use `SSHConfig.from_file` instead. """ config = SSHConfig() config.parse(file_obj) @@ -245,16 +248,16 @@ def get_thread_id(): def log_to_file(filename, level=DEBUG): """send paramiko logs to a logfile, if they're not already going somewhere""" - l = logging.getLogger("paramiko") - if len(l.handlers) > 0: + logger = logging.getLogger("paramiko") + if len(logger.handlers) > 0: return - l.setLevel(level) + logger.setLevel(level) f = open(filename, "a") - lh = logging.StreamHandler(f) + handler = logging.StreamHandler(f) frm = "%(levelname)-.3s [%(asctime)s.%(msecs)03d] thr=%(_threadid)-3d" frm += " %(name)s: %(message)s" - lh.setFormatter(logging.Formatter(frm, "%Y%m%d-%H:%M:%S")) - l.addHandler(lh) + handler.setFormatter(logging.Formatter(frm, "%Y%m%d-%H:%M:%S")) + logger.addHandler(handler) # make only one filter object, so it doesn't get applied more than once @@ -268,9 +271,9 @@ _pfilter = PFilter() def get_logger(name): - l = logging.getLogger(name) - l.addFilter(_pfilter) - return l + logger = logging.getLogger(name) + logger.addFilter(_pfilter) + return logger def retry_on_signal(function): @@ -8,10 +8,12 @@ license_file = LICENSE omit = paramiko/_winapi.py [flake8] -exclude = sites,.git,build,dist,demos,tests +exclude = sites,.git,build,dist,demos # NOTE: W503, E203 are concessions to black 18.0b5 and could be reinstated # later if fixed on that end. -ignore = E124,E125,E128,E261,E301,E302,E303,E402,E721,W503,E203 +# NOTE: E722 seems to only have started popping up on move to flake8 3.6.0 from +# 2.4.0. Not sure why, bare excepts have been a (regrettable) thing forever... +ignore = E124,E125,E128,E261,E301,E302,E303,E402,E721,W503,E203,E722 max-line-length = 79 [tool:pytest] @@ -20,3 +22,7 @@ max-line-length = 79 addopts = -p no:relaxed # Loop on failure looponfailroots = tests paramiko +# Ignore some warnings we cannot easily handle. +filterwarnings = + ignore::DeprecationWarning:pkg_resources + ignore::cryptography.utils.CryptographyDeprecationWarning @@ -30,9 +30,6 @@ Emphasis is on using SSH2 as an alternative to SSL for making secure connections between python scripts. All major ciphers and hash methods are supported. SFTP client and server mode are both supported too. -Required packages: - Cryptography - To install the development version, ``pip install -e git+https://github.com/paramiko/paramiko/#egg=paramiko``. """ @@ -44,6 +41,22 @@ with open("paramiko/_version.py") as fp: exec(fp.read(), None, _locals) version = _locals["__version__"] +# Have to build extras_require dynamically because it doesn't allow +# self-referencing and I hate repeating myself. +extras_require = { + "gssapi": [ + "pyasn1>=0.1.7", + 'gssapi>=1.4.1;platform_system!="Windows"', + 'pywin32>=2.1.8;platform_system=="Windows"', + ], + "ed25519": ["pynacl>=1.0.1", "bcrypt>=3.1.3"], + "invoke": ["invoke>=1.3"], +} +everything = [] +for subdeps in extras_require.values(): + everything.extend(subdeps) +extras_require["all"] = everything + setup( name="paramiko", version=version, @@ -70,6 +83,11 @@ setup( "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], - install_requires=["bcrypt>=3.1.3", "cryptography>=1.5", "pynacl>=1.0.1"], + # TODO 3.0: remove bcrypt, pynacl and update installation docs noting that + # use of the extras_require(s) is now required for those + install_requires=["bcrypt>=3.1.3", "cryptography>=2.5", "pynacl>=1.0.1"], + extras_require=extras_require, ) diff --git a/sites/docs/api/config.rst b/sites/docs/api/config.rst index afb004c9..ea4939b2 100644 --- a/sites/docs/api/config.rst +++ b/sites/docs/api/config.rst @@ -1,5 +1,130 @@ +============= Configuration ============= +Paramiko **does not itself** leverage `OpenSSH-style config file directives +<ssh_config>`_, but it **does** implement a parser for the format, which users +can honor themselves (and is used by higher-level libraries, such as +`Fabric`_). + +The API for this is `.SSHConfig`, which loads SSH config files from disk, +file-like object, or string and exposes a "look up a hostname, get a dict of +applicable keywords/values back" functionality. + +As with OpenSSH's own support, this dict will contain values from across the +parsed file, depending on the order in which keywords were encountered and how +specific or generic the ``Host`` or ``Match`` directives were. + +.. note:; + Result keys are lowercased for consistency and ease of deduping, as the + overall parsing/matching is itself case-insensitive. Thus, a source file + containing e.g. ``ProxyCommand`` will result in lookup results like + ``{"proxycommand": "shell command here"}``. + + +.. _ssh-config-support: + +Keywords currently supported +============================ + +The following is an alphabetical list of which `ssh_config`_ directives +Paramiko interprets during the parse/lookup process (as above, actual SSH +connections **do not** reference parsed configs). Departures from `OpenSSH's +implementation <ssh_config>`_ (e.g. to support backwards compat with older +Paramiko releases) are included. A keyword by itself means no known departures. + +- ``AddressFamily``: used when looking up the local hostname for purposes of + expanding the ``%l``/``%L`` :ref:`tokens <TOKENS>` (this is actually a minor + value-add on top of OpenSSH, which doesn't actually honor this setting when + expanding ``%l``). +- ``CanonicalDomains`` + + .. versionadded:: 2.7 + +- ``CanonicalizeFallbackLocal``: when ``no``, triggers raising of + `.CouldNotCanonicalize` for target hostnames which do not successfully + canonicalize. + + .. versionadded:: 2.7 + +- ``CanonicalizeHostname``: along with the other ``Canonicaliz*`` settings + (sans ``CanonicalizePermittedCNAMEs``, which is not yet implemented), enables + hostname canonicalization, insofar as calling `.SSHConfig.lookup` with a + given hostname will return a canonicalized copy of the config data, including + an updated ``HostName`` value. + + .. versionadded:: 2.7 + +- ``CanonicalizeMaxDots`` + + .. versionadded:: 2.7 + +- ``Host`` +- ``HostName``: used in ``%h`` :ref:`token expansion <TOKENS>` +- ``Match``: fully supported, with the following caveats: + + - You must have the optional dependency Invoke installed; see :ref:`the + installation docs <paramiko-itself>` (in brief: install + ``paramiko[invoke]`` or ``paramiko[all]``). + - As usual, connection-time information is not present during config + lookup, and thus cannot be used to determine matching. This primarily + impacts ``Match user``, which can match against loaded ``User`` values + but has no knowledge about connection-time usernames. + + .. versionadded:: 2.7 + +- ``Port``: supplies potential values for ``%p`` :ref:`token expansion + <TOKENS>`. +- ``ProxyCommand``: see our `.ProxyCommand` class for an easy + way to honor this keyword from a config you've parsed. + + - Honors :ref:`token expansion <TOKENS>`. + - When a lookup would result in an effective ``ProxyCommand none``, + Paramiko (as of 1.x-2.x) strips it from the resulting dict entirely. A + later major version may retain the ``"none"`` marker for clarity's sake. + +- ``User``: supplies potential values for ``%u`` :ref:`token expansion + <TOKENS>`. + +.. _TOKENS: + +Expansion tokens +---------------- + +We support most SSH config expansion tokens where possible, so when they are +present in a config file source, the result of a `.SSHConfig.lookup` will +contain the expansions/substitutions (based on the rest of the config or +properties of the local system). + +Specifically, we are known to support the below, where applicable (e.g. as in +OpenSSH, ``%L`` works in ``ControlPath`` but not elsewhere): + +- ``%d`` +- ``%h`` +- ``%l`` +- ``%L`` +- ``%n`` +- ``%p`` +- ``%r`` +- ``%u``: substitutes the configured ``User`` value, or the local user (as seen + by ``getpass.getuser``) if not specified. + +In addition, we extend OpenSSH's tokens as follows: + +- ``~`` is treated like ``%d`` (expands to the local user's home directory + path) when expanding ``ProxyCommand`` values, since ``ProxyCommand`` does not + natively support ``%d`` for some reason. + + +.. _ssh_config: https://man.openbsd.org/ssh_config +.. _Fabric: http://fabfile.org + + +``config`` module API documentation +=================================== + +Mostly of interest to contributors; see previous section for behavioral +details. + .. automodule:: paramiko.config :member-order: bysource 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/docs/conf.py b/sites/docs/conf.py index eb895804..4805a03c 100644 --- a/sites/docs/conf.py +++ b/sites/docs/conf.py @@ -1,8 +1,9 @@ # Obtain shared config values import os, sys +from os.path import abspath, join, dirname -sys.path.append(os.path.abspath("..")) -sys.path.append(os.path.abspath("../..")) +sys.path.append(abspath("..")) +sys.path.append(abspath("../..")) from shared_conf import * # Enable autodoc, intersphinx @@ -11,6 +12,13 @@ extensions.extend(["sphinx.ext.autodoc"]) # Autodoc settings autodoc_default_flags = ["members", "special-members"] +# Default is 'local' building, but reference the public www site when building +# under RTD. +target = join(dirname(__file__), "..", "www", "_build") +if os.environ.get("READTHEDOCS") == "True": + target = "http://paramiko.org" +intersphinx_mapping["www"] = (target, None) + # Sister-site links to WWW html_theme_options["extra_nav_links"] = { "Main website": "http://www.paramiko.org" diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index ff87bfbb..67ba6554 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -2,6 +2,139 @@ Changelog ========= +- :bug:`- major` ``ssh_config`` :ref:`token expansion <TOKENS>` used a + different method of determining the local username (``$USER`` env var), + compared to what the (much older) client connection code does + (``getpass.getuser``, which includes ``$USER`` but may check other variables + first, and is generally much more comprehensive). Both modules now use + ``getpass.getuser``. +- :feature:`-` A couple of outright `~paramiko.config.SSHConfig` parse errors + were previously represented as vanilla ``Exception`` instances; as part of + recent feature work a more specific exception class, + `~paramiko.ssh_exception.ConfigParseError`, has been created. It is now also + used in those older spots, which is naturally backwards compatible. +- :feature:`717` Implement support for the ``Match`` keyword in ``ssh_config`` + files. Previously, this keyword was simply ignored & keywords inside such + blocks were treated as if they were part of the previous block. Thanks to + Michael Leinartas for the initial patchset. + + .. note:: + This feature adds a new :doc:`optional install dependency </installing>`, + `Invoke <https://www.pyinvoke.org>`_, for managing ``Match exec`` + subprocesses. + +- :support:`-` Additional :doc:`installation </installing>` ``extras_require`` + "flavors" (``ed25519``, ``invoke``, and ``everything``) have been added to + our packaging metadata; see the install docs for details. +- :bug:`- major` Paramiko's use of ``subprocess`` for ``ProxyCommand`` support + is conditionally imported to prevent issues on limited interpreter platforms + like Google Compute Engine. However, any resulting ``ImportError`` was lost + instead of preserved for raising (in the rare cases where a user tried + leveraging ``ProxyCommand`` in such an environment). This has been fixed. +- :bug:`- major` Perform deduplication of ``IdentityFile`` contents during + ``ssh_config`` parsing; previously, if your config would result in the same + value being encountered more than once, ``IdentityFile`` would contain that + many copies of the same string. +- :feature:`897` Implement most 'canonical hostname' ``ssh_config`` + functionality (``CanonicalizeHostname``, ``CanonicalDomains``, + ``CanonicalizeFallbackLocal``, and ``CanonicalizeMaxDots``; + ``CanonicalizePermittedCNAMEs`` has **not** yet been implemented). All were + previously silently ignored. Reported by Michael Leinartas. +- :support:`-` Explicitly document :ref:`which ssh_config features we + currently support <ssh-config-support>`. Previously users just had to guess, + which is simply no good. +- :feature:`-` Add new convenience classmethod constructors to + `~paramiko.config.SSHConfig`: `~paramiko.config.SSHConfig.from_text`, + `~paramiko.config.SSHConfig.from_file`, and + `~paramiko.config.SSHConfig.from_path`. No more annoying two-step process! +- :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 + comment lengths don't cause ``SSHException("Invalid key")`` (this was + 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``, + ``hmac-sha2-512-etm@openssh.com``) and two newer Diffie-Hellman group key + exchange algorithms (``group14``, using SHA256; and ``group16``, using + SHA512). Patch courtesy of Edgar Sousa. +- :feature:`532` (via :issue:`1384` and :issue:`1258`) Add support for + Curve25519 key exchange (aka ``curve25519-sha256@libssh.org``). Thanks to + Alex Gaynor and Dan Fuhry for supplying patches. +- :support:`1379` (also :issue:`1369`) Raise Cryptography dependency + requirement to version 2.5 (from 1.5) and update some deprecated uses of its + API. + + This removes a bunch of warnings of the style + ``CryptographyDeprecationWarning: encode_point has been deprecated on + EllipticCurvePublicNumbers and will be removed in a future version. Please + use EllipticCurvePublicKey.public_bytes to obtain both compressed and + uncompressed point encoding`` and similar, which users who had eventually + upgraded to Cryptography 2.x would encounter. + + .. warning:: + This change is backwards incompatible **if** you are unable to upgrade your + version of Cryptography. Please see `Cryptography's own changelog + <https://cryptography.io/en/latest/changelog/>`_ for details on what may + change when you upgrade; for the most part the only changes involved + dropping older Python versions (such as 2.6, 3.3, or some PyPy editions) + which Paramiko itself has already dropped. + +- :support:`1378 backported` Add support for the modern (as of Python 3.3) + import location of ``MutableMapping`` (used in host key management) to avoid + the old location becoming deprecated in Python 3.8. Thanks to Josh Karpel for + catch & patch. - :release:`2.4.2 <2018-09-18>` - :release:`2.3.3 <2018-09-18>` - :release:`2.2.4 <2018-09-18>` @@ -23,8 +156,9 @@ Changelog for this particular channel). Thanks to Daniel Hoffman for the detailed report. -- :support:`1292 backported` Backport changes from :issue:`979` (added in - Paramiko 2.3) to Paramiko 2.0-2.2, using duck-typing to preserve backwards +- :support:`1292 backported (<2.4)` Backport changes from :issue:`979` (added + in Paramiko + 2.3) to Paramiko 2.0-2.2, using duck-typing to preserve backwards compatibility. This allows these older versions to use newer Cryptography sign/verify APIs when available, without requiring them (as is the case with Paramiko 2.3+). @@ -37,9 +171,9 @@ Changelog This is a no-op for Paramiko 2.3+, which have required newer Cryptography releases since they were released. -- :support:`1291 backported` Backport pytest support and application of the - ``black`` code formatter (both of which previously only existed in the 2.4 - branch and above) to everything 2.0 and newer. This makes back/forward +- :support:`1291 backported (<2.4)` Backport pytest support and application of + the ``black`` code formatter (both of which previously only existed in the + 2.4 branch and above) to everything 2.0 and newer. This makes back/forward porting bugfixes significantly easier. - :support:`1262 backported` Add ``*.pub`` files to the MANIFEST so distributed source packages contain some necessary test assets. Credit: Alexander @@ -95,7 +229,7 @@ Changelog - :support:`1100` Updated the test suite & related docs/metadata/config to be compatible with pytest instead of using the old, custom, crufty unittest-based ``test.py``. - + This includes marking known-slow tests (mostly the SFTP ones) so they can be filtered out by ``inv test``'s default behavior; as well as other minor tweaks to test collection and/or display (for example, GSSAPI tests are diff --git a/sites/www/installing.rst b/sites/www/installing.rst index 3631eb0d..f2d9a341 100644 --- a/sites/www/installing.rst +++ b/sites/www/installing.rst @@ -22,15 +22,31 @@ via `pip <http://pip-installer.org>`_:: We currently support **Python 2.7, 3.4+, and PyPy**. Users on Python 2.6 or older (or 3.3 or older) are urged to upgrade. -Paramiko has only a few direct dependencies: +Paramiko has only a few **direct dependencies**: - The big one, with its own sub-dependencies, is Cryptography; see :ref:`its - specific note below <cryptography>` for more details. + specific note below <cryptography>` for more details; - `bcrypt <https://pypi.org/project/bcrypt/>`_, for Ed25519 key support; - `pynacl <https://pypi.org/project/PyNaCl/>`_, also for Ed25519 key support. -If you need GSS-API / SSPI support, see :ref:`the below subsection on it -<gssapi>` for details on its optional dependencies. +There are also a number of **optional dependencies** you may install using +`setuptools 'extras' +<https://packaging.python.org/tutorials/installing-packages/#installing-setuptools-extras>`_: + +.. TODO 3.0: tweak the invoke line to mention proxycommand too +.. TODO 3.0: tweak the ed25519 line to remove the caveat + +- If you want all optional dependencies at once, use ``paramiko[all]``. +- For ``Match exec`` config support, use ``paramiko[invoke]`` (which installs + `Invoke <https://www.pyinvoke.org>`_). +- For GSS-API / SSPI support, use ``paramiko[gssapi]``, though also see + :ref:`the below subsection on it <gssapi>` for details. +- ``paramiko[ed25519]`` references the dependencies for Ed25519 key support. + + - As of Paramiko 2.x this doesn't technically do anything, as those + dependencies are core installation requirements. + - However, you should use this for forwards compatibility; 3.0 will drop + those dependencies from core, leaving them purely optional. .. _release-lines: @@ -95,22 +111,44 @@ 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.) + + +.. TODO: just axe this once legacy gssapi support is gone, no point reiterating + +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/configs/basic b/tests/configs/basic new file mode 100644 index 00000000..93fe3beb --- /dev/null +++ b/tests/configs/basic @@ -0,0 +1,4 @@ +CanonicalDomains paramiko.org + +Host www.paramiko.org + User rando diff --git a/tests/configs/canon b/tests/configs/canon new file mode 100644 index 00000000..7b979408 --- /dev/null +++ b/tests/configs/canon @@ -0,0 +1,8 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org + +IdentityFile base.key + +Host www.paramiko.org + User rando + IdentityFile canonicalized.key diff --git a/tests/configs/canon-always b/tests/configs/canon-always new file mode 100644 index 00000000..f3f56b70 --- /dev/null +++ b/tests/configs/canon-always @@ -0,0 +1,5 @@ +CanonicalDomains paramiko.org +CanonicalizeHostname always + +Host www.paramiko.org + User rando diff --git a/tests/configs/canon-ipv4 b/tests/configs/canon-ipv4 new file mode 100644 index 00000000..92c3875f --- /dev/null +++ b/tests/configs/canon-ipv4 @@ -0,0 +1,6 @@ +CanonicalDomains paramiko.org +CanonicalizeHostname yes +AddressFamily inet + +Host www.paramiko.org + User rando diff --git a/tests/configs/canon-local b/tests/configs/canon-local new file mode 100644 index 00000000..dde9f77b --- /dev/null +++ b/tests/configs/canon-local @@ -0,0 +1,6 @@ +Host www.paramiko.org + User rando + +Host www + CanonicalDomains paramiko.org + CanonicalizeHostname yes diff --git a/tests/configs/canon-local-always b/tests/configs/canon-local-always new file mode 100644 index 00000000..0ad0535a --- /dev/null +++ b/tests/configs/canon-local-always @@ -0,0 +1,6 @@ +Host www.paramiko.org + User rando + +Host www + CanonicalDomains paramiko.org + CanonicalizeHostname always diff --git a/tests/configs/deep-canon b/tests/configs/deep-canon new file mode 100644 index 00000000..483823d5 --- /dev/null +++ b/tests/configs/deep-canon @@ -0,0 +1,11 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org + +Host www.paramiko.org + User rando + +Host sub.www.paramiko.org + User deep + +Host subber.sub.www.paramiko.org + User deeper diff --git a/tests/configs/deep-canon-maxdots b/tests/configs/deep-canon-maxdots new file mode 100644 index 00000000..7785f660 --- /dev/null +++ b/tests/configs/deep-canon-maxdots @@ -0,0 +1,12 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org +CanonicalizeMaxDots 2 + +Host www.paramiko.org + User rando + +Host sub.www.paramiko.org + User deep + +Host subber.sub.www.paramiko.org + User deeper diff --git a/tests/configs/empty-canon b/tests/configs/empty-canon new file mode 100644 index 00000000..19743ad6 --- /dev/null +++ b/tests/configs/empty-canon @@ -0,0 +1,6 @@ +CanonicalizeHostname yes +CanonicalDomains +AddressFamily inet + +Host www.paramiko.org + User rando diff --git a/tests/configs/fallback-no b/tests/configs/fallback-no new file mode 100644 index 00000000..ec8d13ee --- /dev/null +++ b/tests/configs/fallback-no @@ -0,0 +1,6 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org +CanonicalizeFallbackLocal no + +Host www.paramiko.org + User rando diff --git a/tests/configs/fallback-yes b/tests/configs/fallback-yes new file mode 100644 index 00000000..bc4f4eee --- /dev/null +++ b/tests/configs/fallback-yes @@ -0,0 +1,6 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org +CanonicalizeFallbackLocal yes + +Host www.paramiko.org + User rando diff --git a/tests/configs/hostname-exec-tokenized b/tests/configs/hostname-exec-tokenized new file mode 100644 index 00000000..1cae2c03 --- /dev/null +++ b/tests/configs/hostname-exec-tokenized @@ -0,0 +1,2 @@ +Match exec "ping %h" + HostName pingable.%h diff --git a/tests/configs/hostname-tokenized b/tests/configs/hostname-tokenized new file mode 100644 index 00000000..1905c0cc --- /dev/null +++ b/tests/configs/hostname-tokenized @@ -0,0 +1 @@ +HostName prefix.%h diff --git a/tests/configs/invalid b/tests/configs/invalid new file mode 100644 index 00000000..81332fe8 --- /dev/null +++ b/tests/configs/invalid @@ -0,0 +1 @@ +lolwut diff --git a/tests/configs/match-all b/tests/configs/match-all new file mode 100644 index 00000000..7673e0a0 --- /dev/null +++ b/tests/configs/match-all @@ -0,0 +1,2 @@ +Match all + User awesome diff --git a/tests/configs/match-all-after-canonical b/tests/configs/match-all-after-canonical new file mode 100644 index 00000000..531112cb --- /dev/null +++ b/tests/configs/match-all-after-canonical @@ -0,0 +1,5 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org + +Match canonical all + User awesome diff --git a/tests/configs/match-all-and-more b/tests/configs/match-all-and-more new file mode 100644 index 00000000..bb50696e --- /dev/null +++ b/tests/configs/match-all-and-more @@ -0,0 +1,2 @@ +Match all exec "lol nope" + HostName whatever diff --git a/tests/configs/match-all-and-more-before b/tests/configs/match-all-and-more-before new file mode 100644 index 00000000..4d5b2e34 --- /dev/null +++ b/tests/configs/match-all-and-more-before @@ -0,0 +1,2 @@ +Match exec "lol nope" all + HostName whatever diff --git a/tests/configs/match-all-before-canonical b/tests/configs/match-all-before-canonical new file mode 100644 index 00000000..35e3b0e2 --- /dev/null +++ b/tests/configs/match-all-before-canonical @@ -0,0 +1,5 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org + +Match all canonical + User oops diff --git a/tests/configs/match-canonical-no b/tests/configs/match-canonical-no new file mode 100644 index 00000000..e528dc64 --- /dev/null +++ b/tests/configs/match-canonical-no @@ -0,0 +1,7 @@ +CanonicalizeHostname no + +Match canonical all + User awesome + +Match !canonical host specific + User overload diff --git a/tests/configs/match-canonical-yes b/tests/configs/match-canonical-yes new file mode 100644 index 00000000..d6c20928 --- /dev/null +++ b/tests/configs/match-canonical-yes @@ -0,0 +1,5 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org + +Match !canonical host www* + User hidden diff --git a/tests/configs/match-complex b/tests/configs/match-complex new file mode 100644 index 00000000..63634039 --- /dev/null +++ b/tests/configs/match-complex @@ -0,0 +1,17 @@ +HostName bogus + +Match originalhost target host bogus + User rand + +Match originalhost remote localuser rando + User calrissian + +# Just to set user for subsequent match +Match originalhost www + User calrissian + +Match !canonical originalhost www host bogus localuser rando user calrissian + Port 7777 + +Match !canonical !originalhost www host bogus localuser rando !user calrissian + Port 1234 diff --git a/tests/configs/match-exec b/tests/configs/match-exec new file mode 100644 index 00000000..763346ea --- /dev/null +++ b/tests/configs/match-exec @@ -0,0 +1,16 @@ +Match exec "quoted" + User benjamin + +Match exec unquoted + User rando + +Match exec "quoted spaced" + User neil + +# Just to prepopulate values for tokenizing subsequent exec +Host target + User intermediate + HostName configured + +Match exec "%d %h %L %l %n %p %r %u" + Port 1337 diff --git a/tests/configs/match-exec-canonical b/tests/configs/match-exec-canonical new file mode 100644 index 00000000..794ee9d5 --- /dev/null +++ b/tests/configs/match-exec-canonical @@ -0,0 +1,10 @@ +CanonicalDomains paramiko.org +CanonicalizeHostname always + +# This will match in the first, uncanonicalized pass +Match !canonical exec uncanonicalized + User defenseless + +# And this will match the second time +Match canonical exec canonicalized + Port 8007 diff --git a/tests/configs/match-exec-negation b/tests/configs/match-exec-negation new file mode 100644 index 00000000..937c910e --- /dev/null +++ b/tests/configs/match-exec-negation @@ -0,0 +1,5 @@ +Match !exec "this succeeds" + User nope + +Match !exec "this fails" + User yup diff --git a/tests/configs/match-exec-no-arg b/tests/configs/match-exec-no-arg new file mode 100644 index 00000000..20c16d16 --- /dev/null +++ b/tests/configs/match-exec-no-arg @@ -0,0 +1,2 @@ +Match exec + User uh-oh diff --git a/tests/configs/match-host b/tests/configs/match-host new file mode 100644 index 00000000..86cbff5d --- /dev/null +++ b/tests/configs/match-host @@ -0,0 +1,2 @@ +Match host target + User rand diff --git a/tests/configs/match-host-canonicalized b/tests/configs/match-host-canonicalized new file mode 100644 index 00000000..52dadeae --- /dev/null +++ b/tests/configs/match-host-canonicalized @@ -0,0 +1,8 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org + +Match host www.paramiko.org + User rand + +Match canonical host docs.paramiko.org + User eric diff --git a/tests/configs/match-host-from-match b/tests/configs/match-host-from-match new file mode 100644 index 00000000..172ee116 --- /dev/null +++ b/tests/configs/match-host-from-match @@ -0,0 +1,5 @@ +Match host original-host + HostName substituted-host + +Match host substituted-host + User inner diff --git a/tests/configs/match-host-glob b/tests/configs/match-host-glob new file mode 100644 index 00000000..3d53cf48 --- /dev/null +++ b/tests/configs/match-host-glob @@ -0,0 +1,2 @@ +Match host *ever + User matrim diff --git a/tests/configs/match-host-glob-list b/tests/configs/match-host-glob-list new file mode 100644 index 00000000..3617d136 --- /dev/null +++ b/tests/configs/match-host-glob-list @@ -0,0 +1,8 @@ +Match host *ever + User matrim + +Match host somehost,someotherhost + User thom + +Match host goo*,!goof + User perrin diff --git a/tests/configs/match-host-name b/tests/configs/match-host-name new file mode 100644 index 00000000..783d939e --- /dev/null +++ b/tests/configs/match-host-name @@ -0,0 +1,4 @@ +HostName default-host + +Match host default-host + User silly diff --git a/tests/configs/match-host-negated b/tests/configs/match-host-negated new file mode 100644 index 00000000..7c5d3f3e --- /dev/null +++ b/tests/configs/match-host-negated @@ -0,0 +1,2 @@ +Match !host www + User jeff diff --git a/tests/configs/match-host-no-arg b/tests/configs/match-host-no-arg new file mode 100644 index 00000000..191cebb5 --- /dev/null +++ b/tests/configs/match-host-no-arg @@ -0,0 +1,2 @@ +Match host + User oops diff --git a/tests/configs/match-localuser b/tests/configs/match-localuser new file mode 100644 index 00000000..fe4a276c --- /dev/null +++ b/tests/configs/match-localuser @@ -0,0 +1,14 @@ +Match localuser gandalf + HostName gondor + +Match localuser b* + HostName shire + +Match localuser aragorn,frodo + HostName moria + +Match localuser gimli,!legolas + Port 7373 + +Match !localuser sauron + HostName mordor diff --git a/tests/configs/match-localuser-no-arg b/tests/configs/match-localuser-no-arg new file mode 100644 index 00000000..6623553a --- /dev/null +++ b/tests/configs/match-localuser-no-arg @@ -0,0 +1,2 @@ +Match localuser + User oops diff --git a/tests/configs/match-orighost b/tests/configs/match-orighost new file mode 100644 index 00000000..10541993 --- /dev/null +++ b/tests/configs/match-orighost @@ -0,0 +1,16 @@ +HostName bogus + +Match originalhost target + User tuon + +Match originalhost what* + User matrim + +Match originalhost comma,sep* + User chameleon + +Match originalhost yep,!nope + User skipped + +Match !originalhost www !originalhost nope + User thom diff --git a/tests/configs/match-orighost-canonical b/tests/configs/match-orighost-canonical new file mode 100644 index 00000000..737345e8 --- /dev/null +++ b/tests/configs/match-orighost-canonical @@ -0,0 +1,5 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org + +Match originalhost www + User tuon diff --git a/tests/configs/match-orighost-no-arg b/tests/configs/match-orighost-no-arg new file mode 100644 index 00000000..427382ba --- /dev/null +++ b/tests/configs/match-orighost-no-arg @@ -0,0 +1,2 @@ +Match originalhost + User oops diff --git a/tests/configs/match-user b/tests/configs/match-user new file mode 100644 index 00000000..14d6ac12 --- /dev/null +++ b/tests/configs/match-user @@ -0,0 +1,14 @@ +Match user gandalf + HostName gondor + +Match user b* + HostName shire + +Match user aragorn,frodo + HostName moria + +Match user gimli,!legolas + Port 7373 + +Match !user sauron + HostName mordor diff --git a/tests/configs/match-user-explicit b/tests/configs/match-user-explicit new file mode 100644 index 00000000..9a2b1d82 --- /dev/null +++ b/tests/configs/match-user-explicit @@ -0,0 +1,4 @@ +User explicit + +Match user explicit + HostName dumb diff --git a/tests/configs/match-user-no-arg b/tests/configs/match-user-no-arg new file mode 100644 index 00000000..65a11ab4 --- /dev/null +++ b/tests/configs/match-user-no-arg @@ -0,0 +1,2 @@ +Match user + User oops diff --git a/tests/configs/multi-canon-domains b/tests/configs/multi-canon-domains new file mode 100644 index 00000000..5674b442 --- /dev/null +++ b/tests/configs/multi-canon-domains @@ -0,0 +1,5 @@ +CanonicalizeHostname yes +CanonicalDomains not-a-real-tld paramiko.org + +Host www.paramiko.org + User rando diff --git a/tests/configs/no-canon b/tests/configs/no-canon new file mode 100644 index 00000000..033f8c53 --- /dev/null +++ b/tests/configs/no-canon @@ -0,0 +1,5 @@ +CanonicalizeHostname no +CanonicalDomains paramiko.org + +Host www.paramiko.org + User rando diff --git a/tests/configs/robey b/tests/configs/robey new file mode 100644 index 00000000..b2026224 --- /dev/null +++ b/tests/configs/robey @@ -0,0 +1,17 @@ +# A timeless classic? +# NOTE: some lines in here have 'extra' whitespace (incl trailing, and mixed +# tabs/spaces!) on purpose. + +Host * + User robey + IdentityFile =~/.ssh/id_rsa + +# comment +Host *.example.com + User bjork +Port=3333 +Host * + Crazy something dumb +Host spoo.example.com +Crazy something else + diff --git a/tests/configs/zero-maxdots b/tests/configs/zero-maxdots new file mode 100644 index 00000000..dc00054c --- /dev/null +++ b/tests/configs/zero-maxdots @@ -0,0 +1,9 @@ +CanonicalizeHostname yes +CanonicalDomains paramiko.org +CanonicalizeMaxDots 0 + +Host www.paramiko.org + User rando + +Host sub.www.paramiko.org + User deep diff --git a/tests/stub_sftp.py b/tests/stub_sftp.py index 100076d6..1528a0b8 100644 --- a/tests/stub_sftp.py +++ b/tests/stub_sftp.py @@ -21,18 +21,17 @@ A stub SFTP server for loopback SFTP testing. """ import os -import sys from paramiko import ( - ServerInterface, - SFTPServerInterface, - SFTPServer, + AUTH_SUCCESSFUL, + OPEN_SUCCEEDED, SFTPAttributes, SFTPHandle, - SFTP_OK, + SFTPServer, + SFTPServerInterface, SFTP_FAILURE, - AUTH_SUCCESSFUL, - OPEN_SUCCEEDED, + SFTP_OK, + ServerInterface, ) from paramiko.common import o666 @@ -65,7 +64,8 @@ class StubSFTPHandle(SFTPHandle): class StubSFTPServer(SFTPServerInterface): # assume current folder is a fine root - # (the tests always create and eventually delete a subfolder, so there shouldn't be any mess) + # (the tests always create and eventually delete a subfolder, so there + # shouldn't be any mess) ROOT = os.getcwd() def _realpath(self, path): @@ -206,7 +206,8 @@ class StubSFTPServer(SFTPServerInterface): # compute relative to path abspath = os.path.join(os.path.dirname(path), target_path) if abspath[: len(self.ROOT)] != self.ROOT: - # this symlink isn't going to work anyway -- just break it immediately + # this symlink isn't going to work anyway -- just break it + # immediately target_path = "<error>" try: os.symlink(target_path, path) diff --git a/tests/test_auth.py b/tests/test_auth.py index d98a00c4..01fbac5b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -250,7 +250,7 @@ class AuthTest(unittest.TestCase): self.start_server() self.tc.connect(hostkey=self.public_host_key) try: - remain = self.tc.auth_password("bad-server", "hello") + self.tc.auth_password("bad-server", "hello") except: etype, evalue, etb = sys.exc_info() self.assertTrue(issubclass(etype, AuthenticationException)) @@ -265,7 +265,7 @@ class AuthTest(unittest.TestCase): self.start_server() self.tc.connect() try: - remain = self.tc.auth_password("unresponsive-server", "hello") + self.tc.auth_password("unresponsive-server", "hello") except: etype, evalue, etb = sys.exc_info() self.assertTrue(issubclass(etype, AuthenticationException)) diff --git a/tests/test_buffered_pipe.py b/tests/test_buffered_pipe.py index 28d6e4a2..61c99cc0 100644 --- a/tests/test_buffered_pipe.py +++ b/tests/test_buffered_pipe.py @@ -26,7 +26,6 @@ import unittest from paramiko.buffered_pipe import BufferedPipe, PipeTimeout from paramiko import pipe -from paramiko.py3compat import b def delay_thread(p): @@ -42,7 +41,7 @@ def close_thread(p): class BufferedPipeTest(unittest.TestCase): - def test_1_buffered_pipe(self): + def test_buffered_pipe(self): p = BufferedPipe() self.assertTrue(not p.read_ready()) p.feed("hello.") @@ -59,7 +58,7 @@ class BufferedPipeTest(unittest.TestCase): self.assertTrue(not p.read_ready()) self.assertEqual(b"", p.read(1)) - def test_2_delay(self): + def test_delay(self): p = BufferedPipe() self.assertTrue(not p.read_ready()) threading.Thread(target=delay_thread, args=(p,)).start() @@ -72,13 +71,13 @@ class BufferedPipeTest(unittest.TestCase): self.assertEqual(b"b", p.read(1, 1.0)) self.assertEqual(b"", p.read(1)) - def test_3_close_while_reading(self): + def test_close_while_reading(self): p = BufferedPipe() threading.Thread(target=close_thread, args=(p,)).start() data = p.read(1, 1.0) self.assertEqual(b"", data) - def test_4_or_pipe(self): + def test_or_pipe(self): p = pipe.make_pipe() p1, p2 = pipe.make_or_pipe(p) self.assertFalse(p._set) diff --git a/tests/test_channelfile.py b/tests/test_channelfile.py new file mode 100644 index 00000000..4448fdfb --- /dev/null +++ b/tests/test_channelfile.py @@ -0,0 +1,60 @@ +from mock import patch, MagicMock + +from paramiko import Channel, ChannelFile, ChannelStderrFile, ChannelStdinFile + + +class ChannelFileBase(object): + @patch("paramiko.channel.ChannelFile._set_mode") + def test_defaults_to_unbuffered_reading(self, setmode): + 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): + 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 = self.klass(chan) + cf.read(100) + chan.recv.assert_called_once_with(100) + + def test_write_calls_channel_sendall(self): + chan = MagicMock() + 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() + cf = ChannelStderrFile(chan) + cf.read(100) + chan.recv_stderr.assert_called_once_with(100) + + def test_write_calls_channel_sendall(self): + chan = MagicMock() + 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 80b28adf..60ad310c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -34,10 +34,11 @@ 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.common import PY2 from paramiko.ssh_exception import SSHException, AuthenticationException from .util import _support, slow @@ -48,9 +49,9 @@ requires_gss_auth = unittest.skipUnless( ) FINGERPRINTS = { - "ssh-dss": b"\x44\x78\xf0\xb9\xa2\x3c\xc5\x18\x20\x09\xff\x75\x5b\xc1\xd2\x6c", - "ssh-rsa": b"\x60\x73\x38\x44\xcb\x51\x86\x65\x7f\xde\xda\xa2\x2b\x5a\x57\xd5", - "ecdsa-sha2-nistp256": b"\x25\x19\xeb\x55\xe6\xa1\x47\xff\x4f\x38\xd2\x75\x6f\xa5\xd5\x60", + "ssh-dss": b"\x44\x78\xf0\xb9\xa2\x3c\xc5\x18\x20\x09\xff\x75\x5b\xc1\xd2\x6c", # noqa + "ssh-rsa": b"\x60\x73\x38\x44\xcb\x51\x86\x65\x7f\xde\xda\xa2\x2b\x5a\x57\xd5", # noqa + "ecdsa-sha2-nistp256": b"\x25\x19\xeb\x55\xe6\xa1\x47\xff\x4f\x38\xd2\x75\x6f\xa5\xd5\x60", # noqa "ssh-ed25519": b'\xb3\xd5"\xaa\xf9u^\xe8\xcd\x0e\xea\x02\xb9)\xa2\x80', } @@ -192,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 ) @@ -212,6 +213,12 @@ class ClientTest(unittest.TestCase): stdin, stdout, stderr = self.tc.exec_command("yes") schan = self.ts.accept(1.0) + # Nobody else tests the API of exec_command so let's do it here for + # now. :weary: + assert isinstance(stdin, paramiko.ChannelStdinFile) + assert isinstance(stdout, paramiko.ChannelFile) + assert isinstance(stderr, paramiko.ChannelStderrFile) + schan.send("Hello there.\n") schan.send_stderr("This is on stderr.\n") schan.close() @@ -228,13 +235,13 @@ class ClientTest(unittest.TestCase): class SSHClientTest(ClientTest): - def test_1_client(self): + def test_client(self): """ verify that the SSHClient stuff works too. """ self._test_connection(password="pygmalion") - def test_2_client_dsa(self): + def test_client_dsa(self): """ verify that SSHClient works with a DSA key. """ @@ -246,7 +253,7 @@ class SSHClientTest(ClientTest): """ self._test_connection(key_filename=_support("test_rsa.key")) - def test_2_5_client_ecdsa(self): + def test_client_ecdsa(self): """ verify that SSHClient works with an ECDSA key. """ @@ -255,7 +262,7 @@ class SSHClientTest(ClientTest): def test_client_ed25519(self): self._test_connection(key_filename=_support("test_ed25519.key")) - def test_3_multiple_key_files(self): + def test_multiple_key_files(self): """ verify that SSHClient accepts and tries multiple key files. """ @@ -335,7 +342,7 @@ class SSHClientTest(ClientTest): # code path (!) so we're punting too, sob. pass - def test_4_auto_add_policy(self): + def test_auto_add_policy(self): """ verify that SSHClient's AutoAddPolicy works. """ @@ -344,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) @@ -358,7 +365,7 @@ class SSHClientTest(ClientTest): new_host_key = list(self.tc.get_host_keys()[hostname].values())[0] self.assertEqual(public_host_key, new_host_key) - def test_5_save_host_keys(self): + def test_save_host_keys(self): """ verify that SSHClient correctly saves a known_hosts file. """ @@ -371,16 +378,14 @@ class SSHClientTest(ClientTest): fd, localname = mkstemp() os.close(fd) - client = paramiko.SSHClient() - self.assertEquals(0, len(client.get_host_keys())) + client = SSHClient() + assert len(client.get_host_keys()) == 0 host_id = "[%s]:%d" % (self.addr, self.port) client.get_host_keys().add(host_id, "ssh-rsa", public_host_key) - self.assertEquals(1, len(client.get_host_keys())) - self.assertEquals( - public_host_key, client.get_host_keys()[host_id]["ssh-rsa"] - ) + assert len(client.get_host_keys()) == 1 + assert public_host_key == client.get_host_keys()[host_id]["ssh-rsa"] client.save_host_keys(localname) @@ -389,7 +394,7 @@ class SSHClientTest(ClientTest): os.unlink(localname) - def test_6_cleanup(self): + def test_cleanup(self): """ verify that when an SSHClient is collected, its transport (and the transport's packetizer) is closed. @@ -400,17 +405,17 @@ 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())) + assert len(self.tc.get_host_keys()) == 0 self.tc.connect(**dict(self.connect_kwargs, password="pygmalion")) self.event.wait(1.0) - self.assertTrue(self.event.is_set()) - self.assertTrue(self.ts.is_active()) + assert self.event.is_set() + assert self.ts.is_active() p = weakref.ref(self.tc._transport.packetizer) - self.assertTrue(p() is not None) + assert p() is not None self.tc.close() del self.tc @@ -420,7 +425,7 @@ class SSHClientTest(ClientTest): gc.collect() gc.collect() - self.assertTrue(p() is None) + assert p() is None def test_client_can_be_used_as_context_manager(self): """ @@ -428,10 +433,10 @@ 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()) - self.assertEquals(0, len(self.tc.get_host_keys())) + assert len(self.tc.get_host_keys()) == 0 self.tc.connect(**dict(self.connect_kwargs, password="pygmalion")) self.event.wait(1.0) @@ -442,7 +447,7 @@ class SSHClientTest(ClientTest): self.assertTrue(self.tc._transport is None) - def test_7_banner_timeout(self): + def test_banner_timeout(self): """ verify that the SSHClient has a configurable banner timeout. """ @@ -453,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 ) @@ -461,7 +466,7 @@ class SSHClientTest(ClientTest): kwargs = dict(self.connect_kwargs, banner_timeout=0.5) self.assertRaises(paramiko.SSHException, self.tc.connect, **kwargs) - def test_8_auth_trickledown(self): + def test_auth_trickledown(self): """ Failed key auth doesn't prevent subsequent pw auth from succeeding """ @@ -482,7 +487,7 @@ class SSHClientTest(ClientTest): self._test_connection(**kwargs) @slow - def test_9_auth_timeout(self): + def test_auth_timeout(self): """ verify that the SSHClient has a configurable auth timeout """ @@ -495,28 +500,28 @@ class SSHClientTest(ClientTest): ) @requires_gss_auth - def test_10_auth_trickledown_gsskex(self): + def test_auth_trickledown_gsskex(self): """ - Failed gssapi-keyex auth doesn't prevent subsequent key auth from succeeding + Failed gssapi-keyex doesn't prevent subsequent key from succeeding """ kwargs = dict(gss_kex=True, key_filename=[_support("test_rsa.key")]) self._test_connection(**kwargs) @requires_gss_auth - def test_11_auth_trickledown_gssauth(self): + def test_auth_trickledown_gssauth(self): """ - Failed gssapi-with-mic auth doesn't prevent subsequent key auth from succeeding + Failed gssapi-with-mic doesn't prevent subsequent key from succeeding """ kwargs = dict(gss_auth=True, key_filename=[_support("test_rsa.key")]) self._test_connection(**kwargs) - def test_12_reject_policy(self): + def test_reject_policy(self): """ verify that SSHClient's RejectPolicy works. """ 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( @@ -527,15 +532,16 @@ class SSHClientTest(ClientTest): ) @requires_gss_auth - def test_13_reject_policy_gsskex(self): + def test_reject_policy_gsskex(self): """ verify that SSHClient's RejectPolicy works, even if gssapi-keyex was enabled but not used. """ - # Test for a bug present in paramiko versions released before 2017-08-01 + # Test for a bug present in paramiko versions released before + # 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( @@ -550,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) @@ -566,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() @@ -595,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( @@ -639,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) @@ -649,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 @@ -684,9 +706,9 @@ class PasswordPassphraseTests(ClientTest): ) @raises(AuthenticationException) # TODO: more granular - def test_password_kwarg_not_used_for_passphrase_when_passphrase_kwarg_given( + def test_password_kwarg_not_used_for_passphrase_when_passphrase_kwarg_given( # noqa self - ): # noqa + ): # Sanity: if we're given both fields, the password field is NOT used as # a passphrase. self._test_connection( diff --git a/tests/test_config.py b/tests/test_config.py index cbd3f623..5e9aa059 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,74 +1,992 @@ # This file is part of Paramiko and subject to the license in /LICENSE in this # repository -import pytest +from os.path import expanduser +from socket import gaierror -from paramiko import config -from paramiko.util import parse_ssh_config -from paramiko.py3compat import StringIO +from paramiko.py3compat import string_types +from invoke import Result +from mock import patch +from pytest import raises, mark, fixture -def test_SSHConfigDict_construct_empty(): - assert not config.SSHConfigDict() +from paramiko import ( + SSHConfig, + SSHConfigDict, + CouldNotCanonicalize, + ConfigParseError, +) +from .util import _config -def test_SSHConfigDict_construct_from_list(): - assert config.SSHConfigDict([(1, 2)])[1] == 2 +@fixture +def socket(): + """ + Patch all of socket.* in our config module to prevent eg real DNS lookups. + + Also forces getaddrinfo (used in our addressfamily lookup stuff) to always + fail by default to mimic usual lack of AddressFamily related crap. -def test_SSHConfigDict_construct_from_dict(): - assert config.SSHConfigDict({1: 2})[1] == 2 + Callers who want to mock DNS lookups can then safely assume gethostbyname() + will be in use. + """ + with patch("paramiko.config.socket") as mocket: + # Reinstate gaierror as an actual exception and not a sub-mock. + # (Presumably this would work with any exception, but why not use the + # real one?) + mocket.gaierror = gaierror + # Patch out getaddrinfo, used to detect family-specific IP lookup - + # only useful for a few specific tests. + mocket.getaddrinfo.side_effect = mocket.gaierror + # Patch out getfqdn to return some real string for when it gets called; + # some code (eg tokenization) gets mad w/ MagicMocks + mocket.getfqdn.return_value = "some.fake.fqdn" + yield mocket -@pytest.mark.parametrize("true_ish", ("yes", "YES", "Yes", True)) -def test_SSHConfigDict_as_bool_true_ish(true_ish): - assert config.SSHConfigDict({"key": true_ish}).as_bool("key") is True +def load_config(name): + return SSHConfig.from_path(_config(name)) -@pytest.mark.parametrize("false_ish", ("no", "NO", "No", False)) -def test_SSHConfigDict_as_bool(false_ish): - assert config.SSHConfigDict({"key": false_ish}).as_bool("key") is False +class TestSSHConfig(object): + def setup(self): + self.config = load_config("robey") + def test_init(self): + # No args! + with raises(TypeError): + SSHConfig("uh oh!") + # No args. + assert not SSHConfig()._config -@pytest.mark.parametrize("int_val", ("42", 42)) -def test_SSHConfigDict_as_int(int_val): - assert config.SSHConfigDict({"key": int_val}).as_int("key") == 42 + def test_from_text(self): + config = SSHConfig.from_text("User foo") + assert config.lookup("foo.example.com")["user"] == "foo" + def test_from_file(self): + with open(_config("robey")) as flo: + config = SSHConfig.from_file(flo) + assert config.lookup("whatever")["user"] == "robey" -@pytest.mark.parametrize("non_int", ("not an int", None, object())) -def test_SSHConfigDict_as_int_failures(non_int): - conf = config.SSHConfigDict({"key": non_int}) + def test_from_path(self): + # NOTE: DO NOT replace with use of load_config() :D + config = SSHConfig.from_path(_config("robey")) + assert config.lookup("meh.example.com")["port"] == "3333" - try: - int(non_int) - except Exception as e: - exception_type = type(e) + def test_parse_config(self): + expected = [ + {"host": ["*"], "config": {}}, + { + "host": ["*"], + "config": {"identityfile": ["~/.ssh/id_rsa"], "user": "robey"}, + }, + { + "host": ["*.example.com"], + "config": {"user": "bjork", "port": "3333"}, + }, + {"host": ["*"], "config": {"crazy": "something dumb"}}, + { + "host": ["spoo.example.com"], + "config": {"crazy": "something else"}, + }, + ] + assert self.config._config == expected - with pytest.raises(exception_type): - conf.as_int("key") + @mark.parametrize( + "host,values", + ( + ( + "irc.danger.com", + { + "crazy": "something dumb", + "hostname": "irc.danger.com", + "user": "robey", + }, + ), + ( + "irc.example.com", + { + "crazy": "something dumb", + "hostname": "irc.example.com", + "user": "robey", + "port": "3333", + }, + ), + ( + "spoo.example.com", + { + "crazy": "something dumb", + "hostname": "spoo.example.com", + "user": "robey", + "port": "3333", + }, + ), + ), + ) + def test_host_config(self, host, values): + expected = dict( + values, hostname=host, identityfile=[expanduser("~/.ssh/id_rsa")] + ) + assert self.config.lookup(host) == expected + def test_fabric_issue_33(self): + config = SSHConfig.from_text( + """ +Host www13.* + Port 22 -def test_SSHConfig_host_dicts_are_SSHConfigDict_instances(): - test_config_file = """ Host *.example.com Port 2222 Host * Port 3333 - """ - f = StringIO(test_config_file) - config = parse_ssh_config(f) - assert config.lookup("foo.example.com").as_int("port") == 2222 +""" + ) + host = "www13.example.com" + expected = {"hostname": host, "port": "22"} + assert config.lookup(host) == expected + + def test_proxycommand_config_equals_parsing(self): + """ + ProxyCommand should not split on equals signs within the value. + """ + config = SSHConfig.from_text( + """ +Host space-delimited + ProxyCommand foo bar=biz baz + +Host equals-delimited + ProxyCommand=foo bar=biz baz +""" + ) + for host in ("space-delimited", "equals-delimited"): + value = config.lookup(host)["proxycommand"] + assert value == "foo bar=biz baz" + + def test_proxycommand_interpolation(self): + """ + ProxyCommand should perform interpolation on the value + """ + config = SSHConfig.from_text( + """ +Host specific + Port 37 + ProxyCommand host %h port %p lol + +Host portonly + Port 155 + +Host * + Port 25 + ProxyCommand host %h port %p +""" + ) + for host, val in ( + ("foo.com", "host foo.com port 25"), + ("specific", "host specific port 37 lol"), + ("portonly", "host portonly port 155"), + ): + assert config.lookup(host)["proxycommand"] == val + + def test_proxycommand_tilde_expansion(self): + """ + Tilde (~) should be expanded inside ProxyCommand + """ + config = SSHConfig.from_text( + """ +Host test + ProxyCommand ssh -F ~/.ssh/test_config bastion nc %h %p +""" + ) + expected = "ssh -F {}/.ssh/test_config bastion nc test 22".format( + expanduser("~") + ) + got = config.lookup("test")["proxycommand"] + assert got == expected + + @patch("paramiko.config.getpass") + def test_controlpath_token_expansion(self, getpass): + getpass.getuser.return_value = "gandalf" + config = SSHConfig.from_text( + """ +Host explicit_user + User root + ControlPath user %u remoteuser %r + +Host explicit_host + HostName ohai + ControlPath remoteuser %r host %h orighost %n + """ + ) + result = config.lookup("explicit_user")["controlpath"] + # Remote user is User val, local user is User val + assert result == "user gandalf remoteuser root" + result = config.lookup("explicit_host")["controlpath"] + # Remote user falls back to local user; host and orighost may differ + assert result == "remoteuser gandalf host ohai orighost explicit_host" + + def test_negation(self): + config = SSHConfig.from_text( + """ +Host www13.* !*.example.com + Port 22 + +Host *.example.com !www13.* + Port 2222 + +Host www13.* + Port 8080 + +Host * + Port 3333 +""" + ) + host = "www13.example.com" + expected = {"hostname": host, "port": "8080"} + assert config.lookup(host) == expected + + def test_proxycommand(self): + config = SSHConfig.from_text( + """ +Host proxy-with-equal-divisor-and-space +ProxyCommand = foo=bar + +Host proxy-with-equal-divisor-and-no-space +ProxyCommand=foo=bar + +Host proxy-without-equal-divisor +ProxyCommand foo=bar:%h-%p +""" + ) + for host, values in { + "proxy-with-equal-divisor-and-space": { + "hostname": "proxy-with-equal-divisor-and-space", + "proxycommand": "foo=bar", + }, + "proxy-with-equal-divisor-and-no-space": { + "hostname": "proxy-with-equal-divisor-and-no-space", + "proxycommand": "foo=bar", + }, + "proxy-without-equal-divisor": { + "hostname": "proxy-without-equal-divisor", + "proxycommand": "foo=bar:proxy-without-equal-divisor-22", + }, + }.items(): + + assert config.lookup(host) == values + + def test_identityfile(self): + config = SSHConfig.from_text( + """ + +IdentityFile id_dsa0 + +Host * +IdentityFile id_dsa1 + +Host dsa2 +IdentityFile id_dsa2 + +Host dsa2* +IdentityFile id_dsa22 +""" + ) + for host, values in { + "foo": {"hostname": "foo", "identityfile": ["id_dsa0", "id_dsa1"]}, + "dsa2": { + "hostname": "dsa2", + "identityfile": ["id_dsa0", "id_dsa1", "id_dsa2", "id_dsa22"], + }, + "dsa22": { + "hostname": "dsa22", + "identityfile": ["id_dsa0", "id_dsa1", "id_dsa22"], + }, + }.items(): + + assert config.lookup(host) == values + + def test_config_addressfamily_and_lazy_fqdn(self): + """ + Ensure the code path honoring non-'all' AddressFamily doesn't asplode + """ + config = SSHConfig.from_text( + """ +AddressFamily inet +IdentityFile something_%l_using_fqdn +""" + ) + assert config.lookup( + "meh" + ) # will die during lookup() if bug regresses + + def test_config_dos_crlf_succeeds(self): + config = SSHConfig.from_text( + """ +Host abcqwerty\r\nHostName 127.0.0.1\r\n +""" + ) + assert config.lookup("abcqwerty")["hostname"] == "127.0.0.1" + + def test_get_hostnames(self): + expected = {"*", "*.example.com", "spoo.example.com"} + assert self.config.get_hostnames() == expected + + def test_quoted_host_names(self): + config = SSHConfig.from_text( + """ +Host "param pam" param "pam" + Port 1111 + +Host "param2" + Port 2222 + +Host param3 parara + Port 3333 +Host param4 "p a r" "p" "par" para + Port 4444 +""" + ) + res = { + "param pam": {"hostname": "param pam", "port": "1111"}, + "param": {"hostname": "param", "port": "1111"}, + "pam": {"hostname": "pam", "port": "1111"}, + "param2": {"hostname": "param2", "port": "2222"}, + "param3": {"hostname": "param3", "port": "3333"}, + "parara": {"hostname": "parara", "port": "3333"}, + "param4": {"hostname": "param4", "port": "4444"}, + "p a r": {"hostname": "p a r", "port": "4444"}, + "p": {"hostname": "p", "port": "4444"}, + "par": {"hostname": "par", "port": "4444"}, + "para": {"hostname": "para", "port": "4444"}, + } + for host, values in res.items(): + assert config.lookup(host) == values -def test_SSHConfig_wildcard_host_dicts_are_SSHConfigDict_instances(): - test_config_file = """\ + def test_quoted_params_in_config(self): + config = SSHConfig.from_text( + """ +Host "param pam" param "pam" + IdentityFile id_rsa + +Host "param2" + IdentityFile "test rsa key" + +Host param3 parara + IdentityFile id_rsa + IdentityFile "test rsa key" +""" + ) + res = { + "param pam": {"hostname": "param pam", "identityfile": ["id_rsa"]}, + "param": {"hostname": "param", "identityfile": ["id_rsa"]}, + "pam": {"hostname": "pam", "identityfile": ["id_rsa"]}, + "param2": {"hostname": "param2", "identityfile": ["test rsa key"]}, + "param3": { + "hostname": "param3", + "identityfile": ["id_rsa", "test rsa key"], + }, + "parara": { + "hostname": "parara", + "identityfile": ["id_rsa", "test rsa key"], + }, + } + for host, values in res.items(): + assert config.lookup(host) == values + + def test_quoted_host_in_config(self): + conf = SSHConfig() + correct_data = { + "param": ["param"], + '"param"': ["param"], + "param pam": ["param", "pam"], + '"param" "pam"': ["param", "pam"], + '"param" pam': ["param", "pam"], + 'param "pam"': ["param", "pam"], + 'param "pam" p': ["param", "pam", "p"], + '"param" pam "p"': ["param", "pam", "p"], + '"pa ram"': ["pa ram"], + '"pa ram" pam': ["pa ram", "pam"], + 'param "p a m"': ["param", "p a m"], + } + incorrect_data = ['param"', '"param', 'param "pam', 'param "pam" "p a'] + for host, values in correct_data.items(): + assert conf._get_hosts(host) == values + for host in incorrect_data: + with raises(ConfigParseError): + conf._get_hosts(host) + + def test_invalid_line_format_excepts(self): + with raises(ConfigParseError): + load_config("invalid") + + def test_proxycommand_none_issue_418(self): + config = SSHConfig.from_text( + """ +Host proxycommand-standard-none + ProxyCommand None + +Host proxycommand-with-equals-none + ProxyCommand=None +""" + ) + for host, values in { + "proxycommand-standard-none": { + "hostname": "proxycommand-standard-none" + }, + "proxycommand-with-equals-none": { + "hostname": "proxycommand-with-equals-none" + }, + }.items(): + + assert config.lookup(host) == values + + def test_proxycommand_none_masking(self): + # Re: https://github.com/paramiko/paramiko/issues/670 + config = SSHConfig.from_text( + """ +Host specific-host + ProxyCommand none + +Host other-host + ProxyCommand other-proxy + +Host * + ProxyCommand default-proxy +""" + ) + # When bug is present, the full stripping-out of specific-host's + # ProxyCommand means it actually appears to pick up the default + # ProxyCommand value instead, due to cascading. It should (for + # backwards compatibility reasons in 1.x/2.x) appear completely blank, + # as if the host had no ProxyCommand whatsoever. + # Threw another unrelated host in there just for sanity reasons. + assert "proxycommand" not in config.lookup("specific-host") + assert config.lookup("other-host")["proxycommand"] == "other-proxy" + cmd = config.lookup("some-random-host")["proxycommand"] + assert cmd == "default-proxy" + + def test_hostname_tokenization(self): + result = load_config("hostname-tokenized").lookup("whatever") + assert result["hostname"] == "prefix.whatever" + + +class TestSSHConfigDict(object): + def test_SSHConfigDict_construct_empty(self): + assert not SSHConfigDict() + + def test_SSHConfigDict_construct_from_list(self): + assert SSHConfigDict([(1, 2)])[1] == 2 + + def test_SSHConfigDict_construct_from_dict(self): + assert SSHConfigDict({1: 2})[1] == 2 + + @mark.parametrize("true_ish", ("yes", "YES", "Yes", True)) + def test_SSHConfigDict_as_bool_true_ish(self, true_ish): + assert SSHConfigDict({"key": true_ish}).as_bool("key") is True + + @mark.parametrize("false_ish", ("no", "NO", "No", False)) + def test_SSHConfigDict_as_bool(self, false_ish): + assert SSHConfigDict({"key": false_ish}).as_bool("key") is False + + @mark.parametrize("int_val", ("42", 42)) + def test_SSHConfigDict_as_int(self, int_val): + assert SSHConfigDict({"key": int_val}).as_int("key") == 42 + + @mark.parametrize("non_int", ("not an int", None, object())) + def test_SSHConfigDict_as_int_failures(self, non_int): + conf = SSHConfigDict({"key": non_int}) + + try: + int(non_int) + except Exception as e: + exception_type = type(e) + + with raises(exception_type): + conf.as_int("key") + + def test_SSHConfig_host_dicts_are_SSHConfigDict_instances(self): + config = SSHConfig.from_text( + """ +Host *.example.com + Port 2222 + +Host * + Port 3333 +""" + ) + assert config.lookup("foo.example.com").as_int("port") == 2222 + + def test_SSHConfig_wildcard_host_dicts_are_SSHConfigDict_instances(self): + config = SSHConfig.from_text( + """ Host *.example.com Port 2222 Host * Port 3333 +""" + ) + assert config.lookup("anything-else").as_int("port") == 3333 + + +class TestHostnameCanonicalization(object): + # NOTE: this class uses on-disk configs, and ones with real (at time of + # writing) DNS names, so that one can easily test OpenSSH's behavior using + # "ssh -F path/to/file.config -G <target>". + + def test_off_by_default(self, socket): + result = load_config("basic").lookup("www") + assert result["hostname"] == "www" + assert "user" not in result + assert not socket.gethostbyname.called + + def test_explicit_no_same_as_default(self, socket): + result = load_config("no-canon").lookup("www") + assert result["hostname"] == "www" + assert "user" not in result + assert not socket.gethostbyname.called + + @mark.parametrize( + "config_name", + ("canon", "canon-always", "canon-local", "canon-local-always"), + ) + def test_canonicalization_base_cases(self, socket, config_name): + result = load_config(config_name).lookup("www") + assert result["hostname"] == "www.paramiko.org" + assert result["user"] == "rando" + socket.gethostbyname.assert_called_once_with("www.paramiko.org") + + def test_uses_getaddrinfo_when_AddressFamily_given(self, socket): + # Undo default 'always fails' mock + socket.getaddrinfo.side_effect = None + socket.getaddrinfo.return_value = [True] # just need 1st value truthy + result = load_config("canon-ipv4").lookup("www") + assert result["hostname"] == "www.paramiko.org" + assert result["user"] == "rando" + assert not socket.gethostbyname.called + gai_args = socket.getaddrinfo.call_args[0] + assert gai_args[0] == "www.paramiko.org" + assert gai_args[2] is socket.AF_INET # Mocked, but, still useful + + @mark.skip + def test_empty_CanonicalDomains_canonicalizes_despite_noop(self, socket): + # Confirmed this is how OpenSSH behaves as well. Bit silly, but. + # TODO: this requires modifying SETTINGS_REGEX, which is a mite scary + # (honestly I'd prefer to move to a real parser lib anyhow) and since + # this is a very dumb corner case, it's marked skip for now. + result = load_config("empty-canon").lookup("www") + assert result["hostname"] == "www" # no paramiko.org + assert "user" not in result # did not discover canonicalized block + + def test_CanonicalDomains_may_be_set_to_space_separated_list(self, socket): + # Test config has a bogus domain, followed by paramiko.org + socket.gethostbyname.side_effect = [socket.gaierror, True] + result = load_config("multi-canon-domains").lookup("www") + assert result["hostname"] == "www.paramiko.org" + assert result["user"] == "rando" + assert [x[0][0] for x in socket.gethostbyname.call_args_list] == [ + "www.not-a-real-tld", + "www.paramiko.org", + ] + + def test_canonicalization_applies_to_single_dot_by_default(self, socket): + result = load_config("deep-canon").lookup("sub.www") + assert result["hostname"] == "sub.www.paramiko.org" + assert result["user"] == "deep" + + def test_canonicalization_not_applied_to_two_dots_by_default(self, socket): + result = load_config("deep-canon").lookup("subber.sub.www") + assert result["hostname"] == "subber.sub.www" + assert "user" not in result + + def test_hostname_depth_controllable_with_max_dots_directive(self, socket): + # This config sets MaxDots of 2, so now canonicalization occurs + result = load_config("deep-canon-maxdots").lookup("subber.sub.www") + assert result["hostname"] == "subber.sub.www.paramiko.org" + assert result["user"] == "deeper" + + def test_max_dots_may_be_zero(self, socket): + result = load_config("zero-maxdots").lookup("sub.www") + assert result["hostname"] == "sub.www" + assert "user" not in result + + def test_fallback_yes_does_not_canonicalize_or_error(self, socket): + socket.gethostbyname.side_effect = socket.gaierror + result = load_config("fallback-yes").lookup("www") + assert result["hostname"] == "www" + assert "user" not in result + + def test_fallback_no_causes_errors_for_unresolvable_names(self, socket): + socket.gethostbyname.side_effect = socket.gaierror + with raises(CouldNotCanonicalize) as info: + load_config("fallback-no").lookup("doesnotexist") + assert str(info.value) == "doesnotexist" + + def test_identityfile_continues_being_appended_to(self, socket): + result = load_config("canon").lookup("www") + assert result["identityfile"] == ["base.key", "canonicalized.key"] + + +@mark.skip +class TestCanonicalizationOfCNAMEs(object): + def test_permitted_cnames_may_be_one_to_one_mapping(self): + # CanonicalizePermittedCNAMEs *.foo.com:*.bar.com + pass + + def test_permitted_cnames_may_be_one_to_many_mapping(self): + # CanonicalizePermittedCNAMEs *.foo.com:*.bar.com,*.biz.com + pass + + def test_permitted_cnames_may_be_many_to_one_mapping(self): + # CanonicalizePermittedCNAMEs *.foo.com,*.bar.com:*.biz.com + pass + + def test_permitted_cnames_may_be_many_to_many_mapping(self): + # CanonicalizePermittedCNAMEs *.foo.com,*.bar.com:*.biz.com,*.baz.com + pass + + def test_permitted_cnames_may_be_multiple_mappings(self): + # CanonicalizePermittedCNAMEs *.foo.com,*.bar.com *.biz.com:*.baz.com + pass + + def test_permitted_cnames_may_be_multiple_complex_mappings(self): + # Same as prev but with multiple patterns on both ends in both args + pass + + +class TestMatchAll(object): + def test_always_matches(self): + result = load_config("match-all").lookup("general") + assert result["user"] == "awesome" + + def test_may_not_mix_with_non_canonical_keywords(self): + for config in ("match-all-and-more", "match-all-and-more-before"): + with raises(ConfigParseError): + load_config(config).lookup("whatever") + + def test_may_come_after_canonical(self, socket): + result = load_config("match-all-after-canonical").lookup("www") + assert result["user"] == "awesome" + + def test_may_not_come_before_canonical(self, socket): + with raises(ConfigParseError): + load_config("match-all-before-canonical") + + def test_after_canonical_not_loaded_when_non_canonicalized(self, socket): + result = load_config("match-canonical-no").lookup("a-host") + assert "user" not in result + + +def _expect(success_on): + """ + Returns a side_effect-friendly Invoke success result for given command(s). + + Ensures that any other commands fail; this is useful for testing 'Match + exec' because it means all other such clauses under test act like no-ops. + + :param success_on: + Single string or list of strings, noting commands that should appear to + succeed. """ - f = StringIO(test_config_file) - config = parse_ssh_config(f) - assert config.lookup("anything-else").as_int("port") == 3333 + if isinstance(success_on, string_types): + success_on = [success_on] + + def inner(command, *args, **kwargs): + # Sanity checking - we always expect that invoke.run is called with + # these. + assert kwargs.get("hide", None) == "stdout" + assert kwargs.get("warn", None) is True + # Fake exit + exit = 0 if command in success_on else 1 + return Result(exited=exit) + + return inner + + +class TestMatchExec(object): + @patch("paramiko.config.invoke", new=None) + @patch("paramiko.config.invoke_import_error", new=ImportError("meh")) + def test_raises_invoke_ImportErrors_at_runtime(self): + # Not an ideal test, but I don't know of a non-bad way to fake out + # module-time ImportErrors. So we mock the symptoms. Meh! + with raises(ImportError) as info: + load_config("match-exec").lookup("oh-noes") + assert str(info.value) == "meh" + + @patch("paramiko.config.invoke.run") + @mark.parametrize( + "cmd,user", + [ + ("unquoted", "rando"), + ("quoted", "benjamin"), + ("quoted spaced", "neil"), + ], + ) + def test_accepts_single_possibly_quoted_argument(self, run, cmd, user): + run.side_effect = _expect(cmd) + result = load_config("match-exec").lookup("whatever") + assert result["user"] == user + + @patch("paramiko.config.invoke.run") + def test_does_not_match_nonzero_exit_codes(self, run): + # Nothing will succeed -> no User ever gets loaded + run.return_value = Result(exited=1) + result = load_config("match-exec").lookup("whatever") + assert "user" not in result + + @patch("paramiko.config.getpass") + @patch("paramiko.config.invoke.run") + def test_tokenizes_argument(self, run, getpass, socket): + socket.gethostname.return_value = "local.fqdn" + getpass.getuser.return_value = "gandalf" + # Actual exec value is "%d %h %L %l %n %p %r %u" + parts = ( + expanduser("~"), + "configured", + "local", + "some.fake.fqdn", + "target", + "22", + "intermediate", + "gandalf", + ) + run.side_effect = _expect(" ".join(parts)) + result = load_config("match-exec").lookup("target") + assert result["port"] == "1337" + + @patch("paramiko.config.invoke.run") + def test_works_with_canonical(self, run, socket): + # Ensure both stanzas' exec components appear to match + run.side_effect = _expect(["uncanonicalized", "canonicalized"]) + result = load_config("match-exec-canonical").lookup("who-cares") + # Prove both config values got loaded up, across the two passes + assert result["user"] == "defenseless" + assert result["port"] == "8007" + + @patch("paramiko.config.invoke.run") + def test_may_be_negated(self, run): + run.side_effect = _expect("this succeeds") + result = load_config("match-exec-negation").lookup("so-confusing") + # If negation did not work, the first of the two Match exec directives + # would have set User to 'nope' (and/or the second would have NOT set + # User to 'yup') + assert result["user"] == "yup" + + def test_requires_an_argument(self): + with raises(ConfigParseError): + load_config("match-exec-no-arg") + + @patch("paramiko.config.invoke.run") + def test_works_with_tokenized_hostname(self, run): + run.side_effect = _expect("ping target") + result = load_config("hostname-exec-tokenized").lookup("target") + assert result["hostname"] == "pingable.target" + + +class TestMatchHost(object): + def test_matches_target_name_when_no_hostname(self): + result = load_config("match-host").lookup("target") + assert result["user"] == "rand" + + def test_matches_hostname_from_global_setting(self): + # Also works for ones set in regular Host stanzas + result = load_config("match-host-name").lookup("anything") + assert result["user"] == "silly" + + def test_matches_hostname_from_earlier_match(self): + # Corner case: one Match matches original host, sets HostName, + # subsequent Match matches the latter. + result = load_config("match-host-from-match").lookup("original-host") + assert result["user"] == "inner" + + def test_may_be_globbed(self): + result = load_config("match-host-glob-list").lookup("whatever") + assert result["user"] == "matrim" + + def test_may_be_comma_separated_list(self): + for target in ("somehost", "someotherhost"): + result = load_config("match-host-glob-list").lookup(target) + assert result["user"] == "thom" + + def test_comma_separated_list_may_have_internal_negation(self): + conf = load_config("match-host-glob-list") + assert conf.lookup("good")["user"] == "perrin" + assert "user" not in conf.lookup("goof") + + def test_matches_canonicalized_name(self, socket): + # Without 'canonical' explicitly declared, mind. + result = load_config("match-host-canonicalized").lookup("www") + assert result["user"] == "rand" + + def test_works_with_canonical_keyword(self, socket): + # NOTE: distinct from 'happens to be canonicalized' above + result = load_config("match-host-canonicalized").lookup("docs") + assert result["user"] == "eric" + + def test_may_be_negated(self): + conf = load_config("match-host-negated") + assert conf.lookup("docs")["user"] == "jeff" + assert "user" not in conf.lookup("www") + + def test_requires_an_argument(self): + with raises(ConfigParseError): + load_config("match-host-no-arg") + + +class TestMatchOriginalHost(object): + def test_matches_target_host_not_hostname(self): + result = load_config("match-orighost").lookup("target") + assert result["hostname"] == "bogus" + assert result["user"] == "tuon" + + def test_matches_target_host_not_canonicalized_name(self, socket): + result = load_config("match-orighost-canonical").lookup("www") + assert result["hostname"] == "www.paramiko.org" + assert result["user"] == "tuon" + + def test_may_be_globbed(self): + result = load_config("match-orighost").lookup("whatever") + assert result["user"] == "matrim" + + def test_may_be_comma_separated_list(self): + for target in ("comma", "separated"): + result = load_config("match-orighost").lookup(target) + assert result["user"] == "chameleon" + + def test_comma_separated_list_may_have_internal_negation(self): + result = load_config("match-orighost").lookup("nope") + assert "user" not in result + + def test_may_be_negated(self): + result = load_config("match-orighost").lookup("docs") + assert result["user"] == "thom" + + def test_requires_an_argument(self): + with raises(ConfigParseError): + load_config("match-orighost-no-arg") + + +class TestMatchUser(object): + def test_matches_configured_username(self): + result = load_config("match-user-explicit").lookup("anything") + assert result["hostname"] == "dumb" + + @patch("paramiko.config.getpass.getuser") + def test_matches_local_username_by_default(self, getuser): + getuser.return_value = "gandalf" + result = load_config("match-user").lookup("anything") + assert result["hostname"] == "gondor" + + @patch("paramiko.config.getpass.getuser") + def test_may_be_globbed(self, getuser): + for user in ("bilbo", "bombadil"): + getuser.return_value = user + result = load_config("match-user").lookup("anything") + assert result["hostname"] == "shire" + + @patch("paramiko.config.getpass.getuser") + def test_may_be_comma_separated_list(self, getuser): + for user in ("aragorn", "frodo"): + getuser.return_value = user + result = load_config("match-user").lookup("anything") + assert result["hostname"] == "moria" + + @patch("paramiko.config.getpass.getuser") + def test_comma_separated_list_may_have_internal_negation(self, getuser): + getuser.return_value = "legolas" + result = load_config("match-user").lookup("anything") + assert "port" not in result + getuser.return_value = "gimli" + result = load_config("match-user").lookup("anything") + assert result["port"] == "7373" + + @patch("paramiko.config.getpass.getuser") + def test_may_be_negated(self, getuser): + getuser.return_value = "saruman" + result = load_config("match-user").lookup("anything") + assert result["hostname"] == "mordor" + + def test_requires_an_argument(self): + with raises(ConfigParseError): + load_config("match-user-no-arg") + + +# NOTE: highly derivative of previous suite due to the former's use of +# localuser fallback. Doesn't seem worth conflating/refactoring right now. +class TestMatchLocalUser(object): + @patch("paramiko.config.getpass.getuser") + def test_matches_local_username(self, getuser): + getuser.return_value = "gandalf" + result = load_config("match-localuser").lookup("anything") + assert result["hostname"] == "gondor" + + @patch("paramiko.config.getpass.getuser") + def test_may_be_globbed(self, getuser): + for user in ("bilbo", "bombadil"): + getuser.return_value = user + result = load_config("match-localuser").lookup("anything") + assert result["hostname"] == "shire" + + @patch("paramiko.config.getpass.getuser") + def test_may_be_comma_separated_list(self, getuser): + for user in ("aragorn", "frodo"): + getuser.return_value = user + result = load_config("match-localuser").lookup("anything") + assert result["hostname"] == "moria" + + @patch("paramiko.config.getpass.getuser") + def test_comma_separated_list_may_have_internal_negation(self, getuser): + getuser.return_value = "legolas" + result = load_config("match-localuser").lookup("anything") + assert "port" not in result + getuser.return_value = "gimli" + result = load_config("match-localuser").lookup("anything") + assert result["port"] == "7373" + + @patch("paramiko.config.getpass.getuser") + def test_may_be_negated(self, getuser): + getuser.return_value = "saruman" + result = load_config("match-localuser").lookup("anything") + assert result["hostname"] == "mordor" + + def test_requires_an_argument(self): + with raises(ConfigParseError): + load_config("match-localuser-no-arg") + + +class TestComplexMatching(object): + # NOTE: this is still a cherry-pick of a few levels of complexity, there's + # no point testing literally all possible combinations. + + def test_originalhost_host(self): + result = load_config("match-complex").lookup("target") + assert result["hostname"] == "bogus" + assert result["user"] == "rand" + + @patch("paramiko.config.getpass.getuser") + def test_originalhost_localuser(self, getuser): + getuser.return_value = "rando" + result = load_config("match-complex").lookup("remote") + assert result["user"] == "calrissian" + + @patch("paramiko.config.getpass.getuser") + def test_everything_but_all(self, getuser): + getuser.return_value = "rando" + result = load_config("match-complex").lookup("www") + assert result["port"] == "7777" + + @patch("paramiko.config.getpass.getuser") + def test_everything_but_all_with_some_negated(self, getuser): + getuser.return_value = "rando" + result = load_config("match-complex").lookup("docs") + assert result["port"] == "1234" + + def test_negated_canonical(self, socket): + # !canonical in a config that is not canonicalized - does match + result = load_config("match-canonical-no").lookup("specific") + assert result["user"] == "overload" + # !canonical in a config that is canonicalized - does NOT match + result = load_config("match-canonical-yes").lookup("www") + assert result["user"] == "hidden" diff --git a/tests/test_ed25519-funky-padding.key b/tests/test_ed25519-funky-padding.key new file mode 100644 index 00000000..f178ca45 --- /dev/null +++ b/tests/test_ed25519-funky-padding.key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAHzPvYoDSkMVX52/CbA2M2aSBS7R0wt/9b2n5n+osNygAAAJAHZ1meB2dZ +ngAAAAtzc2gtZWQyNTUxOQAAACAHzPvYoDSkMVX52/CbA2M2aSBS7R0wt/9b2n5n+osNyg +AAAEAIyamvYUpzCovQuUtLhz+fwE4qYQo+rTuUVIX4fmTzMAfM+9igNKQxVfnb8JsDYzZp +IFLtHTC3/1vafmf6iw3KAAAADW15IGNvbW1lbnQgaXM= +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/test_ed25519-funky-padding_password.key b/tests/test_ed25519-funky-padding_password.key new file mode 100644 index 00000000..1b135d69 --- /dev/null +++ b/tests/test_ed25519-funky-padding_password.key @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDo3dGRlE +xKndv32nDnz2mHAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIDcAVH8yDxoiqj0O +rX3YTRMsnvJr+XdKJW16YQpxx8UvAAAAoI78IY+u8lYOzxAEO2N8qEVQH8b/m27yQhcSbK +q1RvvuHmql3NoQvjYQe9/om4oqE+uesNRnoQGNplBHCeroD3ZcksXhLGDhwTh577NR+NQ+ +GNYAK5Ex7Va3Xgao5HUYtBQXlXbtzY1Q+71hcOlRVNnLUDvwShdCa9o6ETIOGcZl04fbzv +Z3vC1C68G3+JMNFenAGYU+iQq0XENtpT6xAIU= +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/test_file.py b/tests/test_file.py index fba14b1b..2a3da74b 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -52,7 +52,7 @@ class LoopbackFile(BufferedFile): class BufferedFileTest(unittest.TestCase): - def test_1_simple(self): + def test_simple(self): f = LoopbackFile("r") try: f.write(b"hi") @@ -69,7 +69,7 @@ class BufferedFileTest(unittest.TestCase): pass f.close() - def test_2_readline(self): + def test_readline(self): f = LoopbackFile("r+U") f.write( b"First line.\nSecond line.\r\nThird line.\n" @@ -96,7 +96,7 @@ class BufferedFileTest(unittest.TestCase): self.assertTrue(crlf in f.newlines) self.assertTrue(cr_byte not in f.newlines) - def test_3_lf(self): + def test_lf(self): """ try to trick the linefeed detector. """ @@ -108,7 +108,7 @@ class BufferedFileTest(unittest.TestCase): f.close() self.assertEqual(f.newlines, crlf) - def test_4_write(self): + def test_write(self): """ verify that write buffering is on. """ @@ -120,7 +120,7 @@ class BufferedFileTest(unittest.TestCase): self.assertEqual(f.readline(), "Incomplete line...\n") f.close() - def test_5_flush(self): + def test_flush(self): """ verify that flush will force a write. """ @@ -134,7 +134,7 @@ class BufferedFileTest(unittest.TestCase): self.assertEqual(f.read(3), b"") f.close() - def test_6_buffering(self): + def test_buffering_flushes(self): """ verify that flushing happens automatically on buffer crossing. """ @@ -147,7 +147,7 @@ class BufferedFileTest(unittest.TestCase): self.assertEqual(f.read(20), b"Too small. Enough.") f.close() - def test_7_read_all(self): + def test_read_all(self): """ verify that read(-1) returns everything left in the file. """ @@ -162,30 +162,30 @@ class BufferedFileTest(unittest.TestCase): ) f.close() - def test_8_buffering(self): + def test_buffering_writes(self): """ verify that buffered objects can be written """ if sys.version_info[0] == 2: f = LoopbackFile("r+", 16) - f.write(buffer(b"Too small.")) + f.write(buffer(b"Too small.")) # noqa f.close() - def test_9_readable(self): + def test_readable(self): f = LoopbackFile("r") self.assertTrue(f.readable()) self.assertFalse(f.writable()) self.assertFalse(f.seekable()) f.close() - def test_A_writable(self): + def test_writable(self): f = LoopbackFile("w") self.assertTrue(f.writable()) self.assertFalse(f.readable()) self.assertFalse(f.seekable()) f.close() - def test_B_readinto(self): + def test_readinto(self): data = bytearray(5) f = LoopbackFile("r+") f._write(b"hello") @@ -215,7 +215,7 @@ class BufferedFileTest(unittest.TestCase): offsets = range(0, len(data), 8) with LoopbackFile("rb+") as f: for offset in offsets: - f.write(buffer(data, offset, 8)) + f.write(buffer(data, offset, 8)) # noqa self.assertEqual(f.read(), data) @needs_builtin("memoryview") diff --git a/tests/test_gssapi.py b/tests/test_gssapi.py index 3e8c39e8..30ffb56d 100644 --- a/tests/test_gssapi.py +++ b/tests/test_gssapi.py @@ -22,22 +22,23 @@ 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(): +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_1_pyasn1(self): + def test_pyasn1(self): """ Test the used methods of pyasn1. """ @@ -48,13 +49,20 @@ class GSSAPITest(unittest.TestCase): mech, __ = decoder.decode(oid) self.assertEquals(self.krb5_mech, mech.__str__()) - def test_2_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_hostkeys.py b/tests/test_hostkeys.py index 295153dd..da47362c 100644 --- a/tests/test_hostkeys.py +++ b/tests/test_hostkeys.py @@ -62,7 +62,7 @@ class HostKeysTest(unittest.TestCase): def tearDown(self): os.unlink("hostfile.temp") - def test_1_load(self): + def test_load(self): hostdict = paramiko.HostKeys("hostfile.temp") self.assertEqual(2, len(hostdict)) self.assertEqual(1, len(list(hostdict.values())[0])) @@ -72,7 +72,7 @@ class HostKeysTest(unittest.TestCase): ).upper() self.assertEqual(b"E6684DB30E109B67B70FF1DC5C7F1363", fp) - def test_2_add(self): + def test_add(self): hostdict = paramiko.HostKeys("hostfile.temp") hh = "|1|BMsIC6cUIP2zBuXR3t2LRcJYjzM=|hpkJMysjTk/+zzUUzxQEa2ieq6c=" key = paramiko.RSAKey(data=decodebytes(keyblob)) @@ -83,7 +83,7 @@ class HostKeysTest(unittest.TestCase): self.assertEqual(b"7EC91BB336CB6D810B124B1353C32396", fp) self.assertTrue(hostdict.check("foo.example.com", key)) - def test_3_dict(self): + def test_dict(self): hostdict = paramiko.HostKeys("hostfile.temp") self.assertTrue("secure.example.com" in hostdict) self.assertTrue("not.example.com" not in hostdict) @@ -98,7 +98,7 @@ class HostKeysTest(unittest.TestCase): i += 1 self.assertEqual(2, i) - def test_4_dict_set(self): + def test_dict_set(self): hostdict = paramiko.HostKeys("hostfile.temp") key = paramiko.RSAKey(data=decodebytes(keyblob)) key_dss = paramiko.DSSKey(data=decodebytes(keyblob_dss)) @@ -122,10 +122,10 @@ class HostKeysTest(unittest.TestCase): def test_delitem(self): hostdict = paramiko.HostKeys("hostfile.temp") target = "happy.example.com" - entry = hostdict[target] # will KeyError if not present + hostdict[target] # will KeyError if not present del hostdict[target] try: - entry = hostdict[target] + hostdict[target] except KeyError: pass # Good else: diff --git a/tests/test_kex.py b/tests/test_kex.py index 62512beb..0244ae84 100644 --- a/tests/test_kex.py +++ b/tests/test_kex.py @@ -24,15 +24,26 @@ from binascii import hexlify, unhexlify import os import unittest +from mock import Mock, patch +import pytest + from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec +try: + from cryptography.hazmat.primitives.asymmetric import x25519 +except ImportError: + x25519 = None + import paramiko.util from paramiko.kex_group1 import KexGroup1 +from paramiko.kex_group14 import KexGroup14SHA256 from paramiko.kex_gex import KexGex, KexGexSHA256 from paramiko import Message from paramiko.common import byte_chr from paramiko.kex_ecdh_nist import KexNistp256 +from paramiko.kex_group16 import KexGroup16SHA512 +from paramiko.kex_curve25519 import KexCurve25519 def dummy_urandom(n): @@ -40,22 +51,22 @@ def dummy_urandom(n): def dummy_generate_key_pair(obj): - private_key_value = 94761803665136558137557783047955027733968423115106677159790289642479432803037 - public_key_numbers = "042bdab212fa8ba1b7c843301682a4db424d307246c7e1e6083c41d9ca7b098bf30b3d63e2ec6278488c135360456cc054b3444ecc45998c08894cbc1370f5f989" - public_key_numbers_obj = ec.EllipticCurvePublicNumbers.from_encoded_point( + private_key_value = 94761803665136558137557783047955027733968423115106677159790289642479432803037 # noqa + public_key_numbers = "042bdab212fa8ba1b7c843301682a4db424d307246c7e1e6083c41d9ca7b098bf30b3d63e2ec6278488c135360456cc054b3444ecc45998c08894cbc1370f5f989" # noqa + public_key_numbers_obj = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), unhexlify(public_key_numbers) - ) + ).public_numbers() obj.P = ec.EllipticCurvePrivateNumbers( private_value=private_key_value, public_numbers=public_key_numbers_obj ).private_key(default_backend()) if obj.transport.server_mode: - obj.Q_S = ec.EllipticCurvePublicNumbers.from_encoded_point( + obj.Q_S = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), unhexlify(public_key_numbers) - ).public_key(default_backend()) + ) return - obj.Q_C = ec.EllipticCurvePublicNumbers.from_encoded_point( + obj.Q_C = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), unhexlify(public_key_numbers) - ).public_key(default_backend()) + ) class FakeKey(object): @@ -70,7 +81,7 @@ class FakeKey(object): class FakeModulusPack(object): - P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF + P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF # noqa G = 2 def get_modulus(self, min, ask, max): @@ -111,7 +122,7 @@ class FakeTransport(object): class KexTest(unittest.TestCase): - K = 14730343317708716439807310032871972459448364195094179797249681733965528989482751523943515690110179031004049109375612685505881911274101441415545039654102474376472240501616988799699744135291070488314748284283496055223852115360852283821334858541043710301057312858051901453919067023103730011648890038847384890504 + K = 14730343317708716439807310032871972459448364195094179797249681733965528989482751523943515690110179031004049109375612685505881911274101441415545039654102474376472240501616988799699744135291070488314748284283496055223852115360852283821334858541043710301057312858051901453919067023103730011648890038847384890504 # noqa def setUp(self): self._original_urandom = os.urandom @@ -119,16 +130,32 @@ class KexTest(unittest.TestCase): self._original_generate_key_pair = KexNistp256._generate_key_pair KexNistp256._generate_key_pair = dummy_generate_key_pair + if KexCurve25519.is_available(): + static_x25519_key = x25519.X25519PrivateKey.from_private_bytes( + unhexlify( + b"2184abc7eb3e656d2349d2470ee695b570c227340c2b2863b6c9ff427af1f040" # noqa + ) + ) + mock_x25519 = Mock() + mock_x25519.generate.return_value = static_x25519_key + patcher = patch( + "paramiko.kex_curve25519.X25519PrivateKey", mock_x25519 + ) + patcher.start() + self.x25519_patcher = patcher + def tearDown(self): os.urandom = self._original_urandom KexNistp256._generate_key_pair = self._original_generate_key_pair + if hasattr(self, "x25519_patcher"): + self.x25519_patcher.stop() - def test_1_group1_client(self): + def test_group1_client(self): transport = FakeTransport() transport.server_mode = False kex = KexGroup1(transport) kex.start_kex() - x = b"1E000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" + x = b"1E000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_group1._MSG_KEXDH_REPLY,), transport._expect @@ -147,7 +174,7 @@ class KexTest(unittest.TestCase): self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) self.assertTrue(transport._activated) - def test_2_group1_server(self): + def test_group1_server(self): transport = FakeTransport() transport.server_mode = True kex = KexGroup1(transport) @@ -161,13 +188,13 @@ class KexTest(unittest.TestCase): msg.rewind() kex.parse_next(paramiko.kex_group1._MSG_KEXDH_INIT, msg) H = b"B16BF34DD10945EDE84E9C1EF24A14BFDC843389" - x = b"1F0000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" + x = b"1F0000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" # noqa self.assertEqual(self.K, transport._K) self.assertEqual(H, hexlify(transport._H).upper()) self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertTrue(transport._activated) - def test_3_gex_client(self): + def test_gex_client(self): transport = FakeTransport() transport.server_mode = False kex = KexGex(transport) @@ -183,7 +210,7 @@ class KexTest(unittest.TestCase): msg.add_mpint(FakeModulusPack.G) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_GROUP, msg) - x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" + x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_REPLY,), transport._expect @@ -201,7 +228,7 @@ class KexTest(unittest.TestCase): self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) self.assertTrue(transport._activated) - def test_4_gex_old_client(self): + def test_gex_old_client(self): transport = FakeTransport() transport.server_mode = False kex = KexGex(transport) @@ -217,7 +244,7 @@ class KexTest(unittest.TestCase): msg.add_mpint(FakeModulusPack.G) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_GROUP, msg) - x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" + x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_REPLY,), transport._expect @@ -235,7 +262,7 @@ class KexTest(unittest.TestCase): self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) self.assertTrue(transport._activated) - def test_5_gex_server(self): + def test_gex_server(self): transport = FakeTransport() transport.server_mode = True kex = KexGex(transport) @@ -254,7 +281,7 @@ class KexTest(unittest.TestCase): msg.add_int(4096) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST, msg) - x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" + x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_INIT,), transport._expect @@ -264,15 +291,15 @@ class KexTest(unittest.TestCase): msg.add_mpint(12345) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_INIT, msg) - K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 + K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 # noqa H = b"CE754197C21BF3452863B4F44D0B3951F12516EF" - x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" + x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" # noqa self.assertEqual(K, transport._K) self.assertEqual(H, hexlify(transport._H).upper()) self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertTrue(transport._activated) - def test_6_gex_server_with_old_client(self): + def test_gex_server_with_old_client(self): transport = FakeTransport() transport.server_mode = True kex = KexGex(transport) @@ -289,7 +316,7 @@ class KexTest(unittest.TestCase): msg.add_int(2048) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST_OLD, msg) - x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" + x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_INIT,), transport._expect @@ -299,15 +326,15 @@ class KexTest(unittest.TestCase): msg.add_mpint(12345) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_INIT, msg) - K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 + K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 # noqa H = b"B41A06B2E59043CEFC1AE16EC31F1E2D12EC455B" - x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" + x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" # noqa self.assertEqual(K, transport._K) self.assertEqual(H, hexlify(transport._H).upper()) self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertTrue(transport._activated) - def test_7_gex_sha256_client(self): + def test_gex_sha256_client(self): transport = FakeTransport() transport.server_mode = False kex = KexGexSHA256(transport) @@ -323,7 +350,7 @@ class KexTest(unittest.TestCase): msg.add_mpint(FakeModulusPack.G) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_GROUP, msg) - x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" + x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_REPLY,), transport._expect @@ -341,7 +368,7 @@ class KexTest(unittest.TestCase): self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) self.assertTrue(transport._activated) - def test_8_gex_sha256_old_client(self): + def test_gex_sha256_old_client(self): transport = FakeTransport() transport.server_mode = False kex = KexGexSHA256(transport) @@ -357,7 +384,7 @@ class KexTest(unittest.TestCase): msg.add_mpint(FakeModulusPack.G) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_GROUP, msg) - x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" + x = b"20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_REPLY,), transport._expect @@ -375,7 +402,7 @@ class KexTest(unittest.TestCase): self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) self.assertTrue(transport._activated) - def test_9_gex_sha256_server(self): + def test_gex_sha256_server(self): transport = FakeTransport() transport.server_mode = True kex = KexGexSHA256(transport) @@ -394,7 +421,7 @@ class KexTest(unittest.TestCase): msg.add_int(4096) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST, msg) - x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" + x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_INIT,), transport._expect @@ -404,15 +431,15 @@ class KexTest(unittest.TestCase): msg.add_mpint(12345) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_INIT, msg) - K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 + K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 # noqa H = b"CCAC0497CF0ABA1DBF55E1A3995D17F4CC31824B0E8D95CDF8A06F169D050D80" - x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" + x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" # noqa self.assertEqual(K, transport._K) self.assertEqual(H, hexlify(transport._H).upper()) self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertTrue(transport._activated) - def test_10_gex_sha256_server_with_old_client(self): + def test_gex_sha256_server_with_old_client(self): transport = FakeTransport() transport.server_mode = True kex = KexGexSHA256(transport) @@ -429,7 +456,7 @@ class KexTest(unittest.TestCase): msg.add_int(2048) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST_OLD, msg) - x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" + x = b"1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102" # noqa self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertEqual( (paramiko.kex_gex._MSG_KEXDH_GEX_INIT,), transport._expect @@ -439,16 +466,16 @@ class KexTest(unittest.TestCase): msg.add_mpint(12345) msg.rewind() kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_INIT, msg) - K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 + K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 # noqa H = b"3DDD2AD840AD095E397BA4D0573972DC60F6461FD38A187CACA6615A5BC8ADBB" - x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" + x = b"210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967" # noqa self.assertEqual(K, transport._K) self.assertEqual(H, hexlify(transport._H).upper()) self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertTrue(transport._activated) - def test_11_kex_nistp256_client(self): - K = 91610929826364598472338906427792435253694642563583721654249504912114314269754 + def test_kex_nistp256_client(self): + K = 91610929826364598472338906427792435253694642563583721654249504912114314269754 # noqa transport = FakeTransport() transport.server_mode = False kex = KexNistp256(transport) @@ -461,7 +488,7 @@ class KexTest(unittest.TestCase): msg = Message() msg.add_string("fake-host-key") Q_S = unhexlify( - "043ae159594ba062efa121480e9ef136203fa9ec6b6e1f8723a321c16e62b945f573f3b822258cbcd094b9fa1c125cbfe5f043280893e66863cc0cb4dccbe70210" + "043ae159594ba062efa121480e9ef136203fa9ec6b6e1f8723a321c16e62b945f573f3b822258cbcd094b9fa1c125cbfe5f043280893e66863cc0cb4dccbe70210" # noqa ) msg.add_string(Q_S) msg.add_string("fake-sig") @@ -473,8 +500,8 @@ class KexTest(unittest.TestCase): self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) self.assertTrue(transport._activated) - def test_12_kex_nistp256_server(self): - K = 91610929826364598472338906427792435253694642563583721654249504912114314269754 + def test_kex_nistp256_server(self): + K = 91610929826364598472338906427792435253694642563583721654249504912114314269754 # noqa transport = FakeTransport() transport.server_mode = True kex = KexNistp256(transport) @@ -486,7 +513,7 @@ class KexTest(unittest.TestCase): # fake init msg = Message() Q_C = unhexlify( - "043ae159594ba062efa121480e9ef136203fa9ec6b6e1f8723a321c16e62b945f573f3b822258cbcd094b9fa1c125cbfe5f043280893e66863cc0cb4dccbe70210" + "043ae159594ba062efa121480e9ef136203fa9ec6b6e1f8723a321c16e62b945f573f3b822258cbcd094b9fa1c125cbfe5f043280893e66863cc0cb4dccbe70210" # noqa ) H = b"2EF4957AFD530DD3F05DBEABF68D724FACC060974DA9704F2AEE4C3DE861E7CA" msg.add_string(Q_C) @@ -495,3 +522,104 @@ class KexTest(unittest.TestCase): self.assertEqual(K, transport._K) self.assertTrue(transport._activated) self.assertEqual(H, hexlify(transport._H).upper()) + + def test_kex_group14_sha256_client(self): + transport = FakeTransport() + transport.server_mode = False + kex = KexGroup14SHA256(transport) + kex.start_kex() + x = b"1E00000101009850B3A8DE3ECCD3F19644139137C93D9C11BC28ED8BE850908EE294E1D43B88B9295311EFAEF5B736A1B652EBE184CCF36CFB0681C1ED66430088FA448B83619F928E7B9592ED6160EC11D639D51C303603F930F743C646B1B67DA38A1D44598DCE6C3F3019422B898044141420E9A10C29B9C58668F7F20A40F154B2C4768FCF7A9AA7179FB6366A7167EE26DD58963E8B880A0572F641DE0A73DC74C930F7C3A0C9388553F3F8403E40CF8B95FEDB1D366596FCF3FDDEB21A0005ADA650EF1733628D807BE5ACB83925462765D9076570056E39994FB328E3108FE406275758D6BF5F32790EF15D8416BF5548164859E785DB45E7787BB0E727ADE08641ED" # noqa + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual( + (paramiko.kex_group1._MSG_KEXDH_REPLY,), transport._expect + ) + + # fake "reply" + msg = Message() + msg.add_string("fake-host-key") + msg.add_mpint(69) + msg.add_string("fake-sig") + msg.rewind() + kex.parse_next(paramiko.kex_group1._MSG_KEXDH_REPLY, msg) + K = 21526936926159575624241589599003964979640840086252478029709904308461709651400109485351462666820496096345766733042945918306284902585618061272525323382142547359684512114160415969631877620660064043178086464811345023251493620331559440565662862858765724251890489795332144543057725932216208403143759943169004775947331771556537814494448612329251887435553890674764339328444948425882382475260315505741818518926349729970262019325118040559191290279100613049085709127598666890434114956464502529053036826173452792849566280474995114751780998069614898221773345705289637708545219204637224261997310181473787577166103031529148842107599 # noqa + H = b"D007C23686BE8A7737F828DC9E899F8EB5AF423F495F138437BE2529C1B8455F" + self.assertEqual(K, transport._K) + self.assertEqual(H, hexlify(transport._H).upper()) + self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) + self.assertTrue(transport._activated) + + def test_kex_group16_sha512_client(self): + transport = FakeTransport() + transport.server_mode = False + kex = KexGroup16SHA512(transport) + kex.start_kex() + x = b"1E0000020100859FF55A23E0F66463561DD8BFC4764C69C05F85665B06EC9E29EF5003A53A8FA890B6A6EB624DEB55A4FB279DE7010A53580A126817E3D235B05A1081662B1500961D0625F0AAD287F1B597CBA9DB9550D9CC26355C4C59F92E613B5C21AC191F152C09A5DB46DCBA5EA58E3CA6A8B0EB7183E27FAC10106022E8521FA91240FB389060F1E1E4A355049D29DCC82921CE6588791743E4B1DEEE0166F7CC5180C3C75F3773342DF95C8C10AAA5D12975257027936B99B3DED6E6E98CF27EADEAEAE04E7F0A28071F578646B985FCE28A59CEB36287CB65759BE0544D4C4018CDF03C9078FE9CA79ECA611CB6966899E6FD29BE0781491C659FE2380E0D99D50D9CFAAB94E61BE311779719C4C43C6D223AD3799C3915A9E55076A21152DBBF911D6594296D6ECDC1B6FA71997CD29DF987B80FCA7F36BB7F19863C72BBBF839746AFBF9A5B407D468C976AA3E36FA118D3EAAD2E08BF6AE219F81F2CE2BE946337F06CC09BBFABE938A4087E413921CBEC1965ED905999B83396ECA226110CDF6EFB80F815F6489AF87561DA3857F13A7705921306D94176231FBB336B17C3724BC17A28BECB910093AB040873D5D760E8C182B88ECCE3E38DDA68CE35BD152DF7550BD908791FCCEDD1FFDF5ED2A57FFAE79599E487A7726D8A3D950B1729A08FBB60EE462A6BBE8BF0F5F0E1358129A37840FE5B3EEB8BF26E99FA222EAE83" # noqa + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual( + (paramiko.kex_group1._MSG_KEXDH_REPLY,), transport._expect + ) + + # fake "reply" + msg = Message() + msg.add_string("fake-host-key") + msg.add_mpint(69) + msg.add_string("fake-sig") + msg.rewind() + kex.parse_next(paramiko.kex_group1._MSG_KEXDH_REPLY, msg) + K = 933242830095376162107925500057692534838883186615567574891154103836907630698358649443101764908667358576734565553213003142941996368306996312915844839972197961603283544950658467545799914435739152351344917376359963584614213874232577733869049670230112638724993540996854599166318001059065780674008011575015459772051180901213815080343343801745386220342919837913506966863570473712948197760657442974564354432738520446202131551650771882909329069340612274196233658123593466135642819578182367229641847749149740891990379052266213711500434128970973602206842980669193719602075489724202241641553472106310932258574377789863734311328542715212248147206865762697424822447603031087553480483833829498375309975229907460562402877655519980113688369262871485777790149373908739910846630414678346163764464587129010141922982925829457954376352735653834300282864445132624993186496129911208133529828461690634463092007726349795944930302881758403402084584307180896465875803621285362317770276493727205689466142632599776710824902573926951951209239626732358074877997756011804454926541386215567756538832824717436605031489511654178384081883801272314328403020205577714999460724519735573055540814037716770051316113795603990199374791348798218428912977728347485489266146775472 # noqa + H = b"F6E2BCC846B9B62591EFB86663D55D4769CA06B2EDABE469DF831639B2DDD5A271985011900A724CB2C87F19F347B3632A7C1536AF3D12EE463E6EA75281AF0C" # noqa + self.assertEqual(K, transport._K) + self.assertEqual(H, hexlify(transport._H).upper()) + self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) + self.assertTrue(transport._activated) + + @pytest.mark.skipif("not KexCurve25519.is_available()") + def test_kex_c25519_client(self): + K = 71294722834835117201316639182051104803802881348227506835068888449366462300724 # noqa + transport = FakeTransport() + transport.server_mode = False + kex = KexCurve25519(transport) + kex.start_kex() + self.assertEqual( + (paramiko.kex_curve25519._MSG_KEXECDH_REPLY,), transport._expect + ) + + # fake reply + msg = Message() + msg.add_string("fake-host-key") + Q_S = unhexlify( + "8d13a119452382a1ada8eea4c979f3e63ad3f0c7366786d6c5b54b87219bae49" + ) + msg.add_string(Q_S) + msg.add_string("fake-sig") + msg.rewind() + kex.parse_next(paramiko.kex_curve25519._MSG_KEXECDH_REPLY, msg) + H = b"05B6F6437C0CF38D1A6C5A6F6E2558DEB54E7FC62447EBFB1E5D7407326A5475" + self.assertEqual(K, kex.transport._K) + self.assertEqual(H, hexlify(transport._H).upper()) + self.assertEqual((b"fake-host-key", b"fake-sig"), transport._verify) + self.assertTrue(transport._activated) + + @pytest.mark.skipif("not KexCurve25519.is_available()") + def test_kex_c25519_server(self): + K = 71294722834835117201316639182051104803802881348227506835068888449366462300724 # noqa + transport = FakeTransport() + transport.server_mode = True + kex = KexCurve25519(transport) + kex.start_kex() + self.assertEqual( + (paramiko.kex_curve25519._MSG_KEXECDH_INIT,), transport._expect + ) + + # fake init + msg = Message() + Q_C = unhexlify( + "8d13a119452382a1ada8eea4c979f3e63ad3f0c7366786d6c5b54b87219bae49" + ) + H = b"DF08FCFCF31560FEE639D9B6D56D760BC3455B5ADA148E4514181023E7A9B042" + msg.add_string(Q_C) + msg.rewind() + kex.parse_next(paramiko.kex_curve25519._MSG_KEXECDH_INIT, msg) + self.assertEqual(K, transport._K) + self.assertTrue(transport._activated) + self.assertEqual(H, hexlify(transport._H).upper()) diff --git a/tests/test_kex_gss.py b/tests/test_kex_gss.py index c71ff91c..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: @@ -142,7 +137,7 @@ class GSSKexTest(unittest.TestCase): stdout.close() stderr.close() - def test_1_gsskex_and_auth(self): + def test_gsskex_and_auth(self): """ Verify that Paramiko can handle SSHv2 GSS-API / SSPI authenticated Diffie-Hellman Key Exchange and user authentication with the GSS-API @@ -150,7 +145,9 @@ class GSSKexTest(unittest.TestCase): """ self._test_gsskex_and_auth(gss_host=None) - def test_2_gsskex_and_auth_rekey(self): + # 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_message.py b/tests/test_message.py index b843a705..57766d90 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -29,14 +29,14 @@ from paramiko.common import byte_chr, zero_byte class MessageTest(unittest.TestCase): __a = ( - b"\x00\x00\x00\x17\x07\x60\xe0\x90\x00\x00\x00\x01\x71\x00\x00\x00\x05\x68\x65\x6c\x6c\x6f\x00\x00\x03\xe8" + b"\x00\x00\x00\x17\x07\x60\xe0\x90\x00\x00\x00\x01\x71\x00\x00\x00\x05\x68\x65\x6c\x6c\x6f\x00\x00\x03\xe8" # noqa + b"x" * 1000 ) - __b = b"\x01\x00\xf3\x00\x3f\x00\x00\x00\x10\x68\x75\x65\x79\x2c\x64\x65\x77\x65\x79\x2c\x6c\x6f\x75\x69\x65" - __c = b"\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\xf5\xe4\xd3\xc2\xb1\x09\x00\x00\x00\x01\x11\x00\x00\x00\x07\x00\xf5\xe4\xd3\xc2\xb1\x09\x00\x00\x00\x06\x9a\x1b\x2c\x3d\x4e\xf7" - __d = b"\x00\x00\x00\x05\xff\x00\x00\x00\x05\x11\x22\x33\x44\x55\xff\x00\x00\x00\x0a\x00\xf0\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x03\x63\x61\x74\x00\x00\x00\x03\x61\x2c\x62" + __b = b"\x01\x00\xf3\x00\x3f\x00\x00\x00\x10\x68\x75\x65\x79\x2c\x64\x65\x77\x65\x79\x2c\x6c\x6f\x75\x69\x65" # noqa + __c = b"\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\xf5\xe4\xd3\xc2\xb1\x09\x00\x00\x00\x01\x11\x00\x00\x00\x07\x00\xf5\xe4\xd3\xc2\xb1\x09\x00\x00\x00\x06\x9a\x1b\x2c\x3d\x4e\xf7" # noqa + __d = b"\x00\x00\x00\x05\xff\x00\x00\x00\x05\x11\x22\x33\x44\x55\xff\x00\x00\x00\x0a\x00\xf0\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x03\x63\x61\x74\x00\x00\x00\x03\x61\x2c\x62" # noqa - def test_1_encode(self): + def test_encode(self): msg = Message() msg.add_int(23) msg.add_int(123789456) @@ -62,7 +62,7 @@ class MessageTest(unittest.TestCase): msg.add_mpint(-0x65e4d3c2b109) self.assertEqual(msg.asbytes(), self.__c) - def test_2_decode(self): + def test_decode(self): msg = Message(self.__a) self.assertEqual(msg.get_int(), 23) self.assertEqual(msg.get_int(), 123789456) @@ -84,7 +84,7 @@ class MessageTest(unittest.TestCase): self.assertEqual(msg.get_mpint(), 0xf5e4d3c2b109) self.assertEqual(msg.get_mpint(), -0x65e4d3c2b109) - def test_3_add(self): + def test_add(self): msg = Message() msg.add(5) msg.add(0x1122334455) @@ -94,7 +94,7 @@ class MessageTest(unittest.TestCase): msg.add(["a", "b"]) self.assertEqual(msg.asbytes(), self.__d) - def test_4_misc(self): + def test_misc(self): msg = Message(self.__d) self.assertEqual(msg.get_adaptive_int(), 5) self.assertEqual(msg.get_adaptive_int(), 0x1122334455) diff --git a/tests/test_packetizer.py b/tests/test_packetizer.py index 6920f08e..de80770e 100644 --- a/tests/test_packetizer.py +++ b/tests/test_packetizer.py @@ -38,7 +38,7 @@ x1f = byte_chr(0x1f) class PacketizerTest(unittest.TestCase): - def test_1_write(self): + def test_write(self): rsock = LoopSocket() wsock = LoopSocket() rsock.link(wsock) @@ -64,11 +64,11 @@ class PacketizerTest(unittest.TestCase): # 32 + 12 bytes of MAC = 44 self.assertEqual(44, len(data)) self.assertEqual( - b"\x43\x91\x97\xbd\x5b\x50\xac\x25\x87\xc2\xc4\x6b\xc7\xe9\x38\xc0", + b"\x43\x91\x97\xbd\x5b\x50\xac\x25\x87\xc2\xc4\x6b\xc7\xe9\x38\xc0", # noqa data[:16], ) - def test_2_read(self): + def test_read(self): rsock = LoopSocket() wsock = LoopSocket() rsock.link(wsock) @@ -82,7 +82,7 @@ class PacketizerTest(unittest.TestCase): ).decryptor() p.set_inbound_cipher(decryptor, 16, sha1, 12, x1f * 20) wsock.send( - b"\x43\x91\x97\xbd\x5b\x50\xac\x25\x87\xc2\xc4\x6b\xc7\xe9\x38\xc0\x90\xd2\x16\x56\x0d\x71\x73\x61\x38\x7c\x4c\x3d\xfb\x97\x7d\xe2\x6e\x03\xb1\xa0\xc2\x1c\xd6\x41\x41\x4c\xb4\x59" + b"\x43\x91\x97\xbd\x5b\x50\xac\x25\x87\xc2\xc4\x6b\xc7\xe9\x38\xc0\x90\xd2\x16\x56\x0d\x71\x73\x61\x38\x7c\x4c\x3d\xfb\x97\x7d\xe2\x6e\x03\xb1\xa0\xc2\x1c\xd6\x41\x41\x4c\xb4\x59" # noqa ) cmd, m = p.read_message() self.assertEqual(100, cmd) @@ -90,7 +90,7 @@ class PacketizerTest(unittest.TestCase): self.assertEqual(1, m.get_int()) self.assertEqual(900, m.get_int()) - def test_3_closed(self): + def test_closed(self): if sys.platform.startswith("win"): # no SIGALRM on windows return rsock = LoopSocket() diff --git a/tests/test_pkey.py b/tests/test_pkey.py index 38c1fbef..a01acf6f 100644 --- a/tests/test_pkey.py +++ b/tests/test_pkey.py @@ -33,20 +33,20 @@ from .util import _support # from openssh's ssh-keygen -PUB_RSA = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA049W6geFpmsljTwfvI1UmKWWJPNFI74+vNKTk4dmzkQY2yAMs6FhlvhlI8ysU4oj71ZsRYMecHbBbxdN79+JRFVYTKaLqjwGENeTd+yv4q+V2PvZv3fLnzApI3l7EJCqhWwJUHJ1jAkZzqDx0tyOL4uoZpww3nmE0kb3y21tH4c=" -PUB_DSS = "ssh-dss AAAAB3NzaC1kc3MAAACBAOeBpgNnfRzr/twmAQRu2XwWAp3CFtrVnug6s6fgwj/oLjYbVtjAy6pl/h0EKCWx2rf1IetyNsTxWrniA9I6HeDj65X1FyDkg6g8tvCnaNB8Xp/UUhuzHuGsMIipRxBxw9LF608EqZcj1E3ytktoW5B5OcjrkEoz3xG7C+rpIjYvAAAAFQDwz4UnmsGiSNu5iqjn3uTzwUpshwAAAIEAkxfFeY8P2wZpDjX0MimZl5wkoFQDL25cPzGBuB4OnB8NoUk/yjAHIIpEShw8V+LzouMK5CTJQo5+Ngw3qIch/WgRmMHy4kBq1SsXMjQCte1So6HBMvBPIW5SiMTmjCfZZiw4AYHK+B/JaOwaG9yRg2Ejg4Ok10+XFDxlqZo8Y+wAAACARmR7CCPjodxASvRbIyzaVpZoJ/Z6x7dAumV+ysrV1BVYd0lYukmnjO1kKBWApqpH1ve9XDQYN8zgxM4b16L21kpoWQnZtXrY3GZ4/it9kUgyB7+NwacIBlXa8cMDL7Q/69o0d54U0X/NeX5QxuYR6OMJlrkQB7oiW/P/1mwjQgE=" -PUB_ECDSA_256 = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJSPZm3ZWkvk/Zx8WP+fZRZ5/NBBHnGQwR6uIC6XHGPDIHuWUzIjAwA0bzqkOUffEsbLe+uQgKl5kbc/L8KA/eo=" -PUB_ECDSA_384 = "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBBbGibQLW9AAZiGN2hEQxWYYoFaWKwN3PKSaDJSMqmIn1Z9sgRUuw8Y/w502OGvXL/wFk0i2z50l3pWZjD7gfMH7gX5TUiCzwrQkS+Hn1U2S9aF5WJp0NcIzYxXw2r4M2A==" -PUB_ECDSA_521 = "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBACaOaFLZGuxa5AW16qj6VLypFbLrEWrt9AZUloCMefxO8bNLjK/O5g0rAVasar1TnyHE9qj4NwzANZASWjQNbc4MAG8vzqezFwLIn/kNyNTsXNfqEko9OgHZknlj2Z79dwTJcRAL4QLcT5aND0EHZLB2fAUDXiWIb2j4rg1mwPlBMiBXA==" -PUB_RSA_2K_OPENSSH = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDF+Dpr54DX0WdeTDpNAMdkCWEkl3OXtNgf58qlN1gX572OLBqLf0zT4bHstUEpU3piazph/rSWcUMuBoD46tZ6jiH7H9b9Pem2eYQWaELDDkM+v9BMbEy5rMbFRLol5OtEvPFqneyEAanPOgvd8t3yyhSev9QVusakzJ8j8LGgrA8huYZ+Srnw0shEWLG70KUKCh3rG0QIvA8nfhtUOisr2Gp+F0YxMGb5gwBlQYAYE5l6u1SjZ7hNjyNosjK+wRBFgFFBYVpkZKJgWoK9w4ijFyzMZTucnZMqKOKAjIJvHfKBf2/cEfYxSq1EndqTqjYsd9T7/s2vcn1OH5a0wkER" -PUB_DSS_1K_OPENSSH = "ssh-dss AAAAB3NzaC1kc3MAAACBAL8XEx7F9xuwBNles+vWpNF+YcofrBhjX1r5QhpBe0eoYWLHRcroN6lxwCdGYRfgOoRjTncBiixQX/uUxAY96zDh3ir492s2BcJt4ihvNn/AY0I0OTuX/2IwGk9CGzafjaeZNVYxMa8lcVt0hSOTjkPQ7gVuk6bJzMInvie+VWKLAAAAFQDUgYdY+rhR0SkKbC09BS/SIHcB+wAAAIB44+4zpCNcd0CGvZlowH99zyPX8uxQtmTLQFuR2O8O0FgVVuCdDgD0D9W8CLOp32oatpM0jyyN89EdvSWzjHzZJ+L6H1FtZps7uhpDFWHdva1R25vyGecLMUuXjo5t/D7oCDih+HwHoSAxoi0QvsPd8/qqHQVznNJKtR6thUpXEwAAAIAG4DCBjbgTTgpBw0egRkJwBSz0oTt+1IcapNU2jA6N8urMSk9YXHEQHKN68BAF3YJ59q2Ujv3LOXmBqGd1T+kzwUszfMlgzq8MMu19Yfzse6AIK1Agn1Vj6F7YXLsXDN+T4KszX5+FJa7t/Zsp3nALWy6l0f4WKivEF5Y2QpEFcQ==" +PUB_RSA = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA049W6geFpmsljTwfvI1UmKWWJPNFI74+vNKTk4dmzkQY2yAMs6FhlvhlI8ysU4oj71ZsRYMecHbBbxdN79+JRFVYTKaLqjwGENeTd+yv4q+V2PvZv3fLnzApI3l7EJCqhWwJUHJ1jAkZzqDx0tyOL4uoZpww3nmE0kb3y21tH4c=" # noqa +PUB_DSS = "ssh-dss AAAAB3NzaC1kc3MAAACBAOeBpgNnfRzr/twmAQRu2XwWAp3CFtrVnug6s6fgwj/oLjYbVtjAy6pl/h0EKCWx2rf1IetyNsTxWrniA9I6HeDj65X1FyDkg6g8tvCnaNB8Xp/UUhuzHuGsMIipRxBxw9LF608EqZcj1E3ytktoW5B5OcjrkEoz3xG7C+rpIjYvAAAAFQDwz4UnmsGiSNu5iqjn3uTzwUpshwAAAIEAkxfFeY8P2wZpDjX0MimZl5wkoFQDL25cPzGBuB4OnB8NoUk/yjAHIIpEShw8V+LzouMK5CTJQo5+Ngw3qIch/WgRmMHy4kBq1SsXMjQCte1So6HBMvBPIW5SiMTmjCfZZiw4AYHK+B/JaOwaG9yRg2Ejg4Ok10+XFDxlqZo8Y+wAAACARmR7CCPjodxASvRbIyzaVpZoJ/Z6x7dAumV+ysrV1BVYd0lYukmnjO1kKBWApqpH1ve9XDQYN8zgxM4b16L21kpoWQnZtXrY3GZ4/it9kUgyB7+NwacIBlXa8cMDL7Q/69o0d54U0X/NeX5QxuYR6OMJlrkQB7oiW/P/1mwjQgE=" # noqa +PUB_ECDSA_256 = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJSPZm3ZWkvk/Zx8WP+fZRZ5/NBBHnGQwR6uIC6XHGPDIHuWUzIjAwA0bzqkOUffEsbLe+uQgKl5kbc/L8KA/eo=" # noqa +PUB_ECDSA_384 = "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBBbGibQLW9AAZiGN2hEQxWYYoFaWKwN3PKSaDJSMqmIn1Z9sgRUuw8Y/w502OGvXL/wFk0i2z50l3pWZjD7gfMH7gX5TUiCzwrQkS+Hn1U2S9aF5WJp0NcIzYxXw2r4M2A==" # noqa +PUB_ECDSA_521 = "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBACaOaFLZGuxa5AW16qj6VLypFbLrEWrt9AZUloCMefxO8bNLjK/O5g0rAVasar1TnyHE9qj4NwzANZASWjQNbc4MAG8vzqezFwLIn/kNyNTsXNfqEko9OgHZknlj2Z79dwTJcRAL4QLcT5aND0EHZLB2fAUDXiWIb2j4rg1mwPlBMiBXA==" # noqa +PUB_RSA_2K_OPENSSH = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDF+Dpr54DX0WdeTDpNAMdkCWEkl3OXtNgf58qlN1gX572OLBqLf0zT4bHstUEpU3piazph/rSWcUMuBoD46tZ6jiH7H9b9Pem2eYQWaELDDkM+v9BMbEy5rMbFRLol5OtEvPFqneyEAanPOgvd8t3yyhSev9QVusakzJ8j8LGgrA8huYZ+Srnw0shEWLG70KUKCh3rG0QIvA8nfhtUOisr2Gp+F0YxMGb5gwBlQYAYE5l6u1SjZ7hNjyNosjK+wRBFgFFBYVpkZKJgWoK9w4ijFyzMZTucnZMqKOKAjIJvHfKBf2/cEfYxSq1EndqTqjYsd9T7/s2vcn1OH5a0wkER" # noqa +PUB_DSS_1K_OPENSSH = "ssh-dss AAAAB3NzaC1kc3MAAACBAL8XEx7F9xuwBNles+vWpNF+YcofrBhjX1r5QhpBe0eoYWLHRcroN6lxwCdGYRfgOoRjTncBiixQX/uUxAY96zDh3ir492s2BcJt4ihvNn/AY0I0OTuX/2IwGk9CGzafjaeZNVYxMa8lcVt0hSOTjkPQ7gVuk6bJzMInvie+VWKLAAAAFQDUgYdY+rhR0SkKbC09BS/SIHcB+wAAAIB44+4zpCNcd0CGvZlowH99zyPX8uxQtmTLQFuR2O8O0FgVVuCdDgD0D9W8CLOp32oatpM0jyyN89EdvSWzjHzZJ+L6H1FtZps7uhpDFWHdva1R25vyGecLMUuXjo5t/D7oCDih+HwHoSAxoi0QvsPd8/qqHQVznNJKtR6thUpXEwAAAIAG4DCBjbgTTgpBw0egRkJwBSz0oTt+1IcapNU2jA6N8urMSk9YXHEQHKN68BAF3YJ59q2Ujv3LOXmBqGd1T+kzwUszfMlgzq8MMu19Yfzse6AIK1Agn1Vj6F7YXLsXDN+T4KszX5+FJa7t/Zsp3nALWy6l0f4WKivEF5Y2QpEFcQ==" # noqa FINGER_RSA = "1024 60:73:38:44:cb:51:86:65:7f:de:da:a2:2b:5a:57:d5" FINGER_DSS = "1024 44:78:f0:b9:a2:3c:c5:18:20:09:ff:75:5b:c1:d2:6c" FINGER_ECDSA_256 = "256 25:19:eb:55:e6:a1:47:ff:4f:38:d2:75:6f:a5:d5:60" FINGER_ECDSA_384 = "384 c1:8d:a0:59:09:47:41:8e:a8:a6:07:01:29:23:b4:65" FINGER_ECDSA_521 = "521 44:58:22:52:12:33:16:0e:ce:0e:be:2c:7c:7e:cc:1e" -SIGNED_RSA = "20:d7:8a:31:21:cb:f7:92:12:f2:a4:89:37:f5:78:af:e6:16:b6:25:b9:97:3d:a2:cd:5f:ca:20:21:73:4c:ad:34:73:8f:20:77:28:e2:94:15:08:d8:91:40:7a:85:83:bf:18:37:95:dc:54:1a:9b:88:29:6c:73:ca:38:b4:04:f1:56:b9:f2:42:9d:52:1b:29:29:b4:4f:fd:c9:2d:af:47:d2:40:76:30:f3:63:45:0c:d9:1d:43:86:0f:1c:70:e2:93:12:34:f3:ac:c5:0a:2f:14:50:66:59:f1:88:ee:c1:4a:e9:d1:9c:4e:46:f0:0e:47:6f:38:74:f1:44:a8" +SIGNED_RSA = "20:d7:8a:31:21:cb:f7:92:12:f2:a4:89:37:f5:78:af:e6:16:b6:25:b9:97:3d:a2:cd:5f:ca:20:21:73:4c:ad:34:73:8f:20:77:28:e2:94:15:08:d8:91:40:7a:85:83:bf:18:37:95:dc:54:1a:9b:88:29:6c:73:ca:38:b4:04:f1:56:b9:f2:42:9d:52:1b:29:29:b4:4f:fd:c9:2d:af:47:d2:40:76:30:f3:63:45:0c:d9:1d:43:86:0f:1c:70:e2:93:12:34:f3:ac:c5:0a:2f:14:50:66:59:f1:88:ee:c1:4a:e9:d1:9c:4e:46:f0:0e:47:6f:38:74:f1:44:a8" # noqa FINGER_RSA_2K_OPENSSH = "2048 68:d1:72:01:bf:c0:0c:66:97:78:df:ce:75:74:46:d6" FINGER_DSS_1K_OPENSSH = "1024 cf:1d:eb:d7:61:d3:12:94:c6:c0:c6:54:35:35:b0:82" @@ -112,8 +112,8 @@ L4QLcT5aND0EHZLB2fAUDXiWIb2j4rg1mwPlBMiBXA== x1234 = b"\x01\x02\x03\x04" -TEST_KEY_BYTESTR_2 = "\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x81\x00\xd3\x8fV\xea\x07\x85\xa6k%\x8d<\x1f\xbc\x8dT\x98\xa5\x96$\xf3E#\xbe>\xbc\xd2\x93\x93\x87f\xceD\x18\xdb \x0c\xb3\xa1a\x96\xf8e#\xcc\xacS\x8a#\xefVlE\x83\x1epv\xc1o\x17M\xef\xdf\x89DUXL\xa6\x8b\xaa<\x06\x10\xd7\x93w\xec\xaf\xe2\xaf\x95\xd8\xfb\xd9\xbfw\xcb\x9f0)#y{\x10\x90\xaa\x85l\tPru\x8c\t\x19\xce\xa0\xf1\xd2\xdc\x8e/\x8b\xa8f\x9c0\xdey\x84\xd2F\xf7\xcbmm\x1f\x87" -TEST_KEY_BYTESTR_3 = "\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x00ӏV\x07k%<\x1fT$E#>ғfD\x18 \x0cae#̬S#VlE\x1epvo\x17M߉DUXL<\x06\x10דw\u2bd5ٿw˟0)#y{\x10l\tPru\t\x19Π\u070e/f0yFmm\x1f" +TEST_KEY_BYTESTR_2 = "\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x81\x00\xd3\x8fV\xea\x07\x85\xa6k%\x8d<\x1f\xbc\x8dT\x98\xa5\x96$\xf3E#\xbe>\xbc\xd2\x93\x93\x87f\xceD\x18\xdb \x0c\xb3\xa1a\x96\xf8e#\xcc\xacS\x8a#\xefVlE\x83\x1epv\xc1o\x17M\xef\xdf\x89DUXL\xa6\x8b\xaa<\x06\x10\xd7\x93w\xec\xaf\xe2\xaf\x95\xd8\xfb\xd9\xbfw\xcb\x9f0)#y{\x10\x90\xaa\x85l\tPru\x8c\t\x19\xce\xa0\xf1\xd2\xdc\x8e/\x8b\xa8f\x9c0\xdey\x84\xd2F\xf7\xcbmm\x1f\x87" # noqa +TEST_KEY_BYTESTR_3 = "\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x00ӏV\x07k%<\x1fT$E#>ғfD\x18 \x0cae#̬S#VlE\x1epvo\x17M߉DUXL<\x06\x10דw\u2bd5ٿw˟0)#y{\x10l\tPru\t\x19Π\u070e/f0yFmm\x1f" # noqa class KeyTest(unittest.TestCase): @@ -134,12 +134,12 @@ class KeyTest(unittest.TestCase): self.assertEqual(fh.readline()[:-1], "Proc-Type: 4,ENCRYPTED") self.assertEqual(fh.readline()[0:10], "DEK-Info: ") - def test_1_generate_key_bytes(self): + def test_generate_key_bytes(self): key = util.generate_key_bytes(md5, x1234, "happy birthday", 30) - exp = b"\x61\xE1\xF2\x72\xF4\xC1\xC4\x56\x15\x86\xBD\x32\x24\x98\xC0\xE9\x24\x67\x27\x80\xF4\x7B\xB3\x7D\xDA\x7D\x54\x01\x9E\x64" + exp = b"\x61\xE1\xF2\x72\xF4\xC1\xC4\x56\x15\x86\xBD\x32\x24\x98\xC0\xE9\x24\x67\x27\x80\xF4\x7B\xB3\x7D\xDA\x7D\x54\x01\x9E\x64" # noqa self.assertEqual(exp, key) - def test_2_load_rsa(self): + def test_load_rsa(self): key = RSAKey.from_private_key_file(_support("test_rsa.key")) self.assertEqual("ssh-rsa", key.get_name()) exp_rsa = b(FINGER_RSA.split()[1].replace(":", "")) @@ -155,7 +155,7 @@ class KeyTest(unittest.TestCase): key2 = RSAKey.from_private_key(s) self.assertEqual(key, key2) - def test_3_load_rsa_password(self): + def test_load_rsa_password(self): key = RSAKey.from_private_key_file( _support("test_rsa_password.key"), "television" ) @@ -166,7 +166,7 @@ class KeyTest(unittest.TestCase): self.assertEqual(PUB_RSA.split()[1], key.get_base64()) self.assertEqual(1024, key.get_bits()) - def test_4_load_dss(self): + def test_load_dss(self): key = DSSKey.from_private_key_file(_support("test_dss.key")) self.assertEqual("ssh-dss", key.get_name()) exp_dss = b(FINGER_DSS.split()[1].replace(":", "")) @@ -182,7 +182,7 @@ class KeyTest(unittest.TestCase): key2 = DSSKey.from_private_key(s) self.assertEqual(key, key2) - def test_5_load_dss_password(self): + def test_load_dss_password(self): key = DSSKey.from_private_key_file( _support("test_dss_password.key"), "television" ) @@ -193,7 +193,7 @@ class KeyTest(unittest.TestCase): self.assertEqual(PUB_DSS.split()[1], key.get_base64()) self.assertEqual(1024, key.get_bits()) - def test_6_compare_rsa(self): + def test_compare_rsa(self): # verify that the private & public keys compare equal key = RSAKey.from_private_key_file(_support("test_rsa.key")) self.assertEqual(key, key) @@ -202,7 +202,7 @@ class KeyTest(unittest.TestCase): self.assertTrue(not pub.can_sign()) self.assertEqual(key, pub) - def test_7_compare_dss(self): + def test_compare_dss(self): # verify that the private & public keys compare equal key = DSSKey.from_private_key_file(_support("test_dss.key")) self.assertEqual(key, key) @@ -211,7 +211,7 @@ class KeyTest(unittest.TestCase): self.assertTrue(not pub.can_sign()) self.assertEqual(key, pub) - def test_8_sign_rsa(self): + def test_sign_rsa(self): # verify that the rsa private key can sign and verify key = RSAKey.from_private_key_file(_support("test_rsa.key")) msg = key.sign_ssh_data(b"ice weasels") @@ -226,7 +226,7 @@ class KeyTest(unittest.TestCase): pub = RSAKey(data=key.asbytes()) self.assertTrue(pub.verify_ssh_sig(b"ice weasels", msg)) - def test_9_sign_dss(self): + def test_sign_dss(self): # verify that the dss private key can sign and verify key = DSSKey.from_private_key_file(_support("test_dss.key")) msg = key.sign_ssh_data(b"ice weasels") @@ -241,19 +241,19 @@ class KeyTest(unittest.TestCase): pub = DSSKey(data=key.asbytes()) self.assertTrue(pub.verify_ssh_sig(b"ice weasels", msg)) - def test_A_generate_rsa(self): + def test_generate_rsa(self): key = RSAKey.generate(1024) msg = key.sign_ssh_data(b"jerri blank") msg.rewind() self.assertTrue(key.verify_ssh_sig(b"jerri blank", msg)) - def test_B_generate_dss(self): + def test_generate_dss(self): key = DSSKey.generate(1024) msg = key.sign_ssh_data(b"jerri blank") msg.rewind() self.assertTrue(key.verify_ssh_sig(b"jerri blank", msg)) - def test_C_generate_ecdsa(self): + def test_generate_ecdsa(self): key = ECDSAKey.generate() msg = key.sign_ssh_data(b"jerri blank") msg.rewind() @@ -282,7 +282,7 @@ class KeyTest(unittest.TestCase): self.assertEqual(key.get_bits(), 521) self.assertEqual(key.get_name(), "ecdsa-sha2-nistp521") - def test_10_load_ecdsa_256(self): + def test_load_ecdsa_256(self): key = ECDSAKey.from_private_key_file(_support("test_ecdsa_256.key")) self.assertEqual("ecdsa-sha2-nistp256", key.get_name()) exp_ecdsa = b(FINGER_ECDSA_256.split()[1].replace(":", "")) @@ -298,7 +298,7 @@ class KeyTest(unittest.TestCase): key2 = ECDSAKey.from_private_key(s) self.assertEqual(key, key2) - def test_11_load_ecdsa_password_256(self): + def test_load_ecdsa_password_256(self): key = ECDSAKey.from_private_key_file( _support("test_ecdsa_password_256.key"), b"television" ) @@ -309,7 +309,7 @@ class KeyTest(unittest.TestCase): self.assertEqual(PUB_ECDSA_256.split()[1], key.get_base64()) self.assertEqual(256, key.get_bits()) - def test_12_compare_ecdsa_256(self): + def test_compare_ecdsa_256(self): # verify that the private & public keys compare equal key = ECDSAKey.from_private_key_file(_support("test_ecdsa_256.key")) self.assertEqual(key, key) @@ -318,7 +318,7 @@ class KeyTest(unittest.TestCase): self.assertTrue(not pub.can_sign()) self.assertEqual(key, pub) - def test_13_sign_ecdsa_256(self): + def test_sign_ecdsa_256(self): # verify that the rsa private key can sign and verify key = ECDSAKey.from_private_key_file(_support("test_ecdsa_256.key")) msg = key.sign_ssh_data(b"ice weasels") @@ -334,7 +334,7 @@ class KeyTest(unittest.TestCase): pub = ECDSAKey(data=key.asbytes()) self.assertTrue(pub.verify_ssh_sig(b"ice weasels", msg)) - def test_14_load_ecdsa_384(self): + def test_load_ecdsa_384(self): key = ECDSAKey.from_private_key_file(_support("test_ecdsa_384.key")) self.assertEqual("ecdsa-sha2-nistp384", key.get_name()) exp_ecdsa = b(FINGER_ECDSA_384.split()[1].replace(":", "")) @@ -350,7 +350,7 @@ class KeyTest(unittest.TestCase): key2 = ECDSAKey.from_private_key(s) self.assertEqual(key, key2) - def test_15_load_ecdsa_password_384(self): + def test_load_ecdsa_password_384(self): key = ECDSAKey.from_private_key_file( _support("test_ecdsa_password_384.key"), b"television" ) @@ -361,7 +361,7 @@ class KeyTest(unittest.TestCase): self.assertEqual(PUB_ECDSA_384.split()[1], key.get_base64()) self.assertEqual(384, key.get_bits()) - def test_16_compare_ecdsa_384(self): + def test_compare_ecdsa_384(self): # verify that the private & public keys compare equal key = ECDSAKey.from_private_key_file(_support("test_ecdsa_384.key")) self.assertEqual(key, key) @@ -370,7 +370,7 @@ class KeyTest(unittest.TestCase): self.assertTrue(not pub.can_sign()) self.assertEqual(key, pub) - def test_17_sign_ecdsa_384(self): + def test_sign_ecdsa_384(self): # verify that the rsa private key can sign and verify key = ECDSAKey.from_private_key_file(_support("test_ecdsa_384.key")) msg = key.sign_ssh_data(b"ice weasels") @@ -386,7 +386,7 @@ class KeyTest(unittest.TestCase): pub = ECDSAKey(data=key.asbytes()) self.assertTrue(pub.verify_ssh_sig(b"ice weasels", msg)) - def test_18_load_ecdsa_521(self): + def test_load_ecdsa_521(self): key = ECDSAKey.from_private_key_file(_support("test_ecdsa_521.key")) self.assertEqual("ecdsa-sha2-nistp521", key.get_name()) exp_ecdsa = b(FINGER_ECDSA_521.split()[1].replace(":", "")) @@ -405,7 +405,7 @@ class KeyTest(unittest.TestCase): key2 = ECDSAKey.from_private_key(s) self.assertEqual(key, key2) - def test_19_load_ecdsa_password_521(self): + def test_load_ecdsa_password_521(self): key = ECDSAKey.from_private_key_file( _support("test_ecdsa_password_521.key"), b"television" ) @@ -416,7 +416,7 @@ class KeyTest(unittest.TestCase): self.assertEqual(PUB_ECDSA_521.split()[1], key.get_base64()) self.assertEqual(521, key.get_bits()) - def test_20_compare_ecdsa_521(self): + def test_compare_ecdsa_521(self): # verify that the private & public keys compare equal key = ECDSAKey.from_private_key_file(_support("test_ecdsa_521.key")) self.assertEqual(key, key) @@ -425,7 +425,7 @@ class KeyTest(unittest.TestCase): self.assertTrue(not pub.can_sign()) self.assertEqual(key, pub) - def test_21_sign_ecdsa_521(self): + def test_sign_ecdsa_521(self): # verify that the rsa private key can sign and verify key = ECDSAKey.from_private_key_file(_support("test_ecdsa_521.key")) msg = key.sign_ssh_data(b"ice weasels") @@ -493,6 +493,18 @@ class KeyTest(unittest.TestCase): ) self.assertNotEqual(key1.asbytes(), key2.asbytes()) + def test_ed25519_funky_padding(self): + # Proves #1306 by just not exploding with 'Invalid key'. + Ed25519Key.from_private_key_file( + _support("test_ed25519-funky-padding.key") + ) + + def test_ed25519_funky_padding_with_passphrase(self): + # Proves #1306 by just not exploding with 'Invalid key'. + Ed25519Key.from_private_key_file( + _support("test_ed25519-funky-padding_password.key"), b"asdf" + ) + def test_ed25519_compare(self): # verify that the private & public keys compare equal key = Ed25519Key.from_private_key_file(_support("test_ed25519.key")) @@ -504,7 +516,7 @@ class KeyTest(unittest.TestCase): def test_ed25519_nonbytes_password(self): # https://github.com/paramiko/paramiko/issues/1039 - key = Ed25519Key.from_private_key_file( + Ed25519Key.from_private_key_file( _support("test_ed25519_password.key"), # NOTE: not a bytes. Amusingly, the test above for same key DOES # explicitly cast to bytes...code smell! @@ -553,7 +565,7 @@ class KeyTest(unittest.TestCase): # Delve into blob contents, for test purposes msg = Message(key.public_blob.key_blob) self.assertEqual(msg.get_text(), "ssh-rsa-cert-v01@openssh.com") - nonce = msg.get_string() + msg.get_string() e = msg.get_mpint() n = msg.get_mpint() self.assertEqual(e, key.public_numbers.e) diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 00000000..2e0b0e51 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,144 @@ +import signal +import socket + +from mock import patch +from pytest import raises + +from paramiko import ProxyCommand, ProxyCommandFailure + + +class TestProxyCommand(object): + @patch("paramiko.proxy.subprocess") + def test_init_takes_command_string(self, subprocess): + ProxyCommand(command_line="do a thing") + subprocess.Popen.assert_called_once_with( + ["do", "a", "thing"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + + @patch("paramiko.proxy.subprocess.Popen") + def test_send_writes_to_process_stdin_returning_length(self, Popen): + proxy = ProxyCommand("hi") + written = proxy.send(b"data") + Popen.return_value.stdin.write.assert_called_once_with(b"data") + assert written == len(b"data") + + @patch("paramiko.proxy.subprocess.Popen") + def test_send_raises_ProxyCommandFailure_on_error(self, Popen): + Popen.return_value.stdin.write.side_effect = IOError(0, "whoops") + with raises(ProxyCommandFailure) as info: + ProxyCommand("hi").send("data") + assert info.value.command == "hi" + assert info.value.error == "whoops" + + @patch("paramiko.proxy.subprocess.Popen") + @patch("paramiko.proxy.os.read") + @patch("paramiko.proxy.select") + def test_recv_reads_from_process_stdout_returning_bytes( + self, select, os_read, Popen + ): + stdout = Popen.return_value.stdout + select.return_value = [stdout], None, None + fileno = stdout.fileno.return_value + # Intentionally returning <5 at a time sometimes + os_read.side_effect = [b"was", b"te", b"of ti", b"me"] + proxy = ProxyCommand("hi") + data = proxy.recv(5) + assert data == b"waste" + assert [x[0] for x in os_read.call_args_list] == [ + (fileno, 5), + (fileno, 2), + ] + + @patch("paramiko.proxy.subprocess.Popen") + @patch("paramiko.proxy.os.read") + @patch("paramiko.proxy.select") + def test_recv_returns_buffer_on_timeout_if_any_read( + self, select, os_read, Popen + ): + stdout = Popen.return_value.stdout + select.return_value = [stdout], None, None + fileno = stdout.fileno.return_value + os_read.side_effect = [b"was", socket.timeout] + proxy = ProxyCommand("hi") + data = proxy.recv(5) + assert data == b"was" # not b"waste" + assert os_read.call_args[0] == (fileno, 2) + + @patch("paramiko.proxy.subprocess.Popen") + @patch("paramiko.proxy.os.read") + @patch("paramiko.proxy.select") + def test_recv_raises_timeout_if_nothing_read(self, select, os_read, Popen): + stdout = Popen.return_value.stdout + select.return_value = [stdout], None, None + fileno = stdout.fileno.return_value + os_read.side_effect = socket.timeout + proxy = ProxyCommand("hi") + with raises(socket.timeout): + proxy.recv(5) + assert os_read.call_args[0] == (fileno, 5) + + @patch("paramiko.proxy.subprocess.Popen") + @patch("paramiko.proxy.os.read") + @patch("paramiko.proxy.select") + def test_recv_raises_ProxyCommandFailure_on_non_timeout_error( + self, select, os_read, Popen + ): + select.return_value = [Popen.return_value.stdout], None, None + os_read.side_effect = IOError(0, "whoops") + with raises(ProxyCommandFailure) as info: + ProxyCommand("hi").recv(5) + assert info.value.command == "hi" + assert info.value.error == "whoops" + + @patch("paramiko.proxy.subprocess.Popen") + @patch("paramiko.proxy.os.kill") + def test_close_kills_subprocess(self, os_kill, Popen): + proxy = ProxyCommand("hi") + proxy.close() + os_kill.assert_called_once_with(Popen.return_value.pid, signal.SIGTERM) + + @patch("paramiko.proxy.subprocess.Popen") + def test_closed_exposes_whether_subprocess_has_exited(self, Popen): + proxy = ProxyCommand("hi") + Popen.return_value.returncode = None + assert proxy.closed is False + assert proxy._closed is False + Popen.return_value.returncode = 0 + assert proxy.closed is True + assert proxy._closed is True + + @patch("paramiko.proxy.time.time") + @patch("paramiko.proxy.subprocess.Popen") + @patch("paramiko.proxy.os.read") + @patch("paramiko.proxy.select") + def test_timeout_affects_whether_timeout_is_raised( + self, select, os_read, Popen, time + ): + stdout = Popen.return_value.stdout + select.return_value = [stdout], None, None + # Base case: None timeout means no timing out + os_read.return_value = b"meh" + proxy = ProxyCommand("yello") + assert proxy.timeout is None + # Implicit 'no raise' check + assert proxy.recv(3) == b"meh" + # Use settimeout to set timeout, and it is honored + time.side_effect = [0, 10] # elapsed > 7 + proxy = ProxyCommand("ohnoz") + proxy.settimeout(7) + assert proxy.timeout == 7 + with raises(socket.timeout): + proxy.recv(3) + + @patch("paramiko.proxy.subprocess", new=None) + @patch("paramiko.proxy.subprocess_import_error", new=ImportError("meh")) + def test_raises_subprocess_ImportErrors_at_runtime(self): + # Not an ideal test, but I don't know of a non-bad way to fake out + # module-time ImportErrors. So we mock the symptoms. Meh! + with raises(ImportError) as info: + ProxyCommand("hi!!!") + assert str(info.value) == "meh" diff --git a/tests/test_sftp.py b/tests/test_sftp.py index fdb7c9bc..e4e18e5a 100644 --- a/tests/test_sftp.py +++ b/tests/test_sftp.py @@ -26,23 +26,18 @@ do test file operations in (so no existing files will be harmed). import os import socket import sys -import threading -import unittest import warnings from binascii import hexlify from tempfile import mkstemp import pytest -import paramiko -import paramiko.util from paramiko.py3compat import PY2, b, u, StringIO from paramiko.common import o777, o600, o666, o644 from paramiko.sftp_attr import SFTPAttributes from .util import needs_builtin -from .stub_sftp import StubServer, StubSFTPServer -from .util import _support, slow +from .util import slow ARTICLE = """ @@ -74,14 +69,21 @@ decreased compared with chicken. # Here is how unicode characters are encoded over 1 to 6 bytes in utf-8 -# U-00000000 - U-0000007F: 0xxxxxxx -# U-00000080 - U-000007FF: 110xxxxx 10xxxxxx -# U-00000800 - U-0000FFFF: 1110xxxx 10xxxxxx 10xxxxxx -# U-00010000 - U-001FFFFF: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx -# U-00200000 - U-03FFFFFF: 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx -# U-04000000 - U-7FFFFFFF: 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx +# U-00000000 - U-0000007F: +# 0xxxxxxx +# U-00000080 - U-000007FF: +# 110xxxxx 10xxxxxx +# U-00000800 - U-0000FFFF: +# 1110xxxx 10xxxxxx 10xxxxxx +# U-00010000 - U-001FFFFF: +# 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx +# U-00200000 - U-03FFFFFF: +# 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx +# U-04000000 - U-7FFFFFFF: +# 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx # Note that: hex(int('11000011',2)) == '0xc3' -# Thus, the following 2-bytes sequence is not valid utf8: "invalid continuation byte" +# Thus, the following 2-bytes sequence is not valid utf8: "invalid continuation +# byte" NON_UTF8_DATA = b"\xC3\xC3" unicode_folder = u"\u00fcnic\u00f8de" if PY2 else "\u00fcnic\u00f8de" @@ -90,7 +92,7 @@ utf8_folder = b"/\xc3\xbcnic\xc3\xb8\x64\x65" @slow class TestSFTP(object): - def test_1_file(self, sftp): + def test_file(self, sftp): """ verify that we can create a file. """ @@ -101,7 +103,7 @@ class TestSFTP(object): f.close() sftp.remove(sftp.FOLDER + "/test") - def test_2_close(self, sftp): + def test_close(self, sftp): """ Verify that SFTP session close() causes a socket error on next action. """ @@ -109,7 +111,7 @@ class TestSFTP(object): with pytest.raises(socket.error, match="Socket is closed"): sftp.open(sftp.FOLDER + "/test2", "w") - def test_2_sftp_can_be_used_as_context_manager(self, sftp): + def test_sftp_can_be_used_as_context_manager(self, sftp): """ verify that the sftp session is closed when exiting the context manager """ @@ -118,7 +120,7 @@ class TestSFTP(object): with pytest.raises(socket.error, match="Socket is closed"): sftp.open(sftp.FOLDER + "/test2", "w") - def test_3_write(self, sftp): + def test_write(self, sftp): """ verify that a file can be created and written, and the size is correct. """ @@ -129,7 +131,7 @@ class TestSFTP(object): finally: sftp.remove(sftp.FOLDER + "/duck.txt") - def test_3_sftp_file_can_be_used_as_context_manager(self, sftp): + def test_sftp_file_can_be_used_as_context_manager(self, sftp): """ verify that an opened file can be used as a context manager """ @@ -140,7 +142,7 @@ class TestSFTP(object): finally: sftp.remove(sftp.FOLDER + "/duck.txt") - def test_4_append(self, sftp): + def test_append(self, sftp): """ verify that a file can be opened for append, and tell() still works. """ @@ -158,7 +160,7 @@ class TestSFTP(object): finally: sftp.remove(sftp.FOLDER + "/append.txt") - def test_5_rename(self, sftp): + def test_rename(self, sftp): """ verify that renaming a file works. """ @@ -185,7 +187,7 @@ class TestSFTP(object): except: pass - def test_5a_posix_rename(self, sftp): + def testa_posix_rename(self, sftp): """Test posix-rename@openssh.com protocol extension.""" try: # first check that the normal rename works as specified @@ -214,7 +216,7 @@ class TestSFTP(object): except: pass - def test_6_folder(self, sftp): + def test_folder(self, sftp): """ create a temporary folder, verify that we can create a file in it, then remove the folder and verify that we can't create a file in it anymore. @@ -227,7 +229,7 @@ class TestSFTP(object): with pytest.raises(IOError, match="No such file"): sftp.open(sftp.FOLDER + "/subfolder/test") - def test_7_listdir(self, sftp): + def test_listdir(self, sftp): """ verify that a folder can be created, a bunch of files can be placed in it, and those files show up in sftp.listdir. @@ -248,7 +250,7 @@ class TestSFTP(object): sftp.remove(sftp.FOLDER + "/fish.txt") sftp.remove(sftp.FOLDER + "/tertiary.py") - def test_7_5_listdir_iter(self, sftp): + def test_listdir_iter(self, sftp): """ listdir_iter version of above test """ @@ -268,7 +270,7 @@ class TestSFTP(object): sftp.remove(sftp.FOLDER + "/fish.txt") sftp.remove(sftp.FOLDER + "/tertiary.py") - def test_8_setstat(self, sftp): + def test_setstat(self, sftp): """ verify that the setstat functions (chown, chmod, utime, truncate) work. """ @@ -305,7 +307,7 @@ class TestSFTP(object): finally: sftp.remove(sftp.FOLDER + "/special") - def test_9_fsetstat(self, sftp): + def test_fsetstat(self, sftp): """ verify that the fsetstat functions (chown, chmod, utime, truncate) work on open files. @@ -345,12 +347,12 @@ class TestSFTP(object): finally: sftp.remove(sftp.FOLDER + "/special") - def test_A_readline_seek(self, sftp): + def test_readline_seek(self, sftp): """ - create a text file and write a bunch of text into it. then count the lines - in the file, and seek around to retrieve particular lines. this should - verify that read buffering and 'tell' work well together, and that read - buffering is reset on 'seek'. + create a text file and write a bunch of text into it. then count the + lines in the file, and seek around to retrieve particular lines. this + should verify that read buffering and 'tell' work well together, and + that read buffering is reset on 'seek'. """ try: with sftp.open(sftp.FOLDER + "/duck.txt", "w") as f: @@ -370,17 +372,14 @@ class TestSFTP(object): f.seek(pos_list[17], f.SEEK_SET) assert f.readline()[:4] == "duck" f.seek(pos_list[10], f.SEEK_SET) - assert ( - f.readline() - == "duck types were equally resistant to exogenous insulin compared with chicken.\n" - ) + expected = "duck types were equally resistant to exogenous insulin compared with chicken.\n" # noqa + assert f.readline() == expected finally: sftp.remove(sftp.FOLDER + "/duck.txt") - def test_B_write_seek(self, sftp): + def test_write_seek(self, sftp): """ - create a text file, seek back and change part of it, and verify that the - changes worked. + Create a text file, seek back, change it, and verify. """ try: with sftp.open(sftp.FOLDER + "/testing.txt", "w") as f: @@ -395,7 +394,7 @@ class TestSFTP(object): finally: sftp.remove(sftp.FOLDER + "/testing.txt") - def test_C_symlink(self, sftp): + def test_symlink(self, sftp): """ create a symlink and then check that lstat doesn't follow it. """ @@ -442,7 +441,7 @@ class TestSFTP(object): except: pass - def test_D_flush_seek(self, sftp): + def test_flush_seek(self, sftp): """ verify that buffered writes are automatically flushed on seek. """ @@ -462,7 +461,7 @@ class TestSFTP(object): except: pass - def test_E_realpath(self, sftp): + def test_realpath(self, sftp): """ test that realpath is returning something non-empty and not an error. @@ -473,7 +472,7 @@ class TestSFTP(object): assert len(f) > 0 assert os.path.join(pwd, sftp.FOLDER) == f - def test_F_mkdir(self, sftp): + def test_mkdir(self, sftp): """ verify that mkdir/rmdir work. """ @@ -484,7 +483,7 @@ class TestSFTP(object): with pytest.raises(IOError, match="No such file"): sftp.rmdir(sftp.FOLDER + "/subfolder") - def test_G_chdir(self, sftp): + def test_chdir(self, sftp): """ verify that chdir/getcwd work. """ @@ -520,7 +519,7 @@ class TestSFTP(object): except: pass - def test_H_get_put(self, sftp): + def test_get_put(self, sftp): """ verify that get/put work. """ @@ -555,7 +554,7 @@ class TestSFTP(object): os.unlink(localname) sftp.unlink(sftp.FOLDER + "/bunny.txt") - def test_I_check(self, sftp): + def test_check(self, sftp): """ verify that file.check() works against our own server. (it's an sftp extension that we support, and may be the only ones who @@ -577,14 +576,12 @@ class TestSFTP(object): == u(hexlify(sum)).upper() ) sum = f.check("md5", 0, 0, 510) - assert ( - u(hexlify(sum)).upper() - == "EB3B45B8CD55A0707D99B177544A319F373183D241432BB2157AB9E46358C4AC90370B5CADE5D90336FC1716F90B36D6" - ) # noqa + expected = "EB3B45B8CD55A0707D99B177544A319F373183D241432BB2157AB9E46358C4AC90370B5CADE5D90336FC1716F90B36D6" # noqa + assert u(hexlify(sum)).upper() == expected finally: sftp.unlink(sftp.FOLDER + "/kitty.txt") - def test_J_x_flag(self, sftp): + def test_x_flag(self, sftp): """ verify that the 'x' flag works when opening a file. """ @@ -599,7 +596,7 @@ class TestSFTP(object): finally: sftp.unlink(sftp.FOLDER + "/unusual.txt") - def test_K_utf8(self, sftp): + def test_utf8(self, sftp): """ verify that unicode strings are encoded into utf8 correctly. """ @@ -615,7 +612,7 @@ class TestSFTP(object): self.fail("exception " + str(e)) sftp.unlink(b(sftp.FOLDER) + utf8_folder) - def test_L_utf8_chdir(self, sftp): + def test_utf8_chdir(self, sftp): sftp.mkdir(sftp.FOLDER + "/" + unicode_folder) try: sftp.chdir(sftp.FOLDER + "/" + unicode_folder) @@ -626,7 +623,7 @@ class TestSFTP(object): sftp.chdir() sftp.rmdir(sftp.FOLDER + "/" + unicode_folder) - def test_M_bad_readv(self, sftp): + def test_bad_readv(self, sftp): """ verify that readv at the end of the file doesn't essplode. """ @@ -642,7 +639,7 @@ class TestSFTP(object): finally: sftp.unlink(sftp.FOLDER + "/zero") - def test_N_put_without_confirm(self, sftp): + def test_put_without_confirm(self, sftp): """ verify that get/put work without confirmation. """ @@ -671,11 +668,11 @@ class TestSFTP(object): os.unlink(localname) sftp.unlink(sftp.FOLDER + "/bunny.txt") - def test_O_getcwd(self, sftp): + def test_getcwd(self, sftp): """ verify that chdir/getcwd work. """ - assert sftp.getcwd() == None + assert sftp.getcwd() is None root = sftp.normalize(".") if root[-1] != "/": root += "/" @@ -690,7 +687,7 @@ class TestSFTP(object): except: pass - def XXX_test_M_seek_append(self, sftp): + def test_seek_append(self, sftp): """ verify that seek does't affect writes during append. @@ -728,7 +725,7 @@ class TestSFTP(object): # appear but they're clearly emitted from subthreads that have no error # handling. No point running it until that is fixed somehow. @pytest.mark.skip("Doesn't prove anything right now") - def test_N_file_with_percent(self, sftp): + def test_file_with_percent(self, sftp): """ verify that we can create a file with a '%' in the filename. ( it needs to be properly escaped by _log() ) @@ -740,7 +737,7 @@ class TestSFTP(object): f.close() sftp.remove(sftp.FOLDER + "/test%file") - def test_O_non_utf8_data(self, sftp): + def test_non_utf8_data(self, sftp): """Test write() and read() of non utf8 data""" try: with sftp.open("%s/nonutf8data" % sftp.FOLDER, "w") as f: @@ -770,7 +767,7 @@ class TestSFTP(object): try: with sftp.open("%s/write_buffer" % sftp.FOLDER, "wb") as f: for offset in range(0, len(data), 8): - f.write(buffer(data, offset, 8)) + f.write(buffer(data, offset, 8)) # noqa with sftp.open("%s/write_buffer" % sftp.FOLDER, "rb") as f: assert f.read() == data diff --git a/tests/test_sftp_big.py b/tests/test_sftp_big.py index 9df566e8..fc556faf 100644 --- a/tests/test_sftp_big.py +++ b/tests/test_sftp_big.py @@ -23,12 +23,10 @@ a real actual sftp server is contacted, and a new folder is created there to do test file operations in (so no existing files will be harmed). """ -import os import random import struct import sys import time -import unittest from paramiko.common import o660 @@ -37,7 +35,7 @@ from .util import slow @slow class TestBigSFTP(object): - def test_1_lots_of_files(self, sftp): + def test_lots_of_files(self, sftp): """ create a bunch of files over the same session. """ @@ -65,7 +63,7 @@ class TestBigSFTP(object): except: pass - def test_2_big_file(self, sftp): + def test_big_file(self, sftp): """ write a 1MB file with no buffering. """ @@ -96,7 +94,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_3_big_file_pipelined(self, sftp): + def test_big_file_pipelined(self, sftp): """ write a 1MB file, with no linefeeds, using pipelining. """ @@ -122,7 +120,8 @@ class TestBigSFTP(object): file_size = f.stat().st_size f.prefetch(file_size) - # read on odd boundaries to make sure the bytes aren't getting scrambled + # read on odd boundaries to make sure the bytes aren't getting + # scrambled n = 0 k2blob = kblob + kblob chunk = 629 @@ -140,7 +139,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_4_prefetch_seek(self, sftp): + def test_prefetch_seek(self, sftp): kblob = bytes().join([struct.pack(">H", n) for n in range(512)]) try: with sftp.open("%s/hongry.txt" % sftp.FOLDER, "wb") as f: @@ -180,7 +179,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_5_readv_seek(self, sftp): + def test_readv_seek(self, sftp): kblob = bytes().join([struct.pack(">H", n) for n in range(512)]) try: with sftp.open("%s/hongry.txt" % sftp.FOLDER, "wb") as f: @@ -220,7 +219,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_6_lots_of_prefetching(self, sftp): + def test_lots_of_prefetching(self, sftp): """ prefetch a 1MB file a bunch of times, discarding the file object without using it, to verify that paramiko doesn't get confused. @@ -255,7 +254,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_7_prefetch_readv(self, sftp): + def test_prefetch_readv(self, sftp): """ verify that prefetch and readv don't conflict with each other. """ @@ -296,7 +295,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_8_large_readv(self, sftp): + def test_large_readv(self, sftp): """ verify that a very large readv is broken up correctly and still returned as a single blob. @@ -325,7 +324,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_9_big_file_big_buffer(self, sftp): + def test_big_file_big_buffer(self, sftp): """ write a 1MB file, with no linefeeds, and a big buffer. """ @@ -342,7 +341,7 @@ class TestBigSFTP(object): finally: sftp.remove("%s/hongry.txt" % sftp.FOLDER) - def test_A_big_file_renegotiate(self, sftp): + def test_big_file_renegotiate(self, sftp): """ write a 1MB file, forcing key renegotiation in the middle. """ 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 b6b50152..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() @@ -139,16 +139,16 @@ class GSSAuthTest(unittest.TestCase): stdout.close() stderr.close() - def test_1_gss_auth(self): + def test_gss_auth(self): """ Verify that Paramiko can handle SSHv2 GSS-API / SSPI authentication (gssapi-with-mic) in client and server mode. """ self._test_connection(allow_agent=False, look_for_keys=False) - def test_2_auth_trickledown(self): + def test_auth_trickledown(self): """ - Failed gssapi-with-mic auth doesn't prevent subsequent key auth from succeeding + Failed gssapi-with-mic doesn't prevent subsequent key from succeeding """ self.hostname = ( "this_host_does_not_exists_and_causes_a_GSSAPI-exception" diff --git a/tests/test_transport.py b/tests/test_transport.py index 2b8ee3bc..e2174896 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -28,36 +28,32 @@ import socket import time import threading import random -from hashlib import sha1 import unittest from mock import Mock from paramiko import ( - Transport, - SecurityOptions, - ServerInterface, - RSAKey, - DSSKey, - SSHException, + AuthHandler, ChannelException, + DSSKey, Packetizer, - Channel, - AuthHandler, + RSAKey, + SSHException, + SecurityOptions, + ServerInterface, + Transport, ) from paramiko import AUTH_FAILED, AUTH_SUCCESSFUL from paramiko import OPEN_SUCCEEDED, OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED from paramiko.common import ( - MSG_KEXINIT, - cMSG_CHANNEL_WINDOW_ADJUST, - cMSG_UNIMPLEMENTED, + DEFAULT_MAX_PACKET_SIZE, + DEFAULT_WINDOW_SIZE, + MAX_WINDOW_SIZE, MIN_PACKET_SIZE, MIN_WINDOW_SIZE, - MAX_WINDOW_SIZE, - DEFAULT_WINDOW_SIZE, - DEFAULT_MAX_PACKET_SIZE, - MSG_NAMES, - MSG_UNIMPLEMENTED, + MSG_KEXINIT, MSG_USERAUTH_SUCCESS, + cMSG_CHANNEL_WINDOW_ADJUST, + cMSG_UNIMPLEMENTED, ) from paramiko.py3compat import bytes, byte_chr from paramiko.message import Message @@ -185,7 +181,7 @@ class TransportTest(unittest.TestCase): self.assertTrue(event.is_set()) self.assertTrue(self.ts.is_active()) - def test_1_security_options(self): + def test_security_options(self): o = self.tc.get_security_options() self.assertEqual(type(o), SecurityOptions) self.assertTrue(("aes256-cbc", "blowfish-cbc") != o.ciphers) @@ -202,7 +198,7 @@ class TransportTest(unittest.TestCase): except TypeError: pass - def test_1b_security_options_reset(self): + def testb_security_options_reset(self): o = self.tc.get_security_options() # should not throw any exceptions o.ciphers = o.ciphers @@ -211,17 +207,17 @@ class TransportTest(unittest.TestCase): o.kex = o.kex o.compression = o.compression - def test_2_compute_key(self): - self.tc.K = 123281095979686581523377256114209720774539068973101330872763622971399429481072519713536292772709507296759612401802191955568143056534122385270077606457721553469730659233569339356140085284052436697480759510519672848743794433460113118986816826624865291116513647975790797391795651716378444844877749505443714557929 - self.tc.H = b"\x0C\x83\x07\xCD\xE6\x85\x6F\xF3\x0B\xA9\x36\x84\xEB\x0F\x04\xC2\x52\x0E\x9E\xD3" + def test_compute_key(self): + self.tc.K = 123281095979686581523377256114209720774539068973101330872763622971399429481072519713536292772709507296759612401802191955568143056534122385270077606457721553469730659233569339356140085284052436697480759510519672848743794433460113118986816826624865291116513647975790797391795651716378444844877749505443714557929 # noqa + self.tc.H = b"\x0C\x83\x07\xCD\xE6\x85\x6F\xF3\x0B\xA9\x36\x84\xEB\x0F\x04\xC2\x52\x0E\x9E\xD3" # noqa self.tc.session_id = self.tc.H key = self.tc._compute_key("C", 32) self.assertEqual( - b"207E66594CA87C44ECCBA3B3CD39FDDB378E6FDB0F97C54B2AA0CFBF900CD995", + b"207E66594CA87C44ECCBA3B3CD39FDDB378E6FDB0F97C54B2AA0CFBF900CD995", # noqa hexlify(key).upper(), ) - def test_3_simple(self): + def test_simple(self): """ verify that we can establish an ssh link with ourselves across the loopback sockets. this is hardly "simple" but it's simpler than the @@ -249,7 +245,7 @@ class TransportTest(unittest.TestCase): self.assertEqual(True, self.tc.is_authenticated()) self.assertEqual(True, self.ts.is_authenticated()) - def test_3a_long_banner(self): + def testa_long_banner(self): """ verify that a long banner doesn't mess up the handshake. """ @@ -268,7 +264,7 @@ class TransportTest(unittest.TestCase): self.assertTrue(event.is_set()) self.assertTrue(self.ts.is_active()) - def test_4_special(self): + def test_special(self): """ verify that the client can demand odd handshake settings, and can renegotiate keys in mid-stream. @@ -289,7 +285,7 @@ class TransportTest(unittest.TestCase): self.ts.send_ignore(1024) @slow - def test_5_keepalive(self): + def test_keepalive(self): """ verify that the keepalive will be sent. """ @@ -299,7 +295,7 @@ class TransportTest(unittest.TestCase): time.sleep(2) self.assertEqual("keepalive@lag.net", self.server._global_request) - def test_6_exec_command(self): + def test_exec_command(self): """ verify that exec_command() does something reasonable. """ @@ -343,7 +339,7 @@ class TransportTest(unittest.TestCase): self.assertEqual("This is on stderr.\n", f.readline()) self.assertEqual("", f.readline()) - def test_6a_channel_can_be_used_as_context_manager(self): + def testa_channel_can_be_used_as_context_manager(self): """ verify that exec_command() does something reasonable. """ @@ -359,7 +355,7 @@ class TransportTest(unittest.TestCase): self.assertEqual("Hello there.\n", f.readline()) self.assertEqual("", f.readline()) - def test_7_invoke_shell(self): + def test_invoke_shell(self): """ verify that invoke_shell() does something reasonable. """ @@ -373,18 +369,18 @@ class TransportTest(unittest.TestCase): chan.close() self.assertEqual("", f.readline()) - def test_8_channel_exception(self): + def test_channel_exception(self): """ verify that ChannelException is thrown for a bad open-channel request. """ self.setup_test_server() try: - chan = self.tc.open_channel("bogus") + self.tc.open_channel("bogus") self.fail("expected exception") except ChannelException as e: self.assertTrue(e.code == OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED) - def test_9_exit_status(self): + def test_exit_status(self): """ verify that get_exit_status() works. """ @@ -413,7 +409,7 @@ class TransportTest(unittest.TestCase): self.assertEqual(23, chan.recv_exit_status()) chan.close() - def test_A_select(self): + def test_select(self): """ verify that select() on a channel works. """ @@ -468,7 +464,7 @@ class TransportTest(unittest.TestCase): # ...and now is closed. self.assertEqual(True, p._closed) - def test_B_renegotiate(self): + def test_renegotiate(self): """ verify that a transport can correctly renegotiate mid-stream. """ @@ -492,7 +488,7 @@ class TransportTest(unittest.TestCase): schan.close() - def test_C_compression(self): + def test_compression(self): """ verify that zlib compression is basically working. """ @@ -510,14 +506,15 @@ class TransportTest(unittest.TestCase): bytes2 = self.tc.packetizer._Packetizer__sent_bytes block_size = self.tc._cipher_info[self.tc.local_cipher]["block-size"] mac_size = self.tc._mac_info[self.tc.local_mac]["size"] - # tests show this is actually compressed to *52 bytes*! including packet overhead! nice!! :) + # tests show this is actually compressed to *52 bytes*! including + # packet overhead! nice!! :) self.assertTrue(bytes2 - bytes < 1024) self.assertEqual(16 + block_size + mac_size, bytes2 - bytes) chan.close() schan.close() - def test_D_x11(self): + def test_x11(self): """ verify that an x11 port can be requested and opened. """ @@ -555,7 +552,7 @@ class TransportTest(unittest.TestCase): chan.close() schan.close() - def test_E_reverse_port_forwarding(self): + def test_reverse_port_forwarding(self): """ verify that a client can ask the server to open a reverse port for forwarding. @@ -563,7 +560,7 @@ class TransportTest(unittest.TestCase): self.setup_test_server() chan = self.tc.open_session() chan.exec_command("yes") - schan = self.ts.accept(1.0) + self.ts.accept(1.0) requested = [] @@ -594,7 +591,7 @@ class TransportTest(unittest.TestCase): self.tc.cancel_port_forward("127.0.0.1", port) self.assertTrue(self.server._listen is None) - def test_F_port_forwarding(self): + def test_port_forwarding(self): """ verify that a client can forward new connections from a locally- forwarded port. @@ -602,7 +599,7 @@ class TransportTest(unittest.TestCase): self.setup_test_server() chan = self.tc.open_session() chan.exec_command("yes") - schan = self.ts.accept(1.0) + self.ts.accept(1.0) # open a port on the "server" that the client will ask to forward to. greeting_server = socket.socket() @@ -626,7 +623,7 @@ class TransportTest(unittest.TestCase): self.assertEqual(b"Hello!\n", cs.recv(7)) cs.close() - def test_G_stderr_select(self): + def test_stderr_select(self): """ verify that select() on a channel works even if only stderr is receiving data. @@ -665,7 +662,7 @@ class TransportTest(unittest.TestCase): schan.close() chan.close() - def test_H_send_ready(self): + def test_send_ready(self): """ verify that send_ready() indicates when a send would not block. """ @@ -689,9 +686,10 @@ class TransportTest(unittest.TestCase): chan.close() self.assertEqual(chan.send_ready(), True) - def test_I_rekey_deadlock(self): + def test_rekey_deadlock(self): """ - Regression test for deadlock when in-transit messages are received after MSG_KEXINIT is sent + Regression test for deadlock when in-transit messages are received + after MSG_KEXINIT is sent Note: When this test fails, it may leak threads. """ @@ -714,12 +712,15 @@ class TransportTest(unittest.TestCase): # MSG_KEXINIT to the remote host. # # On the remote host (using any SSH implementation): - # 5. The MSG_CHANNEL_DATA is received, and MSG_CHANNEL_WINDOW_ADJUST is sent. - # 6. The MSG_KEXINIT is received, and a corresponding MSG_KEXINIT is sent. + # 5. The MSG_CHANNEL_DATA is received, and MSG_CHANNEL_WINDOW_ADJUST + # is sent. + # 6. The MSG_KEXINIT is received, and a corresponding MSG_KEXINIT is + # sent. # # In the main thread: # 7. The user's program calls Channel.send(). - # 8. Channel.send acquires Channel.lock, then calls Transport._send_user_message(). + # 8. Channel.send acquires Channel.lock, then calls + # Transport._send_user_message(). # 9. Transport._send_user_message waits for Transport.clear_to_send # to be set (i.e., it waits for re-keying to complete). # Channel.lock is still held. @@ -854,7 +855,7 @@ class TransportTest(unittest.TestCase): schan.close() chan.close() - def test_J_sanitze_packet_size(self): + def test_sanitze_packet_size(self): """ verify that we conform to the rfc of packet and window sizes. """ @@ -865,7 +866,7 @@ class TransportTest(unittest.TestCase): ]: self.assertEqual(self.tc._sanitize_packet_size(val), correct) - def test_K_sanitze_window_size(self): + def test_sanitze_window_size(self): """ verify that we conform to the rfc of packet and window sizes. """ @@ -877,7 +878,7 @@ class TransportTest(unittest.TestCase): self.assertEqual(self.tc._sanitize_window_size(val), correct) @slow - def test_L_handshake_timeout(self): + def test_handshake_timeout(self): """ verify that we can get a hanshake timeout. """ @@ -914,7 +915,7 @@ class TransportTest(unittest.TestCase): password="pygmalion", ) - def test_M_select_after_close(self): + def test_select_after_close(self): """ verify that select works when a channel is already closed. """ @@ -969,11 +970,11 @@ class TransportTest(unittest.TestCase): # send() accepts buffer instances sent = 0 while sent < len(data): - sent += chan.send(buffer(data, sent, 8)) + sent += chan.send(buffer(data, sent, 8)) # noqa self.assertEqual(sfile.read(len(data)), data) # sendall() accepts a buffer instance - chan.sendall(buffer(data)) + chan.sendall(buffer(data)) # noqa self.assertEqual(sfile.read(len(data)), data) @needs_builtin("memoryview") @@ -1101,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/test_util.py b/tests/test_util.py index 705baa14..8ce260d1 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -26,32 +26,12 @@ import os from hashlib import sha1 import unittest +import paramiko import paramiko.util -from paramiko.util import lookup_ssh_host_config as host_config, safe_string -from paramiko.py3compat import StringIO, byte_ord, b +from paramiko.util import safe_string +from paramiko.py3compat import byte_ord -# Note some lines in this configuration have trailing spaces on purpose -test_config_file = """\ -Host * - User robey - IdentityFile =~/.ssh/id_rsa - -# comment -Host *.example.com - \tUser bjork -Port=3333 -Host * -""" - -dont_strip_whitespace_please = "\t \t Crazy something dumb " - -test_config_file += dont_strip_whitespace_please -test_config_file += """ -Host spoo.example.com -Crazy something else -""" - test_hosts_file = """\ secure.example.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA1PD6U2/TVxET6lkpKhOk5r\ 9q/kAYG6sP9f5zuUYP8i7FOFp/6ncCEbbtg/lB+A3iidyxoSWl+9jtoyyDOOVX4UIDV9G11Ml8om3\ @@ -62,155 +42,75 @@ BGQ3GQ/Fc7SX6gkpXkwcZryoi4kNFhHu5LvHcZPdxXV1D+uTMfGS1eyd2Yz/DoNWXNAl8TI0cAsW\ """ -# for test 1: -from paramiko import * - - class UtilTest(unittest.TestCase): - def test_import(self): + def test_imports(self): """ verify that all the classes can be imported from paramiko. """ - symbols = list(globals().keys()) - self.assertTrue("Transport" in symbols) - self.assertTrue("SSHClient" in symbols) - self.assertTrue("MissingHostKeyPolicy" in symbols) - self.assertTrue("AutoAddPolicy" in symbols) - self.assertTrue("RejectPolicy" in symbols) - self.assertTrue("WarningPolicy" in symbols) - self.assertTrue("SecurityOptions" in symbols) - self.assertTrue("SubsystemHandler" in symbols) - self.assertTrue("Channel" in symbols) - self.assertTrue("RSAKey" in symbols) - self.assertTrue("DSSKey" in symbols) - self.assertTrue("Message" in symbols) - self.assertTrue("SSHException" in symbols) - self.assertTrue("AuthenticationException" in symbols) - self.assertTrue("PasswordRequiredException" in symbols) - self.assertTrue("BadAuthenticationType" in symbols) - self.assertTrue("ChannelException" in symbols) - self.assertTrue("SFTP" in symbols) - self.assertTrue("SFTPFile" in symbols) - self.assertTrue("SFTPHandle" in symbols) - self.assertTrue("SFTPClient" in symbols) - self.assertTrue("SFTPServer" in symbols) - self.assertTrue("SFTPError" in symbols) - self.assertTrue("SFTPAttributes" in symbols) - self.assertTrue("SFTPServerInterface" in symbols) - self.assertTrue("ServerInterface" in symbols) - self.assertTrue("BufferedFile" in symbols) - self.assertTrue("Agent" in symbols) - self.assertTrue("AgentKey" in symbols) - self.assertTrue("HostKeys" in symbols) - self.assertTrue("SSHConfig" in symbols) - self.assertTrue("util" in symbols) - - def test_parse_config(self): - global test_config_file - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - self.assertEqual( - config._config, - [ - {"host": ["*"], "config": {}}, - { - "host": ["*"], - "config": { - "identityfile": ["~/.ssh/id_rsa"], - "user": "robey", - }, - }, - { - "host": ["*.example.com"], - "config": {"user": "bjork", "port": "3333"}, - }, - {"host": ["*"], "config": {"crazy": "something dumb"}}, - { - "host": ["spoo.example.com"], - "config": {"crazy": "something else"}, - }, - ], - ) - - def test_host_config(self): - global test_config_file - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - - for host, values in { - "irc.danger.com": { - "crazy": "something dumb", - "hostname": "irc.danger.com", - "user": "robey", - }, - "irc.example.com": { - "crazy": "something dumb", - "hostname": "irc.example.com", - "user": "robey", - "port": "3333", - }, - "spoo.example.com": { - "crazy": "something dumb", - "hostname": "spoo.example.com", - "user": "robey", - "port": "3333", - }, - }.items(): - values = dict( - values, - hostname=host, - identityfile=[os.path.expanduser("~/.ssh/id_rsa")], - ) - self.assertEqual( - paramiko.util.lookup_ssh_host_config(host, config), values - ) + for name in ( + "Agent", + "AgentKey", + "AuthenticationException", + "AutoAddPolicy", + "BadAuthenticationType", + "BufferedFile", + "Channel", + "ChannelException", + "ConfigParseError", + "CouldNotCanonicalize", + "DSSKey", + "HostKeys", + "Message", + "MissingHostKeyPolicy", + "PasswordRequiredException", + "RSAKey", + "RejectPolicy", + "SFTP", + "SFTPAttributes", + "SFTPClient", + "SFTPError", + "SFTPFile", + "SFTPHandle", + "SFTPServer", + "SFTPServerInterface", + "SSHClient", + "SSHConfig", + "SSHConfigDict", + "SSHException", + "SecurityOptions", + "ServerInterface", + "SubsystemHandler", + "Transport", + "WarningPolicy", + "util", + ): + assert name in paramiko.__all__ def test_generate_key_bytes(self): x = paramiko.util.generate_key_bytes( sha1, b"ABCDEFGH", "This is my secret passphrase.", 64 ) hex = "".join(["%02x" % byte_ord(c) for c in x]) - self.assertEqual( - hex, - "9110e2f6793b69363e58173e9436b13a5a4b339005741d5c680e505f57d871347b4239f14fb5c46e857d5e100424873ba849ac699cea98d729e57b3e84378e8b", - ) + hexpected = "9110e2f6793b69363e58173e9436b13a5a4b339005741d5c680e505f57d871347b4239f14fb5c46e857d5e100424873ba849ac699cea98d729e57b3e84378e8b" # noqa + assert hex == hexpected def test_host_keys(self): with open("hostfile.temp", "w") as f: f.write(test_hosts_file) try: hostdict = paramiko.util.load_host_keys("hostfile.temp") - self.assertEqual(2, len(hostdict)) - self.assertEqual(1, len(list(hostdict.values())[0])) - self.assertEqual(1, len(list(hostdict.values())[1])) + assert 2 == len(hostdict) + assert 1 == len(list(hostdict.values())[0]) + assert 1 == len(list(hostdict.values())[1]) fp = hexlify( hostdict["secure.example.com"]["ssh-rsa"].get_fingerprint() ).upper() - self.assertEqual(b"E6684DB30E109B67B70FF1DC5C7F1363", fp) + assert b"E6684DB30E109B67B70FF1DC5C7F1363" == fp finally: os.unlink("hostfile.temp") - def test_host_config_expose_issue_33(self): - test_config_file = """ -Host www13.* - Port 22 - -Host *.example.com - Port 2222 - -Host * - Port 3333 - """ - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - host = "www13.example.com" - self.assertEqual( - paramiko.util.lookup_ssh_host_config(host, config), - {"hostname": host, "port": "22"}, - ) - def test_eintr_retry(self): - self.assertEqual("foo", paramiko.util.retry_on_signal(lambda: "foo")) + assert "foo" == paramiko.util.retry_on_signal(lambda: "foo") # Variables that are set by raises_intr intr_errors_remaining = [3] @@ -223,8 +123,8 @@ Host * raise IOError(errno.EINTR, "file", "interrupted system call") self.assertTrue(paramiko.util.retry_on_signal(raises_intr) is None) - self.assertEqual(0, intr_errors_remaining[0]) - self.assertEqual(4, call_count[0]) + assert 0 == intr_errors_remaining[0] + assert 4 == call_count[0] def raises_ioerror_not_eintr(): raise IOError(errno.ENOENT, "file", "file not found") @@ -242,273 +142,10 @@ Host * lambda: paramiko.util.retry_on_signal(raises_other_exception), ) - def test_proxycommand_config_equals_parsing(self): - """ - ProxyCommand should not split on equals signs within the value. - """ - conf = """ -Host space-delimited - ProxyCommand foo bar=biz baz - -Host equals-delimited - ProxyCommand=foo bar=biz baz -""" - f = StringIO(conf) - config = paramiko.util.parse_ssh_config(f) - for host in ("space-delimited", "equals-delimited"): - self.assertEqual( - host_config(host, config)["proxycommand"], "foo bar=biz baz" - ) - - def test_proxycommand_interpolation(self): - """ - ProxyCommand should perform interpolation on the value - """ - config = paramiko.util.parse_ssh_config( - StringIO( - """ -Host specific - Port 37 - ProxyCommand host %h port %p lol - -Host portonly - Port 155 - -Host * - Port 25 - ProxyCommand host %h port %p -""" - ) - ) - for host, val in ( - ("foo.com", "host foo.com port 25"), - ("specific", "host specific port 37 lol"), - ("portonly", "host portonly port 155"), - ): - self.assertEqual(host_config(host, config)["proxycommand"], val) - - def test_proxycommand_tilde_expansion(self): - """ - Tilde (~) should be expanded inside ProxyCommand - """ - config = paramiko.util.parse_ssh_config( - StringIO( - """ -Host test - ProxyCommand ssh -F ~/.ssh/test_config bastion nc %h %p -""" - ) - ) - self.assertEqual( - "ssh -F %s/.ssh/test_config bastion nc test 22" - % os.path.expanduser("~"), - host_config("test", config)["proxycommand"], - ) - - def test_host_config_test_negation(self): - test_config_file = """ -Host www13.* !*.example.com - Port 22 - -Host *.example.com !www13.* - Port 2222 - -Host www13.* - Port 8080 - -Host * - Port 3333 - """ - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - host = "www13.example.com" - self.assertEqual( - paramiko.util.lookup_ssh_host_config(host, config), - {"hostname": host, "port": "8080"}, - ) - - def test_host_config_test_proxycommand(self): - test_config_file = """ -Host proxy-with-equal-divisor-and-space -ProxyCommand = foo=bar - -Host proxy-with-equal-divisor-and-no-space -ProxyCommand=foo=bar - -Host proxy-without-equal-divisor -ProxyCommand foo=bar:%h-%p - """ - for host, values in { - "proxy-with-equal-divisor-and-space": { - "hostname": "proxy-with-equal-divisor-and-space", - "proxycommand": "foo=bar", - }, - "proxy-with-equal-divisor-and-no-space": { - "hostname": "proxy-with-equal-divisor-and-no-space", - "proxycommand": "foo=bar", - }, - "proxy-without-equal-divisor": { - "hostname": "proxy-without-equal-divisor", - "proxycommand": "foo=bar:proxy-without-equal-divisor-22", - }, - }.items(): - - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - self.assertEqual( - paramiko.util.lookup_ssh_host_config(host, config), values - ) - - def test_host_config_test_identityfile(self): - test_config_file = """ - -IdentityFile id_dsa0 - -Host * -IdentityFile id_dsa1 - -Host dsa2 -IdentityFile id_dsa2 - -Host dsa2* -IdentityFile id_dsa22 - """ - for host, values in { - "foo": {"hostname": "foo", "identityfile": ["id_dsa0", "id_dsa1"]}, - "dsa2": { - "hostname": "dsa2", - "identityfile": ["id_dsa0", "id_dsa1", "id_dsa2", "id_dsa22"], - }, - "dsa22": { - "hostname": "dsa22", - "identityfile": ["id_dsa0", "id_dsa1", "id_dsa22"], - }, - }.items(): - - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - self.assertEqual( - paramiko.util.lookup_ssh_host_config(host, config), values - ) - - def test_config_addressfamily_and_lazy_fqdn(self): - """ - Ensure the code path honoring non-'all' AddressFamily doesn't asplode - """ - test_config = """ -AddressFamily inet -IdentityFile something_%l_using_fqdn -""" - config = paramiko.util.parse_ssh_config(StringIO(test_config)) - assert config.lookup( - "meh" - ) # will die during lookup() if bug regresses - def test_clamp_value(self): - self.assertEqual(32768, paramiko.util.clamp_value(32767, 32768, 32769)) - self.assertEqual(32767, paramiko.util.clamp_value(32767, 32765, 32769)) - self.assertEqual(32769, paramiko.util.clamp_value(32767, 32770, 32769)) - - def test_config_dos_crlf_succeeds(self): - config_file = StringIO("host abcqwerty\r\nHostName 127.0.0.1\r\n") - config = paramiko.SSHConfig() - config.parse(config_file) - self.assertEqual(config.lookup("abcqwerty")["hostname"], "127.0.0.1") - - def test_get_hostnames(self): - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - self.assertEqual( - config.get_hostnames(), {"*", "*.example.com", "spoo.example.com"} - ) - - def test_quoted_host_names(self): - test_config_file = """\ -Host "param pam" param "pam" - Port 1111 - -Host "param2" - Port 2222 - -Host param3 parara - Port 3333 - -Host param4 "p a r" "p" "par" para - Port 4444 -""" - res = { - "param pam": {"hostname": "param pam", "port": "1111"}, - "param": {"hostname": "param", "port": "1111"}, - "pam": {"hostname": "pam", "port": "1111"}, - "param2": {"hostname": "param2", "port": "2222"}, - "param3": {"hostname": "param3", "port": "3333"}, - "parara": {"hostname": "parara", "port": "3333"}, - "param4": {"hostname": "param4", "port": "4444"}, - "p a r": {"hostname": "p a r", "port": "4444"}, - "p": {"hostname": "p", "port": "4444"}, - "par": {"hostname": "par", "port": "4444"}, - "para": {"hostname": "para", "port": "4444"}, - } - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - for host, values in res.items(): - self.assertEquals( - paramiko.util.lookup_ssh_host_config(host, config), values - ) - - def test_quoted_params_in_config(self): - test_config_file = """\ -Host "param pam" param "pam" - IdentityFile id_rsa - -Host "param2" - IdentityFile "test rsa key" - -Host param3 parara - IdentityFile id_rsa - IdentityFile "test rsa key" -""" - res = { - "param pam": {"hostname": "param pam", "identityfile": ["id_rsa"]}, - "param": {"hostname": "param", "identityfile": ["id_rsa"]}, - "pam": {"hostname": "pam", "identityfile": ["id_rsa"]}, - "param2": {"hostname": "param2", "identityfile": ["test rsa key"]}, - "param3": { - "hostname": "param3", - "identityfile": ["id_rsa", "test rsa key"], - }, - "parara": { - "hostname": "parara", - "identityfile": ["id_rsa", "test rsa key"], - }, - } - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - for host, values in res.items(): - self.assertEquals( - paramiko.util.lookup_ssh_host_config(host, config), values - ) - - def test_quoted_host_in_config(self): - conf = SSHConfig() - correct_data = { - "param": ["param"], - '"param"': ["param"], - "param pam": ["param", "pam"], - '"param" "pam"': ["param", "pam"], - '"param" pam': ["param", "pam"], - 'param "pam"': ["param", "pam"], - 'param "pam" p': ["param", "pam", "p"], - '"param" pam "p"': ["param", "pam", "p"], - '"pa ram"': ["pa ram"], - '"pa ram" pam': ["pa ram", "pam"], - 'param "p a m"': ["param", "p a m"], - } - incorrect_data = ['param"', '"param', 'param "pam', 'param "pam" "p a'] - for host, values in correct_data.items(): - self.assertEquals(conf._get_hosts(host), values) - for host in incorrect_data: - self.assertRaises(Exception, conf._get_hosts, host) + assert 32768 == paramiko.util.clamp_value(32767, 32768, 32769) + assert 32767 == paramiko.util.clamp_value(32767, 32765, 32769) + assert 32769 == paramiko.util.clamp_value(32767, 32770, 32769) def test_safe_string(self): vanilla = b"vanilla" @@ -521,54 +158,3 @@ Host param3 parara assert safe_vanilla == vanilla, msg msg = err.format(safe_has_bytes, expected_bytes) assert safe_has_bytes == expected_bytes, msg - - def test_proxycommand_none_issue_418(self): - test_config_file = """ -Host proxycommand-standard-none - ProxyCommand None - -Host proxycommand-with-equals-none - ProxyCommand=None - """ - for host, values in { - "proxycommand-standard-none": { - "hostname": "proxycommand-standard-none" - }, - "proxycommand-with-equals-none": { - "hostname": "proxycommand-with-equals-none" - }, - }.items(): - - f = StringIO(test_config_file) - config = paramiko.util.parse_ssh_config(f) - self.assertEqual( - paramiko.util.lookup_ssh_host_config(host, config), values - ) - - def test_proxycommand_none_masking(self): - # Re: https://github.com/paramiko/paramiko/issues/670 - source_config = """ -Host specific-host - ProxyCommand none - -Host other-host - ProxyCommand other-proxy - -Host * - ProxyCommand default-proxy -""" - config = paramiko.SSHConfig() - config.parse(StringIO(source_config)) - # When bug is present, the full stripping-out of specific-host's - # ProxyCommand means it actually appears to pick up the default - # ProxyCommand value instead, due to cascading. It should (for - # backwards compatibility reasons in 1.x/2.x) appear completely blank, - # as if the host had no ProxyCommand whatsoever. - # Threw another unrelated host in there just for sanity reasons. - self.assertFalse("proxycommand" in config.lookup("specific-host")) - self.assertEqual( - config.lookup("other-host")["proxycommand"], "other-proxy" - ) - self.assertEqual( - config.lookup("some-random-host")["proxycommand"], "default-proxy" - ) diff --git a/tests/util.py b/tests/util.py index 4ca02374..9057f516 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,19 +1,28 @@ 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 + + +tests_dir = dirname(realpath(__file__)) def _support(filename): - return join(dirname(realpath(__file__)), filename) + return join(tests_dir, filename) + +def _config(name): + return join(tests_dir, "configs", name) -# 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 +34,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)) |