summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--dev-requirements.txt2
-rw-r--r--paramiko/__init__.py1
-rw-r--r--paramiko/_version.py2
-rw-r--r--paramiko/agent.py16
-rw-r--r--paramiko/auth_handler.py148
-rw-r--r--paramiko/buffered_pipe.py4
-rw-r--r--paramiko/channel.py4
-rw-r--r--paramiko/common.py5
-rw-r--r--paramiko/dsskey.py2
-rw-r--r--paramiko/ecdsakey.py2
-rw-r--r--paramiko/ed25519key.py2
-rw-r--r--paramiko/kex_curve25519.py4
-rw-r--r--paramiko/kex_ecdh_nist.py4
-rw-r--r--paramiko/kex_gex.py4
-rw-r--r--paramiko/kex_group1.py4
-rw-r--r--paramiko/pkey.py15
-rw-r--r--paramiko/rsakey.py32
-rw-r--r--paramiko/sftp_file.py2
-rw-r--r--paramiko/ssh_exception.py15
-rw-r--r--paramiko/transport.py241
-rw-r--r--paramiko/util.py18
-rw-r--r--sites/www/changelog.rst131
-rw-r--r--tasks.py1
-rw-r--r--tests/blank_rsa.key0
-rw-r--r--tests/loop.py2
-rw-r--r--tests/test_agent.py50
-rw-r--r--tests/test_client.py42
-rw-r--r--tests/test_kex.py3
-rw-r--r--tests/test_pkey.py55
-rw-r--r--tests/test_transport.py290
30 files changed, 966 insertions, 135 deletions
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 3edcc812..3ed9eb40 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,6 +1,6 @@
# Invocations for common project tasks
invoke==1.6.0
-invocations==2.3.0
+invocations==2.6.0
pytest==4.4.2
pytest-relaxed==1.1.5
# pytest-xdist for test dir watching and the inv guard task
diff --git a/paramiko/__init__.py b/paramiko/__init__.py
index 8642f84a..5318cc9c 100644
--- a/paramiko/__init__.py
+++ b/paramiko/__init__.py
@@ -42,6 +42,7 @@ from paramiko.ssh_exception import (
ChannelException,
ConfigParseError,
CouldNotCanonicalize,
+ IncompatiblePeer,
PasswordRequiredException,
ProxyCommandFailure,
SSHException,
diff --git a/paramiko/_version.py b/paramiko/_version.py
index 0f0c6561..1908d3b0 100644
--- a/paramiko/_version.py
+++ b/paramiko/_version.py
@@ -1,2 +1,2 @@
-__version_info__ = (2, 8, 1)
+__version_info__ = (2, 9, 5)
__version__ = ".".join(map(str, __version_info__))
diff --git a/paramiko/agent.py b/paramiko/agent.py
index c7c8b7cb..f28bf128 100644
--- a/paramiko/agent.py
+++ b/paramiko/agent.py
@@ -42,6 +42,18 @@ SSH2_AGENT_IDENTITIES_ANSWER = 12
cSSH2_AGENTC_SIGN_REQUEST = byte_chr(13)
SSH2_AGENT_SIGN_RESPONSE = 14
+SSH_AGENT_RSA_SHA2_256 = 2
+SSH_AGENT_RSA_SHA2_512 = 4
+# NOTE: RFC mildly confusing; while these flags are OR'd together, OpenSSH at
+# least really treats them like "AND"s, in the sense that if it finds the
+# SHA256 flag set it won't continue looking at the SHA512 one; it
+# short-circuits right away.
+# Thus, we never want to eg submit 6 to say "either's good".
+ALGORITHM_FLAG_MAP = {
+ "rsa-sha2-256": SSH_AGENT_RSA_SHA2_256,
+ "rsa-sha2-512": SSH_AGENT_RSA_SHA2_512,
+}
+
class AgentSSH(object):
def __init__(self):
@@ -411,12 +423,12 @@ class AgentKey(PKey):
def _fields(self):
raise NotImplementedError
- def sign_ssh_data(self, data):
+ def sign_ssh_data(self, data, algorithm=None):
msg = Message()
msg.add_byte(cSSH2_AGENTC_SIGN_REQUEST)
msg.add_string(self.blob)
msg.add_string(data)
- msg.add_int(0)
+ msg.add_int(ALGORITHM_FLAG_MAP.get(algorithm, 0))
ptype, result = self.agent._send_message(msg)
if ptype != SSH2_AGENT_SIGN_RESPONSE:
raise SSHException("key cannot be used for signing")
diff --git a/paramiko/auth_handler.py b/paramiko/auth_handler.py
index 5c7d6be6..42a21a78 100644
--- a/paramiko/auth_handler.py
+++ b/paramiko/auth_handler.py
@@ -22,6 +22,7 @@
import weakref
import time
+import re
from paramiko.common import (
cMSG_SERVICE_REQUEST,
@@ -61,7 +62,7 @@ from paramiko.common import (
cMSG_USERAUTH_BANNER,
)
from paramiko.message import Message
-from paramiko.py3compat import b
+from paramiko.py3compat import b, u
from paramiko.ssh_exception import (
SSHException,
AuthenticationException,
@@ -206,7 +207,19 @@ class AuthHandler(object):
self.transport._send_message(m)
self.transport.close()
- def _get_session_blob(self, key, service, username):
+ def _get_key_type_and_bits(self, key):
+ """
+ Given any key, return its type/algorithm & bits-to-sign.
+
+ Intended for input to or verification of, key signatures.
+ """
+ # Use certificate contents, if available, plain pubkey otherwise
+ if key.public_blob:
+ return key.public_blob.key_type, key.public_blob.key_blob
+ else:
+ return key.get_name(), key
+
+ def _get_session_blob(self, key, service, username, algorithm):
m = Message()
m.add_string(self.transport.session_id)
m.add_byte(cMSG_USERAUTH_REQUEST)
@@ -214,13 +227,9 @@ class AuthHandler(object):
m.add_string(service)
m.add_string("publickey")
m.add_boolean(True)
- # 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)
+ _, bits = self._get_key_type_and_bits(key)
+ m.add_string(algorithm)
+ m.add_string(bits)
return m.asbytes()
def wait_for_response(self, event):
@@ -269,9 +278,98 @@ class AuthHandler(object):
# dunno this one
self._disconnect_service_not_available()
+ def _generate_key_from_request(self, algorithm, keyblob):
+ # For use in server mode.
+ options = self.transport.preferred_pubkeys
+ if algorithm.replace("-cert-v01@openssh.com", "") not in options:
+ err = (
+ "Auth rejected: pubkey algorithm '{}' unsupported or disabled"
+ )
+ self._log(INFO, err.format(algorithm))
+ return None
+ return self.transport._key_info[algorithm](Message(keyblob))
+
+ def _finalize_pubkey_algorithm(self, key_type):
+ # Short-circuit for non-RSA keys
+ if "rsa" not in key_type:
+ return key_type
+ self._log(
+ DEBUG,
+ "Finalizing pubkey algorithm for key of type {!r}".format(
+ key_type
+ ),
+ )
+ # NOTE re #2017: When the key is an RSA cert and the remote server is
+ # OpenSSH 7.7 or earlier, always use ssh-rsa-cert-v01@openssh.com.
+ # Those versions of the server won't support rsa-sha2 family sig algos
+ # for certs specifically, and in tandem with various server bugs
+ # regarding server-sig-algs, it's impossible to fit this into the rest
+ # of the logic here.
+ if key_type.endswith("-cert-v01@openssh.com") and re.search(
+ r"-OpenSSH_(?:[1-6]|7\.[0-7])", self.transport.remote_version
+ ):
+ pubkey_algo = "ssh-rsa-cert-v01@openssh.com"
+ self.transport._agreed_pubkey_algorithm = pubkey_algo
+ self._log(DEBUG, "OpenSSH<7.8 + RSA cert = forcing ssh-rsa!")
+ self._log(
+ DEBUG, "Agreed upon {!r} pubkey algorithm".format(pubkey_algo)
+ )
+ return pubkey_algo
+ # Normal attempts to handshake follow from here.
+ # Only consider RSA algos from our list, lest we agree on another!
+ my_algos = [x for x in self.transport.preferred_pubkeys if "rsa" in x]
+ self._log(DEBUG, "Our pubkey algorithm list: {}".format(my_algos))
+ # Short-circuit negatively if user disabled all RSA algos (heh)
+ if not my_algos:
+ raise SSHException(
+ "An RSA key was specified, but no RSA pubkey algorithms are configured!" # noqa
+ )
+ # Check for server-sig-algs if supported & sent
+ server_algo_str = u(
+ self.transport.server_extensions.get("server-sig-algs", b(""))
+ )
+ pubkey_algo = None
+ if server_algo_str:
+ server_algos = server_algo_str.split(",")
+ self._log(
+ DEBUG, "Server-side algorithm list: {}".format(server_algos)
+ )
+ # Only use algos from our list that the server likes, in our own
+ # preference order. (NOTE: purposefully using same style as in
+ # Transport...expect to refactor later)
+ agreement = list(filter(server_algos.__contains__, my_algos))
+ if agreement:
+ pubkey_algo = agreement[0]
+ self._log(
+ DEBUG,
+ "Agreed upon {!r} pubkey algorithm".format(pubkey_algo),
+ )
+ else:
+ self._log(DEBUG, "No common pubkey algorithms exist! Dying.")
+ # TODO: MAY want to use IncompatiblePeer again here but that's
+ # technically for initial key exchange, not pubkey auth.
+ err = "Unable to agree on a pubkey algorithm for signing a {!r} key!" # noqa
+ raise AuthenticationException(err.format(key_type))
+ else:
+ # Fallback: first one in our (possibly tweaked by caller) list
+ pubkey_algo = my_algos[0]
+ msg = "Server did not send a server-sig-algs list; defaulting to our first preferred algo ({!r})" # noqa
+ self._log(DEBUG, msg.format(pubkey_algo))
+ self._log(
+ DEBUG,
+ "NOTE: you may use the 'disabled_algorithms' SSHClient/Transport init kwarg to disable that or other algorithms if your server does not support them!", # noqa
+ )
+ if key_type.endswith("-cert-v01@openssh.com"):
+ pubkey_algo += "-cert-v01@openssh.com"
+ self.transport._agreed_pubkey_algorithm = pubkey_algo
+ return pubkey_algo
+
def _parse_service_accept(self, m):
service = m.get_text()
if service == "ssh-userauth":
+ # TODO 3.0: this message sucks ass. change it to something more
+ # obvious. it always appears to mean "we already authed" but no! it
+ # just means "we are allowed to TRY authing!"
self._log(DEBUG, "userauth is OK")
m = Message()
m.add_byte(cMSG_USERAUTH_REQUEST)
@@ -284,18 +382,17 @@ class AuthHandler(object):
m.add_string(password)
elif self.auth_method == "publickey":
m.add_boolean(True)
- # 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)
+ key_type, bits = self._get_key_type_and_bits(self.private_key)
+ algorithm = self._finalize_pubkey_algorithm(key_type)
+ m.add_string(algorithm)
+ m.add_string(bits)
blob = self._get_session_blob(
- self.private_key, "ssh-connection", self.username
+ self.private_key,
+ "ssh-connection",
+ self.username,
+ algorithm,
)
- sig = self.private_key.sign_ssh_data(blob)
+ sig = self.private_key.sign_ssh_data(blob, algorithm)
m.add_string(sig)
elif self.auth_method == "keyboard-interactive":
m.add_string("")
@@ -505,10 +602,13 @@ Error Message: {}
)
elif method == "publickey":
sig_attached = m.get_boolean()
- keytype = m.get_text()
+ # NOTE: server never wants to guess a client's algo, they're
+ # telling us directly. No need for _finalize_pubkey_algorithm
+ # anywhere in this flow.
+ algorithm = m.get_text()
keyblob = m.get_binary()
try:
- key = self.transport._key_info[keytype](Message(keyblob))
+ key = self._generate_key_from_request(algorithm, keyblob)
except SSHException as e:
self._log(INFO, "Auth rejected: public key: {}".format(str(e)))
key = None
@@ -532,12 +632,14 @@ Error Message: {}
# signs anything... send special "ok" message
m = Message()
m.add_byte(cMSG_USERAUTH_PK_OK)
- m.add_string(keytype)
+ m.add_string(algorithm)
m.add_string(keyblob)
self.transport._send_message(m)
return
sig = Message(m.get_binary())
- blob = self._get_session_blob(key, service, username)
+ blob = self._get_session_blob(
+ key, service, username, algorithm
+ )
if not key.verify_ssh_sig(blob, sig):
self._log(INFO, "Auth rejected: invalid signature")
result = AUTH_FAILED
diff --git a/paramiko/buffered_pipe.py b/paramiko/buffered_pipe.py
index 69445c97..e8f98714 100644
--- a/paramiko/buffered_pipe.py
+++ b/paramiko/buffered_pipe.py
@@ -101,7 +101,7 @@ class BufferedPipe(object):
if self._event is not None:
self._event.set()
self._buffer_frombytes(b(data))
- self._cv.notifyAll()
+ self._cv.notify_all()
finally:
self._lock.release()
@@ -203,7 +203,7 @@ class BufferedPipe(object):
self._lock.acquire()
try:
self._closed = True
- self._cv.notifyAll()
+ self._cv.notify_all()
if self._event is not None:
self._event.set()
finally:
diff --git a/paramiko/channel.py b/paramiko/channel.py
index 72f65012..5f314361 100644
--- a/paramiko/channel.py
+++ b/paramiko/channel.py
@@ -1066,7 +1066,7 @@ class Channel(ClosingContextManager):
if self.ultra_debug:
self._log(DEBUG, "window up {}".format(nbytes))
self.out_window_size += nbytes
- self.out_buffer_cv.notifyAll()
+ self.out_buffer_cv.notify_all()
finally:
self.lock.release()
@@ -1230,7 +1230,7 @@ class Channel(ClosingContextManager):
self.closed = True
self.in_buffer.close()
self.in_stderr_buffer.close()
- self.out_buffer_cv.notifyAll()
+ self.out_buffer_cv.notify_all()
# Notify any waiters that we are closed
self.event.set()
self.status_event.set()
diff --git a/paramiko/common.py b/paramiko/common.py
index 7bd0cb10..55dd4bdf 100644
--- a/paramiko/common.py
+++ b/paramiko/common.py
@@ -29,7 +29,8 @@ from paramiko.py3compat import byte_chr, PY2, long, b
MSG_DEBUG,
MSG_SERVICE_REQUEST,
MSG_SERVICE_ACCEPT,
-) = range(1, 7)
+ MSG_EXT_INFO,
+) = range(1, 8)
(MSG_KEXINIT, MSG_NEWKEYS) = range(20, 22)
(
MSG_USERAUTH_REQUEST,
@@ -68,6 +69,7 @@ cMSG_UNIMPLEMENTED = byte_chr(MSG_UNIMPLEMENTED)
cMSG_DEBUG = byte_chr(MSG_DEBUG)
cMSG_SERVICE_REQUEST = byte_chr(MSG_SERVICE_REQUEST)
cMSG_SERVICE_ACCEPT = byte_chr(MSG_SERVICE_ACCEPT)
+cMSG_EXT_INFO = byte_chr(MSG_EXT_INFO)
cMSG_KEXINIT = byte_chr(MSG_KEXINIT)
cMSG_NEWKEYS = byte_chr(MSG_NEWKEYS)
cMSG_USERAUTH_REQUEST = byte_chr(MSG_USERAUTH_REQUEST)
@@ -109,6 +111,7 @@ MSG_NAMES = {
MSG_SERVICE_REQUEST: "service-request",
MSG_SERVICE_ACCEPT: "service-accept",
MSG_KEXINIT: "kexinit",
+ MSG_EXT_INFO: "ext-info",
MSG_NEWKEYS: "newkeys",
30: "kex30",
31: "kex31",
diff --git a/paramiko/dsskey.py b/paramiko/dsskey.py
index 09d6f648..1a0c4797 100644
--- a/paramiko/dsskey.py
+++ b/paramiko/dsskey.py
@@ -105,7 +105,7 @@ class DSSKey(PKey):
def can_sign(self):
return self.x is not None
- def sign_ssh_data(self, data):
+ def sign_ssh_data(self, data, algorithm=None):
key = dsa.DSAPrivateNumbers(
x=self.x,
public_numbers=dsa.DSAPublicNumbers(
diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py
index b609d130..c4e2b1af 100644
--- a/paramiko/ecdsakey.py
+++ b/paramiko/ecdsakey.py
@@ -211,7 +211,7 @@ class ECDSAKey(PKey):
def can_sign(self):
return self.signing_key is not None
- def sign_ssh_data(self, data):
+ def sign_ssh_data(self, data, algorithm=None):
ecdsa = ec.ECDSA(self.ecdsa_curve.hash_object())
sig = self.signing_key.sign(data, ecdsa)
r, s = decode_dss_signature(sig)
diff --git a/paramiko/ed25519key.py b/paramiko/ed25519key.py
index 7b19e352..d322a0c1 100644
--- a/paramiko/ed25519key.py
+++ b/paramiko/ed25519key.py
@@ -191,7 +191,7 @@ class Ed25519Key(PKey):
def can_sign(self):
return self._signing_key is not None
- def sign_ssh_data(self, data):
+ def sign_ssh_data(self, data, algorithm=None):
m = Message()
m.add_string("ssh-ed25519")
m.add_string(self._signing_key.sign(data).signature)
diff --git a/paramiko/kex_curve25519.py b/paramiko/kex_curve25519.py
index 59710c1a..3420fb4f 100644
--- a/paramiko/kex_curve25519.py
+++ b/paramiko/kex_curve25519.py
@@ -89,7 +89,9 @@ class KexCurve25519(object):
hm.add_mpint(K)
H = self.hash_algo(hm.asbytes()).digest()
self.transport._set_K_H(K, H)
- sig = self.transport.get_server_key().sign_ssh_data(H)
+ sig = self.transport.get_server_key().sign_ssh_data(
+ H, self.transport.host_key_type
+ )
# construct reply
m = Message()
m.add_byte(c_MSG_KEXECDH_REPLY)
diff --git a/paramiko/kex_ecdh_nist.py b/paramiko/kex_ecdh_nist.py
index ad5c9c79..19de2431 100644
--- a/paramiko/kex_ecdh_nist.py
+++ b/paramiko/kex_ecdh_nist.py
@@ -90,7 +90,9 @@ class KexNistp256:
hm.add_mpint(long(K))
H = self.hash_algo(hm.asbytes()).digest()
self.transport._set_K_H(K, H)
- sig = self.transport.get_server_key().sign_ssh_data(H)
+ sig = self.transport.get_server_key().sign_ssh_data(
+ H, self.transport.host_key_type
+ )
# construct reply
m = Message()
m.add_byte(c_MSG_KEXECDH_REPLY)
diff --git a/paramiko/kex_gex.py b/paramiko/kex_gex.py
index fb8f01fd..ab462e6d 100644
--- a/paramiko/kex_gex.py
+++ b/paramiko/kex_gex.py
@@ -240,7 +240,9 @@ class KexGex(object):
H = self.hash_algo(hm.asbytes()).digest()
self.transport._set_K_H(K, H)
# sign it
- sig = self.transport.get_server_key().sign_ssh_data(H)
+ sig = self.transport.get_server_key().sign_ssh_data(
+ H, self.transport.host_key_type
+ )
# send reply
m = Message()
m.add_byte(c_MSG_KEXDH_GEX_REPLY)
diff --git a/paramiko/kex_group1.py b/paramiko/kex_group1.py
index dce3fd91..6d548b01 100644
--- a/paramiko/kex_group1.py
+++ b/paramiko/kex_group1.py
@@ -143,7 +143,9 @@ class KexGroup1(object):
H = self.hash_algo(hm.asbytes()).digest()
self.transport._set_K_H(K, H)
# sign it
- sig = self.transport.get_server_key().sign_ssh_data(H)
+ sig = self.transport.get_server_key().sign_ssh_data(
+ H, self.transport.host_key_type
+ )
# send reply
m = Message()
m.add_byte(c_MSG_KEXDH_REPLY)
diff --git a/paramiko/pkey.py b/paramiko/pkey.py
index 5bdfb1d4..ff037f71 100644
--- a/paramiko/pkey.py
+++ b/paramiko/pkey.py
@@ -140,7 +140,7 @@ class PKey(object):
return cmp(self.asbytes(), other.asbytes()) # noqa
def __eq__(self, other):
- return self._fields == other._fields
+ return isinstance(other, PKey) and self._fields == other._fields
def __hash__(self):
return hash(self._fields)
@@ -196,13 +196,20 @@ class PKey(object):
"""
return u(encodebytes(self.asbytes())).replace("\n", "")
- def sign_ssh_data(self, data):
+ def sign_ssh_data(self, data, algorithm=None):
"""
Sign a blob of data with this private key, and return a `.Message`
representing an SSH signature message.
- :param str data: the data to sign.
+ :param str data:
+ the data to sign.
+ :param str algorithm:
+ the signature algorithm to use, if different from the key's
+ internal name. Default: ``None``.
:return: an SSH signature `message <.Message>`.
+
+ .. versionchanged:: 2.9
+ Added the ``algorithm`` kwarg.
"""
return bytes()
@@ -317,6 +324,8 @@ class PKey(object):
def _read_private_key(self, tag, f, password=None):
lines = f.readlines()
+ if not lines:
+ raise SSHException("no lines in {} private key file".format(tag))
# find the BEGIN tag
start = 0
diff --git a/paramiko/rsakey.py b/paramiko/rsakey.py
index 292d0ccc..2aea84b9 100644
--- a/paramiko/rsakey.py
+++ b/paramiko/rsakey.py
@@ -37,6 +37,15 @@ class RSAKey(PKey):
data.
"""
+ HASHES = {
+ "ssh-rsa": hashes.SHA1,
+ "ssh-rsa-cert-v01@openssh.com": hashes.SHA1,
+ "rsa-sha2-256": hashes.SHA256,
+ "rsa-sha2-256-cert-v01@openssh.com": hashes.SHA256,
+ "rsa-sha2-512": hashes.SHA512,
+ "rsa-sha2-512-cert-v01@openssh.com": hashes.SHA512,
+ }
+
def __init__(
self,
msg=None,
@@ -61,6 +70,8 @@ class RSAKey(PKey):
else:
self._check_type_and_load_cert(
msg=msg,
+ # NOTE: this does NOT change when using rsa2 signatures; it's
+ # purely about key loading, not exchange or verification
key_type="ssh-rsa",
cert_type="ssh-rsa-cert-v01@openssh.com",
)
@@ -111,26 +122,35 @@ class RSAKey(PKey):
def can_sign(self):
return isinstance(self.key, rsa.RSAPrivateKey)
- def sign_ssh_data(self, data):
+ def sign_ssh_data(self, data, algorithm="ssh-rsa"):
sig = self.key.sign(
- data, padding=padding.PKCS1v15(), algorithm=hashes.SHA1()
+ data,
+ padding=padding.PKCS1v15(),
+ algorithm=self.HASHES[algorithm](),
)
-
m = Message()
- m.add_string("ssh-rsa")
+ m.add_string(algorithm.replace("-cert-v01@openssh.com", ""))
m.add_string(sig)
return m
def verify_ssh_sig(self, data, msg):
- if msg.get_text() != "ssh-rsa":
+ sig_algorithm = msg.get_text()
+ if sig_algorithm not in self.HASHES:
return False
key = self.key
if isinstance(key, rsa.RSAPrivateKey):
key = key.public_key()
+ # NOTE: pad received signature with leading zeros, key.verify()
+ # expects a signature of key size (e.g. PuTTY doesn't pad)
+ sign = msg.get_binary()
+ diff = key.key_size - len(sign) * 8
+ if diff > 0:
+ sign = b"\x00" * ((diff + 7) // 8) + sign
+
try:
key.verify(
- msg.get_binary(), data, padding.PKCS1v15(), hashes.SHA1()
+ sign, data, padding.PKCS1v15(), self.HASHES[sig_algorithm]()
)
except InvalidSignature:
return False
diff --git a/paramiko/sftp_file.py b/paramiko/sftp_file.py
index 0104d857..57e04175 100644
--- a/paramiko/sftp_file.py
+++ b/paramiko/sftp_file.py
@@ -527,7 +527,7 @@ class SFTPFile(BufferedFile):
self._prefetch_done = False
t = threading.Thread(target=self._prefetch_thread, args=(chunks,))
- t.setDaemon(True)
+ t.daemon = True
t.start()
def _prefetch_thread(self, chunks):
diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py
index 2789be99..39fcb10d 100644
--- a/paramiko/ssh_exception.py
+++ b/paramiko/ssh_exception.py
@@ -135,6 +135,21 @@ class BadHostKeyException(SSHException):
)
+class IncompatiblePeer(SSHException):
+ """
+ A disagreement arose regarding an algorithm required for key exchange.
+
+ .. versionadded:: 2.9
+ """
+
+ # TODO 3.0: consider making this annotate w/ 1..N 'missing' algorithms,
+ # either just the first one that would halt kex, or even updating the
+ # Transport logic so we record /all/ that /could/ halt kex.
+ # TODO: update docstrings where this may end up raised so they are more
+ # specific.
+ pass
+
+
class ProxyCommandFailure(SSHException):
"""
The "ProxyCommand" found in the .ssh/config file returned an error.
diff --git a/paramiko/transport.py b/paramiko/transport.py
index 8919043f..c28c3828 100644
--- a/paramiko/transport.py
+++ b/paramiko/transport.py
@@ -84,6 +84,8 @@ from paramiko.common import (
HIGHEST_USERAUTH_MESSAGE_ID,
MSG_UNIMPLEMENTED,
MSG_NAMES,
+ MSG_EXT_INFO,
+ cMSG_EXT_INFO,
)
from paramiko.compress import ZlibCompressor, ZlibDecompressor
from paramiko.dsskey import DSSKey
@@ -107,6 +109,7 @@ from paramiko.ssh_exception import (
SSHException,
BadAuthenticationType,
ChannelException,
+ IncompatiblePeer,
ProxyCommandFailure,
)
from paramiko.util import retry_on_signal, ClosingContextManager, clamp_value
@@ -168,11 +171,25 @@ class Transport(threading.Thread, ClosingContextManager):
"hmac-sha1-96",
"hmac-md5-96",
)
+ # ~= HostKeyAlgorithms in OpenSSH land
_preferred_keys = (
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
+ "rsa-sha2-512",
+ "rsa-sha2-256",
+ "ssh-rsa",
+ "ssh-dss",
+ )
+ # ~= PubKeyAcceptedAlgorithms
+ _preferred_pubkeys = (
+ "ssh-ed25519",
+ "ecdsa-sha2-nistp256",
+ "ecdsa-sha2-nistp384",
+ "ecdsa-sha2-nistp521",
+ "rsa-sha2-512",
+ "rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
)
@@ -259,8 +276,16 @@ class Transport(threading.Thread, ClosingContextManager):
}
_key_info = {
+ # TODO: at some point we will want to drop this as it's no longer
+ # considered secure due to using SHA-1 for signatures. OpenSSH 8.8 no
+ # longer supports it. Question becomes at what point do we want to
+ # prevent users with older setups from using this?
"ssh-rsa": RSAKey,
"ssh-rsa-cert-v01@openssh.com": RSAKey,
+ "rsa-sha2-256": RSAKey,
+ "rsa-sha2-256-cert-v01@openssh.com": RSAKey,
+ "rsa-sha2-512": RSAKey,
+ "rsa-sha2-512-cert-v01@openssh.com": RSAKey,
"ssh-dss": DSSKey,
"ssh-dss-cert-v01@openssh.com": DSSKey,
"ecdsa-sha2-nistp256": ECDSAKey,
@@ -310,6 +335,7 @@ class Transport(threading.Thread, ClosingContextManager):
gss_kex=False,
gss_deleg_creds=True,
disabled_algorithms=None,
+ server_sig_algs=True,
):
"""
Create a new SSH session over an existing socket, or socket-like
@@ -372,6 +398,10 @@ class Transport(threading.Thread, ClosingContextManager):
your code talks to a server which implements it differently from
Paramiko), specify ``disabled_algorithms={"kex":
["diffie-hellman-group16-sha512"]}``.
+ :param bool server_sig_algs:
+ Whether to send an extra message to compatible clients, in server
+ mode, with a list of supported pubkey algorithms. Default:
+ ``True``.
.. versionchanged:: 1.15
Added the ``default_window_size`` and ``default_max_packet_size``
@@ -380,9 +410,12 @@ class Transport(threading.Thread, ClosingContextManager):
Added the ``gss_kex`` and ``gss_deleg_creds`` kwargs.
.. versionchanged:: 2.6
Added the ``disabled_algorithms`` kwarg.
+ .. versionchanged:: 2.9
+ Added the ``server_sig_algs`` kwarg.
"""
self.active = False
self.hostname = None
+ self.server_extensions = {}
if isinstance(sock, string_types):
# convert "host:port" into (host, port)
@@ -417,7 +450,7 @@ class Transport(threading.Thread, ClosingContextManager):
)
# okay, normal socket-ish flow here...
threading.Thread.__init__(self)
- self.setDaemon(True)
+ self.daemon = True
self.sock = sock
# we set the timeout so we can check self.active periodically to
# see if we should bail. socket.timeout exception is never propagated.
@@ -488,6 +521,7 @@ class Transport(threading.Thread, ClosingContextManager):
# how long (seconds) to wait for the auth response.
self.auth_timeout = 30
self.disabled_algorithms = disabled_algorithms or {}
+ self.server_sig_algs = server_sig_algs
# server mode:
self.server_mode = False
@@ -518,6 +552,10 @@ class Transport(threading.Thread, ClosingContextManager):
return self._filter_algorithm("keys")
@property
+ def preferred_pubkeys(self):
+ return self._filter_algorithm("pubkeys")
+
+ @property
def preferred_kex(self):
return self._filter_algorithm("kex")
@@ -743,6 +781,12 @@ class Transport(threading.Thread, ClosingContextManager):
the host key to add, usually an `.RSAKey` or `.DSSKey`.
"""
self.server_key_dict[key.get_name()] = key
+ # Handle SHA-2 extensions for RSA by ensuring that lookups into
+ # self.server_key_dict will yield this key for any of the algorithm
+ # names.
+ if isinstance(key, RSAKey):
+ self.server_key_dict["rsa-sha2-256"] = key
+ self.server_key_dict["rsa-sha2-512"] = key
def get_server_key(self):
"""
@@ -1280,7 +1324,17 @@ class Transport(threading.Thread, ClosingContextManager):
Added the ``gss_trust_dns`` argument.
"""
if hostkey is not None:
- self._preferred_keys = [hostkey.get_name()]
+ # TODO: a more robust implementation would be to ask each key class
+ # for its nameS plural, and just use that.
+ # TODO: that could be used in a bunch of other spots too
+ if isinstance(hostkey, RSAKey):
+ self._preferred_keys = [
+ "rsa-sha2-512",
+ "rsa-sha2-256",
+ "ssh-rsa",
+ ]
+ else:
+ self._preferred_keys = [hostkey.get_name()]
self.set_gss_host(
gss_host=gss_host,
@@ -2126,7 +2180,12 @@ class Transport(threading.Thread, ClosingContextManager):
self._send_message(msg)
self.packetizer.complete_handshake()
except SSHException as e:
- self._log(ERROR, "Exception: " + str(e))
+ self._log(
+ ERROR,
+ "Exception ({}): {}".format(
+ "server" if self.server_mode else "client", e
+ ),
+ )
self._log(ERROR, util.tb_strings())
self.saved_exception = e
except EOFError as e:
@@ -2176,7 +2235,7 @@ class Transport(threading.Thread, ClosingContextManager):
# Log useful, non-duplicative line re: an agreed-upon algorithm.
# Old code implied algorithms could be asymmetrical (different for
# inbound vs outbound) so we preserve that possibility.
- msg = "{} agreed: ".format(which)
+ msg = "{}: ".format(which)
if local == remote:
msg += local
else:
@@ -2237,7 +2296,7 @@ class Transport(threading.Thread, ClosingContextManager):
client = segs[2]
if version != "1.99" and version != "2.0":
msg = "Incompatible version ({} instead of 2.0)"
- raise SSHException(msg.format(version))
+ raise IncompatiblePeer(msg.format(version))
msg = "Connected (version {}, client {})".format(version, client)
self._log(INFO, msg)
@@ -2253,13 +2312,10 @@ class Transport(threading.Thread, ClosingContextManager):
self.clear_to_send_lock.release()
self.gss_kex_used = False
self.in_kex = True
+ kex_algos = list(self.preferred_kex)
if self.server_mode:
mp_required_prefix = "diffie-hellman-group-exchange-sha"
- kex_mp = [
- k
- for k in self.preferred_kex
- if k.startswith(mp_required_prefix)
- ]
+ kex_mp = [k for k in kex_algos if k.startswith(mp_required_prefix)]
if (self._modulus_pack is None) and (len(kex_mp) > 0):
# can't do group-exchange if we don't have a pack of potential
# primes
@@ -2272,16 +2328,29 @@ class Transport(threading.Thread, ClosingContextManager):
available_server_keys = list(
filter(
list(self.server_key_dict.keys()).__contains__,
+ # TODO: ensure tests will catch if somebody streamlines
+ # this by mistake - case is the admittedly silly one where
+ # the only calls to add_server_key() contain keys which
+ # were filtered out of the below via disabled_algorithms.
+ # If this is streamlined, we would then be allowing the
+ # disabled algorithm(s) for hostkey use
+ # TODO: honestly this prob just wants to get thrown out
+ # when we make kex configuration more straightforward
self.preferred_keys,
)
)
else:
available_server_keys = self.preferred_keys
+ # Signal support for MSG_EXT_INFO.
+ # NOTE: doing this here handily means we don't even consider this
+ # value when agreeing on real kex algo to use (which is a common
+ # pitfall when adding this apparently).
+ kex_algos.append("ext-info-c")
m = Message()
m.add_byte(cMSG_KEXINIT)
m.add_bytes(os.urandom(16))
- m.add_list(self.preferred_kex)
+ m.add_list(kex_algos)
m.add_list(available_server_keys)
m.add_list(self.preferred_ciphers)
m.add_list(self.preferred_ciphers)
@@ -2294,50 +2363,74 @@ class Transport(threading.Thread, ClosingContextManager):
m.add_boolean(False)
m.add_int(0)
# save a copy for later (needed to compute a hash)
- self.local_kex_init = m.asbytes()
+ self.local_kex_init = self._latest_kex_init = m.asbytes()
self._send_message(m)
- def _parse_kex_init(self, m):
+ def _really_parse_kex_init(self, m, ignore_first_byte=False):
+ parsed = {}
+ if ignore_first_byte:
+ m.get_byte()
m.get_bytes(16) # cookie, discarded
- kex_algo_list = m.get_list()
- server_key_algo_list = m.get_list()
- client_encrypt_algo_list = m.get_list()
- server_encrypt_algo_list = m.get_list()
- client_mac_algo_list = m.get_list()
- server_mac_algo_list = m.get_list()
- client_compress_algo_list = m.get_list()
- server_compress_algo_list = m.get_list()
- client_lang_list = m.get_list()
- server_lang_list = m.get_list()
- kex_follows = m.get_boolean()
+ parsed["kex_algo_list"] = m.get_list()
+ parsed["server_key_algo_list"] = m.get_list()
+ parsed["client_encrypt_algo_list"] = m.get_list()
+ parsed["server_encrypt_algo_list"] = m.get_list()
+ parsed["client_mac_algo_list"] = m.get_list()
+ parsed["server_mac_algo_list"] = m.get_list()
+ parsed["client_compress_algo_list"] = m.get_list()
+ parsed["server_compress_algo_list"] = m.get_list()
+ parsed["client_lang_list"] = m.get_list()
+ parsed["server_lang_list"] = m.get_list()
+ parsed["kex_follows"] = m.get_boolean()
m.get_int() # unused
+ return parsed
- self._log(
- DEBUG,
- "kex algos:"
- + str(kex_algo_list)
- + " server key:"
- + str(server_key_algo_list)
- + " client encrypt:"
- + str(client_encrypt_algo_list)
- + " server encrypt:"
- + str(server_encrypt_algo_list)
- + " client mac:"
- + str(client_mac_algo_list)
- + " server mac:"
- + str(server_mac_algo_list)
- + " client compress:"
- + str(client_compress_algo_list)
- + " server compress:"
- + str(server_compress_algo_list)
- + " client lang:"
- + str(client_lang_list)
- + " server lang:"
- + str(server_lang_list)
- + " kex follows?"
- + str(kex_follows),
+ def _get_latest_kex_init(self):
+ return self._really_parse_kex_init(
+ Message(self._latest_kex_init), ignore_first_byte=True
)
+ def _parse_kex_init(self, m):
+ parsed = self._really_parse_kex_init(m)
+ kex_algo_list = parsed["kex_algo_list"]
+ server_key_algo_list = parsed["server_key_algo_list"]
+ client_encrypt_algo_list = parsed["client_encrypt_algo_list"]
+ server_encrypt_algo_list = parsed["server_encrypt_algo_list"]
+ client_mac_algo_list = parsed["client_mac_algo_list"]
+ server_mac_algo_list = parsed["server_mac_algo_list"]
+ client_compress_algo_list = parsed["client_compress_algo_list"]
+ server_compress_algo_list = parsed["server_compress_algo_list"]
+ client_lang_list = parsed["client_lang_list"]
+ server_lang_list = parsed["server_lang_list"]
+ kex_follows = parsed["kex_follows"]
+
+ self._log(DEBUG, "=== Key exchange possibilities ===")
+ for prefix, value in (
+ ("kex algos", kex_algo_list),
+ ("server key", server_key_algo_list),
+ # TODO: shouldn't these two lines say "cipher" to match usual
+ # terminology (including elsewhere in paramiko!)?
+ ("client encrypt", client_encrypt_algo_list),
+ ("server encrypt", server_encrypt_algo_list),
+ ("client mac", client_mac_algo_list),
+ ("server mac", server_mac_algo_list),
+ ("client compress", client_compress_algo_list),
+ ("server compress", server_compress_algo_list),
+ ("client lang", client_lang_list),
+ ("server lang", server_lang_list),
+ ):
+ if value == [""]:
+ value = ["<none>"]
+ value = ", ".join(value)
+ self._log(DEBUG, "{}: {}".format(prefix, value))
+ self._log(DEBUG, "kex follows: {}".format(kex_follows))
+ self._log(DEBUG, "=== Key exchange agreements ===")
+
+ # Strip out ext-info "kex algo"
+ self._remote_ext_info = None
+ if kex_algo_list[-1].startswith("ext-info-"):
+ self._remote_ext_info = kex_algo_list.pop()
+
# as a server, we pick the first item in the client's list that we
# support.
# as a client, we pick the first item in our list that the server
@@ -2351,11 +2444,14 @@ class Transport(threading.Thread, ClosingContextManager):
filter(kex_algo_list.__contains__, self.preferred_kex)
)
if len(agreed_kex) == 0:
- raise SSHException(
+ # TODO: do an auth-overhaul style aggregate exception here?
+ # TODO: would let us streamline log output & show all failures up
+ # front
+ raise IncompatiblePeer(
"Incompatible ssh peer (no acceptable kex algorithm)"
) # noqa
self.kex_engine = self._kex_info[agreed_kex[0]](self)
- self._log(DEBUG, "Kex agreed: {}".format(agreed_kex[0]))
+ self._log(DEBUG, "Kex: {}".format(agreed_kex[0]))
if self.server_mode:
available_server_keys = list(
@@ -2374,12 +2470,12 @@ class Transport(threading.Thread, ClosingContextManager):
filter(server_key_algo_list.__contains__, self.preferred_keys)
)
if len(agreed_keys) == 0:
- raise SSHException(
+ raise IncompatiblePeer(
"Incompatible ssh peer (no acceptable host key)"
) # noqa
self.host_key_type = agreed_keys[0]
if self.server_mode and (self.get_server_key() is None):
- raise SSHException(
+ raise IncompatiblePeer(
"Incompatible ssh peer (can't match requested host key type)"
) # noqa
self._log_agreement("HostKey", agreed_keys[0], agreed_keys[0])
@@ -2411,7 +2507,7 @@ class Transport(threading.Thread, ClosingContextManager):
)
)
if len(agreed_local_ciphers) == 0 or len(agreed_remote_ciphers) == 0:
- raise SSHException(
+ raise IncompatiblePeer(
"Incompatible ssh server (no acceptable ciphers)"
) # noqa
self.local_cipher = agreed_local_ciphers[0]
@@ -2435,7 +2531,9 @@ class Transport(threading.Thread, ClosingContextManager):
filter(server_mac_algo_list.__contains__, self.preferred_macs)
)
if (len(agreed_local_macs) == 0) or (len(agreed_remote_macs) == 0):
- raise SSHException("Incompatible ssh server (no acceptable macs)")
+ raise IncompatiblePeer(
+ "Incompatible ssh server (no acceptable macs)"
+ )
self.local_mac = agreed_local_macs[0]
self.remote_mac = agreed_remote_macs[0]
self._log_agreement(
@@ -2474,7 +2572,7 @@ class Transport(threading.Thread, ClosingContextManager):
):
msg = "Incompatible ssh server (no acceptable compression)"
msg += " {!r} {!r} {!r}"
- raise SSHException(
+ raise IncompatiblePeer(
msg.format(
agreed_local_compression,
agreed_remote_compression,
@@ -2488,6 +2586,7 @@ class Transport(threading.Thread, ClosingContextManager):
local=self.local_compression,
remote=self.remote_compression,
)
+ self._log(DEBUG, "=== End of kex handshake ===")
# save for computing hash later...
# now wait! openssh has a bug (and others might too) where there are
@@ -2573,6 +2672,20 @@ class Transport(threading.Thread, ClosingContextManager):
self.packetizer.set_outbound_compressor(compress_out())
if not self.packetizer.need_rekey():
self.in_kex = False
+ # If client indicated extension support, send that packet immediately
+ if (
+ self.server_mode
+ and self.server_sig_algs
+ and self._remote_ext_info == "ext-info-c"
+ ):
+ extensions = {"server-sig-algs": ",".join(self.preferred_pubkeys)}
+ m = Message()
+ m.add_byte(cMSG_EXT_INFO)
+ m.add_int(len(extensions))
+ for name, value in sorted(extensions.items()):
+ m.add_string(name)
+ m.add_string(value)
+ self._send_message(m)
# we always expect to receive NEWKEYS now
self._expect_packet(MSG_NEWKEYS)
@@ -2588,6 +2701,20 @@ class Transport(threading.Thread, ClosingContextManager):
self._log(DEBUG, "Switching on inbound compression ...")
self.packetizer.set_inbound_compressor(compress_in())
+ def _parse_ext_info(self, msg):
+ # Packet is a count followed by that many key-string to possibly-bytes
+ # pairs.
+ extensions = {}
+ for _ in range(msg.get_int()):
+ name = msg.get_text()
+ value = msg.get_string()
+ extensions[name] = value
+ self._log(DEBUG, "Got EXT_INFO: {}".format(extensions))
+ # NOTE: this should work ok in cases where a server sends /two/ such
+ # messages; the RFC explicitly states a 2nd one should overwrite the
+ # 1st.
+ self.server_extensions = extensions
+
def _parse_newkeys(self, m):
self._log(DEBUG, "Switch to new keys ...")
self._activate_inbound()
@@ -2855,6 +2982,7 @@ class Transport(threading.Thread, ClosingContextManager):
self.lock.release()
_handler_table = {
+ MSG_EXT_INFO: _parse_ext_info,
MSG_NEWKEYS: _parse_newkeys,
MSG_GLOBAL_REQUEST: _parse_global_request,
MSG_REQUEST_SUCCESS: _parse_request_success,
@@ -2877,6 +3005,9 @@ class Transport(threading.Thread, ClosingContextManager):
}
+# TODO 3.0: drop this, we barely use it ourselves, it badly replicates the
+# Transport-internal algorithm management, AND does so in a way which doesn't
+# honor newer things like disabled_algorithms!
class SecurityOptions(object):
"""
Simple object containing the security preferences of an ssh transport.
diff --git a/paramiko/util.py b/paramiko/util.py
index 93970289..a344915c 100644
--- a/paramiko/util.py
+++ b/paramiko/util.py
@@ -225,24 +225,20 @@ def mod_inverse(x, m):
return u2
-_g_thread_ids = {}
+_g_thread_data = threading.local()
_g_thread_counter = 0
_g_thread_lock = threading.Lock()
def get_thread_id():
- global _g_thread_ids, _g_thread_counter, _g_thread_lock
- tid = id(threading.currentThread())
+ global _g_thread_data, _g_thread_counter, _g_thread_lock
try:
- return _g_thread_ids[tid]
- except KeyError:
- _g_thread_lock.acquire()
- try:
+ return _g_thread_data.id
+ except AttributeError:
+ with _g_thread_lock:
_g_thread_counter += 1
- ret = _g_thread_ids[tid] = _g_thread_counter
- finally:
- _g_thread_lock.release()
- return ret
+ _g_thread_data.id = _g_thread_counter
+ return _g_thread_data.id
def log_to_file(filename, level=DEBUG):
diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst
index e82a65b1..b6bcf72f 100644
--- a/sites/www/changelog.rst
+++ b/sites/www/changelog.rst
@@ -9,12 +9,143 @@ Changelog
errors (though we hasten to remind everyone that Client and Transport have
their own ``.close()`` methods for use in non-error situations!). Patch
courtesy of ``@YoavCohen``.
+- bug:`1637` (via :issue:`1599`) Raise `SSHException` explicitly when blank
+ private key data is loaded, instead of the natural result of ``IndexError``.
+ This should help more bits of Paramiko or Paramiko-adjacent codebases to
+ correctly handle this class of error. Credit: Nicholas Dietz.
+- :release:`2.9.5 <2022-05-16>`
+- :bug:`1933` Align signature verification algorithm with OpenSSH re:
+ zero-padding signatures which don't match their nominal size/length. This
+ shouldn't affect most users, but will help Paramiko-implemented SSH servers
+ handle poorly behaved clients such as PuTTY. Thanks to Jun Omae for catch &
+ patch.
+- :bug:`2017` OpenSSH 7.7 and older has a bug preventing it from understanding
+ how to perform SHA2 signature verification for RSA certificates (specifically
+ certs - not keys), so when we added SHA2 support it broke all clients using
+ RSA certificates with these servers. This has been fixed in a manner similar
+ to what OpenSSH's own client does: a version check is performed and the
+ algorithm used is downgraded if needed. Reported by Adarsh Chauhan, with fix
+ suggested by Jun Omae.
+- :release:`2.9.4 <2022-04-25>`
+- :support:`1838 backported` (via :issue:`1870`/:issue:`2028`) Update
+ ``camelCase`` method calls against the ``threading`` module to be
+ ``snake_case``; this and related tweaks should fix some deprecation warnings
+ under Python 3.10. Thanks to Karthikeyan Singaravelan for the report,
+ ``@Narendra-Neerukonda`` for the patch, and to Thomas Grainger and Jun Omae
+ for patch workshopping.
+- :bug:`1964` (via :issue:`2024` as also reported in :issue:`2023`)
+ `~paramiko.pkey.PKey` instances' ``__eq__`` did not have the usual safety
+ guard in place to ensure they were being compared to another ``PKey`` object,
+ causing occasional spurious ``BadHostKeyException`` (among other things).
+ This has been fixed. Thanks to Shengdun Hua for the original report/patch and
+ to Christopher Papke for the final version of the fix.
+- :release:`2.9.3 <2022-03-18>`
+- :bug:`1963` (via :issue:`1977`) Certificate-based pubkey auth was
+ inadvertently broken when adding SHA2 support; this has been fixed. Reported
+ by Erik Forsberg and fixed by Jun Omae.
+- :bug:`2002` (via :issue:`2003`) Switch from module-global to thread-local
+ storage when recording thread IDs for a logging helper; this should avoid one
+ flavor of memory leak for long-running processes. Catch & patch via Richard
+ Kojedzinszky.
- :support:`1985` Add ``six`` explicitly to install-requires; it snuck into
active use at some point but has only been indicated by transitive dependency
on ``bcrypt`` until they somewhat-recently dropped it. This will be
short-lived until we `drop Python 2
support <https://bitprophet.org/projects/#roadmap>`_. Thanks to Sondre
Lillebø Gundersen for catch & patch.
+- :release:`2.9.2 <2022-01-08>`
+- :bug:`-` Connecting to servers which support ``server-sig-algs`` but which
+ have no overlap between that list and what a Paramiko client supports, now
+ raise an exception instead of defaulting to ``rsa-sha2-512`` (since the use
+ of ``server-sig-algs`` allows us to know what the server supports).
+- :bug:`-` Enhanced log output when connecting to servers that do not support
+ ``server-sig-algs`` extensions, making the new-as-of-2.9 defaulting to SHA2
+ pubkey algorithms more obvious when it kicks in.
+- :release:`2.9.1 <2021-12-24>`
+- :bug:`1955` Server-side support for ``rsa-sha2-256`` and ``ssh-rsa`` wasn't
+ fully operable after 2.9.0's release (signatures for RSA pubkeys were always
+ run through ``rsa-sha2-512`` instead). Report and early stab at a fix
+ courtesy of Jun Omae.
+- :release:`2.9.0 <2021-12-23>`
+- :feature:`1643` (also :issue:`1925`, :issue:`1644`, :issue:`1326`) Add
+ support for SHA-2 variants of RSA key verification algorithms (as described
+ in :rfc:`8332`) as well as limited SSH extension negotiation (:rfc:`8308`).
+
+ .. warning::
+ This change is slightly backwards incompatible, insofar as action is
+ required if your target systems do not support either RSA2 or the
+ ``server-sig-algs`` protocol extension.
+
+ Specifically, you need to specify ``disabled_algorithms={'keys':
+ ['rsa-sha2-256', 'rsa-sha2-512']}`` in either `SSHClient
+ <paramiko.client.SSHClient.__init__>` or `Transport
+ <paramiko.transport.Transport.__init__>`. See below for details on why.
+
+ How SSH servers/clients decide when and how to use this functionality can be
+ complicated; Paramiko's support is as follows:
+
+ - Client verification of server host key during key exchange will now prefer
+ ``rsa-sha2-512``, ``rsa-sha2-256``, and legacy ``ssh-rsa`` algorithms, in
+ that order, instead of just ``ssh-rsa``.
+
+ - Note that the preference order of other algorithm families such as
+ ``ed25519`` and ``ecdsa`` has not changed; for example, those two
+ groups are still preferred over RSA.
+
+ - Server mode will now offer all 3 RSA algorithms for host key verification
+ during key exchange, similar to client mode, if it has been configured with
+ an RSA host key.
+ - Client mode key exchange now sends the ``ext-info-c`` flag signaling
+ support for ``MSG_EXT_INFO``, and support for parsing the latter
+ (specifically, its ``server-sig-algs`` flag) has been added.
+ - Client mode, when performing public key authentication with an RSA key or
+ cert, will act as follows:
+
+ - In all cases, the list of algorithms to consider is based on the new
+ ``preferred_pubkeys`` list (see below) and ``disabled_algorithms``
+ (specifically, its ``pubkeys`` key); this list, like with host keys,
+ prefers SHA2-512, SHA2-256 and SHA1, in that order.
+ - When the server does not send ``server-sig-algs``, Paramiko will attempt
+ the first algorithm in the above list. Clients connecting to legacy
+ servers should thus use ``disabled_algorithms`` to turn off SHA2.
+ - When the server does send ``server-sig-algs``, the first algorithm
+ supported by both ends is used, or if there is none, it falls back to the
+ previous behavior.
+
+ - SSH agent support grew the ability to specify algorithm flags when
+ requesting private key signatures; this is now used to forward SHA2
+ algorithms when appropriate.
+ - Server mode is now capable of pubkey auth involving SHA-2 signatures from
+ clients, provided one's server implementation actually provides for doing
+ so.
+
+ - This includes basic support for sending ``MSG_EXT_INFO`` (containing
+ ``server-sig-algs`` only) to clients advertising ``ext-info-c`` in their
+ key exchange list.
+
+ In order to implement the above, the following API additions were made:
+
+ - `PKey.sign_ssh_data <paramiko.pkey.PKey>`: Grew an extra, optional
+ ``algorithm`` keyword argument (defaulting to ``None`` for most subclasses,
+ and to ``"ssh-rsa"`` for `~paramiko.rsakey.RSAKey`).
+ - A new `~paramiko.ssh_exception.SSHException` subclass was added,
+ `~paramiko.ssh_exception.IncompatiblePeer`, and is raised in all spots
+ where key exchange aborts due to algorithmic incompatibility.
+
+ - Like all other exceptions in that module, it inherits from
+ ``SSHException``, and as we did not change anything else about the
+ raising (i.e. the attributes and message text are the same) this change
+ is backwards compatible.
+
+ - `~paramiko.transport.Transport` grew a ``_preferred_pubkeys`` attribute and
+ matching ``preferred_pubkeys`` property to match the other, kex-focused,
+ such members. This allows client pubkey authentication to honor the
+ ``disabled_algorithms`` feature.
+
+ Thanks to Krisztián Kovács for the report and an early stab at a patch, as
+ well as the numerous users who submitted feedback on the issue, including but
+ not limited to: Christopher Rabotin, Sam Bull, and Manfred Kaiser.
+
- :release:`2.8.1 <2021-11-28>`
- :bug:`985` (via :issue:`992`) Fix listdir failure when server uses a locale.
Now on Python 2.7 `SFTPAttributes <paramiko.sftp_attr.SFTPAttributes>` will
diff --git a/tasks.py b/tasks.py
index 2e105297..ae408059 100644
--- a/tasks.py
+++ b/tasks.py
@@ -125,7 +125,6 @@ def all_(c, dry_run=False):
release_coll["prepare"](c, dry_run=dry_run)
publish_(c, dry_run=dry_run)
release_coll["push"](c, dry_run=dry_run)
- release_coll["tidelift"](c, dry_run=dry_run)
# TODO: "replace one task with another" needs a better public API, this is
diff --git a/tests/blank_rsa.key b/tests/blank_rsa.key
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tests/blank_rsa.key
diff --git a/tests/loop.py b/tests/loop.py
index dd1f5a0c..40179a64 100644
--- a/tests/loop.py
+++ b/tests/loop.py
@@ -81,7 +81,7 @@ class LoopSocket(object):
self.__lock.acquire()
try:
self.__in_buffer += data
- self.__cv.notifyAll()
+ self.__cv.notify_all()
finally:
self.__lock.release()
diff --git a/tests/test_agent.py b/tests/test_agent.py
new file mode 100644
index 00000000..c3973bbb
--- /dev/null
+++ b/tests/test_agent.py
@@ -0,0 +1,50 @@
+import unittest
+
+from paramiko.message import Message
+from paramiko.agent import (
+ SSH2_AGENT_SIGN_RESPONSE,
+ cSSH2_AGENTC_SIGN_REQUEST,
+ SSH_AGENT_RSA_SHA2_256,
+ SSH_AGENT_RSA_SHA2_512,
+ AgentKey,
+)
+from paramiko.py3compat import b
+
+
+class ChaosAgent:
+ def _send_message(self, msg):
+ self._sent_message = msg
+ sig = Message()
+ sig.add_string(b("lol"))
+ sig.rewind()
+ return SSH2_AGENT_SIGN_RESPONSE, sig
+
+
+class AgentTests(unittest.TestCase):
+ def _sign_with_agent(self, kwargs, expectation):
+ agent = ChaosAgent()
+ key = AgentKey(agent, b("secret!!!"))
+ result = key.sign_ssh_data(b("token"), **kwargs)
+ assert result == b("lol")
+ msg = agent._sent_message
+ msg.rewind()
+ assert msg.get_byte() == cSSH2_AGENTC_SIGN_REQUEST
+ assert msg.get_string() == b("secret!!!")
+ assert msg.get_string() == b("token")
+ assert msg.get_int() == expectation
+
+ def test_agent_signing_defaults_to_0_for_flags_field(self):
+ # No algorithm kwarg at all
+ self._sign_with_agent(kwargs=dict(), expectation=0)
+
+ def test_agent_signing_is_2_for_SHA256(self):
+ self._sign_with_agent(
+ kwargs=dict(algorithm="rsa-sha2-256"),
+ expectation=SSH_AGENT_RSA_SHA2_256,
+ )
+
+ def test_agent_signing_is_2_for_SHA512(self):
+ self._sign_with_agent(
+ kwargs=dict(algorithm="rsa-sha2-512"),
+ expectation=SSH_AGENT_RSA_SHA2_512,
+ )
diff --git a/tests/test_client.py b/tests/test_client.py
index a2e23b39..42487529 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -153,7 +153,12 @@ class ClientTest(unittest.TestCase):
self.sockl.close()
def _run(
- self, allowed_keys=None, delay=0, public_blob=None, kill_event=None
+ self,
+ allowed_keys=None,
+ delay=0,
+ public_blob=None,
+ kill_event=None,
+ server_name=None,
):
if allowed_keys is None:
allowed_keys = FINGERPRINTS.keys()
@@ -165,6 +170,8 @@ class ClientTest(unittest.TestCase):
self.socks.close()
return
self.ts = paramiko.Transport(self.socks)
+ if server_name is not None:
+ self.ts.local_version = server_name
keypath = _support("test_rsa.key")
host_key = paramiko.RSAKey.from_private_key_file(keypath)
self.ts.add_server_key(host_key)
@@ -180,11 +187,11 @@ class ClientTest(unittest.TestCase):
"""
(Most) kwargs get passed directly into SSHClient.connect().
- The exception is ``allowed_keys`` which is stripped and handed to the
- ``NullServer`` used for testing.
+ The exceptions are ``allowed_keys``/``public_blob``/``server_name``
+ which are stripped and handed to the ``NullServer`` used for testing.
"""
run_kwargs = {"kill_event": self.kill_event}
- for key in ("allowed_keys", "public_blob"):
+ for key in ("allowed_keys", "public_blob", "server_name"):
run_kwargs[key] = kwargs.pop(key, None)
# Server setup
threading.Thread(target=self._run, kwargs=run_kwargs).start()
@@ -206,7 +213,9 @@ class ClientTest(unittest.TestCase):
self.event.wait(1.0)
self.assertTrue(self.event.is_set())
self.assertTrue(self.ts.is_active())
- self.assertEqual("slowdive", self.ts.get_username())
+ self.assertEqual(
+ self.connect_kwargs["username"], self.ts.get_username()
+ )
self.assertEqual(True, self.ts.is_authenticated())
self.assertEqual(False, self.tc.get_transport().gss_kex_used)
@@ -337,6 +346,29 @@ class SSHClientTest(ClientTest):
),
)
+ def _cert_algo_test(self, ver, alg):
+ # Issue #2017; see auth_handler.py
+ self.connect_kwargs["username"] = "somecertuser" # neuter pw auth
+ self._test_connection(
+ # NOTE: SSHClient is able to take either the key or the cert & will
+ # set up its internals as needed
+ key_filename=_support(
+ os.path.join("cert_support", "test_rsa.key-cert.pub")
+ ),
+ server_name="SSH-2.0-OpenSSH_{}".format(ver),
+ )
+ assert (
+ self.tc._transport._agreed_pubkey_algorithm
+ == "{}-cert-v01@openssh.com".format(alg)
+ )
+
+ def test_old_openssh_needs_ssh_rsa_for_certs_not_rsa_sha2(self):
+ self._cert_algo_test(ver="7.7", alg="ssh-rsa")
+
+ def test_newer_openssh_uses_rsa_sha2_for_certs_not_ssh_rsa(self):
+ # NOTE: 512 happens to be first in our list and is thus chosen
+ self._cert_algo_test(ver="7.8", alg="rsa-sha2-512")
+
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
diff --git a/tests/test_kex.py b/tests/test_kex.py
index c251611a..b73989c2 100644
--- a/tests/test_kex.py
+++ b/tests/test_kex.py
@@ -76,7 +76,7 @@ class FakeKey(object):
def asbytes(self):
return b"fake-key"
- def sign_ssh_data(self, H):
+ def sign_ssh_data(self, H, algorithm):
return b"fake-sig"
@@ -93,6 +93,7 @@ class FakeTransport(object):
remote_version = "SSH-2.0-lame"
local_kex_init = "local-kex-init"
remote_kex_init = "remote-kex-init"
+ host_key_type = "fake-key"
def _send_message(self, m):
self._message = m
diff --git a/tests/test_pkey.py b/tests/test_pkey.py
index 94b2492b..6acb9121 100644
--- a/tests/test_pkey.py
+++ b/tests/test_pkey.py
@@ -63,6 +63,8 @@ FINGER_ECDSA_256 = "256 25:19:eb:55:e6:a1:47:ff:4f:38:d2:75:6f:a5:d5:60"
FINGER_ECDSA_384 = "384 c1:8d:a0:59:09:47:41:8e:a8:a6:07:01:29:23:b4:65"
FINGER_ECDSA_521 = "521 44:58:22:52:12:33:16:0e:ce:0e:be:2c:7c:7e:cc:1e"
SIGNED_RSA = "20:d7:8a:31:21:cb:f7:92:12:f2:a4:89:37:f5:78:af:e6:16:b6:25:b9:97:3d:a2:cd:5f:ca:20:21:73:4c:ad:34:73:8f:20:77:28:e2:94:15:08:d8:91:40:7a:85:83:bf:18:37:95:dc:54:1a:9b:88:29:6c:73:ca:38:b4:04:f1:56:b9:f2:42:9d:52:1b:29:29:b4:4f:fd:c9:2d:af:47:d2:40:76:30:f3:63:45:0c:d9:1d:43:86:0f:1c:70:e2:93:12:34:f3:ac:c5:0a:2f:14:50:66:59:f1:88:ee:c1:4a:e9:d1:9c:4e:46:f0:0e:47:6f:38:74:f1:44:a8" # noqa
+SIGNED_RSA_256 = "cc:6:60:e0:0:2c:ac:9e:26:bc:d5:68:64:3f:9f:a7:e5:aa:41:eb:88:4a:25:5:9c:93:84:66:ef:ef:60:f4:34:fb:f4:c8:3d:55:33:6a:77:bd:b2:ee:83:f:71:27:41:7e:f5:7:5:0:a9:4c:7:80:6f:be:76:67:cb:58:35:b9:2b:f3:c2:d3:3c:ee:e1:3f:59:e0:fa:e4:5c:92:ed:ae:74:de:d:d6:27:16:8f:84:a3:86:68:c:94:90:7d:6e:cc:81:12:d8:b6:ad:aa:31:a8:13:3d:63:81:3e:bb:5:b6:38:4d:2:d:1b:5b:70:de:83:cc:3a:cb:31" # noqa
+SIGNED_RSA_512 = "87:46:8b:75:92:33:78:a0:22:35:32:39:23:c6:ab:e1:6:92:ad:bc:7f:6e:ab:19:32:e4:78:b2:2c:8f:1d:c:65:da:fc:a5:7:ca:b6:55:55:31:83:b1:a0:af:d1:95:c5:2e:af:56:ba:f5:41:64:f:39:9d:af:82:43:22:8f:90:52:9d:89:e7:45:97:df:f3:f2:bc:7b:3a:db:89:e:34:fd:18:62:25:1b:ef:77:aa:c6:6c:99:36:3a:84:d6:9c:2a:34:8c:7f:f4:bb:c9:a5:9a:6c:11:f2:cf:da:51:5e:1e:7f:90:27:34:de:b2:f3:15:4f:db:47:32:6b:a7" # noqa
FINGER_RSA_2K_OPENSSH = "2048 68:d1:72:01:bf:c0:0c:66:97:78:df:ce:75:74:46:d6"
FINGER_DSS_1K_OPENSSH = "1024 cf:1d:eb:d7:61:d3:12:94:c6:c0:c6:54:35:35:b0:82"
FINGER_EC_384_OPENSSH = "384 72:14:df:c1:9a:c3:e6:0e:11:29:d6:32:18:7b:ea:9b"
@@ -182,6 +184,11 @@ class KeyTest(unittest.TestCase):
with pytest.raises(SSHException, match=str(exception)):
RSAKey.from_private_key_file(_support("test_rsa.key"))
+ def test_loading_empty_keys_errors_usefully(self):
+ # #1599 - raise SSHException instead of IndexError
+ with pytest.raises(SSHException, match="no lines"):
+ RSAKey.from_private_key_file(_support("blank_rsa.key"))
+
def test_load_rsa_password(self):
key = RSAKey.from_private_key_file(
_support("test_rsa_password.key"), "television"
@@ -238,21 +245,29 @@ class KeyTest(unittest.TestCase):
self.assertTrue(not pub.can_sign())
self.assertEqual(key, pub)
- def test_sign_rsa(self):
- # verify that the rsa private key can sign and verify
+ def _sign_and_verify_rsa(self, algorithm, saved_sig):
key = RSAKey.from_private_key_file(_support("test_rsa.key"))
- msg = key.sign_ssh_data(b"ice weasels")
- self.assertTrue(type(msg) is Message)
+ msg = key.sign_ssh_data(b"ice weasels", algorithm)
+ assert isinstance(msg, Message)
msg.rewind()
- self.assertEqual("ssh-rsa", msg.get_text())
- sig = bytes().join(
- [byte_chr(int(x, 16)) for x in SIGNED_RSA.split(":")]
+ assert msg.get_text() == algorithm
+ expected = bytes().join(
+ [byte_chr(int(x, 16)) for x in saved_sig.split(":")]
)
- self.assertEqual(sig, msg.get_binary())
+ assert msg.get_binary() == expected
msg.rewind()
pub = RSAKey(data=key.asbytes())
self.assertTrue(pub.verify_ssh_sig(b"ice weasels", msg))
+ def test_sign_and_verify_ssh_rsa(self):
+ self._sign_and_verify_rsa("ssh-rsa", SIGNED_RSA)
+
+ def test_sign_and_verify_rsa_sha2_512(self):
+ self._sign_and_verify_rsa("rsa-sha2-512", SIGNED_RSA_512)
+
+ def test_sign_and_verify_rsa_sha2_256(self):
+ self._sign_and_verify_rsa("rsa-sha2-256", SIGNED_RSA_256)
+
def test_sign_dss(self):
# verify that the dss private key can sign and verify
key = DSSKey.from_private_key_file(_support("test_dss.key"))
@@ -612,6 +627,11 @@ class KeyTest(unittest.TestCase):
for key1, key2 in self.keys():
assert key1 == key2
+ def test_keys_are_not_equal_to_other(self):
+ for value in [None, True, ""]:
+ for key1, _ in self.keys():
+ assert key1 != value
+
def test_keys_are_hashable(self):
# NOTE: this isn't a great test due to hashseed randomization under
# Python 3 preventing use of static values, but it does still prove
@@ -686,3 +706,22 @@ class KeyTest(unittest.TestCase):
key1.load_certificate,
_support("test_rsa.key-cert.pub"),
)
+
+ def test_sign_rsa_with_certificate(self):
+ data = b"ice weasels"
+ key_path = _support(os.path.join("cert_support", "test_rsa.key"))
+ key = RSAKey.from_private_key_file(key_path)
+ msg = key.sign_ssh_data(data, "rsa-sha2-256")
+ msg.rewind()
+ assert "rsa-sha2-256" == msg.get_text()
+ sign = msg.get_binary()
+ cert_path = _support(
+ os.path.join("cert_support", "test_rsa.key-cert.pub")
+ )
+ key.load_certificate(cert_path)
+ msg = key.sign_ssh_data(data, "rsa-sha2-256-cert-v01@openssh.com")
+ msg.rewind()
+ assert "rsa-sha2-256" == msg.get_text()
+ assert sign == msg.get_binary()
+ msg.rewind()
+ assert key.verify_ssh_sig(b"ice weasels", msg)
diff --git a/tests/test_transport.py b/tests/test_transport.py
index e2174896..737ef705 100644
--- a/tests/test_transport.py
+++ b/tests/test_transport.py
@@ -23,6 +23,7 @@ Some unit tests for the ssh2 protocol in Transport.
from __future__ import with_statement
from binascii import hexlify
+from contextlib import contextmanager
import select
import socket
import time
@@ -38,6 +39,8 @@ from paramiko import (
Packetizer,
RSAKey,
SSHException,
+ AuthenticationException,
+ IncompatiblePeer,
SecurityOptions,
ServerInterface,
Transport,
@@ -80,6 +83,9 @@ class NullServer(ServerInterface):
paranoid_did_public_key = False
paranoid_key = DSSKey.from_private_key_file(_support("test_dss.key"))
+ def __init__(self, allowed_keys=None):
+ self.allowed_keys = allowed_keys if allowed_keys is not None else []
+
def get_allowed_auths(self, username):
if username == "slowdive":
return "publickey,password"
@@ -90,6 +96,11 @@ class NullServer(ServerInterface):
return AUTH_SUCCESSFUL
return AUTH_FAILED
+ def check_auth_publickey(self, username, key):
+ if key in self.allowed_keys:
+ return AUTH_SUCCESSFUL
+ return AUTH_FAILED
+
def check_channel_request(self, kind, chanid):
if kind == "bogus":
return OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
@@ -154,6 +165,7 @@ class TransportTest(unittest.TestCase):
self.socks.close()
self.sockc.close()
+ # TODO: unify with newer contextmanager
def setup_test_server(
self, client_options=None, server_options=None, connect_kwargs=None
):
@@ -245,7 +257,7 @@ class TransportTest(unittest.TestCase):
self.assertEqual(True, self.tc.is_authenticated())
self.assertEqual(True, self.ts.is_authenticated())
- def testa_long_banner(self):
+ def test_long_banner(self):
"""
verify that a long banner doesn't mess up the handshake.
"""
@@ -339,7 +351,7 @@ class TransportTest(unittest.TestCase):
self.assertEqual("This is on stderr.\n", f.readline())
self.assertEqual("", f.readline())
- def testa_channel_can_be_used_as_context_manager(self):
+ def test_channel_can_be_used_as_context_manager(self):
"""
verify that exec_command() does something reasonable.
"""
@@ -744,7 +756,7 @@ class TransportTest(unittest.TestCase):
threading.Thread.__init__(
self, None, None, self.__class__.__name__
)
- self.setDaemon(True)
+ self.daemon = True
self.chan = chan
self.iterations = iterations
self.done_event = done_event
@@ -768,7 +780,7 @@ class TransportTest(unittest.TestCase):
threading.Thread.__init__(
self, None, None, self.__class__.__name__
)
- self.setDaemon(True)
+ self.daemon = True
self.chan = chan
self.done_event = done_event
self.watchdog_event = threading.Event()
@@ -1169,3 +1181,273 @@ class AlgorithmDisablingTests(unittest.TestCase):
assert "ssh-dss" not in server_keys
assert "diffie-hellman-group14-sha256" not in kexen
assert "zlib" not in compressions
+
+
+@contextmanager
+def server(
+ hostkey=None,
+ init=None,
+ server_init=None,
+ client_init=None,
+ connect=None,
+ pubkeys=None,
+ catch_error=False,
+):
+ """
+ SSH server contextmanager for testing.
+
+ :param hostkey:
+ Host key to use for the server; if None, loads
+ ``test_rsa.key``.
+ :param init:
+ Default `Transport` constructor kwargs to use for both sides.
+ :param server_init:
+ Extends and/or overrides ``init`` for server transport only.
+ :param client_init:
+ Extends and/or overrides ``init`` for client transport only.
+ :param connect:
+ Kwargs to use for ``connect()`` on the client.
+ :param pubkeys:
+ List of public keys for auth.
+ :param catch_error:
+ Whether to capture connection errors & yield from contextmanager.
+ Necessary for connection_time exception testing.
+ """
+ if init is None:
+ init = {}
+ if server_init is None:
+ server_init = {}
+ if client_init is None:
+ client_init = {}
+ if connect is None:
+ connect = dict(username="slowdive", password="pygmalion")
+ socks = LoopSocket()
+ sockc = LoopSocket()
+ sockc.link(socks)
+ tc = Transport(sockc, **dict(init, **client_init))
+ ts = Transport(socks, **dict(init, **server_init))
+
+ if hostkey is None:
+ hostkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ ts.add_server_key(hostkey)
+ event = threading.Event()
+ server = NullServer(allowed_keys=pubkeys)
+ assert not event.is_set()
+ assert not ts.is_active()
+ assert tc.get_username() is None
+ assert ts.get_username() is None
+ assert not tc.is_authenticated()
+ assert not ts.is_authenticated()
+
+ err = None
+ # Trap errors and yield instead of raising right away; otherwise callers
+ # cannot usefully deal with problems at connect time which stem from errors
+ # in the server side.
+ try:
+ ts.start_server(event, server)
+ tc.connect(**connect)
+
+ event.wait(1.0)
+ assert event.is_set()
+ assert ts.is_active()
+ assert tc.is_active()
+
+ except Exception as e:
+ if not catch_error:
+ raise
+ err = e
+
+ yield (tc, ts, err) if catch_error else (tc, ts)
+
+ tc.close()
+ ts.close()
+ socks.close()
+ sockc.close()
+
+
+_disable_sha2 = dict(
+ disabled_algorithms=dict(keys=["rsa-sha2-256", "rsa-sha2-512"])
+)
+_disable_sha1 = dict(disabled_algorithms=dict(keys=["ssh-rsa"]))
+_disable_sha2_pubkey = dict(
+ disabled_algorithms=dict(pubkeys=["rsa-sha2-256", "rsa-sha2-512"])
+)
+_disable_sha1_pubkey = dict(disabled_algorithms=dict(pubkeys=["ssh-rsa"]))
+
+
+class TestSHA2SignatureKeyExchange(unittest.TestCase):
+ # NOTE: these all rely on the default server() hostkey being RSA
+ # NOTE: these rely on both sides being properly implemented re: agreed-upon
+ # hostkey during kex being what's actually used. Truly proving that eg
+ # SHA512 was used, is quite difficult w/o super gross hacks. However, there
+ # are new tests in test_pkey.py which use known signature blobs to prove
+ # the SHA2 family was in fact used!
+
+ def test_base_case_ssh_rsa_still_used_as_fallback(self):
+ # Prove that ssh-rsa is used if either, or both, participants have SHA2
+ # algorithms disabled
+ for which in ("init", "client_init", "server_init"):
+ with server(**{which: _disable_sha2}) as (tc, _):
+ assert tc.host_key_type == "ssh-rsa"
+
+ def test_kex_with_sha2_512(self):
+ # It's the default!
+ with server() as (tc, _):
+ assert tc.host_key_type == "rsa-sha2-512"
+
+ def test_kex_with_sha2_256(self):
+ # No 512 -> you get 256
+ with server(
+ init=dict(disabled_algorithms=dict(keys=["rsa-sha2-512"]))
+ ) as (tc, _):
+ assert tc.host_key_type == "rsa-sha2-256"
+
+ def _incompatible_peers(self, client_init, server_init):
+ with server(
+ client_init=client_init, server_init=server_init, catch_error=True
+ ) as (tc, ts, err):
+ # If neither side blew up then that's bad!
+ assert err is not None
+ # If client side blew up first, it'll be straightforward
+ if isinstance(err, IncompatiblePeer):
+ pass
+ # If server side blew up first, client sees EOF & we need to check
+ # the server transport for its saved error (otherwise it can only
+ # appear in log output)
+ elif isinstance(err, EOFError):
+ assert ts.saved_exception is not None
+ assert isinstance(ts.saved_exception, IncompatiblePeer)
+ # If it was something else, welp
+ else:
+ raise err
+
+ def test_client_sha2_disabled_server_sha1_disabled_no_match(self):
+ self._incompatible_peers(
+ client_init=_disable_sha2, server_init=_disable_sha1
+ )
+
+ def test_client_sha1_disabled_server_sha2_disabled_no_match(self):
+ self._incompatible_peers(
+ client_init=_disable_sha1, server_init=_disable_sha2
+ )
+
+ def test_explicit_client_hostkey_not_limited(self):
+ # Be very explicit about the hostkey on BOTH ends,
+ # and ensure it still ends up choosing sha2-512.
+ # (This is a regression test vs previous implementation which overwrote
+ # the entire preferred-hostkeys structure when given an explicit key as
+ # a client.)
+ hostkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(hostkey=hostkey, connect=dict(hostkey=hostkey)) as (tc, _):
+ assert tc.host_key_type == "rsa-sha2-512"
+
+
+class TestExtInfo(unittest.TestCase):
+ def test_ext_info_handshake(self):
+ with server() as (tc, _):
+ kex = tc._get_latest_kex_init()
+ assert kex["kex_algo_list"][-1] == "ext-info-c"
+ assert tc.server_extensions == {
+ "server-sig-algs": b"ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256,ssh-rsa,ssh-dss" # noqa
+ }
+
+ def test_client_uses_server_sig_algs_for_pubkey_auth(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(
+ pubkeys=[privkey],
+ connect=dict(pkey=privkey),
+ server_init=dict(
+ disabled_algorithms=dict(pubkeys=["rsa-sha2-512"])
+ ),
+ ) as (tc, _):
+ assert tc.is_authenticated()
+ # Client settled on 256 despite itself not having 512 disabled
+ assert tc._agreed_pubkey_algorithm == "rsa-sha2-256"
+
+
+# TODO: these could move into test_auth.py but that badly needs refactoring
+# with this module anyways...
+class TestSHA2SignaturePubkeys(unittest.TestCase):
+ def test_pubkey_auth_honors_disabled_algorithms(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(
+ pubkeys=[privkey],
+ connect=dict(pkey=privkey),
+ init=dict(
+ disabled_algorithms=dict(
+ pubkeys=["ssh-rsa", "rsa-sha2-256", "rsa-sha2-512"]
+ )
+ ),
+ catch_error=True,
+ ) as (_, _, err):
+ assert isinstance(err, SSHException)
+ assert "no RSA pubkey algorithms" in str(err)
+
+ def test_client_sha2_disabled_server_sha1_disabled_no_match(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(
+ pubkeys=[privkey],
+ connect=dict(pkey=privkey),
+ client_init=_disable_sha2_pubkey,
+ server_init=_disable_sha1_pubkey,
+ catch_error=True,
+ ) as (tc, ts, err):
+ assert isinstance(err, AuthenticationException)
+
+ def test_client_sha1_disabled_server_sha2_disabled_no_match(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(
+ pubkeys=[privkey],
+ connect=dict(pkey=privkey),
+ client_init=_disable_sha1_pubkey,
+ server_init=_disable_sha2_pubkey,
+ catch_error=True,
+ ) as (tc, ts, err):
+ assert isinstance(err, AuthenticationException)
+
+ def test_ssh_rsa_still_used_when_sha2_disabled(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ # NOTE: this works because key obj comparison uses public bytes
+ # TODO: would be nice for PKey to grow a legit "give me another obj of
+ # same class but just the public bits" using asbytes()
+ with server(
+ pubkeys=[privkey], connect=dict(pkey=privkey), init=_disable_sha2
+ ) as (tc, _):
+ assert tc.is_authenticated()
+
+ def test_sha2_512(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(
+ pubkeys=[privkey],
+ connect=dict(pkey=privkey),
+ init=dict(
+ disabled_algorithms=dict(pubkeys=["ssh-rsa", "rsa-sha2-256"])
+ ),
+ ) as (tc, ts):
+ assert tc.is_authenticated()
+ assert tc._agreed_pubkey_algorithm == "rsa-sha2-512"
+
+ def test_sha2_256(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(
+ pubkeys=[privkey],
+ connect=dict(pkey=privkey),
+ init=dict(
+ disabled_algorithms=dict(pubkeys=["ssh-rsa", "rsa-sha2-512"])
+ ),
+ ) as (tc, ts):
+ assert tc.is_authenticated()
+ assert tc._agreed_pubkey_algorithm == "rsa-sha2-256"
+
+ def test_sha2_256_when_client_only_enables_256(self):
+ privkey = RSAKey.from_private_key_file(_support("test_rsa.key"))
+ with server(
+ pubkeys=[privkey],
+ connect=dict(pkey=privkey),
+ # Client-side only; server still accepts all 3.
+ client_init=dict(
+ disabled_algorithms=dict(pubkeys=["ssh-rsa", "rsa-sha2-512"])
+ ),
+ ) as (tc, ts):
+ assert tc.is_authenticated()
+ assert tc._agreed_pubkey_algorithm == "rsa-sha2-256"