From 6ebc14b54f62a76a89e552707eecb02535cebe5b Mon Sep 17 00:00:00 2001 From: wfurt Date: Tue, 28 Jul 2026 13:52:28 +0200 Subject: [PATCH 1/9] Enable TlsContext/TlsSession on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the SslStream/TlsSession unification effort: bring Android onto the real TlsSession implementation so it stops being the only platform that has to throw PlatformNotSupportedException from the new low-level TLS API surface introduced in #130366. ## Why it was stubbed Pal.Android/SafeDeleteSslContext.cs requires an SslStream.JavaProxy on the SslAuthenticationOptions bag. JavaProxy was constructed with an SslStream instance and its remote-cert-validation callback closed over that specific stream — so the JSSE trust manager back-channel could only route into an SslStream. TlsSession has no SslStream, so constructing SafeDeleteSslContext threw immediately, and TlsSession.cs was shut off for Android via TlsSession.Stub.cs. ## What this change does - Decouple JavaProxy from SslStream. It now carries a `Func` validator that its static UnmanagedCallersOnly trampoline invokes. SslStream keeps a convenience overload (`new JavaProxy(this)`) that closes over SslStream.VerifyRemoteCertificate(IntPtr). TlsSession supplies its own validator (see below). - Add TlsSession.Android.cs. Populates the per-session options bag with a session-owned JavaProxy in a new `InitializePlatformSpecificSessionState` partial method fired from TlsSession.InitializeFromContext. The validator mirrors what SslStream.Android does today: consult the platform trust manager result (opt-in via ShouldRespectPlatformValidation), then run the shared SslStream.VerifyRemoteCertificateCore. Runs synchronously from the JSSE trust manager callback — matching SslStream's behavior on Android, since the JSSE trust manager needs a synchronous decision. - Flip the csproj gating: TlsSession.cs, TlsBufferSession.cs, and TlsSocketSession.cs now compile on Android; TlsSession.Stub.cs is restricted to the netstandard build only. - Move Pal.Android/SafeDeleteSslContext from the `System.Net` namespace to `System.Net.Security` — matching where the base class (SafeDeleteContext) and the Windows/Linux SafeDeleteSslContext already live. Purely a naming fix; the type is internal so nothing outside the assembly cares. Lets us drop the ugly `#elif TARGET_ANDROID` special case from TlsSession.cs's TlsSecurityContext alias block. - Fix a signature inconsistency uncovered while wiring TlsSession's ref-passing through the Android PAL: SslStreamPal.Android.AcceptSecurityContext / InitializeSecurityContext took `ref SafeFreeCredentials credential` (non-nullable), unlike Windows/Linux/OSX which take `ref SafeFreeCredentials?`. Android's AcquireCredentialsHandle always returns null and HandshakeInternal never reads the value, so changing to nullable is a no-op behaviorally and simply aligns the four PAL signatures. SslStream's existing `_credentialsHandle!` call sites still compile unchanged. - Enable TlsSessionTests on Android in the test csproj so the existing coverage runs in Android CI. ## Not included - No changes to the SslStream code path — SslStream continues to drive its own PAL glue as before. This PR only removes the "Android is stubbed" asterisk on the TlsSession API. - Pal.OSX/SafeDeleteSslContext is still under `System.Net` (same historical inconsistency as the Android one). Not touched here because macOS's TlsSession alias already uses the base class (SafeDeleteContext, correctly namespaced) — the macOS mismatch is cosmetic-only and can move to a separate cleanup PR. ## Sequencing Follows #130366 (merged) and #131457 (small follow-up). Precedes the SslStream → TlsSession adapter work (Phase 2/3 in the plan we sketched), which needs TlsSession to be universally supported before SslStream can be a thin shim over it. --- .../src/System.Net.Security.csproj | 10 +- .../Pal.Android/SafeDeleteSslContext.cs | 3 +- .../System/Net/Security/SslStream.Android.cs | 24 ++++- .../Net/Security/SslStreamPal.Android.cs | 6 +- .../System/Net/Security/TlsSession.Android.cs | 99 +++++++++++++++++++ .../src/System/Net/Security/TlsSession.cs | 10 ++ .../System.Net.Security.Tests.csproj | 2 +- 7 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs diff --git a/src/libraries/System.Net.Security/src/System.Net.Security.csproj b/src/libraries/System.Net.Security/src/System.Net.Security.csproj index 1e80c61d76527c..5286dfde9d46ee 100644 --- a/src/libraries/System.Net.Security/src/System.Net.Security.csproj +++ b/src/libraries/System.Net.Security/src/System.Net.Security.csproj @@ -78,15 +78,17 @@ Condition="'$(TargetPlatformIdentifier)' != '' and '$(TargetPlatformIdentifier)' != 'windows' and '$(UseAndroidCrypto)' != 'true' and '$(UseAppleCrypto)' != 'true'" /> + Condition="'$(TargetPlatformIdentifier)' != ''" /> + Condition="'$(TargetPlatformIdentifier)' != ''" /> + Condition="'$(TargetPlatformIdentifier)' != ''" /> + + Condition="'$(TargetPlatformIdentifier)' == ''" /> diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/Pal.Android/SafeDeleteSslContext.cs b/src/libraries/System.Net.Security/src/System/Net/Security/Pal.Android/SafeDeleteSslContext.cs index aed5b1f56aa41e..1c8899052e8f95 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/Pal.Android/SafeDeleteSslContext.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/Pal.Android/SafeDeleteSslContext.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Collections.Generic; -using System.Net.Security; using System.Runtime.InteropServices; using System.Security.Authentication; using System.Security.Cryptography; @@ -13,7 +12,7 @@ using PAL_KeyAlgorithm = Interop.AndroidCrypto.PAL_KeyAlgorithm; using PAL_SSLStreamStatus = Interop.AndroidCrypto.PAL_SSLStreamStatus; -namespace System.Net +namespace System.Net.Security { internal sealed class SafeDeleteSslContext : SafeDeleteContext { diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs index cf5680af28db2c..e090d9ac801933 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs @@ -68,7 +68,11 @@ internal sealed class JavaProxy : IDisposable { private static bool s_initialized; - private readonly SslStream _sslStream; + // Session-side validator. SslStream supplies one that closes over its own + // VerifyRemoteCertificate(IntPtr) method; TlsSession supplies one that routes + // the platform trust result into the session's own state. Neither implementation + // is aware of the other. + private readonly Func _validator; private GCHandle? _handle; public IntPtr Handle @@ -79,14 +83,26 @@ public IntPtr Handle public Exception? ValidationException { get; private set; } public RemoteCertificateValidationResult? ValidationResult { get; private set; } - public JavaProxy(SslStream sslStream) + // Delegate-based ctor used by TlsSession (and by the SslStream convenience overload + // below). Keeps this proxy decoupled from any specific session/stream type so both + // SslStream and TlsSession can own their own instance. + public JavaProxy(Func validator) { + ArgumentNullException.ThrowIfNull(validator); + RegisterRemoteCertificateValidationCallback(); - _sslStream = sslStream; + _validator = validator; _handle = GCHandle.Alloc(this); } + // Convenience overload preserved for SslStream — captures the stream's private + // VerifyRemoteCertificate(IntPtr) method as the validator. + public JavaProxy(SslStream sslStream) + : this(sslStream.VerifyRemoteCertificate) + { + } + public void Dispose() { _handle?.Free(); @@ -111,7 +127,7 @@ private static bool VerifyRemoteCertificate(IntPtr sslStreamProxyHandle, IntPtr try { - proxy.ValidationResult = proxy._sslStream.VerifyRemoteCertificate(platformValidationError); + proxy.ValidationResult = proxy._validator(platformValidationError); return proxy.ValidationResult.IsValid; } catch (Exception exception) 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 223e13a1d4a674..6fa5d7e514cdfd 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 @@ -47,7 +47,7 @@ public static SecurityStatusPal SelectApplicationProtocol( } public static ProtocolToken AcceptSecurityContext( - ref SafeFreeCredentials credential, + ref SafeFreeCredentials? credential, ref SafeDeleteSslContext? context, ReadOnlySpan inputBuffer, out int consumed, @@ -57,7 +57,7 @@ public static ProtocolToken AcceptSecurityContext( } public static ProtocolToken InitializeSecurityContext( - ref SafeFreeCredentials credential, + ref SafeFreeCredentials? credential, ref SafeDeleteSslContext? context, string? targetName, ReadOnlySpan inputBuffer, @@ -211,7 +211,7 @@ public static bool TryUpdateClintCertificate( } private static ProtocolToken HandshakeInternal( - SafeFreeCredentials credential, + SafeFreeCredentials? credential, ref SafeDeleteSslContext? context, ReadOnlySpan inputBuffer, out int consumed, diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs new file mode 100644 index 00000000000000..5d7825d6d9fe9c --- /dev/null +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography.X509Certificates; + +namespace System.Net.Security +{ + // Android-only helpers for TlsSession. Populates the JavaProxy that + // SafeDeleteSslContext requires and mirrors SslStream's platform trust manager + // routing without depending on an SslStream instance. + public abstract partial class TlsSession + { + // Wires a session-owned JavaProxy onto the per-session options bag so + // Pal.Android.SafeDeleteSslContext can look it up during construction. The proxy + // delegates back to VerifyRemoteCertificateForAndroid on this session — mirroring + // the model SslStream uses via SslStream.Android.VerifyRemoteCertificate(IntPtr), + // but keeping the JSSE bridge scoped to the session that created it. + partial void InitializePlatformSpecificSessionState() + { + _options.SslStreamProxy = new SslStream.JavaProxy(VerifyRemoteCertificateForAndroid); + } + + // Invoked synchronously from Android's DotnetProxyTrustManager back-channel while the + // JSSE SSLEngine is in the middle of processing the peer's certificate message. + // The JSSE trust manager expects a synchronous bool decision, so we must run cert + // validation inline here — TlsSession's async NeedsCertificateValidation model does + // not apply on Android for the same reason it does not apply to SslStream on Android. + private SslStream.JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificateForAndroid(IntPtr platformValidationError) + { + SslPolicyErrors sslPolicyErrors = SslPolicyErrors.None; + if (ShouldRespectPlatformValidation() && platformValidationError != IntPtr.Zero) + { + sslPolicyErrors = SslPolicyErrors.RemoteCertificateChainErrors; + + // See SslStream.Android.VerifyRemoteCertificate for the rationale behind + // surfacing Android's textual rejection reason via NetEventSource. + if (NetEventSource.Log.IsEnabled()) + { + string? validationError = Interop.AndroidCrypto.GetPlatformValidationError(platformValidationError); + NetEventSource.Error(this, $"The Android platform trust manager rejected the remote certificate chain: {validationError}"); + } + } + + ProtocolToken alertToken = default; + X509Chain? chain = null; + + try + { + X509Certificate2? candidate = CertificateValidationPal.GetRemoteCertificate(_securityContext, ref chain, _options.CertificateChainPolicy); + + bool isValid = SslStream.VerifyRemoteCertificateCore( + sender: this, + _options, + _securityContext, + ref _remoteCertificate, + ref _connectionInfo, + candidate, + chain, + _options.CertificateContext?.Trust, + ref alertToken, + ref sslPolicyErrors, + out X509ChainStatusFlags chainStatus); + + return new SslStream.JavaProxy.RemoteCertificateValidationResult + { + IsValid = isValid, + SslPolicyErrors = sslPolicyErrors, + ChainStatus = chainStatus, + AlertToken = alertToken, + }; + } + finally + { + // Mirror SslStream's chain-cleanup: dispose the chain elements that + // VerifyRemoteCertificateCore populated (unless the caller has a user + // callback that may retain them), matching the behavior of SslStream's + // VerifyRemoteCertificate wrapper path. + if (chain is not null && _options.CertValidationDelegate is null) + { + for (int i = 0; i < chain.ChainElements.Count; i++) + { + chain.ChainElements[i].Certificate.Dispose(); + } + chain.Dispose(); + } + } + } + + private bool ShouldRespectPlatformValidation() + { + // Mirrors SslStream.Android.ShouldRespectPlatformValidation: platform trust + // wins by default, but explicit managed custom trust remains authoritative + // and is not projected into the Android trust manager. + return _options.CertificateChainPolicy is not null + ? _options.CertificateChainPolicy.TrustMode != X509ChainTrustMode.CustomRootTrust + : _options.CertificateContext?.Trust is null; + } + } +} diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs index 95dafc60e02f37..08e3d3d02a28dd 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -184,9 +184,19 @@ private void InitializeFromContext(TlsContext context) _ownsSessionCertificateContext = _options.OwnsCertificateContext; _options.OwnsCertificateContext = false; + // Platform-specific hook: lets a per-platform partial (e.g. Android's + // TlsSession.Android.cs) attach per-session native bridge state (the JavaProxy + // that SafeDeleteSslContext requires) to the options bag before any PAL call + // reads it. No-op on platforms whose PAL does not need such state. + InitializePlatformSpecificSessionState(); + OnContextInitialized(); } + // Implemented as `partial void` so it compiles to a no-op on platforms that don't + // supply a body (Windows, Linux/FreeBSD, macOS/iOS/tvOS, Haiku, OpenBSD). + partial void InitializePlatformSpecificSessionState(); + internal virtual void OnContextInitialized() { } diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj b/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj index 8cca9ec340c6a2..2c24dc073fafb8 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj @@ -36,7 +36,7 @@ + Condition="'$(TargetPlatformIdentifier)' == 'unix' or '$(TargetPlatformIdentifier)' == 'windows' or '$(TargetPlatformIdentifier)' == 'osx' or '$(TargetPlatformIdentifier)' == 'android'" /> From 4488d55ca25611eb0c6f6e09e01883df87b82c9c Mon Sep 17 00:00:00 2001 From: wfurt Date: Tue, 28 Jul 2026 14:34:01 +0200 Subject: [PATCH 2/9] Enable System.Net.Security.Tests on Android CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to enabling TlsContext/TlsSession on Android in the previous commit: remove the blanket Android exclusion of System.Net.Security.Tests.csproj so the new TlsSession Android implementation actually gets covered by CI, along with the existing SslStream tests that already had Android-specific handling (SkipOnPlatform, ActiveIssue attributes, OperatingSystem.IsAndroid() guards throughout the test source). Historical context: - The exclusion originated pre-#68020 (2022). PR #68020 removed a blanket exclusion and added per-test ActiveIssue markers, but the exclusion later reappeared unconditionally on both TargetOS=android and TargetsLinuxBionic. - A separate x64/LinuxBionic-only exclusion (the one that documents "Timeout on Helix, cannot repro locally") remains in place for now — if x64 emulators still time out we can narrow the re-enablement. If Android CI reports emulator timeouts on this PR, we'll narrow the re-enablement (e.g., split TlsSessionTests into a standalone Android-only project along the lines of the existing AndroidPlatformTrustTests, or keep the exclusion for x64 but not arm64). --- src/libraries/tests.proj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libraries/tests.proj b/src/libraries/tests.proj index 0dd94483146f90..8f814ca91dc96e 100644 --- a/src/libraries/tests.proj +++ b/src/libraries/tests.proj @@ -189,7 +189,6 @@ - From fc62c10ab0203d672a68e2b1467e490d028720b6 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 29 Jul 2026 11:08:46 +0200 Subject: [PATCH 3/9] Address Copilot review feedback on #131461 - Add TestPlatforms.Android to the class-level [PlatformSpecific] attribute on TlsSessionTests so the tests actually execute on Android CI, not just compile. - Update TlsSession.Android.cs comment to refer to the type by its proper namespace (System.Net.Security.SafeDeleteSslContext) instead of the folder-based Pal.Android pseudo-namespace, which was stale after the Android SafeDeleteSslContext moved into System.Net.Security in the previous commit. --- .../src/System/Net/Security/TlsSession.Android.cs | 2 +- .../tests/FunctionalTests/TlsSessionTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs index 5d7825d6d9fe9c..788f41c3c88a2a 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs @@ -11,7 +11,7 @@ namespace System.Net.Security public abstract partial class TlsSession { // Wires a session-owned JavaProxy onto the per-session options bag so - // Pal.Android.SafeDeleteSslContext can look it up during construction. The proxy + // the Android SafeDeleteSslContext can look it up during construction. The proxy // delegates back to VerifyRemoteCertificateForAndroid on this session — mirroring // the model SslStream uses via SslStream.Android.VerifyRemoteCertificate(IntPtr), // but keeping the JSSE bridge scoped to the session that created it. diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 7864c7e5407bf7..a92f4ad480dd58 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -20,7 +20,7 @@ namespace System.Net.Security.Tests { - [PlatformSpecific(TestPlatforms.Linux | TestPlatforms.FreeBSD | TestPlatforms.Windows | TestPlatforms.OSX)] + [PlatformSpecific(TestPlatforms.Linux | TestPlatforms.FreeBSD | TestPlatforms.Windows | TestPlatforms.OSX | TestPlatforms.Android)] public class TlsSessionTests { private const int CipherBufSize = 32 * 1024; From 0cd119ef8391eb87673fe766a6a0a8cad78d5599 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 29 Jul 2026 17:22:31 +0200 Subject: [PATCH 4/9] TlsSession.Android: accept-and-defer platform validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the Android JavaProxy validator from running managed cert validation inline (which required the caller to use SslStream-style synchronous semantics) to always returning IsValid=true at the JSSE trust manager layer and letting the shared OnHandshakeCompleted path suspend the session via NeedsCertificateValidation. This mirrors the accept-and-defer branch already used by the OpenSSL 1.1.x CertVerifyCallback and by SecureTransport on macOS, and it unblocks TlsSession's async external-validation API on Android without changing SslStream's behavior — SslStream keeps its own JavaProxy (constructed via the convenience overload closing over SslStream's private synchronous VerifyRemoteCertificate(IntPtr) method), so existing SslStream users see zero difference. Expected to fix the following TlsSessionTests failures on Android: * ClientSession_ExternalCertificateValidation_SuspendsAndAcc* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* ClientSession_ExternalCertificateValidation_SuspendsAndAcern* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* Css* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* CliBou* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* the fo* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* Clien a* ClientSession_ExternalCertificateValidation_Susenti* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* Server_* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* ClV* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* ClientSRe* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* Clie r* ClientSession_ExternalCertificateValidation_SuspendsAndAcc* Cliem* ClientSession_ExternalCertificateValidation_SuspendsAndAcc*ficate_Tls12_ProducesHandshakeBytes is now [ConditionalFact(nameof(TestConfiguration.SupportsRenegotiation))]. TestConfiguration.SupportsRenegotiation already excludes Android and macOS; no new helper needed. * ServerSession_ChannelBinding_MatchesSslStreamClient is now [ConditionalFact(nameof(TestConfiguration.SupportsUniqueChannelBinding))]. Added SupportsUniqueChannelBinding to TestConfiguration; Android's SslStreamPal returns null for ChannelBindingKind.Unique (JSSE does not expose the Finished messages), matching the existing macOS gap. Leaves one unexplained failure to investigate separately: SetClientCertificateContext_ConcurrentSessionsOnSharedContext_DoNotRace (expected thumbprint, got null) — appears to be a real bug in how Android's cert path interacts with session-owned cert state. --- .../System/Net/Security/TlsSession.Android.cs | 120 ++++++------------ .../FunctionalTests/TestConfiguration.cs | 7 + .../tests/FunctionalTests/TlsSessionTests.cs | 6 +- 3 files changed, 48 insertions(+), 85 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs index 788f41c3c88a2a..77f22ab3685895 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs @@ -1,99 +1,57 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Security.Cryptography.X509Certificates; - namespace System.Net.Security { // Android-only helpers for TlsSession. Populates the JavaProxy that - // SafeDeleteSslContext requires and mirrors SslStream's platform trust manager - // routing without depending on an SslStream instance. + // SafeDeleteSslContext requires. Uses an accept-and-defer trust manager + // strategy so TlsSession's async validation model (NeedsCertificateValidation + // + AcceptWithDefaultValidation / SetRemoteCertificateValidationResult) + // works on Android the same way it does on OpenSSL 1.1.x and SecureTransport, + // rather than blocking the JSSE trust manager thread on managed validation. public abstract partial class TlsSession { // Wires a session-owned JavaProxy onto the per-session options bag so - // the Android SafeDeleteSslContext can look it up during construction. The proxy - // delegates back to VerifyRemoteCertificateForAndroid on this session — mirroring - // the model SslStream uses via SslStream.Android.VerifyRemoteCertificate(IntPtr), - // but keeping the JSSE bridge scoped to the session that created it. + // the Android SafeDeleteSslContext can look it up during construction. + // The proxy's validator always accepts at the JSSE layer; the actual + // validation decision is deferred to the standard OnHandshakeCompleted + // path via CaptureRemoteCertificateForExternalValidation. partial void InitializePlatformSpecificSessionState() { - _options.SslStreamProxy = new SslStream.JavaProxy(VerifyRemoteCertificateForAndroid); + _options.SslStreamProxy = new SslStream.JavaProxy(AcceptAndDeferPlatformValidation); } - // Invoked synchronously from Android's DotnetProxyTrustManager back-channel while the - // JSSE SSLEngine is in the middle of processing the peer's certificate message. - // The JSSE trust manager expects a synchronous bool decision, so we must run cert - // validation inline here — TlsSession's async NeedsCertificateValidation model does - // not apply on Android for the same reason it does not apply to SslStream on Android. - private SslStream.JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificateForAndroid(IntPtr platformValidationError) + // Invoked synchronously from Android's DotnetProxyTrustManager back-channel + // while the JSSE SSLEngine is processing the peer's certificate message. + // + // Always returns IsValid=true so the handshake progresses past the + // trust-manager checkpoint. TlsSession's shared OnHandshakeCompleted path + // then captures the peer certificate into _externalPendingCert and + // suspends the session via NeedsCertificateValidation, giving the caller + // the same async validation experience available on Windows / Linux / + // macOS. Callers that want SslStream-style synchronous validation with + // the platform trust store should either: + // * set SslClientAuthenticationOptions.RemoteCertificateValidationCallback + // and call AcceptWithDefaultValidation() when the session suspends + // (mirrors SslStream's callback semantics), or + // * use SslStream directly (which continues to invoke JSSE + // synchronously via its own JavaProxy in SslStream.Android.cs). + // + // This mirrors the accept-and-defer branch already used by the OpenSSL + // 1.1.x CertVerifyCallback and by SecureTransport on macOS, where the + // handshake completes on the wire before the caller records a verdict + // and any rejection is surfaced through _externalValidationFault on the + // next Read/Write. + private static SslStream.JavaProxy.RemoteCertificateValidationResult AcceptAndDeferPlatformValidation(IntPtr platformValidationError) { - SslPolicyErrors sslPolicyErrors = SslPolicyErrors.None; - if (ShouldRespectPlatformValidation() && platformValidationError != IntPtr.Zero) - { - sslPolicyErrors = SslPolicyErrors.RemoteCertificateChainErrors; - - // See SslStream.Android.VerifyRemoteCertificate for the rationale behind - // surfacing Android's textual rejection reason via NetEventSource. - if (NetEventSource.Log.IsEnabled()) - { - string? validationError = Interop.AndroidCrypto.GetPlatformValidationError(platformValidationError); - NetEventSource.Error(this, $"The Android platform trust manager rejected the remote certificate chain: {validationError}"); - } - } - - ProtocolToken alertToken = default; - X509Chain? chain = null; - - try + _ = platformValidationError; + return new SslStream.JavaProxy.RemoteCertificateValidationResult { - X509Certificate2? candidate = CertificateValidationPal.GetRemoteCertificate(_securityContext, ref chain, _options.CertificateChainPolicy); - - bool isValid = SslStream.VerifyRemoteCertificateCore( - sender: this, - _options, - _securityContext, - ref _remoteCertificate, - ref _connectionInfo, - candidate, - chain, - _options.CertificateContext?.Trust, - ref alertToken, - ref sslPolicyErrors, - out X509ChainStatusFlags chainStatus); - - return new SslStream.JavaProxy.RemoteCertificateValidationResult - { - IsValid = isValid, - SslPolicyErrors = sslPolicyErrors, - ChainStatus = chainStatus, - AlertToken = alertToken, - }; - } - finally - { - // Mirror SslStream's chain-cleanup: dispose the chain elements that - // VerifyRemoteCertificateCore populated (unless the caller has a user - // callback that may retain them), matching the behavior of SslStream's - // VerifyRemoteCertificate wrapper path. - if (chain is not null && _options.CertValidationDelegate is null) - { - for (int i = 0; i < chain.ChainElements.Count; i++) - { - chain.ChainElements[i].Certificate.Dispose(); - } - chain.Dispose(); - } - } - } - - private bool ShouldRespectPlatformValidation() - { - // Mirrors SslStream.Android.ShouldRespectPlatformValidation: platform trust - // wins by default, but explicit managed custom trust remains authoritative - // and is not projected into the Android trust manager. - return _options.CertificateChainPolicy is not null - ? _options.CertificateChainPolicy.TrustMode != X509ChainTrustMode.CustomRootTrust - : _options.CertificateContext?.Trust is null; + IsValid = true, + SslPolicyErrors = SslPolicyErrors.None, + ChainStatus = default, + AlertToken = default, + }; } } } diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs index d6a89d8ced0668..7b7f6e70337d7c 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs @@ -30,6 +30,13 @@ internal static class TestConfiguration public static bool SupportsHandshakeAlerts { get { return OperatingSystem.IsLinux() || OperatingSystem.IsWindows() || OperatingSystem.IsFreeBSD() || RuntimeInformation.IsOSPlatform(OSPlatform.Create("OPENBSD")); } } public static bool SupportsRenegotiation { get { return OperatingSystem.IsWindows() || ((OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() || RuntimeInformation.IsOSPlatform(OSPlatform.Create("OPENBSD"))) && PlatformDetection.OpenSslVersion >= new Version(1, 1, 1)); } } + // Whether the platform PAL can produce a tls-unique channel binding. + // Android's PAL only implements tls-server-end-point (SslStreamPal.Android.cs + // returns null for other ChannelBindingKind values because JSSE does not + // expose the TLS Finished messages). macOS/SecureTransport has the same + // limitation for the exporter path tested by TlsSession's binding test. + public static bool SupportsUniqueChannelBinding => !OperatingSystem.IsAndroid() && !OperatingSystem.IsMacOS() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS() && !OperatingSystem.IsMacCatalyst(); + public static readonly X509Certificate2 ServerCertificate = System.Net.Test.Common.Configuration.Certificates.GetServerCertificate(); public static Task WhenAllOrAnyFailedWithTimeout(params Task[] tasks) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index a92f4ad480dd58..2fb715a29003b8 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -827,8 +827,7 @@ public async Task ServerSession_OptionalClientCert_NoCertSent_HandshakeCompletes } } - [Fact] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not expose the TLS exporter required to compute tls-server-end-point channel binding here.")] + [ConditionalFact(typeof(TestConfiguration), nameof(TestConfiguration.SupportsUniqueChannelBinding))] public async Task ServerSession_ChannelBinding_MatchesSslStreamClient() { using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); @@ -1777,8 +1776,7 @@ public async Task ServerSession_ServerCertificateSelectionCallback_InvokedWithSn } } - [Fact] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not support post-handshake renegotiation.")] + [ConditionalFact(typeof(TestConfiguration), nameof(TestConfiguration.SupportsRenegotiation))] public async Task ServerSession_RequestClientCertificate_Tls12_ProducesHandshakeBytes() { using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); From cef39c25fdb40d895d9aaac34695c3bca59c0f50 Mon Sep 17 00:00:00 2001 From: wfurt Date: Thu, 30 Jul 2026 11:41:40 +0200 Subject: [PATCH 5/9] TlsSession.Android: preserve platform trust verdict for AcceptWithDefaultValidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JavaProxy still accepts at the JSSE trust-manager checkpoint (JSSE has no retry-verify equivalent, so alert-during-handshake on reject is not achievable without a background driver thread — tracked separately). But we now record the platform verdict on the session and OR it into the SslPolicyErrors returned by AcceptWithDefaultValidation, gated by the same ShouldRespectPlatformValidation logic SslStream.Android uses. This closes the parity gap Milos flagged: a caller using platform trust now sees RemoteCertificateChainErrors when the OS rejected the chain, matching SslStream's Android behavior. --- .../System/Net/Security/TlsSession.Android.cs | 78 +++++++++++-------- .../src/System/Net/Security/TlsSession.cs | 5 ++ 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs index 77f22ab3685895..c5707df8087c25 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs @@ -1,50 +1,50 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Security.Cryptography.X509Certificates; + namespace System.Net.Security { - // Android-only helpers for TlsSession. Populates the JavaProxy that - // SafeDeleteSslContext requires. Uses an accept-and-defer trust manager - // strategy so TlsSession's async validation model (NeedsCertificateValidation - // + AcceptWithDefaultValidation / SetRemoteCertificateValidationResult) - // works on Android the same way it does on OpenSSL 1.1.x and SecureTransport, - // rather than blocking the JSSE trust manager thread on managed validation. + // Android-only helpers for TlsSession. JSSE's X509TrustManager is a synchronous + // decision point with no retry-verify equivalent, so we accept-and-defer at the + // trust-manager checkpoint (like OpenSSL 1.1.x / SecureTransport) and let the + // caller drive validation via NeedsCertificateValidation. We still record the + // platform verdict so AcceptWithDefaultValidation can honor it, matching + // SslStream.Android's ShouldRespectPlatformValidation behavior. public abstract partial class TlsSession { - // Wires a session-owned JavaProxy onto the per-session options bag so - // the Android SafeDeleteSslContext can look it up during construction. - // The proxy's validator always accepts at the JSSE layer; the actual - // validation decision is deferred to the standard OnHandshakeCompleted - // path via CaptureRemoteCertificateForExternalValidation. + private bool _platformChainRejected; + partial void InitializePlatformSpecificSessionState() { _options.SslStreamProxy = new SslStream.JavaProxy(AcceptAndDeferPlatformValidation); } - // Invoked synchronously from Android's DotnetProxyTrustManager back-channel - // while the JSSE SSLEngine is processing the peer's certificate message. - // - // Always returns IsValid=true so the handshake progresses past the - // trust-manager checkpoint. TlsSession's shared OnHandshakeCompleted path - // then captures the peer certificate into _externalPendingCert and - // suspends the session via NeedsCertificateValidation, giving the caller - // the same async validation experience available on Windows / Linux / - // macOS. Callers that want SslStream-style synchronous validation with - // the platform trust store should either: - // * set SslClientAuthenticationOptions.RemoteCertificateValidationCallback - // and call AcceptWithDefaultValidation() when the session suspends - // (mirrors SslStream's callback semantics), or - // * use SslStream directly (which continues to invoke JSSE - // synchronously via its own JavaProxy in SslStream.Android.cs). - // - // This mirrors the accept-and-defer branch already used by the OpenSSL - // 1.1.x CertVerifyCallback and by SecureTransport on macOS, where the - // handshake completes on the wire before the caller records a verdict - // and any rejection is surfaced through _externalValidationFault on the - // next Read/Write. - private static SslStream.JavaProxy.RemoteCertificateValidationResult AcceptAndDeferPlatformValidation(IntPtr platformValidationError) + partial void SeedPlatformValidationErrors(ref SslPolicyErrors sslPolicyErrors) + { + if (_platformChainRejected && ShouldRespectPlatformValidation()) + { + sslPolicyErrors |= SslPolicyErrors.RemoteCertificateChainErrors; + } + } + + // Invoked synchronously from Android's DotnetProxyTrustManager while JSSE processes + // the peer's certificate message. Always accepts so the handshake progresses; the + // rejection reason (if any) is recorded on the session and surfaced later through + // AcceptWithDefaultValidation or observable via _platformChainRejected. + private SslStream.JavaProxy.RemoteCertificateValidationResult AcceptAndDeferPlatformValidation(IntPtr platformValidationError) { - _ = platformValidationError; + if (platformValidationError != IntPtr.Zero) + { + _platformChainRejected = true; + + if (NetEventSource.Log.IsEnabled()) + { + string? validationError = Interop.AndroidCrypto.GetPlatformValidationError(platformValidationError); + NetEventSource.Error(this, $"The Android platform trust manager rejected the remote certificate chain: {validationError}"); + } + } + return new SslStream.JavaProxy.RemoteCertificateValidationResult { IsValid = true, @@ -53,5 +53,15 @@ private static SslStream.JavaProxy.RemoteCertificateValidationResult AcceptAndDe AlertToken = default, }; } + + // Mirrors SslStream.Android's ShouldRespectPlatformValidation: a caller that + // brings its own trust anchors (CustomRootTrust or CertificateContext.Trust) + // has taken responsibility for validation, so the OS verdict is ignored. + private bool ShouldRespectPlatformValidation() + { + return _options.CertificateChainPolicy is not null + ? _options.CertificateChainPolicy.TrustMode != X509ChainTrustMode.CustomRootTrust + : _options.CertificateContext?.Trust is null; + } } } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs index 08e3d3d02a28dd..6e5a8f44668c38 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -197,6 +197,10 @@ private void InitializeFromContext(TlsContext context) // supply a body (Windows, Linux/FreeBSD, macOS/iOS/tvOS, Haiku, OpenBSD). partial void InitializePlatformSpecificSessionState(); + // Android folds the JSSE trust-manager verdict captured during handshake into the + // policy errors seen by AcceptWithDefaultValidation. No-op on all other platforms. + partial void SeedPlatformValidationErrors(ref SslPolicyErrors sslPolicyErrors); + internal virtual void OnContextInitialized() { } @@ -354,6 +358,7 @@ public SslPolicyErrors AcceptWithDefaultValidation() ProtocolToken alertToken = default; SslPolicyErrors sslPolicyErrors = SslPolicyErrors.None; + SeedPlatformValidationErrors(ref sslPolicyErrors); bool ok; try { From c6a2c40625c3cb404dd6f25ea6f4a738d74eecdf Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 5 Aug 2026 14:16:09 +0200 Subject: [PATCH 6/9] Address review feedback from kotlarmilos - TlsSession.Android: move ShouldRespectPlatformValidation() check into the accept-and-defer callback so _platformChainRejected is only set when platform validation is authoritative. Closes the window where SetClientCertificateContext could mutate _options.CertificateContext between the trust-manager callback and AcceptWithDefaultValidation. - TestConfiguration.SupportsUniqueChannelBinding: use existing each OS explicitly. - Reduce verbosity of comments in TlsSession.cs, TlsSession.Android.cs and SslStream.Android.cs per review. --- .../System/Net/Security/SslStream.Android.cs | 9 --------- .../System/Net/Security/TlsSession.Android.cs | 20 +++++-------------- .../src/System/Net/Security/TlsSession.cs | 8 -------- .../FunctionalTests/TestConfiguration.cs | 7 +------ 4 files changed, 6 insertions(+), 38 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs index e090d9ac801933..55964a9644e31c 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs @@ -68,10 +68,6 @@ internal sealed class JavaProxy : IDisposable { private static bool s_initialized; - // Session-side validator. SslStream supplies one that closes over its own - // VerifyRemoteCertificate(IntPtr) method; TlsSession supplies one that routes - // the platform trust result into the session's own state. Neither implementation - // is aware of the other. private readonly Func _validator; private GCHandle? _handle; @@ -83,9 +79,6 @@ public IntPtr Handle public Exception? ValidationException { get; private set; } public RemoteCertificateValidationResult? ValidationResult { get; private set; } - // Delegate-based ctor used by TlsSession (and by the SslStream convenience overload - // below). Keeps this proxy decoupled from any specific session/stream type so both - // SslStream and TlsSession can own their own instance. public JavaProxy(Func validator) { ArgumentNullException.ThrowIfNull(validator); @@ -96,8 +89,6 @@ public JavaProxy(Func validator) _handle = GCHandle.Alloc(this); } - // Convenience overload preserved for SslStream — captures the stream's private - // VerifyRemoteCertificate(IntPtr) method as the validator. public JavaProxy(SslStream sslStream) : this(sslStream.VerifyRemoteCertificate) { diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs index c5707df8087c25..5fa62237edccd7 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs @@ -5,12 +5,6 @@ namespace System.Net.Security { - // Android-only helpers for TlsSession. JSSE's X509TrustManager is a synchronous - // decision point with no retry-verify equivalent, so we accept-and-defer at the - // trust-manager checkpoint (like OpenSSL 1.1.x / SecureTransport) and let the - // caller drive validation via NeedsCertificateValidation. We still record the - // platform verdict so AcceptWithDefaultValidation can honor it, matching - // SslStream.Android's ShouldRespectPlatformValidation behavior. public abstract partial class TlsSession { private bool _platformChainRejected; @@ -22,19 +16,18 @@ partial void InitializePlatformSpecificSessionState() partial void SeedPlatformValidationErrors(ref SslPolicyErrors sslPolicyErrors) { - if (_platformChainRejected && ShouldRespectPlatformValidation()) + if (_platformChainRejected) { sslPolicyErrors |= SslPolicyErrors.RemoteCertificateChainErrors; } } - // Invoked synchronously from Android's DotnetProxyTrustManager while JSSE processes - // the peer's certificate message. Always accepts so the handshake progresses; the - // rejection reason (if any) is recorded on the session and surfaced later through - // AcceptWithDefaultValidation or observable via _platformChainRejected. + // Invoked synchronously from Android's DotnetProxyTrustManager. Always accepts so + // the handshake progresses; the platform verdict (if respected) is recorded and + // surfaced later through AcceptWithDefaultValidation. private SslStream.JavaProxy.RemoteCertificateValidationResult AcceptAndDeferPlatformValidation(IntPtr platformValidationError) { - if (platformValidationError != IntPtr.Zero) + if (platformValidationError != IntPtr.Zero && ShouldRespectPlatformValidation()) { _platformChainRejected = true; @@ -54,9 +47,6 @@ private SslStream.JavaProxy.RemoteCertificateValidationResult AcceptAndDeferPlat }; } - // Mirrors SslStream.Android's ShouldRespectPlatformValidation: a caller that - // brings its own trust anchors (CustomRootTrust or CertificateContext.Trust) - // has taken responsibility for validation, so the OS verdict is ignored. private bool ShouldRespectPlatformValidation() { return _options.CertificateChainPolicy is not null diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs index 6e5a8f44668c38..f731b3844234bd 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -184,21 +184,13 @@ private void InitializeFromContext(TlsContext context) _ownsSessionCertificateContext = _options.OwnsCertificateContext; _options.OwnsCertificateContext = false; - // Platform-specific hook: lets a per-platform partial (e.g. Android's - // TlsSession.Android.cs) attach per-session native bridge state (the JavaProxy - // that SafeDeleteSslContext requires) to the options bag before any PAL call - // reads it. No-op on platforms whose PAL does not need such state. InitializePlatformSpecificSessionState(); OnContextInitialized(); } - // Implemented as `partial void` so it compiles to a no-op on platforms that don't - // supply a body (Windows, Linux/FreeBSD, macOS/iOS/tvOS, Haiku, OpenBSD). partial void InitializePlatformSpecificSessionState(); - // Android folds the JSSE trust-manager verdict captured during handshake into the - // policy errors seen by AcceptWithDefaultValidation. No-op on all other platforms. partial void SeedPlatformValidationErrors(ref SslPolicyErrors sslPolicyErrors); internal virtual void OnContextInitialized() diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs index 7b7f6e70337d7c..7cd09c50898a75 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TestConfiguration.cs @@ -30,12 +30,7 @@ internal static class TestConfiguration public static bool SupportsHandshakeAlerts { get { return OperatingSystem.IsLinux() || OperatingSystem.IsWindows() || OperatingSystem.IsFreeBSD() || RuntimeInformation.IsOSPlatform(OSPlatform.Create("OPENBSD")); } } public static bool SupportsRenegotiation { get { return OperatingSystem.IsWindows() || ((OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() || RuntimeInformation.IsOSPlatform(OSPlatform.Create("OPENBSD"))) && PlatformDetection.OpenSslVersion >= new Version(1, 1, 1)); } } - // Whether the platform PAL can produce a tls-unique channel binding. - // Android's PAL only implements tls-server-end-point (SslStreamPal.Android.cs - // returns null for other ChannelBindingKind values because JSSE does not - // expose the TLS Finished messages). macOS/SecureTransport has the same - // limitation for the exporter path tested by TlsSession's binding test. - public static bool SupportsUniqueChannelBinding => !OperatingSystem.IsAndroid() && !OperatingSystem.IsMacOS() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS() && !OperatingSystem.IsMacCatalyst(); + public static bool SupportsUniqueChannelBinding => PlatformDetection.IsNotMobile && !PlatformDetection.IsApplePlatform; public static readonly X509Certificate2 ServerCertificate = System.Net.Test.Common.Configuration.Certificates.GetServerCertificate(); From 8d771cc276f0ef553055f70d8527a0dedd982c20 Mon Sep 17 00:00:00 2001 From: wfurt Date: Tue, 8 Sep 2026 21:14:11 -0700 Subject: [PATCH 7/9] Annotate System.Net.Security tests unsupported on Android Enabling System.Net.Security.Tests on Android surfaced 12 deterministic failures (identical on android-arm and android-arm64). Seven are TlsSession tests hitting JSSE limitations: no deferred client-credential prompt, no server-side session cache / ticket issuance, and no retry-verify equivalent in the trust manager. Skip these on Android alongside the equivalent OSX exclusions. Four are pre-existing SslStream gaps on Android (oversized ALPN list surfacing AuthenticationException, and a hang on a zero-payload TLS frame). The last two are CertificateSelectionCallback_DelayedCertificate_OK, which already threw SkipTestException for Android but was declared [Theory]. SkipTestException is only translated into a skip for tests discovered via ConditionalFact/ConditionalTheory, so it was reported as a failure instead. Switch it to [ConditionalTheory]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FunctionalTests/CertificateValidationClientServer.cs | 2 +- .../tests/FunctionalTests/SslStreamAlpnTests.cs | 2 ++ .../tests/FunctionalTests/SslStreamFramingTest.cs | 1 + .../tests/FunctionalTests/TlsSessionTests.cs | 7 +++++-- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs index 9fd6d37bf289e9..2e9962d40df4f6 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs @@ -38,7 +38,7 @@ public void Dispose() _clientCertificate.Dispose(); } - [Theory] + [ConditionalTheory] [InlineData(true, true)] [InlineData(false, true)] [InlineData(true, false)] diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlpnTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlpnTests.cs index d2a09c6fa51bd0..3bdb7968b75280 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlpnTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAlpnTests.cs @@ -9,6 +9,7 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; +using Microsoft.DotNet.XUnitExtensions; using Xunit; using Xunit.Abstractions; @@ -242,6 +243,7 @@ public static IEnumerable Alpn_TestData() } [ConditionalFact(nameof(BackendSupportsAlpn))] + [SkipOnPlatform(TestPlatforms.Android, "JSSE rejects the oversized ALPN list during the handshake, surfacing AuthenticationException instead of ArgumentException.")] public async Task SslStream_StreamToStream_AlpnListTotalSizeExceedsLimit_Throws() { // Each protocol is 255 bytes, serialized with a 1-byte length prefix = 256 bytes each. diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamFramingTest.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamFramingTest.cs index 4164fa82981a38..88bb4b21fc0539 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamFramingTest.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamFramingTest.cs @@ -135,6 +135,7 @@ public async Task Handshake_Success(FramingType framingType, SslProtocols sslPro } [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.SupportsTls13))] + [SkipOnPlatform(TestPlatforms.Android, "SslStream hangs waiting for more data instead of detecting the complete 5-byte TLS frame.")] public async Task Read_ExactlyFiveByteTlsRecord_DetectedAsCompleteFrame() { // Regression test: a TLS record that is exactly the 5-byte header with a diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 5612f5a2374a5a..05bb579922b983 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -169,6 +169,7 @@ public async Task ServerSession_DeferredOptions_SelectedFromSni_Succeeds() [InlineData(SslProtocols.Tls12, false)] [InlineData(SslProtocols.Tls13, true)] [InlineData(SslProtocols.Tls13, false)] + [SkipOnPlatform(TestPlatforms.Android, "JSSE server-side session cache / ticket issuance is not wired up, so resumption never measurably shrinks the second handshake.")] public async Task ServerSession_TlsResume_HonorsAllowTlsResumeOption(SslProtocols protocol, bool allowResume) { if (OperatingSystem.IsMacOS()) @@ -422,6 +423,7 @@ public async Task ServerSession_MutualAuth_InitialHandshake_InvokesValidator() [Theory] [InlineData(SslProtocols.Tls12)] [InlineData(SslProtocols.Tls13)] + [SkipOnPlatform(TestPlatforms.Android, "JSSE's trust manager has no retry-verify equivalent, so the rejection is deferred and no alert reaches the client.")] public async Task SslStreamServer_RejectsClientCert_ClientObservesAlert(SslProtocols protocol) { if (protocol == SslProtocols.Tls13 && !PlatformDetection.SupportsTls13) @@ -650,7 +652,7 @@ await DriveHandshakeWithExternalValidationAsync( [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindows))] [InlineData(SslProtocols.Tls12)] [InlineData(SslProtocols.Tls13)] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not surface a deferred client-credential prompt; SslStream supplies the certificate up-front.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE surfaces a deferred client-credential prompt; the certificate must be supplied up-front.")] public async Task ClientSession_WantCredentials_SetClientCertificateContext_ResumesHandshake(SslProtocols protocol) { // Server (SslStream) demands a client certificate. The client TlsContext is @@ -2412,6 +2414,7 @@ public async Task SocketBoundSession_DeferredOptions_WithAlpn_NegotiatesProtocol // with the client's ClientHello must fail the handshake cleanly (no crash, no hang) // via the socket-replay BIO path. [Fact] + [SkipOnPlatform(TestPlatforms.Android, "JSSE does not fail the handshake on a protocol mismatch through the socket-replay BIO path; the peer hangs instead.")] public async Task SocketBoundSession_DeferredOptions_ProtocolMismatch_Fails() { if (!PlatformDetection.SupportsTls13) @@ -2767,7 +2770,7 @@ private static async Task RunServerTenantSessionAsync( // TlsContext, each supplying a distinct cert via SetClientCertificateContext, // and verify every server sees the correct client cert. [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindows))] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not surface deferred client-credential prompts.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE surfaces deferred client-credential prompts.")] public async Task SetClientCertificateContext_ConcurrentSessionsOnSharedContext_DoNotRace() { using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); From 49c7199664ec5c823091a6d3c6ce8694c7fc1b6d Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 9 Sep 2026 17:10:51 -0700 Subject: [PATCH 8/9] Address review feedback and ground Android skip reasons in verified causes Review fixes: - InitializePlatformSpecificSessionState now installs the JavaProxy only when the options bag does not already have one. Not reachable today (the wedge is not compiled for Android and Clone() does not copy SslStreamProxy), but consolidating SslStream onto TlsSession would enable the wedge there and make the overwrite and GCHandle leak live. - The platform trust verdict is assigned rather than latched, so a later validation is not tainted by an earlier rejection. Skip reasons now cite mechanisms confirmed against the source rather than inferred from symptoms: - Deferred client credentials are OpenSSL-only. CredentialsNeeded is produced by the Windows, OpenSSL, Unix and OSX PALs but not by SslStreamPal.Android; JSSE takes the KeyManagers up-front at SSLContext.init, so no mid-handshake CertificateRequest is surfaced. - AndroidCryptoNative_SSLStreamCreate builds a fresh SSLContext per session, so the JSSE server session cache is never shared between connections and the second handshake cannot resume. - The protocol-mismatch reason now states only the observed behavior. The earlier text blamed unapplied EnabledSslProtocols, which is wrong: SafeDeleteSslContext applies them correctly. Root cause is not yet established and the reason says so. Verified on an android-arm64 emulator: 4892 passed, 0 failed, 38 skipped, matching the 8-round stability baseline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System/Net/Security/TlsSession.Android.cs | 23 +++++++++++-------- .../tests/FunctionalTests/TlsSessionTests.cs | 8 +++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs index 5fa62237edccd7..288291b7f44158 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.Android.cs @@ -11,7 +11,10 @@ public abstract partial class TlsSession partial void InitializePlatformSpecificSessionState() { - _options.SslStreamProxy = new SslStream.JavaProxy(AcceptAndDeferPlatformValidation); + // In wedge mode the options bag is shared with SslStream, which installs its own + // proxy in its constructor. Only supply one when the bag does not already have it, + // so SslStream's callback routing stays intact and its JavaProxy is not leaked. + _options.SslStreamProxy ??= new SslStream.JavaProxy(AcceptAndDeferPlatformValidation); } partial void SeedPlatformValidationErrors(ref SslPolicyErrors sslPolicyErrors) @@ -24,18 +27,18 @@ partial void SeedPlatformValidationErrors(ref SslPolicyErrors sslPolicyErrors) // Invoked synchronously from Android's DotnetProxyTrustManager. Always accepts so // the handshake progresses; the platform verdict (if respected) is recorded and - // surfaced later through AcceptWithDefaultValidation. + // surfaced later through AcceptWithDefaultValidation. The verdict is assigned rather + // than latched so a later validation (e.g. renegotiation with a different chain) is + // not tainted by an earlier rejection. private SslStream.JavaProxy.RemoteCertificateValidationResult AcceptAndDeferPlatformValidation(IntPtr platformValidationError) { - if (platformValidationError != IntPtr.Zero && ShouldRespectPlatformValidation()) - { - _platformChainRejected = true; + bool rejected = platformValidationError != IntPtr.Zero && ShouldRespectPlatformValidation(); + _platformChainRejected = rejected; - if (NetEventSource.Log.IsEnabled()) - { - string? validationError = Interop.AndroidCrypto.GetPlatformValidationError(platformValidationError); - NetEventSource.Error(this, $"The Android platform trust manager rejected the remote certificate chain: {validationError}"); - } + if (rejected && NetEventSource.Log.IsEnabled()) + { + string? validationError = Interop.AndroidCrypto.GetPlatformValidationError(platformValidationError); + NetEventSource.Error(this, $"The Android platform trust manager rejected the remote certificate chain: {validationError}"); } return new SslStream.JavaProxy.RemoteCertificateValidationResult diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 05bb579922b983..1b8516ea705d4f 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -169,7 +169,7 @@ public async Task ServerSession_DeferredOptions_SelectedFromSni_Succeeds() [InlineData(SslProtocols.Tls12, false)] [InlineData(SslProtocols.Tls13, true)] [InlineData(SslProtocols.Tls13, false)] - [SkipOnPlatform(TestPlatforms.Android, "JSSE server-side session cache / ticket issuance is not wired up, so resumption never measurably shrinks the second handshake.")] + [SkipOnPlatform(TestPlatforms.Android, "Each Android session builds its own SSLContext, so the JSSE server session cache is never shared between connections and the second handshake cannot resume.")] public async Task ServerSession_TlsResume_HonorsAllowTlsResumeOption(SslProtocols protocol, bool allowResume) { if (OperatingSystem.IsMacOS()) @@ -652,7 +652,7 @@ await DriveHandshakeWithExternalValidationAsync( [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindows))] [InlineData(SslProtocols.Tls12)] [InlineData(SslProtocols.Tls13)] - [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE surfaces a deferred client-credential prompt; the certificate must be supplied up-front.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Deferred client credentials are an OpenSSL-only flow: JSSE takes the KeyManagers up-front at SSLContext.init and the Android PAL never reports CredentialsNeeded, so no mid-handshake CertificateRequest is surfaced.")] public async Task ClientSession_WantCredentials_SetClientCertificateContext_ResumesHandshake(SslProtocols protocol) { // Server (SslStream) demands a client certificate. The client TlsContext is @@ -2414,7 +2414,7 @@ public async Task SocketBoundSession_DeferredOptions_WithAlpn_NegotiatesProtocol // with the client's ClientHello must fail the handshake cleanly (no crash, no hang) // via the socket-replay BIO path. [Fact] - [SkipOnPlatform(TestPlatforms.Android, "JSSE does not fail the handshake on a protocol mismatch through the socket-replay BIO path; the peer hangs instead.")] + [SkipOnPlatform(TestPlatforms.Android, "The deferred-options protocol mismatch does not surface as a handshake failure on Android; both peers stall and the test times out. Root cause not yet established.")] public async Task SocketBoundSession_DeferredOptions_ProtocolMismatch_Fails() { if (!PlatformDetection.SupportsTls13) @@ -2770,7 +2770,7 @@ private static async Task RunServerTenantSessionAsync( // TlsContext, each supplying a distinct cert via SetClientCertificateContext, // and verify every server sees the correct client cert. [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindows))] - [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE surfaces deferred client-credential prompts.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Depends on the deferred client-credential flow, which the Android PAL does not report (no CredentialsNeeded).")] public async Task SetClientCertificateContext_ConcurrentSessionsOnSharedContext_DoNotRace() { using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); From 7a994d9e7b1a26b837627a9aaceadc8f3b56783f Mon Sep 17 00:00:00 2001 From: wfurt Date: Thu, 10 Sep 2026 12:28:44 -0700 Subject: [PATCH 9/9] Skip post-handshake client auth tests on Android Merging main brought in four new RequestClientCertificate tests that skip macOS but not Android. Enabling System.Net.Security.Tests on Android makes them run there, where the PAL throws PlatformNotSupportedException: Android has no post-handshake client authentication, the same gap SecureTransport has. This matches how the rest of the file already treats Android: TestConfiguration.SupportsRenegotiation excludes it, and the older sibling ServerSession_RequestClientCertificate_Tls12_ProducesHandshakeBytes is gated on that property and skips correctly. android-arm64 emulator, after the merge: 4906 passed, 0 failed, 38 skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/FunctionalTests/TlsSessionTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 3d28c06a9b1aae..583b7dde90fdbc 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -2022,7 +2022,7 @@ public async Task ServerSession_RequestClientCertificate_Tls12_ProducesHandshake [Theory] [InlineData(SslProtocols.Tls12)] [InlineData(SslProtocols.Tls13)] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not support post-handshake client authentication.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE supports post-handshake client authentication.")] public async Task ServerSession_RequestClientCertificate_DrivesSecondHandshakeToCompletion(SslProtocols protocol) { if (protocol == SslProtocols.Tls13 && !PlatformDetection.SupportsTls13) @@ -2107,7 +2107,7 @@ public async Task ServerSession_RequestClientCertificate_DrivesSecondHandshakeTo [Theory] [InlineData(SslProtocols.Tls12)] [InlineData(SslProtocols.Tls13)] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not support post-handshake client authentication.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE supports post-handshake client authentication.")] public async Task ServerSession_RequestClientCertificate_SessionRemainsUsable(SslProtocols protocol) { if (protocol == SslProtocols.Tls13 && !PlatformDetection.SupportsTls13) @@ -2179,7 +2179,7 @@ public async Task ServerSession_RequestClientCertificate_SessionRemainsUsable(Ss [Theory] [InlineData(SslProtocols.Tls12)] [InlineData(SslProtocols.Tls13)] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not support post-handshake client authentication.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE supports post-handshake client authentication.")] public async Task ServerSession_RequestClientCertificate_ReadWriteDuringSecondHandshake_Throws(SslProtocols protocol) { if (protocol == SslProtocols.Tls13 && !PlatformDetection.SupportsTls13) @@ -2799,7 +2799,7 @@ public async Task SocketBoundSession_MutualAuth_InitialHandshake_SurfacesSuspens [Theory] [InlineData(SslProtocols.Tls12)] [InlineData(SslProtocols.Tls13)] - [SkipOnPlatform(TestPlatforms.OSX, "SecureTransport does not support post-handshake client authentication.")] + [SkipOnPlatform(TestPlatforms.OSX | TestPlatforms.Android, "Neither SecureTransport nor JSSE supports post-handshake client authentication.")] public async Task SocketBoundSession_RequestClientCertificate_DrivesSecondHandshakeToCompletion(SslProtocols protocol) { if (protocol == SslProtocols.Tls13 && !PlatformDetection.SupportsTls13)