From 7d3237be102ff53f857befe5e4bc6f21518f9c9e Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 12 Oct 2026 23:29:37 +0100 Subject: [PATCH] httpclient: a refused server certificate says which check refused it A TLS client that refused a certificate reported only OpenSSL's "certificate verify failed", whatever the reason. SSL_get_verify_result, which holds it, was read only after a handshake that succeeded, where it can only be X509_V_OK. And the error text itself was read after the alert's flush, an await during which other connections on the reactor run OpenSSL calls that clear the thread's error queue - the race #319 fixed on the server. The failure is now captured as soon as SSL_connect fails, before the flush: the queued error, and when verification is on and refused the certificate, X509_verify_cert_error_string of the verify result ("certificate has expired", "self-signed certificate", "unable to get local issuer certificate", ...). The pending test "tls client: a verification failure says which check failed" is now a Test. --- src/clients/ioxide.httpclient/Tls/OpenSsl.cs | 6 ++++++ .../ioxide.httpclient/Tls/TlsClientStream.cs | 19 +++++++++++++++++-- .../TlsClientVerificationTests.cs | 8 +++----- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/clients/ioxide.httpclient/Tls/OpenSsl.cs b/src/clients/ioxide.httpclient/Tls/OpenSsl.cs index 6a03a66b..c03aa438 100644 --- a/src/clients/ioxide.httpclient/Tls/OpenSsl.cs +++ b/src/clients/ioxide.httpclient/Tls/OpenSsl.cs @@ -170,4 +170,10 @@ public static string LastError() } return message; } + + [LibraryImport(Crypto)] private static partial nint X509_verify_cert_error_string(long n); + + /// What an X509 verify result means: "certificate has expired", "hostname mismatch", ... + public static string VerifyResultText(long result) + => Marshal.PtrToStringUTF8(X509_verify_cert_error_string(result)) ?? $"X509 verify result {result}"; } diff --git a/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs b/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs index e9278cd3..219bf77a 100644 --- a/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs +++ b/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs @@ -148,15 +148,20 @@ private async ValueTask RunHandshakeAsync(TlsClientOptions options, string serve { int ret = OpenSsl.Connect(_ssl, out int err); + // Read before the alert's flush, during which other connections' OpenSSL calls clear this thread's queue. + string? failure = ret != 1 && err != OpenSsl.SSL_ERROR_WANT_READ && err != OpenSsl.SSL_ERROR_WANT_WRITE + ? HandshakeFailure(options) + : null; + await FlushCiphertextAsync(); if (ret == 1) { break; } - if (err != OpenSsl.SSL_ERROR_WANT_READ && err != OpenSsl.SSL_ERROR_WANT_WRITE) + if (failure is not null) { - throw new IOException($"TLS handshake to '{serverName}' failed: {OpenSsl.LastError()}"); + throw new IOException($"TLS handshake to '{serverName}' failed: {failure}"); } // The deadline is checked between flights rather than raced against the recv: a ring @@ -192,6 +197,16 @@ private async ValueTask RunHandshakeAsync(TlsClientOptions options, string serve NegotiatedAlpn = ReadNegotiatedAlpn(_ssl); } + // The queue says the handshake failed; a refused certificate's verify result says which check refused it. + private string HandshakeFailure(TlsClientOptions options) + { + string error = OpenSsl.LastError(); + long verify = OpenSsl.SSL_get_verify_result(_ssl); + return options.VerifyCertificate && verify != OpenSsl.X509_V_OK + ? $"{error}: {OpenSsl.VerifyResultText(verify)}" + : error; + } + /// /// Encrypt and send. Returns the number of PLAINTEXT bytes accepted, matching /// , so a caller looping until everything is sent works diff --git a/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs b/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs index 2a7d8646..21e9a4fa 100644 --- a/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs +++ b/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs @@ -213,12 +213,12 @@ private static void RegisterChain(Runner runner) $"should say the connection closed, got: {body}"); }); - runner.Pending("tls client: a verification failure says which check failed", () => + runner.Test("tls client: a verification failure says which check failed", () => { // Expired, self-signed and unknown-issuer are three different X509 verify codes (10, 18 // and 20), and a caller acts on them differently: one is a clock or a renewal, one is // an origin misconfigured, one is trust configured wrongly on OUR side. All three - // arrive as the same sentence. + // used to arrive as the same sentence. using Authority trusted = Authority.Mint(); using Authority stranger = Authority.Mint(); @@ -240,9 +240,7 @@ private static void RegisterChain(Runner runner) string[] reasons = [Reason(expired), Reason(selfSigned), Reason(unknownIssuer)]; Assert.True(reasons.Distinct().Count() == 3, "three different verification failures reported the same thing: " + reasons[0]); - }, "the X509 reason is thrown away: SSL_get_verify_result is consulted only AFTER a handshake " - + "that succeeded, where it can only be X509_V_OK, and never on the failure path where it " - + "holds the code"); + }); } // --- driving the client ----------------------------------------------------------------------