summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNathaniel J. Smith <njs@pobox.com>2021-11-01 23:30:36 -0700
committerGitHub <noreply@github.com>2021-11-02 14:30:36 +0800
commite84e7b57d1838de70ab7a27089fbee78ce0d2106 (patch)
tree9b5fa35e68396ac8c1ba7fbe208062a725323b77
parentea90b55c84ec25fcd725fe42573e93be5497158e (diff)
downloadpyopenssl-git-e84e7b57d1838de70ab7a27089fbee78ce0d2106.tar.gz
Expose some DTLS-related features (#1026)
* Expose DTLS_METHOD and friends * Expose OP_NO_RENEGOTIATION * Expose DTLS MTU-related functions * Expose DTLSv1_listen and associated callbacks * Add a basic DTLS test * Cope with old versions of openssl/libressl * blacken * Soothe flake8 * Add temporary hack to skip DTLS test on old cryptography versions * Update for cryptography v35 release * Add changelog entry * Fix versionadded:: * get_cleartext_mtu doesn't exist on decrepit old openssl * Rewrite DTLS test to work around stupid OpenSSL misbehavior * flake8 go away * minor tidying
-rw-r--r--CHANGELOG.rst5
-rwxr-xr-xsetup.py2
-rw-r--r--src/OpenSSL/SSL.py135
-rw-r--r--tests/test_ssl.py197
-rw-r--r--tox.ini2
5 files changed, 336 insertions, 5 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 6fb31cc..b27ad00 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,7 @@ Backward-incompatible changes:
- Drop support for Python 2.7.
`#1047 <https://github.com/pyca/pyopenssl/pull/1047>`_
+- The minimum ``cryptography`` version is now 35.0.
Deprecations:
^^^^^^^^^^^^^
@@ -19,6 +20,10 @@ Deprecations:
Changes:
^^^^^^^^
+- Expose wrappers for some `DTLS
+ <https://en.wikipedia.org/wiki/Datagram_Transport_Layer_Security>`_
+ primitives. `#1026 <https://github.com/pyca/pyopenssl/pull/1026>`_
+
21.0.0 (2021-09-28)
-------------------
diff --git a/setup.py b/setup.py
index 85c4569..ecc23cc 100755
--- a/setup.py
+++ b/setup.py
@@ -93,7 +93,7 @@ if __name__ == "__main__":
package_dir={"": "src"},
install_requires=[
# Fix cryptographyMinimum in tox.ini when changing this!
- "cryptography>=3.3",
+ "cryptography>=35.0",
],
extras_require={
"test": ["flaky", "pretend", "pytest>=3.0.1"],
diff --git a/src/OpenSSL/SSL.py b/src/OpenSSL/SSL.py
index 8ed91a2..7fd9ea4 100644
--- a/src/OpenSSL/SSL.py
+++ b/src/OpenSSL/SSL.py
@@ -45,6 +45,9 @@ __all__ = [
"TLS_METHOD",
"TLS_SERVER_METHOD",
"TLS_CLIENT_METHOD",
+ "DTLS_METHOD",
+ "DTLS_SERVER_METHOD",
+ "DTLS_CLIENT_METHOD",
"SSL3_VERSION",
"TLS1_VERSION",
"TLS1_1_VERSION",
@@ -80,6 +83,7 @@ __all__ = [
"OP_NO_QUERY_MTU",
"OP_COOKIE_EXCHANGE",
"OP_NO_TICKET",
+ "OP_NO_RENEGOTIATION",
"OP_ALL",
"VERIFY_PEER",
"VERIFY_FAIL_IF_NO_PEER_CERT",
@@ -149,6 +153,9 @@ TLSv1_2_METHOD = 6
TLS_METHOD = 7
TLS_SERVER_METHOD = 8
TLS_CLIENT_METHOD = 9
+DTLS_METHOD = 10
+DTLS_SERVER_METHOD = 11
+DTLS_CLIENT_METHOD = 12
try:
SSL3_VERSION = _lib.SSL3_VERSION
@@ -206,6 +213,11 @@ OP_NO_QUERY_MTU = _lib.SSL_OP_NO_QUERY_MTU
OP_COOKIE_EXCHANGE = _lib.SSL_OP_COOKIE_EXCHANGE
OP_NO_TICKET = _lib.SSL_OP_NO_TICKET
+try:
+ OP_NO_RENEGOTIATION = _lib.SSL_OP_NO_RENEGOTIATION
+except AttributeError:
+ pass
+
OP_ALL = _lib.SSL_OP_ALL
VERIFY_PEER = _lib.SSL_VERIFY_PEER
@@ -547,6 +559,48 @@ class _OCSPClientCallbackHelper(_CallbackExceptionHelper):
self.callback = _ffi.callback("int (*)(SSL *, void *)", wrapper)
+class _CookieGenerateCallbackHelper(_CallbackExceptionHelper):
+ def __init__(self, callback):
+ _CallbackExceptionHelper.__init__(self)
+
+ @wraps(callback)
+ def wrapper(ssl, out, outlen):
+ try:
+ conn = Connection._reverse_mapping[ssl]
+ cookie = callback(conn)
+ out[0 : len(cookie)] = cookie
+ outlen[0] = len(cookie)
+ return 1
+ except Exception as e:
+ self._problems.append(e)
+ # "a zero return value can be used to abort the handshake"
+ return 0
+
+ self.callback = _ffi.callback(
+ "int (*)(SSL *, unsigned char *, unsigned int *)",
+ wrapper,
+ )
+
+
+class _CookieVerifyCallbackHelper(_CallbackExceptionHelper):
+ def __init__(self, callback):
+ _CallbackExceptionHelper.__init__(self)
+
+ @wraps(callback)
+ def wrapper(ssl, c_cookie, cookie_len):
+ try:
+ conn = Connection._reverse_mapping[ssl]
+ return callback(conn, bytes(c_cookie[0:cookie_len]))
+ except Exception as e:
+ self._problems.append(e)
+ return 0
+
+ self.callback = _ffi.callback(
+ "int (*)(SSL *, unsigned char *, unsigned int)",
+ wrapper,
+ )
+
+
def _asFileDescriptor(obj):
fd = None
if not isinstance(obj, int):
@@ -628,7 +682,8 @@ class Context(object):
:class:`OpenSSL.SSL.Context` instances define the parameters for setting
up new SSL connections.
- :param method: One of TLS_METHOD, TLS_CLIENT_METHOD, or TLS_SERVER_METHOD.
+ :param method: One of TLS_METHOD, TLS_CLIENT_METHOD, TLS_SERVER_METHOD,
+ DTLS_METHOD, DTLS_CLIENT_METHOD, or DTLS_SERVER_METHOD.
SSLv23_METHOD, TLSv1_METHOD, etc. are deprecated and should
not be used.
"""
@@ -643,6 +698,9 @@ class Context(object):
TLS_METHOD: "TLS_method",
TLS_SERVER_METHOD: "TLS_server_method",
TLS_CLIENT_METHOD: "TLS_client_method",
+ DTLS_METHOD: "DTLS_method",
+ DTLS_SERVER_METHOD: "DTLS_server_method",
+ DTLS_CLIENT_METHOD: "DTLS_client_method",
}
_methods = dict(
(identifier, getattr(_lib, name))
@@ -687,6 +745,8 @@ class Context(object):
self._ocsp_helper = None
self._ocsp_callback = None
self._ocsp_data = None
+ self._cookie_generate_helper = None
+ self._cookie_verify_helper = None
self.set_mode(_lib.SSL_MODE_ENABLE_PARTIAL_WRITE)
@@ -1527,6 +1587,20 @@ class Context(object):
helper = _OCSPClientCallbackHelper(callback)
self._set_ocsp_callback(helper, data)
+ def set_cookie_generate_callback(self, callback):
+ self._cookie_generate_helper = _CookieGenerateCallbackHelper(callback)
+ _lib.SSL_CTX_set_cookie_generate_cb(
+ self._context,
+ self._cookie_generate_helper.callback,
+ )
+
+ def set_cookie_verify_callback(self, callback):
+ self._cookie_verify_helper = _CookieVerifyCallbackHelper(callback)
+ _lib.SSL_CTX_set_cookie_verify_cb(
+ self._context,
+ self._cookie_verify_helper.callback,
+ )
+
class Connection(object):
_reverse_mapping = WeakValueDictionary()
@@ -1564,6 +1638,10 @@ class Connection(object):
self._verify_helper = context._verify_helper
self._verify_callback = context._verify_callback
+ # And likewise for the cookie callbacks
+ self._cookie_generate_helper = context._cookie_generate_helper
+ self._cookie_verify_helper = context._cookie_verify_helper
+
self._reverse_mapping[self._ssl] = self
if socket is None:
@@ -1672,6 +1750,35 @@ class Connection(object):
return _ffi.string(name)
+ def set_ciphertext_mtu(self, mtu):
+ """
+ For DTLS, set the maximum UDP payload size (*not* including IP/UDP
+ overhead).
+
+ Note that you might have to set :data:`OP_NO_QUERY_MTU` to prevent
+ OpenSSL from spontaneously clearing this.
+
+ :param mtu: An integer giving the maximum transmission unit.
+
+ .. versionadded:: 21.1
+ """
+ _lib.SSL_set_mtu(self._ssl, mtu)
+
+ def get_cleartext_mtu(self):
+ """
+ For DTLS, get the maximum size of unencrypted data you can pass to
+ :meth:`write` without exceeding the MTU (as passed to
+ :meth:`set_ciphertext_mtu`).
+
+ :return: The effective MTU as an integer.
+
+ .. versionadded:: 21.1
+ """
+
+ if not hasattr(_lib, "DTLS_get_data_mtu"):
+ raise NotImplementedError("requires OpenSSL 1.1.1 or better")
+ return _lib.DTLS_get_data_mtu(self._ssl)
+
def set_tlsext_host_name(self, name):
"""
Set the value of the servername extension to send in the client hello.
@@ -1957,6 +2064,32 @@ class Connection(object):
conn.set_accept_state()
return (conn, addr)
+ def DTLSv1_listen(self):
+ """
+ Call the OpenSSL function DTLSv1_listen on this connection. See the
+ OpenSSL manual for more details.
+
+ :return: None
+ """
+ # Possible future extension: return the BIO_ADDR in some form.
+ bio_addr = _lib.BIO_ADDR_new()
+ try:
+ result = _lib.DTLSv1_listen(self._ssl, bio_addr)
+ finally:
+ _lib.BIO_ADDR_free(bio_addr)
+ # DTLSv1_listen is weird. A zero return value means 'didn't find a
+ # ClientHello with valid cookie, but keep trying'. So basically
+ # WantReadError. But it doesn't work correctly with _raise_ssl_error.
+ # So we raise it manually instead.
+ if self._cookie_generate_helper is not None:
+ self._cookie_generate_helper.raise_if_problem()
+ if self._cookie_verify_helper is not None:
+ self._cookie_verify_helper.raise_if_problem()
+ if result == 0:
+ raise WantReadError()
+ if result < 0:
+ self._raise_ssl_error(self._ssl, result)
+
def bio_shutdown(self):
"""
If the Connection was created with a memory BIO, this method can be
diff --git a/tests/test_ssl.py b/tests/test_ssl.py
index ca363b4..05aeeee 100644
--- a/tests/test_ssl.py
+++ b/tests/test_ssl.py
@@ -20,7 +20,14 @@ from errno import (
ESHUTDOWN,
)
from sys import platform, getfilesystemencoding
-from socket import AF_INET, AF_INET6, MSG_PEEK, SHUT_RDWR, error, socket
+from socket import (
+ AF_INET,
+ AF_INET6,
+ MSG_PEEK,
+ SHUT_RDWR,
+ error,
+ socket,
+)
from os import makedirs
from os.path import join
from weakref import ref
@@ -54,6 +61,7 @@ from OpenSSL.SSL import (
TLS1_3_VERSION,
TLS1_2_VERSION,
TLS1_1_VERSION,
+ DTLS_METHOD,
)
from OpenSSL.SSL import SSLEAY_PLATFORM, SSLEAY_DIR, SSLEAY_BUILT_ON
from OpenSSL.SSL import SENT_SHUTDOWN, RECEIVED_SHUTDOWN
@@ -604,7 +612,7 @@ class TestContext(object):
with pytest.raises(TypeError):
Context("")
with pytest.raises(ValueError):
- Context(10)
+ Context(13)
def test_type(self):
"""
@@ -4212,3 +4220,188 @@ class TestOCSP(object):
with pytest.raises(TypeError):
handshake_in_memory(client, server)
+
+
+class TestDTLS(object):
+ # The way you would expect DTLSv1_listen to work is:
+ #
+ # - it reads packets in a loop
+ # - when it finds a valid ClientHello, it returns
+ # - now the handshake can proceed
+ #
+ # However, on older versions of OpenSSL, it did something "cleverer". The
+ # way it worked is:
+ #
+ # - it "peeks" into the BIO to see the next packet without consuming it
+ # - if *not* a valid ClientHello, then it reads the packet to consume it
+ # and loops around
+ # - if it *is* a valid ClientHello, it *leaves the packet in the BIO*, and
+ # returns
+ # - then the handshake finds the ClientHello in the BIO and reads it a
+ # second time.
+ #
+ # I'm not sure exactly when this switched over. The OpenSSL v1.1.1 in
+ # Ubuntu 18.04 has the old behavior. The OpenSSL v1.1.1 in Ubuntu 20.04 has
+ # the new behavior. There doesn't seem to be any mention of this change in
+ # the OpenSSL v1.1.1 changelog, but presumably it changed in some point
+ # release or another. Presumably in 2025 or so there will be only new
+ # OpenSSLs around we can delete this whole comment and the weird
+ # workaround. If anyone is still using this library by then, which seems
+ # both depressing and inevitable.
+ #
+ # Anyway, why do we care? The reason is that the old strategy has a
+ # problem: the "peek" operation is only defined on "DGRAM BIOs", which are
+ # a special type of object that is different from the more familiar "socket
+ # BIOs" and "memory BIOs". If you *don't* have a DGRAM BIO, and you try to
+ # peek into the BIO... then it silently degrades to a full-fledged "read"
+ # operation that consumes the packet. Which is a problem if your algorithm
+ # depends on leaving the packet in the BIO to be read again later.
+ #
+ # So on old OpenSSL, we have a problem:
+ #
+ # - we can't use a DGRAM BIO, because cryptography/pyopenssl don't wrap the
+ # relevant APIs, nor should they.
+ #
+ # - if we use a socket BIO, then the first time DTLSv1_listen sees an
+ # invalid packet (like for example... the challenge packet that *every
+ # DTLS handshake starts with before the real ClientHello!*), it tries to
+ # first "peek" it, and then "read" it. But since the first "peek"
+ # consumes the packet, the second "read" ends up hanging or consuming
+ # some unrelated packet, which is undesirable. So you can't even get to
+ # the handshake stage successfully.
+ #
+ # - if we use a memory BIO, then DTLSv1_listen works OK on invalid packets
+ # -- first the "peek" consumes them, and then it tries to "read" again to
+ # consume them, which fails immediately, and OpenSSL ignores the failure.
+ # So it works by accident. BUT, when we get a valid ClientHello, we have
+ # a problem: DTLSv1_listen tries to "peek" it and then leave it in the
+ # read BIO for do_handshake to consume. But instead "peek" consumes the
+ # packet, so it's not there where do_handshake is expecting it, and the
+ # handshake fails.
+ #
+ # Fortunately (if that's the word), we can work around the memory BIO
+ # problem. (Which is good, because in real life probably all our users will
+ # be using memory BIOs.) All we have to do is to save the valid ClientHello
+ # before calling DTLSv1_listen, and then after it returns we push *a second
+ # copy of it* of the packet memory BIO before calling do_handshake. This
+ # fakes out OpenSSL and makes it think the "peek" operation worked
+ # correctly, and we can go on with our lives.
+ #
+ # In fact, we push the second copy of the ClientHello unconditionally. On
+ # new versions of OpenSSL, this is unnecessary, but harmless, because the
+ # DTLS state machine treats it like a network hiccup that duplicated a
+ # packet, which DTLS is robust against.
+ def test_it_works_at_all(self):
+ # arbitrary number larger than any conceivable handshake volley
+ LARGE_BUFFER = 65536
+
+ s_ctx = Context(DTLS_METHOD)
+
+ def generate_cookie(ssl):
+ return b"xyzzy"
+
+ def verify_cookie(ssl, cookie):
+ return cookie == b"xyzzy"
+
+ s_ctx.set_cookie_generate_callback(generate_cookie)
+ s_ctx.set_cookie_verify_callback(verify_cookie)
+ s_ctx.use_privatekey(load_privatekey(FILETYPE_PEM, server_key_pem))
+ s_ctx.use_certificate(load_certificate(FILETYPE_PEM, server_cert_pem))
+ s_ctx.set_options(OP_NO_QUERY_MTU)
+ s = Connection(s_ctx)
+ s.set_accept_state()
+
+ c_ctx = Context(DTLS_METHOD)
+ c_ctx.set_options(OP_NO_QUERY_MTU)
+ c = Connection(c_ctx)
+ c.set_connect_state()
+
+ # These are mandatory, because openssl can't guess the MTU for a memory
+ # bio and will produce a mysterious error if you make it try.
+ c.set_ciphertext_mtu(1500)
+ s.set_ciphertext_mtu(1500)
+
+ latest_client_hello = None
+
+ def pump_membio(label, source, sink):
+ try:
+ chunk = source.bio_read(LARGE_BUFFER)
+ except WantReadError:
+ return False
+ # I'm not sure this check is needed, but I'm not sure it's *not*
+ # needed either:
+ if not chunk: # pragma: no cover
+ return False
+ # Gross hack: if this is a ClientHello, save it so we can find it
+ # later. See giant comment above.
+ try:
+ # if ContentType == handshake and HandshakeType ==
+ # client_hello:
+ if chunk[0] == 22 and chunk[13] == 1:
+ nonlocal latest_client_hello
+ latest_client_hello = chunk
+ except IndexError: # pragma: no cover
+ pass
+ print(f"{label}: {chunk.hex()}")
+ sink.bio_write(chunk)
+ return True
+
+ def pump():
+ # Raises if there was no data to pump, to avoid infinite loops if
+ # we aren't making progress.
+ assert pump_membio("s -> c", s, c) or pump_membio("c -> s", c, s)
+
+ c_handshaking = True
+ s_listening = True
+ s_handshaking = False
+ first = True
+ while c_handshaking or s_listening or s_handshaking:
+ if not first:
+ pump()
+ first = False
+
+ if c_handshaking:
+ try:
+ c.do_handshake()
+ except WantReadError:
+ pass
+ else:
+ c_handshaking = False
+
+ if s_listening:
+ try:
+ s.DTLSv1_listen()
+ except WantReadError:
+ pass
+ else:
+ s_listening = False
+ s_handshaking = True
+ # Write the duplicate ClientHello. See giant comment above.
+ s.bio_write(latest_client_hello)
+
+ if s_handshaking:
+ try:
+ s.do_handshake()
+ except WantReadError:
+ pass
+ else:
+ s_handshaking = False
+
+ s.write(b"hello")
+ pump()
+ assert c.read(100) == b"hello"
+ c.write(b"goodbye")
+ pump()
+ assert s.read(100) == b"goodbye"
+
+ # Check that the MTU set/query functions are doing *something*
+ c.set_ciphertext_mtu(1000)
+ try:
+ assert 500 < c.get_cleartext_mtu() < 1000
+ except NotImplementedError: # OpenSSL 1.1.0 and earlier
+ pass
+ c.set_ciphertext_mtu(500)
+ try:
+ assert 0 < c.get_cleartext_mtu() < 500
+ except NotImplementedError: # OpenSSL 1.1.0 and earlier
+ pass
diff --git a/tox.ini b/tox.ini
index 110b737..b1011e5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,7 +10,7 @@ extras =
deps =
coverage>=4.2
cryptographyMain: git+https://github.com/pyca/cryptography.git
- cryptographyMinimum: cryptography==3.3
+ cryptographyMinimum: cryptography==35.0
randomorder: pytest-randomly
setenv =
# Do not allow the executing environment to pollute the test environment