Skip to content

Commit a601c73

Browse files
committed
Rewrite DTLS test to work around stupid OpenSSL misbehavior
1 parent 7f8b052 commit a601c73

File tree

1 file changed

+147
-25
lines changed

1 file changed

+147
-25
lines changed

tests/test_ssl.py

+147-25
Original file line numberDiff line numberDiff line change
@@ -4226,7 +4226,78 @@ def client_callback(*args): # pragma: nocover
42264226

42274227

42284228
class TestDTLS(object):
4229+
# The way you would expect DTLSv1_listen to work is:
4230+
#
4231+
# - it reads packets in a loop
4232+
# - when it finds a valid ClientHello, it returns
4233+
# - now the handshake can proceed
4234+
#
4235+
# However, on older versions of OpenSSL, it did something "cleverer". The
4236+
# way it worked is:
4237+
#
4238+
# - it "peeks" into the BIO to see the next packet without consuming it
4239+
# - if *not* a valid ClientHello, then it reads the packet to consume it
4240+
# and loops around
4241+
# - if it *is* a valid ClientHello, it *leaves the packet in the BIO*, and
4242+
# returns
4243+
# - then the handshake finds the ClientHello in the BIO and reads it a
4244+
# second time.
4245+
#
4246+
# I'm not sure exactly when this switched over. The OpenSSL v1.1.1 in
4247+
# Ubuntu 18.04 has the old behavior. The OpenSSL v1.1.1 in Ubuntu 20.04 has
4248+
# the new behavior. There doesn't seem to be any mention of this change in
4249+
# the OpenSSL v1.1.1 changelog, but presumably it changed in some point
4250+
# release or another. Presumably in 2025 or so there will be only new
4251+
# OpenSSLs around we can delete this whole comment and the weird
4252+
# workaround. If anyone is still using this library by then, which seems
4253+
# both depressing and inevitable.
4254+
#
4255+
# Anyway, why do we care? The reason is that the old strategy has a
4256+
# problem: the "peek" operation is only defined on "DGRAM BIOs", which are
4257+
# a special type of object that is different from the more familiar "socket
4258+
# BIOs" and "memory BIOs". If you *don't* have a DGRAM BIO, and you try to
4259+
# peek into the BIO... then it silently degrades to a full-fledged "read"
4260+
# operation that consumes the packet. Which is a problem if your algorithm
4261+
# depends on leaving the packet in the BIO to be read again later.
4262+
#
4263+
# So on old OpenSSL, we have a problem:
4264+
#
4265+
# - we can't use a DGRAM BIO, because cryptography/pyopenssl don't wrap the
4266+
# relevant APIs, nor should they.
4267+
#
4268+
# - if we use a socket BIO, then the first time DTLSv1_listen sees an
4269+
# invalid packet (like for example... the challenge packet that *every
4270+
# DTLS handshake starts with before the real ClientHello!*), it tries to
4271+
# first "peek" it, and then "read" it. But since the first "peek"
4272+
# consumes the packet, the second "read" ends up hanging or consuming
4273+
# some unrelated packet, which is undesirable. So you can't even get to
4274+
# the handshake stage successfully.
4275+
#
4276+
# - if we use a memory BIO, then DTLSv1_listen works OK on invalid packets
4277+
# -- first the "peek" consumes them, and then it tries to "read" again to
4278+
# consume them, which fails immediately, and OpenSSL ignores the failure.
4279+
# So it works by accident. BUT, when we get a valid ClientHello, we have
4280+
# a problem: DTLSv1_listen tries to "peek" it and then leave it in the
4281+
# read BIO for do_handshake to consume. But instead "peek" consumes the
4282+
# packet, so it's not there where do_handshake is expecting it, and the
4283+
# handshake fails.
4284+
#
4285+
# Fortunately (if that's the word), we can work around the memory BIO
4286+
# problem. (Which is good, because in real life probably all our users will
4287+
# be using memory BIOs.) All we have to do is to save the valid ClientHello
4288+
# before calling DTLSv1_listen, and then after it returns we push *a second
4289+
# copy of it* of the packet memory BIO before calling do_handshake. This
4290+
# fakes out OpenSSL and makes it think the "peek" operation worked
4291+
# correctly, and we can go on with our lives.
4292+
#
4293+
# In fact, we push the second copy of the ClientHello unconditionally. On
4294+
# new versions of OpenSSL, this is unnecessary, but harmless, because the
4295+
# DTLS state machine treats it like a network hiccup that duplicated a
4296+
# packet, which DTLS is robust against.
42294297
def test_it_works_at_all(self):
4298+
# arbitrary number larger than any conceivable handshake volley
4299+
LARGE_BUFFER = 65536
4300+
42304301
s_ctx = Context(DTLS_METHOD)
42314302

