diff options
-rw-r--r-- | .travis.yml | 4 | ||||
-rw-r--r-- | README.rst | 4 | ||||
-rw-r--r-- | codecov.yml | 1 | ||||
-rw-r--r-- | paramiko/auth_handler.py | 9 | ||||
-rw-r--r-- | paramiko/client.py | 10 | ||||
-rw-r--r-- | paramiko/common.py | 22 | ||||
-rw-r--r-- | paramiko/dsskey.py | 8 | ||||
-rw-r--r-- | paramiko/ecdsakey.py | 6 | ||||
-rw-r--r-- | paramiko/file.py | 6 | ||||
-rw-r--r-- | paramiko/hostkeys.py | 7 | ||||
-rw-r--r-- | paramiko/kex_gss.py | 2 | ||||
-rw-r--r-- | paramiko/pkey.py | 10 | ||||
-rw-r--r-- | paramiko/primes.py | 1 | ||||
-rw-r--r-- | paramiko/py3compat.py | 11 | ||||
-rw-r--r-- | paramiko/resource.py | 71 | ||||
-rw-r--r-- | paramiko/rsakey.py | 6 | ||||
-rw-r--r-- | paramiko/sftp_client.py | 44 | ||||
-rw-r--r-- | paramiko/sftp_server.py | 6 | ||||
-rw-r--r-- | paramiko/sftp_si.py | 12 | ||||
-rw-r--r-- | paramiko/transport.py | 28 | ||||
-rw-r--r-- | paramiko/win_pageant.py | 2 | ||||
-rw-r--r-- | sites/www/changelog.rst | 57 | ||||
-rw-r--r-- | tests/__init__.py | 36 | ||||
-rw-r--r-- | tests/stub_sftp.py | 14 | ||||
-rw-r--r-- | tests/test_auth.py | 19 | ||||
-rw-r--r-- | tests/test_client.py | 30 | ||||
-rwxr-xr-x | tests/test_file.py | 57 | ||||
-rw-r--r-- | tests/test_pkey.py | 35 | ||||
-rwxr-xr-x | tests/test_sftp.py | 63 | ||||
-rw-r--r-- | tests/test_transport.py | 69 |
30 files changed, 468 insertions, 182 deletions
diff --git a/.travis.yml b/.travis.yml index c8faf0a2..7ca8db09 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ install: # Self-install for setup.py-driven deps - pip install -e . # Dev (doc/test running) requirements - - pip install coveralls # For coveralls.io specifically + - pip install codecov # For codecov specifically - pip install -r dev-requirements.txt script: # Main tests, w/ coverage! @@ -33,4 +33,4 @@ notifications: on_failure: change email: false after_success: - - coveralls + - codecov @@ -6,8 +6,8 @@ Paramiko .. image:: https://travis-ci.org/paramiko/paramiko.svg?branch=master :target: https://travis-ci.org/paramiko/paramiko -.. image:: https://coveralls.io/repos/paramiko/paramiko/badge.svg?branch=master&service=github - :target: https://coveralls.io/github/paramiko/paramiko?branch=master +.. image:: https://codecov.io/gh/paramiko/paramiko/branch/master/graph/badge.svg + :target: https://codecov.io/gh/paramiko/paramiko :Paramiko: Python SSH module :Copyright: Copyright (c) 2003-2009 Robey Pointer <robeypointer@gmail.com> diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..69cb7601 --- /dev/null +++ b/codecov.yml @@ -0,0 +1 @@ +comment: false diff --git a/paramiko/auth_handler.py b/paramiko/auth_handler.py index 33f01da6..ae88179e 100644 --- a/paramiko/auth_handler.py +++ b/paramiko/auth_handler.py @@ -21,6 +21,8 @@ """ import weakref +import time + from paramiko.common import ( cMSG_SERVICE_REQUEST, cMSG_DISCONNECT, DISCONNECT_SERVICE_NOT_AVAILABLE, DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, cMSG_USERAUTH_REQUEST, @@ -35,7 +37,6 @@ from paramiko.common import ( MSG_USERAUTH_GSSAPI_TOKEN, MSG_USERAUTH_GSSAPI_ERROR, MSG_USERAUTH_GSSAPI_ERRTOK, MSG_USERAUTH_GSSAPI_MIC, MSG_NAMES, ) - from paramiko.message import Message from paramiko.py3compat import bytestring from paramiko.ssh_exception import ( @@ -190,6 +191,9 @@ class AuthHandler (object): return m.asbytes() def wait_for_response(self, event): + max_ts = None + if self.transport.auth_timeout is not None: + max_ts = time.time() + self.transport.auth_timeout while True: event.wait(0.1) if not self.transport.is_active(): @@ -199,6 +203,9 @@ class AuthHandler (object): raise e if event.is_set(): break + if max_ts is not None and max_ts <= time.time(): + raise AuthenticationException('Authentication timeout.') + if not self.is_authenticated(): e = self.transport.get_exception() if e is None: diff --git a/paramiko/client.py b/paramiko/client.py index 08fe69d4..b2c21798 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -36,7 +36,6 @@ from paramiko.ecdsakey import ECDSAKey from paramiko.ed25519key import Ed25519Key from paramiko.hostkeys import HostKeys from paramiko.py3compat import string_types -from paramiko.resource import ResourceManager from paramiko.rsakey import RSAKey from paramiko.ssh_exception import ( SSHException, BadHostKeyException, NoValidConnectionsError @@ -227,7 +226,8 @@ class SSHClient (ClosingContextManager): gss_kex=False, gss_deleg_creds=True, gss_host=None, - banner_timeout=None + banner_timeout=None, + auth_timeout=None, ): """ Connect to an SSH server and authenticate to it. The server's host key @@ -279,6 +279,8 @@ class SSHClient (ClosingContextManager): The targets name in the kerberos database. default: hostname :param float banner_timeout: an optional timeout (in seconds) to wait for the SSH banner to be presented. + :param float auth_timeout: an optional timeout (in seconds) to wait for + an authentication response. :raises: `.BadHostKeyException` -- if the server's host key could not be @@ -339,9 +341,9 @@ class SSHClient (ClosingContextManager): t.set_log_channel(self._log_channel) if banner_timeout is not None: t.banner_timeout = banner_timeout + if auth_timeout is not None: + t.auth_timeout = auth_timeout t.start_client(timeout=timeout) - t.set_sshclient(self) - ResourceManager.register(self, t) server_key = t.get_remote_server_key() keytype = server_key.get_name() diff --git a/paramiko/common.py b/paramiko/common.py index 556f046a..0012372a 100644 --- a/paramiko/common.py +++ b/paramiko/common.py @@ -20,9 +20,7 @@ Common constants and global variables. """ import logging -from paramiko.py3compat import ( - byte_chr, PY2, bytes_types, string_types, b, long, -) +from paramiko.py3compat import byte_chr, PY2, bytes_types, text_type, long MSG_DISCONNECT, MSG_IGNORE, MSG_UNIMPLEMENTED, MSG_DEBUG, \ MSG_SERVICE_REQUEST, MSG_SERVICE_ACCEPT = range(1, 7) @@ -163,14 +161,16 @@ else: def asbytes(s): - if not isinstance(s, bytes_types): - if isinstance(s, string_types): - s = b(s) - else: - try: - s = s.asbytes() - except Exception: - raise Exception('Unknown type') + """Coerce to bytes if possible or return unchanged.""" + if isinstance(s, bytes_types): + return s + if isinstance(s, text_type): + # Accept text and encode as utf-8 for compatibility only. + return s.encode("utf-8") + asbytes = getattr(s, "asbytes", None) + if asbytes is not None: + return asbytes() + # May be an object that implements the buffer api, let callers handle. return s diff --git a/paramiko/dsskey.py b/paramiko/dsskey.py index 55ef1e9b..9af5d0c1 100644 --- a/paramiko/dsskey.py +++ b/paramiko/dsskey.py @@ -83,13 +83,7 @@ class DSSKey(PKey): return self.asbytes() def __hash__(self): - h = hash(self.get_name()) - h = h * 37 + hash(self.p) - h = h * 37 + hash(self.q) - h = h * 37 + hash(self.g) - h = h * 37 + hash(self.y) - # h might be a long by now... - return hash(h) + return hash((self.get_name(), self.p, self.q, self.g, self.y)) def get_name(self): return 'ssh-dss' diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py index f5dacac8..fa850c2e 100644 --- a/paramiko/ecdsakey.py +++ b/paramiko/ecdsakey.py @@ -165,10 +165,8 @@ class ECDSAKey(PKey): return self.asbytes() def __hash__(self): - h = hash(self.get_name()) - h = h * 37 + hash(self.verifying_key.public_numbers().x) - h = h * 37 + hash(self.verifying_key.public_numbers().y) - return hash(h) + return hash((self.get_name(), self.verifying_key.public_numbers().x, + self.verifying_key.public_numbers().y)) def get_name(self): return self.ecdsa_curve.key_format_identifier diff --git a/paramiko/file.py b/paramiko/file.py index 5212091a..a1bdafbe 100644 --- a/paramiko/file.py +++ b/paramiko/file.py @@ -18,7 +18,7 @@ from paramiko.common import ( linefeed_byte_value, crlf, cr_byte, linefeed_byte, cr_byte_value, ) -from paramiko.py3compat import BytesIO, PY2, u, b, bytes_types +from paramiko.py3compat import BytesIO, PY2, u, bytes_types, text_type from paramiko.util import ClosingContextManager @@ -391,7 +391,9 @@ class BufferedFile (ClosingContextManager): :param data: ``str``/``bytes`` data to write """ - data = b(data) + if isinstance(data, text_type): + # Accept text and encode as utf-8 for compatibility only. + data = data.encode('utf-8') if self._closed: raise IOError('File is closed') if not (self._flags & self.FLAG_WRITE): diff --git a/paramiko/hostkeys.py b/paramiko/hostkeys.py index 3e27fd52..d023b33d 100644 --- a/paramiko/hostkeys.py +++ b/paramiko/hostkeys.py @@ -20,17 +20,12 @@ import binascii import os +from collections import MutableMapping from hashlib import sha1 from hmac import HMAC from paramiko.py3compat import b, u, encodebytes, decodebytes -try: - from collections import MutableMapping -except ImportError: - # noinspection PyUnresolvedReferences - from UserDict import DictMixin as MutableMapping - from paramiko.dsskey import DSSKey from paramiko.rsakey import RSAKey from paramiko.util import get_logger, constant_time_bytes_eq diff --git a/paramiko/kex_gss.py b/paramiko/kex_gss.py index ba24c0a0..3406babb 100644 --- a/paramiko/kex_gss.py +++ b/paramiko/kex_gss.py @@ -40,7 +40,7 @@ This module provides GSS-API / SSPI Key Exchange as defined in :rfc:`4462`. import os from hashlib import sha1 -from paramiko.common import * # noqa +from paramiko.common import DEBUG, max_byte, zero_byte from paramiko import util from paramiko.message import Message from paramiko.py3compat import byte_chr, byte_mask, byte_ord diff --git a/paramiko/pkey.py b/paramiko/pkey.py index f5b0cd18..35a26fc7 100644 --- a/paramiko/pkey.py +++ b/paramiko/pkey.py @@ -48,6 +48,12 @@ class PKey(object): 'blocksize': 16, 'mode': modes.CBC }, + 'AES-256-CBC': { + 'cipher': algorithms.AES, + 'keysize': 32, + 'blocksize': 16, + 'mode': modes.CBC + }, 'DES-EDE3-CBC': { 'cipher': algorithms.TripleDES, 'keysize': 24, @@ -344,13 +350,13 @@ class PKey(object): """ with open(filename, 'w') as f: os.chmod(filename, o600) - self._write_private_key(f, key, format) + self._write_private_key(f, key, format, password=password) def _write_private_key(self, f, key, format, password=None): if password is None: encryption = serialization.NoEncryption() else: - encryption = serialization.BestEncryption(password) + encryption = serialization.BestAvailableEncryption(b(password)) f.write(key.private_bytes( serialization.Encoding.PEM, diff --git a/paramiko/primes.py b/paramiko/primes.py index 48a34e53..65617914 100644 --- a/paramiko/primes.py +++ b/paramiko/primes.py @@ -25,7 +25,6 @@ import os from paramiko import util from paramiko.py3compat import byte_mask, long from paramiko.ssh_exception import SSHException -from paramiko.common import * # noqa def _roll_random(n): diff --git a/paramiko/py3compat.py b/paramiko/py3compat.py index 095b0d09..6703ace8 100644 --- a/paramiko/py3compat.py +++ b/paramiko/py3compat.py @@ -65,15 +65,8 @@ if PY2: return s - try: - import cStringIO - - StringIO = cStringIO.StringIO # NOQA - except ImportError: - import StringIO - - StringIO = StringIO.StringIO # NOQA - + import cStringIO + StringIO = cStringIO.StringIO BytesIO = StringIO diff --git a/paramiko/resource.py b/paramiko/resource.py deleted file mode 100644 index 5fed22ad..00000000 --- a/paramiko/resource.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com> -# -# 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 distributed 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. - -""" -Resource manager. -""" - -import weakref - - -class ResourceManager (object): - """ - A registry of objects and resources that should be closed when those - objects are deleted. - - This is meant to be a safer alternative to Python's ``__del__`` method, - which can cause reference cycles to never be collected. Objects registered - with the ResourceManager can be collected but still free resources when - they die. - - Resources are registered using `register`, and when an object is garbage - collected, each registered resource is closed by having its ``close()`` - method called. Multiple resources may be registered per object, but a - resource will only be closed once, even if multiple objects register it. - (The last object to register it wins.) - """ - - def __init__(self): - self._table = {} - - def register(self, obj, resource): - """ - Register a resource to be closed with an object is collected. - - When the given ``obj`` is garbage-collected by the Python interpreter, - the ``resource`` will be closed by having its ``close()`` method - called. Any exceptions are ignored. - - :param object obj: the object to track - :param object resource: - the resource to close when the object is collected - """ - def callback(ref): - try: - resource.close() - except: - pass - del self._table[id(resource)] - - # keep the weakref in a table so it sticks around long enough to get - # its callback called. :) - self._table[id(resource)] = weakref.ref(obj, callback) - - -# singleton -ResourceManager = ResourceManager() diff --git a/paramiko/rsakey.py b/paramiko/rsakey.py index f6d11a09..b5107515 100644 --- a/paramiko/rsakey.py +++ b/paramiko/rsakey.py @@ -90,10 +90,8 @@ class RSAKey(PKey): return self.asbytes().decode('utf8', errors='ignore') def __hash__(self): - h = hash(self.get_name()) - h = h * 37 + hash(self.public_numbers.e) - h = h * 37 + hash(self.public_numbers.n) - return hash(h) + return hash((self.get_name(), self.public_numbers.e, + self.public_numbers.n)) def get_name(self): return 'ssh-rsa' diff --git a/paramiko/sftp_client.py b/paramiko/sftp_client.py index 12fccb2f..dee0e2b2 100644 --- a/paramiko/sftp_client.py +++ b/paramiko/sftp_client.py @@ -28,16 +28,14 @@ from paramiko import util from paramiko.channel import Channel from paramiko.message import Message from paramiko.common import INFO, DEBUG, o777 -from paramiko.py3compat import ( - bytestring, b, u, long, string_types, bytes_types, -) +from paramiko.py3compat import bytestring, b, u, long from paramiko.sftp import ( BaseSFTP, CMD_OPENDIR, CMD_HANDLE, SFTPError, CMD_READDIR, CMD_NAME, CMD_CLOSE, SFTP_FLAG_READ, SFTP_FLAG_WRITE, SFTP_FLAG_CREATE, SFTP_FLAG_TRUNC, SFTP_FLAG_APPEND, SFTP_FLAG_EXCL, CMD_OPEN, CMD_REMOVE, CMD_RENAME, CMD_MKDIR, CMD_RMDIR, CMD_STAT, CMD_ATTRS, CMD_LSTAT, - CMD_SYMLINK, CMD_SETSTAT, CMD_READLINK, CMD_REALPATH, CMD_STATUS, SFTP_OK, - SFTP_EOF, SFTP_NO_SUCH_FILE, SFTP_PERMISSION_DENIED, + CMD_SYMLINK, CMD_SETSTAT, CMD_READLINK, CMD_REALPATH, CMD_STATUS, + CMD_EXTENDED, SFTP_OK, SFTP_EOF, SFTP_NO_SUCH_FILE, SFTP_PERMISSION_DENIED, ) from paramiko.sftp_attr import SFTPAttributes @@ -368,8 +366,10 @@ class SFTPClient(BaseSFTP, ClosingContextManager): """ Rename a file or folder from ``oldpath`` to ``newpath``. - :param str oldpath: existing name of the file or folder - :param str newpath: new name for the file or folder + :param str oldpath: + existing name of the file or folder + :param str newpath: + new name for the file or folder, must not exist already :raises: ``IOError`` -- if ``newpath`` is a folder, or something else goes @@ -380,6 +380,26 @@ class SFTPClient(BaseSFTP, ClosingContextManager): self._log(DEBUG, 'rename(%r, %r)' % (oldpath, newpath)) self._request(CMD_RENAME, oldpath, newpath) + def posix_rename(self, oldpath, newpath): + """ + Rename a file or folder from ``oldpath`` to ``newpath``, following + posix conventions. + + :param str oldpath: existing name of the file or folder + :param str newpath: new name for the file or folder, will be + overwritten if it already exists + + :raises: + ``IOError`` -- if ``newpath`` is a folder, posix-rename is not + supported by the server or something else goes wrong + """ + oldpath = self._adjust_cwd(oldpath) + newpath = self._adjust_cwd(newpath) + self._log(DEBUG, 'posix_rename(%r, %r)' % (oldpath, newpath)) + self._request( + CMD_EXTENDED, "posix-rename@openssh.com", oldpath, newpath + ) + def mkdir(self, path, mode=o777): """ Create a folder (directory) named ``path`` with numeric mode ``mode``. @@ -451,8 +471,7 @@ class SFTPClient(BaseSFTP, ClosingContextManager): def symlink(self, source, dest): """ - Create a symbolic link (shortcut) of the ``source`` path at - ``destination``. + Create a symbolic link to the ``source`` path at ``destination``. :param str source: path of the original file :param str dest: path of the newly created symlink @@ -758,13 +777,12 @@ class SFTPClient(BaseSFTP, ClosingContextManager): msg.add_int64(item) elif isinstance(item, int): msg.add_int(item) - elif isinstance(item, (string_types, bytes_types)): - msg.add_string(item) elif isinstance(item, SFTPAttributes): item._pack(msg) else: - raise Exception( - 'unknown type for %r type %r' % (item, type(item))) + # For all other types, rely on as_string() to either coerce + # to bytes before writing or raise a suitable exception. + msg.add_string(item) num = self.request_number self._expecting[num] = fileobj self.request_number += 1 diff --git a/paramiko/sftp_server.py b/paramiko/sftp_server.py index 1cfe286b..f7d1c657 100644 --- a/paramiko/sftp_server.py +++ b/paramiko/sftp_server.py @@ -469,6 +469,12 @@ class SFTPServer (BaseSFTP, SubsystemHandler): tag = msg.get_text() if tag == 'check-file': self._check_file(request_number, msg) + elif tag == 'posix-rename@openssh.com': + oldpath = msg.get_text() + newpath = msg.get_text() + self._send_status( + request_number, self.server.posix_rename(oldpath, newpath) + ) else: self._send_status(request_number, SFTP_OP_UNSUPPORTED) else: diff --git a/paramiko/sftp_si.py b/paramiko/sftp_si.py index 09e7025c..40969309 100644 --- a/paramiko/sftp_si.py +++ b/paramiko/sftp_si.py @@ -201,6 +201,18 @@ class SFTPServerInterface (object): """ return SFTP_OP_UNSUPPORTED + def posix_rename(self, oldpath, newpath): + """ + Rename (or move) a file, following posix conventions. If newpath + already exists, it will be overwritten. + + :param str oldpath: + the requested path (relative or absolute) of the existing file. + :param str newpath: the requested new path of the file. + :return: an SFTP error code `int` like ``SFTP_OK``. + """ + return SFTP_OP_UNSUPPORTED + def mkdir(self, path, attr): """ Create a new directory with the given attributes. The ``attr`` diff --git a/paramiko/transport.py b/paramiko/transport.py index c92260c9..174e0bf4 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -117,10 +117,10 @@ class Transport(threading.Thread, ClosingContextManager): _preferred_macs = ( 'hmac-sha2-256', 'hmac-sha2-512', + 'hmac-sha1', 'hmac-md5', 'hmac-sha1-96', 'hmac-md5-96', - 'hmac-sha1', ) _preferred_keys = ( 'ssh-ed25519', @@ -131,13 +131,13 @@ class Transport(threading.Thread, ClosingContextManager): 'ssh-dss', ) _preferred_kex = ( - 'diffie-hellman-group1-sha1', - 'diffie-hellman-group14-sha1', - 'diffie-hellman-group-exchange-sha1', - 'diffie-hellman-group-exchange-sha256', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521', + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group-exchange-sha1', + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group1-sha1', ) _preferred_compression = ('none',) @@ -287,7 +287,6 @@ class Transport(threading.Thread, ClosingContextManager): arguments. """ self.active = False - self._sshclient = None if isinstance(sock, string_types): # convert "host:port" into (host, port) @@ -321,14 +320,9 @@ class Transport(threading.Thread, ClosingContextManager): threading.Thread.__init__(self) self.setDaemon(True) self.sock = sock - # Python < 2.3 doesn't have the settimeout method - RogerB - try: - # we set the timeout so we can check self.active periodically to - # see if we should bail. socket.timeout exception is never - # propagated. - self.sock.settimeout(self._active_check_timeout) - except AttributeError: - pass + # we set the timeout so we can check self.active periodically to + # see if we should bail. socket.timeout exception is never propagated. + self.sock.settimeout(self._active_check_timeout) # negotiated crypto parameters self.packetizer = Packetizer(sock) @@ -397,6 +391,8 @@ class Transport(threading.Thread, ClosingContextManager): # how long (seconds) to wait for the handshake to finish after SSH # banner sent. self.handshake_timeout = 15 + # how long (seconds) to wait for the auth response. + self.auth_timeout = 30 # server mode: self.server_mode = False @@ -661,9 +657,6 @@ class Transport(threading.Thread, ClosingContextManager): Transport._modulus_pack = None return False - def set_sshclient(self, sshclient): - self._sshclient = sshclient - def close(self): """ Close this session, and any open channels that are tied to it. @@ -674,7 +667,6 @@ class Transport(threading.Thread, ClosingContextManager): for chan in list(self._channels.values()): chan._unlink() self.sock.close() - self._sshclient = None def get_remote_server_key(self): """ diff --git a/paramiko/win_pageant.py b/paramiko/win_pageant.py index c8c2c7bc..fda3b9c1 100644 --- a/paramiko/win_pageant.py +++ b/paramiko/win_pageant.py @@ -25,7 +25,7 @@ import array import ctypes.wintypes import platform import struct -from paramiko.util import * # noqa +from paramiko.common import zero_byte from paramiko.py3compat import b try: diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index 78e1920a..b83473bb 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -2,6 +2,51 @@ Changelog ========= +* :bug:`984` Enhance default cipher preference order such that + ``aes(192|256)-cbc`` are preferred over ``blowfish-cbc``. Thanks to Alex + Gaynor. +* :bug:`971 (1.17+)` Allow any type implementing the buffer API to be used with + `BufferedFile <paramiko.file.BufferedFile>`, `Channel + <paramiko.channel.Channel>`, and `SFTPFile <paramiko.sftp_file.SFTPFile>`. + This resolves a regression introduced in 1.13 with the Python 3 porting + changes, when using types such as ``memoryview``. Credit: Martin Packman. +* :bug:`741` (also :issue:`809`, :issue:`772`; all via :issue:`912`) Writing + encrypted/password-protected private key files was silently broken since 2.0 + due to an incorrect API call; this has been fixed. + + Includes a directly related fix, namely adding the ability to read + ``AES-256-CBC`` ciphered private keys (which is now what we tend to write out + as it is Cryptography's default private key cipher.) + + Thanks to ``@virlos`` for the original report, Chris Harris and ``@ibuler`` + for initial draft PRs, and ``@jhgorrell`` for the final patch. +* :feature:`65` (via :issue:`471`) Add support for OpenSSH's SFTP + ``posix-rename`` protocol extension (section 3.3 of `OpenSSH's protocol + extension document + <http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL?rev=1.31>`_), + via a new ``posix_rename`` method in `SFTPClient + <paramiko.sftp_client.SFTPClient.posix_rename>` and `SFTPServerInterface + <paramiko.sftp_si.SFTPServerInterface.posix_rename>`. Thanks to Wren Turkal + for the initial patch & Mika Pflüger for the enhanced, merged PR. +* :feature:`869` Add an ``auth_timeout`` kwarg to `SSHClient.connect + <paramiko.client.SSHClient.connect>` (default: 30s) to avoid hangs when the + remote end becomes unresponsive during the authentication step. Credit to + ``@timsavage``. + + .. note:: + This technically changes behavior, insofar as very slow auth steps >30s + will now cause timeout exceptions instead of completing. We doubt most + users will notice; those affected can simply give a higher value to + ``auth_timeout``. + +* :support:`921` Tighten up the ``__hash__`` implementation for various key + classes; less code is good code. Thanks to Francisco Couzo for the patch. +* :support:`956 backported (1.17+)` Switch code coverage service from + coveralls.io to codecov.io (& then disable the latter's auto-comments.) + Thanks to Nikolai Røed Kristiansen for the patch. +* :bug:`983` Move ``sha1`` above the now-arguably-broken ``md5`` in the list of + preferred MAC algorithms, as an incremental security improvement for users + whose target systems offer both. Credit: Pierce Lopez. * :bug:`667` The RC4/arcfour family of ciphers has been broken since version 2.0; but since the algorithm is now known to be completely insecure, we are opting to remove support outright instead of fixing it. Thanks to Alex Gaynor @@ -12,7 +57,9 @@ Changelog long-standing gotcha for unaware users. * :feature:`951` Add support for ECDH key exchange (kex), specifically the algorithms ``ecdh-sha2-nistp256``, ``ecdh-sha2-nistp384``, and - ``ecdh-sha2-nistp521``. Thanks to Shashank Veerapaneni for the patch. + ``ecdh-sha2-nistp521``. They now come before the older ``diffie-hellman-*`` + family of kex algorithms in the preferred-kex list. Thanks to Shashank + Veerapaneni for the patch & Pierce Lopez for a follow-up. * :support:`- backported` A big formatting pass to clean up an enormous number of invalid Sphinx reference links, discovered by switching to a modern, rigorous nitpicking doc-building mode. @@ -38,8 +85,12 @@ Changelog (i.e. passes the maintainer's preferred `flake8 <http://flake8.pycqa.org/>`_ configuration) and add a ``flake8`` step to the Travis config. Big thanks to Dorian Pula! -* :bug:`683` Make ``util.log_to_file`` append instead of replace. Thanks - to ``@vlcinsky`` for the report. +* :bug:`949 (1.17+)` SSHClient and Transport could cause a memory leak if + there's a connection problem or protocol error, even if ``Transport.close()`` + is called. Thanks Kyle Agronick for the discovery and investigation, and + Pierce Lopez for assistance. +* :bug:`683 (1.17+)` Make ``util.log_to_file`` append instead of replace. + Thanks to ``@vlcinsky`` for the report. * :release:`2.1.2 <2017-02-20>` * :release:`2.0.5 <2017-02-20>` * :release:`1.18.2 <2017-02-20>` diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..8878f14d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,36 @@ +# Copyright (C) 2017 Martin Packman <gzlist@googlemail.com> +# +# 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 distributed 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. + +"""Base classes and helpers for testing paramiko.""" + +import unittest + +from paramiko.py3compat import ( + builtins, + ) + + +def skipUnlessBuiltin(name): + """Skip decorated test if builtin name does not exist.""" + if getattr(builtins, name, None) is None: + skip = getattr(unittest, "skip", None) + if skip is None: + # Python 2.6 pseudo-skip + return lambda func: None + return skip("No builtin " + repr(name)) + return lambda func: func diff --git a/tests/stub_sftp.py b/tests/stub_sftp.py index 334af561..0d673091 100644 --- a/tests/stub_sftp.py +++ b/tests/stub_sftp.py @@ -24,7 +24,7 @@ import os import sys from paramiko import ( ServerInterface, SFTPServerInterface, SFTPServer, SFTPAttributes, - SFTPHandle, SFTP_OK, AUTH_SUCCESSFUL, OPEN_SUCCEEDED, + SFTPHandle, SFTP_OK, SFTP_FAILURE, AUTH_SUCCESSFUL, OPEN_SUCCEEDED, ) from paramiko.common import o666 @@ -141,12 +141,24 @@ class StubSFTPServer (SFTPServerInterface): def rename(self, oldpath, newpath): oldpath = self._realpath(oldpath) newpath = self._realpath(newpath) + if os.path.exists(newpath): + return SFTP_FAILURE try: os.rename(oldpath, newpath) except OSError as e: return SFTPServer.convert_errno(e.errno) return SFTP_OK + def posix_rename(self, oldpath, newpath): + oldpath = self._realpath(oldpath) + newpath = self._realpath(newpath) + try: + os.rename(oldpath, newpath) + except OSError as e: + return SFTPServer.convert_errno(e.errno) + return SFTP_OK + + def mkdir(self, path, attr): path = self._realpath(path) try: diff --git a/tests/test_auth.py b/tests/test_auth.py index 96f7611c..e78397c6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -23,6 +23,7 @@ Some unit tests for authenticating over a Transport. import sys import threading import unittest +from time import sleep from paramiko import ( Transport, ServerInterface, RSAKey, DSSKey, BadAuthenticationType, @@ -74,6 +75,9 @@ class NullServer (ServerInterface): return AUTH_SUCCESSFUL if username == 'bad-server': raise Exception("Ack!") + if username == 'unresponsive-server': + sleep(5) + return AUTH_SUCCESSFUL return AUTH_FAILED def check_auth_publickey(self, username, key): @@ -233,3 +237,18 @@ class AuthTest (unittest.TestCase): except: etype, evalue, etb = sys.exc_info() self.assertTrue(issubclass(etype, AuthenticationException)) + + def test_9_auth_non_responsive(self): + """ + verify that authentication times out if server takes to long to + respond (or never responds). + """ + self.tc.auth_timeout = 1 # 1 second, to speed up test + self.start_server() + self.tc.connect() + try: + remain = self.tc.auth_password('unresponsive-server', 'hello') + except: + etype, evalue, etb = sys.exc_info() + self.assertTrue(issubclass(etype, AuthenticationException)) + self.assertTrue('Authentication timeout' in str(evalue)) diff --git a/tests/test_client.py b/tests/test_client.py index 3a9001e2..bddaf4bc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -36,7 +36,7 @@ from tests.util import test_path import paramiko from paramiko.common import PY2 -from paramiko.ssh_exception import SSHException +from paramiko.ssh_exception import SSHException, AuthenticationException FINGERPRINTS = { @@ -61,6 +61,9 @@ class NullServer (paramiko.ServerInterface): def check_auth_password(self, username, password): if (username == 'slowdive') and (password == 'pygmalion'): return paramiko.AUTH_SUCCESSFUL + if (username == 'slowdive') and (password == 'unresponsive-server'): + time.sleep(5) + return paramiko.AUTH_SUCCESSFUL return paramiko.AUTH_FAILED def check_auth_publickey(self, username, key): @@ -294,13 +297,10 @@ class SSHClientTest (unittest.TestCase): verify that when an SSHClient is collected, its transport (and the transport's packetizer) is closed. """ - # Unclear why this is borked on Py3, but it is, and does not seem worth - # pursuing at the moment. Skipped on PyPy because it fails on travis - # for unknown reasons, works fine locally. - # XXX: It's the release of the references to e.g packetizer that fails - # in py3... - if not PY2 or platform.python_implementation() == "PyPy": + # Skipped on PyPy because it fails on travis for unknown reasons + if platform.python_implementation() == "PyPy": return + threading.Thread(target=self._run).start() self.tc = paramiko.SSHClient() @@ -318,8 +318,8 @@ class SSHClientTest (unittest.TestCase): del self.tc # force a collection to see whether the SSHClient object is deallocated - # correctly. 2 GCs are needed to make sure it's really collected on - # PyPy + # 2 GCs are needed on PyPy, time is needed for Python 3 + time.sleep(0.3) gc.collect() gc.collect() @@ -384,6 +384,18 @@ class SSHClientTest (unittest.TestCase): ) self._test_connection(**kwargs) + def test_9_auth_timeout(self): + """ + verify that the SSHClient has a configurable auth timeout + """ + # Connect with a half second auth timeout + self.assertRaises( + AuthenticationException, + self._test_connection, + password='unresponsive-server', + auth_timeout=0.5, + ) + def test_update_environment(self): """ Verify that environment variables can be set by the client. diff --git a/tests/test_file.py b/tests/test_file.py index 7fab6985..b33ecd51 100755 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -21,10 +21,14 @@ Some unit tests for the BufferedFile abstraction. """ import unittest -from paramiko.file import BufferedFile -from paramiko.common import linefeed_byte, crlf, cr_byte import sys +from paramiko.common import linefeed_byte, crlf, cr_byte +from paramiko.file import BufferedFile +from paramiko.py3compat import BytesIO + +from tests import skipUnlessBuiltin + class LoopbackFile (BufferedFile): """ @@ -33,19 +37,16 @@ class LoopbackFile (BufferedFile): def __init__(self, mode='r', bufsize=-1): BufferedFile.__init__(self) self._set_mode(mode, bufsize) - self.buffer = bytes() + self.buffer = BytesIO() + self.offset = 0 def _read(self, size): - if len(self.buffer) == 0: - return None - if size > len(self.buffer): - size = len(self.buffer) - data = self.buffer[:size] - self.buffer = self.buffer[size:] + data = self.buffer.getvalue()[self.offset:self.offset+size] + self.offset += len(data) return data def _write(self, data): - self.buffer += data + self.buffer.write(data) return len(data) @@ -187,6 +188,42 @@ class BufferedFileTest (unittest.TestCase): self.assertEqual(data, b'hello') f.close() + def test_write_bad_type(self): + with LoopbackFile('wb') as f: + self.assertRaises(TypeError, f.write, object()) + + def test_write_unicode_as_binary(self): + text = u"\xa7 why is writing text to a binary file allowed?\n" + with LoopbackFile('rb+') as f: + f.write(text) + self.assertEqual(f.read(), text.encode("utf-8")) + + @skipUnlessBuiltin('memoryview') + def test_write_bytearray(self): + with LoopbackFile('rb+') as f: + f.write(bytearray(12)) + self.assertEqual(f.read(), 12 * b"\0") + + @skipUnlessBuiltin('buffer') + def test_write_buffer(self): + data = 3 * b"pretend giant block of data\n" + offsets = range(0, len(data), 8) + with LoopbackFile('rb+') as f: + for offset in offsets: + f.write(buffer(data, offset, 8)) + self.assertEqual(f.read(), data) + + @skipUnlessBuiltin('memoryview') + def test_write_memoryview(self): + data = 3 * b"pretend giant block of data\n" + offsets = range(0, len(data), 8) + with LoopbackFile('rb+') as f: + view = memoryview(data) + for offset in offsets: + f.write(view[offset:offset+8]) + self.assertEqual(f.read(), data) + + if __name__ == '__main__': from unittest import main main() diff --git a/tests/test_pkey.py b/tests/test_pkey.py index a26ff170..6e589915 100644 --- a/tests/test_pkey.py +++ b/tests/test_pkey.py @@ -113,6 +113,25 @@ TEST_KEY_BYTESTR_3 = '\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x00ӏ class KeyTest(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + def assert_keyfile_is_encrypted(self, keyfile): + """ + A quick check that filename looks like an encrypted key. + """ + with open(keyfile, "r") as fh: + self.assertEqual( + fh.readline()[:-1], + "-----BEGIN RSA PRIVATE KEY-----" + ) + self.assertEqual(fh.readline()[:-1], "Proc-Type: 4,ENCRYPTED") + self.assertEqual(fh.readline()[0:10], "DEK-Info: ") + def test_1_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' @@ -419,6 +438,7 @@ class KeyTest(unittest.TestCase): # When the bug under test exists, this will ValueError. try: key.write_private_key_file(newfile, password=newpassword) + self.assert_keyfile_is_encrypted(newfile) # Verify the inner key data still matches (when no ValueError) key2 = RSAKey(filename=newfile, password=newpassword) self.assertEqual(key, key2) @@ -437,3 +457,18 @@ class KeyTest(unittest.TestCase): ) self.assertNotEqual(key1.asbytes(), key2.asbytes()) + + def test_keyfile_is_actually_encrypted(self): + # Read an existing encrypted private key + file_ = test_path('test_rsa_password.key') + password = 'television' + newfile = file_ + '.new' + newpassword = 'radio' + key = RSAKey(filename=file_, password=password) + # Write out a newly re-encrypted copy with a new password. + # When the bug under test exists, this will ValueError. + try: + key.write_private_key_file(newfile, password=newpassword) + self.assert_keyfile_is_encrypted(newfile) + finally: + os.remove(newfile) diff --git a/tests/test_sftp.py b/tests/test_sftp.py index d3064fff..b3c7bf98 100755 --- a/tests/test_sftp.py +++ b/tests/test_sftp.py @@ -35,6 +35,7 @@ from tempfile import mkstemp import paramiko from paramiko.py3compat import PY2, b, u, StringIO from paramiko.common import o777, o600, o666, o644 +from tests import skipUnlessBuiltin from tests.stub_sftp import StubServer, StubSFTPServer from tests.loop import LoopSocket from tests.util import test_path @@ -276,6 +277,39 @@ class SFTPTest (unittest.TestCase): except: pass + + def test_5a_posix_rename(self): + """Test posix-rename@openssh.com protocol extension.""" + try: + # first check that the normal rename works as specified + with sftp.open(FOLDER + '/a', 'w') as f: + f.write('one') + sftp.rename(FOLDER + '/a', FOLDER + '/b') + with sftp.open(FOLDER + '/a', 'w') as f: + f.write('two') + try: + sftp.rename(FOLDER + '/a', FOLDER + '/b') + self.assertTrue(False, 'no exception when rename-ing onto existing file') + except (OSError, IOError): + pass + + # now check with the posix_rename + sftp.posix_rename(FOLDER + '/a', FOLDER + '/b') + with sftp.open(FOLDER + '/b', 'r') as f: + data = u(f.read()) + self.assertEqual('two', data, "Contents of renamed file not the same as original file") + + finally: + try: + sftp.remove(FOLDER + '/a') + except: + pass + try: + sftp.remove(FOLDER + '/b') + except: + pass + + def test_6_folder(self): """ create a temporary folder, verify that we can create a file in it, then @@ -817,6 +851,35 @@ class SFTPTest (unittest.TestCase): sftp_attributes = SFTPAttributes() self.assertEqual(str(sftp_attributes), "?--------- 1 0 0 0 (unknown date) ?") + @skipUnlessBuiltin('buffer') + def test_write_buffer(self): + """Test write() using a buffer instance.""" + data = 3 * b'A potentially large block of data to chunk up.\n' + try: + with sftp.open('%s/write_buffer' % FOLDER, 'wb') as f: + for offset in range(0, len(data), 8): + f.write(buffer(data, offset, 8)) + + with sftp.open('%s/write_buffer' % FOLDER, 'rb') as f: + self.assertEqual(f.read(), data) + finally: + sftp.remove('%s/write_buffer' % FOLDER) + + @skipUnlessBuiltin('memoryview') + def test_write_memoryview(self): + """Test write() using a memoryview instance.""" + data = 3 * b'A potentially large block of data to chunk up.\n' + try: + with sftp.open('%s/write_memoryview' % FOLDER, 'wb') as f: + view = memoryview(data) + for offset in range(0, len(data), 8): + f.write(view[offset:offset+8]) + + with sftp.open('%s/write_memoryview' % FOLDER, 'rb') as f: + self.assertEqual(f.read(), data) + finally: + sftp.remove('%s/write_memoryview' % FOLDER) + if __name__ == '__main__': SFTPTest.init_loopback() diff --git a/tests/test_transport.py b/tests/test_transport.py index c426cef1..3e352919 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -43,6 +43,7 @@ from paramiko.common import ( ) from paramiko.py3compat import bytes from paramiko.message import Message +from tests import skipUnlessBuiltin from tests.loop import LoopSocket from tests.util import test_path @@ -858,3 +859,71 @@ class TransportTest(unittest.TestCase): self.assertEqual([chan], r) self.assertEqual([], w) self.assertEqual([], e) + + def test_channel_send_misc(self): + """ + verify behaviours sending various instances to a channel + """ + self.setup_test_server() + text = u"\xa7 slice me nicely" + with self.tc.open_session() as chan: + schan = self.ts.accept(1.0) + if schan is None: + self.fail("Test server transport failed to accept") + sfile = schan.makefile() + + # TypeError raised on non string or buffer type + self.assertRaises(TypeError, chan.send, object()) + self.assertRaises(TypeError, chan.sendall, object()) + + # sendall() accepts a unicode instance + chan.sendall(text) + expected = text.encode("utf-8") + self.assertEqual(sfile.read(len(expected)), expected) + + @skipUnlessBuiltin('buffer') + def test_channel_send_buffer(self): + """ + verify sending buffer instances to a channel + """ + self.setup_test_server() + data = 3 * b'some test data\n whole' + with self.tc.open_session() as chan: + schan = self.ts.accept(1.0) + if schan is None: + self.fail("Test server transport failed to accept") + sfile = schan.makefile() + + # send() accepts buffer instances + sent = 0 + while sent < len(data): + sent += chan.send(buffer(data, sent, 8)) + self.assertEqual(sfile.read(len(data)), data) + + # sendall() accepts a buffer instance + chan.sendall(buffer(data)) + self.assertEqual(sfile.read(len(data)), data) + + @skipUnlessBuiltin('memoryview') + def test_channel_send_memoryview(self): + """ + verify sending memoryview instances to a channel + """ + self.setup_test_server() + data = 3 * b'some test data\n whole' + with self.tc.open_session() as chan: + schan = self.ts.accept(1.0) + if schan is None: + self.fail("Test server transport failed to accept") + sfile = schan.makefile() + + # send() accepts memoryview slices + sent = 0 + view = memoryview(data) + while sent < len(view): + sent += chan.send(view[sent:sent+8]) + self.assertEqual(sfile.read(len(data)), data) + + # sendall() accepts a memoryview instance + chan.sendall(memoryview(data)) + self.assertEqual(sfile.read(len(data)), data) |