Skip to content

Commit fd8eebb

Browse files
committed
Bugfix: Send the TLS alert to the peer upon a ssl.CertificateError.
This commit (partially) addresses python-trio/trio-websocket#199, which I traced to a bug in Trio itself. A corresponding bugfix relying on the present bugfix will be submitted to the Trio Websocket repo. From the user's perspective, the issue was that a TLS client hanged after submitting an invalid (e.g. expired) client certificate. Before this fix, when `SSLStream._retry` caught a `ssl.CertificateError`, the error was immediately re-raised (wrapped in a `trio.BrokenResourceError`). The TLS alert prepared by the `ssl` library and waiting in MemoryBIO was therefore never sent to the peer. Now, upon catching a `ssl.CertificateError`, we first check whether we have any pending outgoing data (`self._outgoing.pending`). If so, the exception is stashed away and only raised (again, wrapped in a `trio.BrokenResourceError`) after sending out the pending data (which should contain the alert). I had first tried an alternative implementation, where the `CertificateError` was not stashed away. Rather, the error was raised immediately, but only if there was no pending outgoing data. The idea was to rely on the loop in `SSLStream._retry`. Upon seeing `CertificateError` for the first time, there would be pending data, so the exception would not be reraised and the pending data would be sent out, while upon seeing the `CertificateError` for the second time, there would be no pending data, so the exception would be re-raised. However, it turned out that the loop did not always continue (I'm not sure why), so there was no second time in some situations. TESTS in `test_ssl.py` `test_ssl_client_basics` (modified) Here we test whether the TLS alert sent out by the client reaches the (blocking) server. The second (no CA file) and the third (wrong host name) subtest of this test were modified to check that the server encounters the correct SSL error. In the old code, the server encountered `UNEXPECTED_EOF_WHILE_READING` (protocol error) in both subtests. After the fix, it correctly receives `TLSV1_ALERT_UNKNOWN_CA` and `SSLV3_ALERT_BAD_CERTIFICATE`, respectively. To facilitate the modified test, function `ssl_echo_serve_sync` (called by `ssl_echo_server_raw` called by this test) now allows for a special value of keyword argument `expect_fail`: `"raise"`. When given this value, the error is expected but raised nevertheless, the idea being that it should be caught and inspected in the test code. `test_client_certificate` (new) Here we test whether the TLS alert sent out by the server reaches the (blocking) client. The test is modeled on `test_ssl_server_basics`. The server is configured to require client authentication (`SERVER_CTX_REQ`, defined at the top of the file). In the first subtest, the client submits a valid certificate; in the second subtest, an expired one. There is a complication with the second subtest. If the client does not send out the TLS alert (like before the bugfix), the server hangs, but we don't want the test to hang. I could think of no other way to test whether the server hangs than imposing an arbitrary (small) timeout, and there is a (very) small chance that even the correct code will not finish within the allotted time, resulting in a false negative.
1 parent 82af3ab commit fd8eebb

File tree

3 files changed

+159
-15
lines changed

3 files changed

+159
-15
lines changed

newsfragments/NNNN.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Upon a ``SSL.CertificateError``, a TLS alert is now sent to the peer before
2+
raising ``trio.BrokenResourceError`` from the original error, preventing the
3+
client from hanging.