42324303
def generate_cookie(ssl):
@@ -4237,40 +4308,91 @@ def verify_cookie(ssl, cookie):
42374308

42384309
s_ctx.set_cookie_generate_callback(generate_cookie)
42394310
s_ctx.set_cookie_verify_callback(verify_cookie)
4240-
42414311
s_ctx.use_privatekey(load_privatekey(FILETYPE_PEM, server_key_pem))
42424312
s_ctx.use_certificate(load_certificate(FILETYPE_PEM, server_cert_pem))
4243-
4244-
s_sock = socket_any_family(type=SOCK_DGRAM)
4245-
s_sock.bind((loopback_address(s_sock), 0))
4246-
s = Connection(s_ctx, s_sock)
4313+
s_ctx.set_options(OP_NO_QUERY_MTU)
4314+
s = Connection(s_ctx)
42474315
s.set_accept_state()
42484316

4249-
c_sock = socket(s_sock.family, type=SOCK_DGRAM)
4250-
c = loopback_client_factory(c_sock, DTLS_METHOD)
4317+
c_ctx = Context(DTLS_METHOD)
4318+
c_ctx.set_options(OP_NO_QUERY_MTU)
4319+
c = Connection(c_ctx)
4320+
c.set_connect_state()
42514321

4252-
c_sock.connect(s_sock.getsockname())
4253-
s_sock.connect(c_sock.getsockname())
4322+
# These are mandatory, because openssl can't guess the MTU for a memory bio and
4323+
# will produce a mysterious error if you make it try.
4324+
c.set_ciphertext_mtu(1500)
4325+
s.set_ciphertext_mtu(1500)
42544326

4255-
def s_handler():
4256-
s.DTLSv1_listen()
4257-
s.do_handshake()
4258-
s.write(b"hello")
4259-
assert s.read(100) == b"goodbye"
4260-
return "ok"
4327+
latest_client_hello = None
42614328

4262-
def c_handler():
4263-
c.do_handshake()
4264-
assert c.read(100) == b"hello"
4265-
c.write(b"goodbye")
4266-
return "ok"
4329+
def pump_membio(label, source, sink):
4330+
try:
4331+
chunk = source.bio_read(LARGE_BUFFER)
4332+
except WantReadError:
4333+
return False
4334+
if not chunk:
4335+
return False
4336+
# Gross hack: if this is a ClientHello, save it so we can find it
4337+
# later. See giant comment above.
4338+
try:
4339+
# if ContentType == handshake and HandshakeType == client_hello:
4340+
if chunk[0] == 22 and chunk[13] == 1:
4341+
nonlocal latest_client_hello
4342+
latest_client_hello = chunk
4343+
except IndexError:
4344+
pass
4345+
print(f"{label}: {chunk.hex()}")
4346+
sink.bio_write(chunk)
4347+
return True
4348+
4349+
def pump():
4350+
# Raises if there was no data to pump, to avoid infinite loops if we aren't
4351+
# making progress.
4352+
assert pump_membio("s -> c", s, c) or pump_membio("c -> s", c, s)
4353+
4354+
c_handshaking = True
4355+
s_listening = True
4356+
s_handshaking = False
4357+
first = True
4358+
while c_handshaking or s_listening or s_handshaking:
4359+
if not first:
4360+
pump()
4361+
first = False
4362+
4363+
if c_handshaking:
4364+
try:
4365+
c.do_handshake()
4366+
except WantReadError:
4367+
pass
4368+
else:
4369+
c_handshaking = False
42674370

4268-
with ThreadPoolExecutor(max_workers=2) as executor:
4269-
s_fut = executor.submit(s_handler)
4270-
c_fut = executor.submit(c_handler)
4371+
if s_listening:
4372+
try:
4373+
s.DTLSv1_listen()
4374+
except WantReadError:
4375+
pass
4376+
else:
4377+
s_listening = False
4378+
s_handshaking = True
4379+
# Write the duplicate ClientHello. See giant comment above.
4380+
s.bio_write(latest_client_hello)
42714381

4272-
assert s_fut.result() == "ok"
4273-
assert c_fut.result() == "ok"
4382+
if s_handshaking:
4383+
try:
4384+
s.do_handshake()
4385+
except WantReadError:
4386+
pass
4387+
else:
4388+
s_handshaking = False
4389+
4390+
s.write(b"hello")
4391+
pump()
4392+
assert c.read(100) == b"hello"
4393+
c.write(b"goodbye")
4394+
pump()
4395+
assert s.read(100) == b"goodbye"
42744396

42754397
# Check that the MTU set/query functions are doing *something*
42764398
c.set_ciphertext_mtu(1000)

0 commit comments

Comments
 (0)