diff options
author | Jeff Forcier <jeff@bitprophet.org> | 2017-09-11 10:54:18 -0700 |
---|---|---|
committer | Jeff Forcier <jeff@bitprophet.org> | 2017-09-11 10:54:18 -0700 |
commit | fb299ad1617cbe3c76310eab19370878d113366e (patch) | |
tree | 5004e5140b74a8b6f365fd2004c98f0f131b26e1 | |
parent | 538bb118870a8b0dbaf35dd104b1178481c213b3 (diff) | |
parent | 2098abc1f26c572cc4257eb7270d4e8ebc627e9e (diff) | |
download | paramiko-fb299ad1617cbe3c76310eab19370878d113366e.tar.gz |
Merge branch '827-int' into final-827-int
-rw-r--r-- | dev-requirements.txt | 1 | ||||
-rw-r--r-- | paramiko/__init__.py | 2 | ||||
-rw-r--r-- | paramiko/agent.py | 1 | ||||
-rw-r--r-- | paramiko/auth_handler.py | 98 | ||||
-rw-r--r-- | paramiko/client.py | 95 | ||||
-rw-r--r-- | paramiko/compress.py | 3 | ||||
-rw-r--r-- | paramiko/dsskey.py | 19 | ||||
-rw-r--r-- | paramiko/ecdsakey.py | 40 | ||||
-rw-r--r-- | paramiko/ed25519key.py | 26 | ||||
-rw-r--r-- | paramiko/pkcs11.py | 93 | ||||
-rw-r--r-- | paramiko/pkey.py | 170 | ||||
-rw-r--r-- | paramiko/rsakey.py | 25 | ||||
-rw-r--r-- | paramiko/server.py | 13 | ||||
-rw-r--r-- | paramiko/transport.py | 17 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | sites/docs/api/keys.rst | 5 | ||||
-rw-r--r-- | sites/www/changelog.rst | 62 | ||||
-rw-r--r-- | tasks.py | 3 | ||||
-rwxr-xr-x | test.py | 2 | ||||
-rw-r--r-- | tests/test_client.py | 59 | ||||
-rw-r--r-- | tests/test_dss.key-cert.pub | 1 | ||||
-rw-r--r-- | tests/test_ecdsa_256.key-cert.pub | 1 | ||||
-rw-r--r-- | tests/test_ed25519.key-cert.pub | 1 | ||||
-rw-r--r-- | tests/test_pkcs11.py | 166 | ||||
-rw-r--r-- | tests/test_pkey.py | 33 | ||||
-rw-r--r-- | tests/test_rsa.key-cert.pub | 1 | ||||
-rw-r--r-- | tests/test_rsa.key.pub | 1 | ||||
-rw-r--r-- | tests/util.py | 1 |
28 files changed, 773 insertions, 168 deletions
diff --git a/dev-requirements.txt b/dev-requirements.txt index 716f432d..eb7408b1 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,3 +10,4 @@ semantic_version<3.0 wheel==0.24 twine==1.5 flake8==2.6.2 +mock>=2.0.0 diff --git a/paramiko/__init__.py b/paramiko/__init__.py index da8a093f..3b3b1f9d 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -58,7 +58,7 @@ from paramiko.message import Message from paramiko.packet import Packetizer from paramiko.file import BufferedFile from paramiko.agent import Agent, AgentKey -from paramiko.pkey import PKey +from paramiko.pkey import PKey, PublicBlob from paramiko.hostkeys import HostKeys from paramiko.config import SSHConfig from paramiko.proxy import ProxyCommand diff --git a/paramiko/agent.py b/paramiko/agent.py index bc857efa..7a4dde21 100644 --- a/paramiko/agent.py +++ b/paramiko/agent.py @@ -387,6 +387,7 @@ class AgentKey(PKey): def __init__(self, agent, blob): self.agent = agent self.blob = blob + self.public_blob = None self.name = Message(blob).get_text() def asbytes(self): diff --git a/paramiko/auth_handler.py b/paramiko/auth_handler.py index 86935ecc..1090883b 100644 --- a/paramiko/auth_handler.py +++ b/paramiko/auth_handler.py @@ -39,6 +39,7 @@ from paramiko.common import ( cMSG_USERAUTH_GSSAPI_MIC, MSG_USERAUTH_GSSAPI_RESPONSE, MSG_USERAUTH_GSSAPI_TOKEN, MSG_USERAUTH_GSSAPI_ERROR, MSG_USERAUTH_GSSAPI_ERRTOK, MSG_USERAUTH_GSSAPI_MIC, MSG_NAMES, + cMSG_USERAUTH_BANNER ) from paramiko.message import Message from paramiko.py3compat import bytestring @@ -51,6 +52,7 @@ from paramiko.ssh_gss import GSSAuth from paramiko import pkcs11_open_session, pkcs11_close_session from paramiko.pkcs11 import pkcs11_get_public_key from .authentication import hostkey_from_text +from paramiko.pkcs11 import PKCS11Exception class AuthHandler (object): @@ -77,10 +79,7 @@ class AuthHandler (object): self.gss_host = None self.gss_deleg_creds = True # for PKCS11 / Smartcard - self.pkcs11pin = None - self.pkcs11provider = None self.pkcs11session = None - self.pkcs11publickey = None if AuthHandler._pkcs11_lock is None: AuthHandler._pkcs11_lock = threading.Lock() self.pkcs11_lock = AuthHandler._pkcs11_lock @@ -115,15 +114,12 @@ class AuthHandler (object): finally: self.transport.lock.release() - def auth_pkcs11(self, username, pkcs11pin, pkcs11provider, pkcs11session, - event): + def auth_pkcs11(self, username, pkcs11session, event): self.transport.lock.acquire() try: self.auth_event = event self.auth_method = 'publickey' self.username = username - self.pkcs11pin = pkcs11pin - self.pkcs11provider = pkcs11provider self.pkcs11session = pkcs11session self._request_auth() finally: @@ -215,8 +211,13 @@ class AuthHandler (object): m.add_string(service) m.add_string('publickey') m.add_boolean(True) - m.add_string(key.get_name()) - m.add_string(key) + # Use certificate contents, if available, plain pubkey otherwise + if key.public_blob: + m.add_string(key.public_blob.key_type) + m.add_string(key.public_blob.key_blob) + else: + m.add_string(key.get_name()) + m.add_string(key) return m.asbytes() def wait_for_response(self, event): @@ -254,35 +255,39 @@ class AuthHandler (object): m.add_byte(cMSG_SERVICE_ACCEPT) m.add_string(service) self.transport._send_message(m) + banner, language = self.transport.server_object.get_banner() + if banner: + m = Message() + m.add_byte(cMSG_USERAUTH_BANNER) + m.add_string(banner) + m.add_string(language) + self.transport._send_message(m) return # dunno this one self._disconnect_service_not_available() def _pkcs11_get_public_key(self): - if self.pkcs11publickey is None: - if self.pkcs11session is None: - self.pkcs11publickey = pkcs11_get_public_key() - else: - self.pkcs11publickey = self.pkcs11session[1] - - if self.pkcs11publickey is None or len(self.pkcs11publickey) < 1: - raise SSHException("Invalid ssh public key returned by \ - pkcs15-tool") - return str(self.pkcs11publickey) + if "public_key" not in self.pkcs11session: + raise PKCS11Exception("pkcs11 session does not have a public_key") + if len(self.pkcs11session["public_key"]) < 1: + raise PKCS11Exception("pkcs11 session contains invalid public \ + key %s" + % self.pkcs11session["public_key"]) + return self.pkcs11session["public_key"] def _pkcs11_sign_ssh_data(self, blob, key_name): - if not os.path.isfile(self.pkcs11provider): - raise SSHException("pkcs11provider path is not valid: %s" - % self.pkcs11provider) - lib = cdll.LoadLibrary(self.pkcs11provider) - if self.pkcs11session is None: - (session, - public_key, - keyret) = pkcs11_open_session(self.pkcs11provider, - self.pkcs11pin, - self.pkcs11publickey) - else: - (session, public_key, keyret) = self.pkcs11session + if "provider" not in self.pkcs11session: + raise PKCS11Exception("pkcs11 session does not have a provider") + if "session" not in self.pkcs11session: + raise PKCS11Exception("pkcs11 session does not have a session") + if "keyret" not in self.pkcs11session: + raise PKCS11Exception("pkcs11 session does not have a keyret") + if not os.path.isfile(self.pkcs11session["provider"]): + raise PKCS11Exception("pkcs11provider does not exist: %s" + % self.pkcs11session["provider"]) + lib = cdll.LoadLibrary(self.pkcs11session["provider"]) + session = self.pkcs11session["session"] + keyret = self.pkcs11session["keyret"] # Init Signing Data class ck_mechanism(Structure): @@ -294,7 +299,7 @@ class AuthHandler (object): self.pkcs11_lock.acquire() res = lib.C_SignInit(session, byref(mech), keyret) if res != 0: - raise SSHException("PKCS11 Failed to Sign Init") + raise PKCS11Exception("PKCS11 Failed to Sign Init") in_buffer = (c_char * 1025)() sig_buffer = (c_char * 512)() @@ -305,11 +310,8 @@ class AuthHandler (object): r = c_int(len(blob)) res = lib.C_Sign(session, in_buffer, r, sig_buffer, byref(sig_len)) if res != 0: - raise SSHException("PKCS11 Failed to Sign") + raise PKCS11Exception("PKCS11 Failed to Sign") - if self.pkcs11session is None: - # Let User Manage The Session, Do Not Close - pkcs11_close_session(self.pkcs11provider) self.pkcs11_lock.release() # Convert ctype char array to python string @@ -336,11 +338,17 @@ class AuthHandler (object): password = bytestring(self.password) m.add_string(password) elif self.auth_method == 'publickey': - if self.pkcs11pin is None: - # Private Key + if self.pkcs11session is None: m.add_boolean(True) - m.add_string(self.private_key.get_name()) - m.add_string(self.private_key) + # Private Key + # Use certificate contents, if available, plain pubkey + # otherwise + if self.private_key.public_blob: + m.add_string(self.private_key.public_blob.key_type) + m.add_string(self.private_key.public_blob.key_blob) + else: + 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(blob) @@ -351,7 +359,8 @@ class AuthHandler (object): fields = pubkey_source.split(' ') if len(fields) < 2: - SSHException("Not enough fields found in pkcs11 key") + raise PKCS11Exception("Not enough fields \ + found in pkcs11 key") keytype = fields[0] pub_key = fields[1] @@ -566,10 +575,9 @@ class AuthHandler (object): INFO, 'Auth rejected: public key: %s' % str(e)) key = None - except: - self.transport._log( - INFO, - 'Auth rejected: unsupported or mangled public key') + except Exception as e: + msg = 'Auth rejected: unsupported or mangled public key ({0}: {1})' # noqa + self.transport._log(INFO, msg.format(e.__class__.__name__, e)) key = None if key is None: self._disconnect_no_more_auth() diff --git a/paramiko/client.py b/paramiko/client.py index cdfde2ec..f4e6791f 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -227,8 +227,6 @@ class SSHClient (ClosingContextManager): gss_deleg_creds=True, gss_host=None, banner_timeout=None, - pkcs11pin=None, - pkcs11provider=None, pkcs11session=None, auth_timeout=None, ): @@ -243,9 +241,23 @@ class SSHClient (ClosingContextManager): Authentication is attempted in the following order of priority: - The ``pkey`` or ``key_filename`` passed in (if any) + + - ``key_filename`` may contain OpenSSH public certificate paths + as well as regular private-key paths; when files ending in + ``-cert.pub`` are found, they are assumed to match a private + key, and both components will be loaded. (The private key + itself does *not* need to be listed in ``key_filename`` for + this to occur - *just* the certificate.) + - Any key we can find through an SSH agent - Any "id_rsa", "id_dsa" or "id_ecdsa" key discoverable in ``~/.ssh/`` + + - When OpenSSH-style public certificates exist that match an + existing such private key (so e.g. one has ``id_rsa`` and + ``id_rsa-cert.pub``) the certificate will be loaded alongside + the private key and used for authentication. + - Plain username/password auth, if a password was given If a private key requires a password to unlock it, and a password is @@ -260,8 +272,8 @@ class SSHClient (ClosingContextManager): a password to use for authentication or for unlocking a private key :param .PKey pkey: an optional private key to use for authentication :param str key_filename: - the filename, or list of filenames, of optional private key(s) to - try for authentication + the filename, or list of filenames, of optional private key(s) + and/or certs to try for authentication :param float timeout: an optional timeout (in seconds) for the TCP connect :param bool allow_agent: @@ -282,12 +294,11 @@ class SSHClient (ClosingContextManager): The targets name in the kerberos database. default: hostname :param float banner_timeout: an optional timeout (in seconds) to wait for the SSH banner to be presented. - :param str pkcs11pin: If using PKCS11, this will be the pin of your - token or smartcard. - :param str pkcs11provider: If using PKCS11, this will be the provider - for the PKCS11 interface. Example: /usr/local/lib/opensc-pkcs11.so. - :param str pkcs11session: If using PKCS11 in a multithreaded - application you can share the session between threads. + :param str pkcs11session: The pkcs11 session obtained by calling + pkcs11_open_session. If using PKCS11 in a multithreaded application + you can share the session between threads. You can make multiple + calls to connect using the same pkcs11 session. You must call + pkcs11_close_session to cleanly close the session. :param float auth_timeout: an optional timeout (in seconds) to wait for an authentication response. @@ -401,7 +412,7 @@ class SSHClient (ClosingContextManager): gss_host = hostname self._auth(username, password, pkey, key_filenames, allow_agent, look_for_keys, gss_auth, gss_kex, gss_deleg_creds, gss_host, - pkcs11pin, pkcs11provider, pkcs11session) + pkcs11session) def close(self): """ @@ -509,13 +520,45 @@ class SSHClient (ClosingContextManager): """ return self._transport + def _key_from_filepath(self, filename, klass, password): + """ + Attempt to derive a `.PKey` from given string path ``filename``: + + - If ``filename`` appears to be a cert, the matching private key is + loaded. + - Otherwise, the filename is assumed to be a private key, and the + matching public cert will be loaded if it exists. + """ + cert_suffix = '-cert.pub' + # Assume privkey, not cert, by default + if filename.endswith(cert_suffix): + key_path = filename[:-len(cert_suffix)] + cert_path = filename + else: + key_path = filename + cert_path = filename + cert_suffix + # Blindly try the key path; if no private key, nothing will work. + key = klass.from_private_key_file(key_path, password) + # TODO: change this to 'Loading' instead of 'Trying' sometime; probably + # when #387 is released, since this is a critical log message users are + # likely testing/filtering for (bah.) + msg = "Trying discovered key {0} in {1}".format( + hexlify(key.get_fingerprint()), key_path, + ) + self._log(DEBUG, msg) + # Attempt to load cert if it exists. + if os.path.isfile(cert_path): + key.load_certificate(cert_path) + self._log(DEBUG, "Adding public certificate {0}".format(cert_path)) + return key + def _auth(self, username, password, pkey, key_filenames, allow_agent, look_for_keys, gss_auth, gss_kex, gss_deleg_creds, gss_host, - pkcs11pin, pkcs11provider, pkcs11session): + pkcs11session): """ Try, in order: - - The key passed in, if one was passed in. + - The key(s) passed in, if one was passed in. - Any key we can find through an SSH agent (if allowed). - Any "id_rsa", "id_dsa" or "id_ecdsa" key discoverable in ~/.ssh/ (if allowed). @@ -530,11 +573,10 @@ class SSHClient (ClosingContextManager): two_factor_types = set(['keyboard-interactive', 'password']) # PKCS11 / Smartcard authentication - if username is not None and pkcs11pin is not None and \ - pkcs11provider is not None: + if username is not None and pkcs11session is not None: try: allowed_types = set(self._transport.auth_pkcs11(username, - pkcs11pin, pkcs11provider, pkcs11session)) + pkcs11session)) two_factor = (allowed_types & two_factor_types) if not two_factor: return @@ -579,12 +621,9 @@ class SSHClient (ClosingContextManager): for key_filename in key_filenames: for pkey_class in (RSAKey, DSSKey, ECDSAKey, Ed25519Key): 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)) + key = self._key_from_filepath( + key_filename, pkey_class, password, + ) allowed_types = set( self._transport.auth_publickey(username, key)) two_factor = (allowed_types & two_factor_types) @@ -630,19 +669,19 @@ class SSHClient (ClosingContextManager): "~/%s/id_%s" % (directory, name) ) if os.path.isfile(full_path): + # TODO: only do this append if below did not run keyfiles.append((keytype, full_path)) + if os.path.isfile(full_path + '-cert.pub'): + keyfiles.append((keytype, full_path + '-cert.pub')) if not look_for_keys: keyfiles = [] for pkey_class, filename in keyfiles: try: - key = pkey_class.from_private_key_file(filename, password) - self._log( - DEBUG, - 'Trying discovered key %s in %s' % ( - hexlify(key.get_fingerprint()), filename)) - + key = self._key_from_filepath( + filename, pkey_class, password, + ) # for 2-factor auth a successfully auth'd key will result # in ['password'] allowed_types = set( diff --git a/paramiko/compress.py b/paramiko/compress.py index b55f0b1d..5073109c 100644 --- a/paramiko/compress.py +++ b/paramiko/compress.py @@ -25,7 +25,8 @@ import zlib class ZlibCompressor (object): def __init__(self): - self.z = zlib.compressobj(9) + # Use the default level of zlib compression + self.z = zlib.compressobj() def __call__(self, data): return self.z.compress(data) + self.z.flush(zlib.Z_FULL_FLUSH) diff --git a/paramiko/dsskey.py b/paramiko/dsskey.py index 9af5d0c1..ac1d4c2e 100644 --- a/paramiko/dsskey.py +++ b/paramiko/dsskey.py @@ -49,6 +49,7 @@ class DSSKey(PKey): self.g = None self.y = None self.x = None + self.public_blob = None if file_obj is not None: self._from_private_key(file_obj, password) return @@ -60,10 +61,11 @@ class DSSKey(PKey): if vals is not None: self.p, self.q, self.g, self.y = vals else: - if msg is None: - raise SSHException('Key object may not be empty') - if msg.get_text() != 'ssh-dss': - raise SSHException('Invalid key') + self._check_type_and_load_cert( + msg=msg, + key_type='ssh-dss', + cert_type='ssh-dss-cert-v01@openssh.com', + ) self.p = msg.get_mpint() self.q = msg.get_mpint() self.g = msg.get_mpint() @@ -106,9 +108,8 @@ class DSSKey(PKey): ) ) ).private_key(backend=default_backend()) - signer = key.signer(hashes.SHA1()) - signer.update(data) - r, s = decode_dss_signature(signer.finalize()) + sig = key.sign(data, hashes.SHA1()) + r, s = decode_dss_signature(sig) m = Message() m.add_string('ssh-dss') @@ -146,10 +147,8 @@ class DSSKey(PKey): g=self.g ) ).public_key(backend=default_backend()) - verifier = key.verifier(signature, hashes.SHA1()) - verifier.update(data) try: - verifier.verify() + key.verify(signature, data, hashes.SHA1()) except InvalidSignature: return False else: diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py index fa850c2e..1bb5676f 100644 --- a/paramiko/ecdsakey.py +++ b/paramiko/ecdsakey.py @@ -105,6 +105,7 @@ class ECDSAKey(PKey): vals=None, file_obj=None, validate_point=True): self.verifying_key = None self.signing_key = None + self.public_blob = None if file_obj is not None: self._from_private_key(file_obj, password) return @@ -118,12 +119,29 @@ class ECDSAKey(PKey): c_class = self.signing_key.curve.__class__ self.ecdsa_curve = self._ECDSA_CURVES.get_by_curve_class(c_class) else: - if msg is None: - raise SSHException('Key object may not be empty') + # Must set ecdsa_curve first; subroutines called herein may need to + # spit out our get_name(), which relies on this. + key_type = msg.get_text() + # But this also means we need to hand it a real key/curve + # identifier, so strip out any cert business. (NOTE: could push + # that into _ECDSACurveSet.get_by_key_format_identifier(), but it + # feels more correct to do it here?) + suffix = '-cert-v01@openssh.com' + if key_type.endswith(suffix): + key_type = key_type[:-len(suffix)] self.ecdsa_curve = self._ECDSA_CURVES.get_by_key_format_identifier( - msg.get_text()) - if self.ecdsa_curve is None: - raise SSHException('Invalid key') + key_type + ) + key_types = self._ECDSA_CURVES.get_key_format_identifier_list() + cert_types = [ + '{0}-cert-v01@openssh.com'.format(x) + for x in key_types + ] + self._check_type_and_load_cert( + msg=msg, + key_type=key_types, + cert_type=cert_types, + ) curvename = msg.get_text() if curvename != self.ecdsa_curve.nist_name: raise SSHException("Can't handle curve of type %s" % curvename) @@ -179,9 +197,7 @@ class ECDSAKey(PKey): def sign_ssh_data(self, data): ecdsa = ec.ECDSA(self.ecdsa_curve.hash_object()) - signer = self.signing_key.signer(ecdsa) - signer.update(data) - sig = signer.finalize() + sig = self.signing_key.sign(data, ecdsa) r, s = decode_dss_signature(sig) m = Message() @@ -196,12 +212,10 @@ class ECDSAKey(PKey): sigR, sigS = self._sigdecode(sig) signature = encode_dss_signature(sigR, sigS) - verifier = self.verifying_key.verifier( - signature, ec.ECDSA(self.ecdsa_curve.hash_object()) - ) - verifier.update(data) try: - verifier.verify() + self.verifying_key.verify( + signature, data, ec.ECDSA(self.ecdsa_curve.hash_object()) + ) except InvalidSignature: return False else: diff --git a/paramiko/ed25519key.py b/paramiko/ed25519key.py index a50d68bc..434b3f45 100644 --- a/paramiko/ed25519key.py +++ b/paramiko/ed25519key.py @@ -45,18 +45,36 @@ def unpad(data): class Ed25519Key(PKey): - def __init__(self, msg=None, data=None, filename=None, password=None): + """ + Representation of an `Ed25519 <https://ed25519.cr.yp.to/>`_ key. + + .. note:: + Ed25519 key support was added to OpenSSH in version 6.5. + + .. versionadded:: 2.2 + .. versionchanged:: 2.3 + Added a ``file_obj`` parameter to match other key classes. + """ + def __init__(self, msg=None, data=None, filename=None, password=None, + file_obj=None): verifying_key = signing_key = None if msg is None and data is not None: msg = Message(data) if msg is not None: - if msg.get_text() != "ssh-ed25519": - raise SSHException("Invalid key") + self._check_type_and_load_cert( + msg=msg, + key_type="ssh-ed25519", + cert_type="ssh-ed25519-cert-v01@openssh.com", + ) verifying_key = nacl.signing.VerifyKey(msg.get_binary()) elif filename is not None: with open(filename, "r") as f: data = self._read_private_key("OPENSSH", f) - signing_key = self._parse_signing_key_data(data, password) + elif file_obj is not None: + data = self._read_private_key("OPENSSH", file_obj) + + if filename or file_obj: + signing_key = self._parse_signing_key_data(data, password) if signing_key is None and verifying_key is None: raise ValueError("need a key") diff --git a/paramiko/pkcs11.py b/paramiko/pkcs11.py index f7b7f6bf..e8f85b55 100644 --- a/paramiko/pkcs11.py +++ b/paramiko/pkcs11.py @@ -3,13 +3,30 @@ from ctypes import (c_void_p, c_ulong, c_int, c_char_p, cast, addressof, import subprocess import os import errno -from paramiko.ssh_exception import SSHException +from paramiko.ssh_exception import AuthenticationException, SSHException -def pkcs11_get_public_key(): +class PKCS11Exception (SSHException): + """ + Exception raised by failures in the PKCS11 api or logic errors. + """ + pass + + +class PKCS11AuthenticationException (AuthenticationException): + """ + Exception raised when pkcs11 authentication failed for some reason. + """ + pass + + +def pkcs11_get_public_key(keyid="01"): + """ + :param str pkcs11keyid: The keyid to use for the pkcs11 session. + """ public_key = None try: - p = subprocess.Popen(["pkcs15-tool", "--read-ssh-key", "01"], + p = subprocess.Popen(["pkcs15-tool", "--read-ssh-key", keyid], stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) @@ -18,23 +35,34 @@ def pkcs11_get_public_key(): public_key = out except OSError as error: if error.errno == errno.ENOENT: - raise SSHException("Cannot find pkcs15-tool in PATH.") + raise PKCS11Exception("Cannot find pkcs15-tool in PATH.") else: raise if public_key is None or len(public_key) < 1: - raise SSHException("Invalid ssh public key returned by pkcs15-tool") - - return str(public_key) - - -def pkcs11_open_session(pkcs11provider, pkcs11pin, pkcs11publickey=None): - public_key = "" + raise PKCS11Exception("Invalid ssh public key returned by pkcs15-tool") + + return public_key.decode('utf-8') + + +def pkcs11_open_session(pkcs11provider, pkcs11pin, pkcs11keyid="01", + pkcs11slot=0, pkcs11publickey=None): + """ + :param str pkcs11provider: If using PKCS11, this will be the provider + for the PKCS11 interface. Example: /usr/local/lib/opensc-pkcs11.so. + :param str pkcs11pin: If using PKCS11, this will be the pin of your + token or smartcard. + :param str pkcs11keyid: The keyid to use for the pkcs11 session. + :param int pkcs11slot: The slot id used for establishing the session. + :param str pkcs11publickey: If left the default (None), the public key + will be detected using OpenSC pkcs15-tool. Alternatively you can + provide it manually using this argument. + """ session = None # Get Public SSH Key if pkcs11publickey is None: - public_key = pkcs11_get_public_key() + pkcs11publickey = pkcs11_get_public_key(pkcs11keyid) class ck_c_initialize_args(Structure): _fields_ = [('CreateMutex', c_void_p), ('DestroyMutex', c_void_p), @@ -52,28 +80,28 @@ def pkcs11_open_session(pkcs11provider, pkcs11pin, pkcs11publickey=None): # Init if not os.path.isfile(pkcs11provider): - raise Exception("pkcs11provider path is not valid: %s" - % pkcs11provider) + raise PKCS11Exception("pkcs11provider path is not valid: %s" + % pkcs11provider) lib = cdll.LoadLibrary(pkcs11provider) res = lib.C_Initialize(byref(init_args)) if res != 0: - raise Exception("PKCS11 Failed to Initialize") + raise PKCS11Exception("PKCS11 Failed to Initialize") # Session - slot = c_ulong(0) # 0 is the slot number + slot = c_ulong(pkcs11slot) # slot number session = c_ulong() flags = c_int(6) # CKF_SERIAL_SESSION (100b), CKF_RW_SESSION(10b) res = lib.C_OpenSession(slot, flags, 0, 0, byref(session)) if res != 0: - raise Exception("PKCS11 Failed to Open Session") + raise PKCS11Exception("PKCS11 Failed to Open Session") # Login login_type = c_int(1) # 1=USER PIN - str_pin = str(pkcs11pin) + str_pin = pkcs11pin.encode('utf-8') pin = c_char_p(str_pin) res = lib.C_Login(session, login_type, pin, len(str_pin)) if res != 0: - raise Exception("PKCS11 Login Failed") + raise PKCS11AuthenticationException("PKCS11 Login Failed") # Get object for key class ck_attribute(Structure): @@ -89,7 +117,7 @@ def pkcs11_open_session(pkcs11provider, pkcs11pin, pkcs11publickey=None): keyret = c_ulong() cls = c_ulong(3) # CKO_PRIVATE_KEY - objid_str = "1" + objid_str = pkcs11keyid.encode('utf-8') objid = c_char_p(objid_str) objid_len = c_ulong(len(objid_str)) attrs[0].type = c_ulong(0) # CKA_CLASS @@ -100,23 +128,32 @@ def pkcs11_open_session(pkcs11provider, pkcs11pin, pkcs11publickey=None): attrs[1].value_len = objid_len res = lib.C_FindObjectsInit(session, attrs, nattrs) if res != 0: - raise Exception("PKCS11 Failed to Find Init") + raise PKCS11Exception("PKCS11 Failed to Find Init") res = lib.C_FindObjects(session, byref(keyret), 1, byref(count)) if res != 0: - raise Exception("PKCS11 Failed to Find Objects") + raise PKCS11Exception("PKCS11 Failed to Find Objects") res = lib.C_FindObjectsFinal(session) if res != 0: - raise Exception("PKCS11 Failed to Find Objects Final") + raise PKCS11Exception("PKCS11 Failed to Find Objects Final") - return (session, public_key, keyret) + return {"session": session, "public_key": pkcs11publickey, + "keyret": keyret, "provider": pkcs11provider} -def pkcs11_close_session(pkcs11provider): +def pkcs11_close_session(pkcs11session): + """ + :param str pkcs11session: pkcs11 session obtained + by calling pkcs11_open_session + """ + if "provider" not in pkcs11session: + raise PKCS11Exception("pkcs11 session is missing the provider,\ + the session is not valid") + pkcs11provider = pkcs11session["provider"] if not os.path.isfile(pkcs11provider): - raise Exception("pkcs11provider path is not valid: %s" - % pkcs11provider) + raise PKCS11Exception("pkcs11provider path is not valid: %s" + % pkcs11provider) lib = cdll.LoadLibrary(pkcs11provider) # Wrap things up res = lib.C_Finalize(c_int(0)) if res != 0: - raise Exception("PKCS11 Failed to Finalize") + raise PKCS11Exception("PKCS11 Failed to Finalize") diff --git a/paramiko/pkey.py b/paramiko/pkey.py index 35a26fc7..67723be2 100644 --- a/paramiko/pkey.py +++ b/paramiko/pkey.py @@ -31,8 +31,9 @@ from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher from paramiko import util from paramiko.common import o600 -from paramiko.py3compat import u, encodebytes, decodebytes, b +from paramiko.py3compat import u, encodebytes, decodebytes, b, string_types from paramiko.ssh_exception import SSHException, PasswordRequiredException +from paramiko.message import Message class PKey(object): @@ -363,3 +364,170 @@ class PKey(object): format, encryption ).decode()) + + def _check_type_and_load_cert(self, msg, key_type, cert_type): + """ + Perform message type-checking & optional certificate loading. + + This includes fast-forwarding cert ``msg`` objects past the nonce, so + that the subsequent fields are the key numbers; thus the caller may + expect to treat the message as key material afterwards either way. + + The obtained key type is returned for classes which need to know what + it was (e.g. ECDSA.) + """ + # Normalization; most classes have a single key type and give a string, + # but eg ECDSA is a 1:N mapping. + key_types = key_type + cert_types = cert_type + if isinstance(key_type, string_types): + key_types = [key_types] + if isinstance(cert_types, string_types): + cert_types = [cert_types] + # Can't do much with no message, that should've been handled elsewhere + if msg is None: + raise SSHException('Key object may not be empty') + # First field is always key type, in either kind of object. (make sure + # we rewind before grabbing it - sometimes caller had to do their own + # introspection first!) + msg.rewind() + type_ = msg.get_text() + # Regular public key - nothing special to do besides the implicit + # type check. + if type_ in key_types: + pass + # OpenSSH-compatible certificate - store full copy as .public_blob + # (so signing works correctly) and then fast-forward past the + # nonce. + elif type_ in cert_types: + # This seems the cleanest way to 'clone' an already-being-read + # message; they're *IO objects at heart and their .getvalue() + # always returns the full value regardless of pointer position. + self.load_certificate(Message(msg.asbytes())) + # Read out nonce as it comes before the public numbers. + # TODO: usefully interpret it & other non-public-number fields + # (requires going back into per-type subclasses.) + msg.get_string() + else: + err = 'Invalid key (class: {0}, data type: {1}' + raise SSHException(err.format(self.__class__.__name__, type_)) + + def load_certificate(self, value): + """ + Supplement the private key contents with data loaded from an OpenSSH + public key (``.pub``) or certificate (``-cert.pub``) file, a string + containing such a file, or a `.Message` object. + + The .pub contents adds no real value, since the private key + file includes sufficient information to derive the public + key info. For certificates, however, this can be used on + the client side to offer authentication requests to the server + based on certificate instead of raw public key. + + See: + https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys + + Note: very little effort is made to validate the certificate contents, + that is for the server to decide if it is good enough to authenticate + successfully. + """ + if isinstance(value, Message): + constructor = 'from_message' + elif os.path.isfile(value): + constructor = 'from_file' + else: + constructor = 'from_string' + blob = getattr(PublicBlob, constructor)(value) + if not blob.key_type.startswith(self.get_name()): + err = "PublicBlob type {0} incompatible with key type {1}" + raise ValueError(err.format(blob.key_type, self.get_name())) + self.public_blob = blob + + +# General construct for an OpenSSH style Public Key blob +# readable from a one-line file of the format: +# <key-name> <base64-blob> [<comment>] +# Of little value in the case of standard public keys +# {ssh-rsa, ssh-dss, ssh-ecdsa, ssh-ed25519}, but should +# provide rudimentary support for {*-cert.v01} +class PublicBlob(object): + """ + OpenSSH plain public key or OpenSSH signed public key (certificate). + + Tries to be as dumb as possible and barely cares about specific + per-key-type data. + + ..note:: + Most of the time you'll want to call `from_file`, `from_string` or + `from_message` for useful instantiation, the main constructor is + basically "I should be using ``attrs`` for this." + """ + def __init__(self, type_, blob, comment=None): + """ + Create a new public blob of given type and contents. + + :param str type_: Type indicator, eg ``ssh-rsa``. + :param blob: The blob bytes themselves. + :param str comment: A comment, if one was given (e.g. file-based.) + """ + self.key_type = type_ + self.key_blob = blob + self.comment = comment + + @classmethod + def from_file(cls, filename): + """ + Create a public blob from a ``-cert.pub``-style file on disk. + """ + with open(filename) as f: + string = f.read() + return cls.from_string(string) + + @classmethod + def from_string(cls, string): + """ + Create a public blob from a ``-cert.pub``-style string. + """ + fields = string.split(None, 2) + if len(fields) < 2: + msg = "Not enough fields for public blob: {0}" + raise ValueError(msg.format(fields)) + key_type = fields[0] + key_blob = decodebytes(b(fields[1])) + try: + comment = fields[2].strip() + except IndexError: + comment = None + # Verify that the blob message first (string) field matches the + # key_type + m = Message(key_blob) + blob_type = m.get_text() + if blob_type != key_type: + msg = "Invalid PublicBlob contents: key type={0!r}, but blob type={1!r}" # noqa + raise ValueError(msg.format(key_type, blob_type)) + # All good? All good. + return cls(type_=key_type, blob=key_blob, comment=comment) + + @classmethod + def from_message(cls, message): + """ + Create a public blob from a network `.Message`. + + Specifically, a cert-bearing pubkey auth packet, because by definition + OpenSSH-style certificates 'are' their own network representation." + """ + type_ = message.get_text() + return cls(type_=type_, blob=message.asbytes()) + + def __str__(self): + ret = '{0} public key/certificate'.format(self.key_type) + if self.comment: + ret += "- {0}".format(self.comment) + return ret + + def __eq__(self, other): + # Just piggyback on Message/BytesIO, since both of these should be one. + return self and other and self.key_blob == other.key_blob + + def __ne__(self, other): + return not self == other diff --git a/paramiko/rsakey.py b/paramiko/rsakey.py index b5107515..8dfcfb01 100644 --- a/paramiko/rsakey.py +++ b/paramiko/rsakey.py @@ -40,6 +40,7 @@ class RSAKey(PKey): def __init__(self, msg=None, data=None, filename=None, password=None, key=None, file_obj=None): self.key = None + self.public_blob = None if file_obj is not None: self._from_private_key(file_obj, password) return @@ -51,10 +52,11 @@ class RSAKey(PKey): if key is not None: self.key = key else: - if msg is None: - raise SSHException('Key object may not be empty') - if msg.get_text() != 'ssh-rsa': - raise SSHException('Invalid key') + self._check_type_and_load_cert( + msg=msg, + key_type='ssh-rsa', + cert_type='ssh-rsa-cert-v01@openssh.com', + ) self.key = rsa.RSAPublicNumbers( e=msg.get_mpint(), n=msg.get_mpint() ).public_key(default_backend()) @@ -103,12 +105,11 @@ class RSAKey(PKey): return isinstance(self.key, rsa.RSAPrivateKey) def sign_ssh_data(self, data): - signer = self.key.signer( + sig = self.key.sign( + data, padding=padding.PKCS1v15(), algorithm=hashes.SHA1(), ) - signer.update(data) - sig = signer.finalize() m = Message() m.add_string('ssh-rsa') @@ -122,14 +123,10 @@ class RSAKey(PKey): if isinstance(key, rsa.RSAPrivateKey): key = key.public_key() - verifier = key.verifier( - signature=msg.get_binary(), - padding=padding.PKCS1v15(), - algorithm=hashes.SHA1(), - ) - verifier.update(data) try: - verifier.verify() + key.verify( + msg.get_binary(), data, padding.PKCS1v15(), hashes.SHA1() + ) except InvalidSignature: return False else: diff --git a/paramiko/server.py b/paramiko/server.py index adc606bf..c96126e9 100644 --- a/paramiko/server.py +++ b/paramiko/server.py @@ -570,6 +570,19 @@ class ServerInterface (object): """ return False + def get_banner(self): + """ + A pre-login banner to display to the user. The message may span + multiple lines separated by crlf pairs. The language should be in + rfc3066 style, for example: en-US + + The default implementation always returns ``(None, None)``. + + :returns: A tuple containing the banner and language code. + + .. versionadded:: 2.3 + """ + return (None, None) class InteractiveQuery (object): """ diff --git a/paramiko/transport.py b/paramiko/transport.py index 989f9ed0..d782415b 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -1387,15 +1387,10 @@ class Transport(threading.Thread, ClosingContextManager): return [] return self.auth_handler.wait_for_response(my_event) - def auth_pkcs11(self, username, pkcs11pin, pkcs11provider, pkcs11session, - event=None): + def auth_pkcs11(self, username, pkcs11session, event=None): """ :param str username: the username to authenticate as - :param str pkcs11pin: pin to authenticate to smartcard - :param str pkcs11provider: pkcs11 provider such as opensc. - Example: /usr/local/lib/opensc-pkcs11.so. - :param str pkcs11session: pkcs11 session used for multithreaded - applications. + :param str pkcs11session: session obtained from pkcs11_open_session :param .threading.Event event: an event to trigger when the authentication attempt is complete (whether it was successful or not) @@ -1416,8 +1411,7 @@ class Transport(threading.Thread, ClosingContextManager): else: my_event = event self.auth_handler = AuthHandler(self) - self.auth_handler.auth_pkcs11(username, pkcs11pin, pkcs11provider, - pkcs11session, my_event) + self.auth_handler.auth_pkcs11(username, pkcs11session, my_event) if event is not None: # caller wants to wait for event themselves return [] @@ -1851,8 +1845,6 @@ class Transport(threading.Thread, ClosingContextManager): continue elif ptype == MSG_DISCONNECT: self._parse_disconnect(m) - self.active = False - self.packetizer.close() break elif ptype == MSG_DEBUG: self._parse_debug(m) @@ -1876,8 +1868,7 @@ class Transport(threading.Thread, ClosingContextManager): self._log(DEBUG, 'Ignoring message for dead channel %d' % chanid) # noqa else: self._log(ERROR, 'Channel request for unknown channel %d' % chanid) # noqa - self.active = False - self.packetizer.close() + break elif ( self.auth_handler is not None and ptype in self.auth_handler._handler_table @@ -76,7 +76,7 @@ setup( ], install_requires=[ 'bcrypt>=3.1.3', - 'cryptography>=1.1', + 'cryptography>=1.5', 'pynacl>=1.0.1', 'pyasn1>=0.1.7', ], diff --git a/sites/docs/api/keys.rst b/sites/docs/api/keys.rst index c6412f77..a456f502 100644 --- a/sites/docs/api/keys.rst +++ b/sites/docs/api/keys.rst @@ -21,3 +21,8 @@ ECDSA ===== .. automodule:: paramiko.ecdsakey + +Ed25519 +======= + +.. automodule:: paramiko.ed25519key diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index 97b2edd8..4f7303b4 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -12,6 +12,68 @@ Changelog still importable from its previous home, ``hostkeys.py``.) * :feature:`827` Add support for PKCS #11 which enables the use of smartcards and other cryptographic tokens. +* :support:`979` Update how we use `Cryptography <https://cryptography.io>`_'s + signature/verification methods so we aren't relying on a deprecated API. + Thanks to Paul Kehrer for the patch. + + .. warning:: + This bumps the minimum Cryptography version from 1.1 to 1.5. Such an + upgrade should be backwards compatible and easy to do. See `their changelog + <https://cryptography.io/en/latest/changelog/>`_ for additional details. +* :support:`-` Ed25519 keys never got proper API documentation support; this + has been fixed. +* :feature:`1026` Update `~paramiko.ed25519key.Ed25519Key` so its constructor + offers the same ``file_obj`` parameter as its sibling key classes. Credit: + Michal Kuffa. +* :feature:`1013` Added pre-authentication banner support for the server + interface (`ServerInterface.get_banner + <paramiko.server.ServerInterface.get_banner>` plus related support in + ``Transport/AuthHandler``.) Patch courtesy of Dennis Kaarsemaker. +* :bug:`60 major` (via :issue:`1037`) Paramiko originally defaulted to zlib + compression level 9 (when one connects with ``compression=True``; it defaults + to off.) This has been found to be quite wasteful and tends to cause much + longer transfers in most cases, than is necessary. + + OpenSSH defaults to compression level 6, which is a much more reasonable + setting (nearly identical compression characteristics but noticeably, + sometimes significantly, faster transmission); Paramiko now uses this value + instead. + + Thanks to Damien Dubé for the report and ``@DrNeutron`` for investigating & + submitting the patch. +* :support:`-` Display exception type and message when logging auth-rejection + messages (ones reading ``Auth rejected: unsupported or mangled public key``); + previously this error case had a bare except and did not display exactly why + the key failed. It will now append info such as ``KeyError: + 'some-unknown-type-string'`` or similar. +* :feature:`1042` (also partially :issue:`531`) Implement basic client-side + certificate authentication (as per the OpenSSH vendor extension.) + + The core implementation is `PKey.load_certificate + <paramiko.pkey.PKey.load_certificate>` and its corresponding ``.public_blob`` + attribute on key objects, which is honored in the auth and transport modules. + Additionally, `SSHClient.connect <paramiko.client.SSHClient.connect>` will + now automatically load certificate data alongside private key data when one + has appropriately-named cert files (e.g. ``id_rsa-cert.pub``) - see its + docstring for details. + + Thanks to Jason Rigby for a first draft (:issue:`531`) and to Paul Kapp for + the second draft, upon which the current functionality has been based (with + modifications.) + + .. note:: + This support is client-focused; Paramiko-driven server code is capable of + handling cert-bearing pubkey auth packets, *but* it does not interpret any + cert-specific fields, so the end result is functionally identical to a + vanilla pubkey auth process (and thus requires e.g. prepopulated + authorized-keys data.) We expect full server-side cert support to follow + later. + +* :support:`1041` Modify logic around explicit disconnect + messages, and unknown-channel situations, so that they rely on centralized + shutdown code instead of running their own. This is at worst removing some + unnecessary code, and may help with some situations where Paramiko hangs at + the end of a session. Thanks to Paul Kapp for the patch. * :support:`1012` (via :issue:`1016`) Enhance documentation around the new `SFTP.posix_rename <paramiko.sftp_client.SFTPClient.posix_rename>` method so it's referenced in the 'standard' ``rename`` method for increased visibility. @@ -4,6 +4,7 @@ from shutil import rmtree, copytree from invoke import Collection, task from invocations.docs import docs, www, sites from invocations.packaging.release import ns as release_coll, publish +from invocations.testing import count_errors # Until we move to spec-based testing @@ -49,7 +50,7 @@ def release(ctx, sdist=True, wheel=True, sign=True, dry_run=False): # aliasing, defaults etc. release_coll.tasks['publish'] = release -ns = Collection(test, coverage, release_coll, docs, www, sites) +ns = Collection(test, coverage, release_coll, docs, www, sites, count_errors) ns.configure({ 'packaging': { # NOTE: many of these are also set in kwarg defaults above; but having @@ -50,6 +50,7 @@ from test_client import SSHClientTest # XXX why shadow the above import? from test_gssapi import GSSAPITest from test_ssh_gss import GSSAuthTest from test_kex_gss import GSSKexTest +from tests.test_pkcs11 import Pkcs11Test default_host = 'localhost' default_user = os.environ.get('USER', 'nobody') @@ -160,6 +161,7 @@ def main(): suite.addTest(unittest.makeSuite(TransportTest)) suite.addTest(unittest.makeSuite(NoValidConnectionsErrorTest)) suite.addTest(unittest.makeSuite(SSHClientTest)) + suite.addTest(unittest.makeSuite(Pkcs11Test)) if options.use_sftp: suite.addTest(unittest.makeSuite(SFTPTest)) if options.use_big_file: diff --git a/tests/test_client.py b/tests/test_client.py index e912d5b2..7ada13da 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -35,6 +35,7 @@ import time from tests.util import test_path import paramiko +from paramiko.pkey import PublicBlob from paramiko.common import PY2 from paramiko.ssh_exception import SSHException, AuthenticationException @@ -47,10 +48,12 @@ FINGERPRINTS = { } -class NullServer (paramiko.ServerInterface): +class NullServer(paramiko.ServerInterface): def __init__(self, *args, **kwargs): # Allow tests to enable/disable specific key types self.__allowed_keys = kwargs.pop('allowed_keys', []) + # And allow them to set a (single...meh) expected public blob (cert) + self.__expected_public_blob = kwargs.pop('public_blob', None) super(NullServer, self).__init__(*args, **kwargs) def get_allowed_auths(self, username): @@ -71,12 +74,18 @@ class NullServer (paramiko.ServerInterface): expected = FINGERPRINTS[key.get_name()] except KeyError: return paramiko.AUTH_FAILED - if ( + # Base check: allowed auth type & fingerprint matches + happy = ( key.get_name() in self.__allowed_keys and key.get_fingerprint() == expected + ) + # Secondary check: if test wants assertions about cert data + if ( + self.__expected_public_blob is not None and + key.public_blob != self.__expected_public_blob ): - return paramiko.AUTH_SUCCESSFUL - return paramiko.AUTH_FAILED + happy = False + return paramiko.AUTH_SUCCESSFUL if happy else paramiko.AUTH_FAILED def check_channel_request(self, kind, chanid): return paramiko.OPEN_SUCCEEDED @@ -117,7 +126,7 @@ class SSHClientTest (unittest.TestCase): if hasattr(self, attr): getattr(self, attr).close() - def _run(self, allowed_keys=None, delay=0): + def _run(self, allowed_keys=None, delay=0, public_blob=None): if allowed_keys is None: allowed_keys = FINGERPRINTS.keys() self.socks, addr = self.sockl.accept() @@ -128,7 +137,7 @@ class SSHClientTest (unittest.TestCase): keypath = test_path('test_ecdsa_256.key') host_key = paramiko.ECDSAKey.from_private_key_file(keypath) self.ts.add_server_key(host_key) - server = NullServer(allowed_keys=allowed_keys) + server = NullServer(allowed_keys=allowed_keys, public_blob=public_blob) if delay: time.sleep(delay) self.ts.start_server(self.event, server) @@ -140,7 +149,9 @@ class SSHClientTest (unittest.TestCase): The exception is ``allowed_keys`` which is stripped and handed to the ``NullServer`` used for testing. """ - run_kwargs = {'allowed_keys': kwargs.pop('allowed_keys', None)} + run_kwargs = {} + for key in ('allowed_keys', 'public_blob'): + run_kwargs[key] = kwargs.pop(key, 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')) @@ -248,6 +259,40 @@ class SSHClientTest (unittest.TestCase): allowed_keys=['ecdsa-sha2-nistp256'], ) + def test_certs_allowed_as_key_filename_values(self): + # NOTE: giving cert path here, not key path. (Key path test is below. + # They're similar except for which path is given; the expected auth and + # server-side behavior is 100% identical.) + # NOTE: only bothered whipping up one cert per overall class/family. + for type_ in ('rsa', 'dss', 'ecdsa_256', 'ed25519'): + cert_path = test_path('test_{0}.key-cert.pub'.format(type_)) + self._test_connection( + key_filename=cert_path, + public_blob=PublicBlob.from_file(cert_path), + ) + + def test_certs_implicitly_loaded_alongside_key_filename_keys(self): + # NOTE: a regular test_connection() w/ test_rsa.key would incidentally + # test this (because test_xxx.key-cert.pub exists) but incidental tests + # stink, so NullServer and friends were updated to allow assertions + # about the server-side key object's public blob. Thus, we can prove + # that a specific cert was found, along with regular authorization + # succeeding proving that the overall flow works. + for type_ in ('rsa', 'dss', 'ecdsa_256', 'ed25519'): + key_path = test_path('test_{0}.key'.format(type_)) + self._test_connection( + key_filename=key_path, + public_blob=PublicBlob.from_file( + '{0}-cert.pub'.format(key_path) + ), + ) + + def test_default_key_locations_trigger_cert_loads_if_found(self): + # TODO: what it says on the tin: ~/.ssh/id_rsa tries to load + # ~/.ssh/id_rsa-cert.pub. Right now no other tests actually test that + # code path (!) so we're punting too, sob. + pass + def test_4_auto_add_policy(self): """ verify that SSHClient's AutoAddPolicy works. diff --git a/tests/test_dss.key-cert.pub b/tests/test_dss.key-cert.pub new file mode 100644 index 00000000..07fd5578 --- /dev/null +++ b/tests/test_dss.key-cert.pub @@ -0,0 +1 @@ +ssh-dss-cert-v01@openssh.com AAAAHHNzaC1kc3MtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgJA3GjLmg6JbIWxokW/c827lmPOSvSfPDIY586yICFqIAAACBAOeBpgNnfRzr/twmAQRu2XwWAp3CFtrVnug6s6fgwj/oLjYbVtjAy6pl/h0EKCWx2rf1IetyNsTxWrniA9I6HeDj65X1FyDkg6g8tvCnaNB8Xp/UUhuzHuGsMIipRxBxw9LF608EqZcj1E3ytktoW5B5OcjrkEoz3xG7C+rpIjYvAAAAFQDwz4UnmsGiSNu5iqjn3uTzwUpshwAAAIEAkxfFeY8P2wZpDjX0MimZl5wkoFQDL25cPzGBuB4OnB8NoUk/yjAHIIpEShw8V+LzouMK5CTJQo5+Ngw3qIch/WgRmMHy4kBq1SsXMjQCte1So6HBMvBPIW5SiMTmjCfZZiw4AYHK+B/JaOwaG9yRg2Ejg4Ok10+XFDxlqZo8Y+wAAACARmR7CCPjodxASvRbIyzaVpZoJ/Z6x7dAumV+ysrV1BVYd0lYukmnjO1kKBWApqpH1ve9XDQYN8zgxM4b16L21kpoWQnZtXrY3GZ4/it9kUgyB7+NwacIBlXa8cMDL7Q/69o0d54U0X/NeX5QxuYR6OMJlrkQB7oiW/P/1mwjQgEAAAAAAAAAAAAAAAEAAAAJdXNlcl90ZXN0AAAACAAAAAR0ZXN0AAAAAAAAAAD//////////wAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAEXAAAAB3NzaC1yc2EAAAADAQABAAABAQDskr46Umjxh3wo7PoPQsSVS3xt6+5PhwmXrnVtBBnkOo+zHRwQo8G8sY+Lc6oOOzA5GCSawKOwqE305GIDfB8/L9EKOkAjdN18imDjw/YuJFA4bl9yFhsXrCb1GZPJw0pJ0H0Eid9EldyMQAhGE49MWvnFMQl1TgO6YWq/g71xAFimge0LvVWijlbMy7O+nsGxSpinIprV5S9Viv8XC/ku89tadZfca1uxq751aGfAWGeYrVytpUl8UO0ggqH6BaUvkDU7rWh2n5RHUTvgzceKWnz5wqd8BngK37WmJjAgCtHCJS5ZRf6oJGj2QVcqc6cjvEFWsCuOKB4KAjktauWxAAABDwAAAAdzc2gtcnNhAAABAK6jweL231fRhFoybEGTOXJfj0lx55KpDsw9Q1rBvZhrSgwUr2dFr9HVcKe44mTC7CMtdW5VcyB67l1fnMil/D/e4zYxI0PvbW6RxLFNqvvtxBu5sGt5B7uzV4aAV31TpWR0l5RwwpZqc0NUlTx7oMutN1BDrPqW70QZ/iTEwalkn5fo1JWej0cf4BdC9VgYDLnprx0KN3IToukbszRQySnuR6MQUfj0m7lUloJfF3rq8G0kNxWqDGoJilMhO5Lqu9wAhlZWdouypI6bViO6+ToCVixLNUYs3EfS1zCxvXpiyMvh6rZofJ6WqzUuSd4Mzb2Ka4ocTKi7kynF+OG0Ivo= tests/test_dss.key.pub diff --git a/tests/test_ecdsa_256.key-cert.pub b/tests/test_ecdsa_256.key-cert.pub new file mode 100644 index 00000000..f2c93ccf --- /dev/null +++ b/tests/test_ecdsa_256.key-cert.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgJ+ZkRXedIWPl9y6fvel60p47ys5WgwMSjiwzJ2Ho+4MAAAAIbmlzdHAyNTYAAABBBJSPZm3ZWkvk/Zx8WP+fZRZ5/NBBHnGQwR6uIC6XHGPDIHuWUzIjAwA0bzqkOUffEsbLe+uQgKl5kbc/L8KA/eoAAAAAAAAAAAAAAAEAAAAJdXNlcl90ZXN0AAAACAAAAAR0ZXN0AAAAAAAAAAD//////////wAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAEXAAAAB3NzaC1yc2EAAAADAQABAAABAQDskr46Umjxh3wo7PoPQsSVS3xt6+5PhwmXrnVtBBnkOo+zHRwQo8G8sY+Lc6oOOzA5GCSawKOwqE305GIDfB8/L9EKOkAjdN18imDjw/YuJFA4bl9yFhsXrCb1GZPJw0pJ0H0Eid9EldyMQAhGE49MWvnFMQl1TgO6YWq/g71xAFimge0LvVWijlbMy7O+nsGxSpinIprV5S9Viv8XC/ku89tadZfca1uxq751aGfAWGeYrVytpUl8UO0ggqH6BaUvkDU7rWh2n5RHUTvgzceKWnz5wqd8BngK37WmJjAgCtHCJS5ZRf6oJGj2QVcqc6cjvEFWsCuOKB4KAjktauWxAAABDwAAAAdzc2gtcnNhAAABALdnEil8XIFkcgLZgYwS2cIQPHetUzMNxYCqzk7mSfVpCaIYNTr27RG+f+sD0cerdAIUUvhCT7iA82/Y7wzwkO2RUBi61ATfw9DDPPRQTDfix1SSRwbmPB/nVI1HlPMCEs6y48PFaBZqXwJPS3qycgSxoTBhaLCLzT+r6HRaibY7kiRLDeL3/WHyasK2PRdcYJ6KrLd0ctQcUHZCLK3fJfMfuQRg8MZLVrmK3fHStCXHpRFueRxUhZjaiS9evA/NtzEQhf46JDClQ2rLYpSqSg7QUR/rKwqWWyMuQkOHmlJw797VVa+ZzpUFXP7ekWel3FaBj8IHiimIA7Jm6dOCLm4= tests/test_ecdsa_256.key.pub diff --git a/tests/test_ed25519.key-cert.pub b/tests/test_ed25519.key-cert.pub new file mode 100644 index 00000000..4e01415a --- /dev/null +++ b/tests/test_ed25519.key-cert.pub @@ -0,0 +1 @@ +ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIIjBkc8l1X887CLBHraU+d6/74Hxr9oa+3HC0iioecZ6AAAAIHr1K9komH/1WBIvQbbtvnFVhryd62EfcgRFuLRiokNfAAAAAAAAAAAAAAABAAAACXVzZXJfdGVzdAAAAAgAAAAEdGVzdAAAAAAAAAAA//////////8AAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAABFwAAAAdzc2gtcnNhAAAAAwEAAQAAAQEA7JK+OlJo8Yd8KOz6D0LElUt8bevuT4cJl651bQQZ5DqPsx0cEKPBvLGPi3OqDjswORgkmsCjsKhN9ORiA3wfPy/RCjpAI3TdfIpg48P2LiRQOG5fchYbF6wm9RmTycNKSdB9BInfRJXcjEAIRhOPTFr5xTEJdU4DumFqv4O9cQBYpoHtC71Voo5WzMuzvp7BsUqYpyKa1eUvVYr/Fwv5LvPbWnWX3Gtbsau+dWhnwFhnmK1craVJfFDtIIKh+gWlL5A1O61odp+UR1E74M3Hilp8+cKnfAZ4Ct+1piYwIArRwiUuWUX+qCRo9kFXKnOnI7xBVrArjigeCgI5LWrlsQAAAQ8AAAAHc3NoLXJzYQAAAQCNfYITv/GCW42fLI89x0pKpXIET/xHIBVan5S3fy5SZq9gLG1Db9g/FITDfOVA7OX8mU/91rucHGtuEi3isILdNFrCcoLEml289tyyluUbbFD5fjvBchMWBkYPwrOPfEzSs299Yk8ZgfV1pjWlndfV54s4c9pinkGu8c0Vzc6stEbWkdmoOHE8su3ogUPg/hOygDzJ+ZOgP5HIUJ6YgkgVpWgZm7zofwdZfa2HEb+WhZaKfMK1UCw1UiSBVk9dx6qzF9m243tHwSHraXvb9oJ1wT1S/MypTbP4RT4fHN8koYNrv2szEBN+lkRgk1D7xaxS/Md2TJsau9ho/UCXSR8L tests/test_ed25519.key.pub diff --git a/tests/test_pkcs11.py b/tests/test_pkcs11.py new file mode 100644 index 00000000..6fb60025 --- /dev/null +++ b/tests/test_pkcs11.py @@ -0,0 +1,166 @@ +# 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 pkcs11 +""" + +import unittest +import mock +from paramiko.pkcs11 import ( + PKCS11Exception, pkcs11_get_public_key, pkcs11_close_session, + pkcs11_open_session +) +from paramiko.auth_handler import AuthHandler +from paramiko.transport import Transport +from tests.loop import LoopSocket + + +test_rsa_public_key = b"ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA049W6geFpm\ +sljTwfvI1UmKWWJPNFI74+vNKTk4dmzkQY2yAMs6FhlvhlI8ysU4oj71ZsRYMecHbBbxdN\ +79+JRFVYTKaLqjwGENeTd+yv4q+V2PvZv3fLnzApI3l7EJCqhWwJUHJ1jAkZzqDx0tyOL4u\ +oZpww3nmE0kb3y21tH4c=" + + +class MockPKCS11Lib(object): + def __init__(self): + pass + + def C_Finalize(val): + return 0 + + def C_Initialize(val1, val2): + return 0 + + def C_OpenSession(val1, val2, val3, val4, val5, val6): + return 0 + + def C_Login(val1, val2, val3, val4, val5): + return 0 + + def C_FindObjectsInit(val1, val2, val3, val4): + return 0 + + def C_FindObjects(val1, val2, val3, val4, val5): + return 0 + + def C_FindObjectsFinal(val1, val2): + return 0 + + def C_Sign(val1, val2, val3, val4, val5, val6): + return 0 + + def C_SignInit(val1, val2, val3, val4): + return 0 + + +class MockPopen_pkcs15tool_rsakey(object): + def __init__(self, stdout, stderr): + self.stdout = stdout + self.stderr = stderr + self.stdout = test_rsa_public_key + + def communicate(self): + return (self.stdout, self.stderr) + + def wait(self): + pass + + def kill(self): + pass + + +class Pkcs11Test(unittest.TestCase): + def setUp(self): + self.sockc = LoopSocket() + + def tearDown(self): + pass + + @mock.patch('subprocess.Popen', + return_value=MockPopen_pkcs15tool_rsakey([], [])) + def test_1_pkcs11_get_public_key(self, mock_popen): + """ + Test Getting Public Key + """ + public_key = pkcs11_get_public_key() + self.assertEqual(public_key, test_rsa_public_key.decode("utf-8")) + + @mock.patch('os.path.isfile', return_value=True) + @mock.patch('paramiko.pkcs11.cdll.LoadLibrary', + return_value=MockPKCS11Lib()) + def test_2_pkcs11_close_session_success(self, + mock_isfile, + mock_loadlibrary): + pkcs11session = {"pkcs11provider": "/test/path/example"} + threw_exception = True + try: + pkcs11_close_session(pkcs11session) + except Exception: + threw_exception = False + self.assertTrue(not threw_exception) + + @mock.patch('os.path.isfile', return_value=False) + @mock.patch('paramiko.pkcs11.cdll.LoadLibrary', + return_value=MockPKCS11Lib()) + def test_3_pkcs11_close_session_fail_nofile(self, mock_isfile, + mock_loadlibrary): + pkcs11session = {"pkcs11provider": "/test/path/example"} + threw_exception = False + try: + pkcs11_close_session(pkcs11session) + except PKCS11Exception: + threw_exception = True + self.assertTrue(threw_exception) + + @mock.patch('subprocess.Popen', + return_value=MockPopen_pkcs15tool_rsakey([], [])) + @mock.patch('os.path.isfile', return_value=True) + @mock.patch('paramiko.pkcs11.cdll.LoadLibrary', + return_value=MockPKCS11Lib()) + def test_4_pkcs11_open_session(self, + mock_popen, + mock_isfile, + mock_loadlibrary): + session = pkcs11_open_session("/test/provider/example", "1234") + self.assertEqual(0, session["session"].value) + self.assertEqual(test_rsa_public_key.decode("utf-8"), session["public_key"]) + self.assertEqual(0, session["keyret"].value) + self.assertEqual("/test/provider/example", session["provider"]) + + @mock.patch('paramiko.auth_handler.AuthHandler._request_auth', + return_value=True) + def test_5_pkcs11_authhandler_auth_pkcs11_basic(self, mock_requestauth): + # Mock _request_auth just to test the setup + # Just test the setup + tc = Transport(self.sockc) + session = {"session": None} + testauth = AuthHandler(tc) + testauth.auth_pkcs11("testuser", session, None) + self.assertEqual(testauth.auth_event, None) + self.assertEqual(testauth.auth_method, 'publickey') + self.assertEqual(testauth.username, "testuser") + self.assertEqual(testauth.pkcs11session, session) + + @mock.patch('paramiko.auth_handler.AuthHandler._request_auth', + return_value=True) + def test_6_pkcs11_authhandler_pkcs11_get_public_key(self, mock_requestauth): + tc = Transport(self.sockc) + session = {"public_key": test_rsa_public_key} + testauth = AuthHandler(tc) + testauth.auth_pkcs11("testuser", session, None) + public_key = testauth._pkcs11_get_public_key() + self.assertEqual(public_key, test_rsa_public_key) diff --git a/tests/test_pkey.py b/tests/test_pkey.py index 35b425da..a0e03407 100644 --- a/tests/test_pkey.py +++ b/tests/test_pkey.py @@ -467,6 +467,12 @@ class KeyTest(unittest.TestCase): self.assertTrue(not pub.can_sign()) self.assertEqual(key, pub) + def test_ed25519_load_from_file_obj(self): + with open(test_path('test_ed25519.key')) as pkey_fileobj: + key = Ed25519Key.from_private_key(pkey_fileobj) + self.assertEqual(key, key) + self.assertTrue(key.can_sign()) + def test_keyfile_is_actually_encrypted(self): # Read an existing encrypted private key file_ = test_path('test_rsa_password.key') @@ -481,3 +487,30 @@ class KeyTest(unittest.TestCase): self.assert_keyfile_is_encrypted(newfile) finally: os.remove(newfile) + + def test_certificates(self): + # PKey.load_certificate + key = RSAKey.from_private_key_file(test_path('test_rsa.key')) + self.assertTrue(key.public_blob is None) + key.load_certificate(test_path('test_rsa.key-cert.pub')) + self.assertTrue(key.public_blob is not None) + self.assertEqual(key.public_blob.key_type, 'ssh-rsa-cert-v01@openssh.com') + self.assertEqual(key.public_blob.comment, 'test_rsa.key.pub') + # Delve into blob contents, for test purposes + msg = Message(key.public_blob.key_blob) + self.assertEqual(msg.get_text(), 'ssh-rsa-cert-v01@openssh.com') + nonce = msg.get_string() + e = msg.get_mpint() + n = msg.get_mpint() + self.assertEqual(e, key.public_numbers.e) + self.assertEqual(n, key.public_numbers.n) + # Serial number + self.assertEqual(msg.get_int64(), 1234) + + # Prevented from loading certificate that doesn't match + key1 = Ed25519Key.from_private_key_file(test_path('test_ed25519.key')) + self.assertRaises( + ValueError, + key1.load_certificate, + test_path('test_rsa.key-cert.pub'), + ) diff --git a/tests/test_rsa.key-cert.pub b/tests/test_rsa.key-cert.pub new file mode 100644 index 00000000..7487ab66 --- /dev/null +++ b/tests/test_rsa.key-cert.pub @@ -0,0 +1 @@ +ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgsZlXTd5NE4uzGAn6TyAqQj+IPbsTEFGap2x5pTRwQR8AAAABIwAAAIEA049W6geFpmsljTwfvI1UmKWWJPNFI74+vNKTk4dmzkQY2yAMs6FhlvhlI8ysU4oj71ZsRYMecHbBbxdN79+JRFVYTKaLqjwGENeTd+yv4q+V2PvZv3fLnzApI3l7EJCqhWwJUHJ1jAkZzqDx0tyOL4uoZpww3nmE0kb3y21tH4cAAAAAAAAE0gAAAAEAAAAmU2FtcGxlIHNlbGYtc2lnbmVkIE9wZW5TU0ggY2VydGlmaWNhdGUAAAASAAAABXVzZXIxAAAABXVzZXIyAAAAAAAAAAD//////////wAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAACVAAAAB3NzaC1yc2EAAAABIwAAAIEA049W6geFpmsljTwfvI1UmKWWJPNFI74+vNKTk4dmzkQY2yAMs6FhlvhlI8ysU4oj71ZsRYMecHbBbxdN79+JRFVYTKaLqjwGENeTd+yv4q+V2PvZv3fLnzApI3l7EJCqhWwJUHJ1jAkZzqDx0tyOL4uoZpww3nmE0kb3y21tH4cAAACPAAAAB3NzaC1yc2EAAACATFHFsARDgQevc6YLxNnDNjsFtZ08KPMyYVx0w5xm95IVZHVWSOc5w+ccjqN9HRwxV3kP7IvL91qx0Uc3MJdB9g/O6HkAP+rpxTVoTb2EAMekwp5+i8nQJW4CN2BSsbQY1M6r7OBZ5nmF4hOW/5Pu4l22lXe2ydy8kEXOEuRpUeQ= test_rsa.key.pub diff --git a/tests/test_rsa.key.pub b/tests/test_rsa.key.pub new file mode 100644 index 00000000..bfa1e150 --- /dev/null +++ b/tests/test_rsa.key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA049W6geFpmsljTwfvI1UmKWWJPNFI74+vNKTk4dmzkQY2yAMs6FhlvhlI8ysU4oj71ZsRYMecHbBbxdN79+JRFVYTKaLqjwGENeTd+yv4q+V2PvZv3fLnzApI3l7EJCqhWwJUHJ1jAkZzqDx0tyOL4uoZpww3nmE0kb3y21tH4c= diff --git a/tests/util.py b/tests/util.py index b546a7e1..c1b43da8 100644 --- a/tests/util.py +++ b/tests/util.py @@ -4,4 +4,3 @@ root_path = os.path.dirname(os.path.realpath(__file__)) def test_path(filename): return os.path.join(root_path, filename) - |