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 ----------------------------------------------------------------------