diff options
81 files changed, 4591 insertions, 1170 deletions
@@ -5,6 +5,7 @@ dist/ paramiko.egg-info/ test.log docs/ +demos/*.log !sites/docs _build .coverage diff --git a/.travis.yml b/.travis.yml index 64f64e60..55cba46d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,9 @@ sudo: false python: - "2.6" - "2.7" - - "3.2" - "3.3" + - "3.4" + - "3.5" install: # Self-install for setup.py-driven deps - pip install -e . @@ -12,15 +13,13 @@ install: - pip install coveralls # For coveralls.io specifically - pip install -r dev-requirements.txt script: - # Main tests, with coverage! - - invoke coverage + # Main tests, w/ coverage! + - inv test --coverage # Ensure documentation & invoke pipeline run OK. # Run 'docs' first since its objects.inv is referred to by 'www'. # Also force warnings to be errors since most of them tend to be actual # problems. - # Finally, skip them under Python 3.2 due to sphinx shenanigans - - "[[ $TRAVIS_PYTHON_VERSION != 3.2 ]] && invoke docs -o -W || true" - - "[[ $TRAVIS_PYTHON_VERSION != 3.2 ]] && invoke www -o -W || true" + - invoke docs -o -W www -o -W notifications: irc: channels: "irc.freenode.org#paramiko" diff --git a/README b/README deleted file mode 100644 index a645b140..00000000 --- a/README +++ /dev/null @@ -1,134 +0,0 @@ - -======== -paramiko -======== - -:Paramiko: Python SSH module -:Copyright: Copyright (c) 2003-2009 Robey Pointer <robeypointer@gmail.com> -:Copyright: Copyright (c) 2013-2015 Jeff Forcier <jeff@bitprophet.org> -:License: LGPL -:Homepage: https://github.com/paramiko/paramiko/ -:API docs: http://docs.paramiko.org - - -What ----- - -"paramiko" is a combination of the esperanto words for "paranoid" and -"friend". it's a module for python 2.6+ that implements the SSH2 protocol -for secure (encrypted and authenticated) connections to remote machines. -unlike SSL (aka TLS), SSH2 protocol does not require hierarchical -certificates signed by a powerful central authority. you may know SSH2 as -the protocol that replaced telnet and rsh for secure access to remote -shells, but the protocol also includes the ability to open arbitrary -channels to remote services across the encrypted tunnel (this is how sftp -works, for example). - -it is written entirely in python (no C or platform-dependent code) and is -released under the GNU LGPL (lesser GPL). - -the package and its API is fairly well documented in the "doc/" folder -that should have come with this archive. - - -Requirements ------------- - - - Python 2.6 or better <http://www.python.org/> - this includes Python - 3.2 and higher as well. - - pycrypto 2.1 or better <https://www.dlitz.net/software/pycrypto/> - - ecdsa 0.9 or better <https://pypi.python.org/pypi/ecdsa> - -If you have setuptools, you can build and install paramiko and all its -dependencies with this command (as root):: - - easy_install ./ - - -Portability ------------ - -i code and test this library on Linux and MacOS X. for that reason, i'm -pretty sure that it works for all posix platforms, including MacOS. it -should also work on Windows, though i don't test it as frequently there. -if you run into Windows problems, send me a patch: portability is important -to me. - -some python distributions don't include the utf-8 string encodings, for -reasons of space (misdirected as that is). if your distribution is -missing encodings, you'll see an error like this:: - - LookupError: no codec search functions registered: can't find encoding - -this means you need to copy string encodings over from a working system. -(it probably only happens on embedded systems, not normal python -installs.) Valeriy Pogrebitskiy says the best place to look is -``.../lib/python*/encodings/__init__.py``. - - -Bugs & Support --------------- - -Please file bug reports at https://github.com/paramiko/paramiko/. There is currently no mailing list but we plan to create a new one ASAP. - - -Demo ----- - -several demo scripts come with paramiko to demonstrate how to use it. -probably the simplest demo of all is this:: - - import paramiko, base64 - key = paramiko.RSAKey(data=base64.decodestring('AAA...')) - client = paramiko.SSHClient() - client.get_host_keys().add('ssh.example.com', 'ssh-rsa', key) - client.connect('ssh.example.com', username='strongbad', password='thecheat') - stdin, stdout, stderr = client.exec_command('ls') - for line in stdout: - print '... ' + line.strip('\n') - client.close() - -...which prints out the results of executing ``ls`` on a remote server. -(the host key 'AAA...' should of course be replaced by the actual base64 -encoding of the host key. if you skip host key verification, the -connection is not secure!) - -the following example scripts (in demos/) get progressively more detailed: - -:demo_simple.py: - calls invoke_shell() and emulates a terminal/tty through which you can - execute commands interactively on a remote server. think of it as a - poor man's ssh command-line client. - -:demo.py: - same as demo_simple.py, but allows you to authenticiate using a - private key, attempts to use an SSH-agent if present, and uses the long - form of some of the API calls. - -:forward.py: - command-line script to set up port-forwarding across an ssh transport. - (requires python 2.3.) - -:demo_sftp.py: - opens an sftp session and does a few simple file operations. - -:demo_server.py: - an ssh server that listens on port 2200 and accepts a login for - 'robey' (password 'foo'), and pretends to be a BBS. meant to be a - very simple demo of writing an ssh server. - -:demo_keygen.py: - an key generator similar to openssh ssh-keygen(1) program with - paramiko keys generation and progress functions. - -Use ---- - -the demo scripts are probably the best example of how to use this package. -there is also a lot of documentation, generated with Sphinx autodoc, in the doc/ folder. - -there are also unit tests here:: - - $ python ./test.py - -which will verify that most of the core components are working correctly. diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..0dcf30e5 --- /dev/null +++ b/README.rst @@ -0,0 +1,152 @@ +======== +Paramiko +======== + +.. Continuous integration and code coverage badges + +.. 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 + +:Paramiko: Python SSH module +:Copyright: Copyright (c) 2003-2009 Robey Pointer <robeypointer@gmail.com> +:Copyright: Copyright (c) 2013-2015 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 +:Development: https://github.com/paramiko/paramiko + + +What +---- + +"Paramiko" is a combination of the esperanto words for "paranoid" and +"friend". It's a module for Python 2.6+ that implements the SSH2 protocol +for secure (encrypted and authenticated) connections to remote machines. +Unlike SSL (aka TLS), SSH2 protocol does not require hierarchical +certificates signed by a powerful central authority. You may know SSH2 as +the protocol that replaced Telnet and rsh for secure access to remote +shells, but the protocol also includes the ability to open arbitrary +channels to remote services across the encrypted tunnel (this is how SFTP +works, for example). + +It is written entirely in Python (no C or platform-dependent code) and is +released under the GNU Lesser General Public License (`LGPL +<https://www.gnu.org/copyleft/lesser.html>`_). + +The package and its API is fairly well documented in the "doc/" folder +that should have come with this archive. + + +Requirements +------------ + +- `Python <http://www.python.org/>`_ 2.6, 2.7, or 3.3+ (3.2 should also work, + but it is not recommended) +- `pycrypto <https://www.dlitz.net/software/pycrypto/>`_ 2.1+ +- `ecdsa <https://pypi.python.org/pypi/ecdsa>`_ 0.11+ + + +Installation +------------ + +For most users, the recommended method to install is via pip:: + + pip install paramiko + +For more detailed instructions, see the `Installing +<http://www.paramiko.org/installing.html>`_ page on the main Paramiko website. + + +Portability Issues +------------------ + +Paramiko primarily supports POSIX platforms with standard OpenSSH +implementations, and is most frequently tested on Linux and OS X. Windows is +supported as well, though it may not be as straightforward. + +Some Windows users whose Python is 64-bit have found that the PyCrypto +dependency ``winrandom`` may not install properly, leading to an +``ImportError``. In this scenario, you may need to compile ``winrandom`` +yourself. See `Fabric #194 <https://github.com/fabric/fabric/issues/194>`_ +for info. + + +Bugs & Support +-------------- + +:Bug Reports: `Github <https://github.com/paramiko/paramiko/issues/>`_ +:Mailing List: ``paramiko@librelist.com`` (see the `LibreList website + <http://librelist.com/>`_ for usage details). +:IRC: ``#paramiko`` on Freenode + + +Kerberos Support +---------------- + +Paramiko ships with optional Kerberos/GSSAPI support; for info on the extra +dependencies for this, see the `GSS-API section +<http://www.paramiko.org/installing.html#gssapi>`_ +on the main Paramiko website. + + +Demo +---- + +Several demo scripts come with Paramiko to demonstrate how to use it. +Probably the simplest demo of all is this:: + + import paramiko, base64 + key = paramiko.RSAKey(data=base64.decodestring('AAA...')) + client = paramiko.SSHClient() + client.get_host_keys().add('ssh.example.com', 'ssh-rsa', key) + client.connect('ssh.example.com', username='strongbad', password='thecheat') + stdin, stdout, stderr = client.exec_command('ls') + for line in stdout: + print '... ' + line.strip('\n') + client.close() + +This prints out the results of executing ``ls`` on a remote server. The host +key 'AAA...' should of course be replaced by the actual base64 encoding of the +host key. If you skip host key verification, the connection is not secure! + +The following example scripts (in demos/) get progressively more detailed: + +:demo_simple.py: + Calls invoke_shell() and emulates a terminal/TTY through which you can + execute commands interactively on a remote server. Think of it as a + poor man's SSH command-line client. + +:demo.py: + Same as demo_simple.py, but allows you to authenticate using a private + key, attempts to use an SSH agent if present, and uses the long form of + some of the API calls. + +:forward.py: + Command-line script to set up port-forwarding across an SSH transport. + +:demo_sftp.py: + Opens an SFTP session and does a few simple file operations. + +:demo_server.py: + An SSH server that listens on port 2200 and accepts a login for + 'robey' (password 'foo'), and pretends to be a BBS. Meant to be a + very simple demo of writing an SSH server. + +:demo_keygen.py: + A key generator similar to OpenSSH ``ssh-keygen(1)`` program with + Paramiko keys generation and progress functions. + +Use +--- + +The demo scripts are probably the best example of how to use this package. +There is also a lot of documentation, generated with Sphinx autodoc, in the +doc/ folder. + +There are also unit tests here:: + + $ python ./test.py + +Which will verify that most of the core components are working correctly. diff --git a/demos/demo_server.py b/demos/demo_server.py index bb35258b..c4af9b10 100644 --- a/demos/demo_server.py +++ b/demos/demo_server.py @@ -66,9 +66,42 @@ class Server (paramiko.ServerInterface): if (username == 'robey') and (key == self.good_pub_key): return paramiko.AUTH_SUCCESSFUL return paramiko.AUTH_FAILED + + def check_auth_gssapi_with_mic(self, username, + gss_authenticated=paramiko.AUTH_FAILED, + cc_file=None): + """ + .. note:: + We are just checking in `AuthHandler` that the given user is a + valid krb5 principal! We don't check if the krb5 principal is + allowed to log in on the server, because there is no way to do that + in python. So if you develop your own SSH server with paramiko for + a certain platform like Linux, you should call ``krb5_kuserok()`` in + your local kerberos library to make sure that the krb5_principal + has an account on the server and is allowed to log in as a user. + + .. seealso:: + `krb5_kuserok() man page + <http://www.unix.com/man-page/all/3/krb5_kuserok/>`_ + """ + if gss_authenticated == paramiko.AUTH_SUCCESSFUL: + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def check_auth_gssapi_keyex(self, username, + gss_authenticated=paramiko.AUTH_FAILED, + cc_file=None): + if gss_authenticated == paramiko.AUTH_SUCCESSFUL: + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def enable_auth_gssapi(self): + UseGSSAPI = True + GSSAPICleanupCredentials = False + return UseGSSAPI def get_allowed_auths(self, username): - return 'password,publickey' + return 'gssapi-keyex,gssapi-with-mic,password,publickey' def check_channel_shell_request(self, channel): self.event.set() @@ -79,6 +112,8 @@ class Server (paramiko.ServerInterface): return True +DoGSSAPIKeyExchange = True + # now connect try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -101,7 +136,8 @@ except Exception as e: print('Got a connection!') try: - t = paramiko.Transport(client) + t = paramiko.Transport(client, gss_kex=DoGSSAPIKeyExchange) + t.set_gss_host(socket.getfqdn("")) try: t.load_server_moduli() except: @@ -123,7 +159,7 @@ try: print('Authenticated!') server.event.wait(10) - if not server.event.isSet(): + if not server.event.is_set(): print('*** Client never asked for a shell.') sys.exit(1) diff --git a/demos/demo_sftp.py b/demos/demo_sftp.py index a34f2b19..2cb44701 100755..100644 --- a/demos/demo_sftp.py +++ b/demos/demo_sftp.py @@ -34,6 +34,11 @@ from paramiko.py3compat import input # setup logging paramiko.util.log_to_file('demo_sftp.log') +# Paramiko client configuration +UseGSSAPI = True # enable GSS-API / SSPI authentication +DoGSSAPIKeyExchange = True +Port = 22 + # get hostname username = '' if len(sys.argv) > 1: @@ -45,10 +50,10 @@ else: if len(hostname) == 0: print('*** Hostname required.') sys.exit(1) -port = 22 + if hostname.find(':') >= 0: hostname, portstr = hostname.split(':') - port = int(portstr) + Port = int(portstr) # get username @@ -57,7 +62,10 @@ if username == '': username = input('Username [%s]: ' % default_username) if len(username) == 0: username = default_username -password = getpass.getpass('Password for %s@%s: ' % (username, hostname)) +if not UseGSSAPI: + password = getpass.getpass('Password for %s@%s: ' % (username, hostname)) +else: + password = None # get host key, if we know one @@ -81,8 +89,9 @@ if hostname in host_keys: # now, connect and use paramiko Transport to negotiate SSH2 across the connection try: - t = paramiko.Transport((hostname, port)) - t.connect(username=username, password=password, hostkey=hostkey) + t = paramiko.Transport((hostname, Port)) + t.connect(hostkey, username, password, gss_host=socket.getfqdn(hostname), + gss_auth=UseGSSAPI, gss_kex=DoGSSAPIKeyExchange) sftp = paramiko.SFTPClient.from_transport(t) # dirlist on remote host diff --git a/demos/demo_simple.py b/demos/demo_simple.py index ae631e43..3a17988c 100755 --- a/demos/demo_simple.py +++ b/demos/demo_simple.py @@ -36,6 +36,10 @@ except ImportError: # setup logging paramiko.util.log_to_file('demo_simple.log') +# Paramiko client configuration +UseGSSAPI = True # enable GSS-API / SSPI authentication +DoGSSAPIKeyExchange = True +port = 22 # get hostname username = '' @@ -48,7 +52,7 @@ else: if len(hostname) == 0: print('*** Hostname required.') sys.exit(1) -port = 22 + if hostname.find(':') >= 0: hostname, portstr = hostname.split(':') port = int(portstr) @@ -60,7 +64,8 @@ if username == '': username = input('Username [%s]: ' % default_username) if len(username) == 0: username = default_username -password = getpass.getpass('Password for %s@%s: ' % (username, hostname)) +if not UseGSSAPI or (not UseGSSAPI and not DoGSSAPIKeyExchange): + password = getpass.getpass('Password for %s@%s: ' % (username, hostname)) # now, connect and use paramiko Client to negotiate SSH2 across the connection @@ -69,7 +74,18 @@ try: client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.WarningPolicy()) print('*** Connecting...') - client.connect(hostname, port, username, password) + if not UseGSSAPI or (not UseGSSAPI and not DoGSSAPIKeyExchange): + client.connect(hostname, port, username, password) + else: + # SSPI works only with the FQDN of the target host + hostname = socket.getfqdn(hostname) + try: + client.connect(hostname, port, username, gss_auth=UseGSSAPI, + gss_kex=DoGSSAPIKeyExchange) + except Exception: + password = getpass.getpass('Password for %s@%s: ' % (username, hostname)) + client.connect(hostname, port, username, password) + chan = client.invoke_shell() print(repr(client.get_transport())) print('*** Here we go!\n') diff --git a/dev-requirements.txt b/dev-requirements.txt index 059572cf..9e4564a5 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,11 +1,11 @@ # Older junk tox>=1.4,<1.5 # For newer tasks like building Sphinx docs. -invoke>=0.10 -invocations>=0.9.2 +invoke>=0.11.1 +invocations>=0.11.0 sphinx>=1.1.3 -alabaster>=0.6.1 -releases>=0.5.2 +alabaster>=0.7.5 +releases>=1.0.0 semantic_version>=2.4,<2.5 wheel==0.24 twine==1.5 diff --git a/paramiko/__init__.py b/paramiko/__init__.py index 65f6f8a2..9e2ba013 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -30,6 +30,7 @@ __license__ = "GNU Lesser General Public License (LGPL)" from paramiko.transport import SecurityOptions, Transport from paramiko.client import SSHClient, MissingHostKeyPolicy, AutoAddPolicy, RejectPolicy, WarningPolicy from paramiko.auth_handler import AuthHandler +from paramiko.ssh_gss import GSSAuth, GSS_AUTH_AVAILABLE from paramiko.channel import Channel, ChannelFile from paramiko.ssh_exception import SSHException, PasswordRequiredException, \ BadAuthenticationType, ChannelException, BadHostKeyException, \ diff --git a/paramiko/_version.py b/paramiko/_version.py index 0402fcf2..e82b8667 100644 --- a/paramiko/_version.py +++ b/paramiko/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (1, 13, 3) +__version_info__ = (1, 16, 0) __version__ = '.'.join(map(str, __version_info__)) diff --git a/paramiko/agent.py b/paramiko/agent.py index e77b7281..6a8e7fb4 100644 --- a/paramiko/agent.py +++ b/paramiko/agent.py @@ -32,7 +32,7 @@ from select import select from paramiko.common import asbytes, io_sleep from paramiko.py3compat import byte_chr -from paramiko.ssh_exception import SSHException +from paramiko.ssh_exception import SSHException, AuthenticationException from paramiko.message import Message from paramiko.pkey import PKey from paramiko.util import retry_on_signal @@ -43,6 +43,7 @@ cSSH2_AGENTC_SIGN_REQUEST = byte_chr(13) SSH2_AGENT_SIGN_RESPONSE = 14 + class AgentSSH(object): def __init__(self): self._conn = None @@ -108,9 +109,12 @@ class AgentProxyThread(threading.Thread): def run(self): try: (r, addr) = self.get_connection() + # Found that r should be either a socket from the socket library or None self.__inr = r - self.__addr = addr + self.__addr = addr # This should be an IP address as a string? or None self._agent.connect() + if not isinstance(self._agent, int) and (self._agent._conn is None or not hasattr(self._agent._conn, 'fileno')): + raise AuthenticationException("Unable to connect to SSH agent") self._communicate() except: #XXX Not sure what to do here ... raise or pass ? @@ -286,6 +290,26 @@ class AgentServerProxy(AgentSSH): class AgentRequestHandler(object): + """ + Primary/default implementation of SSH agent forwarding functionality. + + Simply instantiate this class, handing it a live command-executing session + object, and it will handle forwarding any local SSH agent processes it + finds. + + For example:: + + # Connect + client = SSHClient() + client.connect(host, port, username) + # Obtain session + session = client.get_transport().open_session() + # Forward local agent + AgentRequestHandler(session) + # Commands executed after this point will see the forwarded agent on + # the remote end. + session.exec_command("git clone https://my.git.repository/") + """ def __init__(self, chanClient): self._conn = None self.__chanC = chanClient @@ -365,7 +389,7 @@ class AgentKey(PKey): def get_name(self): return self.name - def sign_ssh_data(self, rng, data): + def sign_ssh_data(self, data): msg = Message() msg.add_byte(cSSH2_AGENTC_SIGN_REQUEST) msg.add_string(self.blob) diff --git a/paramiko/auth_handler.py b/paramiko/auth_handler.py index c00ad41c..38b23729 100644 --- a/paramiko/auth_handler.py +++ b/paramiko/auth_handler.py @@ -28,20 +28,27 @@ from paramiko.common import cMSG_SERVICE_REQUEST, cMSG_DISCONNECT, \ cMSG_USERAUTH_INFO_REQUEST, WARNING, AUTH_FAILED, cMSG_USERAUTH_PK_OK, \ cMSG_USERAUTH_INFO_RESPONSE, MSG_SERVICE_REQUEST, MSG_SERVICE_ACCEPT, \ MSG_USERAUTH_REQUEST, MSG_USERAUTH_SUCCESS, MSG_USERAUTH_FAILURE, \ - MSG_USERAUTH_BANNER, MSG_USERAUTH_INFO_REQUEST, MSG_USERAUTH_INFO_RESPONSE + MSG_USERAUTH_BANNER, MSG_USERAUTH_INFO_REQUEST, MSG_USERAUTH_INFO_RESPONSE, \ + cMSG_USERAUTH_GSSAPI_RESPONSE, cMSG_USERAUTH_GSSAPI_TOKEN, \ + cMSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE, cMSG_USERAUTH_GSSAPI_ERROR, \ + cMSG_USERAUTH_GSSAPI_ERRTOK, cMSG_USERAUTH_GSSAPI_MIC,\ + MSG_USERAUTH_GSSAPI_RESPONSE, MSG_USERAUTH_GSSAPI_TOKEN, \ + MSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE, 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 SSHException, AuthenticationException, \ BadAuthenticationType, PartialAuthentication from paramiko.server import InteractiveQuery +from paramiko.ssh_gss import GSSAuth class AuthHandler (object): """ Internal class to handle the mechanics of authentication. """ - + def __init__(self, transport): self.transport = weakref.proxy(transport) self.username = None @@ -56,7 +63,10 @@ class AuthHandler (object): # for server mode: self.auth_username = None self.auth_fail_count = 0 - + # for GSSAPI + self.gss_host = None + self.gss_deleg_creds = True + def is_authenticated(self): return self.authenticated @@ -97,7 +107,7 @@ class AuthHandler (object): self._request_auth() finally: self.transport.lock.release() - + def auth_interactive(self, username, handler, event, submethods=''): """ response_list = handler(title, instructions, prompt_list) @@ -112,7 +122,29 @@ class AuthHandler (object): self._request_auth() finally: self.transport.lock.release() - + + def auth_gssapi_with_mic(self, username, gss_host, gss_deleg_creds, event): + self.transport.lock.acquire() + try: + self.auth_event = event + self.auth_method = 'gssapi-with-mic' + self.username = username + self.gss_host = gss_host + self.gss_deleg_creds = gss_deleg_creds + self._request_auth() + finally: + self.transport.lock.release() + + def auth_gssapi_keyex(self, username, event): + self.transport.lock.acquire() + try: + self.auth_event = event + self.auth_method = 'gssapi-keyex' + self.username = username + self._request_auth() + finally: + self.transport.lock.release() + def abort(self): if self.auth_event is not None: self.auth_event.set() @@ -163,7 +195,7 @@ class AuthHandler (object): if (e is None) or issubclass(e.__class__, EOFError): e = AuthenticationException('Authentication failed.') raise e - if event.isSet(): + if event.is_set(): break if not self.is_authenticated(): e = self.transport.get_exception() @@ -206,11 +238,82 @@ class AuthHandler (object): m.add_string(self.private_key.get_name()) m.add_string(self.private_key) blob = self._get_session_blob(self.private_key, 'ssh-connection', self.username) - sig = self.private_key.sign_ssh_data(self.transport.rng, blob) + sig = self.private_key.sign_ssh_data(blob) m.add_string(sig) elif self.auth_method == 'keyboard-interactive': m.add_string('') m.add_string(self.submethods) + elif self.auth_method == "gssapi-with-mic": + sshgss = GSSAuth(self.auth_method, self.gss_deleg_creds) + m.add_bytes(sshgss.ssh_gss_oids()) + # send the supported GSSAPI OIDs to the server + self.transport._send_message(m) + ptype, m = self.transport.packetizer.read_message() + if ptype == MSG_USERAUTH_BANNER: + self._parse_userauth_banner(m) + ptype, m = self.transport.packetizer.read_message() + if ptype == MSG_USERAUTH_GSSAPI_RESPONSE: + # Read the mechanism selected by the server. We send just + # the Kerberos V5 OID, so the server can only respond with + # this OID. + mech = m.get_string() + m = Message() + m.add_byte(cMSG_USERAUTH_GSSAPI_TOKEN) + m.add_string(sshgss.ssh_init_sec_context(self.gss_host, + mech, + self.username,)) + self.transport._send_message(m) + while True: + ptype, m = self.transport.packetizer.read_message() + if ptype == MSG_USERAUTH_GSSAPI_TOKEN: + srv_token = m.get_string() + next_token = sshgss.ssh_init_sec_context(self.gss_host, + mech, + self.username, + srv_token) + # After this step the GSSAPI should not return any + # token. If it does, we keep sending the token to + # the server until no more token is returned. + if next_token is None: + break + else: + m = Message() + m.add_byte(cMSG_USERAUTH_GSSAPI_TOKEN) + m.add_string(next_token) + self.transport.send_message(m) + else: + raise SSHException("Received Package: %s" % MSG_NAMES[ptype]) + m = Message() + m.add_byte(cMSG_USERAUTH_GSSAPI_MIC) + # send the MIC to the server + m.add_string(sshgss.ssh_get_mic(self.transport.session_id)) + elif ptype == MSG_USERAUTH_GSSAPI_ERRTOK: + # RFC 4462 says we are not required to implement GSS-API + # error messages. + # See RFC 4462 Section 3.8 in + # http://www.ietf.org/rfc/rfc4462.txt + raise SSHException("Server returned an error token") + elif ptype == MSG_USERAUTH_GSSAPI_ERROR: + maj_status = m.get_int() + min_status = m.get_int() + err_msg = m.get_string() + lang_tag = m.get_string() # we don't care! + raise SSHException("GSS-API Error:\nMajor Status: %s\n\ + Minor Status: %s\ \nError Message:\ + %s\n") % (str(maj_status), + str(min_status), + err_msg) + elif ptype == MSG_USERAUTH_FAILURE: + self._parse_userauth_failure(m) + return + else: + raise SSHException("Received Package: %s" % MSG_NAMES[ptype]) + elif self.auth_method == 'gssapi-keyex' and\ + self.transport.gss_kex_used: + kexgss = self.transport.kexgss_ctxt + kexgss.set_username(self.username) + mic_token = kexgss.ssh_get_mic(self.transport.session_id) + m.add_string(mic_token) elif self.auth_method == 'none': pass else: @@ -253,7 +356,7 @@ class AuthHandler (object): m.add_string(p[0]) m.add_boolean(p[1]) self.transport._send_message(m) - + def _parse_userauth_request(self, m): if not self.transport.server_mode: # er, uh... what? @@ -278,6 +381,8 @@ class AuthHandler (object): self._disconnect_no_more_auth() return self.auth_username = username + # check if GSS-API authentication is enabled + gss_auth = self.transport.server_object.enable_auth_gssapi() if method == 'none': result = self.transport.server_object.check_auth_none(username) @@ -343,6 +448,91 @@ class AuthHandler (object): # make interactive query instead of response self._interactive_query(result) return + elif method == "gssapi-with-mic" and gss_auth: + sshgss = GSSAuth(method) + # Read the number of OID mechanisms supported by the client. + # OpenSSH sends just one OID. It's the Kerveros V5 OID and that's + # the only OID we support. + mechs = m.get_int() + # We can't accept more than one OID, so if the SSH client sends + # more than one, disconnect. + if mechs > 1: + self.transport._log(INFO, + 'Disconnect: Received more than one GSS-API OID mechanism') + self._disconnect_no_more_auth() + desired_mech = m.get_string() + mech_ok = sshgss.ssh_check_mech(desired_mech) + # if we don't support the mechanism, disconnect. + if not mech_ok: + self.transport._log(INFO, + 'Disconnect: Received an invalid GSS-API OID mechanism') + self._disconnect_no_more_auth() + # send the Kerberos V5 GSSAPI OID to the client + supported_mech = sshgss.ssh_gss_oids("server") + # RFC 4462 says we are not required to implement GSS-API error + # messages. See section 3.8 in http://www.ietf.org/rfc/rfc4462.txt + while True: + m = Message() + m.add_byte(cMSG_USERAUTH_GSSAPI_RESPONSE) + m.add_bytes(supported_mech) + self.transport._send_message(m) + ptype, m = self.transport.packetizer.read_message() + if ptype == MSG_USERAUTH_GSSAPI_TOKEN: + client_token = m.get_string() + # use the client token as input to establish a secure + # context. + try: + token = sshgss.ssh_accept_sec_context(self.gss_host, + client_token, + username) + except Exception: + result = AUTH_FAILED + self._send_auth_result(username, method, result) + raise + if token is not None: + m = Message() + m.add_byte(cMSG_USERAUTH_GSSAPI_TOKEN) + m.add_string(token) + self.transport._send_message(m) + else: + result = AUTH_FAILED + self._send_auth_result(username, method, result) + return + # check MIC + ptype, m = self.transport.packetizer.read_message() + if ptype == MSG_USERAUTH_GSSAPI_MIC: + break + mic_token = m.get_string() + try: + sshgss.ssh_check_mic(mic_token, + self.transport.session_id, + username) + except Exception: + result = AUTH_FAILED + self._send_auth_result(username, method, result) + raise + # TODO: Implement client credential saving. + # The OpenSSH server is able to create a TGT with the delegated + # client credentials, but this is not supported by GSS-API. + result = AUTH_SUCCESSFUL + self.transport.server_object.check_auth_gssapi_with_mic(username, result) + elif method == "gssapi-keyex" and gss_auth: + mic_token = m.get_string() + sshgss = self.transport.kexgss_ctxt + if sshgss is None: + # If there is no valid context, we reject the authentication + result = AUTH_FAILED + self._send_auth_result(username, method, result) + try: + sshgss.ssh_check_mic(mic_token, + self.transport.session_id, + self.auth_username) + except Exception: + result = AUTH_FAILED + self._send_auth_result(username, method, result) + raise + result = AUTH_SUCCESSFUL + self.transport.server_object.check_auth_gssapi_keyex(username, result) else: result = self.transport.server_object.check_auth_none(username) # okay, send result @@ -379,7 +569,7 @@ class AuthHandler (object): lang = m.get_string() self.transport._log(INFO, 'Auth banner: %s' % banner) # who cares. - + def _parse_userauth_info_request(self, m): if self.auth_method != 'keyboard-interactive': raise SSHException('Illegal info request from server') @@ -391,14 +581,14 @@ class AuthHandler (object): for i in range(prompts): prompt_list.append((m.get_text(), m.get_boolean())) response_list = self.interactive_handler(title, instructions, prompt_list) - + m = Message() m.add_byte(cMSG_USERAUTH_INFO_RESPONSE) m.add_int(len(response_list)) for r in response_list: m.add_string(r) self.transport._send_message(m) - + def _parse_userauth_info_response(self, m): if not self.transport.server_mode: raise SSHException('Illegal info response from server') diff --git a/paramiko/ber.py b/paramiko/ber.py index 05152303..a388df07 100644 --- a/paramiko/ber.py +++ b/paramiko/ber.py @@ -45,7 +45,7 @@ class BER(object): def decode(self): return self.decode_next() - + def decode_next(self): if self.idx >= len(self.content): return None @@ -89,6 +89,7 @@ class BER(object): # 1: boolean (00 false, otherwise true) raise BERException('Unknown ber encoding type %d (robey is lazy)' % ident) + @staticmethod def decode_sequence(data): out = [] ber = BER(data) @@ -98,7 +99,6 @@ class BER(object): break out.append(x) return out - decode_sequence = staticmethod(decode_sequence) def encode_tlv(self, ident, val): # no need to support ident > 31 here @@ -125,9 +125,9 @@ class BER(object): else: raise BERException('Unknown type for encoding: %s' % repr(type(x))) + @staticmethod def encode_sequence(data): ber = BER() for item in data: ber.encode(item) return ber.asbytes() - encode_sequence = staticmethod(encode_sequence) diff --git a/paramiko/buffered_pipe.py b/paramiko/buffered_pipe.py index ac35b3e1..d5fe164e 100644 --- a/paramiko/buffered_pipe.py +++ b/paramiko/buffered_pipe.py @@ -81,7 +81,7 @@ class BufferedPipe (object): Feed new data into this pipe. This method is assumed to be called from a separate thread, so synchronization is done. - :param data: the data to add, as a `str` + :param data: the data to add, as a `str` or `bytes` """ self._lock.acquire() try: @@ -125,7 +125,7 @@ class BufferedPipe (object): :param int nbytes: maximum number of bytes to read :param float timeout: maximum seconds to wait (or ``None``, the default, to wait forever) - :return: the read data, as a `str` + :return: the read data, as a `bytes` :raises PipeTimeout: if a timeout was specified and no data was ready before that diff --git a/paramiko/channel.py b/paramiko/channel.py index 23323650..3c43eb10 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -21,9 +21,11 @@ Abstraction for an SSH2 channel. """ import binascii +import os +import socket import time import threading -import socket +from functools import wraps from paramiko import util from paramiko.common import cMSG_CHANNEL_REQUEST, cMSG_CHANNEL_WINDOW_ADJUST, \ @@ -36,13 +38,30 @@ from paramiko.ssh_exception import SSHException from paramiko.file import BufferedFile from paramiko.buffered_pipe import BufferedPipe, PipeTimeout from paramiko import pipe +from paramiko.util import ClosingContextManager + +def open_only(func): + """ + Decorator for `.Channel` methods which performs an openness check. -# lower bound on the max packet size we'll accept from the remote host -MIN_PACKET_SIZE = 1024 + :raises SSHException: + If the wrapped method is called on an unopened `.Channel`. + """ + @wraps(func) + def _check(self, *args, **kwds): + if ( + self.closed + or self.eof_received + or self.eof_sent + or not self.active + ): + raise SSHException('Channel is not open') + return func(self, *args, **kwds) + return _check -class Channel (object): +class Channel (ClosingContextManager): """ A secure tunnel across an SSH `.Transport`. A Channel is meant to behave like a socket, and has an API that should be indistinguishable from the @@ -55,6 +74,8 @@ class Channel (object): flow-controlled independently.) Similarly, if the server isn't reading data you send, calls to `send` may block, unless you set a timeout. This is exactly like a normal network socket, so it shouldn't be too surprising. + + Instances of this class may be used as context managers. """ def __init__(self, chanid): @@ -67,15 +88,20 @@ class Channel (object): :param int chanid: the ID of this channel, as passed by an existing `.Transport`. """ + #: Channel ID self.chanid = chanid + #: Remote channel ID self.remote_chanid = 0 + #: `.Transport` managing this channel self.transport = None + #: Whether the connection is presently active self.active = False self.eof_received = 0 self.eof_sent = 0 self.in_buffer = BufferedPipe() self.in_stderr_buffer = BufferedPipe() self.timeout = None + #: Whether the connection has been closed self.closed = False self.ultra_debug = False self.lock = threading.Lock() @@ -95,13 +121,13 @@ class Channel (object): self.combine_stderr = False self.exit_status = -1 self.origin_addr = None - + def __del__(self): try: self.close() except: pass - + def __repr__(self): """ Return a string representation of this object, for debugging. @@ -121,6 +147,7 @@ class Channel (object): out += '>' return out + @open_only def get_pty(self, term='vt100', width=80, height=24, width_pixels=0, height_pixels=0): """ @@ -135,12 +162,10 @@ class Channel (object): :param int height: height (in characters) of the terminal screen :param int width_pixels: width (in pixels) of the terminal screen :param int height_pixels: height (in pixels) of the terminal screen - + :raises SSHException: if the request was rejected or the channel was closed """ - if self.closed or self.eof_received or self.eof_sent or not self.active: - raise SSHException('Channel is not open') m = Message() m.add_byte(cMSG_CHANNEL_REQUEST) m.add_int(self.remote_chanid) @@ -156,24 +181,23 @@ class Channel (object): self.transport._send_user_message(m) self._wait_for_event() + @open_only def invoke_shell(self): """ Request an interactive shell session on this channel. If the server allows it, the channel will then be directly connected to the stdin, stdout, and stderr of the shell. - + Normally you would call `get_pty` before this, in which case the shell will operate through the pty, and the channel will be connected to the stdin and stdout of the pty. - + When the shell exits, the channel will be closed and can't be reused. You must open a new channel if you wish to open another shell. - + :raises SSHException: if the request was rejected or the channel was closed """ - if self.closed or self.eof_received or self.eof_sent or not self.active: - raise SSHException('Channel is not open') m = Message() m.add_byte(cMSG_CHANNEL_REQUEST) m.add_int(self.remote_chanid) @@ -183,12 +207,13 @@ class Channel (object): self.transport._send_user_message(m) self._wait_for_event() + @open_only def exec_command(self, command): """ Execute a command on the server. If the server allows it, the channel will then be directly connected to the stdin, stdout, and stderr of the command being executed. - + When the command finishes executing, the channel will be closed and can't be reused. You must open a new channel if you wish to execute another command. @@ -198,8 +223,6 @@ class Channel (object): :raises SSHException: if the request was rejected or the channel was closed """ - if self.closed or self.eof_received or self.eof_sent or not self.active: - raise SSHException('Channel is not open') m = Message() m.add_byte(cMSG_CHANNEL_REQUEST) m.add_int(self.remote_chanid) @@ -210,12 +233,13 @@ class Channel (object): self.transport._send_user_message(m) self._wait_for_event() + @open_only def invoke_subsystem(self, subsystem): """ Request a subsystem on the server (for example, ``sftp``). If the server allows it, the channel will then be directly connected to the requested subsystem. - + When the subsystem finishes, the channel will be closed and can't be reused. @@ -224,8 +248,6 @@ class Channel (object): :raises SSHException: if the request was rejected or the channel was closed """ - if self.closed or self.eof_received or self.eof_sent or not self.active: - raise SSHException('Channel is not open') m = Message() m.add_byte(cMSG_CHANNEL_REQUEST) m.add_int(self.remote_chanid) @@ -236,6 +258,7 @@ class Channel (object): self.transport._send_user_message(m) self._wait_for_event() + @open_only def resize_pty(self, width=80, height=24, width_pixels=0, height_pixels=0): """ Resize the pseudo-terminal. This can be used to change the width and @@ -249,8 +272,6 @@ class Channel (object): :raises SSHException: if the request was rejected or the channel was closed """ - if self.closed or self.eof_received or self.eof_sent or not self.active: - raise SSHException('Channel is not open') m = Message() m.add_byte(cMSG_CHANNEL_REQUEST) m.add_int(self.remote_chanid) @@ -268,14 +289,15 @@ class Channel (object): status. You may use this to poll the process status if you don't want to block in `recv_exit_status`. Note that the server may not return an exit status in some cases (like bad servers). - + :return: - ``True`` if `recv_exit_status` will return immediately, else ``False``. + ``True`` if `recv_exit_status` will return immediately, else + ``False``. .. versionadded:: 1.7.3 """ - return self.closed or self.status_event.isSet() - + return self.closed or self.status_event.is_set() + def recv_exit_status(self): """ Return the exit status from the process on the server. This is @@ -283,13 +305,24 @@ class Channel (object): If the command hasn't finished yet, this method will wait until it does, or until the channel is closed. If no exit status is provided by the server, -1 is returned. - + + .. warning:: + In some situations, receiving remote output larger than the current + `.Transport` or session's ``window_size`` (e.g. that set by the + ``default_window_size`` kwarg for `.Transport.__init__`) will cause + `.recv_exit_status` to hang indefinitely if it is called prior to a + sufficiently large `~Channel..read` (or if there are no threads + calling `~Channel.read` in the background). + + In these cases, ensuring that `.recv_exit_status` is called *after* + `~Channel.read` (or, again, using threads) can avoid the hang. + :return: the exit code (as an `int`) of the process on the server. - + .. versionadded:: 1.2 """ self.status_event.wait() - assert self.status_event.isSet() + assert self.status_event.is_set() return self.exit_status def send_exit_status(self, status): @@ -298,9 +331,9 @@ class Channel (object): really only makes sense in server mode.) Many clients expect to get some sort of status code back from an executed command after it completes. - + :param int status: the exit code of the process - + .. versionadded:: 1.2 """ # in many cases, the channel will not still be open here. @@ -312,20 +345,21 @@ class Channel (object): m.add_boolean(False) m.add_int(status) self.transport._send_user_message(m) - + + @open_only def request_x11(self, screen_number=0, auth_protocol=None, auth_cookie=None, single_connection=False, handler=None): """ Request an x11 session on this channel. If the server allows it, further x11 requests can be made from the server to the client, when an x11 application is run in a shell session. - - From RFC4254:: + + From :rfc:`4254`:: It is RECOMMENDED that the 'x11 authentication cookie' that is sent be a fake, random cookie, and that the cookie be checked and replaced by the real cookie when a connection request is received. - + If you omit the auth_cookie, a new secure random 128-bit value will be generated, used, and returned. You will need to use this value to verify incoming x11 requests and replace them with the actual local @@ -335,9 +369,9 @@ class Channel (object): whenever a new x11 connection arrives. The default handler queues up incoming x11 connections, which may be retrieved using `.Transport.accept`. The handler's calling signature is:: - + handler(channel: Channel, (address: str, port: int)) - + :param int screen_number: the x11 screen number (0, 10, etc.) :param str auth_protocol: the name of the X11 authentication method used; if none is given, @@ -353,12 +387,10 @@ class Channel (object): an optional handler to use for incoming X11 connections :return: the auth_cookie used """ - if self.closed or self.eof_received or self.eof_sent or not self.active: - raise SSHException('Channel is not open') if auth_protocol is None: auth_protocol = 'MIT-MAGIC-COOKIE-1' if auth_cookie is None: - auth_cookie = binascii.hexlify(self.transport.rng.read(16)) + auth_cookie = binascii.hexlify(os.urandom(16)) m = Message() m.add_byte(cMSG_CHANNEL_REQUEST) @@ -375,6 +407,7 @@ class Channel (object): self.transport._set_x11_handler(handler) return auth_cookie + @open_only def request_forward_agent(self, handler): """ Request for a forward SSH Agent on this channel. @@ -387,9 +420,6 @@ class Channel (object): :raises: SSHException in case of channel problem. """ - if self.closed or self.eof_received or self.eof_sent or not self.active: - raise SSHException('Channel is not open') - m = Message() m.add_byte(cMSG_CHANNEL_REQUEST) m.add_int(self.remote_chanid) @@ -424,33 +454,33 @@ class Channel (object): def get_id(self): """ Return the `int` ID # for this channel. - + The channel ID is unique across a `.Transport` and usually a small number. It's also the number passed to `.ServerInterface.check_channel_request` when determining whether to accept a channel request in server mode. """ return self.chanid - + def set_combine_stderr(self, combine): """ Set whether stderr should be combined into stdout on this channel. The default is ``False``, but in some cases it may be convenient to have both streams combined. - + If this is ``False``, and `exec_command` is called (or ``invoke_shell`` with no pty), output to stderr will not show up through the `recv` and `recv_ready` calls. You will have to use `recv_stderr` and `recv_stderr_ready` to get stderr output. - + If this is ``True``, data will never show up via `recv_stderr` or `recv_stderr_ready`. - + :param bool combine: ``True`` if stderr output should be combined into stdout on this channel. :return: the previous setting (a `bool`). - + .. versionadded:: 1.1 """ data = bytes() @@ -559,7 +589,7 @@ class Channel (object): Returns true if data is buffered and ready to be read from this channel. A ``False`` result does not mean that the channel has closed; it means you may need to wait before more data arrives. - + :return: ``True`` if a `recv` call on this channel would immediately return at least one byte; ``False`` otherwise. @@ -574,8 +604,8 @@ class Channel (object): is returned, the channel stream has closed. :param int nbytes: maximum number of bytes to read. - :return: received data, as a `str` - + :return: received data, as a `bytes` + :raises socket.timeout: if no data is ready before the timeout set by `settimeout`. """ @@ -601,11 +631,11 @@ class Channel (object): channel's stderr stream. Only channels using `exec_command` or `invoke_shell` without a pty will ever have data on the stderr stream. - + :return: ``True`` if a `recv_stderr` call on this channel would immediately return at least one byte; ``False`` otherwise. - + .. versionadded:: 1.1 """ return self.in_stderr_buffer.read_ready() @@ -621,17 +651,17 @@ class Channel (object): :param int nbytes: maximum number of bytes to read. :return: received data as a `str` - + :raises socket.timeout: if no data is ready before the timeout set by `settimeout`. - + .. versionadded:: 1.1 """ try: out = self.in_stderr_buffer.read(nbytes, self.timeout) except PipeTimeout: raise socket.timeout() - + ack = self._check_add_window(len(out)) # no need to hold the channel lock when sending this if ack > 0: @@ -647,11 +677,11 @@ class Channel (object): """ Returns true if data can be written to this channel without blocking. This means the channel is either closed (so any write attempt would - return immediately) or there is at least one byte of space in the + return immediately) or there is at least one byte of space in the outbound buffer. If there is at least one byte of space in the outbound buffer, a `send` call will succeed immediately and return the number of bytes actually written. - + :return: ``True`` if a `send` call on this channel would immediately succeed or fail @@ -663,7 +693,7 @@ class Channel (object): return self.out_window_size > 0 finally: self.lock.release() - + def send(self, s): """ Send data to the channel. Returns the number of bytes sent, or 0 if @@ -678,23 +708,11 @@ class Channel (object): :raises socket.timeout: if no data could be sent before the timeout set by `settimeout`. """ - size = len(s) - self.lock.acquire() - try: - size = self._wait_for_send_window(size) - if size == 0: - # eof or similar - return 0 - m = Message() - m.add_byte(cMSG_CHANNEL_DATA) - m.add_int(self.remote_chanid) - m.add_string(s[:size]) - finally: - self.lock.release() - # Note: We release self.lock before calling _send_user_message. - # Otherwise, we can deadlock during re-keying. - self.transport._send_user_message(m) - return size + + m = Message() + m.add_byte(cMSG_CHANNEL_DATA) + m.add_int(self.remote_chanid) + return self._send(s, m) def send_stderr(self, s): """ @@ -704,33 +722,22 @@ class Channel (object): stream is closed. Applications are responsible for checking that all data has been sent: if only some of the data was transmitted, the application needs to attempt delivery of the remaining data. - + :param str s: data to send. :return: number of bytes actually sent, as an `int`. - + :raises socket.timeout: if no data could be sent before the timeout set by `settimeout`. - + .. versionadded:: 1.1 """ - size = len(s) - self.lock.acquire() - try: - size = self._wait_for_send_window(size) - if size == 0: - # eof or similar - return 0 - m = Message() - m.add_byte(cMSG_CHANNEL_EXTENDED_DATA) - m.add_int(self.remote_chanid) - m.add_int(1) - m.add_string(s[:size]) - finally: - self.lock.release() - # Note: We release self.lock before calling _send_user_message. - # Otherwise, we can deadlock during re-keying. - self.transport._send_user_message(m) - return size + + m = Message() + m.add_byte(cMSG_CHANNEL_EXTENDED_DATA) + m.add_int(self.remote_chanid) + m.add_int(1) + return self._send(s, m) + def sendall(self, s): """ @@ -751,9 +758,6 @@ class Channel (object): This is irritating, but identically follows Python's API. """ while s: - if self.closed: - # this doesn't seem useful, but it is the documented behavior of Socket - raise socket.error('Socket is closed') sent = self.send(s) s = s[sent:] return None @@ -764,9 +768,9 @@ class Channel (object): results. Unlike `send_stderr`, this method continues to send data from the given string until all data has been sent or an error occurs. Nothing is returned. - + :param str s: data to send to the client as "stderr" output. - + :raises socket.timeout: if sending stalled for longer than the timeout set by `settimeout`. :raises socket.error: @@ -775,8 +779,6 @@ class Channel (object): .. versionadded:: 1.1 """ while s: - if self.closed: - raise socket.error('Socket is closed') sent = self.send_stderr(s) s = s[sent:] return None @@ -796,18 +798,18 @@ class Channel (object): Return a file-like object associated with this channel's stderr stream. Only channels using `exec_command` or `invoke_shell` without a pty will ever have data on the stderr 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 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. .. versionadded:: 1.1 """ return ChannelStderrFile(*([self] + list(params))) - + def fileno(self): """ Returns an OS-level file descriptor which can be used for polling, but @@ -821,7 +823,7 @@ class Channel (object): open at the same time.) :return: an OS-level file descriptor (`int`) - + .. warning:: This method causes channel reads to be slightly less efficient. """ @@ -860,7 +862,7 @@ class Channel (object): self.lock.release() if m is not None: self.transport._send_user_message(m) - + def shutdown_read(self): """ Shutdown the receiving side of this socket, closing the stream in @@ -868,11 +870,11 @@ class Channel (object): channel will fail instantly. This is a convenience method, equivalent to ``shutdown(0)``, for people who don't make it a habit to memorize unix constants from the 1970s. - + .. versionadded:: 1.2 """ self.shutdown(0) - + def shutdown_write(self): """ Shutdown the sending side of this socket, closing the stream in @@ -880,7 +882,7 @@ class Channel (object): channel will fail instantly. This is a convenience method, equivalent to ``shutdown(1)``, for people who don't make it a habit to memorize unix constants from the 1970s. - + .. versionadded:: 1.2 """ self.shutdown(1) @@ -898,14 +900,15 @@ class Channel (object): self.in_window_threshold = window_size // 10 self.in_window_sofar = 0 self._log(DEBUG, 'Max packet in: %d bytes' % max_packet_size) - + def _set_remote_channel(self, chanid, window_size, max_packet_size): self.remote_chanid = chanid self.out_window_size = window_size - self.out_max_packet_size = max(max_packet_size, MIN_PACKET_SIZE) + self.out_max_packet_size = self.transport. \ + _sanitize_packet_size(max_packet_size) self.active = 1 - self._log(DEBUG, 'Max packet out: %d bytes' % max_packet_size) - + self._log(DEBUG, 'Max packet out: %d bytes' % self.out_max_packet_size) + def _request_success(self, m): self._log(DEBUG, 'Sesch channel %d request ok' % self.chanid) self.event_ready = True @@ -940,7 +943,7 @@ class Channel (object): self._feed(s) else: self.in_stderr_buffer.feed(s) - + def _window_adjust(self, m): nbytes = m.get_int() self.lock.acquire() @@ -989,7 +992,7 @@ class Channel (object): else: ok = server.check_channel_env_request(self, name, value) elif key == 'exec': - cmd = m.get_text() + cmd = m.get_string() if server is None: ok = False else: @@ -1063,6 +1066,25 @@ class Channel (object): ### internals... + def _send(self, s, m): + size = len(s) + self.lock.acquire() + try: + if self.closed: + # this doesn't seem useful, but it is the documented behavior of Socket + raise socket.error('Socket is closed') + size = self._wait_for_send_window(size) + if size == 0: + # eof or similar + return 0 + m.add_string(s[:size]) + finally: + self.lock.release() + # Note: We release self.lock before calling _send_user_message. + # Otherwise, we can deadlock during re-keying. + self.transport._send_user_message(m) + return size + def _log(self, level, msg, *args): self.logger.log(level, "[chan " + self._name + "] " + msg, *args) @@ -1072,7 +1094,7 @@ class Channel (object): def _wait_for_event(self): self.event.wait() - assert self.event.isSet() + assert self.event.is_set() if self.event_ready: return e = self.transport.get_exception() @@ -1182,7 +1204,7 @@ class Channel (object): if self.ultra_debug: self._log(DEBUG, 'window down to %d' % self.out_window_size) return size - + class ChannelFile (BufferedFile): """ @@ -1222,7 +1244,7 @@ class ChannelStderrFile (ChannelFile): def _read(self, size): return self.channel.recv_stderr(size) - + def _write(self, data): self.channel.sendall_stderr(data) return len(data) diff --git a/paramiko/client.py b/paramiko/client.py index c1bf4735..8d899a15 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -25,21 +25,25 @@ import getpass import os import socket import warnings +from errno import ECONNREFUSED, EHOSTUNREACH from paramiko.agent import Agent from paramiko.common import DEBUG from paramiko.config import SSH_PORT from paramiko.dsskey import DSSKey +from paramiko.ecdsakey import ECDSAKey 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 +from paramiko.ssh_exception import ( + SSHException, BadHostKeyException, NoValidConnectionsError +) from paramiko.transport import Transport -from paramiko.util import retry_on_signal +from paramiko.util import retry_on_signal, ClosingContextManager -class SSHClient (object): +class SSHClient (ClosingContextManager): """ A high-level representation of a session with an SSH server. This class wraps `.Transport`, `.Channel`, and `.SFTPClient` to take care of most @@ -54,6 +58,8 @@ class SSHClient (object): checking. The default mechanism is to try to use local key files or an SSH agent (if one is running). + Instances of this class may be used as context managers. + .. versionadded:: 1.6 """ @@ -169,9 +175,46 @@ class SSHClient (object): """ self._policy = policy - def connect(self, hostname, port=SSH_PORT, username=None, password=None, pkey=None, - key_filename=None, timeout=None, allow_agent=True, look_for_keys=True, - compress=False, sock=None): + def _families_and_addresses(self, hostname, port): + """ + Yield pairs of address families and addresses to try for connecting. + + :param str hostname: the server to connect to + :param int port: the server port to connect to + :returns: Yields an iterable of ``(family, address)`` tuples + """ + guess = True + addrinfos = socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + for (family, socktype, proto, canonname, sockaddr) in addrinfos: + if socktype == socket.SOCK_STREAM: + yield family, sockaddr + guess = False + + # some OS like AIX don't indicate SOCK_STREAM support, so just guess. :( + # We only do this if we did not get a single result marked as socktype == SOCK_STREAM. + if guess: + for family, _, _, _, sockaddr in addrinfos: + yield family, sockaddr + + def connect( + self, + hostname, + port=SSH_PORT, + username=None, + password=None, + pkey=None, + key_filename=None, + timeout=None, + allow_agent=True, + look_for_keys=True, + compress=False, + sock=None, + gss_auth=False, + gss_kex=False, + gss_deleg_creds=True, + gss_host=None, + banner_timeout=None + ): """ Connect to an SSH server and authenticate to it. The server's host key is checked against the system host keys (see `load_system_host_keys`) @@ -184,7 +227,8 @@ class SSHClient (object): - The ``pkey`` or ``key_filename`` passed in (if any) - Any key we can find through an SSH agent - - Any "id_rsa" or "id_dsa" key discoverable in ``~/.ssh/`` + - Any "id_rsa", "id_dsa" or "id_ecdsa" key discoverable in + ``~/.ssh/`` - Plain username/password auth, if a password was given If a private key requires a password to unlock it, and a password is @@ -201,8 +245,10 @@ class SSHClient (object): :param str key_filename: the filename, or list of filenames, of optional private key(s) to try for authentication - :param float timeout: an optional timeout (in seconds) for the TCP connect - :param bool allow_agent: set to False to disable connecting to the SSH agent + :param float timeout: + an optional timeout (in seconds) for the TCP connect + :param bool allow_agent: + set to False to disable connecting to the SSH agent :param bool look_for_keys: set to False to disable searching for discoverable private key files in ``~/.ssh/`` @@ -210,6 +256,15 @@ class SSHClient (object): :param socket sock: an open socket or socket-like object (such as a `.Channel`) to use for communication to the target host + :param bool gss_auth: + ``True`` if you want to use GSS-API authentication + :param bool gss_kex: + Perform GSS-API Key Exchange and user authentication + :param bool gss_deleg_creds: Delegate GSS-API client credentials or not + :param str gss_host: + 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. :raises BadHostKeyException: if the server's host key could not be verified @@ -217,28 +272,56 @@ class SSHClient (object): :raises SSHException: if there was any other error connecting or establishing an SSH session :raises socket.error: if a socket error occurred while connecting + + .. versionchanged:: 1.15 + Added the ``banner_timeout``, ``gss_auth``, ``gss_kex``, + ``gss_deleg_creds`` and ``gss_host`` arguments. """ if not sock: - for (family, socktype, proto, canonname, sockaddr) in socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM): - if socktype == socket.SOCK_STREAM: - af = family - addr = sockaddr - break - else: - # some OS like AIX don't indicate SOCK_STREAM support, so just guess. :( - af, _, _, _, addr = socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM) - sock = socket.socket(af, socket.SOCK_STREAM) - if timeout is not None: + errors = {} + # Try multiple possible address families (e.g. IPv4 vs IPv6) + to_try = list(self._families_and_addresses(hostname, port)) + for af, addr in to_try: try: - sock.settimeout(timeout) - except: - pass - retry_on_signal(lambda: sock.connect(addr)) - - t = self._transport = Transport(sock) + sock = socket.socket(af, socket.SOCK_STREAM) + if timeout is not None: + try: + sock.settimeout(timeout) + except: + pass + retry_on_signal(lambda: sock.connect(addr)) + # Break out of the loop on success + break + except socket.error as e: + # Raise anything that isn't a straight up connection error + # (such as a resolution error) + if e.errno not in (ECONNREFUSED, EHOSTUNREACH): + raise + # Capture anything else so we know how the run looks once + # iteration is complete. Retain info about which attempt + # this was. + errors[addr] = e + + # Make sure we explode usefully if no address family attempts + # succeeded. We've no way of knowing which error is the "right" + # one, so we construct a hybrid exception containing all the real + # ones, of a subclass that client code should still be watching for + # (socket.error) + if len(errors) == len(to_try): + raise NoValidConnectionsError(errors) + + t = self._transport = Transport(sock, gss_kex=gss_kex, gss_deleg_creds=gss_deleg_creds) t.use_compression(compress=compress) + if gss_kex and gss_host is None: + t.set_gss_host(hostname) + elif gss_kex and gss_host is not None: + t.set_gss_host(gss_host) + else: + pass if self._log_channel is not None: t.set_log_channel(self._log_channel) + if banner_timeout is not None: + t.banner_timeout = banner_timeout t.start_client() ResourceManager.register(self, t) @@ -249,17 +332,25 @@ class SSHClient (object): server_hostkey_name = hostname else: server_hostkey_name = "[%s]:%d" % (hostname, port) - our_server_key = self._system_host_keys.get(server_hostkey_name, {}).get(keytype, None) - if our_server_key is None: - our_server_key = self._host_keys.get(server_hostkey_name, {}).get(keytype, None) - if our_server_key is None: - # will raise exception if the key is rejected; let that fall out - self._policy.missing_host_key(self, server_hostkey_name, server_key) - # if the callback returns, assume the key is ok - our_server_key = server_key - - if server_key != our_server_key: - raise BadHostKeyException(hostname, server_key, our_server_key) + + # If GSS-API Key Exchange is performed we are not required to check the + # host key, because the host is authenticated via GSS-API / SSPI as + # well as our client. + if not self._transport.use_gss_kex: + our_server_key = self._system_host_keys.get(server_hostkey_name, + {}).get(keytype, None) + if our_server_key is None: + our_server_key = self._host_keys.get(server_hostkey_name, + {}).get(keytype, None) + if our_server_key is None: + # will raise exception if the key is rejected; let that fall out + self._policy.missing_host_key(self, server_hostkey_name, + server_key) + # if the callback returns, assume the key is ok + our_server_key = server_key + + if server_key != our_server_key: + raise BadHostKeyException(hostname, server_key, our_server_key) if username is None: username = getpass.getuser() @@ -270,7 +361,10 @@ class SSHClient (object): key_filenames = [key_filename] else: key_filenames = key_filename - self._auth(username, password, pkey, key_filenames, allow_agent, look_for_keys) + if gss_host is None: + gss_host = hostname + self._auth(username, password, pkey, key_filenames, allow_agent, + look_for_keys, gss_auth, gss_kex, gss_deleg_creds, gss_host) def close(self): """ @@ -304,7 +398,7 @@ class SSHClient (object): :raises SSHException: if the server fails to execute the command """ - chan = self._transport.open_session() + chan = self._transport.open_session(timeout=timeout) if get_pty: chan.get_pty() chan.settimeout(timeout) @@ -354,13 +448,15 @@ class SSHClient (object): """ return self._transport - def _auth(self, username, password, pkey, key_filenames, allow_agent, look_for_keys): + def _auth(self, username, password, pkey, key_filenames, allow_agent, + look_for_keys, gss_auth, gss_kex, gss_deleg_creds, gss_host): """ Try, in order: - The key passed in, if one was passed in. - Any key we can find through an SSH agent (if allowed). - - Any "id_rsa" or "id_dsa" key discoverable in ~/.ssh/ (if allowed). + - Any "id_rsa", "id_dsa" or "id_ecdsa" key discoverable in ~/.ssh/ + (if allowed). - Plain username/password auth, if a password was given. (The password might be needed to unlock a private key, or for @@ -368,13 +464,35 @@ class SSHClient (object): """ saved_exception = None two_factor = False - allowed_types = [] + allowed_types = set() + two_factor_types = set(['keyboard-interactive','password']) + + # If GSS-API support and GSS-PI Key Exchange was performed, we attempt + # authentication with gssapi-keyex. + if gss_kex and self._transport.gss_kex_used: + try: + self._transport.auth_gssapi_keyex(username) + return + except Exception as e: + saved_exception = e + + # Try GSS-API authentication (gssapi-with-mic) only if GSS-API Key + # Exchange is not performed, because if we use GSS-API for the key + # exchange, there is already a fully established GSS-API context, so + # why should we do that again? + if gss_auth: + try: + self._transport.auth_gssapi_with_mic(username, gss_host, + gss_deleg_creds) + return + except Exception as e: + saved_exception = e if pkey is not None: try: self._log(DEBUG, 'Trying SSH key %s' % hexlify(pkey.get_fingerprint())) - allowed_types = self._transport.auth_publickey(username, pkey) - two_factor = (allowed_types == ['password']) + allowed_types = set(self._transport.auth_publickey(username, pkey)) + two_factor = (allowed_types & two_factor_types) if not two_factor: return except SSHException as e: @@ -382,12 +500,12 @@ class SSHClient (object): if not two_factor: for key_filename in key_filenames: - for pkey_class in (RSAKey, DSSKey): + for pkey_class in (RSAKey, DSSKey, ECDSAKey): try: key = pkey_class.from_private_key_file(key_filename, password) self._log(DEBUG, 'Trying key %s from %s' % (hexlify(key.get_fingerprint()), key_filename)) - self._transport.auth_publickey(username, key) - two_factor = (allowed_types == ['password']) + allowed_types = set(self._transport.auth_publickey(username, key)) + two_factor = (allowed_types & two_factor_types) if not two_factor: return break @@ -401,9 +519,9 @@ class SSHClient (object): for key in self._agent.get_keys(): try: self._log(DEBUG, 'Trying SSH agent key %s' % hexlify(key.get_fingerprint())) - # for 2-factor auth a successfully auth'd key will result in ['password'] - allowed_types = self._transport.auth_publickey(username, key) - two_factor = (allowed_types == ['password']) + # for 2-factor auth a successfully auth'd key password will return an allowed 2fac auth method + allowed_types = set(self._transport.auth_publickey(username, key)) + two_factor = (allowed_types & two_factor_types) if not two_factor: return break @@ -414,17 +532,23 @@ class SSHClient (object): 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)) if not look_for_keys: keyfiles = [] @@ -434,8 +558,8 @@ class SSHClient (object): key = pkey_class.from_private_key_file(filename, password) self._log(DEBUG, 'Trying discovered key %s in %s' % (hexlify(key.get_fingerprint()), filename)) # for 2-factor auth a successfully auth'd key will result in ['password'] - allowed_types = self._transport.auth_publickey(username, key) - two_factor = (allowed_types == ['password']) + allowed_types = set(self._transport.auth_publickey(username, key)) + two_factor = (allowed_types & two_factor_types) if not two_factor: return break @@ -449,7 +573,11 @@ class SSHClient (object): except SSHException as e: saved_exception = e elif two_factor: - raise SSHException('Two-factor authentication requires a password') + try: + self._transport.auth_interactive_dumb(username) + return + except SSHException as e: + saved_exception = e # if we got an auth-failed exception earlier, re-raise it if saved_exception is not None: diff --git a/paramiko/common.py b/paramiko/common.py index 9a5e2ee1..0b0cc2a7 100644 --- a/paramiko/common.py +++ b/paramiko/common.py @@ -29,6 +29,9 @@ MSG_USERAUTH_REQUEST, MSG_USERAUTH_FAILURE, MSG_USERAUTH_SUCCESS, \ MSG_USERAUTH_BANNER = range(50, 54) MSG_USERAUTH_PK_OK = 60 MSG_USERAUTH_INFO_REQUEST, MSG_USERAUTH_INFO_RESPONSE = range(60, 62) +MSG_USERAUTH_GSSAPI_RESPONSE, MSG_USERAUTH_GSSAPI_TOKEN = range(60, 62) +MSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE, MSG_USERAUTH_GSSAPI_ERROR,\ +MSG_USERAUTH_GSSAPI_ERRTOK, MSG_USERAUTH_GSSAPI_MIC = range(63, 67) MSG_GLOBAL_REQUEST, MSG_REQUEST_SUCCESS, MSG_REQUEST_FAILURE = range(80, 83) MSG_CHANNEL_OPEN, MSG_CHANNEL_OPEN_SUCCESS, MSG_CHANNEL_OPEN_FAILURE, \ MSG_CHANNEL_WINDOW_ADJUST, MSG_CHANNEL_DATA, MSG_CHANNEL_EXTENDED_DATA, \ @@ -50,6 +53,12 @@ cMSG_USERAUTH_BANNER = byte_chr(MSG_USERAUTH_BANNER) cMSG_USERAUTH_PK_OK = byte_chr(MSG_USERAUTH_PK_OK) cMSG_USERAUTH_INFO_REQUEST = byte_chr(MSG_USERAUTH_INFO_REQUEST) cMSG_USERAUTH_INFO_RESPONSE = byte_chr(MSG_USERAUTH_INFO_RESPONSE) +cMSG_USERAUTH_GSSAPI_RESPONSE = byte_chr(MSG_USERAUTH_GSSAPI_RESPONSE) +cMSG_USERAUTH_GSSAPI_TOKEN = byte_chr(MSG_USERAUTH_GSSAPI_TOKEN) +cMSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE = byte_chr(MSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE) +cMSG_USERAUTH_GSSAPI_ERROR = byte_chr(MSG_USERAUTH_GSSAPI_ERROR) +cMSG_USERAUTH_GSSAPI_ERRTOK = byte_chr(MSG_USERAUTH_GSSAPI_ERRTOK) +cMSG_USERAUTH_GSSAPI_MIC = byte_chr(MSG_USERAUTH_GSSAPI_MIC) cMSG_GLOBAL_REQUEST = byte_chr(MSG_GLOBAL_REQUEST) cMSG_REQUEST_SUCCESS = byte_chr(MSG_REQUEST_SUCCESS) cMSG_REQUEST_FAILURE = byte_chr(MSG_REQUEST_FAILURE) @@ -80,6 +89,8 @@ MSG_NAMES = { 32: 'kex32', 33: 'kex33', 34: 'kex34', + 40: 'kex40', + 41: 'kex41', MSG_USERAUTH_REQUEST: 'userauth-request', MSG_USERAUTH_FAILURE: 'userauth-failure', MSG_USERAUTH_SUCCESS: 'userauth-success', @@ -99,7 +110,13 @@ MSG_NAMES = { MSG_CHANNEL_CLOSE: 'channel-close', MSG_CHANNEL_REQUEST: 'channel-request', MSG_CHANNEL_SUCCESS: 'channel-success', - MSG_CHANNEL_FAILURE: 'channel-failure' + MSG_CHANNEL_FAILURE: 'channel-failure', + MSG_USERAUTH_GSSAPI_RESPONSE: 'userauth-gssapi-response', + MSG_USERAUTH_GSSAPI_TOKEN: 'userauth-gssapi-token', + MSG_USERAUTH_GSSAPI_EXCHANGE_COMPLETE: 'userauth-gssapi-exchange-complete', + MSG_USERAUTH_GSSAPI_ERROR: 'userauth-gssapi-error', + MSG_USERAUTH_GSSAPI_ERRTOK: 'userauth-gssapi-error-token', + MSG_USERAUTH_GSSAPI_MIC: 'userauth-gssapi-mic' } @@ -126,11 +143,6 @@ CONNECTION_FAILED_CODE = { DISCONNECT_SERVICE_NOT_AVAILABLE, DISCONNECT_AUTH_CANCELLED_BY_USER, \ DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE = 7, 13, 14 -from Crypto import Random - -# keep a crypto-strong PRNG nearby -rng = Random.new() - zero_byte = byte_chr(0) one_byte = byte_chr(1) four_byte = byte_chr(4) @@ -176,3 +188,18 @@ CRITICAL = logging.CRITICAL # Common IO/select/etc sleep period, in seconds io_sleep = 0.01 + +DEFAULT_WINDOW_SIZE = 64 * 2 ** 15 +DEFAULT_MAX_PACKET_SIZE = 2 ** 15 + +# lower bound on the max packet size we'll accept from the remote host +# Minimum packet size is 32768 bytes according to +# http://www.ietf.org/rfc/rfc4254.txt +MIN_WINDOW_SIZE = 2 ** 15 + +# However, according to http://www.ietf.org/rfc/rfc4253.txt it is perfectly +# legal to accept a size much smaller, as OpenSSH client does as size 16384. +MIN_PACKET_SIZE = 2 ** 12 + +# Max windows size according to http://www.ietf.org/rfc/rfc4254.txt +MAX_WINDOW_SIZE = 2**32 -1 diff --git a/paramiko/config.py b/paramiko/config.py index c21de936..e18fa4bf 100644 --- a/paramiko/config.py +++ b/paramiko/config.py @@ -24,10 +24,10 @@ Configuration file (aka ``ssh_config``) support. import fnmatch import os import re +import shlex import socket SSH_PORT = 22 -proxy_re = re.compile(r"^(proxycommand)\s*=*\s*(.*)", re.I) class SSHConfig (object): @@ -41,6 +41,8 @@ class SSHConfig (object): .. versionadded:: 1.6 """ + SETTINGS_REGEX = re.compile(r'(\w+)(?:\s*=\s*|\s+)(.+)') + def __init__(self): """ Create a new OpenSSH config object. @@ -51,46 +53,45 @@ class SSHConfig (object): """ Read an OpenSSH config from the given file object. - :param file file_obj: a file-like object to read the config file from + :param file_obj: a file-like object to read the config file from """ host = {"host": ['*'], "config": {}} for line in file_obj: - line = line.rstrip('\r\n').lstrip() + # Strip any leading or trailing whitespace from the line. + # See https://github.com/paramiko/paramiko/issues/499 for more info. + line = line.strip() if not line or line.startswith('#'): continue - if '=' in line: - # Ensure ProxyCommand gets properly split - if line.lower().strip().startswith('proxycommand'): - match = proxy_re.match(line) - key, value = match.group(1).lower(), match.group(2) - else: - key, value = line.split('=', 1) - key = key.strip().lower() - else: - # find first whitespace, and split there - i = 0 - while (i < len(line)) and not line[i].isspace(): - i += 1 - if i == len(line): - raise Exception('Unparsable line: %r' % line) - key = line[:i].lower() - value = line[i:].lstrip() + match = re.match(self.SETTINGS_REGEX, line) + if not match: + raise Exception("Unparsable line %s" % line) + key = match.group(1).lower() + value = match.group(2) + if key == 'host': self._config.append(host) - value = value.split() - host = {key: value, 'config': {}} - #identityfile, localforward, remoteforward keys are special cases, since they are allowed to be - # specified multiple times and they should be tried in order - # of specification. - - elif key in ['identityfile', 'localforward', 'remoteforward']: - if key in host['config']: - host['config'][key].append(value) - else: - host['config'][key] = [value] - elif key not in host['config']: - host['config'][key] = value + host = { + 'host': self._get_hosts(value), + 'config': {} + } + elif key == 'proxycommand' and value.lower() == 'none': + # Proxycommands of none should not be added as an actual value. (Issue #415) + continue + else: + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + + #identityfile, localforward, remoteforward keys are special 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) + else: + host['config'][key] = [value] + elif key not in host['config']: + host['config'][key] = value self._config.append(host) def lookup(self, hostname): @@ -99,7 +100,7 @@ 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'' + 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. @@ -132,6 +133,16 @@ class SSHConfig (object): ret = self._expand_variables(ret, hostname) return ret + def get_hostnames(self): + """ + Return the set of literal hostnames defined in the SSH config (both + explicit hostnames and wildcard entries). + """ + hosts = set() + for entry in self._config: + hosts.update(entry['host']) + return hosts + def _allowed(self, hosts, hostname): match = False for host in hosts: @@ -212,6 +223,15 @@ class SSHConfig (object): config[k] = config[k].replace(find, str(replace)) return config + def _get_hosts(self, host): + """ + Return a list of host_names from host value. + """ + try: + return shlex.split(host) + except ValueError: + raise Exception("Unparsable host %s" % host) + class LazyFqdn(object): """ diff --git a/paramiko/dsskey.py b/paramiko/dsskey.py index 6a46d326..d7dd6275 100644 --- a/paramiko/dsskey.py +++ b/paramiko/dsskey.py @@ -20,11 +20,13 @@ DSS keys. """ +import os +from hashlib import sha1 + from Crypto.PublicKey import DSA -from Crypto.Hash import SHA from paramiko import util -from paramiko.common import zero_byte, rng +from paramiko.common import zero_byte from paramiko.py3compat import long from paramiko.ssh_exception import SSHException from paramiko.message import Message @@ -91,17 +93,17 @@ class DSSKey (PKey): def get_bits(self): return self.size - + def can_sign(self): return self.x is not None - def sign_ssh_data(self, rng, data): - digest = SHA.new(data).digest() + def sign_ssh_data(self, data): + digest = sha1(data).digest() dss = DSA.construct((long(self.y), long(self.g), long(self.p), long(self.q), long(self.x))) # generate a suitable k qsize = len(util.deflate_long(self.q, 0)) while True: - k = util.inflate_long(rng.read(qsize), 1) + k = util.inflate_long(os.urandom(qsize), 1) if (k > 2) and (k < self.q): break r, s = dss.sign(util.inflate_long(digest, 1), k) @@ -130,7 +132,7 @@ class DSSKey (PKey): # pull out (r, s) which are NOT encoded as mpints sigR = util.inflate_long(sig[:20], 1) sigS = util.inflate_long(sig[20:], 1) - sigM = util.inflate_long(SHA.new(data).digest(), 1) + sigM = util.inflate_long(sha1(data).digest(), 1) dss = DSA.construct((long(self.y), long(self.g), long(self.p), long(self.q))) return dss.verify(sigM, (sigR, sigS)) @@ -152,6 +154,7 @@ class DSSKey (PKey): def write_private_key(self, file_obj, password=None): self._write_private_key('DSA', file_obj, self._encode_key(), password) + @staticmethod def generate(bits=1024, progress_func=None): """ Generate a new private DSS key. This factory function can be used to @@ -163,22 +166,21 @@ class DSSKey (PKey): by ``pyCrypto.PublicKey``). :return: new `.DSSKey` private key """ - dsa = DSA.generate(bits, rng.read, progress_func) + dsa = DSA.generate(bits, os.urandom, progress_func) key = DSSKey(vals=(dsa.p, dsa.q, dsa.g, dsa.y)) key.x = dsa.x return key - generate = staticmethod(generate) ### internals... def _from_private_key_file(self, filename, password): data = self._read_private_key_file('DSA', filename, password) self._decode_key(data) - + def _from_private_key(self, file_obj, password): data = self._read_private_key('DSA', file_obj, password) self._decode_key(data) - + def _decode_key(self, data): # private key file contains: # DSAPrivateKey = { version = 0, p, q, g, y, x } diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py index 7376b81f..8827a1db 100644 --- a/paramiko/ecdsakey.py +++ b/paramiko/ecdsakey.py @@ -21,11 +21,11 @@ ECDSA keys """ import binascii +from hashlib import sha256 + from ecdsa import SigningKey, VerifyingKey, der, curves -from Crypto.Hash import SHA256 -from ecdsa.test_pyecdsa import ECDSA -from paramiko.common import four_byte, one_byte +from paramiko.common import four_byte, one_byte from paramiko.message import Message from paramiko.pkey import PKey from paramiko.py3compat import byte_chr, u @@ -38,7 +38,8 @@ class ECDSAKey (PKey): data. """ - def __init__(self, msg=None, data=None, filename=None, password=None, vals=None, file_obj=None): + def __init__(self, msg=None, data=None, filename=None, password=None, + vals=None, file_obj=None, validate_point=True): self.verifying_key = None self.signing_key = None if file_obj is not None: @@ -50,7 +51,7 @@ class ECDSAKey (PKey): if (msg is None) and (data is not None): msg = Message(data) if vals is not None: - self.verifying_key, self.signing_key = vals + self.signing_key, self.verifying_key = vals else: if msg is None: raise SSHException('Key object may not be empty') @@ -65,7 +66,8 @@ class ECDSAKey (PKey): raise SSHException('Point compression is being used: %s' % binascii.hexlify(pointinfo)) self.verifying_key = VerifyingKey.from_string(pointinfo[1:], - curve=curves.NIST256p) + curve=curves.NIST256p, + validate_point=validate_point) self.size = 256 def asbytes(self): @@ -97,10 +99,9 @@ class ECDSAKey (PKey): def can_sign(self): return self.signing_key is not None - def sign_ssh_data(self, rpool, data): - digest = SHA256.new(data).digest() - sig = self.signing_key.sign_digest(digest, entropy=rpool.read, - sigencode=self._sigencode) + def sign_ssh_data(self, data): + sig = self.signing_key.sign_deterministic( + data, sigencode=self._sigencode, hashfunc=sha256) m = Message() m.add_string('ecdsa-sha2-nistp256') m.add_string(sig) @@ -113,7 +114,7 @@ class ECDSAKey (PKey): # verify the signature by SHA'ing the data and encrypting it # using the public key. - hash_obj = SHA256.new(data).digest() + hash_obj = sha256(data).digest() return self.verifying_key.verify_digest(sig, hash_obj, sigdecode=self._sigdecode) @@ -125,20 +126,18 @@ class ECDSAKey (PKey): key = self.signing_key or self.verifying_key self._write_private_key('EC', file_obj, key.to_der(), password) - def generate(bits, progress_func=None): + @staticmethod + def generate(curve=curves.NIST256p, progress_func=None): """ - Generate a new private RSA key. This factory function can be used to + Generate a new private ECDSA key. This factory function can be used to generate a new host key or authentication key. - :param function progress_func: - an optional function to call at key points in key generation (used - by ``pyCrypto.PublicKey``). - :returns: A new private key (`.RSAKey`) object + :param function progress_func: Not used for this type of key. + :returns: A new private key (`.ECDSAKey`) object """ - signing_key = ECDSA.generate() + signing_key = SigningKey.generate(curve) key = ECDSAKey(vals=(signing_key, signing_key.get_verifying_key())) return key - generate = staticmethod(generate) ### internals... diff --git a/paramiko/file.py b/paramiko/file.py index 1c99abeb..e3b0a16a 100644 --- a/paramiko/file.py +++ b/paramiko/file.py @@ -19,8 +19,10 @@ 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.util import ClosingContextManager -class BufferedFile (object): + +class BufferedFile (ClosingContextManager): """ Reusable base class to implement Python-style file buffering around a simpler stream. diff --git a/paramiko/hostkeys.py b/paramiko/hostkeys.py index 7c6a5aa5..38ac866b 100644 --- a/paramiko/hostkeys.py +++ b/paramiko/hostkeys.py @@ -18,8 +18,11 @@ import binascii -from Crypto.Hash import SHA, HMAC -from paramiko.common import rng +import os + +from hashlib import sha1 +from hmac import HMAC + from paramiko.py3compat import b, u, encodebytes, decodebytes try: @@ -32,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.ssh_exception import SSHException class HostKeys (MutableMapping): @@ -89,11 +93,14 @@ class HostKeys (MutableMapping): :raises IOError: if there was an error reading the file """ with open(filename, 'r') as f: - for lineno, line in enumerate(f): + for lineno, line in enumerate(f, 1): line = line.strip() if (len(line) == 0) or (line[0] == '#'): continue - e = HostKeyEntry.from_line(line, lineno) + try: + e = HostKeyEntry.from_line(line, lineno) + except SSHException: + continue if e is not None: _hostnames = e.hostnames for h in _hostnames: @@ -252,6 +259,7 @@ class HostKeys (MutableMapping): ret.append(self.lookup(k)) return ret + @staticmethod def hash_host(hostname, salt=None): """ Return a "hashed" form of the hostname, as used by OpenSSH when storing @@ -262,16 +270,15 @@ class HostKeys (MutableMapping): :return: the hashed hostname as a `str` """ if salt is None: - salt = rng.read(SHA.digest_size) + salt = os.urandom(sha1().digest_size) else: if salt.startswith('|1|'): salt = salt.split('|')[2] salt = decodebytes(b(salt)) - assert len(salt) == SHA.digest_size - hmac = HMAC.HMAC(salt, b(hostname), SHA).digest() + assert len(salt) == sha1().digest_size + hmac = HMAC(salt, b(hostname), sha1).digest() hostkey = '|1|%s|%s' % (u(encodebytes(salt)), u(encodebytes(hmac))) return hostkey.replace('\n', '') - hash_host = staticmethod(hash_host) class InvalidHostKey(Exception): @@ -291,6 +298,7 @@ class HostKeyEntry: self.hostnames = hostnames self.key = key + @classmethod def from_line(cls, line, lineno=None): """ Parses the given line of text to find the names for the host, @@ -324,7 +332,7 @@ class HostKeyEntry: elif keytype == 'ssh-dss': key = DSSKey(data=decodebytes(key)) elif keytype == 'ecdsa-sha2-nistp256': - key = ECDSAKey(data=decodebytes(key)) + key = ECDSAKey(data=decodebytes(key), validate_point=False) else: log.info("Unable to handle key of type %s" % (keytype,)) return None @@ -333,7 +341,6 @@ class HostKeyEntry: raise InvalidHostKey(line, e) return cls(names, key) - from_line = classmethod(from_line) def to_line(self): """ diff --git a/paramiko/kex_gex.py b/paramiko/kex_gex.py index a7cfb7c9..c980b690 100644 --- a/paramiko/kex_gex.py +++ b/paramiko/kex_gex.py @@ -22,7 +22,8 @@ generator "g" are provided by the server. A bit more work is required on the client side, and a **lot** more on the server side. """ -from Crypto.Hash import SHA +import os +from hashlib import sha1, sha256 from paramiko import util from paramiko.common import DEBUG @@ -43,6 +44,7 @@ class KexGex (object): min_bits = 1024 max_bits = 8192 preferred_bits = 2048 + hash_algo = sha1 def __init__(self, transport): self.transport = transport @@ -86,7 +88,7 @@ class KexGex (object): return self._parse_kexdh_gex_reply(m) elif ptype == _MSG_KEXDH_GEX_REQUEST_OLD: return self._parse_kexdh_gex_request_old(m) - raise SSHException('KexGex asked to handle packet type %d' % ptype) + raise SSHException('KexGex %s asked to handle packet type %d' % self.name, ptype) ### internals... @@ -101,7 +103,7 @@ class KexGex (object): qhbyte <<= 1 qmask >>= 1 while True: - x_bytes = self.transport.rng.read(byte_count) + x_bytes = os.urandom(byte_count) x_bytes = byte_mask(x_bytes[0], qmask) + x_bytes[1:] x = util.inflate_long(x_bytes, 1) if (x > 1) and (x < q): @@ -203,10 +205,10 @@ class KexGex (object): hm.add_mpint(self.e) hm.add_mpint(self.f) hm.add_mpint(K) - H = SHA.new(hm.asbytes()).digest() + H = self.hash_algo(hm.asbytes()).digest() self.transport._set_K_H(K, H) # sign it - sig = self.transport.get_server_key().sign_ssh_data(self.transport.rng, H) + sig = self.transport.get_server_key().sign_ssh_data(H) # send reply m = Message() m.add_byte(c_MSG_KEXDH_GEX_REPLY) @@ -215,7 +217,7 @@ class KexGex (object): m.add_string(sig) self.transport._send_message(m) self.transport._activate_outbound() - + def _parse_kexdh_gex_reply(self, m): host_key = m.get_string() self.f = m.get_mpint() @@ -238,6 +240,10 @@ class KexGex (object): hm.add_mpint(self.e) hm.add_mpint(self.f) hm.add_mpint(K) - self.transport._set_K_H(K, SHA.new(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() + +class KexGexSHA256(KexGex): + name = 'diffie-hellman-group-exchange-sha256' + hash_algo = sha256 diff --git a/paramiko/kex_group1.py b/paramiko/kex_group1.py index 3dfb7f18..9eee066c 100644 --- a/paramiko/kex_group1.py +++ b/paramiko/kex_group1.py @@ -21,7 +21,8 @@ Standard SSH key exchange ("kex" if you wanna sound cool). Diffie-Hellman of 1024 bit key halves, using a known "p" prime and "g" generator. """ -from Crypto.Hash import SHA +import os +from hashlib import sha1 from paramiko import util from paramiko.common import max_byte, zero_byte @@ -33,17 +34,18 @@ from paramiko.ssh_exception import SSHException _MSG_KEXDH_INIT, _MSG_KEXDH_REPLY = range(30, 32) c_MSG_KEXDH_INIT, c_MSG_KEXDH_REPLY = [byte_chr(c) for c in range(30, 32)] -# draft-ietf-secsh-transport-09.txt, page 17 -P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF -G = 2 - b7fffffffffffffff = byte_chr(0x7f) + max_byte * 7 b0000000000000000 = zero_byte * 8 class KexGroup1(object): + # draft-ietf-secsh-transport-09.txt, page 17 + P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF + G = 2 + name = 'diffie-hellman-group1-sha1' + hash_algo = sha1 def __init__(self, transport): self.transport = transport @@ -55,11 +57,11 @@ class KexGroup1(object): self._generate_x() if self.transport.server_mode: # compute f = g^x mod p, but don't send it yet - self.f = pow(G, self.x, P) + self.f = pow(self.G, self.x, self.P) self.transport._expect_packet(_MSG_KEXDH_INIT) return # compute e = g^x mod p (where g=2), and send it - self.e = pow(G, self.x, P) + self.e = pow(self.G, self.x, self.P) m = Message() m.add_byte(c_MSG_KEXDH_INIT) m.add_mpint(self.e) @@ -82,7 +84,7 @@ class KexGroup1(object): # potential x where the first 63 bits are 1, because some of those will be # larger than q (but this is a tiny tiny subset of potential x). while 1: - x_bytes = self.transport.rng.read(128) + x_bytes = os.urandom(128) x_bytes = byte_mask(x_bytes[0], 0x7f) + x_bytes[1:] if (x_bytes[:8] != b7fffffffffffffff and x_bytes[:8] != b0000000000000000): @@ -93,10 +95,10 @@ class KexGroup1(object): # client mode host_key = m.get_string() self.f = m.get_mpint() - if (self.f < 1) or (self.f > P - 1): + if (self.f < 1) or (self.f > self.P - 1): raise SSHException('Server kex "f" is out of range') sig = m.get_binary() - K = pow(self.f, self.x, P) + K = pow(self.f, self.x, self.P) # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || e || f || K) hm = Message() hm.add(self.transport.local_version, self.transport.remote_version, @@ -105,16 +107,16 @@ class KexGroup1(object): hm.add_mpint(self.e) hm.add_mpint(self.f) hm.add_mpint(K) - self.transport._set_K_H(K, SHA.new(hm.asbytes()).digest()) + self.transport._set_K_H(K, sha1(hm.asbytes()).digest()) self.transport._verify_key(host_key, sig) self.transport._activate_outbound() def _parse_kexdh_init(self, m): # server mode self.e = m.get_mpint() - if (self.e < 1) or (self.e > P - 1): + if (self.e < 1) or (self.e > self.P - 1): raise SSHException('Client kex "e" is out of range') - K = pow(self.e, self.x, P) + K = pow(self.e, self.x, self.P) key = self.transport.get_server_key().asbytes() # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || e || f || K) hm = Message() @@ -124,10 +126,10 @@ class KexGroup1(object): hm.add_mpint(self.e) hm.add_mpint(self.f) hm.add_mpint(K) - H = SHA.new(hm.asbytes()).digest() + H = sha1(hm.asbytes()).digest() self.transport._set_K_H(K, H) # sign it - sig = self.transport.get_server_key().sign_ssh_data(self.transport.rng, H) + sig = self.transport.get_server_key().sign_ssh_data(H) # send reply m = Message() m.add_byte(c_MSG_KEXDH_REPLY) diff --git a/paramiko/kex_group14.py b/paramiko/kex_group14.py new file mode 100644 index 00000000..9f7dd216 --- /dev/null +++ b/paramiko/kex_group14.py @@ -0,0 +1,35 @@ +# Copyright (C) 2013 Torsten Landschoff <torsten@debian.org> +# +# 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 +2048 bit key halves, using a known "p" prime and "g" generator. +""" + +from paramiko.kex_group1 import KexGroup1 +from hashlib import sha1 + + +class KexGroup14(KexGroup1): + + # http://tools.ietf.org/html/rfc3526#section-3 + P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF + G = 2 + + name = 'diffie-hellman-group14-sha1' + hash_algo = sha1 diff --git a/paramiko/kex_gss.py b/paramiko/kex_gss.py new file mode 100644 index 00000000..69969f8a --- /dev/null +++ b/paramiko/kex_gss.py @@ -0,0 +1,607 @@ +# Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com> +# Copyright (C) 2013-2014 science + computing ag +# Author: Sebastian Deiss <sebastian.deiss@t-online.de> +# +# +# 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. + + +""" +This module provides GSS-API / SSPI Key Exchange as defined in :rfc:`4462`. + +.. note:: Credential delegation is not supported in server mode. + +.. note:: + `RFC 4462 Section 2.2 + <https://tools.ietf.org/html/rfc4462.html#section-2.2>`_ says we are not + required to implement GSS-API error messages. Thus, in many methods within + this module, if an error occurs an exception will be thrown and the + connection will be terminated. + +.. seealso:: :doc:`/api/ssh_gss` + +.. versionadded:: 1.15 +""" + +import os +from hashlib import sha1 + +from paramiko.common import * +from paramiko import util +from paramiko.message import Message +from paramiko.py3compat import byte_chr, long, byte_mask, byte_ord +from paramiko.ssh_exception import SSHException + + +MSG_KEXGSS_INIT, MSG_KEXGSS_CONTINUE, MSG_KEXGSS_COMPLETE, MSG_KEXGSS_HOSTKEY,\ +MSG_KEXGSS_ERROR = range(30, 35) +MSG_KEXGSS_GROUPREQ, MSG_KEXGSS_GROUP = range(40, 42) +c_MSG_KEXGSS_INIT, c_MSG_KEXGSS_CONTINUE, c_MSG_KEXGSS_COMPLETE,\ +c_MSG_KEXGSS_HOSTKEY, c_MSG_KEXGSS_ERROR = [byte_chr(c) for c in range(30, 35)] +c_MSG_KEXGSS_GROUPREQ, c_MSG_KEXGSS_GROUP = [byte_chr(c) for c in range(40, 42)] + + +class KexGSSGroup1(object): + """ + GSS-API / SSPI Authenticated Diffie-Hellman Key Exchange as defined in `RFC + 4462 Section 2 <https://tools.ietf.org/html/rfc4462.html#section-2>`_ + """ + # draft-ietf-secsh-transport-09.txt, page 17 + P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF + G = 2 + b7fffffffffffffff = byte_chr(0x7f) + max_byte * 7 + b0000000000000000 = zero_byte * 8 + NAME = "gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==" + + def __init__(self, transport): + self.transport = transport + self.kexgss = self.transport.kexgss_ctxt + self.gss_host = None + self.x = 0 + self.e = 0 + self.f = 0 + + def start_kex(self): + """ + Start the GSS-API / SSPI Authenticated Diffie-Hellman Key Exchange. + """ + self.transport.gss_kex_used = True + self._generate_x() + if self.transport.server_mode: + # compute f = g^x mod p, but don't send it yet + self.f = pow(self.G, self.x, self.P) + self.transport._expect_packet(MSG_KEXGSS_INIT) + return + # compute e = g^x mod p (where g=2), and send it + self.e = pow(self.G, self.x, self.P) + # Initialize GSS-API Key Exchange + self.gss_host = self.transport.gss_host + m = Message() + m.add_byte(c_MSG_KEXGSS_INIT) + m.add_string(self.kexgss.ssh_init_sec_context(target=self.gss_host)) + m.add_mpint(self.e) + self.transport._send_message(m) + self.transport._expect_packet(MSG_KEXGSS_HOSTKEY, + MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE, + MSG_KEXGSS_ERROR) + + def parse_next(self, ptype, m): + """ + Parse the next packet. + + :param char ptype: The type of the incomming packet + :param `.Message` m: The paket content + """ + if self.transport.server_mode and (ptype == MSG_KEXGSS_INIT): + return self._parse_kexgss_init(m) + elif not self.transport.server_mode and (ptype == MSG_KEXGSS_HOSTKEY): + return self._parse_kexgss_hostkey(m) + elif self.transport.server_mode and (ptype == MSG_KEXGSS_CONTINUE): + return self._parse_kexgss_continue(m) + elif not self.transport.server_mode and (ptype == MSG_KEXGSS_COMPLETE): + return self._parse_kexgss_complete(m) + elif ptype == MSG_KEXGSS_ERROR: + return self._parse_kexgss_error(m) + raise SSHException('GSS KexGroup1 asked to handle packet type %d' + % ptype) + + # ## internals... + + def _generate_x(self): + """ + generate an "x" (1 < x < q), where q is (p-1)/2. + p is a 128-byte (1024-bit) number, where the first 64 bits are 1. + therefore q can be approximated as a 2^1023. we drop the subset of + potential x where the first 63 bits are 1, because some of those will be + larger than q (but this is a tiny tiny subset of potential x). + """ + while 1: + x_bytes = os.urandom(128) + x_bytes = byte_mask(x_bytes[0], 0x7f) + x_bytes[1:] + if (x_bytes[:8] != self.b7fffffffffffffff) and \ + (x_bytes[:8] != self.b0000000000000000): + break + self.x = util.inflate_long(x_bytes) + + def _parse_kexgss_hostkey(self, m): + """ + Parse the SSH2_MSG_KEXGSS_HOSTKEY message (client mode). + + :param `.Message` m: The content of the SSH2_MSG_KEXGSS_HOSTKEY message + """ + # client mode + host_key = m.get_string() + self.transport.host_key = host_key + sig = m.get_string() + self.transport._verify_key(host_key, sig) + self.transport._expect_packet(MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE) + + def _parse_kexgss_continue(self, m): + """ + Parse the SSH2_MSG_KEXGSS_CONTINUE message. + + :param `.Message` m: The content of the SSH2_MSG_KEXGSS_CONTINUE message + """ + if not self.transport.server_mode: + srv_token = m.get_string() + m = Message() + m.add_byte(c_MSG_KEXGSS_CONTINUE) + m.add_string(self.kexgss.ssh_init_sec_context(target=self.gss_host, + recv_token=srv_token)) + self.transport.send_message(m) + self.transport._expect_packet(MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE, + MSG_KEXGSS_ERROR) + else: + pass + + def _parse_kexgss_complete(self, m): + """ + Parse the SSH2_MSG_KEXGSS_COMPLETE message (client mode). + + :param `.Message` m: The content of the SSH2_MSG_KEXGSS_COMPLETE message + """ + # client mode + if self.transport.host_key is None: + self.transport.host_key = NullHostKey() + self.f = m.get_mpint() + if (self.f < 1) or (self.f > self.P - 1): + raise SSHException('Server kex "f" is out of range') + mic_token = m.get_string() + # This must be TRUE, if there is a GSS-API token in this message. + bool = m.get_boolean() + srv_token = None + if bool: + srv_token = m.get_string() + K = pow(self.f, self.x, self.P) + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || e || f || K) + 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(self.transport.host_key.__str__()) + hm.add_mpint(self.e) + hm.add_mpint(self.f) + hm.add_mpint(K) + self.transport._set_K_H(K, sha1(str(hm)).digest()) + if srv_token is not None: + self.kexgss.ssh_init_sec_context(target=self.gss_host, + recv_token=srv_token) + self.kexgss.ssh_check_mic(mic_token, + self.transport.session_id) + else: + self.kexgss.ssh_check_mic(mic_token, + self.transport.session_id) + self.transport._activate_outbound() + + def _parse_kexgss_init(self, m): + """ + Parse the SSH2_MSG_KEXGSS_INIT message (server mode). + + :param `.Message` m: The content of the SSH2_MSG_KEXGSS_INIT message + """ + # server mode + client_token = m.get_string() + self.e = m.get_mpint() + if (self.e < 1) or (self.e > self.P - 1): + raise SSHException('Client kex "e" is out of range') + K = pow(self.e, self.x, self.P) + self.transport.host_key = NullHostKey() + key = self.transport.host_key.__str__() + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || e || f || K) + 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(key) + hm.add_mpint(self.e) + hm.add_mpint(self.f) + hm.add_mpint(K) + H = sha1(hm.asbytes()).digest() + self.transport._set_K_H(K, H) + srv_token = self.kexgss.ssh_accept_sec_context(self.gss_host, + client_token) + m = Message() + if self.kexgss._gss_srv_ctxt_status: + mic_token = self.kexgss.ssh_get_mic(self.transport.session_id, + gss_kex=True) + m.add_byte(c_MSG_KEXGSS_COMPLETE) + m.add_mpint(self.f) + m.add_string(mic_token) + if srv_token is not None: + m.add_boolean(True) + m.add_string(srv_token) + else: + m.add_boolean(False) + self.transport._send_message(m) + self.transport._activate_outbound() + else: + m.add_byte(c_MSG_KEXGSS_CONTINUE) + m.add_string(srv_token) + self.transport._send_message(m) + self.transport._expect_packet(MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE, + MSG_KEXGSS_ERROR) + + def _parse_kexgss_error(self, m): + """ + Parse the SSH2_MSG_KEXGSS_ERROR message (client mode). + The server may send a GSS-API error message. if it does, we display + the error by throwing an exception (client mode). + + :param `.Message` m: The content of the SSH2_MSG_KEXGSS_ERROR message + :raise SSHException: Contains GSS-API major and minor status as well as + the error message and the language tag of the + message + """ + maj_status = m.get_int() + min_status = m.get_int() + err_msg = m.get_string() + lang_tag = m.get_string() # we don't care about the language! + raise SSHException("GSS-API Error:\nMajor Status: %s\nMinor Status: %s\ + \nError Message: %s\n") % (str(maj_status), + str(min_status), + err_msg) + + +class KexGSSGroup14(KexGSSGroup1): + """ + GSS-API / SSPI Authenticated Diffie-Hellman Group14 Key Exchange as defined + in `RFC 4462 Section 2 + <https://tools.ietf.org/html/rfc4462.html#section-2>`_ + """ + P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF + G = 2 + NAME = "gss-group14-sha1-toWM5Slw5Ew8Mqkay+al2g==" + + +class KexGSSGex(object): + """ + GSS-API / SSPI Authenticated Diffie-Hellman Group Exchange as defined in + `RFC 4462 Section 2 <https://tools.ietf.org/html/rfc4462.html#section-2>`_ + """ + NAME = "gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==" + min_bits = 1024 + max_bits = 8192 + preferred_bits = 2048 + + def __init__(self, transport): + self.transport = transport + self.kexgss = self.transport.kexgss_ctxt + self.gss_host = None + self.p = None + self.q = None + self.g = None + self.x = None + self.e = None + self.f = None + self.old_style = False + + def start_kex(self): + """ + Start the GSS-API / SSPI Authenticated Diffie-Hellman Group Exchange + """ + self.transport.gss_kex_used = True + if self.transport.server_mode: + self.transport._expect_packet(MSG_KEXGSS_GROUPREQ) + return + # request a bit range: we accept (min_bits) to (max_bits), but prefer + # (preferred_bits). according to the spec, we shouldn't pull the + # minimum up above 1024. + self.gss_host = self.transport.gss_host + m = Message() + m.add_byte(c_MSG_KEXGSS_GROUPREQ) + m.add_int(self.min_bits) + m.add_int(self.preferred_bits) + m.add_int(self.max_bits) + self.transport._send_message(m) + self.transport._expect_packet(MSG_KEXGSS_GROUP) + + def parse_next(self, ptype, m): + """ + Parse the next packet. + + :param char ptype: The type of the incomming packet + :param `.Message` m: The paket content + """ + if ptype == MSG_KEXGSS_GROUPREQ: + return self._parse_kexgss_groupreq(m) + elif ptype == MSG_KEXGSS_GROUP: + return self._parse_kexgss_group(m) + elif ptype == MSG_KEXGSS_INIT: + return self._parse_kexgss_gex_init(m) + elif ptype == MSG_KEXGSS_HOSTKEY: + return self._parse_kexgss_hostkey(m) + elif ptype == MSG_KEXGSS_CONTINUE: + return self._parse_kexgss_continue(m) + elif ptype == MSG_KEXGSS_COMPLETE: + return self._parse_kexgss_complete(m) + elif ptype == MSG_KEXGSS_ERROR: + return self._parse_kexgss_error(m) + raise SSHException('KexGex asked to handle packet type %d' % ptype) + + # ## internals... + + def _generate_x(self): + # generate an "x" (1 < x < (p-1)/2). + q = (self.p - 1) // 2 + qnorm = util.deflate_long(q, 0) + qhbyte = byte_ord(qnorm[0]) + byte_count = len(qnorm) + qmask = 0xff + while not (qhbyte & 0x80): + qhbyte <<= 1 + qmask >>= 1 + while True: + x_bytes = os.urandom(byte_count) + x_bytes = byte_mask(x_bytes[0], qmask) + x_bytes[1:] + x = util.inflate_long(x_bytes, 1) + if (x > 1) and (x < q): + break + self.x = x + + def _parse_kexgss_groupreq(self, m): + """ + Parse the SSH2_MSG_KEXGSS_GROUPREQ message (server mode). + + :param `.Message` m: The content of the SSH2_MSG_KEXGSS_GROUPREQ message + """ + minbits = m.get_int() + preferredbits = m.get_int() + maxbits = m.get_int() + # smoosh the user's preferred size into our own limits + if preferredbits > self.max_bits: + preferredbits = self.max_bits + if preferredbits < self.min_bits: + preferredbits = self.min_bits + # fix min/max if they're inconsistent. technically, we could just pout + # and hang up, but there's no harm in giving them the benefit of the + # doubt and just picking a bitsize for them. + if minbits > preferredbits: + minbits = preferredbits + if maxbits < preferredbits: + maxbits = preferredbits + # now save a copy + self.min_bits = minbits + self.preferred_bits = preferredbits + self.max_bits = maxbits + # generate prime + pack = self.transport._get_modulus_pack() + if pack is None: + raise SSHException('Can\'t do server-side gex with no modulus pack') + self.transport._log(DEBUG, 'Picking p (%d <= %d <= %d bits)' % (minbits, preferredbits, maxbits)) + self.g, self.p = pack.get_modulus(minbits, preferredbits, maxbits) + m = Message() + m.add_byte(c_MSG_KEXGSS_GROUP) + m.add_mpint(self.p) + m.add_mpint(self.g) + self.transport._send_message(m) + self.transport._expect_packet(MSG_KEXGSS_INIT) + + def _parse_kexgss_group(self, m): + """ + Parse the SSH2_MSG_KEXGSS_GROUP message (client mode). + + :param `Message` m: The content of the SSH2_MSG_KEXGSS_GROUP message + """ + self.p = m.get_mpint() + self.g = m.get_mpint() + # reject if p's bit length < 1024 or > 8192 + bitlen = util.bit_length(self.p) + if (bitlen < 1024) or (bitlen > 8192): + raise SSHException('Server-generated gex p (don\'t ask) is out of range (%d bits)' % bitlen) + self.transport._log(DEBUG, 'Got server p (%d bits)' % bitlen) + self._generate_x() + # now compute e = g^x mod p + self.e = pow(self.g, self.x, self.p) + m = Message() + m.add_byte(c_MSG_KEXGSS_INIT) + m.add_string(self.kexgss.ssh_init_sec_context(target=self.gss_host)) + m.add_mpint(self.e) + self.transport._send_message(m) + self.transport._expect_packet(MSG_KEXGSS_HOSTKEY, + MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE, + MSG_KEXGSS_ERROR) + + def _parse_kexgss_gex_init(self, m): + """ + Parse the SSH2_MSG_KEXGSS_INIT message (server mode). + + :param `Message` m: The content of the SSH2_MSG_KEXGSS_INIT message + """ + client_token = m.get_string() + self.e = m.get_mpint() + if (self.e < 1) or (self.e > self.p - 1): + raise SSHException('Client kex "e" is out of range') + self._generate_x() + self.f = pow(self.g, self.x, self.p) + K = pow(self.e, self.x, self.p) + self.transport.host_key = NullHostKey() + key = self.transport.host_key.__str__() + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || min || n || max || p || g || e || f || K) + hm = Message() + hm.add(self.transport.remote_version, self.transport.local_version, + self.transport.remote_kex_init, self.transport.local_kex_init, + key) + hm.add_int(self.min_bits) + hm.add_int(self.preferred_bits) + hm.add_int(self.max_bits) + hm.add_mpint(self.p) + hm.add_mpint(self.g) + hm.add_mpint(self.e) + hm.add_mpint(self.f) + hm.add_mpint(K) + H = sha1(hm.asbytes()).digest() + self.transport._set_K_H(K, H) + srv_token = self.kexgss.ssh_accept_sec_context(self.gss_host, + client_token) + m = Message() + if self.kexgss._gss_srv_ctxt_status: + mic_token = self.kexgss.ssh_get_mic(self.transport.session_id, + gss_kex=True) + m.add_byte(c_MSG_KEXGSS_COMPLETE) + m.add_mpint(self.f) + m.add_string(mic_token) + if srv_token is not None: + m.add_boolean(True) + m.add_string(srv_token) + else: + m.add_boolean(False) + self.transport._send_message(m) + self.transport._activate_outbound() + else: + m.add_byte(c_MSG_KEXGSS_CONTINUE) + m.add_string(srv_token) + self.transport._send_message(m) + self.transport._expect_packet(MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE, + MSG_KEXGSS_ERROR) + + def _parse_kexgss_hostkey(self, m): + """ + Parse the SSH2_MSG_KEXGSS_HOSTKEY message (client mode). + + :param `Message` m: The content of the SSH2_MSG_KEXGSS_HOSTKEY message + """ + # client mode + host_key = m.get_string() + self.transport.host_key = host_key + sig = m.get_string() + self.transport._verify_key(host_key, sig) + self.transport._expect_packet(MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE) + + def _parse_kexgss_continue(self, m): + """ + Parse the SSH2_MSG_KEXGSS_CONTINUE message. + + :param `Message` m: The content of the SSH2_MSG_KEXGSS_CONTINUE message + """ + if not self.transport.server_mode: + srv_token = m.get_string() + m = Message() + m.add_byte(c_MSG_KEXGSS_CONTINUE) + m.add_string(self.kexgss.ssh_init_sec_context(target=self.gss_host, + recv_token=srv_token)) + self.transport.send_message(m) + self.transport._expect_packet(MSG_KEXGSS_CONTINUE, + MSG_KEXGSS_COMPLETE, + MSG_KEXGSS_ERROR) + else: + pass + + def _parse_kexgss_complete(self, m): + """ + Parse the SSH2_MSG_KEXGSS_COMPLETE message (client mode). + + :param `Message` m: The content of the SSH2_MSG_KEXGSS_COMPLETE message + """ + if self.transport.host_key is None: + self.transport.host_key = NullHostKey() + self.f = m.get_mpint() + mic_token = m.get_string() + # This must be TRUE, if there is a GSS-API token in this message. + bool = m.get_boolean() + srv_token = None + if bool: + srv_token = m.get_string() + if (self.f < 1) or (self.f > self.p - 1): + raise SSHException('Server kex "f" is out of range') + K = pow(self.f, self.x, self.p) + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || min || n || max || p || g || e || f || K) + hm = Message() + hm.add(self.transport.local_version, self.transport.remote_version, + self.transport.local_kex_init, self.transport.remote_kex_init, + self.transport.host_key.__str__()) + if not self.old_style: + hm.add_int(self.min_bits) + hm.add_int(self.preferred_bits) + if not self.old_style: + hm.add_int(self.max_bits) + hm.add_mpint(self.p) + hm.add_mpint(self.g) + hm.add_mpint(self.e) + hm.add_mpint(self.f) + hm.add_mpint(K) + H = sha1(hm.asbytes()).digest() + self.transport._set_K_H(K, H) + if srv_token is not None: + self.kexgss.ssh_init_sec_context(target=self.gss_host, + recv_token=srv_token) + self.kexgss.ssh_check_mic(mic_token, + self.transport.session_id) + else: + self.kexgss.ssh_check_mic(mic_token, + self.transport.session_id) + self.transport._activate_outbound() + + def _parse_kexgss_error(self, m): + """ + Parse the SSH2_MSG_KEXGSS_ERROR message (client mode). + The server may send a GSS-API error message. if it does, we display + the error by throwing an exception (client mode). + + :param `Message` m: The content of the SSH2_MSG_KEXGSS_ERROR message + :raise SSHException: Contains GSS-API major and minor status as well as + the error message and the language tag of the + message + """ + maj_status = m.get_int() + min_status = m.get_int() + err_msg = m.get_string() + lang_tag = m.get_string() # we don't care about the language! + raise SSHException("GSS-API Error:\nMajor Status: %s\nMinor Status: %s\ + \nError Message: %s\n") % (str(maj_status), + str(min_status), + err_msg) + + +class NullHostKey(object): + """ + This class represents the Null Host Key for GSS-API Key Exchange as defined + in `RFC 4462 Section 5 + <https://tools.ietf.org/html/rfc4462.html#section-5>`_ + """ + def __init__(self): + self.key = "" + + def __str__(self): + return self.key + + def get_name(self): + return self.key diff --git a/paramiko/message.py b/paramiko/message.py index da6acf8e..bf4c6b95 100644 --- a/paramiko/message.py +++ b/paramiko/message.py @@ -129,7 +129,7 @@ class Message (object): b = self.get_bytes(1) return b != zero_byte - def get_int(self): + def get_adaptive_int(self): """ Fetch an int from the stream. @@ -141,7 +141,7 @@ class Message (object): byte += self.get_bytes(3) return struct.unpack('>I', byte)[0] - def get_size(self): + def get_int(self): """ Fetch an int from the stream. @@ -172,7 +172,7 @@ class Message (object): contain unprintable characters. (It's not unheard of for a string to contain another byte-stream message.) """ - return self.get_bytes(self.get_size()) + return self.get_bytes(self.get_int()) def get_text(self): """ @@ -183,7 +183,7 @@ class Message (object): @return: a string. @rtype: string """ - return u(self.get_bytes(self.get_size())) + return u(self.get_bytes(self.get_int())) #return self.get_bytes(self.get_size()) def get_binary(self): @@ -195,7 +195,7 @@ class Message (object): @return: a string. @rtype: string """ - return self.get_bytes(self.get_size()) + return self.get_bytes(self.get_int()) def get_list(self): """ @@ -235,7 +235,7 @@ class Message (object): self.packet.write(zero_byte) return self - def add_size(self, n): + def add_int(self, n): """ Add an integer to the stream. @@ -244,7 +244,7 @@ class Message (object): self.packet.write(struct.pack('>I', n)) return self - def add_int(self, n): + def add_adaptive_int(self, n): """ Add an integer to the stream. @@ -283,7 +283,7 @@ class Message (object): :param str s: string to add """ s = asbytes(s) - self.add_size(len(s)) + self.add_int(len(s)) self.packet.write(s) return self @@ -302,7 +302,7 @@ class Message (object): if type(i) is bool: return self.add_boolean(i) elif isinstance(i, integer_types): - return self.add_int(i) + return self.add_adaptive_int(i) elif type(i) is list: return self.add_list(i) else: diff --git a/paramiko/packet.py b/paramiko/packet.py index 0f51df5e..89a514d1 100644 --- a/paramiko/packet.py +++ b/paramiko/packet.py @@ -21,25 +21,21 @@ Packet handling """ import errno +import os import socket import struct import threading import time +from hmac import HMAC from paramiko import util from paramiko.common import linefeed_byte, cr_byte_value, asbytes, MSG_NAMES, \ - DEBUG, xffffffff, zero_byte, rng + DEBUG, xffffffff, zero_byte from paramiko.py3compat import u, byte_ord from paramiko.ssh_exception import SSHException, ProxyCommandFailure from paramiko.message import Message -try: - from r_hmac import HMAC -except ImportError: - from Crypto.Hash.HMAC import HMAC - - def compute_hmac(key, message, digest_class): return HMAC(key, message, digest_class).digest() @@ -103,6 +99,10 @@ class Packetizer (object): self.__keepalive_last = time.time() self.__keepalive_callback = None + self.__timer = None + self.__handshake_complete = False + self.__timer_expired = False + def set_log(self, log): """ Set the Python log object to use for logging. @@ -186,6 +186,46 @@ class Packetizer (object): self.__keepalive_callback = callback self.__keepalive_last = time.time() + def read_timer(self): + self.__timer_expired = True + + def start_handshake(self, timeout): + """ + Tells `Packetizer` that the handshake process started. + Starts a book keeping timer that can signal a timeout in the + handshake process. + + :param float timeout: amount of seconds to wait before timing out + """ + if not self.__timer: + self.__timer = threading.Timer(float(timeout), self.read_timer) + self.__timer.start() + + def handshake_timed_out(self): + """ + Checks if the handshake has timed out. + + If `start_handshake` wasn't called before the call to this function, + the return value will always be `False`. If the handshake completed + before a timeout was reached, the return value will be `False` + + :return: handshake time out status, as a `bool` + """ + if not self.__timer: + return False + if self.__handshake_complete: + return False + return self.__timer_expired + + def complete_handshake(self): + """ + Tells `Packetizer` that the handshake has completed. + """ + if self.__timer: + self.__timer.cancel() + self.__timer_expired = False + self.__handshake_complete = True + def read_all(self, n, check_rekey=False): """ Read as close to N bytes as possible, blocking as long as necessary. @@ -204,6 +244,8 @@ class Packetizer (object): n -= len(out) while n > 0: got_timeout = False + if self.handshake_timed_out(): + raise EOFError() try: x = self.__socket.recv(n) if len(x) == 0: @@ -235,6 +277,7 @@ class Packetizer (object): def write_all(self, out): self.__keepalive_last = time.time() + iteration_with_zero_as_return_value = 0 while len(out) > 0: retry_write = False try: @@ -258,6 +301,15 @@ class Packetizer (object): n = 0 if self.__closed: n = -1 + else: + if n == 0 and iteration_with_zero_as_return_value > 10: + # We shouldn't retry the write, but we didn't + # manage to send anything over the socket. This might be an + # indication that we have lost contact with the remote side, + # but are yet to receive an EOFError or other socket errors. + # Let's give it some iteration to try and catch up. + n = -1 + iteration_with_zero_as_return_value += 1 if n < 0: raise EOFError() if n == len(out): @@ -338,7 +390,8 @@ class Packetizer (object): 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 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') @@ -359,7 +412,7 @@ class Packetizer (object): raise SSHException('Mismatched MAC') padding = byte_ord(packet[0]) payload = packet[1:packet_size - padding] - + if self.__dump_packets: self._log(DEBUG, 'Got payload (%d bytes, %d padding)' % (packet_size, padding)) @@ -455,7 +508,7 @@ class Packetizer (object): # don't waste random bytes for the padding packet += (zero_byte * padding) else: - packet += rng.read(padding) + packet += os.urandom(padding) return packet def _trigger_rekey(self): diff --git a/paramiko/pipe.py b/paramiko/pipe.py index b0cfcf24..4f62d7c5 100644 --- a/paramiko/pipe.py +++ b/paramiko/pipe.py @@ -28,6 +28,7 @@ will trigger as readable in `select <select.select>`. import sys import os import socket +from paramiko.py3compat import b def make_pipe(): diff --git a/paramiko/pkey.py b/paramiko/pkey.py index c8f84e0a..e95d60ba 100644 --- a/paramiko/pkey.py +++ b/paramiko/pkey.py @@ -23,12 +23,12 @@ Common API for all public keys. import base64 from binascii import hexlify, unhexlify import os +from hashlib import md5 -from Crypto.Hash import MD5 from Crypto.Cipher import DES3, AES from paramiko import util -from paramiko.common import o600, rng, zero_byte +from paramiko.common import o600, zero_byte from paramiko.py3compat import u, encodebytes, decodebytes, b from paramiko.ssh_exception import SSHException, PasswordRequiredException @@ -126,7 +126,7 @@ class PKey (object): a 16-byte `string <str>` (binary) of the MD5 fingerprint, in SSH format. """ - return MD5.new(self.asbytes()).digest() + return md5(self.asbytes()).digest() def get_base64(self): """ @@ -138,12 +138,11 @@ class PKey (object): """ return u(encodebytes(self.asbytes())).replace('\n', '') - def sign_ssh_data(self, rng, data): + def sign_ssh_data(self, data): """ Sign a blob of data with this private key, and return a `.Message` representing an SSH signature message. - :param .Crypto.Util.rng.RandomPool rng: a secure random number generator. :param str data: the data to sign. :return: an SSH signature `message <.Message>`. """ @@ -161,6 +160,7 @@ class PKey (object): """ return False + @classmethod def from_private_key_file(cls, filename, password=None): """ Create a key object by reading a private key file. If the private @@ -171,8 +171,9 @@ class PKey (object): is useless on the abstract PKey class. :param str filename: name of the file to read - :param str password: an optional password to use to decrypt the key file, - if it's encrypted + :param str password: + an optional password to use to decrypt the key file, if it's + encrypted :return: a new `.PKey` based on the given private key :raises IOError: if there was an error reading the file @@ -182,28 +183,27 @@ class PKey (object): """ key = cls(filename=filename, password=password) return key - from_private_key_file = classmethod(from_private_key_file) + @classmethod def from_private_key(cls, file_obj, password=None): """ Create a key object by reading a private key from a file (or file-like) - object. If the private key is encrypted and ``password`` is not ``None``, - the given password will be used to decrypt the key (otherwise + object. If the private key is encrypted and ``password`` is not + ``None``, the given password will be used to decrypt the key (otherwise `.PasswordRequiredException` is thrown). - :param file file_obj: the file to read from + :param file_obj: the file-like object to read from :param str password: an optional password to use to decrypt the key, if it's encrypted :return: a new `.PKey` based on the given private key :raises IOError: if there was an error reading the key - :raises PasswordRequiredException: if the private key file is encrypted, - and ``password`` is ``None`` + :raises PasswordRequiredException: + if the private key file is encrypted, and ``password`` is ``None`` :raises SSHException: if the key file is invalid """ key = cls(file_obj=file_obj, password=password) return key - from_private_key = classmethod(from_private_key) def write_private_key_file(self, filename, password=None): """ @@ -224,7 +224,7 @@ class PKey (object): Write private key contents into a file (or file-like) object. If the password is not ``None``, the key is encrypted before writing. - :param file file_obj: the file object to write into + :param file_obj: the file-like object to write into :param str password: an optional password to use to encrypt the key :raises IOError: if there was an error writing to the file @@ -274,7 +274,7 @@ class PKey (object): start += 1 # find end end = start - while (lines[end].strip() != '-----END ' + tag + ' PRIVATE KEY-----') and (end < len(lines)): + while end < len(lines) and lines[end].strip() != '-----END ' + tag + ' PRIVATE KEY-----': end += 1 # if we trudged to the end of the file, just try to cope. try: @@ -300,7 +300,7 @@ class PKey (object): keysize = self._CIPHER_TABLE[encryption_type]['keysize'] mode = self._CIPHER_TABLE[encryption_type]['mode'] salt = unhexlify(b(saltstr)) - key = util.generate_key_bytes(MD5, salt, password, keysize) + key = util.generate_key_bytes(md5, salt, password, keysize) return cipher.new(key, mode, salt).decrypt(data) def _write_private_key_file(self, tag, filename, data, password=None): @@ -310,8 +310,9 @@ class PKey (object): a trivially-encoded format (base64) which is completely insecure. If a password is given, DES-EDE3-CBC is used. - :param str tag: ``"RSA"`` or ``"DSA"``, the tag used to mark the data block. - :param file filename: name of the file to write. + :param str tag: + ``"RSA"`` or ``"DSA"``, the tag used to mark the data block. + :param filename: name of the file to write. :param str data: data blob that makes up the private key. :param str password: an optional password to use to encrypt the file. @@ -325,17 +326,16 @@ class PKey (object): def _write_private_key(self, tag, f, data, password=None): f.write('-----BEGIN %s PRIVATE KEY-----\n' % tag) if password is not None: - # since we only support one cipher here, use it cipher_name = list(self._CIPHER_TABLE.keys())[0] cipher = self._CIPHER_TABLE[cipher_name]['cipher'] keysize = self._CIPHER_TABLE[cipher_name]['keysize'] blocksize = self._CIPHER_TABLE[cipher_name]['blocksize'] mode = self._CIPHER_TABLE[cipher_name]['mode'] - salt = rng.read(16) - key = util.generate_key_bytes(MD5, salt, password, keysize) + salt = os.urandom(blocksize) + key = util.generate_key_bytes(md5, salt, password, keysize) if len(data) % blocksize != 0: n = blocksize - len(data) % blocksize - #data += rng.read(n) + #data += os.urandom(n) # that would make more sense ^, but it confuses openssh. data += zero_byte * n data = cipher.new(key, mode, salt).encrypt(data) diff --git a/paramiko/primes.py b/paramiko/primes.py index 58d158c8..7415c182 100644 --- a/paramiko/primes.py +++ b/paramiko/primes.py @@ -20,32 +20,15 @@ Utility functions for dealing with primes. """ -from Crypto.Util import number +import os from paramiko import util from paramiko.py3compat import byte_mask, long from paramiko.ssh_exception import SSHException +from paramiko.common import * -def _generate_prime(bits, rng): - """primtive attempt at prime generation""" - hbyte_mask = pow(2, bits % 8) - 1 - while True: - # loop catches the case where we increment n into a higher bit-range - x = rng.read((bits + 7) // 8) - if hbyte_mask > 0: - x = byte_mask(x[0], hbyte_mask) + x[1:] - n = util.inflate_long(x, 1) - n |= 1 - n |= (1 << (bits - 1)) - while not number.isPrime(n): - n += 2 - if util.bit_length(n) == bits: - break - return n - - -def _roll_random(rng, n): +def _roll_random(n): """returns a random # from 0 to N-1""" bits = util.bit_length(n - 1) byte_count = (bits + 7) // 8 @@ -58,7 +41,7 @@ def _roll_random(rng, n): # fits, so i can't guarantee that this loop will ever finish, but the odds # of it looping forever should be infinitesimal. while True: - x = rng.read(byte_count) + x = os.urandom(byte_count) if hbyte_mask > 0: x = byte_mask(x[0], hbyte_mask) + x[1:] num = util.inflate_long(x, 1) @@ -73,11 +56,10 @@ class ModulusPack (object): on systems that have such a file. """ - def __init__(self, rpool): + def __init__(self): # pack is a hash of: bits -> [ (generator, modulus) ... ] self.pack = {} self.discarded = [] - self.rng = rpool def _parse_modulus(self, line): timestamp, mod_type, tests, tries, size, generator, modulus = line.split() @@ -147,5 +129,5 @@ class ModulusPack (object): if min > good: good = bitsizes[-1] # now pick a random modulus of this bitsize - n = _roll_random(self.rng, len(self.pack[good])) + n = _roll_random(len(self.pack[good])) return self.pack[good][n] diff --git a/paramiko/proxy.py b/paramiko/proxy.py index 25666be5..ca602c4c 100644 --- a/paramiko/proxy.py +++ b/paramiko/proxy.py @@ -27,9 +27,10 @@ import socket import time from paramiko.ssh_exception import ProxyCommandFailure +from paramiko.util import ClosingContextManager -class ProxyCommand(object): +class ProxyCommand(ClosingContextManager): """ Wraps a subprocess running ProxyCommand-driven programs. @@ -37,6 +38,8 @@ class ProxyCommand(object): `.Transport` and `.Packetizer` classes. Using this class instead of a regular socket makes it possible to talk with a Popen'd command that will proxy traffic between the client and a server hosted in another machine. + + Instances of this class may be used as context managers. """ def __init__(self, command_line): """ diff --git a/paramiko/rsakey.py b/paramiko/rsakey.py index c93f3218..4ebd8354 100644 --- a/paramiko/rsakey.py +++ b/paramiko/rsakey.py @@ -20,11 +20,13 @@ RSA keys. """ +import os +from hashlib import sha1 + from Crypto.PublicKey import RSA -from Crypto.Hash import SHA from paramiko import util -from paramiko.common import rng, max_byte, zero_byte, one_byte +from paramiko.common import max_byte, zero_byte, one_byte from paramiko.message import Message from paramiko.ber import BER, BERException from paramiko.pkey import PKey @@ -90,8 +92,8 @@ class RSAKey (PKey): def can_sign(self): return self.d is not None - def sign_ssh_data(self, rpool, data): - digest = SHA.new(data).digest() + def sign_ssh_data(self, data): + digest = sha1(data).digest() rsa = RSA.construct((long(self.n), long(self.e), long(self.d))) sig = util.deflate_long(rsa.sign(self._pkcs1imify(digest), bytes())[0], 0) m = Message() @@ -106,7 +108,7 @@ class RSAKey (PKey): # verify the signature by SHA'ing the data and encrypting it using the # public key. some wackiness ensues where we "pkcs1imify" the 20-byte # hash into a string as long as the RSA key. - hash_obj = util.inflate_long(self._pkcs1imify(SHA.new(data).digest()), True) + hash_obj = util.inflate_long(self._pkcs1imify(sha1(data).digest()), True) rsa = RSA.construct((long(self.n), long(self.e))) return rsa.verify(hash_obj, (sig,)) @@ -125,10 +127,11 @@ class RSAKey (PKey): def write_private_key_file(self, filename, password=None): self._write_private_key_file('RSA', filename, self._encode_key(), password) - + def write_private_key(self, file_obj, password=None): self._write_private_key('RSA', file_obj, self._encode_key(), password) + @staticmethod def generate(bits, progress_func=None): """ Generate a new private RSA key. This factory function can be used to @@ -140,13 +143,12 @@ class RSAKey (PKey): by ``pyCrypto.PublicKey``). :return: new `.RSAKey` private key """ - rsa = RSA.generate(bits, rng.read, progress_func) + rsa = RSA.generate(bits, os.urandom, progress_func) key = RSAKey(vals=(rsa.e, rsa.n)) key.d = rsa.d key.p = rsa.p key.q = rsa.q return key - generate = staticmethod(generate) ### internals... @@ -162,11 +164,11 @@ class RSAKey (PKey): def _from_private_key_file(self, filename, password): data = self._read_private_key_file('RSA', filename, password) self._decode_key(data) - + def _from_private_key(self, file_obj, password): data = self._read_private_key('RSA', file_obj, password) self._decode_key(data) - + def _decode_key(self, data): # private key file contains: # RSAPrivateKey = { version = 0, n, e, d, p, q, d mod p-1, d mod q-1, q**-1 mod p } diff --git a/paramiko/server.py b/paramiko/server.py index 2f630dfb..f79a1748 100644 --- a/paramiko/server.py +++ b/paramiko/server.py @@ -22,7 +22,7 @@ import threading from paramiko import util -from paramiko.common import DEBUG, ERROR, OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED, AUTH_FAILED +from paramiko.common import DEBUG, ERROR, OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED, AUTH_FAILED, AUTH_SUCCESSFUL from paramiko.py3compat import string_types @@ -229,6 +229,80 @@ class ServerInterface (object): :rtype: int or `.InteractiveQuery` """ return AUTH_FAILED + + def check_auth_gssapi_with_mic(self, username, + gss_authenticated=AUTH_FAILED, + cc_file=None): + """ + Authenticate the given user to the server if he is a valid krb5 + principal. + + :param str username: The username of the authenticating client + :param int gss_authenticated: The result of the krb5 authentication + :param str cc_filename: The krb5 client credentials cache filename + :return: `.AUTH_FAILED` if the user is not authenticated otherwise + `.AUTH_SUCCESSFUL` + :rtype: int + :note: Kerberos credential delegation is not supported. + :see: `.ssh_gss` + :note: : We are just checking in L{AuthHandler} that the given user is + a valid krb5 principal! + We don't check if the krb5 principal is allowed to log in on + the server, because there is no way to do that in python. So + if you develop your own SSH server with paramiko for a cetain + plattform like Linux, you should call C{krb5_kuserok()} in your + local kerberos library to make sure that the krb5_principal has + an account on the server and is allowed to log in as a user. + :see: `http://www.unix.com/man-page/all/3/krb5_kuserok/` + """ + if gss_authenticated == AUTH_SUCCESSFUL: + return AUTH_SUCCESSFUL + return AUTH_FAILED + + def check_auth_gssapi_keyex(self, username, + gss_authenticated=AUTH_FAILED, + cc_file=None): + """ + Authenticate the given user to the server if he is a valid krb5 + principal and GSS-API Key Exchange was performed. + If GSS-API Key Exchange was not performed, this authentication method + won't be available. + + :param str username: The username of the authenticating client + :param int gss_authenticated: The result of the krb5 authentication + :param str cc_filename: The krb5 client credentials cache filename + :return: `.AUTH_FAILED` if the user is not authenticated otherwise + `.AUTH_SUCCESSFUL` + :rtype: int + :note: Kerberos credential delegation is not supported. + :see: `.ssh_gss` `.kex_gss` + :note: : We are just checking in L{AuthHandler} that the given user is + a valid krb5 principal! + We don't check if the krb5 principal is allowed to log in on + the server, because there is no way to do that in python. So + if you develop your own SSH server with paramiko for a cetain + plattform like Linux, you should call C{krb5_kuserok()} in your + local kerberos library to make sure that the krb5_principal has + an account on the server and is allowed to log in as a user. + :see: `http://www.unix.com/man-page/all/3/krb5_kuserok/` + """ + if gss_authenticated == AUTH_SUCCESSFUL: + return AUTH_SUCCESSFUL + return AUTH_FAILED + + def enable_auth_gssapi(self): + """ + Overwrite this function in your SSH server to enable GSSAPI + authentication. + The default implementation always returns false. + + :return: True if GSSAPI authentication is enabled otherwise false + :rtype: Boolean + :see: : `.ssh_gss` + """ + UseGSSAPI = False + GSSAPICleanupCredentials = False + return UseGSSAPI def check_port_forward_request(self, address, port): """ diff --git a/paramiko/sftp_attr.py b/paramiko/sftp_attr.py index d12eff8d..0eaca30b 100644 --- a/paramiko/sftp_attr.py +++ b/paramiko/sftp_attr.py @@ -60,6 +60,7 @@ class SFTPAttributes (object): self.st_mtime = None self.attr = {} + @classmethod def from_stat(cls, obj, filename=None): """ Create an `.SFTPAttributes` object from an existing ``stat`` object (an @@ -79,13 +80,12 @@ class SFTPAttributes (object): if filename is not None: attr.filename = filename return attr - from_stat = classmethod(from_stat) def __repr__(self): return '<SFTPAttributes: %s>' % self._debug_str() ### internals... - + @classmethod def _from_msg(cls, msg, filename=None, longname=None): attr = cls() attr._unpack(msg) @@ -94,7 +94,6 @@ class SFTPAttributes (object): if longname is not None: attr.longname = longname return attr - _from_msg = classmethod(_from_msg) def _unpack(self, msg): self._flags = msg.get_int() @@ -159,6 +158,7 @@ class SFTPAttributes (object): out += ']' return out + @staticmethod def _rwx(n, suid, sticky=False): if suid: suid = 2 @@ -168,7 +168,6 @@ class SFTPAttributes (object): else: out += '-xSs'[suid + (n & 1)] return out - _rwx = staticmethod(_rwx) def __str__(self): """create a unix-style long description of the file (like ls -l)""" @@ -210,12 +209,15 @@ class SFTPAttributes (object): # not all servers support uid/gid uid = self.st_uid gid = self.st_gid + size = self.st_size if uid is None: uid = 0 if gid is None: gid = 0 + if size is None: + size = 0 - return '%s 1 %-8d %-8d %8d %-12s %s' % (ks, uid, gid, self.st_size, datestr, filename) + return '%s 1 %-8d %-8d %8d %-12s %s' % (ks, uid, gid, size, datestr, filename) def asbytes(self): return b(str(self)) diff --git a/paramiko/sftp_client.py b/paramiko/sftp_client.py index 99a29e36..daaae3ef 100644 --- a/paramiko/sftp_client.py +++ b/paramiko/sftp_client.py @@ -39,6 +39,7 @@ from paramiko.sftp import BaseSFTP, CMD_OPENDIR, CMD_HANDLE, SFTPError, CMD_READ from paramiko.sftp_attr import SFTPAttributes from paramiko.ssh_exception import SSHException from paramiko.sftp_file import SFTPFile +from paramiko.util import ClosingContextManager def _to_unicode(s): @@ -58,12 +59,14 @@ def _to_unicode(s): b_slash = b'/' -class SFTPClient(BaseSFTP): +class SFTPClient(BaseSFTP, ClosingContextManager): """ SFTP client object. - + Used to open an SFTP session across an open SSH `.Transport` and perform remote file operations. + + Instances of this class may be used as context managers. """ def __init__(self, sock): """ @@ -98,21 +101,35 @@ class SFTPClient(BaseSFTP): raise SSHException('EOF during negotiation') self._log(INFO, 'Opened sftp connection (server version %d)' % server_version) - def from_transport(cls, t): + @classmethod + def from_transport(cls, t, window_size=None, max_packet_size=None): """ Create an SFTP client channel from an open `.Transport`. + Setting the window and packet sizes might affect the transfer speed. + The default settings in the `.Transport` class are the same as in + OpenSSH and should work adequately for both files transfers and + interactive sessions. + :param .Transport t: an open `.Transport` which is already authenticated + :param int window_size: + optional window size for the `.SFTPClient` session. + :param int max_packet_size: + optional max packet size for the `.SFTPClient` session.. + :return: a new `.SFTPClient` object, referring to an sftp session (channel) across the transport + + .. versionchanged:: 1.15 + Added the ``window_size`` and ``max_packet_size`` arguments. """ - chan = t.open_session() + chan = t.open_session(window_size=window_size, + max_packet_size=max_packet_size) if chan is None: return None chan.invoke_subsystem('sftp') return cls(chan) - from_transport = classmethod(from_transport) def _log(self, level, msg, *args): if isinstance(msg, list): @@ -196,6 +213,71 @@ class SFTPClient(BaseSFTP): self._request(CMD_CLOSE, handle) return filelist + def listdir_iter(self, path='.', read_aheads=50): + """ + Generator version of `.listdir_attr`. + + See the API docs for `.listdir_attr` for overall details. + + This function adds one more kwarg on top of `.listdir_attr`: + ``read_aheads``, an integer controlling how many + ``SSH_FXP_READDIR`` requests are made to the server. The default of 50 + should suffice for most file listings as each request/response cycle + may contain multiple files (dependant on server implementation.) + + .. versionadded:: 1.15 + """ + path = self._adjust_cwd(path) + self._log(DEBUG, 'listdir(%r)' % path) + t, msg = self._request(CMD_OPENDIR, path) + + if t != CMD_HANDLE: + raise SFTPError('Expected handle') + + handle = msg.get_string() + + nums = list() + while True: + try: + # Send out a bunch of readdir requests so that we can read the + # responses later on Section 6.7 of the SSH file transfer RFC + # explains this + # http://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt + for i in range(read_aheads): + num = self._async_request(type(None), CMD_READDIR, handle) + nums.append(num) + + + # For each of our sent requests + # Read and parse the corresponding packets + # If we're at the end of our queued requests, then fire off + # some more requests + # Exit the loop when we've reached the end of the directory + # handle + for num in nums: + t, pkt_data = self._read_packet() + msg = Message(pkt_data) + new_num = msg.get_int() + if num == new_num: + if t == CMD_STATUS: + self._convert_status(msg) + count = msg.get_int() + for i in range(count): + filename = msg.get_text() + longname = msg.get_text() + attr = SFTPAttributes._from_msg( + msg, filename, longname) + if (filename != '.') and (filename != '..'): + yield attr + + # If we've hit the end of our queued requests, reset nums. + nums = list() + + except EOFError: + self._request(CMD_CLOSE, handle) + return + + def open(self, filename, mode='r', bufsize=-1): """ Open a file on the remote server. The arguments are the same as for @@ -507,8 +589,21 @@ class SFTPClient(BaseSFTP): .. versionadded:: 1.4 """ + # TODO: make class initialize with self._cwd set to self.normalize('.') return self._cwd and u(self._cwd) + def _transfer_with_callback(self, reader, writer, file_size, callback): + size = 0 + while True: + data = reader.read(32768) + writer.write(data) + size += len(data) + if len(data) == 0: + break + if callback is not None: + callback(size, file_size) + return size + def putfo(self, fl, remotepath, file_size=0, callback=None, confirm=True): """ Copy the contents of an open file object (``fl``) to the SFTP server as @@ -517,7 +612,7 @@ class SFTPClient(BaseSFTP): The SFTP operations use pipelining for speed. - :param file fl: opened file or file-like object to copy + :param fl: opened file or file-like object to copy :param str remotepath: the destination path on the SFTP server :param int file_size: optional size parameter passed to callback. If none is specified, @@ -538,15 +633,9 @@ class SFTPClient(BaseSFTP): """ with self.file(remotepath, 'wb') as fr: fr.set_pipelined(True) - size = 0 - while True: - data = fl.read(32768) - fr.write(data) - size += len(data) - if callback is not None: - callback(size, file_size) - if len(data) == 0: - break + size = self._transfer_with_callback( + reader=fl, writer=fr, file_size=file_size, callback=callback + ) if confirm: s = self.stat(remotepath) if s.st_size != size: @@ -578,7 +667,7 @@ class SFTPClient(BaseSFTP): .. versionadded:: 1.4 .. versionchanged:: 1.7.4 - ``callback`` and rich attribute return value added. + ``callback`` and rich attribute return value added. .. versionchanged:: 1.7.7 ``confirm`` param added. """ @@ -603,18 +692,13 @@ class SFTPClient(BaseSFTP): .. versionadded:: 1.10 """ + file_size = self.stat(remotepath).st_size with self.open(remotepath, 'rb') as fr: - file_size = self.stat(remotepath).st_size - fr.prefetch() - size = 0 - while True: - data = fr.read(32768) - fl.write(data) - size += len(data) - if callback is not None: - callback(size, file_size) - if len(data) == 0: - break + fr.prefetch(file_size) + return self._transfer_with_callback( + reader=fr, writer=fl, file_size=file_size, callback=callback + ) + return size def get(self, remotepath, localpath, callback=None): @@ -633,7 +717,6 @@ class SFTPClient(BaseSFTP): .. versionchanged:: 1.7.4 Added the ``callback`` param """ - file_size = self.stat(remotepath).st_size with open(localpath, 'wb') as fl: size = self.getfo(remotepath, fl, callback) s = os.stat(localpath) diff --git a/paramiko/sftp_file.py b/paramiko/sftp_file.py index 03d67b33..c5b65488 100644 --- a/paramiko/sftp_file.py +++ b/paramiko/sftp_file.py @@ -379,7 +379,7 @@ class SFTPFile (BufferedFile): """ self.pipelined = pipelined - def prefetch(self): + def prefetch(self, file_size): """ Pre-fetch the remaining contents of this file in anticipation of future `.read` calls. If reading the entire file, pre-fetching can @@ -393,12 +393,11 @@ class SFTPFile (BufferedFile): .. versionadded:: 1.5.1 """ - size = self.stat().st_size # queue up async reads for the rest of the file chunks = [] n = self._realpos - while n < size: - chunk = min(self.MAX_REQUEST_SIZE, size - n) + while n < file_size: + chunk = min(self.MAX_REQUEST_SIZE, file_size - n) chunks.append((n, chunk)) n += chunk if len(chunks) > 0: @@ -488,9 +487,3 @@ class SFTPFile (BufferedFile): x = self._saved_exception self._saved_exception = None raise x - - def __enter__(self): - return self - - def __exit__(self, type, value, traceback): - self.close() diff --git a/paramiko/sftp_handle.py b/paramiko/sftp_handle.py index 92dd9cfe..edceb5ad 100644 --- a/paramiko/sftp_handle.py +++ b/paramiko/sftp_handle.py @@ -22,9 +22,10 @@ Abstraction of an SFTP file handle (for server mode). import os from paramiko.sftp import SFTP_OP_UNSUPPORTED, SFTP_OK +from paramiko.util import ClosingContextManager -class SFTPHandle (object): +class SFTPHandle (ClosingContextManager): """ Abstract object representing a handle to an open file (or folder) in an SFTP server implementation. Each handle has a string representation used @@ -32,6 +33,8 @@ class SFTPHandle (object): Server implementations can (and should) subclass SFTPHandle to implement features of a file handle, like `stat` or `chattr`. + + Instances of this class may be used as context managers. """ def __init__(self, flags=0): """ diff --git a/paramiko/sftp_server.py b/paramiko/sftp_server.py index dadfd026..ce287e8f 100644 --- a/paramiko/sftp_server.py +++ b/paramiko/sftp_server.py @@ -22,9 +22,9 @@ Server-mode SFTP support. import os import errno - -from Crypto.Hash import MD5, SHA import sys +from hashlib import md5, sha1 + from paramiko import util from paramiko.sftp import BaseSFTP, Message, SFTP_FAILURE, \ SFTP_PERMISSION_DENIED, SFTP_NO_SUCH_FILE @@ -45,8 +45,8 @@ from paramiko.sftp import CMD_HANDLE, SFTP_DESC, CMD_STATUS, SFTP_EOF, CMD_NAME, CMD_READLINK, CMD_SYMLINK, CMD_REALPATH, CMD_EXTENDED, SFTP_OP_UNSUPPORTED _hash_class = { - 'sha1': SHA, - 'md5': MD5, + 'sha1': sha1, + 'md5': md5, } @@ -82,14 +82,14 @@ class SFTPServer (BaseSFTP, SubsystemHandler): self.file_table = {} self.folder_table = {} self.server = sftp_si(server, *largs, **kwargs) - + def _log(self, level, msg): if issubclass(type(msg), list): for m in msg: super(SFTPServer, self)._log(level, "[chan " + self.sock.get_name() + "] " + m) else: super(SFTPServer, self)._log(level, "[chan " + self.sock.get_name() + "] " + msg) - + def start_subsystem(self, name, transport, channel): self.sock = channel self._log(DEBUG, 'Started sftp server on channel %s' % repr(channel)) @@ -129,6 +129,7 @@ class SFTPServer (BaseSFTP, SubsystemHandler): self.file_table = {} self.folder_table = {} + @staticmethod def convert_errno(e): """ Convert an errno value (as from an ``OSError`` or ``IOError``) into a @@ -146,8 +147,8 @@ class SFTPServer (BaseSFTP, SubsystemHandler): return SFTP_NO_SUCH_FILE else: return SFTP_FAILURE - convert_errno = staticmethod(convert_errno) + @staticmethod def set_file_attr(filename, attr): """ Change a file's attributes on the local filesystem. The contents of @@ -157,7 +158,7 @@ class SFTPServer (BaseSFTP, SubsystemHandler): This is meant to be a handy helper function for translating SFTP file requests into local file operations. - + :param str filename: name of the file to alter (should usually be an absolute path). :param .SFTPAttributes attr: attributes to change. @@ -173,7 +174,6 @@ class SFTPServer (BaseSFTP, SubsystemHandler): if attr._flags & attr.FLAG_SIZE: with open(filename, 'w+') as f: f.truncate(attr.st_size) - set_file_attr = staticmethod(set_file_attr) ### internals... @@ -281,7 +281,7 @@ class SFTPServer (BaseSFTP, SubsystemHandler): # don't try to read more than about 64KB at a time chunklen = min(blocklen, 65536) count = 0 - hash_obj = alg.new() + hash_obj = alg() while count < blocklen: data = f.read(offset, chunklen) if not isinstance(data, bytes_types): @@ -298,7 +298,7 @@ class SFTPServer (BaseSFTP, SubsystemHandler): msg.add_string(algname) msg.add_bytes(sum_out) self._send_packet(CMD_EXTENDED_REPLY, msg) - + def _convert_pflags(self, pflags): """convert SFTP-style open() flags to Python's os.open() flags""" if (pflags & SFTP_FLAG_READ) and (pflags & SFTP_FLAG_WRITE): diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py index b99e42b3..016a411e 100644 --- a/paramiko/ssh_exception.py +++ b/paramiko/ssh_exception.py @@ -16,6 +16,8 @@ # along with Paramiko; if not, write to the Free Software Foundation, Inc., # 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. +import socket + class SSHException (Exception): """ @@ -105,7 +107,11 @@ class BadHostKeyException (SSHException): .. versionadded:: 1.6 """ def __init__(self, hostname, got_key, expected_key): - SSHException.__init__(self, 'Host key for server %s does not match!' % hostname) + SSHException.__init__(self, + 'Host key for server %s does not match : got %s expected %s' % ( + hostname, + got_key.get_base64(), + expected_key.get_base64())) self.hostname = hostname self.key = got_key self.expected_key = expected_key @@ -129,3 +135,41 @@ class ProxyCommandFailure (SSHException): self.error = error # for unpickling self.args = (command, error, ) + + +class NoValidConnectionsError(socket.error): + """ + Multiple connection attempts were made and no families succeeded. + + This exception class wraps multiple "real" underlying connection errors, + all of which represent failed connection attempts. Because these errors are + not guaranteed to all be of the same error type (i.e. different errno, + `socket.error` subclass, message, etc) we expose a single unified error + message and a ``None`` errno so that instances of this class match most + normal handling of `socket.error` objects. + + To see the wrapped exception objects, access the ``errors`` attribute. + ``errors`` is a dict whose keys are address tuples (e.g. ``('127.0.0.1', + 22)``) and whose values are the exception encountered trying to connect to + that address. + + It is implied/assumed that all the errors given to a single instance of + this class are from connecting to the same hostname + port (and thus that + the differences are in the resolution of the hostname - e.g. IPv4 vs v6). + + .. versionadded:: 1.16 + """ + def __init__(self, errors): + """ + :param dict errors: + The errors dict to store, as described by class docstring. + """ + addrs = errors.keys() + body = ', '.join([x[0] for x in addrs[:-1]]) + tail = addrs[-1][0] + msg = "Unable to connect to port {0} on {1} or {2}" + super(NoValidConnectionsError, self).__init__( + None, # stand-in for errno + msg.format(addrs[0][1], body, tail) + ) + self.errors = errors diff --git a/paramiko/ssh_gss.py b/paramiko/ssh_gss.py new file mode 100644 index 00000000..e9b13a66 --- /dev/null +++ b/paramiko/ssh_gss.py @@ -0,0 +1,578 @@ +# Copyright (C) 2013-2014 science + computing ag +# Author: Sebastian Deiss <sebastian.deiss@t-online.de> +# +# +# 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. + + +""" +This module provides GSS-API / SSPI authentication as defined in :rfc:`4462`. + +.. note:: Credential delegation is not supported in server mode. + +.. seealso:: :doc:`/api/kex_gss` + +.. versionadded:: 1.15 +""" + +import struct +import os +import sys + +""" +:var bool GSS_AUTH_AVAILABLE: + Constraint that indicates if GSS-API / SSPI is available. +""" +GSS_AUTH_AVAILABLE = True + +try: + from pyasn1.type.univ import ObjectIdentifier + from pyasn1.codec.der import encoder, decoder +except ImportError: + GSS_AUTH_AVAILABLE = False + class ObjectIdentifier(object): + def __init__(self, *args): + raise NotImplementedError("Module pyasn1 not importable") + + class decoder(object): + def decode(self): + raise NotImplementedError("Module pyasn1 not importable") + + class encoder(object): + def encode(self): + raise NotImplementedError("Module pyasn1 not importable") + +from paramiko.common import MSG_USERAUTH_REQUEST +from paramiko.ssh_exception import SSHException + +""" +:var str _API: Constraint for the used API +""" +_API = "MIT" + +try: + import gssapi +except (ImportError, OSError): + try: + import sspicon + import sspi + _API = "SSPI" + except ImportError: + GSS_AUTH_AVAILABLE = False + _API = None + + +def GSSAuth(auth_method, gss_deleg_creds=True): + """ + Provide SSH2 GSS-API / SSPI authentication. + + :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. + We delegate credentials by default. + :return: Either an `._SSH_GSSAPI` (Unix) object or an + `_SSH_SSPI` (Windows) object + :rtype: Object + + :raise 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. + If there is no supported API available, + ``None`` will be returned. + """ + if _API == "MIT": + return _SSH_GSSAPI(auth_method, gss_deleg_creds) + elif _API == "SSPI" and os.name == "nt": + return _SSH_SSPI(auth_method, gss_deleg_creds) + else: + raise ImportError("Unable to import a GSS-API / SSPI module!") + + +class _SSH_GSSAuth(object): + """ + Contains the shared variables and methods of `._SSH_GSSAPI` and + `._SSH_SSPI`. + """ + 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 + """ + self._auth_method = auth_method + self._gss_deleg_creds = gss_deleg_creds + self._gss_host = None + self._username = None + self._session_id = None + self._service = "ssh-connection" + """ + OpenSSH supports Kerberos V5 mechanism only for GSS-API authentication, + so we also support the krb5 mechanism only. + """ + self._krb5_mech = "1.2.840.113554.1.2.2" + + # client mode + self._gss_ctxt = None + self._gss_ctxt_status = False + + # server mode + self._gss_srv_ctxt = None + self._gss_srv_ctxt_status = False + self.cc_file = None + + def set_service(self, service): + """ + This is just a setter to use a non default service. + I added this method, because RFC 4462 doesn't specify "ssh-connection" + as the only service value. + + :param str service: The desired SSH service + :rtype: Void + """ + if service.find("ssh-"): + self._service = service + + def set_username(self, username): + """ + Setter for C{username}. If GSS-API Key Exchange is performed, the + username is not set by C{ssh_init_sec_context}. + + :param str username: The name of the user who attempts to login + :rtype: Void + """ + self._username = username + + def ssh_gss_oids(self, mode="client"): + """ + This method returns a single OID, because we only support the + Kerberos V5 mechanism. + + :param str mode: Client for client mode and server for server mode + :return: A byte sequence containing the number of supported + OIDs, the length of the OID and the actual OID encoded with + DER + :rtype: Bytes + :note: In server mode we just return the OID length and the DER encoded + OID. + """ + OIDs = self._make_uint32(1) + krb5_OID = encoder.encode(ObjectIdentifier(self._krb5_mech)) + OID_len = self._make_uint32(len(krb5_OID)) + if mode == "server": + return OID_len + krb5_OID + return OIDs + OID_len + krb5_OID + + def ssh_check_mech(self, desired_mech): + """ + Check if the given OID is the Kerberos V5 OID (server mode). + + :param str desired_mech: The desired GSS-API mechanism of the client + :return: ``True`` if the given OID is supported, otherwise C{False} + :rtype: Boolean + """ + mech, __ = decoder.decode(desired_mech) + if mech.__str__() != self._krb5_mech: + return False + return True + + # Internals + #-------------------------------------------------------------------------- + def _make_uint32(self, integer): + """ + Create a 32 bit unsigned integer (The byte sequence of an integer). + + :param int integer: The integer value to convert + :return: The byte sequence of an 32 bit integer + :rtype: Bytes + """ + return struct.pack("!I", integer) + + def _ssh_build_mic(self, session_id, username, service, auth_method): + """ + Create the SSH2 MIC filed for gssapi-with-mic. + + :param str session_id: The SSH session ID + :param str username: The name of the user who attempts to login + :param str service: The requested SSH service + :param str auth_method: The requested SSH authentication mechanism + :return: The MIC as defined in RFC 4462. The contents of the + MIC field are: + string session_identifier, + byte SSH_MSG_USERAUTH_REQUEST, + string user-name, + string service (ssh-connection), + string authentication-method + (gssapi-with-mic or gssapi-keyex) + :rtype: Bytes + """ + mic = self._make_uint32(len(session_id)) + mic += session_id + mic += struct.pack('B', MSG_USERAUTH_REQUEST) + mic += self._make_uint32(len(username)) + mic += username.encode() + mic += self._make_uint32(len(service)) + mic += service.encode() + mic += self._make_uint32(len(auth_method)) + mic += auth_method.encode() + return mic + + +class _SSH_GSSAPI(_SSH_GSSAuth): + """ + Implementation of the GSS-API MIT Kerberos Authentication for SSH2. + + :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.C_PROT_READY_FLAG, + gssapi.C_INTEG_FLAG, + gssapi.C_MUTUAL_FLAG, + gssapi.C_DELEG_FLAG) + else: + self._gss_flags = (gssapi.C_PROT_READY_FLAG, + gssapi.C_INTEG_FLAG, + gssapi.C_MUTUAL_FLAG) + + 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 + :raise SSHException: Is raised if the desired mechanism of the client + is not supported + :return: A ``String`` if the GSS-API has returned a token or ``None`` if + no token was returned + :rtype: String or None + """ + self._username = username + self._gss_host = target + targ_name = gssapi.Name("host@" + self._gss_host, + gssapi.C_NT_HOSTBASED_SERVICE) + ctx = gssapi.Context() + ctx.flags = self._gss_flags + if desired_mech is None: + krb5_mech = gssapi.OID.mech_from_string(self._krb5_mech) + else: + mech, __ = decoder.decode(desired_mech) + if mech.__str__() != self._krb5_mech: + raise SSHException("Unsupported mechanism OID.") + else: + krb5_mech = gssapi.OID.mech_from_string(self._krb5_mech) + token = None + try: + if recv_token is None: + self._gss_ctxt = gssapi.InitContext(peer_name=targ_name, + mech_type=krb5_mech, + req_flags=ctx.flags) + token = self._gss_ctxt.step(token) + else: + token = self._gss_ctxt.step(recv_token) + except gssapi.GSSException: + raise gssapi.GSSException("{0} Target: {1}".format(sys.exc_info()[1], + self._gss_host)) + self._gss_ctxt_status = self._gss_ctxt.established + 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: String + :see: `._ssh_build_mic` + """ + 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_mic(mic_field) + else: + # for key exchange with gssapi-keyex + mic_token = self._gss_srv_ctxt.get_mic(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 + :rtype: String or None + """ + # 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.AcceptContext() + token = self._gss_srv_ctxt.step(recv_token) + self._gss_srv_ctxt_status = self._gss_srv_ctxt.established + 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.GSSException: 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_mic(mic_field, mic_token) + else: + # for key exchange with gssapi-keyex + # client mode + self._gss_ctxt.verify_mic(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_cred 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 + :raise 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. + + :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 = sspicon.ISC_REQ_INTEGRITY |\ + sspicon.ISC_REQ_MUTUAL_AUTH |\ + sspicon.ISC_REQ_DELEGATE + else: + self._gss_flags = sspicon.ISC_REQ_INTEGRITY |\ + sspicon.ISC_REQ_MUTUAL_AUTH + + def ssh_init_sec_context(self, target, desired_mech=None, + username=None, recv_token=None): + """ + Initialize a SSPI context. + + :param str username: The name of the user who attempts to login + :param str target: The FQDN of the target to connect to + :param str desired_mech: The negotiated SSPI mechanism + ("pseudo negotiated" mechanism, because we + support just the krb5 mechanism :-)) + :param recv_token: The SSPI token received from the Server + :raise SSHException: Is raised if the desired mechanism of the client + is not supported + :return: A ``String`` if the SSPI has returned a token or ``None`` if + no token was returned + :rtype: String or None + """ + self._username = username + self._gss_host = target + error = 0 + targ_name = "host/" + self._gss_host + if desired_mech is not None: + mech, __ = decoder.decode(desired_mech) + if mech.__str__() != self._krb5_mech: + raise SSHException("Unsupported mechanism OID.") + try: + if recv_token is None: + self._gss_ctxt = sspi.ClientAuth("Kerberos", + scflags=self._gss_flags, + targetspn=targ_name) + error, token = self._gss_ctxt.authorize(recv_token) + token = token[0].Buffer + except: + raise Exception("{0}, Target: {1}".format(sys.exc_info()[1], + self._gss_host)) + if error == 0: + """ + if the status is GSS_COMPLETE (error = 0) the context is fully + established an we can set _gss_ctxt_status to True. + """ + self._gss_ctxt_status = True + token = None + """ + You won't get another token if the context is fully established, + so i set token to None instead of "" + """ + 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 Key Exchange with SSPI or not + :return: gssapi-with-mic: + Returns the MIC token from SSPI for the message we created + with ``_ssh_build_mic``. + gssapi-keyex: + Returns the MIC token from SSPI with the SSH session ID as + message. + :rtype: String + :see: `._ssh_build_mic` + """ + 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.sign(mic_field) + else: + # for key exchange with gssapi-keyex + mic_token = self._gss_srv_ctxt.sign(self._session_id) + return mic_token + + def ssh_accept_sec_context(self, hostname, username, recv_token): + """ + Accept a SSPI context (server mode). + + :param str hostname: The servers FQDN + :param str username: The name of the user who attempts to login + :param str recv_token: The SSPI Token received from the server, + if it's not the initial call. + :return: A ``String`` if the SSPI has returned a token or ``None`` if + no token was returned + :rtype: String or None + """ + self._gss_host = hostname + self._username = username + targ_name = "host/" + self._gss_host + self._gss_srv_ctxt = sspi.ServerAuth("Kerberos", spn=targ_name) + error, token = self._gss_srv_ctxt.authorize(recv_token) + token = token[0].Buffer + if error == 0: + self._gss_srv_ctxt_status = True + token = None + 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 sspi.error: if the MIC check failed + """ + self._session_id = session_id + self._username = username + if username is not None: + # server mode + mic_field = self._ssh_build_mic(self._session_id, + self._username, + self._service, + self._auth_method) + # Verifies data and its signature. If verification fails, an + # sspi.error will be raised. + self._gss_srv_ctxt.verify(mic_field, mic_token) + else: + # for key exchange with gssapi-keyex + # client mode + # Verifies data and its signature. If verification fails, an + # sspi.error will be raised. + self._gss_ctxt.verify(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: Boolean + """ + return ( + self._gss_flags & sspicon.ISC_REQ_DELEGATE + ) and ( + self._gss_srv_ctxt_status or (self._gss_flags) + ) + + 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 credentails if credentials are delegated + (server mode). + + :param str client_token: The SSPI token received form the client + :raise NotImplementedError: Credential delegation is currently not + supported in server mode + """ + raise NotImplementedError diff --git a/paramiko/transport.py b/paramiko/transport.py index cbbdb79f..18fb103b 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -20,17 +20,21 @@ Core protocol implementation """ +from __future__ import print_function +import os import socket import sys import threading import time import weakref +from hashlib import md5, sha1, sha256, sha512 import paramiko from paramiko import util from paramiko.auth_handler import AuthHandler +from paramiko.ssh_gss import GSSAuth from paramiko.channel import Channel -from paramiko.common import rng, xffffffff, cMSG_CHANNEL_OPEN, cMSG_IGNORE, \ +from paramiko.common import xffffffff, cMSG_CHANNEL_OPEN, cMSG_IGNORE, \ cMSG_GLOBAL_REQUEST, DEBUG, MSG_KEXINIT, MSG_IGNORE, MSG_DISCONNECT, \ MSG_DEBUG, ERROR, WARNING, cMSG_UNIMPLEMENTED, INFO, cMSG_KEXINIT, \ cMSG_NEWKEYS, MSG_NEWKEYS, cMSG_REQUEST_SUCCESS, cMSG_REQUEST_FAILURE, \ @@ -40,11 +44,14 @@ from paramiko.common import rng, xffffffff, cMSG_CHANNEL_OPEN, cMSG_IGNORE, \ MSG_CHANNEL_OPEN_SUCCESS, MSG_CHANNEL_OPEN_FAILURE, MSG_CHANNEL_OPEN, \ MSG_CHANNEL_SUCCESS, MSG_CHANNEL_FAILURE, MSG_CHANNEL_DATA, \ MSG_CHANNEL_EXTENDED_DATA, MSG_CHANNEL_WINDOW_ADJUST, MSG_CHANNEL_REQUEST, \ - MSG_CHANNEL_EOF, MSG_CHANNEL_CLOSE + MSG_CHANNEL_EOF, MSG_CHANNEL_CLOSE, MIN_WINDOW_SIZE, MIN_PACKET_SIZE, \ + MAX_WINDOW_SIZE, DEFAULT_WINDOW_SIZE, DEFAULT_MAX_PACKET_SIZE from paramiko.compress import ZlibCompressor, ZlibDecompressor from paramiko.dsskey import DSSKey -from paramiko.kex_gex import KexGex +from paramiko.kex_gex import KexGex, KexGexSHA256 from paramiko.kex_group1 import KexGroup1 +from paramiko.kex_group14 import KexGroup14 +from paramiko.kex_gss import KexGSSGex, KexGSSGroup1, KexGSSGroup14, NullHostKey from paramiko.message import Message from paramiko.packet import Packetizer, NeedRekeyException from paramiko.primes import ModulusPack @@ -55,11 +62,9 @@ from paramiko.server import ServerInterface from paramiko.sftp_client import SFTPClient from paramiko.ssh_exception import (SSHException, BadAuthenticationType, ChannelException, ProxyCommandFailure) -from paramiko.util import retry_on_signal +from paramiko.util import retry_on_signal, ClosingContextManager, clamp_value -from Crypto import Random from Crypto.Cipher import Blowfish, AES, DES3, ARC4 -from Crypto.Hash import SHA, MD5 try: from Crypto.Util import Counter except ImportError: @@ -77,40 +82,124 @@ import atexit atexit.register(_join_lingering_threads) -class Transport (threading.Thread): +class Transport (threading.Thread, ClosingContextManager): """ An SSH Transport attaches to a stream (usually a socket), negotiates an encrypted session, authenticates, and then creates stream tunnels, called `channels <.Channel>`, across the session. Multiple channels can be multiplexed across a single session (and often are, in the case of port forwardings). + + Instances of this class may be used as context managers. """ _PROTO_ID = '2.0' _CLIENT_ID = 'paramiko_%s' % paramiko.__version__ - _preferred_ciphers = ('aes128-ctr', 'aes256-ctr', 'aes128-cbc', 'blowfish-cbc', - 'aes256-cbc', '3des-cbc', 'arcfour128', 'arcfour256') - _preferred_macs = ('hmac-sha1', 'hmac-md5', 'hmac-sha1-96', 'hmac-md5-96') - _preferred_keys = ('ssh-rsa', 'ssh-dss', 'ecdsa-sha2-nistp256') - _preferred_kex = ('diffie-hellman-group1-sha1', 'diffie-hellman-group-exchange-sha1') + # These tuples of algorithm identifiers are in preference order; do not + # reorder without reason! + _preferred_ciphers = ( + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', + 'aes128-cbc', + 'blowfish-cbc', + 'aes192-cbc', + 'aes256-cbc', + '3des-cbc', + 'arcfour128', + 'arcfour256', + ) + _preferred_macs = ( + 'hmac-sha2-256', + 'hmac-sha2-512', + 'hmac-md5', + 'hmac-sha1-96', + 'hmac-md5-96', + 'hmac-sha1', + ) + _preferred_keys = ( + 'ssh-rsa', + 'ssh-dss', + 'ecdsa-sha2-nistp256', + ) + _preferred_kex = ( + 'diffie-hellman-group1-sha1', + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group-exchange-sha1', + 'diffie-hellman-group-exchange-sha256', + ) _preferred_compression = ('none',) _cipher_info = { - 'aes128-ctr': {'class': AES, 'mode': AES.MODE_CTR, 'block-size': 16, 'key-size': 16}, - 'aes256-ctr': {'class': AES, 'mode': AES.MODE_CTR, 'block-size': 16, 'key-size': 32}, - 'blowfish-cbc': {'class': Blowfish, 'mode': Blowfish.MODE_CBC, 'block-size': 8, 'key-size': 16}, - 'aes128-cbc': {'class': AES, 'mode': AES.MODE_CBC, 'block-size': 16, 'key-size': 16}, - 'aes256-cbc': {'class': AES, 'mode': AES.MODE_CBC, 'block-size': 16, 'key-size': 32}, - '3des-cbc': {'class': DES3, 'mode': DES3.MODE_CBC, 'block-size': 8, 'key-size': 24}, - 'arcfour128': {'class': ARC4, 'mode': None, 'block-size': 8, 'key-size': 16}, - 'arcfour256': {'class': ARC4, 'mode': None, 'block-size': 8, 'key-size': 32}, + 'aes128-ctr': { + 'class': AES, + 'mode': AES.MODE_CTR, + 'block-size': 16, + 'key-size': 16 + }, + 'aes192-ctr': { + 'class': AES, + 'mode': AES.MODE_CTR, + 'block-size': 16, + 'key-size': 24 + }, + 'aes256-ctr': { + 'class': AES, + 'mode': AES.MODE_CTR, + 'block-size': 16, + 'key-size': 32 + }, + 'blowfish-cbc': { + 'class': Blowfish, + 'mode': Blowfish.MODE_CBC, + 'block-size': 8, + 'key-size': 16 + }, + 'aes128-cbc': { + 'class': AES, + 'mode': AES.MODE_CBC, + 'block-size': 16, + 'key-size': 16 + }, + 'aes192-cbc': { + 'class': AES, + 'mode': AES.MODE_CBC, + 'block-size': 16, + 'key-size': 24 + }, + 'aes256-cbc': { + 'class': AES, + 'mode': AES.MODE_CBC, + 'block-size': 16, + 'key-size': 32 + }, + '3des-cbc': { + 'class': DES3, + 'mode': DES3.MODE_CBC, + 'block-size': 8, + 'key-size': 24 + }, + 'arcfour128': { + 'class': ARC4, + 'mode': None, + 'block-size': 8, + 'key-size': 16 + }, + 'arcfour256': { + 'class': ARC4, + 'mode': None, + 'block-size': 8, + 'key-size': 32 + }, } _mac_info = { - 'hmac-sha1': {'class': SHA, 'size': 20}, - 'hmac-sha1-96': {'class': SHA, 'size': 12}, - 'hmac-md5': {'class': MD5, 'size': 16}, - 'hmac-md5-96': {'class': MD5, 'size': 12}, + 'hmac-sha1': {'class': sha1, 'size': 20}, + 'hmac-sha1-96': {'class': sha1, 'size': 12}, + 'hmac-sha2-256': {'class': sha256, 'size': 32}, + 'hmac-sha2-512': {'class': sha512, 'size': 64}, + 'hmac-md5': {'class': md5, 'size': 16}, + 'hmac-md5-96': {'class': md5, 'size': 12}, } _key_info = { @@ -121,7 +210,12 @@ class Transport (threading.Thread): _kex_info = { 'diffie-hellman-group1-sha1': KexGroup1, + 'diffie-hellman-group14-sha1': KexGroup14, 'diffie-hellman-group-exchange-sha1': KexGex, + 'diffie-hellman-group-exchange-sha256': KexGexSHA256, + 'gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==': KexGSSGroup1, + 'gss-group14-sha1-toWM5Slw5Ew8Mqkay+al2g==': KexGSSGroup14, + 'gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==': KexGSSGex } _compression_info = { @@ -135,7 +229,12 @@ class Transport (threading.Thread): _modulus_pack = None - def __init__(self, sock): + def __init__(self, + sock, + default_window_size=DEFAULT_WINDOW_SIZE, + default_max_packet_size=DEFAULT_MAX_PACKET_SIZE, + gss_kex=False, + gss_deleg_creds=True): """ Create a new SSH session over an existing socket, or socket-like object. This only creates the `.Transport` object; it doesn't begin the @@ -161,8 +260,24 @@ class Transport (threading.Thread): address and used for communication. Exceptions from the ``socket`` call may be thrown in this case. + .. note:: + Modifying the the window and packet sizes might have adverse + effects on your channels created from this transport. The default + values are the same as in the OpenSSH code base and have been + battle tested. + :param socket sock: a socket or socket-like object to create the session over. + :param int default_window_size: + sets the default window size on the transport. (defaults to + 2097152) + :param int default_max_packet_size: + sets the default max packet size on the transport. (defaults to + 32768) + + .. versionchanged:: 1.15 + Added the ``default_window_size`` and ``default_max_packet_size`` + arguments. """ self.active = False @@ -194,7 +309,6 @@ class Transport (threading.Thread): # okay, normal socket-ish flow here... threading.Thread.__init__(self) self.setDaemon(True) - self.rng = rng self.sock = sock # Python < 2.3 doesn't have the settimeout method - RogerB try: @@ -217,6 +331,21 @@ class Transport (threading.Thread): self.host_key_type = None self.host_key = None + # GSS-API / SSPI Key Exchange + self.use_gss_kex = gss_kex + # This will be set to True if GSS-API Key Exchange was performed + self.gss_kex_used = False + self.kexgss_ctxt = None + self.gss_host = None + if self.use_gss_kex: + self.kexgss_ctxt = GSSAuth("gssapi-keyex", gss_deleg_creds) + self._preferred_kex = ('gss-gex-sha1-toWM5Slw5Ew8Mqkay+al2g==', + 'gss-group14-sha1-toWM5Slw5Ew8Mqkay+al2g==', + 'gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==', + 'diffie-hellman-group-exchange-sha1', + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group1-sha1') + # state used during negotiation self.kex_engine = None self.H = None @@ -233,8 +362,8 @@ class Transport (threading.Thread): self.channel_events = {} # (id -> Event) self.channels_seen = {} # (id -> True) self._channel_counter = 0 - self.window_size = 65536 - self.max_packet_size = 34816 + self.default_max_packet_size = default_max_packet_size + self.default_window_size = default_window_size self._forward_agent_handler = None self._x11_handler = None self._tcp_handler = None @@ -250,6 +379,8 @@ class Transport (threading.Thread): self.global_response = None # response Message from an arbitrary global request self.completion_event = None # user-defined event callbacks self.banner_timeout = 15 # how long (seconds) to wait for the SSH banner + self.handshake_timeout = 15 # how long (seconds) to wait for the handshake to finish after SSH banner sent. + # server mode: self.server_mode = False @@ -289,6 +420,7 @@ class Transport (threading.Thread): .. versionadded:: 1.5.3 """ + self.sock.close() self.close() def get_security_options(self): @@ -300,6 +432,17 @@ class Transport (threading.Thread): """ return SecurityOptions(self) + def set_gss_host(self, gss_host): + """ + Setter for C{gss_host} if GSS-API Key Exchange is performed. + + :param str gss_host: The targets name in the kerberos database + Default: The name of the host to connect to + :rtype: Void + """ + # We need the FQDN to get this working with SSPI + self.gss_host = socket.getfqdn(gss_host) + def start_client(self, event=None): """ Negotiate a new SSH2 session as a client. This is the first step after @@ -320,9 +463,10 @@ class Transport (threading.Thread): .. note:: `connect` is a simpler method for connecting as a client. - .. note:: After calling this method (or `start_server` or `connect`), - you should no longer directly read from or write to the original - socket object. + .. note:: + After calling this method (or `start_server` or `connect`), you + should no longer directly read from or write to the original socket + object. :param .threading.Event event: an event to trigger when negotiation is complete (optional) @@ -340,7 +484,6 @@ class Transport (threading.Thread): # synchronous, wait for a result self.completion_event = event = threading.Event() self.start() - Random.atfork() while True: event.wait(0.1) if not self.active: @@ -348,7 +491,7 @@ class Transport (threading.Thread): if e is not None: raise e raise SSHException('Negotiation failed.') - if event.isSet(): + if event.is_set(): break def start_server(self, event=None, server=None): @@ -413,7 +556,7 @@ class Transport (threading.Thread): if e is not None: raise e raise SSHException('Negotiation failed.') - if event.isSet(): + if event.is_set(): break def add_server_key(self, key): @@ -451,6 +594,7 @@ class Transport (threading.Thread): pass return None + @staticmethod def load_server_moduli(filename=None): """ (optional) @@ -476,7 +620,7 @@ class Transport (threading.Thread): .. note:: This has no effect when used in client mode. """ - Transport._modulus_pack = ModulusPack(rng) + Transport._modulus_pack = ModulusPack() # places to look for the openssh "moduli" file file_list = ['/etc/ssh/moduli', '/usr/local/etc/moduli'] if filename is not None: @@ -490,7 +634,6 @@ class Transport (threading.Thread): # none succeeded Transport._modulus_pack = None return False - load_server_moduli = staticmethod(load_server_moduli) def close(self): """ @@ -530,18 +673,33 @@ class Transport (threading.Thread): """ return self.active - def open_session(self): + def open_session(self, window_size=None, max_packet_size=None, timeout=None): """ Request a new channel to the server, of type ``"session"``. This is just an alias for calling `open_channel` with an argument of ``"session"``. + .. note:: Modifying the the window and packet sizes might have adverse + effects on the session created. The default values are the same + as in the OpenSSH code base and have been battle tested. + + :param int window_size: + optional window size for this session. + :param int max_packet_size: + optional max packet size for this session. + :return: a new `.Channel` :raises SSHException: if the request is rejected or the session ends prematurely + + .. versionchanged:: 1.15 + Added the ``window_size`` and ``max_packet_size`` arguments. """ - return self.open_channel('session') + return self.open_channel('session', + window_size=window_size, + max_packet_size=max_packet_size, + timeout=timeout) def open_x11_channel(self, src_addr=None): """ @@ -583,13 +741,23 @@ class Transport (threading.Thread): """ return self.open_channel('forwarded-tcpip', dest_addr, src_addr) - def open_channel(self, kind, dest_addr=None, src_addr=None): + def open_channel(self, + kind, + dest_addr=None, + src_addr=None, + window_size=None, + max_packet_size=None, + timeout=None): """ Request a new channel to the server. `Channels <.Channel>` are socket-like objects used for the actual transfer of data across the session. You may only request a channel after negotiating encryption (using `connect` or `start_client`) and authenticating. + .. note:: Modifying the the window and packet sizes might have adverse + effects on the channel created. The default values are the same + as in the OpenSSH code base and have been battle tested. + :param str kind: the kind of channel requested (usually ``"session"``, ``"forwarded-tcpip"``, ``"direct-tcpip"``, or ``"x11"``) @@ -599,22 +767,35 @@ class Transport (threading.Thread): ``"direct-tcpip"`` (ignored for other channel types) :param src_addr: the source address of this port forwarding, if ``kind`` is ``"forwarded-tcpip"``, ``"direct-tcpip"``, or ``"x11"`` + :param int window_size: + optional window size for this session. + :param int max_packet_size: + optional max packet size for this session. + :param float timeout: + optional timeout opening a channel, default 3600s (1h) + :return: a new `.Channel` on success - :raises SSHException: if the request is rejected or the session ends - prematurely + :raises SSHException: if the request is rejected, the session ends + prematurely or there is a timeout openning a channel + + .. versionchanged:: 1.15 + Added the ``window_size`` and ``max_packet_size`` arguments. """ if not self.active: raise SSHException('SSH session not active') + timeout = 3600 if timeout is None else timeout self.lock.acquire() try: + window_size = self._sanitize_window_size(window_size) + max_packet_size = self._sanitize_packet_size(max_packet_size) chanid = self._next_channel() m = Message() m.add_byte(cMSG_CHANNEL_OPEN) m.add_string(kind) m.add_int(chanid) - m.add_int(self.window_size) - m.add_int(self.max_packet_size) + m.add_int(window_size) + m.add_int(max_packet_size) if (kind == 'forwarded-tcpip') or (kind == 'direct-tcpip'): m.add_string(dest_addr[0]) m.add_int(dest_addr[1]) @@ -628,10 +809,11 @@ class Transport (threading.Thread): self.channel_events[chanid] = event = threading.Event() self.channels_seen[chanid] = True chan._set_transport(self) - chan._set_window(self.window_size, self.max_packet_size) + chan._set_window(window_size, max_packet_size) finally: self.lock.release() self._send_user_message(m) + start_ts = time.time() while True: event.wait(0.1) if not self.active: @@ -639,8 +821,10 @@ class Transport (threading.Thread): if e is None: e = SSHException('Unable to open channel.') raise e - if event.isSet(): + if event.is_set(): break + elif start_ts + timeout < time.time(): + raise SSHException('Timeout openning channel.') chan = self._channels.get(chanid) if chan is not None: return chan @@ -672,6 +856,7 @@ class Transport (threading.Thread): :param callable handler: optional handler for incoming forwarded connections, of the form ``func(Channel, (str, int), (str, int))``. + :return: the port number (`int`) allocated by the server :raises SSHException: if the server refused the TCP forward request @@ -733,8 +918,8 @@ class Transport (threading.Thread): m = Message() m.add_byte(cMSG_IGNORE) if byte_count is None: - byte_count = (byte_ord(rng.read(1)) % 32) + 10 - m.add_bytes(rng.read(byte_count)) + byte_count = (byte_ord(os.urandom(1)) % 32) + 10 + m.add_bytes(os.urandom(byte_count)) self._send_user_message(m) def renegotiate_keys(self): @@ -758,7 +943,7 @@ class Transport (threading.Thread): if e is not None: raise e raise SSHException('Negotiation failed.') - if self.completion_event.isSet(): + if self.completion_event.is_set(): break return @@ -809,7 +994,7 @@ class Transport (threading.Thread): self.completion_event.wait(0.1) if not self.active: return None - if self.completion_event.isSet(): + if self.completion_event.is_set(): break return self.global_response @@ -838,7 +1023,8 @@ class Transport (threading.Thread): self.lock.release() return chan - def connect(self, hostkey=None, username='', password=None, pkey=None): + def connect(self, hostkey=None, username='', password=None, pkey=None, + gss_host=None, gss_auth=False, gss_kex=False, gss_deleg_creds=True): """ Negotiate an SSH2 session, and optionally verify the server's host key and authenticate using a password or private key. This is a shortcut @@ -868,6 +1054,14 @@ class Transport (threading.Thread): :param .PKey pkey: a private key to use for authentication, if you want to use private key authentication; otherwise ``None``. + :param str gss_host: + The target's name in the kerberos database. Default: hostname + :param bool gss_auth: + ``True`` if you want to use GSS-API authentication. + :param bool gss_kex: + Perform GSS-API Key Exchange and user authentication. + :param bool gss_deleg_creds: + Whether to delegate GSS-API client credentials. :raises SSHException: if the SSH2 negotiation fails, the host key supplied by the server is incorrect, or authentication fails. @@ -878,7 +1072,9 @@ class Transport (threading.Thread): self.start_client() # check host key if we were given one - if hostkey is not None: + # If GSS-API Key Exchange was performed, we are not required to check + # the host key. + if (hostkey is not None) and not gss_kex: key = self.get_remote_server_key() if (key.get_name() != hostkey.get_name()) or (key.asbytes() != hostkey.asbytes()): self._log(DEBUG, 'Bad host key from server') @@ -887,13 +1083,19 @@ class Transport (threading.Thread): raise SSHException('Bad host key from server') self._log(DEBUG, 'Host key verified (%s)' % hostkey.get_name()) - if (pkey is not None) or (password is not None): - if password is not None: - self._log(DEBUG, 'Attempting password auth...') - self.auth_password(username, password) - else: + if (pkey is not None) or (password is not None) or gss_auth or gss_kex: + if gss_auth: + self._log(DEBUG, 'Attempting GSS-API auth... (gssapi-with-mic)') + self.auth_gssapi_with_mic(username, gss_host, gss_deleg_creds) + elif gss_kex: + self._log(DEBUG, 'Attempting GSS-API auth... (gssapi-keyex)') + self.auth_gssapi_keyex(username) + elif pkey is not None: self._log(DEBUG, 'Attempting public-key auth...') self.auth_publickey(username, pkey) + else: + self._log(DEBUG, 'Attempting password auth...') + self.auth_password(username, password) return @@ -1176,6 +1378,76 @@ class Transport (threading.Thread): self.auth_handler.auth_interactive(username, handler, my_event, submethods) return self.auth_handler.wait_for_response(my_event) + def auth_interactive_dumb(self, username, handler=None, submethods=''): + """ + Autenticate to the server interactively but dumber. + Just print the prompt and / or instructions to stdout and send back + the response. This is good for situations where partial auth is + achieved by key and then the user has to enter a 2fac token. + """ + + if not handler: + def handler(title, instructions, prompt_list): + answers = [] + if title: + print(title.strip()) + if instructions: + print(instructions.strip()) + for prompt,show_input in prompt_list: + print(prompt.strip(),end=' ') + answers.append(raw_input()) + return answers + return self.auth_interactive(username, handler, submethods) + + def auth_gssapi_with_mic(self, username, gss_host, gss_deleg_creds): + """ + Authenticate to the Server using GSS-API / SSPI. + + :param str username: The username to authenticate as + :param str gss_host: The target host + :param bool gss_deleg_creds: Delegate credentials or not + :return: list of auth types permissible for the next stage of + authentication (normally empty) + :rtype: list + :raise BadAuthenticationType: if gssapi-with-mic isn't + allowed by the server (and no event was passed in) + :raise AuthenticationException: if the authentication failed (and no + event was passed in) + :raise SSHException: if there was a network error + """ + if (not self.active) or (not self.initial_kex_done): + # we should never try to authenticate unless we're on a secure link + raise SSHException('No existing session') + my_event = threading.Event() + self.auth_handler = AuthHandler(self) + self.auth_handler.auth_gssapi_with_mic(username, gss_host, gss_deleg_creds, my_event) + return self.auth_handler.wait_for_response(my_event) + + def auth_gssapi_keyex(self, username): + """ + Authenticate to the Server with GSS-API / SSPI if GSS-API Key Exchange + was the used key exchange method. + + :param str username: The username to authenticate as + :param str gss_host: The target host + :param bool gss_deleg_creds: Delegate credentials or not + :return: list of auth types permissible for the next stage of + authentication (normally empty) + :rtype: list + :raise BadAuthenticationType: if GSS-API Key Exchange was not performed + (and no event was passed in) + :raise AuthenticationException: if the authentication failed (and no + event was passed in) + :raise SSHException: if there was a network error + """ + if (not self.active) or (not self.initial_kex_done): + # we should never try to authenticate unless we're on a secure link + raise SSHException('No existing session') + my_event = threading.Event() + self.auth_handler = AuthHandler(self) + self.auth_handler.auth_gssapi_keyex(username, my_event) + return self.auth_handler.wait_for_response(my_event) + def set_log_channel(self, name): """ Set the channel for this transport's logging. The default is @@ -1260,7 +1532,7 @@ class Transport (threading.Thread): def stop_thread(self): self.active = False self.packetizer.close() - while self.isAlive(): + while self.is_alive() and (self is not threading.current_thread()): self.join(10) ### internals... @@ -1304,7 +1576,7 @@ class Transport (threading.Thread): self._log(DEBUG, 'Dropping user packet because connection is dead.') return self.clear_to_send_lock.acquire() - if self.clear_to_send.isSet(): + if self.clear_to_send.is_set(): break self.clear_to_send_lock.release() if time.time() > start + self.clear_to_send_timeout: @@ -1340,13 +1612,23 @@ class Transport (threading.Thread): m.add_bytes(self.H) m.add_byte(b(id)) m.add_bytes(self.session_id) - out = sofar = SHA.new(m.asbytes()).digest() + # Fallback to SHA1 for kex engines that fail to specify a hex + # algorithm, or for e.g. transport tests that don't run kexinit. + hash_algo = getattr(self.kex_engine, 'hash_algo', None) + hash_select_msg = "kex engine %s specified hash_algo %r" % (self.kex_engine.__class__.__name__, hash_algo) + if hash_algo is None: + hash_algo = sha1 + hash_select_msg += ", falling back to sha1" + if not hasattr(self, '_logged_hash_selection'): + self._log(DEBUG, hash_select_msg) + setattr(self, '_logged_hash_selection', True) + out = sofar = hash_algo(m.asbytes()).digest() while len(out) < nbytes: m = Message() m.add_mpint(self.K) m.add_bytes(self.H) m.add_bytes(sofar) - digest = SHA.new(m.asbytes()).digest() + digest = hash_algo(m.asbytes()).digest() out += digest sofar += digest return out[:nbytes] @@ -1394,6 +1676,17 @@ class Transport (threading.Thread): finally: self.lock.release() + def _sanitize_window_size(self, window_size): + if window_size is None: + window_size = self.default_window_size + return clamp_value(MIN_WINDOW_SIZE, window_size, MAX_WINDOW_SIZE) + + def _sanitize_packet_size(self, max_packet_size): + if max_packet_size is None: + max_packet_size = self.default_max_packet_size + return clamp_value(MIN_PACKET_SIZE, max_packet_size, MAX_WINDOW_SIZE) + + def run(self): # (use the exposed "run" method, because if we specify a thread target # of a private method, threading.Thread will keep a reference to it @@ -1404,10 +1697,6 @@ class Transport (threading.Thread): # interpreter shutdown. self.sys = sys - # Required to prevent RNG errors when running inside many subprocess - # containers. - Random.atfork() - # active=True occurs before the thread is launched, to avoid a race _active_threads.append(self) if self.server_mode: @@ -1417,7 +1706,16 @@ class Transport (threading.Thread): try: try: self.packetizer.write_all(b(self.local_version + '\r\n')) + self._log(DEBUG, 'Local version/idstring: %s' % self.local_version) self._check_banner() + # The above is actually very much part of the handshake, but + # sometimes the banner can be read but the machine is not + # responding, for example when the remote ssh daemon is loaded + # in to memory but we can not read from the disk/spawn a new + # shell. + # Make sure we can specify a timeout for the initial handshake. + # Re-use the banner timeout for now. + self.packetizer.start_handshake(self.handshake_timeout) self._send_kex_init() self._expect_packet(MSG_KEXINIT) @@ -1442,7 +1740,7 @@ class Transport (threading.Thread): if ptype not in self._expected_packet: raise SSHException('Expecting packet from %r, got %d' % (self._expected_packet, ptype)) self._expected_packet = tuple() - if (ptype >= 30) and (ptype <= 39): + if (ptype >= 30) and (ptype <= 41): self.kex_engine.parse_next(ptype, m) continue @@ -1467,6 +1765,7 @@ class Transport (threading.Thread): msg.add_byte(cMSG_UNIMPLEMENTED) msg.add_int(m.seqno) self._send_message(msg) + self.packetizer.complete_handshake() except SSHException as e: self._log(ERROR, 'Exception: ' + str(e)) self._log(ERROR, util.tb_strings()) @@ -1515,6 +1814,18 @@ class Transport (threading.Thread): if self.sys.modules is not None: raise + + def _log_agreement(self, which, local, remote): + # Log useful, non-duplicative line re: an agreed-upon algorithm. + # Old code implied algorithms could be asymmetrical (different for + # inbound vs outbound) so we preserve that possibility. + msg = "{0} agreed: ".format(which) + if local == remote: + msg += local + else: + msg += "local={0}, remote={1}".format(local, remote) + self._log(DEBUG, msg) + ### protocol stages def _negotiate_keys(self, m): @@ -1552,6 +1863,7 @@ class Transport (threading.Thread): raise SSHException('Indecipherable protocol version "' + buf + '"') # save this server version string for later self.remote_version = buf + self._log(DEBUG, 'Remote version/idstring: %s' % buf) # pull off any attached comment comment = '' i = buf.find(' ') @@ -1580,10 +1892,12 @@ class Transport (threading.Thread): self.clear_to_send_lock.release() self.in_kex = True if self.server_mode: - if (self._modulus_pack is None) and ('diffie-hellman-group-exchange-sha1' in self._preferred_kex): + mp_required_prefix = 'diffie-hellman-group-exchange-sha' + kex_mp = [k for k in self._preferred_kex if k.startswith(mp_required_prefix)] + if (self._modulus_pack is None) and (len(kex_mp) > 0): # can't do group-exchange if we don't have a pack of potential primes - pkex = list(self.get_security_options().kex) - pkex.remove('diffie-hellman-group-exchange-sha1') + pkex = [k for k in self.get_security_options().kex + if not k.startswith(mp_required_prefix)] self.get_security_options().kex = pkex available_server_keys = list(filter(list(self.server_key_dict.keys()).__contains__, self._preferred_keys)) @@ -1592,7 +1906,7 @@ class Transport (threading.Thread): m = Message() m.add_byte(cMSG_KEXINIT) - m.add_bytes(rng.read(16)) + m.add_bytes(os.urandom(16)) m.add_list(self._preferred_kex) m.add_list(available_server_keys) m.add_list(self._preferred_ciphers) @@ -1635,15 +1949,24 @@ class Transport (threading.Thread): ' server lang:' + str(server_lang_list) + ' kex follows?' + str(kex_follows)) - # as a server, we pick the first item in the client's list that we support. - # as a client, we pick the first item in our list that the server supports. + # as a server, we pick the first item in the client's list that we + # support. + # as a client, we pick the first item in our list that the server + # supports. if self.server_mode: - agreed_kex = list(filter(self._preferred_kex.__contains__, kex_algo_list)) + agreed_kex = list(filter( + self._preferred_kex.__contains__, + kex_algo_list + )) else: - agreed_kex = list(filter(kex_algo_list.__contains__, self._preferred_kex)) + agreed_kex = list(filter( + kex_algo_list.__contains__, + self._preferred_kex + )) if len(agreed_kex) == 0: raise SSHException('Incompatible ssh peer (no acceptable kex algorithm)') self.kex_engine = self._kex_info[agreed_kex[0]](self) + self._log(DEBUG, "Kex agreed: %s" % agreed_kex[0]) if self.server_mode: available_server_keys = list(filter(list(self.server_key_dict.keys()).__contains__, @@ -1671,7 +1994,9 @@ class Transport (threading.Thread): raise SSHException('Incompatible ssh server (no acceptable ciphers)') self.local_cipher = agreed_local_ciphers[0] self.remote_cipher = agreed_remote_ciphers[0] - self._log(DEBUG, 'Ciphers agreed: local=%s, remote=%s' % (self.local_cipher, self.remote_cipher)) + self._log_agreement( + 'Cipher', local=self.local_cipher, remote=self.remote_cipher + ) if self.server_mode: agreed_remote_macs = list(filter(self._preferred_macs.__contains__, client_mac_algo_list)) @@ -1683,6 +2008,9 @@ class Transport (threading.Thread): raise SSHException('Incompatible ssh server (no acceptable macs)') self.local_mac = agreed_local_macs[0] self.remote_mac = agreed_remote_macs[0] + self._log_agreement( + 'MAC', local=self.local_mac, remote=self.remote_mac + ) if self.server_mode: agreed_remote_compression = list(filter(self._preferred_compression.__contains__, client_compress_algo_list)) @@ -1694,10 +2022,11 @@ class Transport (threading.Thread): raise SSHException('Incompatible ssh server (no acceptable compression) %r %r %r' % (agreed_local_compression, agreed_remote_compression, self._preferred_compression)) self.local_compression = agreed_local_compression[0] self.remote_compression = agreed_remote_compression[0] - - self._log(DEBUG, 'using kex %s; server key type %s; cipher: local %s, remote %s; mac: local %s, remote %s; compression: local %s, remote %s' % - (agreed_kex[0], self.host_key_type, self.local_cipher, self.remote_cipher, self.local_mac, - self.remote_mac, self.local_compression, self.remote_compression)) + self._log_agreement( + 'Compression', + local=self.local_compression, + remote=self.remote_compression + ) # save for computing hash later... # now wait! openssh has a bug (and others might too) where there are @@ -1718,12 +2047,12 @@ class Transport (threading.Thread): engine = self._get_cipher(self.remote_cipher, key_in, IV_in) 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 potentially truncated - # transmission size) + # initial mac keys are done in the hash's natural size (not the + # potentially truncated transmission size) if self.server_mode: - mac_key = self._compute_key('E', mac_engine.digest_size) + mac_key = self._compute_key('E', mac_engine().digest_size) else: - mac_key = self._compute_key('F', mac_engine.digest_size) + mac_key = self._compute_key('F', mac_engine().digest_size) self.packetizer.set_inbound_cipher(engine, block_size, mac_engine, mac_size, mac_key) compress_in = self._compression_info[self.remote_compression][1] if (compress_in is not None) and ((self.remote_compression != 'zlib@openssh.com') or self.authenticated): @@ -1745,12 +2074,12 @@ class Transport (threading.Thread): engine = self._get_cipher(self.local_cipher, key_out, IV_out) 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 potentially truncated - # transmission size) + # initial mac keys are done in the hash's natural size (not the + # potentially truncated transmission size) if self.server_mode: - mac_key = self._compute_key('F', mac_engine.digest_size) + mac_key = self._compute_key('F', mac_engine().digest_size) else: - mac_key = self._compute_key('E', mac_engine.digest_size) + 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) compress_out = self._compression_info[self.local_compression][0] @@ -1862,7 +2191,7 @@ class Transport (threading.Thread): self.lock.acquire() try: chan._set_remote_channel(server_chanid, server_window_size, server_max_packet_size) - self._log(INFO, 'Secsh channel %d opened.' % chanid) + self._log(DEBUG, 'Secsh channel %d opened.' % chanid) if chanid in self.channel_events: self.channel_events[chanid].set() del self.channel_events[chanid] @@ -1876,7 +2205,7 @@ class Transport (threading.Thread): reason_str = m.get_text() lang = m.get_text() reason_text = CONNECTION_FAILED_CODE.get(reason, '(unknown code)') - self._log(INFO, 'Secsh channel %d open FAILED: %s: %s' % (chanid, reason_str, reason_text)) + self._log(ERROR, 'Secsh channel %d open FAILED: %s: %s' % (chanid, reason_str, reason_text)) self.lock.acquire() try: self.saved_exception = ChannelException(reason, reason_text) @@ -1961,7 +2290,7 @@ class Transport (threading.Thread): self._channels.put(my_chanid, chan) self.channels_seen[my_chanid] = True chan._set_transport(self) - chan._set_window(self.window_size, self.max_packet_size) + chan._set_window(self.default_window_size, self.default_max_packet_size) chan._set_remote_channel(chanid, initial_window_size, max_packet_size) finally: self.lock.release() @@ -1969,10 +2298,10 @@ class Transport (threading.Thread): m.add_byte(cMSG_CHANNEL_OPEN_SUCCESS) m.add_int(chanid) m.add_int(my_chanid) - m.add_int(self.window_size) - m.add_int(self.max_packet_size) + m.add_int(self.default_window_size) + m.add_int(self.default_max_packet_size) self._send_message(m) - self._log(INFO, 'Secsh channel %d (%s) opened.', my_chanid, kind) + self._log(DEBUG, 'Secsh channel %d (%s) opened.', my_chanid, kind) if kind == 'auth-agent@openssh.com': self._forward_agent_handler(chan) elif kind == 'x11': @@ -2045,21 +2374,6 @@ class SecurityOptions (object): """ return '<paramiko.SecurityOptions for %s>' % repr(self._transport) - def _get_ciphers(self): - return self._transport._preferred_ciphers - - def _get_digests(self): - return self._transport._preferred_macs - - def _get_key_types(self): - return self._transport._preferred_keys - - def _get_kex(self): - return self._transport._preferred_kex - - def _get_compression(self): - return self._transport._preferred_compression - def _set(self, name, orig, x): if type(x) is list: x = tuple(x) @@ -2071,30 +2385,51 @@ class SecurityOptions (object): raise ValueError('unknown cipher') setattr(self._transport, name, x) - def _set_ciphers(self, x): + @property + def ciphers(self): + """Symmetric encryption ciphers""" + return self._transport._preferred_ciphers + + @ciphers.setter + def ciphers(self, x): self._set('_preferred_ciphers', '_cipher_info', x) - def _set_digests(self, x): + @property + def digests(self): + """Digest (one-way hash) algorithms""" + return self._transport._preferred_macs + + @digests.setter + def digests(self, x): self._set('_preferred_macs', '_mac_info', x) - def _set_key_types(self, x): + @property + def key_types(self): + """Public-key algorithms""" + return self._transport._preferred_keys + + @key_types.setter + def key_types(self, x): self._set('_preferred_keys', '_key_info', x) - def _set_kex(self, x): + + @property + def kex(self): + """Key exchange algorithms""" + return self._transport._preferred_kex + + @kex.setter + def kex(self, x): self._set('_preferred_kex', '_kex_info', x) - def _set_compression(self, x): - self._set('_preferred_compression', '_compression_info', x) + @property + def compression(self): + """Compression algorithms""" + return self._transport._preferred_compression - ciphers = property(_get_ciphers, _set_ciphers, None, - "Symmetric encryption ciphers") - digests = property(_get_digests, _set_digests, None, - "Digest (one-way hash) algorithms") - key_types = property(_get_key_types, _set_key_types, None, - "Public-key algorithms") - kex = property(_get_kex, _set_kex, None, "Key exchange algorithms") - compression = property(_get_compression, _set_compression, None, - "Compression algorithms") + @compression.setter + def compression(self, x): + self._set('_preferred_compression', '_compression_info', x) class ChannelMap (object): diff --git a/paramiko/util.py b/paramiko/util.py index 46278a69..855e5757 100644 --- a/paramiko/util.py +++ b/paramiko/util.py @@ -118,7 +118,7 @@ def safe_string(s): def bit_length(n): try: - return n.bitlength() + return n.bit_length() except AttributeError: norm = deflate_long(n, False) hbyte = byte_ord(norm[0]) @@ -135,15 +135,14 @@ def tb_strings(): return ''.join(traceback.format_exception(*sys.exc_info())).split('\n') -def generate_key_bytes(hashclass, salt, key, nbytes): +def generate_key_bytes(hash_alg, salt, key, nbytes): """ Given a password, passphrase, or other human-source key, scramble it through a secure hash into some keyworthy bytes. This specific algorithm is used for encrypting/decrypting private key files. - :param class hashclass: - class from `Crypto.Hash` that can be used as a secure hashing function - (like ``MD5`` or ``SHA``). + :param function hash_alg: A function which creates a new hash object, such + as ``hashlib.sha256``. :param salt: data to salt the hash with. :type salt: byte string :param str key: human-entered password or passphrase. @@ -155,7 +154,7 @@ def generate_key_bytes(hashclass, salt, key, nbytes): if len(salt) > 8: salt = salt[:8] while nbytes > 0: - hash_obj = hashclass.new() + hash_obj = hash_alg() if len(digest) > 0: hash_obj.update(digest) hash_obj.update(b(key)) @@ -300,9 +299,9 @@ class Counter (object): self.value = array.array('c', zero_byte * (self.blocksize - len(x)) + x) return self.value.tostring() + @classmethod def new(cls, nbits, initial_value=long(1), overflow=long(0)): return cls(nbits, initial_value=initial_value, overflow=overflow) - new = classmethod(new) def constant_time_bytes_eq(a, b): @@ -313,3 +312,15 @@ def constant_time_bytes_eq(a, b): for i in (xrange if PY2 else range)(len(a)): res |= byte_ord(a[i]) ^ byte_ord(b[i]) return res == 0 + + +class ClosingContextManager(object): + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.close() + + +def clamp_value(minimum, val, maximum): + return max(minimum, min(val, maximum)) @@ -41,8 +41,8 @@ try: from setuptools import setup kw = { 'install_requires': [ - 'pycrypto >= 2.1, != 2.4', - 'ecdsa', + 'pycrypto>=2.1,!=2.4', + 'ecdsa>=0.11', ], } except ImportError: @@ -86,6 +86,8 @@ setup( 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', ], **kw ) diff --git a/setup_helper.py b/setup_helper.py index ff6b0e16..9e3834b3 100644 --- a/setup_helper.py +++ b/setup_helper.py @@ -30,9 +30,42 @@ import distutils.archive_util from distutils.dir_util import mkpath from distutils.spawn import spawn - -def make_tarball(base_name, base_dir, compress='gzip', - verbose=False, dry_run=False): +try: + from pwd import getpwnam +except ImportError: + getpwnam = None + +try: + from grp import getgrnam +except ImportError: + getgrnam = None + +def _get_gid(name): + """Returns a gid, given a group name.""" + if getgrnam is None or name is None: + return None + try: + result = getgrnam(name) + except KeyError: + result = None + if result is not None: + return result[2] + return None + +def _get_uid(name): + """Returns an uid, given a user name.""" + if getpwnam is None or name is None: + return None + try: + result = getpwnam(name) + except KeyError: + result = None + if result is not None: + return result[2] + return None + +def make_tarball(base_name, base_dir, compress='gzip', verbose=0, dry_run=0, + owner=None, group=None): """Create a tar file from all the files under 'base_dir'. This file may be compressed. @@ -75,11 +108,30 @@ def make_tarball(base_name, base_dir, compress='gzip', mkpath(os.path.dirname(archive_name), dry_run=dry_run) log.info('Creating tar file %s with mode %s' % (archive_name, mode)) + uid = _get_uid(owner) + gid = _get_gid(group) + + def _set_uid_gid(tarinfo): + if gid is not None: + tarinfo.gid = gid + tarinfo.gname = group + if uid is not None: + tarinfo.uid = uid + tarinfo.uname = owner + return tarinfo + if not dry_run: tar = tarfile.open(archive_name, mode=mode) # This recursively adds everything underneath base_dir - tar.add(base_dir) - tar.close() + try: + try: + # Support for the `filter' parameter was added in Python 2.7, + # earlier versions will raise TypeError. + tar.add(base_dir, filter=_set_uid_gid) + except TypeError: + tar.add(base_dir) + finally: + tar.close() if compress and compress not in tarfile_compress_flag: spawn([compress] + compress_flags[compress] + [archive_name], diff --git a/sites/docs/api/agent.rst b/sites/docs/api/agent.rst index 3b614a82..f01ad972 100644 --- a/sites/docs/api/agent.rst +++ b/sites/docs/api/agent.rst @@ -1,4 +1,4 @@ -SSH Agents +SSH agents ========== .. automodule:: paramiko.agent diff --git a/sites/docs/api/kex_gss.rst b/sites/docs/api/kex_gss.rst new file mode 100644 index 00000000..9fd09221 --- /dev/null +++ b/sites/docs/api/kex_gss.rst @@ -0,0 +1,5 @@ +GSS-API key exchange +==================== + +.. automodule:: paramiko.kex_gss + :member-order: bysource diff --git a/sites/docs/api/ssh_gss.rst b/sites/docs/api/ssh_gss.rst new file mode 100644 index 00000000..7a687e11 --- /dev/null +++ b/sites/docs/api/ssh_gss.rst @@ -0,0 +1,14 @@ +GSS-API authentication +====================== + +.. automodule:: paramiko.ssh_gss + :member-order: bysource + +.. autoclass:: _SSH_GSSAuth + :member-order: bysource + +.. autoclass:: _SSH_GSSAPI + :member-order: bysource + +.. autoclass:: _SSH_SSPI + :member-order: bysource diff --git a/sites/docs/index.rst b/sites/docs/index.rst index f336b393..87265d95 100644 --- a/sites/docs/index.rst +++ b/sites/docs/index.rst @@ -50,6 +50,8 @@ Authentication & keys api/agent api/hostkeys api/keys + api/ssh_gss + api/kex_gss Other primary functions diff --git a/sites/shared_conf.py b/sites/shared_conf.py index 4a6a5c4e..99fab315 100644 --- a/sites/shared_conf.py +++ b/sites/shared_conf.py @@ -12,7 +12,6 @@ html_theme_options = { 'description': "A Python implementation of SSHv2.", 'github_user': 'paramiko', 'github_repo': 'paramiko', - 'gratipay_user': 'bitprophet', 'analytics_id': 'UA-18486793-2', 'travis_button': True, } diff --git a/sites/www/blog.py b/sites/www/blog.py deleted file mode 100644 index 3b129ebf..00000000 --- a/sites/www/blog.py +++ /dev/null @@ -1,140 +0,0 @@ -from collections import namedtuple -from datetime import datetime -import time -import email.utils - -from sphinx.util.compat import Directive -from docutils import nodes - - -class BlogDateDirective(Directive): - """ - Used to parse/attach date info to blog post documents. - - No nodes generated, since none are needed. - """ - has_content = True - - def run(self): - # Tag parent document with parsed date value. - self.state.document.blog_date = datetime.strptime( - self.content[0], "%Y-%m-%d" - ) - # Don't actually insert any nodes, we're already done. - return [] - -class blog_post_list(nodes.General, nodes.Element): - pass - -class BlogPostListDirective(Directive): - """ - Simply spits out a 'blog_post_list' temporary node for replacement. - - Gets replaced at doctree-resolved time - only then will all blog post - documents be written out (& their date directives executed). - """ - def run(self): - return [blog_post_list('')] - - -Post = namedtuple('Post', 'name doc title date opener') - -def get_posts(app): - # Obtain blog posts - post_names = filter(lambda x: x.startswith('blog/'), app.env.found_docs) - posts = map(lambda x: (x, app.env.get_doctree(x)), post_names) - # Obtain common data used for list page & RSS - data = [] - for post, doc in sorted(posts, key=lambda x: x[1].blog_date, reverse=True): - # Welp. No "nice" way to get post title. Thanks Sphinx. - title = doc[0][0][0] - # Date. This may or may not end up reflecting the required - # *input* format, but doing it here gives us flexibility. - date = doc.blog_date - # 1st paragraph as opener. TODO: allow a role or something marking - # where to actually pull from? - opener = doc.traverse(nodes.paragraph)[0] - data.append(Post(post, doc, title, date, opener)) - return data - -def replace_blog_post_lists(app, doctree, fromdocname): - """ - Replace blog_post_list nodes with ordered list-o-links to posts. - """ - # Obtain blog posts - post_names = filter(lambda x: x.startswith('blog/'), app.env.found_docs) - posts = map(lambda x: (x, app.env.get_doctree(x)), post_names) - # Build "list" of links/etc - post_links = [] - for post, doc, title, date, opener in get_posts(app): - # Link itself - uri = app.builder.get_relative_uri(fromdocname, post) - link = nodes.reference('', '', refdocname=post, refuri=uri) - # Title, bolded. TODO: use 'topic' or something maybe? - link.append(nodes.strong('', title)) - date = date.strftime("%Y-%m-%d") - # Meh @ not having great docutils nodes which map to this. - html = '<div class="timestamp"><span>%s</span></div>' % date - timestamp = nodes.raw(text=html, format='html') - # NOTE: may group these within another element later if styling - # necessitates it - group = [timestamp, nodes.paragraph('', '', link), opener] - post_links.extend(group) - - # Replace temp node(s) w/ expanded list-o-links - for node in doctree.traverse(blog_post_list): - node.replace_self(post_links) - -def rss_timestamp(timestamp): - # Use horribly inappropriate module for its magical daylight-savings-aware - # timezone madness. Props to Tinkerer for the idea. - return email.utils.formatdate( - time.mktime(timestamp.timetuple()), - localtime=True - ) - -def generate_rss(app): - # Meh at having to run this subroutine like 3x per build. Not worth trying - # to be clever for now tho. - posts_ = get_posts(app) - # LOL URLs - root = app.config.rss_link - if not root.endswith('/'): - root += '/' - # Oh boy - posts = [ - ( - root + app.builder.get_target_uri(x.name), - x.title, - str(x.opener[0]), # Grab inner text element from paragraph - rss_timestamp(x.date), - ) - for x in posts_ - ] - location = 'blog/rss.xml' - context = { - 'title': app.config.project, - 'link': root, - 'atom': root + location, - 'description': app.config.rss_description, - # 'posts' is sorted by date already - 'date': rss_timestamp(posts_[0].date), - 'posts': posts, - } - yield (location, context, 'rss.xml') - -def setup(app): - # Link in RSS feed back to main website, e.g. 'http://paramiko.org' - app.add_config_value('rss_link', None, '') - # Ditto for RSS description field - app.add_config_value('rss_description', None, '') - # Interprets date metadata in blog post documents - app.add_directive('date', BlogDateDirective) - # Inserts blog post list node (in e.g. a listing page) for replacement - # below - app.add_node(blog_post_list) - app.add_directive('blog-posts', BlogPostListDirective) - # Performs abovementioned replacement - app.connect('doctree-resolved', replace_blog_post_lists) - # Generates RSS page from whole cloth at page generation step - app.connect('html-collect-pages', generate_rss) diff --git a/sites/www/blog.rst b/sites/www/blog.rst deleted file mode 100644 index af9651e4..00000000 --- a/sites/www/blog.rst +++ /dev/null @@ -1,16 +0,0 @@ -==== -Blog -==== - -.. blog-posts directive gets replaced with an ordered list of blog posts. - -.. blog-posts:: - - -.. The following toctree ensures blog posts get processed. - -.. toctree:: - :hidden: - :glob: - - blog/* diff --git a/sites/www/blog/first-post.rst b/sites/www/blog/first-post.rst deleted file mode 100644 index 7b075073..00000000 --- a/sites/www/blog/first-post.rst +++ /dev/null @@ -1,7 +0,0 @@ -=========== -First post! -=========== - -A blog post. - -.. date:: 2013-12-04 diff --git a/sites/www/blog/second-post.rst b/sites/www/blog/second-post.rst deleted file mode 100644 index c4463f33..00000000 --- a/sites/www/blog/second-post.rst +++ /dev/null @@ -1,7 +0,0 @@ -=========== -Another one -=========== - -.. date:: 2013-12-05 - -Indeed! diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index 9ce2eded..6a605216 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -2,18 +2,144 @@ Changelog ========= +* :support:`621 backported` Annotate some public attributes on + `~paramiko.channel.Channel` such as ``.closed``. Thanks to Sergey Vasilyev + for the report. +* :bug:`632` Fix logic bug in the SFTP client's callback-calling functionality; + previously there was a chance the given callback would fire twice at the end + of a transfer. Thanks to ``@ab9-er`` for catch & original patch. +* :support:`612` Identify & work around a race condition in the test for + handshake timeouts, which was causing frequent test failures for a subset of + contributors as well as Travis-CI (usually, but not always, limited to Python + 3.5). Props to Ed Kellett for assistance during some of the troubleshooting. +* :support:`697` Remove whitespace in our ``setup.py``'s ``install_requires`` + as it triggers occasional bugs in some versions of ``setuptools``. Thanks to + Justin Lecher for catch & original patch. +* :bug:`499` Strip trailing/leading whitespace from lines when parsing SSH + config files - this brings things in line with OpenSSH behavior. Thanks to + Alfredo Esteban for the original report and Nick Pillitteri for the patch. +* :bug:`652` Fix behavior of ``gssapi-with-mic`` auth requests so they fail + gracefully (allowing followup via other auth methods) instead of raising an + exception. Patch courtesy of ``@jamercee``. +* :support:`636` Clean up and enhance the README (and rename it to + ``README.rst`` from just ``README``). Thanks to ``@LucasRMehl``. +* :release:`1.16.0 <2015-11-04>` +* :bug:`194 major` (also :issue:`562`, :issue:`530`, :issue:`576`) Streamline + use of ``stat`` when downloading SFTP files via `SFTPClient.get + <paramiko.sftp_client.SFTPClient.get>`; this avoids triggering bugs in some + off-spec SFTP servers such as IBM Sterling. Thanks to ``@muraleee`` for the + initial report and to Torkil Gustavsen for the patch. +* :feature:`467` (also :issue:`139`, :issue:`412`) Fully enable two-factor + authentication (e.g. when a server requires ``AuthenticationMethods + pubkey,keyboard-interactive``). Thanks to ``@perryjrandall`` for the patch + and to ``@nevins-b`` and Matt Robenolt for additional support. +* :bug:`502 major` Fix 'exec' requests in server mode to use ``get_string`` + instead of ``get_text`` to avoid ``UnicodeDecodeError`` on non-UTF-8 input. + Thanks to Anselm Kruis for the patch & discussion. +* :bug:`401` Fix line number reporting in log output regarding invalid + ``known_hosts`` line entries. Thanks to Dylan Thacker-Smith for catch & + patch. +* :support:`525 backported` Update the vendored Windows API addon to a more + recent edition. Also fixes :issue:`193`, :issue:`488`, :issue:`498`. Thanks + to Jason Coombs. +* :release:`1.15.4 <2015-11-02>` +* :release:`1.14.3 <2015-11-02>` +* :release:`1.13.4 <2015-11-02>` +* :bug:`366` Fix `~paramiko.sftp_attributes.SFTPAttributes` so its string + representation doesn't raise exceptions on empty/initialized instances. Patch + by Ulrich Petri. +* :bug:`359` Use correct attribute name when trying to use Python 3's + ``int.bit_length`` method; prior to fix, the Python 2 custom fallback + implementation was always used, even on Python 3. Thanks to Alex Gaynor. +* :support:`594 backported` Correct some post-Python3-port docstrings to + specify ``bytes`` type instead of ``str``. Credit to ``@redixin``. +* :bug:`565` Don't explode with ``IndexError`` when reading private key files + lacking an ``-----END <type> PRIVATE KEY-----`` footer. Patch courtesy of + Prasanna Santhanam. +* :feature:`604` Add support for the ``aes192-ctr`` and ``aes192-cbc`` ciphers. + Thanks to Michiel Tiller for noticing it was as easy as tweaking some key + sizes :D +* :feature:`356` (also :issue:`596`, :issue:`365`, :issue:`341`, :issue:`164`, + :issue:`581`, and a bunch of other duplicates besides) Add support for SHA-2 + based key exchange (kex) algorithm ``diffie-hellman-group-exchange-sha256`` + and (H)MAC algorithms ``hmac-sha2-256`` and ``hmac-sha2-512``. + + This change includes tweaks to debug-level logging regarding + algorithm-selection handshakes; the old all-in-one log line is now multiple + easier-to-read, printed-at-handshake-time log lines. + + Thanks to the many people who submitted patches for this functionality and/or + assisted in testing those patches. That list includes but is not limited to, + and in no particular order: Matthias Witte, Dag Wieers, Ash Berlin, Etienne + Perot, Gert van Dijk, ``@GuyShaanan``, Aaron Bieber, ``@cyphase``, and Eric + Brown. +* :release:`1.15.3 <2015-10-02>` +* :support:`554 backported` Fix inaccuracies in the docstring for the ECDSA key + class. Thanks to Jared Hance for the patch. +* :support:`516 backported` Document `~paramiko.agent.AgentRequestHandler`. + Thanks to ``@toejough`` for report & suggestions. +* :bug:`496` Fix a handful of small but critical bugs in Paramiko's GSSAPI + support (note: this includes switching from PyCrypo's Random to + `os.urandom`). Thanks to Anselm Kruis for catch & patch. +* :bug:`491` (combines :issue:`62` and :issue:`439`) Implement timeout + functionality to address hangs from dropped network connections and/or failed + handshakes. Credit to ``@vazir`` and ``@dacut`` for the original patches and + to Olle Lundberg for reimplementation. +* :bug:`490` Skip invalid/unparseable lines in ``known_hosts`` files, instead + of raising `~paramiko.ssh_exception.SSHException`. This brings Paramiko's + behavior more in line with OpenSSH, which silently ignores such input. Catch + & patch courtesy of Martin Topholm. +* :bug:`404` Print details when displaying + `~paramiko.ssh_exception.BadHostKeyException` objects (expected vs received + data) instead of just "hey shit broke". Patch credit: Loic Dachary. +* :bug:`469` (also :issue:`488`, :issue:`461` and like a dozen others) Fix a + typo introduced in the 1.15 release which broke WinPageant support. Thanks to + everyone who submitted patches, and to Steve Cohen who was the lucky winner + of the cherry-pick lottery. +* :bug:`353` (via :issue:`482`) Fix a bug introduced in the Python 3 port + which caused ``OverFlowError`` (and other symptoms) in SFTP functionality. + Thanks to ``@dboreham`` for leading the troubleshooting charge, and to + Scott Maxwell for the final patch. +* :support:`582` Fix some old ``setup.py`` related helper code which was + breaking ``bdist_dumb`` on Mac OS X. Thanks to Peter Odding for the patch. +* :bug:`22 major` Try harder to connect to multiple network families (e.g. IPv4 + vs IPv6) in case of connection issues; this helps with problems such as hosts + which resolve both IPv4 and IPv6 addresses but are only listening on IPv4. + Thanks to Dries Desmet for original report and Torsten Landschoff for the + foundational patchset. +* :bug:`402` Check to see if an SSH agent is actually present before trying to + forward it to the remote end. This replaces what was usually a useless + ``TypeError`` with a human-readable + `~paramiko.ssh_exception.AuthenticationException`. Credit to Ken Jordan for + the fix and Yvan Marques for original report. +* :release:`1.15.2 <2014-12-19>` +* :release:`1.14.2 <2014-12-19>` * :release:`1.13.3 <2014-12-19>` * :bug:`413` (also :issue:`414`, :issue:`420`, :issue:`454`) Be significantly smarter about polling & timing behavior when running proxy commands, to avoid unnecessary (often 100%!) CPU usage. Major thanks to Jason Dunsmore for report & initial patchset and to Chris Adams & John Morrissey for followup improvements. +* :bug:`455` Tweak packet size handling to conform better to the OpenSSH RFCs; + this helps address issues with interactive program cursors. Courtesy of Jeff + Quast. * :bug:`428` Fix an issue in `~paramiko.file.BufferedFile` (primarily used in the SFTP modules) concerning incorrect behavior by `~paramiko.file.BufferedFile.readlines` on files whose size exceeds the buffer size. Thanks to ``@achapp`` for catch & patch. +* :bug:`415` Fix ``ssh_config`` parsing to correctly interpret ``ProxyCommand + none`` as the lack of a proxy command, instead of as a literal command string + of ``"none"``. Thanks to Richard Spiers for the catch & Sean Johnson for the + fix. +* :support:`431 backported` Replace handrolled ``ssh_config`` parsing code with + use of the ``shlex`` module. Thanks to Yan Kalchevskiy. * :support:`422 backported` Clean up some unused imports. Courtesy of Olle Lundberg. +* :support:`421 backported` Modernize threading calls to use newer API. Thanks + to Olle Lundberg. +* :support:`419 backported` Modernize a bunch of the codebase internals to + leverage decorators. Props to ``@beckjake`` for realizing we're no longer on + Python 2.2 :D * :bug:`266` Change numbering of `~paramiko.transport.Transport` channels to start at 0 instead of 1 for better compatibility with OpenSSH & certain server implementations which break on 1-indexed channels. Thanks to @@ -27,10 +153,70 @@ Changelog for the catch. * :bug:`320` Update our win_pageant module to be Python 3 compatible. Thanks to ``@sherbang`` and ``@adamkerz`` for the patches. +* :release:`1.15.1 <2014-09-22>` +* :bug:`399` SSH agent forwarding (potentially other functionality as + well) would hang due to incorrect values passed into the new window size + arguments for `.Transport` (thanks to a botched merge). This has been + corrected. Thanks to Dylan Thacker-Smith for the report & patch. +* :feature:`167` Add `~paramiko.config.SSHConfig.get_hostnames` for easier + introspection of a loaded SSH config file or object. Courtesy of Søren + Løvborg. +* :release:`1.15.0 <2014-09-18>` +* :support:`393` Replace internal use of PyCrypto's ``SHA.new`` with the + stdlib's ``hashlib.sha1``. Thanks to Alex Gaynor. +* :feature:`267` (also :issue:`250`, :issue:`241`, :issue:`228`) Add GSS-API / + SSPI (e.g. Kerberos) key exchange and authentication support + (:ref:`installation docs here <gssapi>`). Mega thanks to Sebastian Deiß, with + assist by Torsten Landschoff. + + .. note:: + Unix users should be aware that the ``python-gssapi`` library (a + requirement for using this functionality) only appears to support + Python 2.7 and up at this time. + +* :bug:`346 major` Fix an issue in private key files' encryption salts that + could cause tracebacks and file corruption if keys were re-encrypted. Credit + to Xavier Nunn. +* :feature:`362` Allow users to control the SSH banner timeout. Thanks to Cory + Benfield. +* :feature:`372` Update default window & packet sizes to more closely adhere to + the pertinent RFC; also expose these settings in the public API so they may + be overridden by client code. This should address some general speed issues + such as :issue:`175`. Big thanks to Olle Lundberg for the update. +* :bug:`373 major` Attempt to fix a handful of issues (such as :issue:`354`) + related to infinite loops and threading deadlocks. Thanks to Olle Lundberg as + well as a handful of community members who provided advice & feedback via + IRC. +* :support:`374` (also :issue:`375`) Old code cleanup courtesy of Olle + Lundberg. +* :support:`377` Factor `~paramiko.channel.Channel` openness sanity check into + a decorator. Thanks to Olle Lundberg for original patch. +* :bug:`298 major` Don't perform point validation on ECDSA keys in + ``known_hosts`` files, since a) this can cause significant slowdown when such + keys exist, and b) ``known_hosts`` files are implicitly trustworthy. Thanks + to Kieran Spear for catch & patch. + + .. note:: + This change bumps up the version requirement for the ``ecdsa`` library to + ``0.11``. + +* :bug:`234 major` Lower logging levels for a few overly-noisy log messages + about secure channels. Thanks to David Pursehouse for noticing & contributing + the fix. +* :feature:`218` Add support for ECDSA private keys on the client side. Thanks + to ``@aszlig`` for the patch. +* :bug:`335 major` Fix ECDSA key generation (generation of brand new ECDSA keys + was broken previously). Thanks to ``@solarw`` for catch & patch. +* :feature:`184` Support quoted values in SSH config file parsing. Credit to + Yan Kalchevskiy. +* :feature:`131` Add a `~paramiko.sftp_client.SFTPClient.listdir_iter` method + to `~paramiko.sftp_client.SFTPClient` allowing for more efficient, + async/generator based file listings. Thanks to John Begeman. * :support:`378 backported` Minor code cleanup in the SSH config module courtesy of Olle Lundberg. * :support:`249 backported` Consolidate version information into one spot. Thanks to Gabi Davar for the reminder. +* :release:`1.14.1 <2014-08-25>` * :release:`1.13.2 <2014-08-25>` * :bug:`376` Be less aggressive about expanding variables in ``ssh_config`` files, which results in a speedup of SSH config parsing. Credit to Olle @@ -57,6 +243,9 @@ Changelog Thanks to ``@basictheprogram`` for the initial report, Jelmer Vernooij for the fix and Andrew Starr-Bochicchio & Jeremy T. Bouse (among others) for discussion & feedback. +* :support:`371` Add Travis support & docs update for Python 3.4. Thanks to + Olle Lundberg. +* :release:`1.14.0 <2014-05-07>` * :release:`1.13.1 <2014-05-07>` * :release:`1.12.4 <2014-05-07>` * :release:`1.11.6 <2014-05-07>` @@ -81,6 +270,12 @@ Changelog character. Thanks to Antoine Brenner. * :bug:`308` Fix regression in dsskey.py that caused sporadic signature verification failures. Thanks to Chris Rose. +* :support:`299` Use deterministic signatures for ECDSA keys for improved + security. Thanks to Alex Gaynor. +* :support:`297` Replace PyCrypto's ``Random`` with `os.urandom` for improved + speed and security. Thanks again to Alex. +* :support:`295` Swap out a bunch of PyCrypto hash functions with use of + `hashlib`. Thanks to Alex Gaynor. * :support:`290` (also :issue:`292`) Add support for building universal (Python 2+3 compatible) wheel files during the release process. Courtesy of Alex Gaynor. @@ -94,7 +289,8 @@ Changelog * :release:`1.11.5 <2014-03-13>` * :release:`1.10.7 <2014-03-13>` * :feature:`16` **Python 3 support!** Our test suite passes under Python 3, and - it (& Fabric's test suite) continues to pass under Python 2. + it (& Fabric's test suite) continues to pass under Python 2. **Python 2.5 is + no longer supported with this change!** The merged code was built on many contributors' efforts, both code & feedback. In no particular order, we thank Daniel Goertzen, Ivan Kolodyazhny, diff --git a/sites/www/conf.py b/sites/www/conf.py index c7828203..0b0fb85c 100644 --- a/sites/www/conf.py +++ b/sites/www/conf.py @@ -6,24 +6,16 @@ from os.path import abspath, join, dirname sys.path.append(abspath(join(dirname(__file__), '..'))) from shared_conf import * -# Local blog extension -sys.path.append(abspath('.')) -extensions.append('blog') -rss_link = 'http://paramiko.org' -rss_description = 'Paramiko project news' - # Releases changelog extension extensions.append('releases') -releases_release_uri = "https://github.com/paramiko/paramiko/tree/%s" +# Paramiko 1.x tags start with 'v'. Meh. +releases_release_uri = "https://github.com/paramiko/paramiko/tree/v%s" releases_issue_uri = "https://github.com/paramiko/paramiko/issues/%s" -# Intersphinx for referencing API/usage docs -extensions.append('sphinx.ext.intersphinx') # Default is 'local' building, but reference the public docs site when building # under RTD. target = join(dirname(__file__), '..', 'docs', '_build') if os.environ.get('READTHEDOCS') == 'True': - # TODO: switch to docs.paramiko.org post go-live of sphinx API docs target = 'http://docs.paramiko.org/en/latest/' intersphinx_mapping['docs'] = (target, None) diff --git a/sites/www/contact.rst b/sites/www/contact.rst index 2b6583f5..7e6c947e 100644 --- a/sites/www/contact.rst +++ b/sites/www/contact.rst @@ -9,3 +9,4 @@ following ways: * Mailing list: ``paramiko@librelist.com`` (see `the LibreList homepage <http://librelist.com>`_ for usage details). * This website - a blog section is forthcoming. +* Submit contributions on Github - see the :doc:`contributing` page. diff --git a/sites/www/contributing.rst b/sites/www/contributing.rst index 2b752cc5..a44414e8 100644 --- a/sites/www/contributing.rst +++ b/sites/www/contributing.rst @@ -5,15 +5,22 @@ Contributing How to get the code =================== -Our primary Git repository is on Github at `paramiko/paramiko -<https://github.com/paramiko/paramiko>`_; please follow their instructions for -cloning to your local system. (If you intend to submit patches/pull requests, -we recommend forking first, then cloning your fork. Github has excellent -documentation for all this.) +Our primary Git repository is on Github at `paramiko/paramiko`_; +please follow their instructions for cloning to your local system. (If you +intend to submit patches/pull requests, we recommend forking first, then +cloning your fork. Github has excellent documentation for all this.) How to submit bug reports or new code ===================================== Please see `this project-agnostic contribution guide -<http://contribution-guide.org>`_ - we follow it explicitly. +<http://contribution-guide.org>`_ - we follow it explicitly. Again, our code +repository and bug tracker is `on Github`_. + +Our current changelog is located in ``sites/www/changelog.rst`` - the top +level files like ``ChangeLog.*`` and ``NEWS`` are historical only. + + +.. _paramiko/paramiko: +.. _on Github: https://github.com/paramiko/paramiko diff --git a/sites/www/faq.rst b/sites/www/faq.rst new file mode 100644 index 00000000..a5d9b383 --- /dev/null +++ b/sites/www/faq.rst @@ -0,0 +1,26 @@ +=================================== +Frequently Asked/Answered Questions +=================================== + +Which version should I use? I see multiple active releases. +=========================================================== + +Please see :ref:`the installation docs <release-lines>` which have an explicit +section about this topic. + +Paramiko doesn't work with my Cisco, Windows or other non-Unix system! +====================================================================== + +In an ideal world, the developers would love to support every possible target +system. Unfortunately, volunteer development time and access to non-mainstream +platforms are limited, meaning that we can only fully support standard OpenSSH +implementations such as those found on the average Linux distribution (as well +as on Mac OS X and \*BSD.) + +Because of this, **we typically close bug reports for nonstandard SSH +implementations or host systems**. + +However, **closed does not imply locked** - affected users can still post +comments on such tickets - and **we will always consider actual patch +submissions for these issues**, provided they can get +1s from similarly +affected users and are proven to not break existing functionality. diff --git a/sites/www/index.rst b/sites/www/index.rst index 0f07d7e9..8e7562af 100644 --- a/sites/www/index.rst +++ b/sites/www/index.rst @@ -1,7 +1,7 @@ Welcome to Paramiko! ==================== -Paramiko is a Python (2.5+) implementation of the SSHv2 protocol [#]_, +Paramiko is a Python (2.6+, 3.3+) implementation of the SSHv2 protocol [#]_, providing both client and server functionality. While it leverages a Python C extension for low level cryptography (`PyCrypto <http://pycrypto.org>`_), Paramiko itself is a pure Python interface around SSH networking concepts. @@ -11,30 +11,22 @@ contribution guidelines, development roadmap, news/blog, and so forth. Detailed usage and API documentation can be found at our code documentation site, `docs.paramiko.org <http://docs.paramiko.org>`_. +Please see the sidebar to the left to begin. + .. toctree:: :hidden: changelog + FAQs <faq> installing contributing contact -.. Hide blog in hidden toctree for now (to avoid warnings.) - -.. toctree:: - :hidden: - - blog - .. rubric:: Footnotes .. [#] - SSH is defined in RFCs - `4251 <http://www.rfc-editor.org/rfc/rfc4251.txt>`_, - `4252 <http://www.rfc-editor.org/rfc/rfc4252.txt>`_, - `4253 <http://www.rfc-editor.org/rfc/rfc4253.txt>`_, and - `4254 <http://www.rfc-editor.org/rfc/rfc4254.txt>`_; - the primary working implementation of the protocol is the `OpenSSH project + SSH is defined in :rfc:`4251`, :rfc:`4252`, :rfc:`4253` and :rfc:`4254`. The + primary working implementation of the protocol is the `OpenSSH project <http://openssh.org>`_. Paramiko implements a large portion of the SSH feature set, but there are occasional gaps. diff --git a/sites/www/installing.rst b/sites/www/installing.rst index 0ca9b156..a657c3fc 100644 --- a/sites/www/installing.rst +++ b/sites/www/installing.rst @@ -2,6 +2,8 @@ Installing ========== +.. _paramiko-itself: + Paramiko itself =============== @@ -14,50 +16,47 @@ via `pip <http://pip-installer.org>`_:: Users who want the bleeding edge can install the development version via ``pip install paramiko==dev``. -We currently support **Python 2.5/2.6/2.7**, with support for Python 3 coming -soon. Users on Python 2.4 or older are urged to upgrade. Paramiko *may* work on -Python 2.4 still, but there is no longer any support guarantee. +We currently support **Python 2.6, 2.7 and 3.3+** (Python **3.2** should also +work but has a less-strong compatibility guarantee from us.) Users on Python +2.5 or older are urged to upgrade. -Paramiko has two dependencies: the pure-Python ECDSA module ``ecdsa``, and the +Paramiko has two hard dependencies: the pure-Python ECDSA module ``ecdsa``, and the PyCrypto C extension. ``ecdsa`` is easily installable from wherever you obtained Paramiko's package; PyCrypto may require more work. Read on for details. -PyCrypto -======== - -`PyCrypto <https://www.dlitz.net/software/pycrypto/>`_ provides the low-level -(C-based) encryption algorithms we need to implement the SSH protocol. There -are a couple gotchas associated with installing PyCrypto: its compatibility -with Python's package tools, and the fact that it is a C-based extension. +If you need GSS-API / SSPI support, see :ref:`the below subsection on it +<gssapi>` for details on additional dependencies. -.. _pycrypto-and-pip: +.. _release-lines: -Possible gotcha on older Python and/or pip versions ---------------------------------------------------- +Release lines +------------- -We strongly recommend using ``pip`` to as it is newer and generally better than -``easy_install``. However, a combination of bugs in specific (now rather old) -versions of Python, ``pip`` and PyCrypto can prevent installation of PyCrypto. -Specifically: +Users desiring stability may wish to pin themselves to a specific release line +once they first start using Paramiko; to assist in this, we guarantee bugfixes +for the last 2-3 releases including the latest stable one. -* Python = 2.5.x -* PyCrypto >= 2.1 (required for most modern versions of Paramiko) -* ``pip`` < 0.8.1 +If you're unsure which version to install, we have suggestions: -When all three criteria are met, you may encounter ``No such file or -directory`` IOErrors when trying to ``pip install paramiko`` or ``pip install -PyCrypto``. +* **Completely new users** should always default to the **latest stable + release** (as above, whatever is newest / whatever shows up with ``pip + install paramiko``.) +* **Users upgrading from a much older version** (e.g. the 1.7.x line) should + probably get the **oldest actively supported line** (see the paragraph above + this list for what that currently is.) +* **Everybody else** is hopefully already "on" a given version and can + carefully upgrade to whichever version they care to, when their release line + stops being supported. -The fix is to make sure at least one of the above criteria is not met, by doing -the following (in order of preference): -* Upgrade to ``pip`` 0.8.1 or above, e.g. by running ``pip install -U pip``. -* Upgrade to Python 2.6 or above. -* Downgrade to Paramiko 1.7.6 or 1.7.7, which do not require PyCrypto >= 2.1, - and install PyCrypto 2.0.1 (the oldest version on PyPI which works with - Paramiko 1.7.6/1.7.7) +PyCrypto +======== +`PyCrypto <https://www.dlitz.net/software/pycrypto/>`_ provides the low-level +(C-based) encryption algorithms we need to implement the SSH protocol. There +are a couple gotchas associated with installing PyCrypto: its compatibility +with Python's package tools, and the fact that it is a C-based extension. C extension ----------- @@ -103,3 +102,31 @@ installation of Paramiko via ``pypm``:: Installing paramiko-1.7.8 Installing pycrypto-2.4 C:\> + + +.. _gssapi: + +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. +* **All platforms** need `pyasn1 <https://pypi.python.org/pypi/pyasn1>`_ + ``0.1.7`` or better. +* **Unix** needs `python-gssapi <https://pypi.python.org/pypi/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. + +.. 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 @@ -3,14 +3,20 @@ from os.path import join from shutil import rmtree, copytree from invoke import Collection, ctask as task -from invocations.docs import docs, www +from invocations.docs import docs, www, sites from invocations.packaging import publish # Until we move to spec-based testing @task -def test(ctx): - ctx.run("python test.py --verbose", pty=True) +def test(ctx, coverage=False, flags=""): + if "--verbose" not in flags.split(): + flags += " --verbose" + runner = "python" + if coverage: + runner = "coverage run --source=paramiko" + ctx.run("{0} test.py {1}".format(runner, flags), pty=True) + @task def coverage(ctx): @@ -19,17 +25,21 @@ def coverage(ctx): # Until we stop bundling docs w/ releases. Need to discover use cases first. @task -def release(ctx): +def release(ctx, sdist=True, wheel=True): + """ + Wraps invocations.packaging.release to add baked-in docs folder. + """ # Build docs first. Use terribad workaround pending invoke #146 ctx.run("inv docs") # Move the built docs into where Epydocs used to live target = 'docs' rmtree(target, ignore_errors=True) - copytree(docs_build, target) + # TODO: make it easier to yank out this config val from the docs coll + copytree('sites/docs/_build', target) # Publish - publish(ctx) + publish(ctx, sdist=sdist, wheel=wheel) # Remind print("\n\nDon't forget to update RTD's versions page for new minor releases!") -ns = Collection(test, coverage, release, docs, www) +ns = Collection(test, coverage, release, docs, www, sites) @@ -44,6 +44,10 @@ from tests.test_packetizer import PacketizerTest from tests.test_auth import AuthTest from tests.test_transport import TransportTest from tests.test_client import SSHClientTest +from test_client import SSHClientTest +from test_gssapi import GSSAPITest +from test_ssh_gss import GSSAuthTest +from test_kex_gss import GSSKexTest default_host = 'localhost' default_user = os.environ.get('USER', 'nobody') @@ -85,6 +89,17 @@ def main(): help='skip SFTP client/server tests, which can be slow') parser.add_option('--no-big-file', action='store_false', dest='use_big_file', default=True, help='skip big file SFTP tests, which are slow as molasses') + parser.add_option('--gssapi-test', action='store_true', dest='gssapi_test', default=False, + help='Test the used APIs for GSS-API / SSPI authentication') + parser.add_option('--test-gssauth', action='store_true', dest='test_gssauth', default=False, + help='Test GSS-API / SSPI authentication for SSHv2. To test this, you need kerberos a infrastructure.\ + Note: Paramiko needs access to your krb5.keytab file. Make it readable for Paramiko or\ + copy the used key to another file and set the environment variable KRB5_KTNAME to this file.') + parser.add_option('--test-gssapi-keyex', action='store_true', dest='test_gsskex', default=False, + help='Test GSS-API / SSPI authenticated iffie-Hellman Key Exchange and user\ + authentication. To test this, you need kerberos a infrastructure.\ + Note: Paramiko needs access to your krb5.keytab file. Make it readable for Paramiko or\ + copy the used key to another file and set the environment variable KRB5_KTNAME to this file.') parser.add_option('-R', action='store_false', dest='use_loopback_sftp', default=True, help='perform SFTP tests against a remote server (by default, SFTP tests ' + 'are done through a loopback socket)') @@ -101,12 +116,22 @@ def main(): parser.add_option('-P', '--sftp-passwd', dest='password', type='string', default=default_passwd, metavar='<password>', help='[with -R] (optional) password to unlock the private key for remote sftp tests') - + parser.add_option('--krb5_principal', dest='krb5_principal', type='string', + metavar='<krb5_principal>', + help='The krb5 principal (your username) for GSS-API / SSPI authentication') + parser.add_option('--targ_name', dest='targ_name', type='string', + metavar='<targ_name>', + help='Target name for GSS-API / SSPI authentication.\ + This is the hosts name you are running the test on in the kerberos database.') + parser.add_option('--server_mode', action='store_true', dest='server_mode', default=False, + help='Usage with --gssapi-test. Test the available GSS-API / SSPI server mode to.\ + Note: you need to have access to the kerberos keytab file.') + options, args = parser.parse_args() - + # setup logging paramiko.util.log_to_file('test.log') - + if options.use_sftp: from tests.test_sftp import SFTPTest if options.use_loopback_sftp: @@ -136,6 +161,15 @@ def main(): suite.addTest(unittest.makeSuite(SFTPTest)) if options.use_big_file: suite.addTest(unittest.makeSuite(BigSFTPTest)) + if options.gssapi_test: + GSSAPITest.init(options.targ_name, options.server_mode) + suite.addTest(unittest.makeSuite(GSSAPITest)) + if options.test_gssauth: + GSSAuthTest.init(options.krb5_principal, options.targ_name) + suite.addTest(unittest.makeSuite(GSSAuthTest)) + if options.test_gsskex: + GSSKexTest.init(options.krb5_principal, options.targ_name) + suite.addTest(unittest.makeSuite(GSSKexTest)) verbosity = 1 if options.verbose: verbosity = 2 @@ -149,10 +183,7 @@ def main(): # TODO: make that not a problem, jeez for thread in threading.enumerate(): if thread is not threading.currentThread(): - if PY2: - thread._Thread__stop() - else: - thread._stop() + thread.join(timeout=1) # Exit correctly if not result.wasSuccessful(): sys.exit(1) diff --git a/tests/stub_sftp.py b/tests/stub_sftp.py index 47644433..24380ba1 100644 --- a/tests/stub_sftp.py +++ b/tests/stub_sftp.py @@ -21,6 +21,7 @@ A stub SFTP server for loopback SFTP testing. """ import os +import sys from paramiko import ServerInterface, SFTPServerInterface, SFTPServer, SFTPAttributes, \ SFTPHandle, SFTP_OK, AUTH_SUCCESSFUL, OPEN_SUCCEEDED from paramiko.common import o666 diff --git a/tests/test_auth.py b/tests/test_auth.py index 1d972d53..ec78e3ce 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -118,12 +118,12 @@ class AuthTest (unittest.TestCase): self.ts.add_server_key(host_key) self.event = threading.Event() self.server = NullServer() - self.assertTrue(not self.event.isSet()) + self.assertTrue(not self.event.is_set()) self.ts.start_server(self.event, self.server) def verify_finished(self): self.event.wait(1.0) - self.assertTrue(self.event.isSet()) + self.assertTrue(self.event.is_set()) self.assertTrue(self.ts.is_active()) def test_1_bad_auth_type(self): diff --git a/tests/test_buffered_pipe.py b/tests/test_buffered_pipe.py index a53081a9..eeb4d0ad 100644 --- a/tests/test_buffered_pipe.py +++ b/tests/test_buffered_pipe.py @@ -22,10 +22,10 @@ Some unit tests for BufferedPipe. import threading import time +import unittest from paramiko.buffered_pipe import BufferedPipe, PipeTimeout from paramiko import pipe - -from tests.util import ParamikoTest +from paramiko.py3compat import b def delay_thread(p): @@ -40,7 +40,7 @@ def close_thread(p): p.close() -class BufferedPipeTest(ParamikoTest): +class BufferedPipeTest(unittest.TestCase): def test_1_buffered_pipe(self): p = BufferedPipe() self.assertTrue(not p.read_ready()) @@ -48,12 +48,12 @@ class BufferedPipeTest(ParamikoTest): self.assertTrue(p.read_ready()) data = p.read(6) self.assertEqual(b'hello.', data) - + p.feed('plus/minus') self.assertEqual(b'plu', p.read(3)) self.assertEqual(b's/m', p.read(3)) self.assertEqual(b'inus', p.read(4)) - + p.close() self.assertTrue(not p.read_ready()) self.assertEqual(b'', p.read(1)) diff --git a/tests/test_client.py b/tests/test_client.py index 7e5c80b4..f71efd5a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,6 +20,8 @@ Some unit tests for SSHClient. """ +from __future__ import with_statement + import socket from tempfile import mkstemp import threading @@ -27,12 +29,25 @@ import unittest import weakref import warnings import os +import time from tests.util import test_path import paramiko -from paramiko.common import PY2 +from paramiko.common import PY2, b +from paramiko.ssh_exception import SSHException + + +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', +} class NullServer (paramiko.ServerInterface): + def __init__(self, *args, **kwargs): + # Allow tests to enable/disable specific key types + self.__allowed_keys = kwargs.pop('allowed_keys', []) + super(NullServer, self).__init__(*args, **kwargs) def get_allowed_auths(self, username): if username == 'slowdive': @@ -45,7 +60,14 @@ class NullServer (paramiko.ServerInterface): return paramiko.AUTH_FAILED def check_auth_publickey(self, username, key): - if (key.get_name() == 'ssh-dss') and key.get_fingerprint() == b'\x44\x78\xf0\xb9\xa2\x3c\xc5\x18\x20\x09\xff\x75\x5b\xc1\xd2\x6c': + try: + expected = FINGERPRINTS[key.get_name()] + except KeyError: + return paramiko.AUTH_FAILED + if ( + key.get_name() in self.__allowed_keys and + key.get_fingerprint() == expected + ): return paramiko.AUTH_SUCCESSFUL return paramiko.AUTH_FAILED @@ -53,7 +75,7 @@ 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 @@ -72,32 +94,46 @@ class SSHClientTest (unittest.TestCase): if hasattr(self, attr): getattr(self, attr).close() - def _run(self): + def _run(self, allowed_keys=None, delay=0): + if allowed_keys is None: + allowed_keys = FINGERPRINTS.keys() self.socks, addr = self.sockl.accept() self.ts = paramiko.Transport(self.socks) host_key = paramiko.RSAKey.from_private_key_file(test_path('test_rsa.key')) self.ts.add_server_key(host_key) - server = NullServer() + server = NullServer(allowed_keys=allowed_keys) + if delay: + time.sleep(delay) self.ts.start_server(self.event, server) - def test_1_client(self): + def _test_connection(self, **kwargs): """ - verify that the SSHClient stuff works too. + (Most) kwargs get passed directly into SSHClient.connect(). + + The exception is ``allowed_keys`` which is stripped and handed to the + ``NullServer`` used for testing. """ - threading.Thread(target=self._run).start() + run_kwargs = {'allowed_keys': kwargs.pop('allowed_keys', None)} + # Server setup + threading.Thread(target=self._run, kwargs=run_kwargs).start() host_key = paramiko.RSAKey.from_private_key_file(test_path('test_rsa.key')) public_host_key = paramiko.RSAKey(data=host_key.asbytes()) + # Client setup self.tc = paramiko.SSHClient() self.tc.get_host_keys().add('[%s]:%d' % (self.addr, self.port), 'ssh-rsa', public_host_key) - self.tc.connect(self.addr, self.port, username='slowdive', password='pygmalion') + # Actual connection + self.tc.connect(self.addr, self.port, username='slowdive', **kwargs) + + # Authentication successful? self.event.wait(1.0) - self.assertTrue(self.event.isSet()) + self.assertTrue(self.event.is_set()) self.assertTrue(self.ts.is_active()) self.assertEqual('slowdive', self.ts.get_username()) self.assertEqual(True, self.ts.is_authenticated()) + # Command execution functions? stdin, stdout, stderr = self.tc.exec_command('yes') schan = self.ts.accept(1.0) @@ -110,61 +146,77 @@ class SSHClientTest (unittest.TestCase): self.assertEqual('This is on stderr.\n', stderr.readline()) self.assertEqual('', stderr.readline()) + # Cleanup stdin.close() stdout.close() stderr.close() + def test_1_client(self): + """ + verify that the SSHClient stuff works too. + """ + self._test_connection(password='pygmalion') + def test_2_client_dsa(self): """ verify that SSHClient works with a DSA key. """ - threading.Thread(target=self._run).start() - host_key = paramiko.RSAKey.from_private_key_file(test_path('test_rsa.key')) - public_host_key = paramiko.RSAKey(data=host_key.asbytes()) + self._test_connection(key_filename=test_path('test_dss.key')) - self.tc = paramiko.SSHClient() - self.tc.get_host_keys().add('[%s]:%d' % (self.addr, self.port), 'ssh-rsa', public_host_key) - self.tc.connect(self.addr, self.port, username='slowdive', key_filename=test_path('test_dss.key')) - - self.event.wait(1.0) - self.assertTrue(self.event.isSet()) - self.assertTrue(self.ts.is_active()) - self.assertEqual('slowdive', self.ts.get_username()) - self.assertEqual(True, self.ts.is_authenticated()) - - stdin, stdout, stderr = self.tc.exec_command('yes') - schan = self.ts.accept(1.0) - - schan.send('Hello there.\n') - schan.send_stderr('This is on stderr.\n') - schan.close() - - self.assertEqual('Hello there.\n', stdout.readline()) - self.assertEqual('', stdout.readline()) - self.assertEqual('This is on stderr.\n', stderr.readline()) - self.assertEqual('', stderr.readline()) + def test_client_rsa(self): + """ + verify that SSHClient works with an RSA key. + """ + self._test_connection(key_filename=test_path('test_rsa.key')) - stdin.close() - stdout.close() - stderr.close() + def test_2_5_client_ecdsa(self): + """ + verify that SSHClient works with an ECDSA key. + """ + self._test_connection(key_filename=test_path('test_ecdsa.key')) def test_3_multiple_key_files(self): """ verify that SSHClient accepts and tries multiple key files. """ - threading.Thread(target=self._run).start() - host_key = paramiko.RSAKey.from_private_key_file(test_path('test_rsa.key')) - public_host_key = paramiko.RSAKey(data=host_key.asbytes()) - - self.tc = paramiko.SSHClient() - self.tc.get_host_keys().add('[%s]:%d' % (self.addr, self.port), 'ssh-rsa', public_host_key) - self.tc.connect(self.addr, self.port, username='slowdive', key_filename=[test_path('test_rsa.key'), test_path('test_dss.key')]) - - self.event.wait(1.0) - self.assertTrue(self.event.isSet()) - self.assertTrue(self.ts.is_active()) - self.assertEqual('slowdive', self.ts.get_username()) - self.assertEqual(True, self.ts.is_authenticated()) + # This is dumb :( + types_ = { + 'rsa': 'ssh-rsa', + 'dss': 'ssh-dss', + 'ecdsa': 'ecdsa-sha2-nistp256', + } + # Various combos of attempted & valid keys + # TODO: try every possible combo using itertools functions + for attempt, accept in ( + (['rsa', 'dss'], ['dss']), # Original test #3 + (['dss', 'rsa'], ['dss']), # Ordering matters sometimes, sadly + (['dss', 'rsa', 'ecdsa'], ['dss']), # Try ECDSA but fail + (['rsa', 'ecdsa'], ['ecdsa']), # ECDSA success + ): + try: + self._test_connection( + key_filename=[ + test_path('test_{0}.key'.format(x)) for x in attempt + ], + allowed_keys=[types_[x] for x in accept], + ) + finally: + # Clean up to avoid occasional gc-related deadlocks. + # TODO: use nose test generators after nose port + self.tearDown() + self.setUp() + + def test_multiple_key_files_failure(self): + """ + Expect failure when multiple keys in play and none are accepted + """ + # Until #387 is fixed we have to catch a high-up exception since + # various platforms trigger different errors here >_< + self.assertRaises(SSHException, + self._test_connection, + key_filename=[test_path('test_rsa.key')], + allowed_keys=['ecdsa-sha2-nistp256'], + ) def test_4_auto_add_policy(self): """ @@ -180,7 +232,7 @@ class SSHClientTest (unittest.TestCase): self.tc.connect(self.addr, self.port, username='slowdive', password='pygmalion') self.event.wait(1.0) - self.assertTrue(self.event.isSet()) + self.assertTrue(self.event.is_set()) self.assertTrue(self.ts.is_active()) self.assertEqual('slowdive', self.ts.get_username()) self.assertEqual(True, self.ts.is_authenticated()) @@ -221,6 +273,8 @@ class SSHClientTest (unittest.TestCase): """ # Unclear why this is borked on Py3, but it is, and does not seem worth # pursuing at the moment. + # XXX: It's the release of the references to e.g packetizer that fails + # in py3... if not PY2: return threading.Thread(target=self._run).start() @@ -233,7 +287,7 @@ class SSHClientTest (unittest.TestCase): self.tc.connect(self.addr, self.port, username='slowdive', password='pygmalion') self.event.wait(1.0) - self.assertTrue(self.event.isSet()) + self.assertTrue(self.event.is_set()) self.assertTrue(self.ts.is_active()) p = weakref.ref(self.tc._transport.packetizer) @@ -252,3 +306,47 @@ class SSHClientTest (unittest.TestCase): gc.collect() self.assertTrue(p() is None) + + def test_client_can_be_used_as_context_manager(self): + """ + verify that an SSHClient can be used a context manager + """ + threading.Thread(target=self._run).start() + host_key = paramiko.RSAKey.from_private_key_file(test_path('test_rsa.key')) + public_host_key = paramiko.RSAKey(data=host_key.asbytes()) + + with paramiko.SSHClient() as tc: + self.tc = tc + self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.assertEquals(0, len(self.tc.get_host_keys())) + self.tc.connect(self.addr, self.port, username='slowdive', password='pygmalion') + + self.event.wait(1.0) + self.assertTrue(self.event.is_set()) + self.assertTrue(self.ts.is_active()) + + self.assertTrue(self.tc._transport is not None) + + self.assertTrue(self.tc._transport is None) + + def test_7_banner_timeout(self): + """ + verify that the SSHClient has a configurable banner timeout. + """ + # Start the thread with a 1 second wait. + threading.Thread(target=self._run, kwargs={'delay': 1}).start() + host_key = paramiko.RSAKey.from_private_key_file(test_path('test_rsa.key')) + public_host_key = paramiko.RSAKey(data=host_key.asbytes()) + + self.tc = paramiko.SSHClient() + self.tc.get_host_keys().add('[%s]:%d' % (self.addr, self.port), 'ssh-rsa', public_host_key) + # Connect with a half second banner timeout. + self.assertRaises( + paramiko.SSHException, + self.tc.connect, + self.addr, + self.port, + username='slowdive', + password='pygmalion', + banner_timeout=0.5 + ) diff --git a/tests/test_gssapi.py b/tests/test_gssapi.py new file mode 100644 index 00000000..96c268d9 --- /dev/null +++ b/tests/test_gssapi.py @@ -0,0 +1,135 @@ +# Copyright (C) 2013-2014 science + computing ag +# Author: Sebastian Deiss <sebastian.deiss@t-online.de> +# +# +# 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. + +""" +Test the used APIs for GSS-API / SSPI authentication +""" + +import unittest +import socket + + +class GSSAPITest(unittest.TestCase): + @staticmethod + def init(hostname=None, srv_mode=False): + global krb5_mech, targ_name, server_mode + krb5_mech = "1.2.840.113554.1.2.2" + targ_name = hostname + server_mode = srv_mode + + def test_1_pyasn1(self): + """ + Test the used methods of pyasn1. + """ + from pyasn1.type.univ import ObjectIdentifier + from pyasn1.codec.der import encoder, decoder + oid = encoder.encode(ObjectIdentifier(krb5_mech)) + mech, __ = decoder.decode(oid) + self.assertEquals(krb5_mech, mech.__str__()) + + def test_2_gssapi_sspi(self): + """ + Test the used methods of python-gssapi or sspi, sspicon from pywin32. + """ + _API = "MIT" + try: + import gssapi + except ImportError: + import sspicon + import sspi + _API = "SSPI" + + c_token = None + gss_ctxt_status = False + mic_msg = b"G'day Mate!" + + if _API == "MIT": + if server_mode: + gss_flags = (gssapi.C_PROT_READY_FLAG, + gssapi.C_INTEG_FLAG, + gssapi.C_MUTUAL_FLAG, + gssapi.C_DELEG_FLAG) + else: + gss_flags = (gssapi.C_PROT_READY_FLAG, + gssapi.C_INTEG_FLAG, + gssapi.C_DELEG_FLAG) + # Initialize a GSS-API context. + ctx = gssapi.Context() + ctx.flags = gss_flags + krb5_oid = gssapi.OID.mech_from_string(krb5_mech) + target_name = gssapi.Name("host@" + targ_name, + gssapi.C_NT_HOSTBASED_SERVICE) + gss_ctxt = gssapi.InitContext(peer_name=target_name, + mech_type=krb5_oid, + req_flags=ctx.flags) + if server_mode: + c_token = gss_ctxt.step(c_token) + gss_ctxt_status = gss_ctxt.established + self.assertEquals(False, gss_ctxt_status) + # Accept a GSS-API context. + gss_srv_ctxt = gssapi.AcceptContext() + s_token = gss_srv_ctxt.step(c_token) + gss_ctxt_status = gss_srv_ctxt.established + 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.established: + c_token = gss_ctxt.step(c_token) + self.assertNotEquals(None, c_token) + # Build MIC + mic_token = gss_ctxt.get_mic(mic_msg) + + if server_mode: + # Check MIC + status = gss_srv_ctxt.verify_mic(mic_msg, mic_token) + self.assertEquals(0, status) + else: + gss_flags = sspicon.ISC_REQ_INTEGRITY |\ + sspicon.ISC_REQ_MUTUAL_AUTH |\ + sspicon.ISC_REQ_DELEGATE + # Initialize a GSS-API context. + target_name = "host/" + socket.getfqdn(targ_name) + gss_ctxt = sspi.ClientAuth("Kerberos", + scflags=gss_flags, + targetspn=target_name) + if server_mode: + error, token = gss_ctxt.authorize(c_token) + c_token = token[0].Buffer + self.assertEquals(0, error) + # Accept a GSS-API context. + gss_srv_ctxt = sspi.ServerAuth("Kerberos", spn=target_name) + error, token = gss_srv_ctxt.authorize(c_token) + s_token = token[0].Buffer + # Establish the context. + error, token = gss_ctxt.authorize(s_token) + c_token = token[0].Buffer + self.assertEquals(None, c_token) + self.assertEquals(0, error) + # Build MIC + mic_token = gss_ctxt.sign(mic_msg) + # Check MIC + gss_srv_ctxt.verify(mic_msg, mic_token) + else: + error, token = gss_ctxt.authorize(c_token) + c_token = token[0].Buffer + self.assertNotEquals(0, error) diff --git a/tests/test_hostkeys.py b/tests/test_hostkeys.py index 0ee1bbf0..2bdcad9c 100644 --- a/tests/test_hostkeys.py +++ b/tests/test_hostkeys.py @@ -31,6 +31,7 @@ test_hosts_file = """\ secure.example.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA1PD6U2/TVxET6lkpKhOk5r\ 9q/kAYG6sP9f5zuUYP8i7FOFp/6ncCEbbtg/lB+A3iidyxoSWl+9jtoyyDOOVX4UIDV9G11Ml8om3\ D+jrpI9cycZHqilK0HmxDeCuxbwyMuaCygU9gS2qoRvNLWZk70OpIKSSpBo0Wl3/XUmz9uhc= +broken.example.com ssh-rsa AAAA happy.example.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA8bP1ZA7DCZDB9J0s50l31M\ BGQ3GQ/Fc7SX6gkpXkwcZryoi4kNFhHu5LvHcZPdxXV1D+uTMfGS1eyd2Yz/DoNWXNAl8TI0cAsW\ 5ymME3bQ4J/k1IKxCtz/bAlAqFgKoc+EolMziDYqWIATtW0rYTJvzGAzTmMj80/QpsFH+Pc2M= diff --git a/tests/test_kex.py b/tests/test_kex.py index c522be46..19804fbf 100644 --- a/tests/test_kex.py +++ b/tests/test_kex.py @@ -21,17 +21,18 @@ Some unit tests for the key exchange protocols. """ from binascii import hexlify +import os import unittest + import paramiko.util from paramiko.kex_group1 import KexGroup1 -from paramiko.kex_gex import KexGex +from paramiko.kex_gex import KexGex, KexGexSHA256 from paramiko import Message from paramiko.common import byte_chr -class FakeRng (object): - def read(self, n): - return byte_chr(0xcc) * n +def dummy_urandom(n): + return byte_chr(0xcc) * n class FakeKey (object): @@ -41,7 +42,7 @@ class FakeKey (object): def asbytes(self): return b'fake-key' - def sign_ssh_data(self, rng, H): + def sign_ssh_data(self, H): return b'fake-sig' @@ -53,8 +54,7 @@ class FakeModulusPack (object): return self.G, self.P -class FakeTransport (object): - rng = FakeRng() +class FakeTransport(object): local_version = 'SSH-2.0-paramiko_1.0' remote_version = 'SSH-2.0-lame' local_kex_init = 'local-kex-init' @@ -91,10 +91,11 @@ class KexTest (unittest.TestCase): K = 14730343317708716439807310032871972459448364195094179797249681733965528989482751523943515690110179031004049109375612685505881911274101441415545039654102474376472240501616988799699744135291070488314748284283496055223852115360852283821334858541043710301057312858051901453919067023103730011648890038847384890504 def setUp(self): - pass + self._original_urandom = os.urandom + os.urandom = dummy_urandom def tearDown(self): - pass + os.urandom = self._original_urandom def test_1_group1_client(self): transport = FakeTransport() @@ -251,3 +252,121 @@ class KexTest (unittest.TestCase): 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): + transport = FakeTransport() + transport.server_mode = False + kex = KexGexSHA256(transport) + kex.start_kex() + x = b'22000004000000080000002000' + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_GROUP,), transport._expect) + + msg = Message() + msg.add_mpint(FakeModulusPack.P) + msg.add_mpint(FakeModulusPack.G) + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_GROUP, msg) + x = b'20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4' + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_REPLY,), transport._expect) + + msg = Message() + msg.add_string('fake-host-key') + msg.add_mpint(69) + msg.add_string('fake-sig') + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REPLY, msg) + H = b'AD1A9365A67B4496F05594AD1BF656E3CDA0851289A4C1AFF549FEAE50896DF4' + self.assertEqual(self.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_8_gex_sha256_old_client(self): + transport = FakeTransport() + transport.server_mode = False + kex = KexGexSHA256(transport) + kex.start_kex(_test_old_style=True) + x = b'1E00000800' + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_GROUP,), transport._expect) + + msg = Message() + msg.add_mpint(FakeModulusPack.P) + msg.add_mpint(FakeModulusPack.G) + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_GROUP, msg) + x = b'20000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D4' + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_REPLY,), transport._expect) + + msg = Message() + msg.add_string('fake-host-key') + msg.add_mpint(69) + msg.add_string('fake-sig') + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REPLY, msg) + H = b'518386608B15891AE5237DEE08DCADDE76A0BCEFCE7F6DB3AD66BC41D256DFE5' + self.assertEqual(self.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_9_gex_sha256_server(self): + transport = FakeTransport() + transport.server_mode = True + kex = KexGexSHA256(transport) + kex.start_kex() + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST, paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST_OLD), transport._expect) + + msg = Message() + msg.add_int(1024) + msg.add_int(2048) + msg.add_int(4096) + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST, msg) + x = b'1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102' + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_INIT,), transport._expect) + + msg = Message() + msg.add_mpint(12345) + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_INIT, msg) + K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 + H = b'CCAC0497CF0ABA1DBF55E1A3995D17F4CC31824B0E8D95CDF8A06F169D050D80' + x = b'210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967' + 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): + transport = FakeTransport() + transport.server_mode = True + kex = KexGexSHA256(transport) + kex.start_kex() + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST, paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST_OLD), transport._expect) + + msg = Message() + msg.add_int(2048) + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_REQUEST_OLD, msg) + x = b'1F0000008100FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF0000000102' + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertEqual((paramiko.kex_gex._MSG_KEXDH_GEX_INIT,), transport._expect) + + msg = Message() + msg.add_mpint(12345) + msg.rewind() + kex.parse_next(paramiko.kex_gex._MSG_KEXDH_GEX_INIT, msg) + K = 67592995013596137876033460028393339951879041140378510871612128162185209509220726296697886624612526735888348020498716482757677848959420073720160491114319163078862905400020959196386947926388406687288901564192071077389283980347784184487280885335302632305026248574716290537036069329724382811853044654824945750581 + H = b'3DDD2AD840AD095E397BA4D0573972DC60F6461FD38A187CACA6615A5BC8ADBB' + x = b'210000000866616B652D6B6579000000807E2DDB1743F3487D6545F04F1C8476092FB912B013626AB5BCEB764257D88BBA64243B9F348DF7B41B8C814A995E00299913503456983FFB9178D3CD79EB6D55522418A8ABF65375872E55938AB99A84A0B5FC8A1ECC66A7C3766E7E0F80B7CE2C9225FC2DD683F4764244B72963BBB383F529DCF0C5D17740B8A2ADBE9208D40000000866616B652D736967' + self.assertEqual(K, transport._K) + self.assertEqual(H, hexlify(transport._H).upper()) + self.assertEqual(x, hexlify(transport._message.asbytes()).upper()) + self.assertTrue(transport._activated) + + diff --git a/tests/test_kex_gss.py b/tests/test_kex_gss.py new file mode 100644 index 00000000..3bf788da --- /dev/null +++ b/tests/test_kex_gss.py @@ -0,0 +1,131 @@ +# Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com> +# Copyright (C) 2013-2014 science + computing ag +# Author: Sebastian Deiss <sebastian.deiss@t-online.de> +# +# +# 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. + +""" +Unit Tests for the GSS-API / SSPI SSHv2 Diffie-Hellman Key Exchange and user +authentication +""" + + +import socket +import threading +import unittest + +import paramiko + + +class NullServer (paramiko.ServerInterface): + + def get_allowed_auths(self, username): + return 'gssapi-keyex' + + def check_auth_gssapi_keyex(self, username, + gss_authenticated=paramiko.AUTH_FAILED, + cc_file=None): + if gss_authenticated == paramiko.AUTH_SUCCESSFUL: + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def enable_auth_gssapi(self): + UseGSSAPI = True + return UseGSSAPI + + def check_channel_request(self, kind, chanid): + return paramiko.OPEN_SUCCEEDED + + def check_channel_exec_request(self, channel, command): + if command != 'yes': + return False + return True + + +class GSSKexTest(unittest.TestCase): + @staticmethod + def init(username, hostname): + global krb5_principal, targ_name + krb5_principal = username + targ_name = hostname + + def setUp(self): + self.username = krb5_principal + self.hostname = socket.getfqdn(targ_name) + self.sockl = socket.socket() + self.sockl.bind((targ_name, 0)) + self.sockl.listen(1) + self.addr, self.port = self.sockl.getsockname() + self.event = threading.Event() + thread = threading.Thread(target=self._run) + thread.start() + + def tearDown(self): + for attr in "tc ts socks sockl".split(): + if hasattr(self, attr): + getattr(self, attr).close() + + def _run(self): + self.socks, addr = self.sockl.accept() + 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) + try: + self.ts.load_server_moduli() + except: + print ('(Failed to load moduli -- gex will be unsupported.)') + server = NullServer() + self.ts.start_server(self.event, server) + + def test_1_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 + context created during key exchange. + """ + host_key = paramiko.RSAKey.from_private_key_file('tests/test_rsa.key') + public_host_key = paramiko.RSAKey(data=host_key.asbytes()) + + self.tc = paramiko.SSHClient() + self.tc.get_host_keys().add('[%s]:%d' % (self.hostname, self.port), + 'ssh-rsa', public_host_key) + self.tc.connect(self.hostname, self.port, username=self.username, + gss_auth=True, gss_kex=True) + + self.event.wait(1.0) + self.assert_(self.event.is_set()) + self.assert_(self.ts.is_active()) + self.assertEquals(self.username, self.ts.get_username()) + self.assertEquals(True, self.ts.is_authenticated()) + + stdin, stdout, stderr = self.tc.exec_command('yes') + schan = self.ts.accept(1.0) + + schan.send('Hello there.\n') + schan.send_stderr('This is on stderr.\n') + schan.close() + + self.assertEquals('Hello there.\n', stdout.readline()) + self.assertEquals('', stdout.readline()) + self.assertEquals('This is on stderr.\n', stderr.readline()) + self.assertEquals('', stderr.readline()) + + stdin.close() + stdout.close() + stderr.close() diff --git a/tests/test_message.py b/tests/test_message.py index f308c037..f18cae90 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -92,12 +92,12 @@ class MessageTest (unittest.TestCase): def test_4_misc(self): msg = Message(self.__d) - self.assertEqual(msg.get_int(), 5) - self.assertEqual(msg.get_int(), 0x1122334455) - self.assertEqual(msg.get_int(), 0xf00000000000000000) + self.assertEqual(msg.get_adaptive_int(), 5) + self.assertEqual(msg.get_adaptive_int(), 0x1122334455) + self.assertEqual(msg.get_adaptive_int(), 0xf00000000000000000) self.assertEqual(msg.get_so_far(), self.__d[:29]) self.assertEqual(msg.get_remainder(), self.__d[29:]) msg.rewind() - self.assertEqual(msg.get_int(), 5) + self.assertEqual(msg.get_adaptive_int(), 5) self.assertEqual(msg.get_so_far(), self.__d[:4]) self.assertEqual(msg.get_remainder(), self.__d[4:]) diff --git a/tests/test_packetizer.py b/tests/test_packetizer.py index d4d5544e..8faec03c 100644 --- a/tests/test_packetizer.py +++ b/tests/test_packetizer.py @@ -21,9 +21,12 @@ Some unit tests for the ssh2 protocol in Transport. """ import unittest +from hashlib import sha1 + from tests.loop import LoopSocket + from Crypto.Cipher import AES -from Crypto.Hash import SHA + from paramiko import Message, Packetizer, util from paramiko.common import byte_chr, zero_byte @@ -41,7 +44,7 @@ class PacketizerTest (unittest.TestCase): p.set_log(util.get_logger('paramiko.transport')) p.set_hexdump(True) cipher = AES.new(zero_byte * 16, AES.MODE_CBC, x55 * 16) - p.set_outbound_cipher(cipher, 16, SHA, 12, x1f * 20) + p.set_outbound_cipher(cipher, 16, sha1, 12, x1f * 20) # message has to be at least 16 bytes long, so we'll have at least one # block of data encrypted that contains zero random padding bytes @@ -64,10 +67,56 @@ class PacketizerTest (unittest.TestCase): p.set_log(util.get_logger('paramiko.transport')) p.set_hexdump(True) cipher = AES.new(zero_byte * 16, AES.MODE_CBC, x55 * 16) - p.set_inbound_cipher(cipher, 16, SHA, 12, x1f * 20) + p.set_inbound_cipher(cipher, 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') cmd, m = p.read_message() self.assertEqual(100, cmd) self.assertEqual(100, m.get_int()) self.assertEqual(1, m.get_int()) self.assertEqual(900, m.get_int()) + + def test_3_closed(self): + rsock = LoopSocket() + wsock = LoopSocket() + rsock.link(wsock) + p = Packetizer(wsock) + p.set_log(util.get_logger('paramiko.transport')) + p.set_hexdump(True) + cipher = AES.new(zero_byte * 16, AES.MODE_CBC, x55 * 16) + p.set_outbound_cipher(cipher, 16, sha1, 12, x1f * 20) + + # message has to be at least 16 bytes long, so we'll have at least one + # block of data encrypted that contains zero random padding bytes + m = Message() + m.add_byte(byte_chr(100)) + m.add_int(100) + m.add_int(1) + m.add_int(900) + wsock.send = lambda x: 0 + from functools import wraps + import errno + import os + import signal + + class TimeoutError(Exception): + pass + + def timeout(seconds=1, error_message=os.strerror(errno.ETIME)): + def decorator(func): + def _handle_timeout(signum, frame): + raise TimeoutError(error_message) + + def wrapper(*args, **kwargs): + signal.signal(signal.SIGALRM, _handle_timeout) + signal.alarm(seconds) + try: + result = func(*args, **kwargs) + finally: + signal.alarm(0) + return result + + return wraps(func)(wrapper) + + return decorator + send = timeout()(p.send_message) + self.assertRaises(EOFError, send, m) diff --git a/tests/test_pkey.py b/tests/test_pkey.py index 6ff68fc2..f673254f 100644 --- a/tests/test_pkey.py +++ b/tests/test_pkey.py @@ -20,11 +20,14 @@ Some unit tests for public/private key objects. """ -from binascii import hexlify import unittest +import os +from binascii import hexlify +from hashlib import md5 + from paramiko import RSAKey, DSSKey, ECDSAKey, Message, util from paramiko.py3compat import StringIO, byte_chr, b, bytes -from paramiko.common import rng + from tests.util import test_path # from openssh's ssh-keygen @@ -90,8 +93,7 @@ class KeyTest (unittest.TestCase): pass def test_1_generate_key_bytes(self): - from Crypto.Hash import MD5 - key = util.generate_key_bytes(MD5, x1234, 'happy birthday', 30) + 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' self.assertEqual(exp, key) @@ -166,7 +168,7 @@ class KeyTest (unittest.TestCase): def test_8_sign_rsa(self): # verify that the rsa private key can sign and verify key = RSAKey.from_private_key_file(test_path('test_rsa.key')) - msg = key.sign_ssh_data(rng, b'ice weasels') + msg = key.sign_ssh_data(b'ice weasels') self.assertTrue(type(msg) is Message) msg.rewind() self.assertEqual('ssh-rsa', msg.get_text()) @@ -179,7 +181,7 @@ class KeyTest (unittest.TestCase): def test_9_sign_dss(self): # verify that the dss private key can sign and verify key = DSSKey.from_private_key_file(test_path('test_dss.key')) - msg = key.sign_ssh_data(rng, b'ice weasels') + msg = key.sign_ssh_data(b'ice weasels') self.assertTrue(type(msg) is Message) msg.rewind() self.assertEqual('ssh-dss', msg.get_text()) @@ -193,13 +195,13 @@ class KeyTest (unittest.TestCase): def test_A_generate_rsa(self): key = RSAKey.generate(1024) - msg = key.sign_ssh_data(rng, b'jerri blank') + 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): key = DSSKey.generate(1024) - msg = key.sign_ssh_data(rng, b'jerri blank') + msg = key.sign_ssh_data(b'jerri blank') msg.rewind() self.assertTrue(key.verify_ssh_sig(b'jerri blank', msg)) @@ -240,7 +242,7 @@ class KeyTest (unittest.TestCase): def test_13_sign_ecdsa(self): # verify that the rsa private key can sign and verify key = ECDSAKey.from_private_key_file(test_path('test_ecdsa.key')) - msg = key.sign_ssh_data(rng, b'ice weasels') + msg = key.sign_ssh_data(b'ice weasels') self.assertTrue(type(msg) is Message) msg.rewind() self.assertEqual('ecdsa-sha2-nistp256', msg.get_text()) @@ -252,3 +254,20 @@ class KeyTest (unittest.TestCase): msg.rewind() pub = ECDSAKey(data=key.asbytes()) self.assertTrue(pub.verify_ssh_sig(b'ice weasels', msg)) + + def test_salt_size(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) + # Verify the inner key data still matches (when no ValueError) + key2 = RSAKey(filename=newfile, password=newpassword) + self.assertEqual(key, key2) + finally: + os.remove(newfile) diff --git a/tests/test_sftp.py b/tests/test_sftp.py index 2b6aa3b6..ff146ade 100755 --- a/tests/test_sftp.py +++ b/tests/test_sftp.py @@ -23,12 +23,13 @@ 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). """ -from binascii import hexlify import os +import socket import sys -import warnings import threading import unittest +import warnings +from binascii import hexlify from tempfile import mkstemp import paramiko @@ -96,7 +97,7 @@ def get_sftp(): class SFTPTest (unittest.TestCase): - + @staticmethod def init(hostname, username, keyfile, passwd): global sftp, tc @@ -128,8 +129,8 @@ class SFTPTest (unittest.TestCase): sys.stderr.write('\n') sys.exit(1) sftp = paramiko.SFTP.from_transport(t) - init = staticmethod(init) + @staticmethod def init_loopback(): global sftp, tc @@ -149,12 +150,11 @@ class SFTPTest (unittest.TestCase): event.wait(1.0) sftp = paramiko.SFTP.from_transport(tc) - init_loopback = staticmethod(init_loopback) + @staticmethod def set_big_file_test(onoff): global g_big_file_test g_big_file_test = onoff - set_big_file_test = staticmethod(set_big_file_test) def setUp(self): global FOLDER @@ -195,6 +195,21 @@ class SFTPTest (unittest.TestCase): pass sftp = paramiko.SFTP.from_transport(tc) + def test_2_sftp_can_be_used_as_context_manager(self): + """ + verify that the sftp session is closed when exiting the context manager + """ + global sftp + with sftp: + pass + try: + sftp.open(FOLDER + '/test2', 'w') + self.fail('expected exception') + except (EOFError, socket.error): + pass + finally: + sftp = paramiko.SFTP.from_transport(tc) + def test_3_write(self): """ verify that a file can be created and written, and the size is correct. @@ -279,8 +294,8 @@ class SFTPTest (unittest.TestCase): def test_7_listdir(self): """ - verify that a folder can be created, a bunch of files can be placed in it, - and those files show up in sftp.listdir. + verify that a folder can be created, a bunch of files can be placed in + it, and those files show up in sftp.listdir. """ try: sftp.open(FOLDER + '/duck.txt', 'w').close() @@ -298,6 +313,26 @@ class SFTPTest (unittest.TestCase): sftp.remove(FOLDER + '/fish.txt') sftp.remove(FOLDER + '/tertiary.py') + def test_7_5_listdir_iter(self): + """ + listdir_iter version of above test + """ + try: + sftp.open(FOLDER + '/duck.txt', 'w').close() + sftp.open(FOLDER + '/fish.txt', 'w').close() + sftp.open(FOLDER + '/tertiary.py', 'w').close() + + x = [x.filename for x in sftp.listdir_iter(FOLDER)] + self.assertEqual(len(x), 3) + self.assertTrue('duck.txt' in x) + self.assertTrue('fish.txt' in x) + self.assertTrue('tertiary.py' in x) + self.assertTrue('random' not in x) + finally: + sftp.remove(FOLDER + '/duck.txt') + sftp.remove(FOLDER + '/fish.txt') + sftp.remove(FOLDER + '/tertiary.py') + def test_8_setstat(self): """ verify that the setstat functions (chown, chmod, utime, truncate) work. @@ -575,7 +610,7 @@ class SFTPTest (unittest.TestCase): with sftp.open(FOLDER + '/bunny.txt', 'rb') as f: self.assertEqual(text, f.read(128)) - self.assertEqual((41, 41), saved_progress[-1]) + self.assertEqual([(41, 41)], saved_progress) os.unlink(localname) fd, localname = mkstemp() @@ -585,7 +620,7 @@ class SFTPTest (unittest.TestCase): with open(localname, 'rb') as f: self.assertEqual(text, f.read(128)) - self.assertEqual((41, 41), saved_progress[-1]) + self.assertEqual([(41, 41)], saved_progress) os.unlink(localname) sftp.unlink(FOLDER + '/bunny.txt') @@ -661,7 +696,8 @@ class SFTPTest (unittest.TestCase): f.readv([(0, 12)]) with sftp.open(FOLDER + '/zero', 'r') as f: - f.prefetch() + file_size = f.stat().st_size + f.prefetch(file_size) f.read(100) finally: sftp.unlink(FOLDER + '/zero') @@ -776,6 +812,11 @@ class SFTPTest (unittest.TestCase): sftp.remove('%s/nonutf8data' % FOLDER) + def test_sftp_attributes_empty_str(self): + sftp_attributes = SFTPAttributes() + self.assertEqual(str(sftp_attributes), "?--------- 1 0 0 0 (unknown date) ?") + + if __name__ == '__main__': SFTPTest.init_loopback() # logging is required by test_N_file_with_percent diff --git a/tests/test_sftp_big.py b/tests/test_sftp_big.py index abed27b8..cfad5682 100644 --- a/tests/test_sftp_big.py +++ b/tests/test_sftp_big.py @@ -132,7 +132,8 @@ class BigSFTPTest (unittest.TestCase): start = time.time() with sftp.open('%s/hongry.txt' % FOLDER, 'rb') as f: - f.prefetch() + file_size = f.stat().st_size + f.prefetch(file_size) # read on odd boundaries to make sure the bytes aren't getting scrambled n = 0 @@ -171,7 +172,8 @@ class BigSFTPTest (unittest.TestCase): chunk = 793 for i in range(10): with sftp.open('%s/hongry.txt' % FOLDER, 'rb') as f: - f.prefetch() + file_size = f.stat().st_size + f.prefetch(file_size) base_offset = (512 * 1024) + 17 * random.randint(1000, 2000) offsets = [base_offset + j * chunk for j in range(100)] # randomly seek around and read them out @@ -245,9 +247,11 @@ class BigSFTPTest (unittest.TestCase): for i in range(10): with sftp.open('%s/hongry.txt' % FOLDER, 'r') as f: - f.prefetch() + file_size = f.stat().st_size + f.prefetch(file_size) with sftp.open('%s/hongry.txt' % FOLDER, 'r') as f: - f.prefetch() + file_size = f.stat().st_size + f.prefetch(file_size) for n in range(1024): data = f.read(1024) self.assertEqual(data, kblob) @@ -275,7 +279,8 @@ class BigSFTPTest (unittest.TestCase): self.assertEqual(sftp.stat('%s/hongry.txt' % FOLDER).st_size, 1024 * 1024) with sftp.open('%s/hongry.txt' % FOLDER, 'rb') as f: - f.prefetch() + file_size = f.stat().st_size + f.prefetch(file_size) data = f.read(1024) self.assertEqual(data, kblob) @@ -353,7 +358,8 @@ class BigSFTPTest (unittest.TestCase): # try to read it too. with sftp.open('%s/hongry.txt' % FOLDER, 'r', 128 * 1024) as f: - f.prefetch() + file_size = f.stat().st_size + f.prefetch(file_size) total = 0 while total < 1024 * 1024: total += len(f.read(32 * 1024)) diff --git a/tests/test_ssh_gss.py b/tests/test_ssh_gss.py new file mode 100644 index 00000000..e20d348f --- /dev/null +++ b/tests/test_ssh_gss.py @@ -0,0 +1,124 @@ +# Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com> +# Copyright (C) 2013-2014 science + computing ag +# Author: Sebastian Deiss <sebastian.deiss@t-online.de> +# +# +# 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. + +""" +Unit Tests for the GSS-API / SSPI SSHv2 Authentication (gssapi-with-mic) +""" + +import socket +import threading +import unittest + +import paramiko + + +class NullServer (paramiko.ServerInterface): + + def get_allowed_auths(self, username): + return 'gssapi-with-mic' + + def check_auth_gssapi_with_mic(self, username, + gss_authenticated=paramiko.AUTH_FAILED, + cc_file=None): + if gss_authenticated == paramiko.AUTH_SUCCESSFUL: + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def enable_auth_gssapi(self): + UseGSSAPI = True + GSSAPICleanupCredentials = True + return UseGSSAPI + + def check_channel_request(self, kind, chanid): + return paramiko.OPEN_SUCCEEDED + + def check_channel_exec_request(self, channel, command): + if command != 'yes': + return False + return True + + +class GSSAuthTest(unittest.TestCase): + @staticmethod + def init(username, hostname): + global krb5_principal, targ_name + krb5_principal = username + targ_name = hostname + + def setUp(self): + self.username = krb5_principal + self.hostname = socket.getfqdn(targ_name) + self.sockl = socket.socket() + self.sockl.bind((targ_name, 0)) + self.sockl.listen(1) + self.addr, self.port = self.sockl.getsockname() + self.event = threading.Event() + thread = threading.Thread(target=self._run) + thread.start() + + def tearDown(self): + for attr in "tc ts socks sockl".split(): + if hasattr(self, attr): + getattr(self, attr).close() + + def _run(self): + self.socks, addr = self.sockl.accept() + self.ts = paramiko.Transport(self.socks) + host_key = paramiko.RSAKey.from_private_key_file('tests/test_rsa.key') + self.ts.add_server_key(host_key) + server = NullServer() + self.ts.start_server(self.event, server) + + def test_1_gss_auth(self): + """ + Verify that Paramiko can handle SSHv2 GSS-API / SSPI authentication + (gssapi-with-mic) in client and server mode. + """ + host_key = paramiko.RSAKey.from_private_key_file('tests/test_rsa.key') + public_host_key = paramiko.RSAKey(data=host_key.asbytes()) + + self.tc = paramiko.SSHClient() + self.tc.get_host_keys().add('[%s]:%d' % (self.hostname, self.port), + 'ssh-rsa', public_host_key) + self.tc.connect(self.hostname, self.port, username=self.username, + gss_auth=True) + + self.event.wait(1.0) + self.assert_(self.event.is_set()) + self.assert_(self.ts.is_active()) + self.assertEquals(self.username, self.ts.get_username()) + self.assertEquals(True, self.ts.is_authenticated()) + + stdin, stdout, stderr = self.tc.exec_command('yes') + schan = self.ts.accept(1.0) + + schan.send('Hello there.\n') + schan.send_stderr('This is on stderr.\n') + schan.close() + + self.assertEquals('Hello there.\n', stdout.readline()) + self.assertEquals('', stdout.readline()) + self.assertEquals('This is on stderr.\n', stderr.readline()) + self.assertEquals('', stderr.readline()) + + stdin.close() + stdout.close() + stderr.close() diff --git a/tests/test_transport.py b/tests/test_transport.py index 485a18e8..5069e5b0 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -20,22 +20,28 @@ Some unit tests for the ssh2 protocol in Transport. """ +from __future__ import with_statement + from binascii import hexlify import select import socket import time import threading import random +from hashlib import sha1 +import unittest from paramiko import Transport, SecurityOptions, ServerInterface, RSAKey, DSSKey, \ - SSHException, ChannelException + SSHException, ChannelException, Packetizer 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 +from paramiko.common import MSG_KEXINIT, cMSG_CHANNEL_WINDOW_ADJUST, \ + MIN_PACKET_SIZE, MIN_WINDOW_SIZE, MAX_WINDOW_SIZE, \ + DEFAULT_WINDOW_SIZE, DEFAULT_MAX_PACKET_SIZE from paramiko.py3compat import bytes from paramiko.message import Message from tests.loop import LoopSocket -from tests.util import ParamikoTest, test_path +from tests.util import test_path LONG_BANNER = """\ @@ -55,7 +61,7 @@ class NullServer (ServerInterface): paranoid_did_password = False paranoid_did_public_key = False paranoid_key = DSSKey.from_private_key_file(test_path('test_dss.key')) - + def get_allowed_auths(self, username): if username == 'slowdive': return 'publickey,password' @@ -72,30 +78,30 @@ class NullServer (ServerInterface): return OPEN_SUCCEEDED def check_channel_exec_request(self, channel, command): - if command != 'yes': + if command != b'yes': return False return True def check_channel_shell_request(self, channel): return True - + def check_global_request(self, kind, msg): self._global_request = kind return False - + def check_channel_x11_request(self, channel, single_connection, auth_protocol, auth_cookie, screen_number): self._x11_single_connection = single_connection self._x11_auth_protocol = auth_protocol self._x11_auth_cookie = auth_cookie self._x11_screen_number = screen_number return True - + def check_port_forward_request(self, addr, port): self._listen = socket.socket() self._listen.bind(('127.0.0.1', 0)) self._listen.listen(1) return self._listen.getsockname()[1] - + def cancel_port_forward_request(self, addr, port): self._listen.close() self._listen = None @@ -105,7 +111,7 @@ class NullServer (ServerInterface): return OPEN_SUCCEEDED -class TransportTest(ParamikoTest): +class TransportTest(unittest.TestCase): def setUp(self): self.socks = LoopSocket() self.sockc = LoopSocket() @@ -123,20 +129,20 @@ class TransportTest(ParamikoTest): host_key = RSAKey.from_private_key_file(test_path('test_rsa.key')) public_host_key = RSAKey(data=host_key.asbytes()) self.ts.add_server_key(host_key) - + if client_options is not None: client_options(self.tc.get_security_options()) if server_options is not None: server_options(self.ts.get_security_options()) - + event = threading.Event() self.server = NullServer() - self.assertTrue(not event.isSet()) + self.assertTrue(not event.is_set()) self.ts.start_server(event, self.server) self.tc.connect(hostkey=public_host_key, username='slowdive', password='pygmalion') event.wait(1.0) - self.assertTrue(event.isSet()) + self.assertTrue(event.is_set()) self.assertTrue(self.ts.is_active()) def test_1_security_options(self): @@ -155,7 +161,7 @@ class TransportTest(ParamikoTest): self.assertTrue(False) except TypeError: pass - + 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' @@ -175,7 +181,7 @@ class TransportTest(ParamikoTest): self.ts.add_server_key(host_key) event = threading.Event() server = NullServer() - self.assertTrue(not event.isSet()) + self.assertTrue(not event.is_set()) self.assertEqual(None, self.tc.get_username()) self.assertEqual(None, self.ts.get_username()) self.assertEqual(False, self.tc.is_authenticated()) @@ -184,7 +190,7 @@ class TransportTest(ParamikoTest): self.tc.connect(hostkey=public_host_key, username='slowdive', password='pygmalion') event.wait(1.0) - self.assertTrue(event.isSet()) + self.assertTrue(event.is_set()) self.assertTrue(self.ts.is_active()) self.assertEqual('slowdive', self.tc.get_username()) self.assertEqual('slowdive', self.ts.get_username()) @@ -200,15 +206,15 @@ class TransportTest(ParamikoTest): self.ts.add_server_key(host_key) event = threading.Event() server = NullServer() - self.assertTrue(not event.isSet()) + self.assertTrue(not event.is_set()) self.socks.send(LONG_BANNER) self.ts.start_server(event, server) self.tc.connect(hostkey=public_host_key, username='slowdive', password='pygmalion') event.wait(1.0) - self.assertTrue(event.isSet()) + self.assertTrue(event.is_set()) self.assertTrue(self.ts.is_active()) - + def test_4_special(self): """ verify that the client can demand odd handshake settings, and can @@ -222,7 +228,7 @@ class TransportTest(ParamikoTest): self.assertEqual('aes256-cbc', self.tc.remote_cipher) self.assertEqual(12, self.tc.packetizer.get_mac_size_out()) self.assertEqual(12, self.tc.packetizer.get_mac_size_in()) - + self.tc.send_ignore(1024) self.tc.renegotiate_keys() self.ts.send_ignore(1024) @@ -236,7 +242,7 @@ class TransportTest(ParamikoTest): self.tc.set_keepalive(1) time.sleep(2) self.assertEqual('keepalive@lag.net', self.server._global_request) - + def test_6_exec_command(self): """ verify that exec_command() does something reasonable. @@ -246,11 +252,11 @@ class TransportTest(ParamikoTest): chan = self.tc.open_session() schan = self.ts.accept(1.0) try: - chan.exec_command('no') + chan.exec_command(b'command contains \xfc and is not a valid UTF-8 string') self.assertTrue(False) except SSHException: pass - + chan = self.tc.open_session() chan.exec_command('yes') schan = self.ts.accept(1.0) @@ -264,7 +270,7 @@ class TransportTest(ParamikoTest): f = chan.makefile_stderr() self.assertEqual('This is on stderr.\n', f.readline()) self.assertEqual('', f.readline()) - + # now try it with combined stdout/stderr chan = self.tc.open_session() chan.exec_command('yes') @@ -273,11 +279,27 @@ class TransportTest(ParamikoTest): schan.send_stderr('This is on stderr.\n') schan.close() - chan.set_combine_stderr(True) + chan.set_combine_stderr(True) f = chan.makefile() self.assertEqual('Hello there.\n', f.readline()) self.assertEqual('This is on stderr.\n', f.readline()) self.assertEqual('', f.readline()) + + def test_6a_channel_can_be_used_as_context_manager(self): + """ + verify that exec_command() does something reasonable. + """ + self.setup_test_server() + + with self.tc.open_session() as chan: + with self.ts.accept(1.0) as schan: + chan.exec_command('yes') + schan.send('Hello there.\n') + schan.close() + + f = chan.makefile() + self.assertEqual('Hello there.\n', f.readline()) + self.assertEqual('', f.readline()) def test_7_invoke_shell(self): """ @@ -320,7 +342,7 @@ class TransportTest(ParamikoTest): schan.shutdown_write() schan.send_exit_status(23) schan.close() - + f = chan.makefile() self.assertEqual('Hello there.\n', f.readline()) self.assertEqual('', f.readline()) @@ -342,14 +364,14 @@ class TransportTest(ParamikoTest): chan.invoke_shell() schan = self.ts.accept(1.0) - # nothing should be ready + # nothing should be ready r, w, e = select.select([chan], [], [], 0.1) self.assertEqual([], r) self.assertEqual([], w) self.assertEqual([], e) - + schan.send('hello\n') - + # something should be ready now (give it 1 second to appear) for i in range(10): r, w, e = select.select([chan], [], [], 0.1) @@ -361,7 +383,7 @@ class TransportTest(ParamikoTest): self.assertEqual([], e) self.assertEqual(b'hello\n', chan.recv(6)) - + # and, should be dead again now r, w, e = select.select([chan], [], [], 0.1) self.assertEqual([], r) @@ -369,7 +391,7 @@ class TransportTest(ParamikoTest): self.assertEqual([], e) schan.close() - + # detect eof? for i in range(10): r, w, e = select.select([chan], [], [], 0.1) @@ -380,14 +402,14 @@ class TransportTest(ParamikoTest): self.assertEqual([], w) self.assertEqual([], e) self.assertEqual(bytes(), chan.recv(16)) - + # make sure the pipe is still open for now... p = chan._pipe self.assertEqual(False, p._closed) chan.close() # ...and now is closed. self.assertEqual(True, p._closed) - + def test_B_renegotiate(self): """ verify that a transport can correctly renegotiate mid-stream. @@ -402,7 +424,7 @@ class TransportTest(ParamikoTest): for i in range(20): chan.send('x' * 1024) chan.close() - + # allow a few seconds for the rekeying to complete for i in range(50): if self.tc.H != self.tc.session_id: @@ -426,9 +448,11 @@ class TransportTest(ParamikoTest): bytes = self.tc.packetizer._Packetizer__sent_bytes chan.send('x' * 1024) 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!! :) self.assertTrue(bytes2 - bytes < 1024) - self.assertEqual(52, bytes2 - bytes) + self.assertEqual(16 + block_size + mac_size, bytes2 - bytes) chan.close() schan.close() @@ -441,28 +465,28 @@ class TransportTest(ParamikoTest): chan = self.tc.open_session() chan.exec_command('yes') schan = self.ts.accept(1.0) - + requested = [] def handler(c, addr_port): addr, port = addr_port requested.append((addr, port)) self.tc._queue_incoming_channel(c) - + self.assertEqual(None, getattr(self.server, '_x11_screen_number', None)) cookie = chan.request_x11(0, single_connection=True, handler=handler) self.assertEqual(0, self.server._x11_screen_number) self.assertEqual('MIT-MAGIC-COOKIE-1', self.server._x11_auth_protocol) self.assertEqual(cookie, self.server._x11_auth_cookie) self.assertEqual(True, self.server._x11_single_connection) - + x11_server = self.ts.open_x11_channel(('localhost', 6093)) x11_client = self.tc.accept() self.assertEqual('localhost', requested[0][0]) self.assertEqual(6093, requested[0][1]) - + x11_server.send('hello') self.assertEqual(b'hello', x11_client.recv(5)) - + x11_server.close() x11_client.close() chan.close() @@ -477,13 +501,13 @@ class TransportTest(ParamikoTest): chan = self.tc.open_session() chan.exec_command('yes') schan = self.ts.accept(1.0) - + requested = [] def handler(c, origin_addr_port, server_addr_port): requested.append(origin_addr_port) requested.append(server_addr_port) self.tc._queue_incoming_channel(c) - + port = self.tc.request_port_forward('127.0.0.1', 0, handler) self.assertEqual(port, self.server._listen.getsockname()[1]) @@ -492,14 +516,14 @@ class TransportTest(ParamikoTest): ss, _ = self.server._listen.accept() sch = self.ts.open_forwarded_tcpip_channel(ss.getsockname(), ss.getpeername()) cch = self.tc.accept() - + sch.send('hello') self.assertEqual(b'hello', cch.recv(5)) sch.close() cch.close() ss.close() cs.close() - + # now cancel it. self.tc.cancel_port_forward('127.0.0.1', port) self.assertTrue(self.server._listen is None) @@ -513,7 +537,7 @@ class TransportTest(ParamikoTest): chan = self.tc.open_session() chan.exec_command('yes') schan = self.ts.accept(1.0) - + # open a port on the "server" that the client will ask to forward to. greeting_server = socket.socket() greeting_server.bind(('127.0.0.1', 0)) @@ -524,13 +548,13 @@ class TransportTest(ParamikoTest): sch = self.ts.accept(1.0) cch = socket.socket() cch.connect(self.server._tcpip_dest) - + ss, _ = greeting_server.accept() ss.send(b'Hello!\n') ss.close() sch.send(cch.recv(8192)) sch.close() - + self.assertEqual(b'Hello!\n', cs.recv(7)) cs.close() @@ -544,14 +568,14 @@ class TransportTest(ParamikoTest): chan.invoke_shell() schan = self.ts.accept(1.0) - # nothing should be ready + # nothing should be ready r, w, e = select.select([chan], [], [], 0.1) self.assertEqual([], r) self.assertEqual([], w) self.assertEqual([], e) - + schan.send_stderr('hello\n') - + # something should be ready now (give it 1 second to appear) for i in range(10): r, w, e = select.select([chan], [], [], 0.1) @@ -563,7 +587,7 @@ class TransportTest(ParamikoTest): self.assertEqual([], e) self.assertEqual(b'hello\n', chan.recv_stderr(6)) - + # and, should be dead again now r, w, e = select.select([chan], [], [], 0.1) self.assertEqual([], r) @@ -585,12 +609,13 @@ class TransportTest(ParamikoTest): self.assertEqual(chan.send_ready(), True) total = 0 K = '*' * 1024 - while total < 1024 * 1024: + limit = 1+(64 * 2 ** 15) + while total < limit: chan.send(K) total += len(K) if not chan.send_ready(): break - self.assertTrue(total < 1024 * 1024) + self.assertTrue(total < limit) schan.close() chan.close() @@ -599,10 +624,10 @@ class TransportTest(ParamikoTest): def test_I_rekey_deadlock(self): """ Regression test for deadlock when in-transit messages are received after MSG_KEXINIT is sent - + Note: When this test fails, it may leak threads. """ - + # Test for an obscure deadlocking bug that can occur if we receive # certain messages while initiating a key exchange. # @@ -619,7 +644,7 @@ class TransportTest(ParamikoTest): # NeedRekeyException. # 4. In response to NeedRekeyException, the transport thread sends # 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. @@ -654,11 +679,11 @@ class TransportTest(ParamikoTest): self.done_event = done_event self.watchdog_event = threading.Event() self.last = None - + def run(self): try: for i in range(1, 1+self.iterations): - if self.done_event.isSet(): + if self.done_event.is_set(): break self.watchdog_event.set() #print i, "SEND" @@ -666,7 +691,7 @@ class TransportTest(ParamikoTest): finally: self.done_event.set() self.watchdog_event.set() - + class ReceiveThread(threading.Thread): def __init__(self, chan, done_event): threading.Thread.__init__(self, None, None, self.__class__.__name__) @@ -674,10 +699,10 @@ class TransportTest(ParamikoTest): self.chan = chan self.done_event = done_event self.watchdog_event = threading.Event() - + def run(self): try: - while not self.done_event.isSet(): + while not self.done_event.is_set(): if self.chan.recv_ready(): chan.recv(65536) self.watchdog_event.set() @@ -687,10 +712,10 @@ class TransportTest(ParamikoTest): finally: self.done_event.set() self.watchdog_event.set() - + self.setup_test_server() self.ts.packetizer.REKEY_BYTES = 2048 - + chan = self.tc.open_session() chan.exec_command('yes') schan = self.ts.accept(1.0) @@ -712,7 +737,7 @@ class TransportTest(ParamikoTest): self._send_message(m2) return _negotiate_keys(self, m) self.tc._handler_table[MSG_KEXINIT] = _negotiate_keys_wrapper - + # Parameters for the test iterations = 500 # The deadlock does not happen every time, but it # should after many iterations. @@ -724,23 +749,23 @@ class TransportTest(ParamikoTest): # Start the sending thread st = SendThread(schan, iterations, done_event) st.start() - + # Start the receiving thread rt = ReceiveThread(chan, done_event) rt.start() - # Act as a watchdog timer, checking + # Act as a watchdog timer, checking deadlocked = False - while not deadlocked and not done_event.isSet(): + while not deadlocked and not done_event.is_set(): for event in (st.watchdog_event, rt.watchdog_event): event.wait(timeout) - if done_event.isSet(): + if done_event.is_set(): break - if not event.isSet(): + if not event.is_set(): deadlocked = True break event.clear() - + # Tell the threads to stop (if they haven't already stopped). Note # that if one or more threads are deadlocked, they might hang around # forever (until the process exits). @@ -752,3 +777,54 @@ class TransportTest(ParamikoTest): # Close the channels schan.close() chan.close() + + def test_J_sanitze_packet_size(self): + """ + verify that we conform to the rfc of packet and window sizes. + """ + for val, correct in [(4095, MIN_PACKET_SIZE), + (None, DEFAULT_MAX_PACKET_SIZE), + (2**32, MAX_WINDOW_SIZE)]: + self.assertEqual(self.tc._sanitize_packet_size(val), correct) + + def test_K_sanitze_window_size(self): + """ + verify that we conform to the rfc of packet and window sizes. + """ + for val, correct in [(32767, MIN_WINDOW_SIZE), + (None, DEFAULT_WINDOW_SIZE), + (2**32, MAX_WINDOW_SIZE)]: + self.assertEqual(self.tc._sanitize_window_size(val), correct) + + def test_L_handshake_timeout(self): + """ + verify that we can get a hanshake timeout. + """ + # Tweak client Transport instance's Packetizer instance so + # its read_message() sleeps a bit. This helps prevent race conditions + # where the client Transport's timeout timer thread doesn't even have + # time to get scheduled before the main client thread finishes + # handshaking with the server. + # (Doing this on the server's transport *sounds* more 'correct' but + # actually doesn't work nearly as well for whatever reason.) + class SlowPacketizer(Packetizer): + def read_message(self): + time.sleep(1) + return super(SlowPacketizer, self).read_message() + # NOTE: prettttty sure since the replaced .packetizer Packetizer is now + # no longer doing anything with its copy of the socket...everything'll + # be fine. Even tho it's a bit squicky. + self.tc.packetizer = SlowPacketizer(self.tc.sock) + # Continue with regular test red tape. + host_key = RSAKey.from_private_key_file(test_path('test_rsa.key')) + public_host_key = RSAKey(data=host_key.asbytes()) + self.ts.add_server_key(host_key) + event = threading.Event() + server = NullServer() + self.assertTrue(not event.is_set()) + self.tc.handshake_timeout = 0.000000000001 + self.ts.start_server(event, server) + self.assertRaises(EOFError, self.tc.connect, + hostkey=public_host_key, + username='slowdive', + password='pygmalion') diff --git a/tests/test_util.py b/tests/test_util.py index 0e7d0b2b..a6a2c30b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -23,13 +23,14 @@ Some unit tests for utility functions. from binascii import hexlify import errno import os -from Crypto.Hash import SHA +from hashlib import sha1 +import unittest + 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 tests.util import ParamikoTest - +# Note some lines in this configuration have trailing spaces on purpose test_config_file = """\ Host * User robey @@ -40,7 +41,12 @@ Host *.example.com \tUser bjork Port=3333 Host * - \t \t Crazy something dumb +""" + +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 """ @@ -59,7 +65,7 @@ BGQ3GQ/Fc7SX6gkpXkwcZryoi4kNFhHu5LvHcZPdxXV1D+uTMfGS1eyd2Yz/DoNWXNAl8TI0cAsW\ from paramiko import * -class UtilTest(ParamikoTest): +class UtilTest(unittest.TestCase): def test_1_import(self): """ verify that all the classes can be imported from paramiko. @@ -105,7 +111,7 @@ class UtilTest(ParamikoTest): 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': ['*'], 'config': {'crazy': 'something dumb'}}, {'host': ['spoo.example.com'], 'config': {'crazy': 'something else'}}]) def test_3_host_config(self): @@ -114,14 +120,14 @@ class UtilTest(ParamikoTest): config = paramiko.util.parse_ssh_config(f) for host, values in { - 'irc.danger.com': {'crazy': 'something dumb ', + 'irc.danger.com': {'crazy': 'something dumb', 'hostname': 'irc.danger.com', 'user': 'robey'}, - 'irc.example.com': {'crazy': 'something dumb ', + 'irc.example.com': {'crazy': 'something dumb', 'hostname': 'irc.example.com', 'user': 'robey', 'port': '3333'}, - 'spoo.example.com': {'crazy': 'something dumb ', + 'spoo.example.com': {'crazy': 'something dumb', 'hostname': 'spoo.example.com', 'user': 'robey', 'port': '3333'} @@ -136,7 +142,7 @@ class UtilTest(ParamikoTest): ) def test_4_generate_key_bytes(self): - x = paramiko.util.generate_key_bytes(SHA, b'ABCDEFGH', 'This is my secret passphrase.', 64) + 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') @@ -153,12 +159,6 @@ class UtilTest(ParamikoTest): finally: os.unlink('hostfile.temp') - def test_6_random(self): - from paramiko.common import rng - # just verify that we can pull out 32 bytes and not get an exception. - x = rng.read(32) - self.assertEqual(len(x), 32) - def test_7_host_config_expose_issue_33(self): test_config_file = """ Host www13.* @@ -339,12 +339,122 @@ 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_13_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_14_get_hostnames(self): + f = StringIO(test_config_file) + config = paramiko.util.parse_ssh_config(f) + self.assertEqual(config.get_hostnames(), set(['*', '*.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) + def test_safe_string(self): vanilla = b("vanilla") has_bytes = b("has \7\3 bytes") @@ -355,3 +465,23 @@ IdentityFile something_%l_using_fqdn assert safe_vanilla == vanilla, err.format(safe_vanilla, vanilla) assert safe_has_bytes == expected_bytes, \ err.format(safe_has_bytes, expected_bytes) + + 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 + ) diff --git a/tests/util.py b/tests/util.py index 66d2696c..b546a7e1 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,17 +1,7 @@ import os -import unittest root_path = os.path.dirname(os.path.realpath(__file__)) - -class ParamikoTest(unittest.TestCase): - # for Python 2.3 and below - if not hasattr(unittest.TestCase, 'assertTrue'): - assertTrue = unittest.TestCase.failUnless - if not hasattr(unittest.TestCase, 'assertFalse'): - assertFalse = unittest.TestCase.failIf - - def test_path(filename): return os.path.join(root_path, filename) @@ -1,6 +1,6 @@ [tox] -envlist = py25,py26,py27,py32,py33 +envlist = py26,py27,py32,py33,py34 [testenv] -commands = pip install --use-mirrors -q -r tox-requirements.txt +commands = pip install -q -r tox-requirements.txt python test.py |