diff options
-rw-r--r-- | paramiko/__init__.py | 1 | ||||
-rw-r--r-- | paramiko/auth_handler.py | 9 | ||||
-rw-r--r-- | paramiko/channel.py | 1 | ||||
-rw-r--r-- | paramiko/client.py | 60 | ||||
-rw-r--r-- | paramiko/dsskey.py | 8 | ||||
-rw-r--r-- | paramiko/ecdsakey.py | 6 | ||||
-rw-r--r-- | paramiko/ed25519key.py | 194 | ||||
-rw-r--r-- | paramiko/hostkeys.py | 3 | ||||
-rw-r--r-- | paramiko/kex_ecdh_nist.py | 118 | ||||
-rw-r--r-- | paramiko/rsakey.py | 6 | ||||
-rw-r--r-- | paramiko/sftp_client.py | 33 | ||||
-rw-r--r-- | paramiko/sftp_server.py | 6 | ||||
-rw-r--r-- | paramiko/sftp_si.py | 12 | ||||
-rw-r--r-- | paramiko/transport.py | 20 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | sites/www/changelog.rst | 38 | ||||
-rw-r--r-- | sites/www/index.rst | 1 | ||||
-rw-r--r-- | sites/www/installing-1.x.rst | 1 | ||||
-rw-r--r-- | sites/www/installing.rst | 6 | ||||
-rw-r--r-- | tests/stub_sftp.py | 14 | ||||
-rw-r--r-- | tests/test_auth.py | 19 | ||||
-rw-r--r-- | tests/test_client.py | 38 | ||||
-rw-r--r-- | tests/test_ed25519.key | 8 | ||||
-rw-r--r-- | tests/test_ed25519_password.key | 8 | ||||
-rw-r--r-- | tests/test_kex.py | 57 | ||||
-rw-r--r-- | tests/test_pkey.py | 12 | ||||
-rwxr-xr-x | tests/test_sftp.py | 33 |
27 files changed, 646 insertions, 68 deletions
diff --git a/paramiko/__init__.py b/paramiko/__init__.py index 197f519a..d67ad62f 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -45,6 +45,7 @@ from paramiko.server import ServerInterface, SubsystemHandler, InteractiveQuery from paramiko.rsakey import RSAKey from paramiko.dsskey import DSSKey from paramiko.ecdsakey import ECDSAKey +from paramiko.ed25519key import Ed25519Key from paramiko.sftp import SFTPError, BaseSFTP from paramiko.sftp_client import SFTP, SFTPClient from paramiko.sftp_server import SFTPServer 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/channel.py b/paramiko/channel.py index db2aa586..c6016a0e 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -25,6 +25,7 @@ import os import socket import time import threading +# TODO: switch as much of py3compat.py to 'six' as possible, then use six.wraps from functools import wraps from paramiko import util diff --git a/paramiko/client.py b/paramiko/client.py index cc769fee..b2c21798 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -22,6 +22,7 @@ SSH client & key policies from binascii import hexlify import getpass +import inspect import os import socket import warnings @@ -32,6 +33,7 @@ from paramiko.common import DEBUG from paramiko.config import SSH_PORT from paramiko.dsskey import DSSKey from paramiko.ecdsakey import ECDSAKey +from paramiko.ed25519key import Ed25519Key from paramiko.hostkeys import HostKeys from paramiko.py3compat import string_types from paramiko.rsakey import RSAKey @@ -168,16 +170,10 @@ class SSHClient (ClosingContextManager): Specifically: - * A **policy** is an instance of a "policy class", namely some subclass - of `.MissingHostKeyPolicy` such as `.RejectPolicy` (the default), - `.AutoAddPolicy`, `.WarningPolicy`, or a user-created subclass. - - .. note:: - This method takes class **instances**, not **classes** themselves. - Thus it must be called as e.g. - ``.set_missing_host_key_policy(WarningPolicy())`` and *not* - ``.set_missing_host_key_policy(WarningPolicy)``. - + * A **policy** is a "policy class" (or instance thereof), namely some + subclass of `.MissingHostKeyPolicy` such as `.RejectPolicy` (the + default), `.AutoAddPolicy`, `.WarningPolicy`, or a user-created + subclass. * A host key is **known** when it appears in the client object's cached host keys structures (those manipulated by `load_system_host_keys` and/or `load_host_keys`). @@ -186,6 +182,8 @@ class SSHClient (ClosingContextManager): the policy to use when receiving a host key from a previously-unknown server """ + if inspect.isclass(policy): + policy = policy() self._policy = policy def _families_and_addresses(self, hostname, port): @@ -228,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 @@ -280,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 @@ -340,6 +341,8 @@ 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) server_key = t.get_remote_server_key() @@ -545,7 +548,7 @@ class SSHClient (ClosingContextManager): if not two_factor: for key_filename in key_filenames: - for pkey_class in (RSAKey, DSSKey, ECDSAKey): + for pkey_class in (RSAKey, DSSKey, ECDSAKey, Ed25519Key): try: key = pkey_class.from_private_key_file( key_filename, password) @@ -585,25 +588,20 @@ class SSHClient (ClosingContextManager): if not two_factor: keyfiles = [] - rsa_key = os.path.expanduser('~/.ssh/id_rsa') - dsa_key = os.path.expanduser('~/.ssh/id_dsa') - ecdsa_key = os.path.expanduser('~/.ssh/id_ecdsa') - if os.path.isfile(rsa_key): - keyfiles.append((RSAKey, rsa_key)) - if os.path.isfile(dsa_key): - keyfiles.append((DSSKey, dsa_key)) - if os.path.isfile(ecdsa_key): - keyfiles.append((ECDSAKey, ecdsa_key)) - # look in ~/ssh/ for windows users: - rsa_key = os.path.expanduser('~/ssh/id_rsa') - dsa_key = os.path.expanduser('~/ssh/id_dsa') - ecdsa_key = os.path.expanduser('~/ssh/id_ecdsa') - if os.path.isfile(rsa_key): - keyfiles.append((RSAKey, rsa_key)) - if os.path.isfile(dsa_key): - keyfiles.append((DSSKey, dsa_key)) - if os.path.isfile(ecdsa_key): - keyfiles.append((ECDSAKey, ecdsa_key)) + + for keytype, name in [ + (RSAKey, "rsa"), + (DSSKey, "dsa"), + (ECDSAKey, "ecdsa"), + (Ed25519Key, "ed25519"), + ]: + # ~/ssh/ is for windows + for directory in [".ssh", "ssh"]: + full_path = os.path.expanduser( + "~/%s/id_%s" % (directory, name) + ) + if os.path.isfile(full_path): + keyfiles.append((keytype, full_path)) if not look_for_keys: keyfiles = [] 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/ed25519key.py b/paramiko/ed25519key.py new file mode 100644 index 00000000..e1a8a732 --- /dev/null +++ b/paramiko/ed25519key.py @@ -0,0 +1,194 @@ +# 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. + +import bcrypt + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher + +import nacl.signing + +import six + +from paramiko.message import Message +from paramiko.pkey import PKey +from paramiko.ssh_exception import SSHException, PasswordRequiredException + + +OPENSSH_AUTH_MAGIC = b"openssh-key-v1\x00" + + +def unpad(data): + # At the moment, this is only used for unpadding private keys on disk. This + # 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: + raise SSHException("Invalid key") + for i in range(1, padding_length + 1): + if six.indexbytes(data, -i) != (padding_length - i + 1): + raise SSHException("Invalid key") + return data[:-padding_length] + + +class Ed25519Key(PKey): + def __init__(self, msg=None, data=None, filename=None, password=None): + verifying_key = signing_key = None + if msg is None and data is not None: + msg = Message(data) + if msg is not None: + if msg.get_text() != "ssh-ed25519": + raise SSHException("Invalid key") + verifying_key = nacl.signing.VerifyKey(msg.get_binary()) + elif filename is not None: + with open(filename, "r") as f: + data = self._read_private_key("OPENSSH", f) + signing_key = self._parse_signing_key_data(data, password) + + if signing_key is None and verifying_key is None: + raise ValueError("need a key") + + self._signing_key = signing_key + self._verifying_key = verifying_key + + def _parse_signing_key_data(self, data, password): + from paramiko.transport import Transport + # We may eventually want this to be usable for other key types, as + # OpenSSH moves to it, but for now this is just for Ed25519 keys. + # This format is described here: + # https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key + # The description isn't totally complete, and I had to refer to the + # source for a full implementation. + message = Message(data) + if message.get_bytes(len(OPENSSH_AUTH_MAGIC)) != OPENSSH_AUTH_MAGIC: + raise SSHException("Invalid key") + + ciphername = message.get_text() + kdfname = message.get_text() + kdfoptions = message.get_binary() + num_keys = message.get_int() + + if kdfname == "none": + # kdfname of "none" must have an empty kdfoptions, the ciphername + # must be "none" + if kdfoptions or ciphername != "none": + raise SSHException("Invalid key") + elif kdfname == "bcrypt": + if not password: + raise PasswordRequiredException( + "Private key file is encrypted" + ) + kdf = Message(kdfoptions) + bcrypt_salt = kdf.get_binary() + bcrypt_rounds = kdf.get_int() + else: + raise SSHException("Invalid key") + + if ciphername != "none" and ciphername not in Transport._cipher_info: + raise SSHException("Invalid key") + + public_keys = [] + for _ in range(num_keys): + pubkey = Message(message.get_binary()) + if pubkey.get_text() != "ssh-ed25519": + raise SSHException("Invalid key") + public_keys.append(pubkey.get_binary()) + + private_ciphertext = message.get_binary() + if ciphername == "none": + private_data = private_ciphertext + else: + cipher = Transport._cipher_info[ciphername] + key = bcrypt.kdf( + password=password, + salt=bcrypt_salt, + desired_key_bytes=cipher["key-size"] + cipher["block-size"], + rounds=bcrypt_rounds, + # We can't control how many rounds are on disk, so no sense + # warning about it. + ignore_few_rounds=True, + ) + decryptor = Cipher( + cipher["class"](key[:cipher["key-size"]]), + cipher["mode"](key[cipher["key-size"]:]), + backend=default_backend() + ).decryptor() + private_data = ( + decryptor.update(private_ciphertext) + decryptor.finalize() + ) + + message = Message(unpad(private_data)) + if message.get_int() != message.get_int(): + raise SSHException("Invalid key") + + signing_keys = [] + for i in range(num_keys): + if message.get_text() != "ssh-ed25519": + raise SSHException("Invalid key") + # A copy of the public key, again, ignore. + public = message.get_binary() + key_data = message.get_binary() + # The second half of the key data is yet another copy of the public + # key... + signing_key = nacl.signing.SigningKey(key_data[:32]) + # Verify that all the public keys are the same... + assert ( + signing_key.verify_key.encode() == public == public_keys[i] == + key_data[32:] + ) + signing_keys.append(signing_key) + # Comment, ignore. + message.get_binary() + + if len(signing_keys) != 1: + raise SSHException("Invalid key") + return signing_keys[0] + + def asbytes(self): + if self.can_sign(): + v = self._signing_key.verify_key + else: + v = self._verifying_key + m = Message() + m.add_string("ssh-ed25519") + m.add_string(v.encode()) + return m.asbytes() + + def get_name(self): + return "ssh-ed25519" + + def get_bits(self): + return 256 + + def can_sign(self): + return self._signing_key is not None + + def sign_ssh_data(self, data): + m = Message() + m.add_string("ssh-ed25519") + m.add_string(self._signing_key.sign(data).signature) + return m + + def verify_ssh_sig(self, data, msg): + if msg.get_text() != "ssh-ed25519": + return False + + try: + self._verifying_key.verify(data, msg.get_binary()) + except nacl.exceptions.BadSignatureError: + return False + else: + return True diff --git a/paramiko/hostkeys.py b/paramiko/hostkeys.py index 008ba592..3e27fd52 100644 --- a/paramiko/hostkeys.py +++ b/paramiko/hostkeys.py @@ -35,6 +35,7 @@ from paramiko.dsskey import DSSKey from paramiko.rsakey import RSAKey from paramiko.util import get_logger, constant_time_bytes_eq from paramiko.ecdsakey import ECDSAKey +from paramiko.ed25519key import Ed25519Key from paramiko.ssh_exception import SSHException @@ -360,6 +361,8 @@ class HostKeyEntry: key = DSSKey(data=decodebytes(key)) elif keytype in ECDSAKey.supported_key_format_identifiers(): key = ECDSAKey(data=decodebytes(key), validate_point=False) + elif keytype == 'ssh-ed25519': + key = Ed25519Key(data=decodebytes(key)) else: log.info("Unable to handle key of type %s" % (keytype,)) return None diff --git a/paramiko/kex_ecdh_nist.py b/paramiko/kex_ecdh_nist.py new file mode 100644 index 00000000..702a872d --- /dev/null +++ b/paramiko/kex_ecdh_nist.py @@ -0,0 +1,118 @@ +""" +Ephemeral Elliptic Curve Diffie-Hellman (ECDH) key exchange +RFC 5656, Section 4 +""" + +from hashlib import sha256, sha384, sha512 +from paramiko.message import Message +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 binascii import hexlify + +_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 KexNistp256(): + + name = "ecdh-sha2-nistp256" + hash_algo = sha256 + curve = ec.SECP256R1() + + def __init__(self, transport): + self.transport = transport + # private key, client public and server public keys + self.P = long(0) + self.Q_C = None + self.Q_S = None + + def start_kex(self): + self._generate_key_pair() + if self.transport.server_mode: + self.transport._expect_packet(_MSG_KEXECDH_INIT) + return + 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()) + 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('KexECDH asked to handle packet type %d' % ptype) + + def _generate_key_pair(self): + self.P = ec.generate_private_key(self.curve, default_backend()) + if self.transport.server_mode: + self.Q_S = self.P.public_key() + return + self.Q_C = self.P.public_key() + + def _parse_kexecdh_init(self, m): + Q_C_bytes = m.get_string() + self.Q_C = ec.EllipticCurvePublicNumbers.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 = long(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) + 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_mpint(long(K)) + H = self.hash_algo(hm.asbytes()).digest() + self.transport._set_K_H(K, H) + sig = self.transport.get_server_key().sign_ssh_data(H) + # construct reply + 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(sig) + self.transport._send_message(m) + self.transport._activate_outbound() + + 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.curve, Q_S_bytes + ) + sig = m.get_binary() + K = self.P.exchange(ec.ECDH(), self.Q_S.public_key(default_backend())) + K = long(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(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(Q_S_bytes) + hm.add_mpint(K) + self.transport._set_K_H(K, self.hash_algo(hm.asbytes()).digest()) + self.transport._verify_key(K_S, sig) + self.transport._activate_outbound() + + +class KexNistp384(KexNistp256): + name = "ecdh-sha2-nistp384" + hash_algo = sha384 + curve = ec.SECP384R1() + + +class KexNistp521(KexNistp256): + name = "ecdh-sha2-nistp521" + hash_algo = sha512 + curve = ec.SECP521R1() 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..f2e6869f 100644 --- a/paramiko/sftp_client.py +++ b/paramiko/sftp_client.py @@ -36,8 +36,8 @@ from paramiko.sftp import ( 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 +368,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 +382,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 +473,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 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 1b2dce40..35aa2f23 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -54,9 +54,11 @@ from paramiko.common import ( ) from paramiko.compress import ZlibCompressor, ZlibDecompressor from paramiko.dsskey import DSSKey +from paramiko.ed25519key import Ed25519Key from paramiko.kex_gex import KexGex, KexGexSHA256 from paramiko.kex_group1 import KexGroup1 from paramiko.kex_group14 import KexGroup14 +from paramiko.kex_ecdh_nist import KexNistp256, KexNistp384, KexNistp521 from paramiko.kex_gss import KexGSSGex, KexGSSGroup1, KexGSSGroup14 from paramiko.message import Message from paramiko.packet import Packetizer, NeedRekeyException @@ -124,14 +126,18 @@ class Transport(threading.Thread, ClosingContextManager): 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', + 'ssh-ed25519', 'ssh-rsa', 'ssh-dss', ) _preferred_kex = ( - 'diffie-hellman-group1-sha1', - 'diffie-hellman-group14-sha1', - 'diffie-hellman-group-exchange-sha1', + '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',) @@ -202,6 +208,7 @@ class Transport(threading.Thread, ClosingContextManager): 'ecdsa-sha2-nistp256': ECDSAKey, 'ecdsa-sha2-nistp384': ECDSAKey, 'ecdsa-sha2-nistp521': ECDSAKey, + 'ssh-ed25519': Ed25519Key, } _kex_info = { @@ -211,7 +218,10 @@ class Transport(threading.Thread, ClosingContextManager): 'diffie-hellman-group-exchange-sha256': KexGexSHA256, 'gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==': KexGSSGroup1, 'gss-group14-sha1-toWM5Slw5Ew8Mqkay+al2g==': KexGSSGroup14, - 'gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==': KexGSSGex + 'gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==': KexGSSGex, + 'ecdh-sha2-nistp256': KexNistp256, + 'ecdh-sha2-nistp384': KexNistp384, + 'ecdh-sha2-nistp521': KexNistp521, } _compression_info = { @@ -386,6 +396,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 @@ -74,7 +74,9 @@ setup( 'Programming Language :: Python :: 3.5', ], install_requires=[ + 'bcrypt>=3.0.0', 'cryptography>=1.1', + 'pynacl>=1.0.1', 'pyasn1>=0.1.7', ], ) diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index e9adee21..e8576b4d 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -12,6 +12,27 @@ Changelog 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 (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. @@ -22,6 +43,15 @@ Changelog 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 for catch & patch. +* :feature:`857` Allow `SSHClient.set_missing_host_key_policy + <paramiko.client.SSHClient.set_missing_host_key_policy>` to accept policy + classes _or_ instances, instead of only instances, thus fixing a + 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``. 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. @@ -35,6 +65,14 @@ Changelog to hosts which only offer these key types and no others. This is now fixed. Thanks to ``@ncoult`` and ``@kasdoe`` for reports and Pierce Lopez for the patch. +* :feature:`325` (via :issue:`972`) Add Ed25519 support, for both host keys + and user authentication. Big thanks to Alex Gaynor for the patch. + + .. note:: + This change adds the ``bcrypt`` and ``pynacl`` Python libraries as + dependencies. No C-level dependencies beyond those previously required (for + Cryptography) have been added. + * :support:`974 backported` Overhaul the codebase to be PEP-8, etc, compliant (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 diff --git a/sites/www/index.rst b/sites/www/index.rst index b09ab589..f0a5db8a 100644 --- a/sites/www/index.rst +++ b/sites/www/index.rst @@ -20,6 +20,7 @@ Please see the sidebar to the left to begin. changelog FAQs <faq> installing + installing-1.x contributing contact diff --git a/sites/www/installing-1.x.rst b/sites/www/installing-1.x.rst index 356fac49..8ede40d5 100644 --- a/sites/www/installing-1.x.rst +++ b/sites/www/installing-1.x.rst @@ -1,3 +1,4 @@ +================ Installing (1.x) ================ diff --git a/sites/www/installing.rst b/sites/www/installing.rst index 6537b850..f335a9e7 100644 --- a/sites/www/installing.rst +++ b/sites/www/installing.rst @@ -110,9 +110,3 @@ due to their infrequent utility & non-platform-agnostic requirements): 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 - - -.. toctree:: - :hidden: - - installing-1.x 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 229df991..bddaf4bc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -36,13 +36,14 @@ 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 = { '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-ed25519': b'\xb3\xd5"\xaa\xf9u^\xe8\xcd\x0e\xea\x02\xb9)\xa2\x80', } @@ -60,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): @@ -194,6 +198,9 @@ class SSHClientTest (unittest.TestCase): """ self._test_connection(key_filename=test_path('test_ecdsa_256.key')) + def test_client_ed25519(self): + self._test_connection(key_filename=test_path('test_ed25519.key')) + def test_3_multiple_key_files(self): """ verify that SSHClient accepts and tries multiple key files. @@ -377,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. @@ -411,3 +430,20 @@ class SSHClientTest (unittest.TestCase): 'Expected original SSHException in exception') else: self.assertFalse(False, 'SSHException was not thrown.') + + + def test_missing_key_policy_accepts_classes_or_instances(self): + """ + Client.missing_host_key_policy() can take classes or instances. + """ + # AN ACTUAL UNIT TEST?! GOOD LORD + # (But then we have to test a private API...meh.) + client = paramiko.SSHClient() + # Default + assert isinstance(client._policy, paramiko.RejectPolicy) + # Hand in an instance (classic behavior) + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + assert isinstance(client._policy, paramiko.AutoAddPolicy) + # Hand in just the class (new behavior) + client.set_missing_host_key_policy(paramiko.AutoAddPolicy) + assert isinstance(client._policy, paramiko.AutoAddPolicy) diff --git a/tests/test_ed25519.key b/tests/test_ed25519.key new file mode 100644 index 00000000..eb9f94c2 --- /dev/null +++ b/tests/test_ed25519.key @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXwAAAKhjwAdrY8AH +awAAAAtzc2gtZWQyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXw +AAAEA9tGQi2IrprbOSbDCF+RmAHd6meNSXBUQ2ekKXm4/8xnr1K9komH/1WBIvQbbtvnFV +hryd62EfcgRFuLRiokNfAAAAI2FsZXhfZ2F5bm9yQEFsZXhzLU1hY0Jvb2stQWlyLmxvY2 +FsAQI= +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/test_ed25519_password.key b/tests/test_ed25519_password.key new file mode 100644 index 00000000..d178aaae --- /dev/null +++ b/tests/test_ed25519_password.key @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABDaKD4ac7 +kieb+UfXaLaw68AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIOQn7fjND5ozMSV3 +CvbEtIdT73hWCMRjzS/lRdUDw50xAAAAsE8kLGyYBnl9ihJNqv378y6mO3SkzrDbWXOnK6 +ij0vnuTAvcqvWHAnyu6qBbplu/W2m55ZFeAItgaEcV2/V76sh/sAKlERqrLFyXylN0xoOW +NU5+zU08aTlbSKGmeNUU2xE/xfJq12U9XClIRuVUkUpYANxNPbmTRpVrbD3fgXMhK97Jrb +DEn8ca1IqMPiYmd/hpe5+tq3OxyRljXjCUFWTnqkp9VvUdzSTdSGZHsW9i +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/test_kex.py b/tests/test_kex.py index 19804fbf..b7f588f7 100644 --- a/tests/test_kex.py +++ b/tests/test_kex.py @@ -20,7 +20,7 @@ Some unit tests for the key exchange protocols. """ -from binascii import hexlify +from binascii import hexlify, unhexlify import os import unittest @@ -29,11 +29,24 @@ from paramiko.kex_group1 import KexGroup1 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 cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import ec def dummy_urandom(n): return byte_chr(0xcc) * n +def dummy_generate_key_pair(obj): + private_key_value = 94761803665136558137557783047955027733968423115106677159790289642479432803037 + public_key_numbers = "042bdab212fa8ba1b7c843301682a4db424d307246c7e1e6083c41d9ca7b098bf30b3d63e2ec6278488c135360456cc054b3444ecc45998c08894cbc1370f5f989" + public_key_numbers_obj = ec.EllipticCurvePublicNumbers.from_encoded_point(ec.SECP256R1(), unhexlify(public_key_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(ec.SECP256R1(), unhexlify(public_key_numbers)).public_key(default_backend()) + return + obj.Q_C = ec.EllipticCurvePublicNumbers.from_encoded_point(ec.SECP256R1(), unhexlify(public_key_numbers)).public_key(default_backend()) + class FakeKey (object): def __str__(self): @@ -93,9 +106,12 @@ class KexTest (unittest.TestCase): def setUp(self): self._original_urandom = os.urandom os.urandom = dummy_urandom + self._original_generate_key_pair = KexNistp256._generate_key_pair + KexNistp256._generate_key_pair = dummy_generate_key_pair def tearDown(self): os.urandom = self._original_urandom + KexNistp256._generate_key_pair = self._original_generate_key_pair def test_1_group1_client(self): transport = FakeTransport() @@ -369,4 +385,43 @@ class KexTest (unittest.TestCase): self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) self.assertTrue(transport._activated) + def test_11_kex_nistp256_client(self): + K = 91610929826364598472338906427792435253694642563583721654249504912114314269754 + transport = FakeTransport() + transport.server_mode = False + kex = KexNistp256(transport) + kex.start_kex() + self.assertEqual((paramiko.kex_ecdh_nist._MSG_KEXECDH_REPLY,), transport._expect) + + #fake reply + msg = Message() + msg.add_string('fake-host-key') + Q_S = unhexlify("043ae159594ba062efa121480e9ef136203fa9ec6b6e1f8723a321c16e62b945f573f3b822258cbcd094b9fa1c125cbfe5f043280893e66863cc0cb4dccbe70210") + msg.add_string(Q_S) + msg.add_string('fake-sig') + msg.rewind() + kex.parse_next(paramiko.kex_ecdh_nist._MSG_KEXECDH_REPLY, msg) + H = b'BAF7CE243A836037EB5D2221420F35C02B9AB6C957FE3BDE3369307B9612570A' + 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) + + def test_12_kex_nistp256_server(self): + K = 91610929826364598472338906427792435253694642563583721654249504912114314269754 + transport = FakeTransport() + transport.server_mode = True + kex = KexNistp256(transport) + kex.start_kex() + self.assertEqual((paramiko.kex_ecdh_nist._MSG_KEXECDH_INIT,), transport._expect) + #fake init + msg=Message() + Q_C = unhexlify("043ae159594ba062efa121480e9ef136203fa9ec6b6e1f8723a321c16e62b945f573f3b822258cbcd094b9fa1c125cbfe5f043280893e66863cc0cb4dccbe70210") + H = b'2EF4957AFD530DD3F05DBEABF68D724FACC060974DA9704F2AEE4C3DE861E7CA' + msg.add_string(Q_C) + msg.rewind() + kex.parse_next(paramiko.kex_ecdh_nist._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_pkey.py b/tests/test_pkey.py index 394a2cf4..6e589915 100644 --- a/tests/test_pkey.py +++ b/tests/test_pkey.py @@ -27,7 +27,7 @@ from binascii import hexlify from hashlib import md5 import base64 -from paramiko import RSAKey, DSSKey, ECDSAKey, Message, util +from paramiko import RSAKey, DSSKey, ECDSAKey, Ed25519Key, Message, util from paramiko.py3compat import StringIO, byte_chr, b, bytes, PY2 from tests.util import test_path @@ -112,7 +112,7 @@ TEST_KEY_BYTESTR_2 = '\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01#\x00\x00\x00\x81\x 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' -class KeyTest (unittest.TestCase): +class KeyTest(unittest.TestCase): def setUp(self): pass @@ -450,6 +450,14 @@ class KeyTest (unittest.TestCase): comparable = TEST_KEY_BYTESTR_2 if PY2 else TEST_KEY_BYTESTR_3 self.assertEqual(str(key), comparable) + def test_ed25519(self): + key1 = Ed25519Key.from_private_key_file(test_path('test_ed25519.key')) + key2 = Ed25519Key.from_private_key_file( + test_path('test_ed25519_password.key'), b'abc123' + ) + + 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') diff --git a/tests/test_sftp.py b/tests/test_sftp.py index d3064fff..8f1b7d2e 100755 --- a/tests/test_sftp.py +++ b/tests/test_sftp.py @@ -276,6 +276,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 |