Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using System.IO;
using System.Net;
using System.Net.Security;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
using System.Security.Authentication;
Expand Down Expand Up @@ -217,6 +218,8 @@ internal static unsafe SafeSslContextHandle AllocateSslContext(SslAuthentication
throw CreateSslException(SR.net_allocate_ssl_context_failed);
}

Ssl.SslCtxSetCertVerifyCallback(sslCtx, &CertVerifyCallback);

Ssl.SslCtxSetProtocolOptions(sslCtx, protocols);

if (sslAuthenticationOptions.EncryptionPolicy != EncryptionPolicy.RequireEncryption)
Expand Down Expand Up @@ -388,7 +391,7 @@ internal static SafeSslHandle AllocateSslHandle(SslAuthenticationOptions sslAuth
// Dispose() here will not close the handle.
using SafeSslContextHandle sslCtxHandle = GetOrCreateSslContextHandle(sslAuthenticationOptions, cacheSslContext);

GCHandle alpnHandle = default;
GCHandle authOptionsHandle = default;
try
{
sslHandle = SafeSslHandle.Create(sslCtxHandle, sslAuthenticationOptions.IsServer);
Expand Down Expand Up @@ -418,21 +421,19 @@ internal static SafeSslHandle AllocateSslHandle(SslAuthenticationOptions sslAuth
sslHandle.SslContextHandle = sslCtxHandle;
}

authOptionsHandle = GCHandle.Alloc(sslAuthenticationOptions);
Interop.Ssl.SslSetData(sslHandle, GCHandle.ToIntPtr(authOptionsHandle));
sslHandle.AuthOptionsHandle = authOptionsHandle;
authOptionsHandle = default; // the ownership is transferred to sslHandle, so we should not free it in the caller anymore.

if (!sslAuthenticationOptions.AllowRsaPssPadding || !sslAuthenticationOptions.AllowRsaPkcs1Padding)
{
ConfigureSignatureAlgorithms(sslHandle, sslAuthenticationOptions.AllowRsaPssPadding, sslAuthenticationOptions.AllowRsaPkcs1Padding);
}

if (sslAuthenticationOptions.ApplicationProtocols != null && sslAuthenticationOptions.ApplicationProtocols.Count != 0)
{
if (sslAuthenticationOptions.IsServer)
{
Debug.Assert(Interop.Ssl.SslGetData(sslHandle) == IntPtr.Zero);
alpnHandle = GCHandle.Alloc(sslAuthenticationOptions.ApplicationProtocols);
Interop.Ssl.SslSetData(sslHandle, GCHandle.ToIntPtr(alpnHandle));
sslHandle.AlpnHandle = alpnHandle;
}
else
if (!sslAuthenticationOptions.IsServer)
{
if (Interop.Ssl.SslSetAlpnProtos(sslHandle, sslAuthenticationOptions.ApplicationProtocols) != 0)
{
Expand All @@ -443,6 +444,9 @@ internal static SafeSslHandle AllocateSslHandle(SslAuthenticationOptions sslAuth

if (sslAuthenticationOptions.IsClient)
{
// Client side always verifies the server's certificate.
Ssl.SslSetVerifyPeer(sslHandle, failIfNoPeerCert: false);

if (!string.IsNullOrEmpty(sslAuthenticationOptions.TargetHost) && !IPAddress.IsValid(sslAuthenticationOptions.TargetHost))
{
// Similar to windows behavior, set SNI on openssl by default for client context, ignore errors.
Expand Down Expand Up @@ -474,7 +478,14 @@ internal static SafeSslHandle AllocateSslHandle(SslAuthenticationOptions sslAuth
{
if (sslAuthenticationOptions.RemoteCertRequired)
{
Ssl.SslSetVerifyPeer(sslHandle);
// When no user callback is registered, also set
// SSL_VERIFY_FAIL_IF_NO_PEER_CERT so that OpenSSL sends the
// appropriate TLS alert when the client doesn't provide a
// certificate. When a callback IS registered, the application
// may choose to accept connections without a client certificate,
// so we only set SSL_VERIFY_PEER and let managed code handle it.
bool failIfNoPeerCert = sslAuthenticationOptions.CertValidationDelegate is null;
Ssl.SslSetVerifyPeer(sslHandle, failIfNoPeerCert);
}

if (sslAuthenticationOptions.CertificateContext != null)
Expand Down Expand Up @@ -514,9 +525,9 @@ internal static SafeSslHandle AllocateSslHandle(SslAuthenticationOptions sslAuth
}
catch
{
if (alpnHandle.IsAllocated)
if (authOptionsHandle.IsAllocated)
{
alpnHandle.Free();
authOptionsHandle.Free();
}

throw;
Expand Down Expand Up @@ -694,7 +705,12 @@ internal static SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle context,
return SecurityStatusPalErrorCode.CredentialsNeeded;
}

if ((retVal != -1) || (errorCode != Ssl.SslErrorCode.SSL_ERROR_WANT_READ))
if (errorCode == Ssl.SslErrorCode.SSL_ERROR_SSL && context.CertificateValidationException is Exception ex)
{
handshakeException = ex;
context.CertificateValidationException = null;
}
else if ((retVal != -1) || (errorCode != Ssl.SslErrorCode.SSL_ERROR_WANT_READ))
{
Exception? innerError = GetSslError(retVal, errorCode);

Expand Down Expand Up @@ -731,7 +747,7 @@ internal static SecurityStatusPalErrorCode DoSslHandshake(SafeSslHandle context,

if (handshakeException != null)
{
throw handshakeException;
ExceptionDispatchInfo.Throw(handshakeException);
}

// in case of TLS 1.3 post-handshake authentication, SslDoHandhaske
Expand Down Expand Up @@ -858,18 +874,110 @@ private static void QueryUniqueChannelBinding(SafeSslHandle context, SafeChannel
bindingHandle.SetCertHashLength(certHashLength);
}

#pragma warning disable IDE0060
[UnmanagedCallersOnly]
private static int VerifyClientCertificate(int preverify_ok, IntPtr x509_ctx_ptr)
internal static Interop.Crypto.X509VerifyStatusCodeUniversal CertVerifyCallback(IntPtr ssl, IntPtr store)
{
// Full validation is handled after the handshake in VerifyCertificateProperties and the
// user callback. It's also up to those handlers to decide if a null certificate
// is appropriate. So just return success to tell OpenSSL that the cert is acceptable,
// we'll process it after the handshake finishes.
const int OpenSslSuccess = 1;
return OpenSslSuccess;
IntPtr data = Ssl.SslGetData(ssl);
Debug.Assert(data != IntPtr.Zero, "Expected non-null data pointer from SslGetData");
GCHandle gch = GCHandle.FromIntPtr(data);
SslAuthenticationOptions options = (SslAuthenticationOptions)gch.Target!;

using SafeX509StoreCtxHandle storeHandle = new(store, ownsHandle: false);

// the chain will get properly disposed inside the VerifyRemoteCertificate call
X509Chain chain = new X509Chain();
if (options.CertificateChainPolicy is not null)
{
chain.ChainPolicy = options.CertificateChainPolicy;
}
X509Certificate2? certificate = null;
SafeSslHandle sslHandle = (SafeSslHandle)options.SslStream!._securityContext!;

using (SafeSharedX509StackHandle chainStack = Interop.Crypto.X509StoreCtxGetSharedUntrusted(storeHandle))
{
if (!chainStack.IsInvalid)
{
int count = Interop.Crypto.GetX509StackFieldCount(chainStack);

for (int i = 0; i < count; i++)
{
IntPtr certPtr = Interop.Crypto.GetX509StackField(chainStack, i);

if (certPtr != IntPtr.Zero)
{
// X509Certificate2(IntPtr) calls X509_dup, so the reference is appropriately tracked.
X509Certificate2 chainCert = new X509Certificate2(certPtr);

if (certificate is null)
{
// First cert in the stack is the leaf cert.
certificate = chainCert;
}
else
{
chain.ChainPolicy.ExtraStore.Add(chainCert);
}
}
}
}
}

Debug.Assert(certificate != null, "OpenSSL only calls the callback if the certificate is present.");

SslCertificateTrust? trust = options.CertificateContext?.Trust;

ProtocolToken alertToken = default;

try
{
if (options.SslStream!.VerifyRemoteCertificate(certificate, chain, trust, ref alertToken, out SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus))
{
Comment on lines +888 to +934
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CertVerifyCallback populates chain.ChainPolicy.ExtraStore, but SslStream.VerifyRemoteCertificate will overwrite chain.ChainPolicy when options.CertificateChainPolicy is non-null, which discards the just-added certs and can break validation for callers supplying a custom chain policy. Build the chain using the same X509ChainPolicy instance that VerifyRemoteCertificate will apply (or copy the extracted certs into that policy) so the extracted chain is preserved.

Copilot uses AI. Check for mistakes.
// success
return Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_OK;
}

sslHandle.CertificateValidationException = SslStream.CreateCertificateValidationException(options, sslPolicyErrors, chainStatus);

if (options.CertValidationDelegate != null)
{
// rejected by user validation callback
return Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_APPLICATION_VERIFICATION;
}

TlsAlertMessage alert;
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
{
alert = SslStream.GetAlertMessageFromChain(chain);
}
else if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != SslPolicyErrors.None)
{
alert = TlsAlertMessage.BadCertificate;
}
else
{
alert = TlsAlertMessage.CertificateUnknown;
}

// since we can't set the alert directly, we pick one of the error verify statuses
// which will result in the same alert being sent

return alert switch
{
TlsAlertMessage.BadCertificate => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_CERT_REJECTED,
TlsAlertMessage.UnknownCA => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the end result of my two comments (that I'm leaving in the same comment-posting) are "leave the code as-is", but I'll go ahead and say both of them:

This un-mapping looks confusing

"Depth-0 self-signed cert is just one of the statuses that could have caused UnknownCA...", after which I re-read the comment above and it clicked that this is "OpenSSL has a switch of status code to TLS alert, we just need to land in the right block"

Why call GetAlertMessageFromChain at all?

X509Chain turned an X509VerifyStatusCode into an X509ChainStatus, which GetAlertMessageFromChain turns into a TlsAlertMessage which this is turning back into an X509VerifyStatusCode... so why not just avoid all of the intermediate status?

Well, X509Chain won't give you the original VerifyStatusCode, so you're already taking an ambiguous code, like NotTimeValid, and guessing that the more common reason is "expired" vs "not yet valid" (math could, of course, be done). And while it's true that GetAlertMessageFromChain is doing bit-testing when chain.ChainStatus does only one set bit per element ("So why is the enum [Flags]?!?!?!"), it does feel brittle to do something akin to return chain.ChainStatus.First().Status;

TlsAlertMessage.CertificateRevoked => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_CERT_REVOKED,
TlsAlertMessage.CertificateExpired => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_CERT_HAS_EXPIRED,
TlsAlertMessage.UnsupportedCert => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_INVALID_PURPOSE,
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new mapping from managed cert validation failures (SslPolicyErrors / chain status) to OpenSSL verify error codes (to drive specific TLS alerts) isn’t covered by tests here. Adding functional tests that assert the peer observes the expected TLS alert for common failures (expired cert, unknown CA, revoked, etc.) would help prevent regressions across OpenSSL versions.

Suggested change
TlsAlertMessage.UnsupportedCert => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_INVALID_PURPOSE,
TlsAlertMessage.UnsupportedCert => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_INVALID_PURPOSE,
TlsAlertMessage.CertificateUnknown => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_CERT_REJECTED,

Copilot uses AI. Check for mistakes.
_ => Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_CERT_REJECTED,
};
}
catch (Exception ex)
{
sslHandle.CertificateValidationException = ex;

return Interop.Crypto.X509VerifyStatusCodeUniversal.X509_V_ERR_UNSPECIFIED;
}
}
#pragma warning restore IDE0060

[UnmanagedCallersOnly]
private static unsafe int AlpnServerSelectCallback(IntPtr ssl, byte** outp, byte* outlen, byte* inp, uint inlen, IntPtr arg)
Expand All @@ -883,8 +991,8 @@ private static unsafe int AlpnServerSelectCallback(IntPtr ssl, byte** outp, byte
return Ssl.SSL_TLSEXT_ERR_ALERT_FATAL;
}

GCHandle protocolHandle = GCHandle.FromIntPtr(sslData);
if (!(protocolHandle.Target is List<SslApplicationProtocol> protocolList))
GCHandle authOptionsHandle = GCHandle.FromIntPtr(sslData);
if (!((authOptionsHandle.Target as SslAuthenticationOptions)?.ApplicationProtocols is List<SslApplicationProtocol> protocolList))
{
return Ssl.SSL_TLSEXT_ERR_ALERT_FATAL;
}
Expand All @@ -911,17 +1019,9 @@ private static unsafe int AlpnServerSelectCallback(IntPtr ssl, byte** outp, byte
}
catch
{
// No common application protocol was negotiated, set the target on the alpnHandle to null.
// It is ok to clear the handle value here, this results in handshake failure, so the SslStream object is disposed.
protocolHandle.Target = null;

return Ssl.SSL_TLSEXT_ERR_ALERT_FATAL;
}

// No common application protocol was negotiated, set the target on the alpnHandle to null.
// It is ok to clear the handle value here, this results in handshake failure, so the SslStream object is disposed.
protocolHandle.Target = null;

// No common application protocol was negotiated
return Ssl.SSL_TLSEXT_ERR_ALERT_FATAL;
}

Expand Down Expand Up @@ -955,20 +1055,28 @@ private static unsafe int NewSessionCallback(IntPtr ssl, IntPtr session)

Interop.Ssl.SslSessionSetData(session, cert);

IntPtr ptr = Ssl.SslGetData(ssl);
IntPtr ctx = Ssl.SslGetSslCtx(ssl);
IntPtr ptr = Ssl.SslCtxGetData(ctx);
// while SSL_CTX is kept alive by reference from SSL, the same is not true
// for the stored GCHandle pointing to SafeSslContextHandle. The managed
// SafeSslContextHandle may have been disposed and its GCHandle freed, in
// which case SslCtxGetData returns IntPtr.Zero and no GCHandle should be
// reconstructed.
if (ptr != IntPtr.Zero)
{
GCHandle gch = GCHandle.FromIntPtr(ptr);
byte* name = Ssl.SslGetServerName(ssl);
Debug.Assert(name != null);

SafeSslContextHandle? ctxHandle = gch.Target as SafeSslContextHandle;
// There is no relation between SafeSslContextHandle and SafeSslHandle so the handle
// may be released while the ssl session is still active.
if (ctxHandle != null && ctxHandle.TryAddSession(name, session))

if (ctxHandle != null)
{
// offered session was stored in our cache.
return 1;
if (ctxHandle.TryAddSession(name, session))
{
// offered session was stored in our cache.
return 1;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ internal static SafeSharedX509StackHandle SslGetPeerCertChain(SafeSslHandle ssl)
internal static unsafe partial bool SslSetCiphers(SafeSslHandle ssl, byte* cipherList, byte* cipherSuites);

[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslSetVerifyPeer")]
internal static partial void SslSetVerifyPeer(SafeSslHandle ssl);
internal static partial void SslSetVerifyPeer(SafeSslHandle ssl, [MarshalAs(UnmanagedType.Bool)] bool failIfNoPeerCert);

[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslGetData")]
internal static partial IntPtr SslGetData(IntPtr ssl);
Expand Down Expand Up @@ -235,6 +235,9 @@ internal static SafeSharedX509StackHandle SslGetPeerCertChain(SafeSslHandle ssl)
[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslSessionSetData")]
internal static partial void SslSessionSetData(IntPtr session, IntPtr val);

[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslGetSslCtx")]
internal static partial IntPtr SslGetSslCtx(IntPtr ssl);

internal static class Capabilities
{
// needs separate type (separate static cctor) to be sure OpenSSL is initialized.
Expand Down Expand Up @@ -394,11 +397,15 @@ internal sealed class SafeSslHandle : SafeDeleteSslContext
private bool _isServer;
private bool _handshakeCompleted;

public GCHandle AlpnHandle;
public GCHandle AuthOptionsHandle;
// Reference to the parent SSL_CTX handle in the SSL_CTX is being cached. Only used for
// refcount management.
public SafeSslContextHandle? SslContextHandle;

// Storage for the exception that occurred during certificate validation callback so that
// we may rethrow it after returning to managed code.
public Exception? CertificateValidationException;

public bool IsServer
{
get { return _isServer; }
Expand Down Expand Up @@ -492,10 +499,10 @@ protected override bool ReleaseHandle()

SslContextHandle?.Dispose();

if (AlpnHandle.IsAllocated)
if (AuthOptionsHandle.IsAllocated)
{
Interop.Ssl.SslSetData(handle, IntPtr.Zero);
AlpnHandle.Free();
AuthOptionsHandle.Free();
}

IntPtr h = handle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ internal static bool AddExtraChainCertificates(SafeSslContextHandle ctx, ReadOnl

return true;
}

[LibraryImport(Libraries.CryptoNative, EntryPoint = "CryptoNative_SslCtxSetCertVerifyCallback")]
internal static unsafe partial void SslCtxSetCertVerifyCallback(SafeSslContextHandle ctx, delegate* unmanaged<IntPtr, IntPtr, Interop.Crypto.X509VerifyStatusCodeUniversal> callback);
}
}

Expand Down Expand Up @@ -254,11 +257,6 @@ internal bool TrySetSession(SafeSslHandle sslHandle, string name)
return false;
}

// even if we don't have matching session, we can get new one and we need
// way how to link SSL back to `this`.
Debug.Assert(Interop.Ssl.SslGetData(sslHandle) == IntPtr.Zero);
Interop.Ssl.SslSetData(sslHandle, (IntPtr)_gch);

lock (_sslSessions)
{
if (_sslSessions.TryGetValue(name, out IntPtr session))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ public async Task SendMailAsync_CanBeCanceled_CancellationToken()

// The server will introduce some fake latency so that the operation can be canceled before the request completes
CancellationTokenSource cts = new CancellationTokenSource();

server.OnConnected += _ => cts.Cancel();

var message = new MailMessage("foo@internet.com", "bar@internet.com", "Foo", "Bar");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ internal void SetCertificateContextFromCert(X509Certificate2 certificate, bool?
internal SslStream.JavaProxy? SslStreamProxy { get; set; }
#endif

#if !TARGET_WINDOWS && !SYSNETSECURITY_NO_OPENSSL
internal SslStream? SslStream { get; set; }
#endif

public void Dispose()
{
if (OwnsCertificateContext && CertificateContext != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate()
{
ProtocolToken alertToken = default;
var isValid = VerifyRemoteCertificate(
_sslAuthenticationOptions.CertValidationDelegate,
_sslAuthenticationOptions.CertificateContext?.Trust,
ref alertToken,
out SslPolicyErrors sslPolicyErrors,
Expand Down
Loading
Loading