From a7fce92ed0490b62f168f3207884c74f1b37f49b Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 16 Oct 2026 21:38:40 -0400 Subject: [PATCH] Let a client resume its session with an OpenSSL mTLS server Without a session id context, OpenSSL answers a client resuming its session with a fatal alert once client certificates are verified, which curl and browsers run into on their second connection. Set one on every server context. A changed client CA still stops resumption, as fcf7568 flushes the cache and rotates the ticket keys. --- httplib.h | 4 +++ test/test.cc | 93 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/httplib.h b/httplib.h index b4c9162d..d020b099 100644 --- a/httplib.h +++ b/httplib.h @@ -19812,6 +19812,10 @@ inline ctx_t create_server_context() { SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION | SSL_OP_NO_RENEGOTIATION); SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + // Without one, a client resuming its session is sent a fatal alert once + // client certificates are verified. + static const unsigned char sid_ctx[] = "cpp-httplib"; + SSL_CTX_set_session_id_context(ctx, sid_ctx, sizeof(sid_ctx) - 1); } return static_cast(ctx); } diff --git a/test/test.cc b/test/test.cc index a53ed0dc..b83585cf 100644 --- a/test/test.cc +++ b/test/test.cc @@ -4867,6 +4867,16 @@ TEST(SSLClientServerTest, UpdateCertsPemWithClientAuth) { EXPECT_EQ("ok", res->body); } +// Puts a CA that did not sign the client's certificate in the old one's place. +static bool replace_client_ca(SSLServer &svr) { + std::string cert_pem, key_pem, ca_pem; + read_file(SERVER_CERT_FILE, cert_pem); + read_file(SERVER_PRIVATE_KEY_FILE, key_pem); + read_file(SERVER_CERT2_FILE, ca_pem); + return svr.update_certs_pem(cert_pem.c_str(), key_pem.c_str(), + ca_pem.c_str()); +} + TEST(SSLClientServerTest, UpdateCertsPemReplacesClientCA) { SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE); ASSERT_TRUE(svr.is_valid()); @@ -4889,16 +4899,85 @@ TEST(SSLClientServerTest, UpdateCertsPemReplacesClientCA) { }; ASSERT_TRUE(get()); - // A CA that did not sign the client's certificate takes the old one's place. - std::string cert_pem, key_pem, ca_pem; - read_file(SERVER_CERT_FILE, cert_pem); - read_file(SERVER_PRIVATE_KEY_FILE, key_pem); - read_file(SERVER_CERT2_FILE, ca_pem); - ASSERT_TRUE( - svr.update_certs_pem(cert_pem.c_str(), key_pem.c_str(), ca_pem.c_str())); + ASSERT_TRUE(replace_client_ca(svr)); EXPECT_FALSE(get()); } +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT +// A client that resumes its session gets through, but not once the client CA +// has changed, as a resumed session does not show the certificate again. +TEST(SSLClientServerTest, ClientResumesSessionUntilClientCAChanges) { + SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE, CLIENT_CA_CERT_FILE); + ASSERT_TRUE(svr.is_valid()); + svr.Get("/test", [](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + + thread t = thread([&]() { ASSERT_TRUE(svr.listen(HOST, PORT)); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + svr.wait_until_ready(); + + auto ctx = SSL_CTX_new(TLS_client_method()); + auto ctx_se = detail::scope_exit([&] { SSL_CTX_free(ctx); }); + ASSERT_EQ( + 1, SSL_CTX_use_certificate_file(ctx, CLIENT_CERT_FILE, SSL_FILETYPE_PEM)); + ASSERT_EQ(1, SSL_CTX_use_PrivateKey_file(ctx, CLIENT_PRIVATE_KEY_FILE, + SSL_FILETYPE_PEM)); + + SSL_SESSION *session = nullptr; + auto session_se = detail::scope_exit([&] { SSL_SESSION_free(session); }); + + // A request over a new connection, resuming `session` when there is one and + // keeping the connection's own in its place. Tells whether it was answered. + auto reused = false; + auto get = [&]() { + auto error = Error::Success; + auto sock = detail::create_client_socket( + HOST, "", PORT, AF_UNSPEC, false, false, nullptr, + /*connection_timeout_sec=*/5, 0, + /*read_timeout_sec=*/5, 0, + /*write_timeout_sec=*/5, 0, std::string(), error); + if (sock == INVALID_SOCKET) { return false; } + auto sock_se = detail::scope_exit([&] { detail::close_socket(sock); }); + + auto ssl = SSL_new(ctx); + auto ssl_se = detail::scope_exit([&] { SSL_free(ssl); }); + SSL_set_fd(ssl, static_cast(sock)); + if (session) { SSL_set_session(ssl, session); } + if (SSL_connect(ssl) != 1) { return false; } + reused = SSL_session_reused(ssl) == 1; + + // Reading the response also takes in a TLS 1.3 session ticket. + std::string req = "GET /test HTTP/1.1\r\nHost: x\r\n" + "Connection: close\r\n\r\n"; + SSL_write(ssl, req.data(), static_cast(req.size())); + std::string res; + char buf[256]; + int n; + while ((n = SSL_read(ssl, buf, sizeof(buf))) > 0) { + res.append(buf, static_cast(n)); + } + + // Without a proper shutdown, OpenSSL marks the session not resumable. + SSL_shutdown(ssl); + SSL_SESSION_free(session); + session = SSL_get1_session(ssl); + return res.find("200 OK") != std::string::npos; + }; + + ASSERT_TRUE(get()); + ASSERT_TRUE(get()); + EXPECT_TRUE(reused); + + ASSERT_TRUE(replace_client_ca(svr)); + EXPECT_FALSE(get()); +} +#endif + TEST(SSLClientServerTest, UpdateCertsPemWaitsForHandshakeInProgress) { SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE); ASSERT_TRUE(svr.is_valid());