src/trio/_ssl.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ def __init__(
394394

395395
self._estimated_receive_size = STARTING_RECEIVE_SIZE
396396

397+
self._ssl_error = None
398+
397399
_forwarded: ClassVar = {
398400
"context",
399401
"server_side",
@@ -499,7 +501,19 @@ async def _retry(
499501
ret = fn(*args)
500502
except _stdlib_ssl.SSLWantReadError:
501503
want_read = True
502-
except (_stdlib_ssl.SSLError, _stdlib_ssl.CertificateError) as exc:
504+
except _stdlib_ssl.CertificateError as exc:
505+
if self._outgoing.pending:
506+
# We have pending data in MemoryBIO. The assumption is
507+
# that this is the TLS alert corresponding to the
508+
# exception. As the alert needs to be sent to the peer, we
509+
# stash the exception for now. We'll raise it after the
510+
# alert is sent below, which will surely happen as sending
511+
# has priority over receiving.
512+
self._ssl_error = exc
513+
else:
514+
self._state = _State.BROKEN
515+
raise trio.BrokenResourceError from exc
516+
except _stdlib_ssl.SSLError as exc:
503517
self._state = _State.BROKEN
504518
raise trio.BrokenResourceError from exc
505519
else:
@@ -633,6 +647,13 @@ async def _retry(
633647
)
634648
self._incoming.write(data)
635649
self._inner_recv_count += 1
650+
if self._ssl_error:
651+
# Raise the stashed SSLError exception.
652+
self._state = _State.BROKEN
653+
# Not sure if this is necessary, but let's clean up
654+
# self._ssl_error just in case.
655+
self._ssl_error, ssl_error = None, self._ssl_error
656+
raise trio.BrokenResourceError from ssl_error
636657
if not yielded:
637658
await trio.lowlevel.cancel_shielded_checkpoint()
638659
return ret

src/trio/_tests/test_ssl.py

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import datetime
34
import os
45
import socket as stdlib_socket
56
import ssl
@@ -77,6 +78,13 @@
7778

7879
TRIO_TEST_1_CERT.configure_cert(SERVER_CTX)
7980

81+
SERVER_CTX_REQ = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
82+
if hasattr(ssl, "OP_IGNORE_UNEXPECTED_EOF"):
83+
SERVER_CTX_REQ.options &= ~ssl.OP_IGNORE_UNEXPECTED_EOF
84+
TRIO_TEST_1_CERT.configure_cert(SERVER_CTX_REQ)
85+
SERVER_CTX_REQ.verify_mode = ssl.CERT_REQUIRED
86+
TRIO_TEST_CA.configure_trust(SERVER_CTX_REQ)
87+
8088

8189
# TLS 1.3 has a lot of changes from previous versions. So we want to run tests
8290
# with both TLS 1.3, and TLS 1.2.
@@ -105,6 +113,9 @@ def client_ctx(request: pytest.FixtureRequest) -> ssl.SSLContext:
105113
def ssl_echo_serve_sync(
106114
sock: stdlib_socket.socket,
107115
*,
116+
# expect_fail = "raise" expects to fail but raises nevertheless, i.e. it is
117+
# like False but does not print; used where the raised exception is checked
118+
# in the main thread.
108119
expect_fail: bool = False,
109120
) -> None:
110121
try:
@@ -142,7 +153,9 @@ def ssl_echo_serve_sync(
142153
except (ConnectionResetError, ConnectionAbortedError): # pragma: no cover
143154
return
144155
except Exception as exc:
145-
if expect_fail:
156+
if expect_fail == "raise":
157+
raise
158+
elif expect_fail:
146159
print("ssl_echo_serve_sync got error as expected:", exc)
147160
else: # pragma: no cover
148161
print("ssl_echo_serve_sync got unexpected error:", exc)
@@ -453,21 +466,33 @@ async def test_ssl_client_basics(client_ctx: SSLContext) -> None:
453466
await s.aclose()
454467

455468
# Didn't configure the CA file, should fail
456-
async with ssl_echo_server_raw(expect_fail=True) as sock:
457-
bad_client_ctx = ssl.create_default_context()
458-
s = SSLStream(sock, bad_client_ctx, server_hostname="trio-test-1.example.org")
459-
assert not s.server_side
460-
with pytest.raises(BrokenResourceError) as excinfo:
461-
await s.send_all(b"x")
462-
assert isinstance(excinfo.value.__cause__, ssl.SSLError)
469+
# Check that the server receives TLSV1_ALERT_UNKNOWN_CA
470+
# rather than choking with UNEXPECTED_EOF_WHILE_READING.
471+
with pytest.RaisesGroup(
472+
pytest.RaisesExc(ssl.SSLError, match="TLSV1_ALERT_UNKNOWN_CA")
473+
) as excinfo:
474+
async with ssl_echo_server_raw(expect_fail="raise") as sock:
475+
bad_client_ctx = ssl.create_default_context()
476+
s = SSLStream(
477+
sock, bad_client_ctx, server_hostname="trio-test-1.example.org"
478+
)
479+
assert not s.server_side
480+
with pytest.raises(BrokenResourceError) as excinfo:
481+
await s.send_all(b"x")
482+
assert isinstance(excinfo.value.__cause__, ssl.SSLError)
463483

464484
# Trusted CA, but wrong host name
465-
async with ssl_echo_server_raw(expect_fail=True) as sock:
466-
s = SSLStream(sock, client_ctx, server_hostname="trio-test-2.example.org")
467-
assert not s.server_side
468-
with pytest.raises(BrokenResourceError) as excinfo:
469-
await s.send_all(b"x")
470-
assert isinstance(excinfo.value.__cause__, ssl.CertificateError)
485+
# Check that the server receives SSLV3_ALERT_BAD_CERTIFICATE
486+
# rather than choking with UNEXPECTED_EOF_WHILE_READING.
487+
with pytest.RaisesGroup(
488+
pytest.RaisesExc(ssl.SSLError, match="SSLV3_ALERT_BAD_CERTIFICATE")
489+
) as excinfo:
490+
async with ssl_echo_server_raw(expect_fail="raise") as sock:
491+
s = SSLStream(sock, client_ctx, server_hostname="trio-test-2.example.org")
492+
assert not s.server_side
493+
with pytest.raises(BrokenResourceError) as excinfo:
494+
await s.send_all(b"x")
495+
assert isinstance(excinfo.value.__cause__, ssl.CertificateError)
471496

472497

473498
async def test_ssl_server_basics(client_ctx: SSLContext) -> None:
@@ -503,6 +528,101 @@ def client() -> None:
503528
t.join()
504529

505530

531+
async def test_client_certificate(client_ctx: SSLContext) -> None:
532+
533+
# A valid client certificate
534+
client_cert = TRIO_TEST_CA.issue_cert("user@example.org")
535+
client_cert.configure_cert(client_ctx)
536+
537+
a, b = stdlib_socket.socketpair()
538+
with a, b:
539+
server_sock = tsocket.from_stdlib_socket(b)
540+
server_transport = SSLStream(
541+
SocketStream(server_sock),
542+
SERVER_CTX_REQ,
543+
server_side=True,
544+
)
545+
assert server_transport.server_side
546+
547+
def client() -> None:
548+
with client_ctx.wrap_socket(
549+
a,
550+
server_hostname="trio-test-1.example.org",
551+
) as client_sock:
552+
client_sock.sendall(b"x")
553+
assert client_sock.recv(1) == b"y"
554+
client_sock.sendall(b"z")
555+
client_sock.unwrap()
556+
557+
t = threading.Thread(target=client)
558+
t.start()
559+
560+
assert await server_transport.receive_some(1) == b"x"
561+
await server_transport.send_all(b"y")
562+
assert await server_transport.receive_some(1) == b"z"
563+
assert await server_transport.receive_some(1) == b""
564+
await server_transport.aclose()
565+
566+
t.join()
567+
568+
# An expired client certificate: this should fail
569+
client_cert = TRIO_TEST_CA.issue_cert(
570+
"user@example.org", not_after=datetime.datetime.now(datetime.UTC)
571+
)
572+
client_cert.configure_cert(client_ctx)
573+
574+
a, b = stdlib_socket.socketpair()
575+
with a, b:
576+
server_sock = tsocket.from_stdlib_socket(b)
577+
server_transport = SSLStream(
578+
SocketStream(server_sock),
579+
SERVER_CTX_REQ,
580+
server_side=True,
581+
)
582+
assert server_transport.server_side
583+
584+
# Prior to the changes to _ssl.py made in this commit, this test hangs
585+
# without the timeout introduced below. With the new _ssl.py,
586+
# pytest.raises in the client successfully catches the error; the
587+
# thread therefore finishes, setting client_done. With the old
588+
# _ssl.py, the thread hangs and will be abandoned after the timeout,
589+
# leaving client_done unset and thereby triggering the assertion error.
590+
#
591+
# Potential problem: determinism. It is highly unlikely but I guess it
592+
# could happen that the client thread doesn't get from .recv to
593+
# client_done.set in the allotted time, resulting in a false negative.
594+
595+
client_done = trio.Event()
596+
597+
def client() -> None:
598+
# For TLS 1.3, pytest.raises is ok around .sendall below, but it
599+
# needs to be here for TLS 1.2. (Is this because TLS 1.2 verifies
600+
# client-side certificates during the initial handshake?)
601+
with pytest.raises(ssl.SSLError, match="SSLV3_ALERT_CERTIFICATE_EXPIRED"):
602+
with client_ctx.wrap_socket(
603+
a,
604+
server_hostname="trio-test-1.example.org",
605+
) as client_sock:
606+
client_sock.sendall(b"x")
607+
client_sock.recv(1)
608+
trio.from_thread.run_sync(client_done.set)
609+
610+
async with trio.open_nursery() as nursery:
611+
nursery.start_soon(
612+
partial(trio.to_thread.run_sync, client, abandon_on_cancel=True)
613+
)
614+
with pytest.raises(BrokenResourceError) as excinfo:
615+
assert await server_transport.receive_some(1) == b"x"
616+
assert isinstance(excinfo.value.__cause__, ssl.SSLError)
617+
assert excinfo.value.__cause__.reason == "CERTIFICATE_VERIFY_FAILED"
618+
# This timeout shouldn't affect the execution time of the test on
619+
# healthy code.
620+
with trio.move_on_after(0.1):
621+
await client_done.wait()
622+
nursery.cancel_scope.cancel()
623+
assert client_done.is_set(), "The client thread is hanging"
624+
625+
506626
async def test_attributes(client_ctx: SSLContext) -> None:
507627
async with ssl_echo_server_raw(expect_fail=True) as sock:
508628
good_ctx = client_ctx

0 commit comments

Comments
 (0)