diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6fb31ccf9..b27ad002c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,7 @@ Backward-incompatible changes: - Drop support for Python 2.7. `#1047 `_ +- The minimum ``cryptography`` version is now 35.0. Deprecations: ^^^^^^^^^^^^^ @@ -19,6 +20,10 @@ Deprecations: Changes: ^^^^^^^^ +- Expose wrappers for some `DTLS + `_ + primitives. `#1026 `_ + 21.0.0 (2021-09-28) ------------------- diff --git a/setup.py b/setup.py index 85c45692d..ecc23cca3 100755 --- a/setup.py +++ b/setup.py @@ -93,7 +93,7 @@ def find_meta(meta): 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 59f21cecc..4e5a632ce 100644 --- a/src/OpenSSL/SSL.py +++ b/src/OpenSSL/SSL.py @@ -45,6 +45,9 @@ "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 @@ "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 @@ class _buffer(object): 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 @@ class _buffer(object): 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 @@ def wrapper(ssl, cdata): 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 @@ def __init__(self, method): 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) @@ -1521,6 +1581,20 @@ def set_ocsp_client_callback(self, callback, data=None): 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() @@ -1558,6 +1632,10 @@ def __init__(self, context, socket=None): 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: @@ -1666,6 +1744,35 @@ def get_servername(self): 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. @@ -1951,6 +2058,32 @@ def accept(self): 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 ffc505d8f..3ee635940 100644 --- a/tests/test_ssl.py +++ b/tests/test_ssl.py @@ -20,7 +20,14 @@ 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 @@ 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 @@ def test_method(self): with pytest.raises(TypeError): Context("") with pytest.raises(ValueError): - Context(10) + Context(13) def test_type(self): """ @@ -4212,3 +4220,188 @@ def client_callback(*args): # pragma: nocover 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 110b7378c..b1011e568 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