Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/clients/ioxide.httpclient/Tls/OpenSsl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,10 @@ public static string LastError()
}
return message;
}

[LibraryImport(Crypto)] private static partial nint X509_verify_cert_error_string(long n);

/// <summary>What an X509 verify result means: "certificate has expired", "hostname mismatch", ...</summary>
public static string VerifyResultText(long result)
=> Marshal.PtrToStringUTF8(X509_verify_cert_error_string(result)) ?? $"X509 verify result {result}";
}
19 changes: 17 additions & 2 deletions src/clients/ioxide.httpclient/Tls/TlsClientStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Encrypt and send. Returns the number of PLAINTEXT bytes accepted, matching
/// <see cref="RingSocket.SendAsync"/>, so a caller looping until everything is sent works
Expand Down
8 changes: 3 additions & 5 deletions tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 ----------------------------------------------------------------------
Expand Down
Loading