net: detect half-closed sockets before connection reuse
What changed, and why it matters
This patch fixes a connection-reuse bug in Monero's networking helper. Previously, the client could reuse a TCP connection that the peer had already half-closed (for example, after sending data and shutting down its send side). That could lead to sending new requests over a dead connection, reading stale or truncated data, or reusing a TLS session after the peer sent a close alert. The change makes the client check for peer EOF and TLS close-notify before declaring a connection reusable, and makes disconnect safe to call multiple times without crashing or corrupting buffered data.
Review and merge promptly; this is a correctness fix for connection state handling that can prevent protocol desynchronization and data corruption. Run the new unit tests (especially blocked_mode_client_ssl parameterized tests) on both TLS 1.2 and TLS 1.3 builds. Consider backporting to release branches because the bug affects any long-lived or pooled connection.
Security signals we found
Half-closed / EOF socket reuse
TLS close_notify not checked before connection reuse
Idempotent disconnect to avoid repeated TLS shutdown / exceptions
Preservation of buffered data across status probes
Potential request/response desynchronization or data truncation
Evidence from the diff
The commit modifies contrib/epee/include/net/net_helper.h’s blocked_mode_client. It adds a pending_read probe for SSL streams (async_read_some of one byte with handler chaining) and a non-blocking PEEK receive for plain TCP to detect EOF before is_connected() returns true. disconnect() is made idempotent: it now sets m_connected=false immediately, only calls shutdown_ssl and socket shutdown when the socket is still open and shutdown wasn’t already requested, ignores shutdown errors, and closes the socket so repeated calls don’t re-run TLS shutdown. recv() now marks m_connected=false on error before logging, and async_read chains through the pending probe byte to avoid losing buffered application data. Unit tests cover EOF detection, idempotent disconnect, TLS close-notify, partial records, key updates, and reconnect behavior.
Changed components
contrib/epee/include/net/net_helper.hblocked_mode_client classPlain TCP and TLS connection reuse pathstests/unit_tests/net.cppInspect captured patch +685 / −9
diff --git a/contrib/epee/include/net/net_helper.h b/contrib/epee/include/net/net_helper.h
index e52217d..c815c53 100644
--- a/contrib/epee/include/net/net_helper.h
+++ b/contrib/epee/include/net/net_helper.h
@@ -30,6 +30,7 @@
#pragma once
#include <atomic>
+#include <memory>
#include <string>
#include <boost/version.hpp>
#include <boost/asio/io_context.hpp>
@@ -266,6 +267,7 @@ namespace net_utils
try
{
m_ssl_socket->next_layer().close();
+ m_pending_read.reset();
// Set SSL options
// disable sslv2
@@ -309,14 +311,18 @@ namespace net_utils
inline
bool disconnect()
{
+ m_connected = false;
try
{
- if(m_connected)
+ if(!shutdown_requested() && m_ssl_socket->next_layer().is_open())
{
- m_connected = false;
if(m_ssl_options)
shutdown_ssl();
- m_ssl_socket->next_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both);
+ // The peer or the TLS shutdown deadline may already have closed the connection.
+ boost::system::error_code ignored_ec;
+ m_ssl_socket->next_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ignored_ec);
+ // shutdown() alone leaves is_open() true and repeats TLS shutdown on later calls.
+ m_ssl_socket->next_layer().close();
}
}
catch(const boost::system::system_error& /*er*/)
@@ -403,9 +409,61 @@ namespace net_utils
{
if (!m_connected || !m_ssl_socket->next_layer().is_open())
return false;
+
+ boost::system::error_code ec;
+ if (m_ssl_options)
+ {
+ // let Asio process TLS alerts and retain a byte of application data for recv
+ if (!m_pending_read)
+ {
+ const auto pending = std::make_shared<pending_read>();
+ // reconnect may replace the stream before this handler completes
+ const auto socket = m_ssl_socket;
+ socket->async_read_some(boost::asio::buffer(&pending->byte, 1),
+ [socket, pending](const boost::system::error_code& error, size_t) {
+ pending->error = error;
+ if (pending->handler)
+ {
+ auto handler = std::move(pending->handler);
+ handler(error, pending->byte);
+ }
+ });
+ m_pending_read = pending;
+ }
+ m_io_service.restart();
+ m_io_service.poll();
+ ec = m_pending_read->error;
+ }
+ else
+ {
+ // peek for EOF without consuming application data or changing the socket mode
+ auto& socket = m_ssl_socket->next_layer();
+ const bool non_blocking = socket.non_blocking();
+ socket.non_blocking(true, ec);
+ if (!ec)
+ {
+ char byte;
+ socket.receive(boost::asio::buffer(&byte, 1), boost::asio::ip::tcp::socket::message_peek, ec);
+ boost::system::error_code restore_error;
+ socket.non_blocking(non_blocking, restore_error);
+ if (restore_error)
+ {
+ m_connected = false;
+ return false;
+ }
+ }
+ }
+ if (ec && ec != boost::asio::error::would_block &&
+ ec != boost::asio::error::try_again && ec != boost::asio::error::interrupted)
+ {
+ MDEBUG("Peer closed idle connection, marking disconnected: " << ec.message());
+ m_connected = false;
+ return false;
+ }
+
if (ssl)
*ssl = m_ssl_options.support != ssl_support_t::e_ssl_support_disabled;
- return true;
+ return m_connected;
}
inline
@@ -438,7 +496,7 @@ namespace net_utils
static const size_t max_size = 16384;
buff.resize(max_size);
- async_read(&buff[0], max_size, boost::asio::transfer_at_least(1), hndlr);
+ async_read(&buff[0], max_size, hndlr);
// Block until the asynchronous operation has completed.
while (ec == boost::asio::error::would_block && !shutdown_requested())
@@ -462,6 +520,7 @@ namespace net_utils
if (ec)
{
+ m_connected = false;
MTRACE("READ ENDS: Connection err_code " << ec.value());
if(ec == boost::asio::error::eof)
{
@@ -472,7 +531,6 @@ namespace net_utils
}
MDEBUG("Problems at read: " << ec.message());
- m_connected = false;
return false;
}else
{
@@ -537,6 +595,13 @@ namespace net_utils
private:
+ struct pending_read
+ {
+ char byte = 0;
+ boost::system::error_code error = boost::asio::error::would_block;
+ std::function<void(const boost::system::error_code&, char)> handler;
+ };
+
bool shutdown_requested() const
{
return m_aborted.load();
@@ -605,12 +670,34 @@ namespace net_utils
boost::asio::async_write(m_ssl_socket->next_layer(), boost::asio::buffer(data, sz), boost::lambda::var(ec) = boost::lambda::_1);
}
- void async_read(char* buff, size_t sz, boost::asio::detail::transfer_at_least_t transfer_at_least, handler_obj& hndlr)
+ void async_read(char* buff, size_t sz, handler_obj& hndlr)
{
+ // finish the probe before starting another TLS read
+ if (sz && m_pending_read)
+ {
+ const auto pending = m_pending_read;
+ auto on_read = [buff, hndlr]
+ (const boost::system::error_code& error, char byte) mutable {
+ if (error)
+ {
+ hndlr(error, 0);
+ return;
+ }
+ buff[0] = byte;
+ // recv() only needs one byte; deliver it before observing any later EOF.
+ hndlr(error, 1);
+ };
+ if (pending->error == boost::asio::error::would_block)
+ pending->handler = std::move(on_read);
+ m_pending_read.reset();
+ if (pending->error != boost::asio::error::would_block)
+ on_read(pending->error, pending->byte);
+ return;
+ }
if(m_ssl_options.support == ssl_support_t::e_ssl_support_disabled)
- boost::asio::async_read(m_ssl_socket->next_layer(), boost::asio::buffer(buff, sz), transfer_at_least, hndlr);
+ boost::asio::async_read(m_ssl_socket->next_layer(), boost::asio::buffer(buff, sz), boost::asio::transfer_at_least(1), hndlr);
else
- boost::asio::async_read(*m_ssl_socket, boost::asio::buffer(buff, sz), transfer_at_least, hndlr);
+ boost::asio::async_read(*m_ssl_socket, boost::asio::buffer(buff, sz), boost::asio::transfer_at_least(1), hndlr);
}
@@ -618,6 +705,7 @@ namespace net_utils
boost::asio::io_context m_io_service;
boost::asio::ssl::context m_ctx;
std::shared_ptr<boost::asio::ssl::stream<boost::asio::ip::tcp::socket>> m_ssl_socket;
+ std::shared_ptr<pending_read> m_pending_read;
std::function<connect_func> m_connector;
ssl_options_t m_ssl_options;
std::atomic<bool> m_connected;
diff --git a/tests/data/ssl/README.md b/tests/data/ssl/README.md
new file mode 100644
index 0000000..1fda5bb
--- /dev/null
+++ b/tests/data/ssl/README.md
@@ -0,0 +1,14 @@
+# TLS unit-test credentials
+
+`test.key` and `test.crt` are public, test-only credentials for loopback TLS tests.
+Never use this private key outside tests. The tests disable peer verification;
+they exercise connection and record handling, not certificate trust or validity.
+
+Loading these fixtures avoids generating a new RSA key for each client and server
+context. They were generated with:
+
+```sh
+openssl req -x509 -newkey rsa:4096 -sha256 -nodes \
+ -keyout test.key -out test.crt -days 36500 \
+ -subj '/CN=monero-unit-tests.invalid'
+```
diff --git a/tests/data/ssl/test.crt b/tests/data/ssl/test.crt
new file mode 100644
index 0000000..8ecb245
--- /dev/null
+++ b/tests/data/ssl/test.crt
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFKzCCAxOgAwIBAgIUd0AScwuUiPylHvlBJq4rFZaKN6IwDQYJKoZIhvcNAQEL
+BQAwJDEiMCAGA1UEAwwZbW9uZXJvLXVuaXQtdGVzdHMuaW52YWxpZDAgFw0yNjA5
+MjEwNTM2MDBaGA8yMTI2MDgyODA1MzYwMFowJDEiMCAGA1UEAwwZbW9uZXJvLXVu
+aXQtdGVzdHMuaW52YWxpZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
+AJ+j7lLEHRJSORlw9mR6Stz99TC6KaWqp/KXPKQzQzomhfqrr9MCm07gp5VrmIrD
+MU2tObhsJdq4u+8q1FDwZ781O9qm17sBDWwiF/OCEIWMiILqF+FmwunbJWrn9EV8
+70aWbqRo6INcA2SvgMqigXPQ4GMtCBsL8ft/WyMmavTlR1rqzWpMvxoPlYFmMS0X
+CfSEOb9/PoRyBqa2eTjVveqbwPlFAuKy1xX37nNJGEaqCZEhr1qdQ3fAZhAkPcAs
+FPMk7XhQH0Bpcrwi6ld4JlyisQgg0lP4hjU+RcUOy5ZMxV+cWX8FNs9pek7XxjVB
+UpIunMmemwUNzR8r5Wzn8qHKzcn5KR56iy+c7nEq1LrHsAsNnaELuDRYOrJdLoWo
+e+qt6oUIYzkt2COQuDQrQ8dsehpsbpYnrptfv0+4xn1hngw3zvZHNJG+v+Z2Cp0Q
+9fBBHJWfLq+wylKzp2O0S3HXqc36CIi8qwGVhT8DUKsoYg/OLZUckxjRMpUqq+8w
+TPDA0viiXx/k8rm7iEfSkGf0Q9aC7YbH+E8lg5J+xCb7Uis3+nwsmCwG1yyUn1Dk
+qCL28MeB42MSunhaG/vVg2Mc8SRS9BTj7V1itnbwI4vCRAdjp0KCn95tNhTuujBw
+OR/wBcoEtYaAtZpOi5A9J3ZSXFuJYEEZe0tSbGrRn2ZhAgMBAAGjUzBRMB0GA1Ud
+DgQWBBSIboly3I6SE/1UixxQOVRp/6uoTTAfBgNVHSMEGDAWgBSIboly3I6SE/1U
+ixxQOVRp/6uoTTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQA1
+bXapt3SuuZGvCU13+4K9TVJDX15TYqga1NJbomoUsFtvcio2PNGwPLj8O3Po6god
+W+uEpJNF30FT8AzDxy90dRSBLjVBhfuHyNb0YAdkSVejndvbH0Pe25eZ/o4dHRIz
+Xg6slE2192K6Rahq6Yeb7npNfanrdt2akNMidEzwno1BTQ78kr1jWdvaUd+HXIMT
+QgUuKYvEKE93lB7elgkEtlNp8sHWdTF8h78VxdDoyV+1RBAz5C5v9HppEekUaeia
+a5h50SkkltADfWhE9bDv/vqpjJ8FyHYsyqNJbLbHWMYnlP0/AJblGDst0D4XH0ZV
+++iH2bLxM9AITytZOdoPtvT5cvUNqYtyvIlq5OCrCBGcvo681yiBm7azuIFt7fS5
+Al6IpUi0MfdqpefYqVAm5uXDcDrZQuxauXdeRqrM8QJ4HxbK9STMzHVDmx3wkEA2
+KXDiNE+EfwH76ae3SS0cPIw/ng5mctQ5aLlmP7Ezt14bQ0/dJBH8Jpj6uUj2R6s+
+ASEPm20qtnwDAq1nSceKAvh8HwOlaTc4SKr7faAIIRhfPXIJQN0DqJhQWgcCN/Zu
+CTg/dFP/fe4TM2L9/tRXiArp/JSdx+7n9J68krU7Vnq1MJ43dAE4TlGEBNNPMzDM
+hG7YKywPL38eWhv0rVNUE9B7nHvfZVC82Wl1vUGzBQ==
+-----END CERTIFICATE-----
diff --git a/tests/data/ssl/test.key b/tests/data/ssl/test.key
new file mode 100644
index 0000000..fb36ace
--- /dev/null
+++ b/tests/data/ssl/test.key
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+MEPbK7BIR9js26unXYswf1Cy8rfO
+-----END PRIVATE KEY-----
diff --git a/tests/unit_tests/net.cpp b/tests/unit_tests/net.cpp
index 0c5cfac..1c19ff1 100644
--- a/tests/unit_tests/net.cpp
+++ b/tests/unit_tests/net.cpp
@@ -74,6 +74,7 @@
#include "p2p/net_peerlist_boost_serialization.h"
#include "serialization/keyvalue_serialization.h"
#include "storages/portable_storage.h"
+#include "unit_tests_utils.h"
TEST(host, canonicalize_host)
{
@@ -275,6 +276,497 @@ TEST(blocked_mode_client, shutdown_is_permanent)
EXPECT_FALSE(client.connect("127.0.0.1", port, std::chrono::seconds{5}));
}
+TEST(blocked_mode_client, eof_marks_disconnected_and_allows_reconnect)
+{
+ boost::asio::io_context server_io;
+ boost::asio::ip::tcp::acceptor acceptor{
+ server_io,
+ {boost::asio::ip::address_v4::loopback(), 0}
+ };
+
+ epee::net_utils::blocked_mode_client client;
+ client.set_ssl(epee::net_utils::ssl_options_t{
+ epee::net_utils::ssl_support_t::e_ssl_support_disabled
+ });
+ const std::string port = std::to_string(acceptor.local_endpoint().port());
+ ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5}));
+
+ boost::system::error_code error;
+ boost::asio::ip::tcp::socket first_peer{server_io};
+ acceptor.accept(first_peer, error);
+ ASSERT_FALSE(error);
+ boost::asio::write(first_peer, boost::asio::buffer("x", 1), error);
+ ASSERT_FALSE(error);
+ first_peer.shutdown(boost::asio::ip::tcp::socket::shutdown_send, error);
+ ASSERT_FALSE(error);
+
+ std::string response;
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_EQ("x", response);
+
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_TRUE(response.empty());
+ EXPECT_FALSE(client.is_connected());
+
+ ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5}));
+ boost::asio::ip::tcp::socket second_peer{server_io};
+ acceptor.accept(second_peer, error);
+ ASSERT_FALSE(error);
+ boost::asio::write(second_peer, boost::asio::buffer("y", 1), error);
+ ASSERT_FALSE(error);
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_EQ("y", response);
+}
+
+TEST(blocked_mode_client, peer_eof_detected_before_reuse)
+{
+ boost::asio::io_context server_io;
+ boost::asio::ip::tcp::acceptor acceptor{
+ server_io,
+ {boost::asio::ip::address_v4::loopback(), 0}
+ };
+
+ struct test_client : epee::net_utils::blocked_mode_client
+ {
+ boost::asio::ip::tcp::socket& socket() { return m_ssl_socket->next_layer(); }
+ } client;
+ client.set_ssl(epee::net_utils::ssl_options_t{
+ epee::net_utils::ssl_support_t::e_ssl_support_disabled
+ });
+ const std::string port = std::to_string(acceptor.local_endpoint().port());
+ ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5}));
+
+ boost::system::error_code error;
+ boost::asio::ip::tcp::socket peer{server_io};
+ acceptor.accept(peer, error);
+ ASSERT_FALSE(error);
+ for (const bool non_blocking : {false, true})
+ {
+ client.socket().non_blocking(non_blocking);
+ boost::asio::write(peer, boost::asio::buffer("x", 1));
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{5};
+ while (!client.socket().available() && std::chrono::steady_clock::now() < deadline)
+ std::this_thread::sleep_for(std::chrono::milliseconds{1});
+ ASSERT_GT(client.socket().available(), 0u);
+ EXPECT_TRUE(client.is_connected());
+ EXPECT_EQ(non_blocking, client.socket().non_blocking());
+ std::string response;
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_EQ("x", response);
+ }
+
+ // close the idle connection from the peer; is_connected must observe the EOF without a request failing first
+ peer.shutdown(boost::asio::ip::tcp::socket::shutdown_send, error);
+ ASSERT_FALSE(error);
+ bool disconnected = false;
+ for (int i = 0; i < 500 && !disconnected; ++i)
+ {
+ disconnected = !client.is_connected();
+ if (!disconnected)
+ std::this_thread::sleep_for(std::chrono::milliseconds{10});
+ }
+ EXPECT_TRUE(disconnected);
+
+ ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5}));
+ boost::asio::ip::tcp::socket second_peer{server_io};
+ acceptor.accept(second_peer, error);
+ ASSERT_FALSE(error);
+ EXPECT_TRUE(client.is_connected());
+}
+
+TEST(blocked_mode_client, disconnect_is_idempotent_after_peer_close)
+{
+ using tcp = boost::asio::ip::tcp;
+ boost::asio::io_context io;
+ tcp::acceptor acceptor{io, {boost::asio::ip::address_v4::loopback(), 0}};
+ struct test_client : epee::net_utils::blocked_mode_client
+ {
+ bool socket_is_open() const { return m_ssl_socket->next_layer().is_open(); }
+ } client;
+ client.set_ssl(epee::net_utils::ssl_options_t{
+ epee::net_utils::ssl_support_t::e_ssl_support_disabled
+ });
+ ASSERT_TRUE(client.connect("127.0.0.1", std::to_string(acceptor.local_endpoint().port()),
+ std::chrono::seconds{5}));
+ tcp::socket peer{io};
+ acceptor.accept(peer);
+ peer.shutdown(tcp::socket::shutdown_both);
+ peer.close();
+ std::string response;
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ ASSERT_TRUE(response.empty());
+ for (unsigned i = 0; i < 3; ++i)
+ {
+ EXPECT_TRUE(client.disconnect());
+ EXPECT_FALSE(client.socket_is_open());
+ EXPECT_FALSE(client.is_connected());
+ }
+ // disconnect is not the permanent shutdown: reconnect must still work.
+ ASSERT_TRUE(client.connect("127.0.0.1", std::to_string(acceptor.local_endpoint().port()),
+ std::chrono::seconds{5}));
+ acceptor.accept(peer);
+ EXPECT_TRUE(client.is_connected());
+}
+
+namespace
+{
+ class blocked_mode_client_ssl : public testing::TestWithParam<int>
+ {
+ protected:
+ using tcp = boost::asio::ip::tcp;
+ using ssl_stream = boost::asio::ssl::stream<tcp::socket>;
+
+ static epee::net_utils::ssl_options_t ssl_options()
+ {
+ epee::net_utils::ssl_options_t options{epee::net_utils::ssl_support_t::e_ssl_support_enabled};
+ options.verification = epee::net_utils::ssl_verification_t::none;
+ // Public test-only credentials avoid generating RSA keys for every context.
+ options.auth = epee::net_utils::ssl_authentication_t{
+ (unit_test::data_dir / "ssl" / "test.key").string(),
+ (unit_test::data_dir / "ssl" / "test.crt").string()
+ };
+ return options;
+ }
+
+ boost::asio::io_context io;
+ boost::asio::ssl::context context{ssl_options().create_context()};
+ tcp::acceptor acceptor{io, {boost::asio::ip::address_v4::loopback(), 0}};
+ std::unique_ptr<ssl_stream> peer;
+ struct test_client : epee::net_utils::blocked_mode_client
+ {
+ bool socket_is_open() const { return m_ssl_socket->next_layer().is_open(); }
+ } client;
+
+ void SetUp() override
+ {
+#if (OPENSSL_VERSION_NUMBER >= 0x10101000L && !defined(LIBRESSL_VERSION_NUMBER)) \
+ || (defined(LIBRESSL_VERSION_NUMBER) && LIBRESSL_VERSION_NUMBER >= 0x2070000fL)
+ ASSERT_EQ(1, SSL_CTX_set_min_proto_version(context.native_handle(), GetParam()));
+ ASSERT_EQ(1, SSL_CTX_set_max_proto_version(context.native_handle(), GetParam()));
+#endif
+ client.set_ssl(ssl_options());
+ ASSERT_TRUE(connect());
+ ASSERT_EQ(GetParam(), SSL_version(peer->native_handle()));
+ }
+
+ void TearDown() override
+ {
+ boost::system::error_code ignored;
+ if (peer)
+ peer->next_layer().close(ignored);
+ io.restart();
+ io.poll();
+ }
+
+ bool connect()
+ {
+ if (peer)
+ {
+ boost::system::error_code ignored;
+ peer->next_layer().close(ignored);
+ io.restart();
+ io.poll();
+ }
+ peer.reset(new ssl_stream{io, context});
+ io.restart();
+ boost::system::error_code server_error = boost::asio::error::would_block;
+ boost::asio::steady_timer deadline{io, std::chrono::seconds{5}};
+ deadline.async_wait([this](const boost::system::error_code& error) {
+ if (!error)
+ {
+ boost::system::error_code ignored;
+ acceptor.cancel(ignored);
+ peer->next_layer().close(ignored);
+ }
+ });
+ acceptor.async_accept(peer->next_layer(), [&](const boost::system::error_code& error) {
+ if (error)
+ {
+ server_error = error;
+ deadline.cancel();
+ return;
+ }
+ peer->async_handshake(boost::asio::ssl::stream_base::server,
+ [&](const boost::system::error_code& error) {
+ server_error = error;
+ deadline.cancel();
+ });
+ });
+ std::thread server{[this] { io.run(); }};
+ const bool connected = client.connect("127.0.0.1",
+ std::to_string(acceptor.local_endpoint().port()), std::chrono::seconds{5});
+ server.join();
+ return connected && !server_error;
+ }
+
+ bool wait_for_disconnect()
+ {
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{2};
+ do
+ {
+ if (!client.is_connected())
+ return true;
+ std::this_thread::sleep_for(std::chrono::milliseconds{1});
+ } while (std::chrono::steady_clock::now() < deadline);
+ return false;
+ }
+ };
+}
+
+TEST_P(blocked_mode_client_ssl, close_notify_detected_before_reuse)
+{
+ bool ssl = false;
+ ASSERT_TRUE(client.is_connected(&ssl));
+ ASSERT_TRUE(ssl);
+ ASSERT_TRUE(client.send("x", std::chrono::seconds{5}));
+ char request = 0;
+ boost::asio::read(*peer, boost::asio::buffer(&request, 1));
+ ASSERT_EQ('x', request);
+ boost::asio::write(*peer, boost::asio::buffer("y", 1));
+ std::string response;
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ ASSERT_EQ("y", response);
+ ASSERT_TRUE(client.is_connected());
+ // close only after the client has received the response; leave TCP open
+ // while waiting for the client's alert
+ io.restart();
+ peer->async_shutdown([](const boost::system::error_code&) {});
+ io.poll();
+ ASSERT_TRUE(wait_for_disconnect());
+ ASSERT_TRUE(connect());
+ ASSERT_TRUE(client.send("x", std::chrono::seconds{5}));
+ request = 0;
+ boost::asio::read(*peer, boost::asio::buffer(&request, 1));
+ EXPECT_EQ('x', request);
+}
+
+TEST_P(blocked_mode_client_ssl, preserves_unread_data)
+{
+ boost::asio::write(*peer, boost::asio::buffer("xyz", 3));
+ EXPECT_TRUE(client.is_connected());
+ EXPECT_TRUE(client.is_connected());
+ std::string response, received;
+ while (received.size() < 3)
+ {
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ ASSERT_FALSE(response.empty());
+ received += response;
+ }
+ EXPECT_EQ("xyz", received);
+ EXPECT_EQ(3u, client.get_bytes_received());
+ EXPECT_TRUE(client.is_connected());
+}
+
+TEST_P(blocked_mode_client_ssl, abrupt_eof_detected_before_reuse)
+{
+ peer->next_layer().shutdown(tcp::socket::shutdown_send);
+ EXPECT_TRUE(wait_for_disconnect());
+}
+
+TEST_P(blocked_mode_client_ssl, partial_record_does_not_block)
+{
+ // an incomplete TLS record must not turn a status check into a blocking read
+ const unsigned char header[] = {0x17, 0x03, 0x03, 0x00, 0x20};
+ boost::asio::write(peer->next_layer(), boost::asio::buffer(header));
+ const auto start = std::chrono::steady_clock::now();
+ EXPECT_TRUE(client.is_connected());
+ EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{1});
+ peer->next_layer().shutdown(tcp::socket::shutdown_send);
+ EXPECT_TRUE(wait_for_disconnect());
+}
+
+TEST_P(blocked_mode_client_ssl, idle_probe_allows_request_and_response)
+{
+ const auto start = std::chrono::steady_clock::now();
+ for (unsigned i = 0; i < 10; ++i)
+ ASSERT_TRUE(client.is_connected());
+ EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{1});
+ ASSERT_TRUE(client.send("x", std::chrono::seconds{5}));
+ char request = 0;
+ boost::asio::read(*peer, boost::asio::buffer(&request, 1));
+ EXPECT_EQ('x', request);
+ boost::asio::write(*peer, boost::asio::buffer("y", 1));
+ std::string response;
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_EQ("y", response);
+ EXPECT_EQ(1u, client.get_bytes_received());
+}
+
+TEST_P(blocked_mode_client_ssl, reconnect_with_pending_probe)
+{
+ ASSERT_TRUE(client.is_connected());
+ ASSERT_TRUE(connect());
+ boost::asio::write(*peer, boost::asio::buffer("y", 1));
+ std::string response;
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_EQ("y", response);
+}
+
+TEST_P(blocked_mode_client_ssl, shutdown_aborts_pending_probe)
+{
+ ASSERT_TRUE(client.is_connected());
+ std::thread stopper{[this] {
+ std::this_thread::sleep_for(std::chrono::milliseconds{100});
+ client.shutdown();
+ }};
+ std::string response;
+ const auto start = std::chrono::steady_clock::now();
+ EXPECT_FALSE(client.recv(response, std::chrono::seconds{30}));
+ EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{5});
+ stopper.join();
+ EXPECT_FALSE(client.is_connected());
+}
+
+TEST_P(blocked_mode_client_ssl, data_before_close_notify_is_preserved)
+{
+ boost::asio::write(*peer, boost::asio::buffer("xyz", 3));
+ io.restart();
+ peer->async_shutdown([](const boost::system::error_code&) {});
+ io.poll();
+ ASSERT_TRUE(client.is_connected());
+ std::string response, received;
+ while (received.size() < 3)
+ {
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ ASSERT_FALSE(response.empty());
+ received += response;
+ }
+ EXPECT_EQ("xyz", received);
+ EXPECT_EQ(3u, client.get_bytes_received());
+ EXPECT_TRUE(wait_for_disconnect());
+}
+
+#if OPENSSL_VERSION_NUMBER >= 0x10101000L && !defined(LIBRESSL_VERSION_NUMBER)
+TEST_P(blocked_mode_client_ssl, key_update_allows_request_and_response)
+{
+ if (GetParam() != TLS1_3_VERSION)
+ GTEST_SKIP() << "SSL_key_update requires TLS 1.3";
+ ASSERT_EQ(1, SSL_key_update(peer->native_handle(), SSL_KEY_UPDATE_REQUESTED));
+ boost::asio::write(*peer, boost::asio::buffer("x", 1));
+ ASSERT_TRUE(client.is_connected());
+ std::string response;
+ ASSERT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_EQ("x", response);
+ ASSERT_TRUE(client.send("y", std::chrono::seconds{5}));
+ char request = 0;
+ boost::asio::read(*peer, boost::asio::buffer(&request, 1));
+ EXPECT_EQ('y', request);
+}
+#endif
+
+TEST_P(blocked_mode_client_ssl, recv_times_out_with_pending_probe)
+{
+ ASSERT_TRUE(client.is_connected());
+ std::string response;
+ const auto start = std::chrono::steady_clock::now();
+ EXPECT_FALSE(client.recv(response, std::chrono::milliseconds{100}));
+ EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{5});
+ EXPECT_FALSE(client.is_connected());
+}
+
+TEST_P(blocked_mode_client_ssl, disconnect_with_pending_probe_is_graceful)
+{
+ ASSERT_TRUE(client.is_connected());
+ boost::system::error_code peer_error = boost::asio::error::would_block;
+ char byte;
+ io.restart();
+ boost::asio::steady_timer deadline{io, std::chrono::seconds{5}};
+ deadline.async_wait([this](const boost::system::error_code& error) {
+ if (!error)
+ {
+ boost::system::error_code ignored;
+ peer->next_layer().close(ignored);
+ }
+ });
+ peer->async_read_some(boost::asio::buffer(&byte, 1),
+ [&](const boost::system::error_code& error, size_t) {
+ EXPECT_EQ(boost::asio::error::eof, error);
+ if (error != boost::asio::error::eof)
+ {
+ peer_error = boost::asio::error::fault;
+ deadline.cancel();
+ }
+ else
+ peer->async_shutdown([&](const boost::system::error_code& error) {
+ peer_error = error;
+ deadline.cancel();
+ });
+ });
+ std::thread server{[this] { io.run(); }};
+ EXPECT_TRUE(client.disconnect());
+ server.join();
+ EXPECT_FALSE(peer_error);
+ EXPECT_FALSE(client.socket_is_open());
+ // Repeated disconnects must not attempt another TLS shutdown, even after the peer closes.
+ peer->next_layer().close();
+ EXPECT_TRUE(client.disconnect());
+ EXPECT_TRUE(client.disconnect());
+ EXPECT_FALSE(client.is_connected());
+ ASSERT_TRUE(connect());
+ EXPECT_TRUE(client.is_connected());
+}
+
+TEST_P(blocked_mode_client_ssl, disconnect_after_peer_eof_is_graceful)
+{
+ boost::system::error_code peer_error = boost::asio::error::would_block;
+ io.restart();
+ boost::asio::steady_timer deadline{io, std::chrono::seconds{5}};
+ deadline.async_wait([this](const boost::system::error_code& error) {
+ if (!error)
+ {
+ boost::system::error_code ignored;
+ peer->next_layer().close(ignored);
+ }
+ });
+ peer->async_shutdown([&](const boost::system::error_code& error) {
+ peer_error = error;
+ deadline.cancel();
+ });
+ std::thread server{[this] { io.run(); }};
+ std::string response;
+ EXPECT_TRUE(client.recv(response, std::chrono::seconds{5}));
+ EXPECT_TRUE(response.empty());
+ EXPECT_FALSE(client.is_connected());
+ EXPECT_TRUE(client.disconnect());
+ server.join();
+ EXPECT_FALSE(peer_error);
+ EXPECT_FALSE(client.socket_is_open());
+ peer->next_layer().close();
+ EXPECT_TRUE(client.disconnect());
+ EXPECT_TRUE(client.disconnect());
+}
+
+TEST_P(blocked_mode_client_ssl, disconnect_after_abrupt_eof_is_idempotent)
+{
+ peer->next_layer().shutdown(tcp::socket::shutdown_both);
+ peer->next_layer().close();
+ ASSERT_TRUE(wait_for_disconnect());
+ for (unsigned i = 0; i < 3; ++i)
+ {
+ EXPECT_TRUE(client.disconnect());
+ EXPECT_FALSE(client.socket_is_open());
+ EXPECT_FALSE(client.is_connected());
+ }
+}
+
+TEST_P(blocked_mode_client_ssl, disconnect_after_shutdown_does_not_wait_for_peer)
+{
+ ASSERT_TRUE(client.is_connected());
+ ASSERT_TRUE(client.shutdown());
+ // permanent shutdown must not wait for the peer's TLS close alert
+ const auto start = std::chrono::steady_clock::now();
+ EXPECT_TRUE(client.disconnect());
+ EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{1});
+ EXPECT_FALSE(client.is_connected());
+}
+
+INSTANTIATE_TEST_SUITE_P(tls, blocked_mode_client_ssl, testing::Values(
+ TLS1_2_VERSION
+#if OPENSSL_VERSION_NUMBER >= 0x10101000L && !defined(LIBRESSL_VERSION_NUMBER)
+ , TLS1_3_VERSION
+#endif
+));
+
TEST(tor_address, constants)
{
static_assert(!net::tor_address::is_local(), "bad is_local() response");
Why this scored 63/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.