Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Workaround OpenSSL 1.1.1 session-ticket issue #1171

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions newsfragments/819.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
OpenSSL has a bug in its handling of TLS 1.3 session tickets that can
cause deadlocks or data loss in some rare edge cases. These edge cases
most frequently happen during tests. (OpenSSL bugs: `#7948
<https://github.com/openssl/openssl/issues/7948>`__, `#7967
<https://github.com/openssl/openssl/issues/7967>`__.) `trio.SSLStream`
now works around this issue, so you don't have to worry about it.
36 changes: 34 additions & 2 deletions trio/_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ def __init__(
)
self._https_compatible = https_compatible
self._outgoing = _stdlib_ssl.MemoryBIO()
self._delayed_outgoing = None
self._incoming = _stdlib_ssl.MemoryBIO()
self._ssl_object = ssl_context.wrap_bio(
self._incoming,
Expand Down Expand Up @@ -428,7 +429,9 @@ def _check_status(self):
# comments, though, just make sure to think carefully if you ever have to
# touch it. The big comment at the top of this file will help explain
# too.
async def _retry(self, fn, *args, ignore_want_read=False):
async def _retry(
self, fn, *args, ignore_want_read=False, is_handshake=False
):
await trio.hazmat.checkpoint_if_cancelled()
yielded = False
finished = False
Expand Down Expand Up @@ -473,6 +476,32 @@ async def _retry(self, fn, *args, ignore_want_read=False):
finished = True
to_send = self._outgoing.read()

# Some versions of SSL_do_handshake have a bug in how they handle
# the TLS 1.3 handshake on the server side: after the handshake
# finishes, they automatically send session tickets, even though
# the client may not be expecting data to arrive at this point and
# sending it could cause a deadlock or lost data. This applies at
# least to OpenSSL 1.1.1c and earlier, and the OpenSSL devs
# currently have no plans to fix it:
#
# https://github.com/openssl/openssl/issues/7948
# https://github.com/openssl/openssl/issues/7967
#
# The correct behavior is to wait to send session tickets on the
# first call to SSL_write. (This is what BoringSSL does.) So, we
# use a heuristic to detect when OpenSSL has tried to send session
# tickets, and we manually delay sending them until the
# appropriate moment. For more discussion see:
#
# https://github.com/python-trio/trio/issues/819#issuecomment-517529763
if (
is_handshake and not want_read and self._ssl_object.server_side
and self._ssl_object.version() == "TLSv1.3"
):
assert self._delayed_outgoing is None
self._delayed_outgoing = to_send
to_send = b""

# Outputs from the above code block are:
#
# - to_send: bytestring; if non-empty then we need to send
Expand Down Expand Up @@ -535,6 +564,9 @@ async def _retry(self, fn, *args, ignore_want_read=False):
async with self._inner_send_lock:
yielded = True
try:
if self._delayed_outgoing is not None:
to_send = self._delayed_outgoing + to_send
self._delayed_outgoing = None
await self.transport_stream.send_all(to_send)
except:
# Some unknown amount of our data got sent, and we
Expand Down Expand Up @@ -571,7 +603,7 @@ async def _retry(self, fn, *args, ignore_want_read=False):

async def _do_handshake(self):
try:
await self._retry(self._ssl_object.do_handshake)
await self._retry(self._ssl_object.do_handshake, is_handshake=True)
except:
self._state = _State.BROKEN
raise
Expand Down
13 changes: 8 additions & 5 deletions trio/tests/test_highlevel_ssl_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import trio
from trio.socket import AF_INET, SOCK_STREAM, IPPROTO_TCP
import trio.testing
from .test_ssl import CLIENT_CTX, SERVER_CTX
from .test_ssl import client_ctx, SERVER_CTX

from .._highlevel_ssl_helpers import (
open_ssl_over_tcp_stream, open_ssl_over_tcp_listeners, serve_ssl_over_tcp
Expand Down Expand Up @@ -40,7 +40,10 @@ async def getnameinfo(self, *args): # pragma: no cover


# This uses serve_ssl_over_tcp, which uses open_ssl_over_tcp_listeners...
async def test_open_ssl_over_tcp_stream_and_everything_else():
# noqa is needed because flake8 doesn't understand how pytest fixtures work.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can fix the flake8 issue by removing the unused client_ctx import

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not unused, it's making the fixture function visible to pytest...

Copy link
Member

@pquentin pquentin Aug 2, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about that, I'm used to projects where pytest discovers the fixtures through conftest.py

async def test_open_ssl_over_tcp_stream_and_everything_else(
client_ctx, # noqa: F811
):
async with trio.open_nursery() as nursery:
(listener,) = await nursery.start(
partial(
Expand All @@ -66,7 +69,7 @@ async def test_open_ssl_over_tcp_stream_and_everything_else():
stream = await open_ssl_over_tcp_stream(
"xyzzy.example.org",
80,
ssl_context=CLIENT_CTX,
ssl_context=client_ctx,
)
with pytest.raises(trio.BrokenResourceError):
await stream.do_handshake()
Expand All @@ -75,7 +78,7 @@ async def test_open_ssl_over_tcp_stream_and_everything_else():
stream = await open_ssl_over_tcp_stream(
"trio-test-1.example.org",
80,
ssl_context=CLIENT_CTX,
ssl_context=client_ctx,
)
assert isinstance(stream, trio.SSLStream)
assert stream.server_hostname == "trio-test-1.example.org"
Expand All @@ -88,7 +91,7 @@ async def test_open_ssl_over_tcp_stream_and_everything_else():
stream = await open_ssl_over_tcp_stream(
"trio-test-1.example.org",
80,
ssl_context=CLIENT_CTX,
ssl_context=client_ctx,
https_compatible=True,
# also, smoke test happy_eyeballs_delay
happy_eyeballs_delay=1,
Expand Down
Loading