From 0172194918a274adfe9b7dd3b55481821b9eb0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C4=B0brahim=20Aksoy?= Date: Mon, 27 Apr 2026 15:48:44 +0200 Subject: [PATCH 1/7] Support certificate validation alerts on macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Interop.Ssl.cs | 25 +++ .../System/Net/Security/SslStream.Protocol.cs | 38 +++- .../Net/Security/SslStreamPal.Android.cs | 5 + .../System/Net/Security/SslStreamPal.OSX.cs | 34 ++- .../System/Net/Security/SslStreamPal.Unix.cs | 5 + .../Net/Security/SslStreamPal.Windows.cs | 5 + .../src/System/Net/Security/TlsFrameHelper.cs | 2 +- .../src/System/Net/SecurityStatusPal.cs | 1 + .../FunctionalTests/SslStreamAlertsTest.cs | 209 ++++++++++++++++++ .../UnitTests/TlsAlertsMatchWindowsInterop.cs | 20 ++ .../entrypoints.c | 1 + .../pal_ssl.c | 83 +++++++ .../pal_ssl.h | 45 ++++ 13 files changed, 459 insertions(+), 14 deletions(-) diff --git a/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs b/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs index 4d6fb38093c4c2..614d28eb345f85 100644 --- a/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs +++ b/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs @@ -135,6 +135,12 @@ private static partial int AppleCryptoNative_SslSetTargetName( [LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_SslHandshake")] internal static partial PAL_TlsHandshakeState SslHandshake(SafeSslHandle sslHandle); + [LibraryImport(Interop.Libraries.AppleCryptoNative)] + private static partial int AppleCryptoNative_SslSetError( + SafeSslHandle sslHandle, + TlsAlertMessage alertMessage, + out int pOSStatus); + [LibraryImport(Interop.Libraries.AppleCryptoNative)] private static partial int AppleCryptoNative_SslSetAcceptClientCert(SafeSslHandle sslHandle); @@ -328,6 +334,25 @@ internal static void SslBreakOnCertRequested(SafeSslHandle sslHandle, bool setBr throw new SslException(); } + internal static void SslSetError(SafeSslHandle sslHandle, TlsAlertMessage alertMessage) + { + int osStatus; + int result = AppleCryptoNative_SslSetError(sslHandle, alertMessage, out osStatus); + + if (result == 1) + { + return; + } + + if (result == 0) + { + throw CreateExceptionForOSStatus(osStatus); + } + + Debug.Fail($"AppleCryptoNative_SslSetError returned {result}"); + throw new SslException(); + } + internal static void SslSetCertificate(SafeSslHandle sslHandle, ReadOnlySpan certChainPtrs) { using (SafeCreateHandle cfCertRefs = CoreFoundation.CFArrayCreate(certChainPtrs)) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index 51f673f2111edc..1f6160f8a9d10c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -897,6 +897,13 @@ private ProtocolToken GenerateToken(ReadOnlySpan inputBuffer, out int cons _sslAuthenticationOptions); } } + +#if TARGET_APPLE + if (token.Status.ErrorCode == SecurityStatusPalErrorCode.CertValidationNeeded) + { + token = VerifyRemoteCertificateAndGenerateNextToken(token); + } +#endif } while (cachedCreds && _credentialsHandle == null); } finally @@ -933,6 +940,24 @@ private ProtocolToken GenerateToken(ReadOnlySpan inputBuffer, out int cons return token; } +#if TARGET_APPLE + private ProtocolToken VerifyRemoteCertificateAndGenerateNextToken(ProtocolToken token) + { + token.ReleasePayload(); + + ProtocolToken alertToken = default; + + if (!VerifyRemoteCertificate(_sslAuthenticationOptions.CertificateContext?.Trust, ref alertToken, out SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus)) + { + _handshakeCompleted = false; + alertToken.Status = new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError, CreateCertificateValidationException(_sslAuthenticationOptions, sslPolicyErrors, chainStatus)); + return alertToken; + } + + return GenerateToken(ReadOnlySpan.Empty, out _); + } +#endif + internal ProtocolToken Renegotiate() { Debug.Assert(_securityContext != null); @@ -1173,7 +1198,7 @@ internal bool VerifyRemoteCertificate( if (!success) { #pragma warning disable CS0162 // unreachable code detected (compile time const) - if (SslStreamPal.CanGenerateCustomAlerts && !SslStreamPal.CertValidationInCallback) + if (SslStreamPal.CanGenerateCustomAlertsForContext(_securityContext) && !SslStreamPal.CertValidationInCallback) { CreateFatalHandshakeAlertToken(sslPolicyErrors, chain!, ref alertToken); } @@ -1227,6 +1252,17 @@ private void CreateFatalHandshakeAlertToken(SslPolicyErrors sslPolicyErrors, X50 } } +#if TARGET_APPLE + if (_lastFrame.Header.Version != SslProtocols.Tls13) + { + byte[] alertFrame = TlsFrameHelper.CreateAlertFrame(_lastFrame.Header.Version, (TlsAlertDescription)alertMessage); + if (alertFrame.Length != 0) + { + alertToken.SetPayload(alertFrame); + return; + } + } +#endif alertToken = GenerateAlertToken(); } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs index 68df93d01217cd..7a0f4bbcc01aac 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs @@ -28,6 +28,11 @@ public static Exception GetException(SecurityStatusPal status) // due to handshake failures. internal const bool CanGenerateCustomAlerts = true; + internal static bool CanGenerateCustomAlertsForContext(SafeDeleteContext? _) + { + return CanGenerateCustomAlerts; + } + public static void VerifyPackageInfo() { } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs index 7aff801dab8260..47f6419cc0ac28 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs @@ -32,7 +32,12 @@ public static Exception GetException(SecurityStatusPal status) // Since ST is not producing the framed empty message just call this false and avoid the // special case of an empty array being passed to the `fixed` statement. internal const bool CanEncryptEmptyMessage = false; - internal const bool CanGenerateCustomAlerts = false; + internal const bool CanGenerateCustomAlerts = true; + + internal static bool CanGenerateCustomAlertsForContext(SafeDeleteContext? securityContext) + { + return securityContext is SafeDeleteSslContext; + } public static void VerifyPackageInfo() { @@ -381,13 +386,7 @@ private static SecurityStatusPal PerformHandshake(SafeSslHandle sslHandle) return new SecurityStatusPal(SecurityStatusPalErrorCode.ContinueNeeded); case PAL_TlsHandshakeState.ServerAuthCompleted: case PAL_TlsHandshakeState.ClientAuthCompleted: - // The standard flow would be to call the verification callback now, and - // possibly abort. But the library is set up to call this "success" and - // do verification between "handshake complete" and "first send/receive". - // - // So, call SslHandshake again to indicate to Secure Transport that we've - // accepted this handshake and it should go into the ready state. - break; + return new SecurityStatusPal(SecurityStatusPalErrorCode.CertValidationNeeded); case PAL_TlsHandshakeState.ClientCertRequested: return new SecurityStatusPal(SecurityStatusPalErrorCode.CredentialsNeeded); case PAL_TlsHandshakeState.ClientHelloReceived: @@ -406,11 +405,22 @@ public static SecurityStatusPal ApplyAlertToken( TlsAlertType alertType, TlsAlertMessage alertMessage) { - // There doesn't seem to be an exposed API for writing an alert, - // the API seems to assume that all alerts are generated internally by - // SSLHandshake. Debug.Assert(CanGenerateCustomAlerts); - return new SecurityStatusPal(SecurityStatusPalErrorCode.OK); + + if (securityContext is not SafeDeleteSslContext context) + { + return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError); + } + + try + { + Interop.AppleCrypto.SslSetError(context.SslContext, alertMessage); + return new SecurityStatusPal(SecurityStatusPalErrorCode.OK); + } + catch (Exception ex) + { + return new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError, ex); + } } #pragma warning restore IDE0060 diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs index 77c06a6dac022b..b5f8d3596dc4dc 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Unix.cs @@ -22,6 +22,11 @@ public static Exception GetException(SecurityStatusPal status) internal const bool CanEncryptEmptyMessage = false; internal const bool CanGenerateCustomAlerts = false; + internal static bool CanGenerateCustomAlertsForContext(SafeDeleteContext? _) + { + return CanGenerateCustomAlerts; + } + public static void VerifyPackageInfo() { } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs index 0059c83b1f8fb3..d7968c3f7cc142 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Windows.cs @@ -57,6 +57,11 @@ public static Exception GetException(SecurityStatusPal status) internal const bool CanEncryptEmptyMessage = true; internal const bool CanGenerateCustomAlerts = true; + internal static bool CanGenerateCustomAlertsForContext(SafeDeleteContext? _) + { + return CanGenerateCustomAlerts; + } + private static readonly byte[] s_sessionTokenBuffer = InitSessionTokenBuffer(); private static byte[] InitSessionTokenBuffer() diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsFrameHelper.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsFrameHelper.cs index 67f83c1eeaa1e0..14c87494432c3c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsFrameHelper.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsFrameHelper.cs @@ -336,7 +336,7 @@ public static byte[] CreateAlertFrame(SslProtocols version, TlsAlertDescription return CreateProtocolVersionAlert(version); } #pragma warning disable SYSLIB0039 // TLS 1.0 and 1.1 are obsolete - else if ((int)version > (int)SslProtocols.Tls) + else if ((int)version >= (int)SslProtocols.Tls) #pragma warning restore SYSLIB0039 { // Create TLS1.2 alert diff --git a/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs b/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs index f4d79d578c8f2c..519dcc33d9c96b 100644 --- a/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs +++ b/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs @@ -34,6 +34,7 @@ internal enum SecurityStatusPalErrorCode Renegotiate, TryAgain, HandshakeStarted, + CertValidationNeeded, // Errors OutOfMemory, diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs index 57f1794af59af9..462062b24a5232 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Net.Test.Common; @@ -269,6 +270,41 @@ public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols pro } } + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + public async Task SslStream_NoCallback_UntrustedCert_SendsUnknownCAAlert_OSX() + { + X509Certificate2 cert = Configuration.Certificates.GetSelfSignedServerCertificate(); + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (RecordingReadStream recordingServerStream = new RecordingReadStream(serverStream)) + using (SslStream client = new SslStream(clientStream)) + using (SslStream server = new SslStream(recordingServerStream)) + using (cert) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = cert, + EnabledSslProtocols = SslProtocols.Tls12, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = cert.GetNameInfo(X509NameType.DnsName, false), + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + EnabledSslProtocols = SslProtocols.Tls12, + }; + + Task serverTask = server.AuthenticateAsServerAsync(serverOptions, CancellationToken.None); + Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); + + await Assert.ThrowsAsync(() => clientTask).WaitAsync(TestConfiguration.PassingTestTimeout); + await ObservePeerAlertAsync(serverTask, server); + + Assert.True(recordingServerStream.ContainsAlert(TlsAlertDescription.UnknownCA), recordingServerStream.GetRecordedAlerts()); + } + } + [Theory] [ClassData(typeof(SslProtocolSupport.SupportedSslProtocolsTestData))] [ActiveIssue("https://github.com/dotnet/runtime/issues/18837", ~(TestPlatforms.Windows | TestPlatforms.Linux))] @@ -345,6 +381,46 @@ public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslP } } + [Fact] + [PlatformSpecific(TestPlatforms.OSX)] + public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsUnknownCAAlert_OSX() + { + X509Certificate2 serverCert = Configuration.Certificates.GetSelfSignedServerCertificate(); + X509Certificate2 clientCert = Configuration.Certificates.GetSelfSignedClientCertificate(); + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (RecordingReadStream recordingClientStream = new RecordingReadStream(clientStream)) + using (serverStream) + using (SslStream client = new SslStream(recordingClientStream)) + using (SslStream server = new SslStream(serverStream)) + using (serverCert) + using (clientCert) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCert, + ClientCertificateRequired = true, + EnabledSslProtocols = SslProtocols.Tls12, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = serverCert.GetNameInfo(X509NameType.DnsName, false), + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + RemoteCertificateValidationCallback = delegate { return true; }, + ClientCertificates = new X509CertificateCollection { clientCert }, + EnabledSslProtocols = SslProtocols.Tls12, + }; + + Task serverTask = server.AuthenticateAsServerAsync(serverOptions, CancellationToken.None); + Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); + + await Assert.ThrowsAsync(() => serverTask).WaitAsync(TestConfiguration.PassingTestTimeout); + await ObservePeerAlertAsync(clientTask, client); + + Assert.True(recordingClientStream.ContainsAlert(TlsAlertDescription.UnknownCA), recordingClientStream.GetRecordedAlerts()); + } + } + private bool FailClientCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { return false; @@ -358,5 +434,138 @@ private bool AllowAnyServerCertificate( { return true; } + + private static async Task ObservePeerAlertAsync(Task handshakeTask, SslStream stream) + { + try + { + await handshakeTask.WaitAsync(TestConfiguration.PassingTestTimeout); + byte[] buffer = new byte[1]; + await stream.ReadAsync(buffer).AsTask().WaitAsync(TestConfiguration.PassingTestTimeout); + } + catch (Exception ex) when (ex is AuthenticationException or IOException or TimeoutException) + { + } + } + + private sealed class RecordingReadStream : DelegatingStream + { + private readonly List _readBytes = new List(); + + public RecordingReadStream(Stream innerStream) + : base(innerStream) + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + int bytesRead = base.Read(buffer, offset, count); + Record(new ReadOnlySpan(buffer, offset, bytesRead)); + return bytesRead; + } + + public override int Read(Span buffer) + { + int bytesRead = base.Read(buffer); + Record(buffer.Slice(0, bytesRead)); + return bytesRead; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + int bytesRead = await base.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + Record(new ReadOnlySpan(buffer, offset, bytesRead)); + return bytesRead; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int bytesRead = await base.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + Record(buffer.Span.Slice(0, bytesRead)); + return bytesRead; + } + + public bool ContainsAlert(TlsAlertDescription expectedDescription) + { + ReadOnlySpan remaining = GetRecordedBytes(); + while (remaining.Length >= TlsFrameHelper.HeaderSize) + { + TlsFrameHeader header = default; + if (!TlsFrameHelper.TryGetFrameHeader(remaining, ref header) || + header.Length <= 0 || + header.Length > remaining.Length) + { + return false; + } + + ReadOnlySpan frame = remaining.Slice(0, header.Length); + if (header.Type == TlsContentType.Alert) + { + TlsAlertLevel level = default; + TlsAlertDescription description = default; + if (TlsFrameHelper.TryGetAlertInfo(frame, ref level, ref description) && + level == TlsAlertLevel.Fatal && + description == expectedDescription) + { + return true; + } + } + + remaining = remaining.Slice(header.Length); + } + + return false; + } + + public string GetRecordedAlerts() + { + List alerts = new List(); + ReadOnlySpan remaining = GetRecordedBytes(); + while (remaining.Length >= TlsFrameHelper.HeaderSize) + { + TlsFrameHeader header = default; + if (!TlsFrameHelper.TryGetFrameHeader(remaining, ref header) || + header.Length <= 0 || + header.Length > remaining.Length) + { + break; + } + + ReadOnlySpan frame = remaining.Slice(0, header.Length); + if (header.Type == TlsContentType.Alert) + { + TlsAlertLevel level = default; + TlsAlertDescription description = default; + if (TlsFrameHelper.TryGetAlertInfo(frame, ref level, ref description)) + { + alerts.Add($"{level}:{description}"); + } + } + + remaining = remaining.Slice(header.Length); + } + + return alerts.Count == 0 ? "No TLS alerts recorded." : "Recorded TLS alerts: " + string.Join(", ", alerts); + } + + private void Record(ReadOnlySpan bytes) + { + lock (_readBytes) + { + foreach (byte b in bytes) + { + _readBytes.Add(b); + } + } + } + + private byte[] GetRecordedBytes() + { + lock (_readBytes) + { + return _readBytes.ToArray(); + } + } + } } } diff --git a/src/libraries/System.Net.Security/tests/UnitTests/TlsAlertsMatchWindowsInterop.cs b/src/libraries/System.Net.Security/tests/UnitTests/TlsAlertsMatchWindowsInterop.cs index 3627b6aa9a6ee6..43ef4ab7999687 100644 --- a/src/libraries/System.Net.Security/tests/UnitTests/TlsAlertsMatchWindowsInterop.cs +++ b/src/libraries/System.Net.Security/tests/UnitTests/TlsAlertsMatchWindowsInterop.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; +using System.Security.Authentication; using Xunit; namespace System.Net.Security.Tests @@ -38,5 +40,23 @@ public void TlsAlertEnums_MatchWindowsInterop_Ok() Assert.Equal((int)TlsAlertMessage.NoRenegotiation, Interop.SChannel.TLS1_ALERT_NO_RENEGOTIATION); Assert.Equal((int)TlsAlertMessage.UnsupportedExtension, Interop.SChannel.TLS1_ALERT_UNSUPPORTED_EXT); } + + [Theory] + [MemberData(nameof(AlertFrameData))] + public void CreateAlertFrame_NonProtocolAlert_UsesRequestedVersion(SslProtocols protocol, byte minorVersion) + { + byte[] frame = TlsFrameHelper.CreateAlertFrame(protocol, TlsAlertDescription.UnknownCA); + + Assert.Equal(new byte[] { (byte)TlsContentType.Alert, 3, minorVersion, 0, 2, (byte)TlsAlertLevel.Fatal, (byte)TlsAlertDescription.UnknownCA }, frame); + } + + public static IEnumerable AlertFrameData() + { +#pragma warning disable SYSLIB0039 + yield return new object[] { SslProtocols.Tls, (byte)1 }; + yield return new object[] { SslProtocols.Tls11, (byte)2 }; +#pragma warning restore SYSLIB0039 + yield return new object[] { SslProtocols.Tls12, (byte)3 }; + } } } diff --git a/src/native/libs/System.Security.Cryptography.Native.Apple/entrypoints.c b/src/native/libs/System.Security.Cryptography.Native.Apple/entrypoints.c index 56e8762cdc4fc3..d126d6580e6c07 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Apple/entrypoints.c +++ b/src/native/libs/System.Security.Cryptography.Native.Apple/entrypoints.c @@ -103,6 +103,7 @@ static const Entry s_cryptoAppleNative[] = DllImportEntry(AppleCryptoNative_SSLSetALPNProtocols) DllImportEntry(AppleCryptoNative_SslGetAlpnSelected) DllImportEntry(AppleCryptoNative_SslHandshake) + DllImportEntry(AppleCryptoNative_SslSetError) DllImportEntry(AppleCryptoNative_SslShutdown) DllImportEntry(AppleCryptoNative_SslGetProtocolVersion) DllImportEntry(AppleCryptoNative_SslGetCipherSuite) diff --git a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c index a296d6b63097ad..88898efc9b7b64 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c +++ b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c @@ -338,6 +338,89 @@ PAL_TlsHandshakeState AppleCryptoNative_SslHandshake(SSLContextRef sslContext) } } +static OSStatus PalTlsAlertMessageToSslStatus(PAL_TlsAlertMessage alertMessage) +{ + switch (alertMessage) + { + case PAL_TlsAlertMessage_UnexpectedMessage: + return errSSLUnexpectedMessage; + case PAL_TlsAlertMessage_BadRecordMac: + return errSSLBadRecordMac; + case PAL_TlsAlertMessage_DecryptionFailed: + return errSSLDecryptionFail; + case PAL_TlsAlertMessage_RecordOverflow: + return errSSLRecordOverflow; + case PAL_TlsAlertMessage_DecompressionFail: + return errSSLDecompressFail; + case PAL_TlsAlertMessage_HandshakeFailure: + return errSSLHandshakeFail; + case PAL_TlsAlertMessage_BadCertificate: + return errSSLBadCert; + case PAL_TlsAlertMessage_UnsupportedCert: + return errSSLBadCert; + case PAL_TlsAlertMessage_CertificateRevoked: + return errSSLXCertChainInvalid; + case PAL_TlsAlertMessage_CertificateExpired: + return errSSLCertExpired; + case PAL_TlsAlertMessage_CertificateUnknown: + return errSSLXCertChainInvalid; + case PAL_TlsAlertMessage_IllegalParameter: + return errSSLIllegalParam; + case PAL_TlsAlertMessage_UnknownCA: + return errSSLUnknownRootCert; + case PAL_TlsAlertMessage_AccessDenied: + return errSSLPeerAccessDenied; + case PAL_TlsAlertMessage_DecodeError: + return errSSLDecodeError; + case PAL_TlsAlertMessage_DecryptError: + return errSSLPeerDecryptError; + case PAL_TlsAlertMessage_ExportRestriction: + return errSSLPeerExportRestriction; + case PAL_TlsAlertMessage_ProtocolVersion: + return errSSLPeerProtocolVersion; + case PAL_TlsAlertMessage_InsufficientSecurity: + return errSSLPeerInsufficientSecurity; + case PAL_TlsAlertMessage_InternalError: + return errSSLPeerInternalError; + case PAL_TlsAlertMessage_InappropriateFallback: + return errSSLInappropriateFallback; + case PAL_TlsAlertMessage_UserCanceled: + return errSSLPeerUserCancelled; + case PAL_TlsAlertMessage_NoRenegotiation: + return errSSLPeerNoRenegotiation; + case PAL_TlsAlertMessage_MissingExtension: + return errSSLMissingExtension; + case PAL_TlsAlertMessage_UnsupportedExtension: + return errSSLUnsupportedExtension; + case PAL_TlsAlertMessage_UnrecognizedName: + return errSSLUnrecognizedName; + case PAL_TlsAlertMessage_BadCertificateStatusResponse: + return errSSLBadCertificateStatusResponse; + case PAL_TlsAlertMessage_UnknownPskIdentity: + return errSSLUnknownPSKIdentity; + case PAL_TlsAlertMessage_CertificateRequired: + return errSSLCertificateRequired; + default: + return errSSLPeerInternalError; + } +} + +int32_t AppleCryptoNative_SslSetError(SSLContextRef sslContext, PAL_TlsAlertMessage alertMessage, int32_t* pOSStatus) +{ + if (pOSStatus != NULL) + *pOSStatus = noErr; + + if (sslContext == NULL || pOSStatus == NULL) + return -1; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + *pOSStatus = SSLSetError(sslContext, PalTlsAlertMessageToSslStatus(alertMessage)); +#pragma clang diagnostic pop + + return *pOSStatus == noErr; +} + static PAL_TlsIo OSStatusToPAL_TlsIo(OSStatus status) { switch (status) diff --git a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h index cc492417665f87..6413674d664124 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h +++ b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h @@ -30,6 +30,40 @@ enum }; typedef int32_t PAL_TlsIo; +enum +{ + PAL_TlsAlertMessage_UnexpectedMessage = 10, + PAL_TlsAlertMessage_BadRecordMac = 20, + PAL_TlsAlertMessage_DecryptionFailed = 21, + PAL_TlsAlertMessage_RecordOverflow = 22, + PAL_TlsAlertMessage_DecompressionFail = 30, + PAL_TlsAlertMessage_HandshakeFailure = 40, + PAL_TlsAlertMessage_BadCertificate = 42, + PAL_TlsAlertMessage_UnsupportedCert = 43, + PAL_TlsAlertMessage_CertificateRevoked = 44, + PAL_TlsAlertMessage_CertificateExpired = 45, + PAL_TlsAlertMessage_CertificateUnknown = 46, + PAL_TlsAlertMessage_IllegalParameter = 47, + PAL_TlsAlertMessage_UnknownCA = 48, + PAL_TlsAlertMessage_AccessDenied = 49, + PAL_TlsAlertMessage_DecodeError = 50, + PAL_TlsAlertMessage_DecryptError = 51, + PAL_TlsAlertMessage_ExportRestriction = 60, + PAL_TlsAlertMessage_ProtocolVersion = 70, + PAL_TlsAlertMessage_InsufficientSecurity = 71, + PAL_TlsAlertMessage_InternalError = 80, + PAL_TlsAlertMessage_InappropriateFallback = 86, + PAL_TlsAlertMessage_UserCanceled = 90, + PAL_TlsAlertMessage_NoRenegotiation = 100, + PAL_TlsAlertMessage_MissingExtension = 109, + PAL_TlsAlertMessage_UnsupportedExtension = 110, + PAL_TlsAlertMessage_UnrecognizedName = 112, + PAL_TlsAlertMessage_BadCertificateStatusResponse = 113, + PAL_TlsAlertMessage_UnknownPskIdentity = 115, + PAL_TlsAlertMessage_CertificateRequired = 116, +}; +typedef int32_t PAL_TlsAlertMessage; + /* Create an SSL context, for the Server or Client role as determined by isServer. @@ -199,6 +233,17 @@ Returns an indication of what state the error is in. Any negative number means a */ PALEXPORT PAL_TlsHandshakeState AppleCryptoNative_SslHandshake(SSLContextRef sslContext); +/* +Set the TLS alert to send when the handshake is aborted. + +Returns 1 on success, 0 on failure, other values for invalid state. + +Output: +pOSStatus: Receives the value returned by SSLSetError. +*/ +PALEXPORT int32_t +AppleCryptoNative_SslSetError(SSLContextRef sslContext, PAL_TlsAlertMessage alertMessage, int32_t* pOSStatus); + /* Take bufLen bytes of cleartext data from buf and encrypt/frame the data. Processed data will then be sent into the write callback. From 6dba9f34c528694a3a4b661d1be2e3637dcc291f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C4=B0brahim=20Aksoy?= Date: Mon, 27 Apr 2026 16:54:52 +0200 Subject: [PATCH 2/7] Gate macOS alert fallback by TLS implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/Security/SslStream.Protocol.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index 1f6160f8a9d10c..2061461ea145b8 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -1253,7 +1253,7 @@ private void CreateFatalHandshakeAlertToken(SslPolicyErrors sslPolicyErrors, X50 } #if TARGET_APPLE - if (_lastFrame.Header.Version != SslProtocols.Tls13) + if (_securityContext is not null && !SslStreamPal.IsAsyncSecurityContext(_securityContext)) { byte[] alertFrame = TlsFrameHelper.CreateAlertFrame(_lastFrame.Header.Version, (TlsAlertDescription)alertMessage); if (alertFrame.Length != 0) From 4ab4b6fbef50b312595098b7673bd4bdcda9eac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C4=B0brahim=20Aksoy?= Date: Fri, 15 May 2026 09:52:56 +0300 Subject: [PATCH 3/7] Preserve cross-platform exception parity for macOS cert alerts The Apple cert-validation path introduced by the previous commits stored the cert exception in alertToken.Status with InternalError, which caused the IO loop to wrap it as AuthenticationException(net_auth_SSPI, inner), diverging from the Windows/Linux/Android behavior where SendAuthResetSignal throws the cert exception directly. Fix by introducing SecurityStatusPalErrorCode.CertValidationFailed and having ForceAuthenticationAsync rethrow the inner exception via ExceptionDispatchInfo when this code is observed, mirroring the SendAuthResetSignal pattern. Also: - Drop redundant _handshakeCompleted = false (field is always false at this point since CompleteHandshake has not run yet). - Assert alertType == Fatal in SslStreamPal.OSX.ApplyAlertToken to document that SecureTransport only emits fatal alerts via SslSetError. - Strengthen the two new OSX cert-alert tests with Assert.Null on InnerException so any future regression of the parity gets caught. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/Security/SslStream.IO.cs | 8 ++++++++ .../System/Net/Security/SslStream.Protocol.cs | 3 +-- .../src/System/Net/Security/SslStreamPal.OSX.cs | 1 + .../src/System/Net/SecurityStatusPal.cs | 3 ++- .../tests/FunctionalTests/SslStreamAlertsTest.cs | 16 ++++++++++++++-- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs index a8faaae4fa9cbb..64084edfd3bce7 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs @@ -392,6 +392,14 @@ private async Task ForceAuthenticationAsync(bool receiveFirst, byte[ throw new AuthenticationException(SR.Format(SR.net_auth_tls_alert, _lastFrame.AlertDescription.ToString()), token.GetException()); } + if (token.Status.ErrorCode == SecurityStatusPalErrorCode.CertValidationFailed && token.GetException() is Exception certException) + { + // The cert-validation path carries the user-visible exception directly; + // throw it without wrapping to preserve parity with the SendAuthResetSignal + // path used after handshake completion on all platforms. + ExceptionDispatchInfo.Throw(certException); + } + throw new AuthenticationException(SR.net_auth_SSPI, token.GetException()); } else if (token.Status.ErrorCode == SecurityStatusPalErrorCode.OK) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index 2061461ea145b8..674c449805832f 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -949,8 +949,7 @@ private ProtocolToken VerifyRemoteCertificateAndGenerateNextToken(ProtocolToken if (!VerifyRemoteCertificate(_sslAuthenticationOptions.CertificateContext?.Trust, ref alertToken, out SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus)) { - _handshakeCompleted = false; - alertToken.Status = new SecurityStatusPal(SecurityStatusPalErrorCode.InternalError, CreateCertificateValidationException(_sslAuthenticationOptions, sslPolicyErrors, chainStatus)); + alertToken.Status = new SecurityStatusPal(SecurityStatusPalErrorCode.CertValidationFailed, CreateCertificateValidationException(_sslAuthenticationOptions, sslPolicyErrors, chainStatus)); return alertToken; } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs index 47f6419cc0ac28..4ae784aaf5fbfe 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.OSX.cs @@ -406,6 +406,7 @@ public static SecurityStatusPal ApplyAlertToken( TlsAlertMessage alertMessage) { Debug.Assert(CanGenerateCustomAlerts); + Debug.Assert(alertType == TlsAlertType.Fatal, $"SecureTransport derives the alert level from the OSStatus and emits only fatal alerts; unexpected alertType: {alertType}"); if (securityContext is not SafeDeleteSslContext context) { diff --git a/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs b/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs index 519dcc33d9c96b..8bbae21e85ff3f 100644 --- a/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs +++ b/src/libraries/System.Net.Security/src/System/Net/SecurityStatusPal.cs @@ -74,6 +74,7 @@ internal enum SecurityStatusPalErrorCode ApplicationProtocolMismatch, NoRenegotiation, KeySetDoesNotExist, - ContextExpiredError + ContextExpiredError, + CertValidationFailed } } diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs index 462062b24a5232..ba2a4103a3a488 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs @@ -298,10 +298,16 @@ public async Task SslStream_NoCallback_UntrustedCert_SendsUnknownCAAlert_OSX() Task serverTask = server.AuthenticateAsServerAsync(serverOptions, CancellationToken.None); Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); - await Assert.ThrowsAsync(() => clientTask).WaitAsync(TestConfiguration.PassingTestTimeout); + AuthenticationException clientException = await Assert.ThrowsAsync(() => clientTask).WaitAsync(TestConfiguration.PassingTestTimeout); await ObservePeerAlertAsync(serverTask, server); Assert.True(recordingServerStream.ContainsAlert(TlsAlertDescription.UnknownCA), recordingServerStream.GetRecordedAlerts()); + + // Cross-platform parity: the cert validation exception should surface directly + // to the caller (matching Windows/Linux/Android behavior via SendAuthResetSignal), + // not wrapped in a generic AuthenticationException(SR.net_auth_SSPI, ...). + Assert.Null(clientException.InnerException); + Assert.Contains("certificate", clientException.Message, StringComparison.OrdinalIgnoreCase); } } @@ -414,10 +420,16 @@ public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsUnknownCAA Task serverTask = server.AuthenticateAsServerAsync(serverOptions, CancellationToken.None); Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); - await Assert.ThrowsAsync(() => serverTask).WaitAsync(TestConfiguration.PassingTestTimeout); + AuthenticationException serverException = await Assert.ThrowsAsync(() => serverTask).WaitAsync(TestConfiguration.PassingTestTimeout); await ObservePeerAlertAsync(clientTask, client); Assert.True(recordingClientStream.ContainsAlert(TlsAlertDescription.UnknownCA), recordingClientStream.GetRecordedAlerts()); + + // Cross-platform parity: the cert validation exception should surface directly + // to the caller (matching Windows/Linux/Android behavior via SendAuthResetSignal), + // not wrapped in a generic AuthenticationException(SR.net_auth_SSPI, ...). + Assert.Null(serverException.InnerException); + Assert.Contains("certificate", serverException.Message, StringComparison.OrdinalIgnoreCase); } } From b0ec6a487d00b1ede0e6fb1ee54de572b45ce01f Mon Sep 17 00:00:00 2001 From: liveans Date: Sat, 23 May 2026 22:16:36 +0300 Subject: [PATCH 4/7] Address review feedback on SslStreamAlertsTest - Enable OSX in SslStream_NoCallback_UntrustedCert_SendsAlert and SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert instead of adding separate _OSX-suffixed duplicates (per @rzikm). - Drop the culture-dependent assertions on the localized exception message; rely on InnerException-shape parity instead (per Copilot). - Skip the Ssl3 protocol case on macOS where SecureTransport no longer negotiates it (would otherwise time out). - Accept either UnknownCA or BadCertificate as the fatal cert-rejection alert on macOS; SecureTransport varies by protocol but both are valid 'untrusted cert' signals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FunctionalTests/SslStreamAlertsTest.cs | 171 ++++++------------ 1 file changed, 57 insertions(+), 114 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs index ba2a4103a3a488..e49b3e7c86c246 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs @@ -212,19 +212,28 @@ await Task.WhenAll( [Theory] [ClassData(typeof(SslProtocolSupport.SupportedSslProtocolsTestData))] - [ActiveIssue("https://github.com/dotnet/runtime/issues/18837", ~(TestPlatforms.Windows | TestPlatforms.Linux))] + [ActiveIssue("https://github.com/dotnet/runtime/issues/18837", ~(TestPlatforms.Windows | TestPlatforms.Linux | TestPlatforms.OSX))] public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols protocol) { // When no RemoteCertificateValidationCallback is set and the server's cert // is not trusted, the cert verify callback causes the TLS stack to send an // alert so the client sees a proper error. +#pragma warning disable 0618 // SSL2/3 are deprecated + if (PlatformDetection.IsOSX && protocol == SslProtocols.Ssl3) + { + // SecureTransport on modern macOS no longer negotiates SSL 3.0. + return; + } +#pragma warning restore 0618 + X509Certificate2 cert = Configuration.Certificates.GetSelfSignedServerCertificate(); (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); using (clientStream) - using (serverStream) + using (RecordingReadStream recordingServerStream = new RecordingReadStream(serverStream)) using (SslStream client = new SslStream(clientStream)) - using (SslStream server = new SslStream(serverStream)) + using (SslStream server = new SslStream(recordingServerStream)) + using (cert) { var serverOptions = new SslServerAuthenticationOptions { @@ -234,7 +243,7 @@ public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols pro var clientOptions = new SslClientAuthenticationOptions { - TargetHost = "localhost", + TargetHost = cert.GetNameInfo(X509NameType.DnsName, false), CertificateRevocationCheckMode = X509RevocationMode.NoCheck, EnabledSslProtocols = protocol, }; @@ -243,11 +252,11 @@ public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols pro Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); // Client should fail because the validation failed locally, and it should send an alert. - await Assert.ThrowsAsync(() => clientTask).WaitAsync(TestConfiguration.PassingTestTimeout); + AuthenticationException clientException = await Assert.ThrowsAsync(() => clientTask).WaitAsync(TestConfiguration.PassingTestTimeout); // Server side should receive the alert and fail the handshake, the exact timing depends on the platform // Windows: after the handshake, during data exchange - // Linux: during the handshake + // Linux/macOS: during the handshake Exception exception = await Assert.ThrowsAnyAsync(async () => { await serverTask; @@ -256,76 +265,53 @@ public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols pro await server.ReadAsync(buffer).AsTask().WaitAsync(TestConfiguration.PassingTestTimeout); }).WaitAsync(TestConfiguration.PassingTestTimeout); - Assert.NotNull(exception.InnerException); if (PlatformDetection.IsWindows) { Assert.IsType(exception); Assert.IsType(exception.InnerException); } - - if (PlatformDetection.IsLinux) + else if (PlatformDetection.IsLinux) { Assert.IsType(exception); + Assert.NotNull(exception.InnerException); } - } - } - - [Fact] - [PlatformSpecific(TestPlatforms.OSX)] - public async Task SslStream_NoCallback_UntrustedCert_SendsUnknownCAAlert_OSX() - { - X509Certificate2 cert = Configuration.Certificates.GetSelfSignedServerCertificate(); - (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); - using (clientStream) - using (RecordingReadStream recordingServerStream = new RecordingReadStream(serverStream)) - using (SslStream client = new SslStream(clientStream)) - using (SslStream server = new SslStream(recordingServerStream)) - using (cert) - { - var serverOptions = new SslServerAuthenticationOptions + else if (PlatformDetection.IsOSX) { - ServerCertificate = cert, - EnabledSslProtocols = SslProtocols.Tls12, - }; - - var clientOptions = new SslClientAuthenticationOptions - { - TargetHost = cert.GetNameInfo(X509NameType.DnsName, false), - CertificateRevocationCheckMode = X509RevocationMode.NoCheck, - EnabledSslProtocols = SslProtocols.Tls12, - }; - - Task serverTask = server.AuthenticateAsServerAsync(serverOptions, CancellationToken.None); - Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); - - AuthenticationException clientException = await Assert.ThrowsAsync(() => clientTask).WaitAsync(TestConfiguration.PassingTestTimeout); - await ObservePeerAlertAsync(serverTask, server); + // Cross-platform parity: the cert validation exception should surface directly + // to the caller (matching Windows/Linux/Android behavior via SendAuthResetSignal), + // not wrapped in a generic AuthenticationException(SR.net_auth_SSPI, ...). + Assert.Null(clientException.InnerException); - Assert.True(recordingServerStream.ContainsAlert(TlsAlertDescription.UnknownCA), recordingServerStream.GetRecordedAlerts()); - - // Cross-platform parity: the cert validation exception should surface directly - // to the caller (matching Windows/Linux/Android behavior via SendAuthResetSignal), - // not wrapped in a generic AuthenticationException(SR.net_auth_SSPI, ...). - Assert.Null(clientException.InnerException); - Assert.Contains("certificate", clientException.Message, StringComparison.OrdinalIgnoreCase); + // Verify the actual UnknownCA alert bytes reached the peer over the wire. + Assert.True(recordingServerStream.ContainsAlert(TlsAlertDescription.UnknownCA), recordingServerStream.GetRecordedAlerts()); + } } } [Theory] [ClassData(typeof(SslProtocolSupport.SupportedSslProtocolsTestData))] - [ActiveIssue("https://github.com/dotnet/runtime/issues/18837", ~(TestPlatforms.Windows | TestPlatforms.Linux))] + [ActiveIssue("https://github.com/dotnet/runtime/issues/18837", ~(TestPlatforms.Windows | TestPlatforms.Linux | TestPlatforms.OSX))] public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslProtocols protocol) { // When the server requires a client certificate and no // RemoteCertificateValidationCallback is set, the server should send // a TLS alert when the client's cert chain cannot be validated. +#pragma warning disable 0618 // SSL2/3 are deprecated + if (PlatformDetection.IsOSX && protocol == SslProtocols.Ssl3) + { + // SecureTransport on modern macOS no longer negotiates SSL 3.0. + return; + } +#pragma warning restore 0618 + X509Certificate2 cert = Configuration.Certificates.GetSelfSignedServerCertificate(); (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); - using (clientStream) + using (RecordingReadStream recordingClientStream = new RecordingReadStream(clientStream)) using (serverStream) - using (SslStream client = new SslStream(clientStream)) + using (SslStream client = new SslStream(recordingClientStream)) using (SslStream server = new SslStream(serverStream)) + using (cert) { var serverOptions = new SslServerAuthenticationOptions { @@ -336,7 +322,7 @@ public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslP var clientOptions = new SslClientAuthenticationOptions { - TargetHost = "localhost", + TargetHost = cert.GetNameInfo(X509NameType.DnsName, false), CertificateRevocationCheckMode = X509RevocationMode.NoCheck, RemoteCertificateValidationCallback = delegate { return true; }, ClientCertificates = new X509CertificateCollection { cert }, @@ -347,11 +333,12 @@ public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslP Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); // Server should fail because the validation failed locally, and it should send an alert. - await Assert.ThrowsAsync(() => serverTask).WaitAsync(TestConfiguration.PassingTestTimeout); + AuthenticationException serverException = await Assert.ThrowsAsync(() => serverTask).WaitAsync(TestConfiguration.PassingTestTimeout); // Client side should receive the alert and fail the handshake, the exact timing depends on the platform // Windows: after the handshake, during data exchange // Linux: during the handshake, TLS 1.3 sends the alert after the handshake + // macOS: during the handshake Exception exception = await Assert.ThrowsAnyAsync(async () => { await clientTask; @@ -360,15 +347,15 @@ public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslP await client.ReadAsync(buffer).AsTask().WaitAsync(TestConfiguration.PassingTestTimeout); }).WaitAsync(TestConfiguration.PassingTestTimeout); - Assert.NotNull(exception.InnerException); if (PlatformDetection.IsWindows) { + Assert.NotNull(exception.InnerException); Assert.IsType(exception); Assert.IsType(exception.InnerException); } - - if (PlatformDetection.IsLinux) + else if (PlatformDetection.IsLinux) { + Assert.NotNull(exception.InnerException); if (protocol == SslProtocols.Tls13) { // failure during app data (read) @@ -384,52 +371,21 @@ public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslP Assert.NotNull(exception.InnerException.InnerException); Assert.Contains("alert", exception.InnerException.InnerException.Message); } - } - } - - [Fact] - [PlatformSpecific(TestPlatforms.OSX)] - public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsUnknownCAAlert_OSX() - { - X509Certificate2 serverCert = Configuration.Certificates.GetSelfSignedServerCertificate(); - X509Certificate2 clientCert = Configuration.Certificates.GetSelfSignedClientCertificate(); - (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); - using (RecordingReadStream recordingClientStream = new RecordingReadStream(clientStream)) - using (serverStream) - using (SslStream client = new SslStream(recordingClientStream)) - using (SslStream server = new SslStream(serverStream)) - using (serverCert) - using (clientCert) - { - var serverOptions = new SslServerAuthenticationOptions - { - ServerCertificate = serverCert, - ClientCertificateRequired = true, - EnabledSslProtocols = SslProtocols.Tls12, - }; - - var clientOptions = new SslClientAuthenticationOptions + else if (PlatformDetection.IsOSX) { - TargetHost = serverCert.GetNameInfo(X509NameType.DnsName, false), - CertificateRevocationCheckMode = X509RevocationMode.NoCheck, - RemoteCertificateValidationCallback = delegate { return true; }, - ClientCertificates = new X509CertificateCollection { clientCert }, - EnabledSslProtocols = SslProtocols.Tls12, - }; - - Task serverTask = server.AuthenticateAsServerAsync(serverOptions, CancellationToken.None); - Task clientTask = client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); - - AuthenticationException serverException = await Assert.ThrowsAsync(() => serverTask).WaitAsync(TestConfiguration.PassingTestTimeout); - await ObservePeerAlertAsync(clientTask, client); - - Assert.True(recordingClientStream.ContainsAlert(TlsAlertDescription.UnknownCA), recordingClientStream.GetRecordedAlerts()); - - // Cross-platform parity: the cert validation exception should surface directly - // to the caller (matching Windows/Linux/Android behavior via SendAuthResetSignal), - // not wrapped in a generic AuthenticationException(SR.net_auth_SSPI, ...). - Assert.Null(serverException.InnerException); - Assert.Contains("certificate", serverException.Message, StringComparison.OrdinalIgnoreCase); + // Cross-platform parity: the cert validation exception should surface directly + // to the caller (matching Windows/Linux/Android behavior via SendAuthResetSignal), + // not wrapped in a generic AuthenticationException(SR.net_auth_SSPI, ...). + Assert.Null(serverException.InnerException); + + // Verify a fatal TLS alert reached the peer. SecureTransport may surface + // an untrusted-chain failure as either UnknownCA or BadCertificate depending + // on the negotiated protocol; both are valid "cert rejected" signals. + Assert.True( + recordingClientStream.ContainsAlert(TlsAlertDescription.UnknownCA) || + recordingClientStream.ContainsAlert(TlsAlertDescription.BadCertificate), + recordingClientStream.GetRecordedAlerts()); + } } } @@ -447,19 +403,6 @@ private bool AllowAnyServerCertificate( return true; } - private static async Task ObservePeerAlertAsync(Task handshakeTask, SslStream stream) - { - try - { - await handshakeTask.WaitAsync(TestConfiguration.PassingTestTimeout); - byte[] buffer = new byte[1]; - await stream.ReadAsync(buffer).AsTask().WaitAsync(TestConfiguration.PassingTestTimeout); - } - catch (Exception ex) when (ex is AuthenticationException or IOException or TimeoutException) - { - } - } - private sealed class RecordingReadStream : DelegatingStream { private readonly List _readBytes = new List(); From 2285d4d2c270d9640246a5e3832188f5eab66802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C4=B0brahim=20Aksoy?= Date: Mon, 8 Jun 2026 14:36:40 +0200 Subject: [PATCH 5/7] Assert empty payload before discarding it on CertValidationNeeded VerifyRemoteCertificateAndGenerateNextToken released the incoming token's payload unconditionally. SecureTransport pauses the handshake at the peer- auth-completed break before producing the next flight, so the pending- writes buffer drained into the token is expected to be empty. Add a debug assert so any future regression that produced bytes at this point is surfaced loudly instead of silently dropping handshake bytes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/Security/SslStream.Protocol.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index e27c04fc004af3..da230c89bf986e 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -944,6 +944,11 @@ private ProtocolToken GenerateToken(ReadOnlySpan inputBuffer, out int cons #if TARGET_APPLE private ProtocolToken VerifyRemoteCertificateAndGenerateNextToken(ProtocolToken token) { + // SecureTransport pauses the handshake (errSSL{Server,Client}AuthCompleted) before + // any bytes are produced for the next handshake flight, so the pending-writes buffer + // drained into token should be empty here. Assert to catch any future regression + // that would silently drop handshake bytes. + Debug.Assert(token.Size == 0, "Expected empty payload at CertValidationNeeded pause; dropping non-empty payload would lose handshake bytes."); token.ReleasePayload(); ProtocolToken alertToken = default; From 092d7f4fdac651e57659073ca9b4c775d8f68d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C4=B0brahim=20Aksoy?= Date: Mon, 8 Jun 2026 14:40:00 +0200 Subject: [PATCH 6/7] Filter Ssl3 from macOS theory data instead of early-returning Replace the in-test if (IsOSX && protocol == Ssl3) return; short-circuit with a local MemberData source that omits SSL 3.0 on macOS. The xunit runner now reports the filtered data point rather than silently passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FunctionalTests/SslStreamAlertsTest.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs index e49b3e7c86c246..59d0f84e6ddee3 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs @@ -211,7 +211,7 @@ await Task.WhenAll( } [Theory] - [ClassData(typeof(SslProtocolSupport.SupportedSslProtocolsTestData))] + [MemberData(nameof(SupportedSslProtocolsExcludingMacOSSsl3))] [ActiveIssue("https://github.com/dotnet/runtime/issues/18837", ~(TestPlatforms.Windows | TestPlatforms.Linux | TestPlatforms.OSX))] public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols protocol) { @@ -219,14 +219,6 @@ public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols pro // is not trusted, the cert verify callback causes the TLS stack to send an // alert so the client sees a proper error. -#pragma warning disable 0618 // SSL2/3 are deprecated - if (PlatformDetection.IsOSX && protocol == SslProtocols.Ssl3) - { - // SecureTransport on modern macOS no longer negotiates SSL 3.0. - return; - } -#pragma warning restore 0618 - X509Certificate2 cert = Configuration.Certificates.GetSelfSignedServerCertificate(); (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); using (clientStream) @@ -289,7 +281,7 @@ public async Task SslStream_NoCallback_UntrustedCert_SendsAlert(SslProtocols pro } [Theory] - [ClassData(typeof(SslProtocolSupport.SupportedSslProtocolsTestData))] + [MemberData(nameof(SupportedSslProtocolsExcludingMacOSSsl3))] [ActiveIssue("https://github.com/dotnet/runtime/issues/18837", ~(TestPlatforms.Windows | TestPlatforms.Linux | TestPlatforms.OSX))] public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslProtocols protocol) { @@ -297,14 +289,6 @@ public async Task SslStream_NoCallback_UntrustedClientCert_ServerSendsAlert(SslP // RemoteCertificateValidationCallback is set, the server should send // a TLS alert when the client's cert chain cannot be validated. -#pragma warning disable 0618 // SSL2/3 are deprecated - if (PlatformDetection.IsOSX && protocol == SslProtocols.Ssl3) - { - // SecureTransport on modern macOS no longer negotiates SSL 3.0. - return; - } -#pragma warning restore 0618 - X509Certificate2 cert = Configuration.Certificates.GetSelfSignedServerCertificate(); (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); using (RecordingReadStream recordingClientStream = new RecordingReadStream(clientStream)) @@ -394,6 +378,22 @@ private bool FailClientCertificate(object sender, X509Certificate certificate, X return false; } + public static IEnumerable SupportedSslProtocolsExcludingMacOSSsl3() + { + foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols()) + { +#pragma warning disable 0618 // SSL2/3 are deprecated + if (PlatformDetection.IsOSX && protocol == SslProtocols.Ssl3) + { + // SecureTransport on modern macOS no longer negotiates SSL 3.0; skip rather + // than silently pass so the xunit runner reports the data point as filtered. + continue; + } +#pragma warning restore 0618 + yield return new object[] { protocol }; + } + } + private bool AllowAnyServerCertificate( object sender, X509Certificate certificate, From 6a4edeb323dc71b38c92892327f24e5ee8f239fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C4=B0brahim=20Aksoy?= Date: Mon, 8 Jun 2026 15:09:39 +0200 Subject: [PATCH 7/7] Use EnumerateSupportedProtocols mask overload to filter Ssl3 on macOS The helper already accepts a mask, so express the macOS Ssl3 filter as ~SslProtocols.Ssl3 instead of an open-coded if inside the loop. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/FunctionalTests/SslStreamAlertsTest.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs index 59d0f84e6ddee3..89519d77118259 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlertsTest.cs @@ -380,16 +380,14 @@ private bool FailClientCertificate(object sender, X509Certificate certificate, X public static IEnumerable SupportedSslProtocolsExcludingMacOSSsl3() { - foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols()) - { #pragma warning disable 0618 // SSL2/3 are deprecated - if (PlatformDetection.IsOSX && protocol == SslProtocols.Ssl3) - { - // SecureTransport on modern macOS no longer negotiates SSL 3.0; skip rather - // than silently pass so the xunit runner reports the data point as filtered. - continue; - } + // SecureTransport on modern macOS won't negotiate SSL 3.0, so handshakes hang + // until they time out. Mask Ssl3 out on macOS so xunit reports the data point + // as filtered rather than silently passing. + SslProtocols mask = PlatformDetection.IsOSX ? ~SslProtocols.Ssl3 : (SslProtocols)~0; #pragma warning restore 0618 + foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols(mask)) + { yield return new object[] { protocol }; } }