diff options
30 files changed, 652 insertions, 147 deletions
diff --git a/.travis.yml b/.travis.yml index 24511c8d..f8e093c9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,34 +15,27 @@ python: matrix: allow_failures: - python: "3.7-dev" - # Pull in a few specific combos of older cryptography.io as needed. - # NOTE: this should only exist in the 2.0-2.2 branches, as 2.3+ requires - # crypto 1.5+. + # Explicitly test against our oldest supported cryptography.io, in addition + # to whatever the latest default is. include: - python: 2.7 - env: "CRYPTO=1.1" - - python: 2.7 - env: "CRYPTO=1.5" - - python: 3.6 - env: "CRYPTO=1.1" + env: "OLDEST_CRYPTO=1.5" - python: 3.6 - env: "CRYPTO=1.5" + env: "OLDEST_CRYPTO=1.5" install: # Ensure modern pip/etc on Python 3.3 workers (not sure WTF, but, eh) - pip install pip==9.0.1 setuptools==36.6.0 - # Grab a specific version of Cryptography if desired. (The 'vanilla' cells - # should all end up with latest public Cryptography version.) - # Doing this before other installations ensures we don't have to do any - # downgrading/overriding. - - "if [[ $CRYPTO == '1.1' ]]; then pip install 'cryptography<1.2'; fi" - - "if [[ $CRYPTO == '1.5' ]]; then pip install 'cryptography<1.6'; fi" + # Grab a specific version of Cryptography if desired. Doing this before other + # installations ensures we don't have to do any downgrading/overriding. + - | + if [[ -n "$OLDEST_CRYPTO" ]]; then + pip install "cryptography==${OLDEST_CRYPTO}" + fi # Dev (doc/test running) requirements # TODO: use pipenv + whatever contexty-type stuff it has - pip install codecov # For codecov specifically - pip install -r dev-requirements.txt - # Self-install for setup.py-driven deps. Comes after dev-requirements so that - # is able to 'pre-pin' certain libs to lower versions (eg for py2.6 support - # on Paramiko <2.4). + # Self-install for setup.py-driven deps - pip install -e . script: | # NOTE: the below hilarity should only exist in 2.0-2.3, 2.4+ should've gone @@ -50,7 +43,7 @@ script: | if [[ $TRAVIS_PYTHON_VERSION == '2.6' || $TRAVIS_PYTHON_VERSION == '3.3' ]]; then flake8 - coverage run --source=paramiko -m pytest --verbose --color=yes + coverage run --source=paramiko -m pytest else inv travis.blacken flake8 diff --git a/MANIFEST.in b/MANIFEST.in index 1eec2054..62239689 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include LICENSE setup_helper.py recursive-include docs * -recursive-include tests *.py *.key +recursive-include tests *.py *.key *.pub recursive-include demos *.py *.key user_rsa_key user_rsa_key.pub diff --git a/demos/demo_simple.py b/demos/demo_simple.py index 6a933dcd..5dd4f6c1 100644 --- a/demos/demo_simple.py +++ b/demos/demo_simple.py @@ -84,8 +84,6 @@ try: if 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, diff --git a/paramiko/__init__.py b/paramiko/__init__.py index 22505402..5780a9c7 100644 --- a/paramiko/__init__.py +++ b/paramiko/__init__.py @@ -64,7 +64,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/_version.py b/paramiko/_version.py index 96e885f5..fcdc1c31 100644 --- a/paramiko/_version.py +++ b/paramiko/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (2, 2, 4) +__version_info__ = (2, 3, 3) __version__ = ".".join(map(str, __version_info__)) diff --git a/paramiko/agent.py b/paramiko/agent.py index f2b1ecbe..622b95e4 100644 --- a/paramiko/agent.py +++ b/paramiko/agent.py @@ -395,6 +395,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 6a7b8bdd..bbfa1712 100644 --- a/paramiko/auth_handler.py +++ b/paramiko/auth_handler.py @@ -46,6 +46,7 @@ from paramiko.common import ( MSG_USERAUTH_REQUEST, MSG_USERAUTH_SUCCESS, MSG_USERAUTH_FAILURE, + cMSG_USERAUTH_BANNER, MSG_USERAUTH_BANNER, MSG_USERAUTH_INFO_REQUEST, MSG_USERAUTH_INFO_RESPONSE, @@ -210,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): @@ -249,6 +255,13 @@ 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() @@ -268,8 +281,14 @@ class AuthHandler(object): m.add_string(password) elif self.auth_method == "publickey": m.add_boolean(True) - m.add_string(self.private_key.get_name()) - m.add_string(self.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 ) @@ -493,10 +512,9 @@ Error Message: {} 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 c959b433..49a9a301 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -232,6 +232,7 @@ class SSHClient(ClosingContextManager): gss_host=None, banner_timeout=None, auth_timeout=None, + gss_trust_dns=True, ): """ Connect to an SSH server and authenticate to it. The server's host key @@ -244,9 +245,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 @@ -261,8 +276,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: @@ -281,6 +296,10 @@ class SSHClient(ClosingContextManager): :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 bool gss_trust_dns: + Indicates whether or not the DNS is trusted to securely + canonicalize the name of the host being connected to (default + ``True``). :param float banner_timeout: an optional timeout (in seconds) to wait for the SSH banner to be presented. :param float auth_timeout: an optional timeout (in seconds) to wait for @@ -298,6 +317,8 @@ class SSHClient(ClosingContextManager): .. versionchanged:: 1.15 Added the ``banner_timeout``, ``gss_auth``, ``gss_kex``, ``gss_deleg_creds`` and ``gss_host`` arguments. + .. versionchanged:: 2.3 + Added the ``gss_trust_dns`` argument. """ if not sock: errors = {} @@ -336,12 +357,13 @@ class SSHClient(ClosingContextManager): 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 + t.set_gss_host( + # t.hostname may be None, but GSS-API requires a target name. + # Therefore use hostname as fallback. + gss_host=gss_host or hostname, + trust_dns=gss_trust_dns, + gssapi_requested=gss_auth or gss_kex, + ) if self._log_channel is not None: t.set_log_channel(self._log_channel) if banner_timeout is not None: @@ -392,8 +414,7 @@ class SSHClient(ClosingContextManager): key_filenames = [key_filename] else: key_filenames = key_filename - if gss_host is None: - gss_host = hostname + self._auth( username, password, @@ -404,7 +425,7 @@ class SSHClient(ClosingContextManager): gss_auth, gss_kex, gss_deleg_creds, - gss_host, + t.gss_host, ) def close(self): @@ -520,6 +541,38 @@ 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, @@ -536,7 +589,7 @@ class SSHClient(ClosingContextManager): """ 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). @@ -565,10 +618,9 @@ class SSHClient(ClosingContextManager): # why should we do that again? if gss_auth: try: - self._transport.auth_gssapi_with_mic( + return self._transport.auth_gssapi_with_mic( username, gss_host, gss_deleg_creds ) - return except Exception as e: saved_exception = e @@ -591,13 +643,8 @@ 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) @@ -647,20 +694,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 b27d5915..fa3b6aa3 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 471e2851..ec358ee2 100644 --- a/paramiko/dsskey.py +++ b/paramiko/dsskey.py @@ -57,6 +57,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 @@ -68,10 +69,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() @@ -112,13 +114,7 @@ class DSSKey(PKey): ), ), ).private_key(backend=default_backend()) - algo = hashes.SHA1() - if hasattr(key, "sign"): # Cryptography 1.5+ - sig = key.sign(data, algo) - else: - signer = key.signer(algo) - signer.update(data) - sig = signer.finalize() + sig = key.sign(data, hashes.SHA1()) r, s = decode_dss_signature(sig) m = Message() @@ -155,14 +151,8 @@ class DSSKey(PKey): p=self.p, q=self.q, g=self.g ), ).public_key(backend=default_backend()) - algo = hashes.SHA1() try: - if hasattr(key, "verify"): - key.verify(signature, data, algo) - else: - verifier = key.verifier(signature, algo) - verifier.update(data) - verifier.verify() + key.verify(signature, data, hashes.SHA1()) except InvalidSignature: return False else: diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py index c2ccaba3..ab9ec15f 100644 --- a/paramiko/ecdsakey.py +++ b/paramiko/ecdsakey.py @@ -118,6 +118,7 @@ class ECDSAKey(PKey): ): 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 @@ -131,13 +132,26 @@ 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() + 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 ) - if self.ecdsa_curve is None: - raise SSHException("Invalid key") curvename = msg.get_text() if curvename != self.ecdsa_curve.nist_name: raise SSHException("Can't handle curve of type %s" % curvename) @@ -198,12 +212,7 @@ class ECDSAKey(PKey): def sign_ssh_data(self, data): ecdsa = ec.ECDSA(self.ecdsa_curve.hash_object()) - if hasattr(self.signing_key, "sign"): - sig = self.signing_key.sign(data, ecdsa) - else: - 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() @@ -218,14 +227,10 @@ class ECDSAKey(PKey): sigR, sigS = self._sigdecode(sig) signature = encode_dss_signature(sigR, sigS) - algo = ec.ECDSA(self.ecdsa_curve.hash_object()) try: - if hasattr(self.verifying_key, "verify"): - self.verifying_key.verify(signature, data, algo) - else: - verifier = self.verifying_key.verifier(signature, algo) - verifier.update(data) - 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 32ab4643..6e0d3ccf 100644 --- a/paramiko/ed25519key.py +++ b/paramiko/ed25519key.py @@ -48,18 +48,39 @@ 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 + ): + self.public_blob = 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/pkey.py b/paramiko/pkey.py index 255dd66b..50e989f6 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): @@ -365,3 +366,171 @@ class PKey(object): serialization.Encoding.PEM, 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 a45d1020..442bfe1f 100644 --- a/paramiko/rsakey.py +++ b/paramiko/rsakey.py @@ -47,6 +47,7 @@ class RSAKey(PKey): file_obj=None, ): self.key = None + self.public_blob = None if file_obj is not None: self._from_private_key(file_obj, password) return @@ -58,10 +59,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()) @@ -111,13 +113,9 @@ class RSAKey(PKey): return isinstance(self.key, rsa.RSAPrivateKey) def sign_ssh_data(self, data): - kwargs = dict(padding=padding.PKCS1v15(), algorithm=hashes.SHA1()) - if hasattr(self.key, "sign"): - sig = self.key.sign(data, **kwargs) - else: - signer = self.key.signer(**kwargs) - signer.update(data) - sig = signer.finalize() + sig = self.key.sign( + data, padding=padding.PKCS1v15(), algorithm=hashes.SHA1() + ) m = Message() m.add_string("ssh-rsa") @@ -131,23 +129,10 @@ class RSAKey(PKey): if isinstance(key, rsa.RSAPrivateKey): key = key.public_key() - kwargs = dict( - signature=msg.get_binary(), - padding=padding.PKCS1v15(), - algorithm=hashes.SHA1(), - ) try: - if hasattr(key, "verify"): - key.verify( - kwargs["signature"], - data, - kwargs["padding"], - kwargs["algorithm"], - ) - else: - verifier = key.verifier(**kwargs) - verifier.update(data) - 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 78c56738..3eb1a170 100644 --- a/paramiko/server.py +++ b/paramiko/server.py @@ -579,6 +579,20 @@ 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 9517d4a9..22348f87 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -247,11 +247,17 @@ class Transport(threading.Thread, ClosingContextManager): _key_info = { "ssh-rsa": RSAKey, + "ssh-rsa-cert-v01@openssh.com": RSAKey, "ssh-dss": DSSKey, + "ssh-dss-cert-v01@openssh.com": DSSKey, "ecdsa-sha2-nistp256": ECDSAKey, + "ecdsa-sha2-nistp256-cert-v01@openssh.com": ECDSAKey, "ecdsa-sha2-nistp384": ECDSAKey, + "ecdsa-sha2-nistp384-cert-v01@openssh.com": ECDSAKey, "ecdsa-sha2-nistp521": ECDSAKey, + "ecdsa-sha2-nistp521-cert-v01@openssh.com": ECDSAKey, "ssh-ed25519": Ed25519Key, + "ssh-ed25519-cert-v01@openssh.com": Ed25519Key, } _kex_info = { @@ -332,10 +338,12 @@ class Transport(threading.Thread, ClosingContextManager): arguments. """ self.active = False + self.hostname = None if isinstance(sock, string_types): # convert "host:port" into (host, port) hl = sock.split(":", 1) + self.hostname = hl[0] if len(hl) == 1: sock = (hl[0], 22) else: @@ -343,6 +351,7 @@ class Transport(threading.Thread, ClosingContextManager): if type(sock) is tuple: # connect to the given (host, port) hostname, port = sock + self.hostname = hostname reason = "No suitable address family" addrinfos = socket.getaddrinfo( hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM @@ -487,15 +496,39 @@ class Transport(threading.Thread, ClosingContextManager): """ return SecurityOptions(self) - def set_gss_host(self, gss_host): + def set_gss_host(self, gss_host, trust_dns=True, gssapi_requested=True): """ - Setter for C{gss_host} if GSS-API Key Exchange is performed. + Normalize/canonicalize ``self.gss_host`` depending on various factors. - :param str gss_host: The targets name in the kerberos database - Default: The name of the host to connect to - """ - # We need the FQDN to get this working with SSPI - self.gss_host = socket.getfqdn(gss_host) + :param str gss_host: + The explicitly requested GSS-oriented hostname to connect to (i.e. + what the host's name is in the Kerberos database.) Defaults to + ``self.hostname`` (which will be the 'real' target hostname and/or + host portion of given socket object.) + :param bool trust_dns: + Indicates whether or not DNS is trusted; if true, DNS will be used + to canonicalize the GSS hostname (which again will either be + ``gss_host`` or the transport's default hostname.) + (Defaults to True due to backwards compatibility.) + :param bool gssapi_requested: + Whether GSSAPI key exchange or authentication was even requested. + If not, this is a no-op and nothing happens + (and ``self.gss_host`` is not set.) + (Defaults to True due to backwards compatibility.) + :returns: ``None``. + """ + # No GSSAPI in play == nothing to do + if not gssapi_requested: + return + # Obtain the correct host first - did user request a GSS-specific name + # to use that is distinct from the actual SSH target hostname? + if gss_host is None: + gss_host = self.hostname + # Finally, canonicalize via DNS if DNS is trusted. + if trust_dns and gss_host is not None: + gss_host = socket.getfqdn(gss_host) + # And set attribute for reference later. + self.gss_host = gss_host def start_client(self, event=None, timeout=None): """ @@ -1120,6 +1153,7 @@ class Transport(threading.Thread, ClosingContextManager): gss_auth=False, gss_kex=False, gss_deleg_creds=True, + gss_trust_dns=True, ): """ Negotiate an SSH2 session, and optionally verify the server's host key @@ -1158,13 +1192,26 @@ class Transport(threading.Thread, ClosingContextManager): Perform GSS-API Key Exchange and user authentication. :param bool gss_deleg_creds: Whether to delegate GSS-API client credentials. + :param gss_trust_dns: + Indicates whether or not the DNS is trusted to securely + canonicalize the name of the host being connected to (default + ``True``). :raises: `.SSHException` -- if the SSH2 negotiation fails, the host key supplied by the server is incorrect, or authentication fails. + + .. versionchanged:: 2.3 + Added the ``gss_trust_dns`` argument. """ if hostkey is not None: self._preferred_keys = [hostkey.get_name()] + self.set_gss_host( + gss_host=gss_host, + trust_dns=gss_trust_dns, + gssapi_requested=gss_kex or gss_auth, + ) + self.start_client() # check host key if we were given one @@ -1194,7 +1241,9 @@ class Transport(threading.Thread, ClosingContextManager): self._log( DEBUG, "Attempting GSS-API auth... (gssapi-with-mic)" ) # noqa - self.auth_gssapi_with_mic(username, gss_host, gss_deleg_creds) + self.auth_gssapi_with_mic( + username, self.gss_host, gss_deleg_creds + ) elif gss_kex: self._log(DEBUG, "Attempting GSS-API auth... (gssapi-keyex)") self.auth_gssapi_keyex(username) @@ -1928,8 +1977,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) @@ -1968,8 +2015,7 @@ class Transport(threading.Thread, ClosingContextManager): "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 @@ -77,7 +77,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 1204d4ea..91d170c1 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -11,6 +11,7 @@ Changelog import location of ``MutableMapping`` (used in host key management) to avoid the old location becoming deprecated in Python 3.8. Thanks to Josh Karpel for catch & patch. +- :release:`2.3.3 <2018-09-18>` - :release:`2.2.4 <2018-09-18>` - :release:`2.1.6 <2018-09-18>` - :release:`2.0.9 <2018-09-18>` @@ -48,6 +49,10 @@ Changelog ``black`` code formatter (both of which previously only existed in the 2.4 branch and above) to everything 2.0 and newer. This makes back/forward porting bugfixes significantly easier. +- :support:`1262 backported` Add ``*.pub`` files to the MANIFEST so distributed + source packages contain some necessary test assets. Credit: Alexander + Kapshuna. +- :release:`2.3.2 <2018-03-12>` - :release:`2.2.3 <2018-03-12>` - :release:`2.1.5 <2018-03-12>` - :release:`2.0.8 <2018-03-12>` @@ -65,6 +70,13 @@ Changelog ``async``) so that we're compatible with the upcoming Python 3.7 release (where ``async`` is a new keyword.) Thanks to ``@vEpiphyte`` for the report. - :support:`- backported` Include LICENSE file in wheel archives. +- :release:`2.3.1 <2017-09-22>` +- :bug:`1071` Certificate support broke the no-certificate case for Ed25519 + keys (symptom is an ``AttributeError`` about ``public_blob``.) This went + uncaught due to cert autoload behavior (i.e. our test suite never actually + ran the no-cert case, because the cert existed!) Both issues have been fixed. + Thanks to John Hu for the report. +- :release:`2.3.0 <2017-09-18>` - :release:`2.2.2 <2017-09-18>` - :release:`2.1.4 <2017-09-18>` - :release:`2.0.7 <2017-09-18>` @@ -74,6 +86,10 @@ Changelog a ``gss-kex``-authed `~paramiko.transport.Transport` would cause a MIC failure and terminate the connection. Thanks to Sebastian Deiß and Anselm Kruis for the patch. +- :feature:`1063` Add a ``gss_trust_dns`` option to ``Client`` and + ``Transport`` to allow explicitly setting whether or not DNS canonicalization + should occur when using GSSAPI. Thanks to Richard E. Silverman for the report + & Sebastian Deiß for initial patchset. - :bug:`1061` Clean up GSSAPI authentication procedures so they do not prevent normal fallback to other authentication methods on failure. (In other words, presence of GSSAPI functionality on a target server precluded use of _any_ @@ -91,6 +107,68 @@ Changelog consider a different type to be a "Missing" host key. This fixes a common case where an ECDSA key is in known_hosts and the server also has an RSA host key. Thanks to Pierce Lopez. +- :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. diff --git a/tests/cert_support/test_dss.key b/tests/cert_support/test_dss.key new file mode 100644 index 00000000..e10807f1 --- /dev/null +++ b/tests/cert_support/test_dss.key @@ -0,0 +1,12 @@ +-----BEGIN DSA PRIVATE KEY----- +MIIBuwIBAAKBgQDngaYDZ30c6/7cJgEEbtl8FgKdwhba1Z7oOrOn4MI/6C42G1bY +wMuqZf4dBCglsdq39SHrcjbE8Vq54gPSOh3g4+uV9Rcg5IOoPLbwp2jQfF6f1FIb +sx7hrDCIqUcQccPSxetPBKmXI9RN8rZLaFuQeTnI65BKM98Ruwvq6SI2LwIVAPDP +hSeawaJI27mKqOfe5PPBSmyHAoGBAJMXxXmPD9sGaQ419DIpmZecJKBUAy9uXD8x +gbgeDpwfDaFJP8owByCKREocPFfi86LjCuQkyUKOfjYMN6iHIf1oEZjB8uJAatUr +FzI0ArXtUqOhwTLwTyFuUojE5own2WYsOAGByvgfyWjsGhvckYNhI4ODpNdPlxQ8 +ZamaPGPsAoGARmR7CCPjodxASvRbIyzaVpZoJ/Z6x7dAumV+ysrV1BVYd0lYukmn +jO1kKBWApqpH1ve9XDQYN8zgxM4b16L21kpoWQnZtXrY3GZ4/it9kUgyB7+NwacI +BlXa8cMDL7Q/69o0d54U0X/NeX5QxuYR6OMJlrkQB7oiW/P/1mwjQgECFGI9QPSc +h9pT9XHqn+1rZ4bK+QGA +-----END DSA PRIVATE KEY----- diff --git a/tests/cert_support/test_dss.key-cert.pub b/tests/cert_support/test_dss.key-cert.pub new file mode 100644 index 00000000..07fd5578 --- /dev/null +++ b/tests/cert_support/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/cert_support/test_ecdsa_256.key b/tests/cert_support/test_ecdsa_256.key new file mode 100644 index 00000000..42d44734 --- /dev/null +++ b/tests/cert_support/test_ecdsa_256.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKB6ty3yVyKEnfF/zprx0qwC76MsMlHY4HXCnqho2eKioAoGCCqGSM49 +AwEHoUQDQgAElI9mbdlaS+T9nHxY/59lFnn80EEecZDBHq4gLpccY8Mge5ZTMiMD +ADRvOqQ5R98Sxst765CAqXmRtz8vwoD96g== +-----END EC PRIVATE KEY----- diff --git a/tests/cert_support/test_ecdsa_256.key-cert.pub b/tests/cert_support/test_ecdsa_256.key-cert.pub new file mode 100644 index 00000000..f2c93ccf --- /dev/null +++ b/tests/cert_support/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/cert_support/test_ed25519.key b/tests/cert_support/test_ed25519.key new file mode 100644 index 00000000..eb9f94c2 --- /dev/null +++ b/tests/cert_support/test_ed25519.key @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXwAAAKhjwAdrY8AH +awAAAAtzc2gtZWQyNTUxOQAAACB69SvZKJh/9VgSL0G27b5xVYa8nethH3IERbi0YqJDXw +AAAEA9tGQi2IrprbOSbDCF+RmAHd6meNSXBUQ2ekKXm4/8xnr1K9komH/1WBIvQbbtvnFV +hryd62EfcgRFuLRiokNfAAAAI2FsZXhfZ2F5bm9yQEFsZXhzLU1hY0Jvb2stQWlyLmxvY2 +FsAQI= +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/cert_support/test_ed25519.key-cert.pub b/tests/cert_support/test_ed25519.key-cert.pub new file mode 100644 index 00000000..4e01415a --- /dev/null +++ b/tests/cert_support/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/cert_support/test_rsa.key b/tests/cert_support/test_rsa.key new file mode 100644 index 00000000..f50e9c53 --- /dev/null +++ b/tests/cert_support/test_rsa.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWgIBAAKBgQDTj1bqB4WmayWNPB+8jVSYpZYk80Ujvj680pOTh2bORBjbIAyz +oWGW+GUjzKxTiiPvVmxFgx5wdsFvF03v34lEVVhMpouqPAYQ15N37K/ir5XY+9m/ +d8ufMCkjeXsQkKqFbAlQcnWMCRnOoPHS3I4vi6hmnDDeeYTSRvfLbW0fhwIBIwKB +gBIiOqZYaoqbeD9OS9z2K9KR2atlTxGxOJPXiP4ESqP3NVScWNwyZ3NXHpyrJLa0 +EbVtzsQhLn6rF+TzXnOlcipFvjsem3iYzCpuChfGQ6SovTcOjHV9z+hnpXvQ/fon +soVRZY65wKnF7IAoUwTmJS9opqgrN6kRgCd3DASAMd1bAkEA96SBVWFt/fJBNJ9H +tYnBKZGw0VeHOYmVYbvMSstssn8un+pQpUm9vlG/bp7Oxd/m+b9KWEh2xPfv6zqU +avNwHwJBANqzGZa/EpzF4J8pGti7oIAPUIDGMtfIcmqNXVMckrmzQ2vTfqtkEZsA +4rE1IERRyiJQx6EJsz21wJmGV9WJQ5kCQQDwkS0uXqVdFzgHO6S++tjmjYcxwr3g +H0CoFYSgbddOT6miqRskOQF3DZVkJT3kyuBgU2zKygz52ukQZMqxCb1fAkASvuTv +qfpH87Qq5kQhNKdbbwbmd2NxlNabazPijWuphGTdW0VfJdWfklyS2Kr+iqrs/5wV +HhathJt636Eg7oIjAkA8ht3MQ+XSl9yIJIS8gVpbPxSw5OMfw0PjVE7tBdQruiSc +nvuQES5C9BMHjF39LZiGH1iLQy7FgdHyoP+eodI7 +-----END RSA PRIVATE KEY----- diff --git a/tests/cert_support/test_rsa.key-cert.pub b/tests/cert_support/test_rsa.key-cert.pub new file mode 100644 index 00000000..7487ab66 --- /dev/null +++ b/tests/cert_support/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_client.py b/tests/test_client.py index f87c2692..bfbd395f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -34,6 +34,7 @@ import weakref from tempfile import mkstemp import paramiko +from paramiko.pkey import PublicBlob from paramiko.common import PY2 from paramiko.ssh_exception import SSHException, AuthenticationException @@ -52,6 +53,8 @@ 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): @@ -72,12 +75,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 = _support("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( @@ -254,6 +265,42 @@ 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_name = "test_{0}.key-cert.pub".format(type_) + cert_path = _support(os.path.join("cert_support", cert_name)) + 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_name = "test_{0}.key".format(type_) + key_path = _support(os.path.join("cert_support", key_name)) + 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_pkey.py b/tests/test_pkey.py index 18916454..4e84da7b 100644 --- a/tests/test_pkey.py +++ b/tests/test_pkey.py @@ -495,6 +495,12 @@ class KeyTest(unittest.TestCase): ) # No exception -> it's good. Meh. + def test_ed25519_load_from_file_obj(self): + with open(_support("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_ = _support("test_rsa_password.key") @@ -509,3 +515,40 @@ class KeyTest(unittest.TestCase): self.assert_keyfile_is_encrypted(newfile) finally: os.remove(newfile) + + def test_certificates(self): + # NOTE: we also test 'live' use of cert auth for all key types in + # test_client.py; this and nearby cert tests are more about the gritty + # details. + # PKey.load_certificate + key_path = _support(os.path.join("cert_support", "test_rsa.key")) + key = RSAKey.from_private_key_file(key_path) + self.assertTrue(key.public_blob is None) + cert_path = _support( + os.path.join("cert_support", "test_rsa.key-cert.pub") + ) + key.load_certificate(cert_path) + 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 + key_path = _support(os.path.join("cert_support", "test_ed25519.key")) + key1 = Ed25519Key.from_private_key_file(key_path) + self.assertRaises( + ValueError, + key1.load_certificate, + _support("test_rsa.key-cert.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= |