From 06ea80430968f3133c7d43b505798ffe4fc52ed1 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 9 Feb 2026 13:53:29 +0100 Subject: [PATCH 01/17] [Android] Respect platform trust manager in SslStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android, SslStream's DotnetProxyTrustManager previously bypassed the platform's trust infrastructure entirely. This meant that Android's network-security-config.xml (certificate pinning, custom trust anchors) was ignored, and RemoteCertificateValidationCallback always received SslPolicyErrors.None even when the platform would have rejected the certificate chain. This change wraps the platform's default X509TrustManager inside DotnetProxyTrustManager so that Android's trust infrastructure is consulted before delegating to the managed SslStream validation code. Changes: - DotnetProxyTrustManager.java now wraps the platform X509TrustManager and calls checkServerTrusted/checkClientTrusted before invoking the managed callback. Uses X509TrustManagerExtensions for hostname-aware validation on API 24+. - The targetHost is passed through SSLStreamCreate* to GetTrustManagers so the trust manager can perform hostname-aware validation. - SSLStreamSetTargetHost is removed; SNI setup is now done in SSLStreamInitialize. - VerifyRemoteCertificate receives a chainTrustedByPlatform flag. When the platform rejects the chain, RemoteCertificateChainErrors is reported. When the platform trusts it, AllowUnknownCertificateAuthority is set to prevent the managed chain builder from re-introducing errors for CAs that are trusted by the platform but not in the managed store. - AndroidAppBuilder supports NetworkSecurityConfig to include network_security_config.xml in test APKs. Behavioral change: Apps using managed-only custom trust roots for CAs not in the Android system store will now see RemoteCertificateChainErrors in the callback (previously SslPolicyErrors.None due to platform bypass). This is correct — the callback accurately reflects the platform's trust assessment, and apps can still accept by returning true. Fixes #107695 --- .../Interop.Ssl.cs | 34 ++-- .../Pal.Android/SafeDeleteSslContext.cs | 24 ++- .../System/Net/Security/SslStream.Android.cs | 47 ++++- .../src/System/Net/Security/SslStream.IO.cs | 3 +- .../System/Net/Security/SslStream.Protocol.cs | 3 +- .../AndroidPlatformTrustTests.cs | 190 ++++++++++++++++++ ...Security.AndroidPlatformTrust.Tests.csproj | 57 ++++++ .../network_security_config.xml | 11 + ...StreamPlatformTrustManagerTests.Android.cs | 126 ++++++++++++ .../System.Net.Security.Tests.csproj | 5 +- .../android/build/AndroidBuild.targets | 2 + .../crypto/DotnetProxyTrustManager.java | 51 ++++- .../pal_jni.c | 20 +- .../pal_jni.h | 10 + .../pal_sslstream.c | 149 +++++++------- .../pal_sslstream.h | 16 +- .../pal_trust_manager.c | 65 +++++- .../pal_trust_manager.h | 6 +- .../AndroidAppBuilder/AndroidAppBuilder.cs | 14 ++ src/tasks/AndroidAppBuilder/ApkBuilder.cs | 42 +++- .../Templates/AndroidManifest.xml | 2 +- 21 files changed, 725 insertions(+), 152 deletions(-) create mode 100644 src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs create mode 100644 src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj create mode 100644 src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml create mode 100644 src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamPlatformTrustManagerTests.Android.cs diff --git a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs index 91b7136059f24d..b1db4e2328bca1 100644 --- a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs +++ b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs @@ -17,8 +17,6 @@ internal static partial class Interop { internal static partial class AndroidCrypto { - private const int UNSUPPORTED_API_LEVEL = 2; - internal enum PAL_SSLStreamStatus { OK = 0, @@ -29,13 +27,16 @@ internal enum PAL_SSLStreamStatus }; [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreate")] - private static partial SafeSslHandle SSLStreamCreate(IntPtr sslStreamProxyHandle); - internal static SafeSslHandle SSLStreamCreate(SslStream.JavaProxy sslStreamProxy) - => SSLStreamCreate(sslStreamProxy.Handle); + private static partial SafeSslHandle SSLStreamCreate( + IntPtr sslStreamProxyHandle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? targetHost); + internal static SafeSslHandle SSLStreamCreate(SslStream.JavaProxy sslStreamProxy, string? targetHost) + => SSLStreamCreate(sslStreamProxy.Handle, targetHost); [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateWithCertificates")] private static partial SafeSslHandle SSLStreamCreateWithCertificates( IntPtr sslStreamProxyHandle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? targetHost, ref byte pkcs8PrivateKey, int pkcs8PrivateKeyLen, PAL_KeyAlgorithm algorithm, @@ -43,12 +44,14 @@ private static partial SafeSslHandle SSLStreamCreateWithCertificates( int certsLen); internal static SafeSslHandle SSLStreamCreateWithCertificates( SslStream.JavaProxy sslStreamProxy, + string? targetHost, ReadOnlySpan pkcs8PrivateKey, PAL_KeyAlgorithm algorithm, IntPtr[] certificates) { return SSLStreamCreateWithCertificates( sslStreamProxy.Handle, + targetHost, ref MemoryMarshal.GetReference(pkcs8PrivateKey), pkcs8PrivateKey.Length, algorithm, @@ -59,17 +62,19 @@ ref MemoryMarshal.GetReference(pkcs8PrivateKey), [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry")] private static partial SafeSslHandle SSLStreamCreateWithKeyStorePrivateKeyEntry( IntPtr sslStreamProxyHandle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? targetHost, IntPtr keyStorePrivateKeyEntryHandle); internal static SafeSslHandle SSLStreamCreateWithKeyStorePrivateKeyEntry( SslStream.JavaProxy sslStreamProxy, + string? targetHost, IntPtr keyStorePrivateKeyEntryHandle) { - return SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy.Handle, keyStorePrivateKeyEntryHandle); + return SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy.Handle, targetHost, keyStorePrivateKeyEntryHandle); } [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_RegisterRemoteCertificateValidationCallback")] internal static unsafe partial void RegisterRemoteCertificateValidationCallback( - delegate* unmanaged verifyRemoteCertificate); + delegate* unmanaged verifyRemoteCertificate); [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamInitialize")] private static unsafe partial int SSLStreamInitializeImpl( @@ -96,21 +101,6 @@ internal static unsafe void SSLStreamInitialize( throw new SslException(); } - [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamSetTargetHost")] - private static partial int SSLStreamSetTargetHostImpl( - SafeSslHandle sslHandle, - [MarshalAs(UnmanagedType.LPUTF8Str)] string targetHost); - internal static void SSLStreamSetTargetHost( - SafeSslHandle sslHandle, - string targetHost) - { - int ret = SSLStreamSetTargetHostImpl(sslHandle, targetHost); - if (ret == UNSUPPORTED_API_LEVEL) - throw new PlatformNotSupportedException(SR.net_android_ssl_api_level_unsupported); - else if (ret != SUCCESS) - throw new SslException(); - } - [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamIsLocalCertificateUsed")] [return: MarshalAs(UnmanagedType.U1)] internal static partial bool SSLStreamIsLocalCertificateUsed(SafeSslHandle sslHandle); 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 844b0f6095c1c2..7496c7086424cc 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 @@ -209,9 +209,10 @@ internal int ReadPendingWrites(byte[] buf, int offset, int count) private static SafeSslHandle CreateSslContext(SslStream.JavaProxy sslStreamProxy, SslAuthenticationOptions authOptions) { + string? targetHost = GetTargetHostIfAvailable(authOptions); if (authOptions.CertificateContext == null) { - return Interop.AndroidCrypto.SSLStreamCreate(sslStreamProxy); + return Interop.AndroidCrypto.SSLStreamCreate(sslStreamProxy, targetHost); } SslStreamCertificateContext context = authOptions.CertificateContext; @@ -220,7 +221,7 @@ private static SafeSslHandle CreateSslContext(SslStream.JavaProxy sslStreamProxy if (Interop.AndroidCrypto.IsKeyStorePrivateKeyEntry(cert.Handle)) { - return Interop.AndroidCrypto.SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy, cert.Handle); + return Interop.AndroidCrypto.SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy, targetHost, cert.Handle); } PAL_KeyAlgorithm algorithm; @@ -236,7 +237,15 @@ private static SafeSslHandle CreateSslContext(SslStream.JavaProxy sslStreamProxy ptrs[i + 1] = context.IntermediateCertificates[i].Handle; } - return Interop.AndroidCrypto.SSLStreamCreateWithCertificates(sslStreamProxy, keyBytes, algorithm, ptrs); + return Interop.AndroidCrypto.SSLStreamCreateWithCertificates(sslStreamProxy, targetHost, keyBytes, algorithm, ptrs); + + static string? GetTargetHostIfAvailable(SslAuthenticationOptions authOptions) + => !authOptions.IsServer && IsValidTargetHost(authOptions.TargetHost) + ? authOptions.TargetHost + : null; + + static bool IsValidTargetHost(string? targetHost) + => !string.IsNullOrEmpty(targetHost) && !IPAddress.IsValid(targetHost); } private static AsymmetricAlgorithm GetPrivateKeyAlgorithm(X509Certificate2 cert, out PAL_KeyAlgorithm algorithm) @@ -288,7 +297,9 @@ private unsafe void InitializeSslContext( // Make sure the class instance is associated to the session and is provided in the Read/Write callback connection parameter // Additionally, all calls should be synchronous so there's no risk of the managed object being collected while native code is executing. IntPtr managedContextHandle = GCHandle.ToIntPtr(GCHandle.Alloc(this, GCHandleType.Weak)); - string? peerHost = !isServer && !string.IsNullOrEmpty(authOptions.TargetHost) ? authOptions.TargetHost : null; + string? peerHost = !isServer && !string.IsNullOrEmpty(authOptions.TargetHost) && !IPAddress.IsValid(authOptions.TargetHost) + ? authOptions.TargetHost + : null; Interop.AndroidCrypto.SSLStreamInitialize(handle, isServer, managedContextHandle, &ReadFromConnection, &WriteToConnection, &CleanupManagedContext, InitialBufferSize, peerHost); if (authOptions.EnabledSslProtocols != SslProtocols.None) @@ -314,11 +325,6 @@ private unsafe void InitializeSslContext( { Interop.AndroidCrypto.SSLStreamRequestClientAuthentication(handle); } - - if (!isServer && !string.IsNullOrEmpty(authOptions.TargetHost) && !IPAddress.IsValid(authOptions.TargetHost)) - { - Interop.AndroidCrypto.SSLStreamSetTargetHost(handle, authOptions.TargetHost); - } } } } 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 317375cec0b1d4..9818c092c64e82 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 @@ -1,27 +1,58 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Diagnostics; -using System.Net.Security; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; -using System.Threading; namespace System.Net.Security { public partial class SslStream { - private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate() + private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate(bool chainTrustedByPlatform) { + // If the platform's trust manager rejected the certificate chain, + // report RemoteCertificateChainErrors so the callback can handle it. + SslPolicyErrors sslPolicyErrors = chainTrustedByPlatform + ? SslPolicyErrors.None + : SslPolicyErrors.RemoteCertificateChainErrors; + ProtocolToken alertToken = default; + + RemoteCertificateValidationCallback? userCallback = _sslAuthenticationOptions.CertValidationDelegate; + RemoteCertificateValidationCallback? effectiveCallback = userCallback; + + if (chainTrustedByPlatform) + { + // The platform's trust manager (which respects network-security-config.xml) + // already validated the certificate chain. The managed X509 chain builder may + // report RemoteCertificateChainErrors because the root CA trusted by the platform + // is not in the managed certificate store (e.g. PartialChain or UntrustedRoot). + // Strip chain errors — the platform's assessment is authoritative for chain trust. + // + // We wrap (or provide) the callback so that: + // 1. User callbacks see errors without spurious RemoteCertificateChainErrors. + // 2. When no user callback is set, the default "accept if no errors" logic + // doesn't reject connections that the platform already accepted. + effectiveCallback = userCallback is not null + ? (sender, certificate, chain, errors) => + userCallback(sender, certificate, chain, errors & ~SslPolicyErrors.RemoteCertificateChainErrors) + : (sender, certificate, chain, errors) => + (errors & ~SslPolicyErrors.RemoteCertificateChainErrors) == SslPolicyErrors.None; + } + var isValid = VerifyRemoteCertificate( - _sslAuthenticationOptions.CertValidationDelegate, + effectiveCallback, _sslAuthenticationOptions.CertificateContext?.Trust, ref alertToken, - out SslPolicyErrors sslPolicyErrors, + ref sslPolicyErrors, out X509ChainStatusFlags chainStatus); + if (chainTrustedByPlatform) + { + sslPolicyErrors &= ~SslPolicyErrors.RemoteCertificateChainErrors; + } + return new() { IsValid = isValid, @@ -80,7 +111,7 @@ private static unsafe void RegisterRemoteCertificateValidationCallback() } [UnmanagedCallersOnly] - private static unsafe bool VerifyRemoteCertificate(IntPtr sslStreamProxyHandle) + private static unsafe bool VerifyRemoteCertificate(IntPtr sslStreamProxyHandle, int chainTrustedByPlatform) { var proxy = (JavaProxy?)GCHandle.FromIntPtr(sslStreamProxyHandle).Target; Debug.Assert(proxy is not null); @@ -88,7 +119,7 @@ private static unsafe bool VerifyRemoteCertificate(IntPtr sslStreamProxyHandle) try { - proxy.ValidationResult = proxy._sslStream.VerifyRemoteCertificate(); + proxy.ValidationResult = proxy._sslStream.VerifyRemoteCertificate(chainTrustedByPlatform != 0); return proxy.ValidationResult.IsValid; } catch (Exception exception) 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 cda4db5e7f7cad..7d33d932444768 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 @@ -601,7 +601,8 @@ private bool CompleteHandshake(ref ProtocolToken alertToken, out SslPolicyErrors } #endif - if (!VerifyRemoteCertificate(_sslAuthenticationOptions.CertValidationDelegate, _sslAuthenticationOptions.CertificateContext?.Trust, ref alertToken, out sslPolicyErrors, out chainStatus)) + sslPolicyErrors = SslPolicyErrors.None; + if (!VerifyRemoteCertificate(_sslAuthenticationOptions.CertValidationDelegate, _sslAuthenticationOptions.CertificateContext?.Trust, ref alertToken, ref sslPolicyErrors, out chainStatus)) { _handshakeCompleted = false; return false; 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 50711bd4eaba8c..9013b43a0ab0d1 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 @@ -1038,9 +1038,8 @@ internal SecurityStatusPal Decrypt(Span buffer, out int outputOffset, out --*/ //This method validates a remote certificate. - internal bool VerifyRemoteCertificate(RemoteCertificateValidationCallback? remoteCertValidationCallback, SslCertificateTrust? trust, ref ProtocolToken alertToken, out SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus) + internal bool VerifyRemoteCertificate(RemoteCertificateValidationCallback? remoteCertValidationCallback, SslCertificateTrust? trust, ref ProtocolToken alertToken, ref SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus) { - sslPolicyErrors = SslPolicyErrors.None; chainStatus = X509ChainStatusFlags.NoError; // We don't catch exceptions in this method, so it's safe for "accepted" be initialized with true. diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs new file mode 100644 index 00000000000000..9d901a1a365c65 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs @@ -0,0 +1,190 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Xunit; + +namespace System.Net.Security.Tests +{ + using Configuration = System.Net.Test.Common.Configuration; + + /// + /// Tests for Android's network_security_config.xml integration with SslStream. + /// + /// This test project is bundled into an APK with a network_security_config.xml that + /// trusts the NDX Test Root CA (from contoso.com.p7b) for the domain + /// "testservereku.contoso.com". The root CA DER file is extracted at build time + /// from the System.Net.TestData package and placed in res/raw/test_ca.der. + /// + /// Certificate hierarchy from System.Net.TestData: + /// - NDX Test Root CA (root, self-signed) — trusted in network_security_config.xml + /// └─ testservereku.contoso.com (leaf, CA-signed) + /// - testselfsignedservereku.contoso.com (self-signed, different CA) + /// + public class AndroidPlatformTrustTests + { + [Fact] + public async Task SslStream_CertificateSignedByTrustedCA_NoChainErrors() + { + // The server uses testservereku.contoso.com.pfx which is signed by the NDX Test Root CA. + // The network_security_config.xml trusts that root CA for "testservereku.contoso.com". + // The platform trust manager should accept this chain, so no chain errors are reported. + + SslPolicyErrors? reportedErrors = null; + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetServerCertificate()) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "testservereku.contoso.com", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + reportedErrors = sslPolicyErrors; + return true; + } + }; + + await Task.WhenAll( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)).WaitAsync(TimeSpan.FromSeconds(30)); + } + + Assert.NotNull(reportedErrors); + Assert.Equal(SslPolicyErrors.None, reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors); + } + + [Fact] + public async Task SslStream_CertificateNotSignedByTrustedCA_ReportsChainErrors() + { + // The server uses a self-signed certificate that is NOT signed by the NDX Test Root CA. + // The network_security_config.xml only trusts the NDX Test Root CA, so the platform + // trust manager rejects this certificate chain — simulating a certificate pinning mismatch. + + SslPolicyErrors? reportedErrors = null; + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetSelfSignedServerCertificate()) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "testservereku.contoso.com", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + reportedErrors = sslPolicyErrors; + return true; + } + }; + + await Task.WhenAll( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)).WaitAsync(TimeSpan.FromSeconds(30)); + } + + Assert.NotNull(reportedErrors); + Assert.NotEqual(SslPolicyErrors.None, reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors); + } + + [Fact] + public async Task SslStream_CallbackCanOverridePlatformTrustFailure() + { + // Even when the platform trust manager rejects the certificate chain, + // the RemoteCertificateValidationCallback can override the decision and + // allow the connection to succeed. + + bool callbackInvoked = false; + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetSelfSignedServerCertificate()) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "testservereku.contoso.com", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + callbackInvoked = true; + return true; + } + }; + + await Task.WhenAll( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)).WaitAsync(TimeSpan.FromSeconds(30)); + } + + Assert.True(callbackInvoked); + } + + [Fact] + public async Task SslStream_CallbackRejectingUntrustedCertificate_ThrowsAuthenticationException() + { + // When the callback returns false for an untrusted certificate, the connection fails. + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetSelfSignedServerCertificate()) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "testservereku.contoso.com", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => false + }; + + Task serverTask = server.AuthenticateAsServerAsync(serverOptions); + await Assert.ThrowsAsync(() => + client.AuthenticateAsClientAsync(clientOptions)); + + // Server side may throw too since the client rejected the connection. + try { await serverTask.WaitAsync(TimeSpan.FromSeconds(5)); } + catch { } + } + } + + private static (SslStream client, SslStream server) GetConnectedSslStreams() + { + using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(1); + + var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + clientSocket.Connect(listener.LocalEndPoint!); + + Socket serverSocket = listener.Accept(); + + var clientStream = new SslStream(new NetworkStream(clientSocket, ownsSocket: true)); + var serverStream = new SslStream(new NetworkStream(serverSocket, ownsSocket: true)); + + return (clientStream, serverStream); + } + } +} diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj new file mode 100644 index 00000000000000..875a790a5a9b5d --- /dev/null +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj @@ -0,0 +1,57 @@ + + + + $(NetCoreAppCurrent)-android + + + + + + + + + + + + + + + + + <_NetworkSecurityConfigDir>$(IntermediateOutputPath)network-security-config + + + + + + <_CaCertP7bPath>$(OutputPath)TestData/contoso.com.p7b + <_CaCertDerPath>$(_NetworkSecurityConfigDir)/res/raw/test_ca.der + <_FinalNetworkSecurityConfigPath>$(_NetworkSecurityConfigDir)/network_security_config.xml + + + + + + + + + + + + + + + + + $(IntermediateOutputPath)network-security-config/network_security_config.xml + $(IntermediateOutputPath)network-security-config/res + + + diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml new file mode 100644 index 00000000000000..c0aa6bb18de5ae --- /dev/null +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + testservereku.contoso.com + + + + + + diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamPlatformTrustManagerTests.Android.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamPlatformTrustManagerTests.Android.cs new file mode 100644 index 00000000000000..779dbf6f9ef2cf --- /dev/null +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamPlatformTrustManagerTests.Android.cs @@ -0,0 +1,126 @@ +// 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.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Xunit; + +namespace System.Net.Security.Tests +{ + using Configuration = System.Net.Test.Common.Configuration; + + public class SslStreamPlatformTrustManagerTests + { + [Fact] + public async Task SslStream_UntrustedCertificate_ReportsChainErrors() + { + // This test verifies that Android's platform trust manager is consulted. + // A self-signed certificate is not in any trust store, so the platform + // should report chain errors. + + SslPolicyErrors? reportedErrors = null; + + (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetSelfSignedServerCertificate()) + { + SslServerAuthenticationOptions serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "localhost", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + reportedErrors = sslPolicyErrors; + return true; + } + }; + + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); + } + + Assert.NotNull(reportedErrors); + Assert.True( + (reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors) != 0, + $"Expected RemoteCertificateChainErrors but got: {reportedErrors.Value}"); + } + + [Fact] + public async Task SslStream_UntrustedCertificate_CallbackCanOverride() + { + // This test verifies that even when the platform trust manager rejects + // the certificate chain, the RemoteCertificateValidationCallback can + // still accept the connection. + + bool callbackInvoked = false; + + (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetSelfSignedServerCertificate()) + { + SslServerAuthenticationOptions serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "localhost", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + callbackInvoked = true; + return true; + } + }; + + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); + + Assert.True(callbackInvoked); + Assert.True(client.IsAuthenticated); + Assert.True(server.IsAuthenticated); + } + } + + [Fact] + public async Task SslStream_UntrustedCertificate_CallbackRejectingCausesFailure() + { + // This test verifies that when the callback returns false for an + // untrusted certificate, the connection fails. + + (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetSelfSignedServerCertificate()) + { + SslServerAuthenticationOptions serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "localhost", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + return false; + } + }; + + await Assert.ThrowsAsync(() => + TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions))); + } + } + } +} 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 79610468fe7995..e21cf00da705fc 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 @@ -2,7 +2,7 @@ true true - $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-ios + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-ios;$(NetCoreAppCurrent)-android true true true @@ -161,6 +161,9 @@ + + + diff --git a/src/mono/msbuild/android/build/AndroidBuild.targets b/src/mono/msbuild/android/build/AndroidBuild.targets index 96e8607dd80dab..2de96330129725 100644 --- a/src/mono/msbuild/android/build/AndroidBuild.targets +++ b/src/mono/msbuild/android/build/AndroidBuild.targets @@ -261,6 +261,8 @@ MainLibraryFileName="$(MainLibraryFileName)" RuntimeHeaders="@(RuntimeHeaders)" NativeDependencies="@(_NativeDependencies)" + NetworkSecurityConfig="$(NetworkSecurityConfig)" + NetworkSecurityConfigResourcesDir="$(NetworkSecurityConfigResourcesDir)" OutputDir="$(AndroidBundleDir)" ProjectName="$(AppName)" RuntimeComponents="@(RuntimeComponents)" diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java index 1e8baed9bf45ca..8eff37fc187b34 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java +++ b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java @@ -3,40 +3,73 @@ package net.dot.android.crypto; +import android.net.http.X509TrustManagerExtensions; +import android.os.Build; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import javax.net.ssl.X509TrustManager; /** - * This class is meant to replace the built-in X509TrustManager. - * Its sole responsibility is to invoke the C# code in the SslStream - * class during TLS handshakes to perform the validation of the remote - * peer's certificate. + * This class wraps the platform's default X509TrustManager to first consult + * Android's trust infrastructure (which respects network-security-config.xml), + * then delegates to the managed SslStream code for final validation. */ public final class DotnetProxyTrustManager implements X509TrustManager { private final long sslStreamProxyHandle; + private final X509TrustManager platformTrustManager; + private final X509TrustManagerExtensions trustManagerExtensions; + private final String targetHost; - public DotnetProxyTrustManager(long sslStreamProxyHandle) { + public DotnetProxyTrustManager(long sslStreamProxyHandle, X509TrustManager platformTrustManager, String targetHost) { this.sslStreamProxyHandle = sslStreamProxyHandle; + this.platformTrustManager = platformTrustManager; + this.targetHost = targetHost; + this.trustManagerExtensions = new X509TrustManagerExtensions(platformTrustManager); } public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - if (!verifyRemoteCertificate(sslStreamProxyHandle)) { + boolean platformTrusted = isClientTrustedByPlatformTrustManager(chain, authType); + if (!verifyRemoteCertificate(sslStreamProxyHandle, platformTrusted)) { throw new CertificateException(); } } public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { - if (!verifyRemoteCertificate(sslStreamProxyHandle)) { + boolean platformTrusted = isServerTrustedByPlatformTrustManager(chain, authType); + if (!verifyRemoteCertificate(sslStreamProxyHandle, platformTrusted)) { throw new CertificateException(); } } + private boolean isServerTrustedByPlatformTrustManager(X509Certificate[] chain, String authType) { + try { + if (targetHost != null && Build.VERSION.SDK_INT >= 24) { + // Use hostname-aware validation (API 24+) for server certificates + trustManagerExtensions.checkServerTrusted(chain, authType, targetHost); + } else { + // Fallback for API 21-23: use basic validation without hostname + platformTrustManager.checkServerTrusted(chain, authType); + } + return true; + } catch (CertificateException e) { + return false; + } + } + + private boolean isClientTrustedByPlatformTrustManager(X509Certificate[] chain, String authType) { + try { + platformTrustManager.checkClientTrusted(chain, authType); + return true; + } catch (CertificateException e) { + return false; + } + } + public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; + return platformTrustManager.getAcceptedIssuers(); } - static native boolean verifyRemoteCertificate(long sslStreamProxyHandle); + static native boolean verifyRemoteCertificate(long sslStreamProxyHandle, boolean chainTrustedByPlatform); } diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c index ecb0c1f8ec43f9..a460e7a312d84f 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c @@ -487,6 +487,16 @@ jmethodID g_KeyAgreementGenerateSecret; // javax/net/ssl/TrustManager jclass g_TrustManager; +// javax/net/ssl/TrustManagerFactory +jclass g_TrustManagerFactory; +jmethodID g_TrustManagerFactoryGetInstance; +jmethodID g_TrustManagerFactoryGetDefaultAlgorithm; +jmethodID g_TrustManagerFactoryInit; +jmethodID g_TrustManagerFactoryGetTrustManagers; + +// javax/net/ssl/X509TrustManager +jclass g_X509TrustManager; + // net/dot/android/crypto/DotnetProxyTrustManager jclass g_DotnetProxyTrustManager; jmethodID g_DotnetProxyTrustManagerCtor; @@ -1103,8 +1113,16 @@ jint AndroidCryptoNative_InitLibraryOnLoad (JavaVM *vm, void *reserved) g_TrustManager = GetClassGRef(env, "javax/net/ssl/TrustManager"); + g_TrustManagerFactory = GetClassGRef(env, "javax/net/ssl/TrustManagerFactory"); + g_TrustManagerFactoryGetInstance = GetMethod(env, true, g_TrustManagerFactory, "getInstance", "(Ljava/lang/String;)Ljavax/net/ssl/TrustManagerFactory;"); + g_TrustManagerFactoryGetDefaultAlgorithm = GetMethod(env, true, g_TrustManagerFactory, "getDefaultAlgorithm", "()Ljava/lang/String;"); + g_TrustManagerFactoryInit = GetMethod(env, false, g_TrustManagerFactory, "init", "(Ljava/security/KeyStore;)V"); + g_TrustManagerFactoryGetTrustManagers = GetMethod(env, false, g_TrustManagerFactory, "getTrustManagers", "()[Ljavax/net/ssl/TrustManager;"); + + g_X509TrustManager = GetClassGRef(env, "javax/net/ssl/X509TrustManager"); + g_DotnetProxyTrustManager = GetClassGRef(env, "net/dot/android/crypto/DotnetProxyTrustManager"); - g_DotnetProxyTrustManagerCtor = GetMethod(env, false, g_DotnetProxyTrustManager, "", "(J)V"); + g_DotnetProxyTrustManagerCtor = GetMethod(env, false, g_DotnetProxyTrustManager, "", "(JLjavax/net/ssl/X509TrustManager;Ljava/lang/String;)V"); g_DotnetX509KeyManager = GetClassGRef(env, "net/dot/android/crypto/DotnetX509KeyManager"); g_DotnetX509KeyManagerCtor = GetMethod(env, false, g_DotnetX509KeyManager, "", "(Ljava/security/KeyStore$PrivateKeyEntry;)V"); diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h index 2828a68dc03c03..5534a1f44e34e8 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h @@ -501,6 +501,16 @@ extern jmethodID g_KeyAgreementGenerateSecret; // javax/net/ssl/TrustManager extern jclass g_TrustManager; +// javax/net/ssl/TrustManagerFactory +extern jclass g_TrustManagerFactory; +extern jmethodID g_TrustManagerFactoryGetInstance; +extern jmethodID g_TrustManagerFactoryGetDefaultAlgorithm; +extern jmethodID g_TrustManagerFactoryInit; +extern jmethodID g_TrustManagerFactoryGetTrustManagers; + +// javax/net/ssl/X509TrustManager +extern jclass g_X509TrustManager; + // net/dot/android/crypto/DotnetProxyTrustManager extern jclass g_DotnetProxyTrustManager; extern jmethodID g_DotnetProxyTrustManagerCtor; diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c index a7fda9f371fbd5..bac42e010c8fb2 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c @@ -463,7 +463,7 @@ ARGS_NON_NULL_ALL static jobject GetKeyStoreInstance(JNIEnv* env) return keyStore; } -SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle) +SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, char* targetHost) { abort_unless(sslStreamProxyHandle != 0, "invalid pointer to the .NET SslStream proxy"); @@ -476,7 +476,7 @@ SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle) if (!loc[sslContext]) goto cleanup; - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle); + loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle, targetHost); if (!loc[trustManagers]) goto cleanup; @@ -557,6 +557,7 @@ static int32_t AddCertChainToStore(JNIEnv* env, } SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStreamProxyHandle, + char* targetHost, uint8_t* pkcs8PrivateKey, int32_t pkcs8PrivateKeyLen, PAL_KeyAlgorithm algorithm, @@ -598,7 +599,7 @@ SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStrea ON_EXCEPTION_PRINT_AND_GOTO(cleanup); // TrustManager[] trustManagers = GetTrustManagers(sslStreamProxyHandle); - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle); + loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle, targetHost); if (!loc[trustManagers]) goto cleanup; @@ -615,7 +616,10 @@ SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStrea return sslStream; } -SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry(intptr_t sslStreamProxyHandle, jobject privateKeyEntry) +SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry( + intptr_t sslStreamProxyHandle, + char* targetHost, + jobject privateKeyEntry) { abort_unless(sslStreamProxyHandle != 0, "invalid pointer to the .NET SslStream proxy"); @@ -635,7 +639,7 @@ SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry(intptr ON_EXCEPTION_PRINT_AND_GOTO(cleanup); // TrustManager[] trustManagers = GetTrustManagers(sslStreamProxyHandle); - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle); + loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle, targetHost); if (!loc[trustManagers]) goto cleanup; @@ -652,6 +656,34 @@ SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry(intptr return sslStream; } +// This method calls internal Android APIs that are specific to Android API 21-23 and it won't work +// on newer API levels. By calling the sslEngine.sslParameters.useSni(true) method, the SSLEngine +// will include the peerHost that was passed in to the SSLEngine factory method in the client hello +// message. +ARGS_NON_NULL_ALL static int32_t ApplyLegacyAndroidSNIWorkaround(JNIEnv* env, SSLStream* sslStream) +{ + if (g_ConscryptOpenSSLEngineImplClass == NULL || !(*env)->IsInstanceOf(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplClass)) + return FAIL; + + int32_t ret = FAIL; + INIT_LOCALS(loc, sslParameters); + + loc[sslParameters] = (*env)->GetObjectField(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplSslParametersField); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + if (!loc[sslParameters]) + goto cleanup; + + (*env)->CallVoidMethod(env, loc[sslParameters], g_ConscryptSSLParametersImplSetUseSni, true); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + ret = SUCCESS; + +cleanup: + RELEASE_LOCALS(loc, env); + return ret; +} + int32_t AndroidCryptoNative_SSLStreamInitialize( SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, char* peerHost) { @@ -684,6 +716,42 @@ int32_t AndroidCryptoNative_SSLStreamInitialize( (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetUseClientMode, !isServer); ON_EXCEPTION_PRINT_AND_GOTO(exit); + if (peerHost && !isServer) + { + if (g_SNIHostName != NULL && g_SSLParametersSetServerNames != NULL) + { + jstring peerHostStr = make_java_string(env, peerHost); + jobject sniHostName = (*env)->NewObject(env, g_SNIHostName, g_SNIHostNameCtor, peerHostStr); + ReleaseLRef(env, peerHostStr); + ON_EXCEPTION_PRINT_AND_GOTO(exit); + + jobject serverNames = (*env)->NewObject(env, g_ArrayListClass, g_ArrayListCtorWithCapacity, 1); + ON_EXCEPTION_PRINT_AND_GOTO(exit); + + (*env)->CallBooleanMethod(env, serverNames, g_ArrayListAdd, sniHostName); + ON_EXCEPTION_PRINT_AND_GOTO(exit); + + jobject sslParameters = (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSSLParameters); + ON_EXCEPTION_PRINT_AND_GOTO(exit); + + (*env)->CallVoidMethod(env, sslParameters, g_SSLParametersSetServerNames, serverNames); + ON_EXCEPTION_PRINT_AND_GOTO(exit); + + (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetSSLParameters, sslParameters); + ON_EXCEPTION_PRINT_AND_GOTO(exit); + + ReleaseLRef(env, sniHostName); + ReleaseLRef(env, serverNames); + ReleaseLRef(env, sslParameters); + } + else + { + // SNIHostName is only available since API 24 + // on APIs 21-23 we use a workaround to force the SSLEngine to use SNI + ApplyLegacyAndroidSNIWorkaround(env, sslStream); + } + } + // SSLSession sslSession = sslEngine.getSession(); sslStream->sslSession = ToGRef(env, (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSession)); @@ -717,77 +785,6 @@ int32_t AndroidCryptoNative_SSLStreamInitialize( return ret; } -// This method calls internal Android APIs that are specific to Android API 21-23 and it won't work -// on newer API levels. By calling the sslEngine.sslParameters.useSni(true) method, the SSLEngine -// will include the peerHost that was passed in to the SSLEngine factory method in the client hello -// message. -ARGS_NON_NULL_ALL static int32_t ApplyLegacyAndroidSNIWorkaround(JNIEnv* env, SSLStream* sslStream) -{ - if (g_ConscryptOpenSSLEngineImplClass == NULL || !(*env)->IsInstanceOf(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplClass)) - return FAIL; - - int32_t ret = FAIL; - INIT_LOCALS(loc, sslParameters); - - loc[sslParameters] = (*env)->GetObjectField(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplSslParametersField); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - if (!loc[sslParameters]) - goto cleanup; - - (*env)->CallVoidMethod(env, loc[sslParameters], g_ConscryptSSLParametersImplSetUseSni, true); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - ret = SUCCESS; - -cleanup: - RELEASE_LOCALS(loc, env); - return ret; -} - -int32_t AndroidCryptoNative_SSLStreamSetTargetHost(SSLStream* sslStream, char* targetHost) -{ - abort_if_invalid_pointer_argument (sslStream); - abort_if_invalid_pointer_argument (targetHost); - - JNIEnv* env = GetJNIEnv(); - - if (g_SNIHostName == NULL || g_SSLParametersSetServerNames == NULL) - { - // SNIHostName is only available since API 24 - // on APIs 21-23 we use a workaround to force the SSLEngine to use SNI - return ApplyLegacyAndroidSNIWorkaround(env, sslStream); - } - - int32_t ret = FAIL; - INIT_LOCALS(loc, hostStr, nameList, hostName, params); - - // ArrayList nameList = new ArrayList(); - // SNIHostName hostName = new SNIHostName(targetHost); - // nameList.add(hostName); - loc[hostStr] = make_java_string(env, targetHost); - loc[nameList] = (*env)->NewObject(env, g_ArrayListClass, g_ArrayListCtor); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - loc[hostName] = (*env)->NewObject(env, g_SNIHostName, g_SNIHostNameCtor, loc[hostStr]); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - (*env)->CallBooleanMethod(env, loc[nameList], g_ArrayListAdd, loc[hostName]); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - // SSLParameters params = sslEngine.getSSLParameters(); - // params.setServerNames(nameList); - // sslEngine.setSSLParameters(params); - loc[params] = (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSSLParameters); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - (*env)->CallVoidMethod(env, loc[params], g_SSLParametersSetServerNames, loc[nameList]); - (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetSSLParameters, loc[params]); - - ret = SUCCESS; - -cleanup: - RELEASE_LOCALS(loc, env); - return ret; -} - PAL_SSLStreamStatus AndroidCryptoNative_SSLStreamHandshake(SSLStream* sslStream) { abort_if_invalid_pointer_argument (sslStream); diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h index 000677d54f32f8..0a4826c42f5bb5 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h @@ -46,7 +46,7 @@ Create an SSL context Returns NULL on failure */ -PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle); +PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, char* targetHost); /* Create an SSL context with the specified certificates @@ -54,6 +54,7 @@ Create an SSL context with the specified certificates Returns NULL on failure */ PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStreamProxyHandle, + char* targetHost, uint8_t* pkcs8PrivateKey, int32_t pkcs8PrivateKeyLen, PAL_KeyAlgorithm algorithm, @@ -65,7 +66,10 @@ Create an SSL context with the specified certificates and private key from KeyCh Returns NULL on failure */ -PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry(intptr_t sslStreamProxyHandle, jobject privateKeyEntry); +PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry( + intptr_t sslStreamProxyHandle, + char* targetHost, + jobject privateKeyEntry); /* Initialize an SSL context @@ -80,14 +84,6 @@ Returns 1 on success, 0 otherwise PALEXPORT int32_t AndroidCryptoNative_SSLStreamInitialize( SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, char* peerHost); -/* -Set target host - - targetHost : SNI host name - -Returns 1 on success, 0 otherwise -*/ -PALEXPORT int32_t AndroidCryptoNative_SSLStreamSetTargetHost(SSLStream* sslStream, char* targetHost); - /* Check if the local certificate has been sent to the peer during the TLS handshake. diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c index af87c04a4a031c..47b59ce9c2a3f2 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c @@ -8,20 +8,71 @@ ARGS_NON_NULL_ALL void AndroidCryptoNative_RegisterRemoteCertificateValidationCa atomic_store(&verifyRemoteCertificate, callback); } -ARGS_NON_NULL_ALL jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle) +// Gets the default X509TrustManager from the system TrustManagerFactory +static jobject GetDefaultX509TrustManager(JNIEnv* env) { - // X509TrustManager dotnetProxyTrustManager = new DotnetProxyTrustManager(sslStreamProxyHandle); + jobject result = NULL; + INIT_LOCALS(loc, algorithm, tmf, trustManagers); + + // String algorithm = TrustManagerFactory.getDefaultAlgorithm(); + loc[algorithm] = (*env)->CallStaticObjectMethod(env, g_TrustManagerFactory, g_TrustManagerFactoryGetDefaultAlgorithm); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + // TrustManagerFactory tmf = TrustManagerFactory.getInstance(algorithm); + loc[tmf] = (*env)->CallStaticObjectMethod(env, g_TrustManagerFactory, g_TrustManagerFactoryGetInstance, loc[algorithm]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + // tmf.init((KeyStore)null) -> use default system key store + (*env)->CallVoidMethod(env, loc[tmf], g_TrustManagerFactoryInit, NULL); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + // TrustManager[] tms = tmf.getTrustManagers(); + loc[trustManagers] = (*env)->CallObjectMethod(env, loc[tmf], g_TrustManagerFactoryGetTrustManagers); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + // Find the first X509TrustManager in the array + jsize length = (*env)->GetArrayLength(env, loc[trustManagers]); + for (jsize i = 0; i < length; i++) + { + jobject tm = (*env)->GetObjectArrayElement(env, loc[trustManagers], i); + if ((*env)->IsInstanceOf(env, tm, g_X509TrustManager)) + { + result = tm; + break; + } + ReleaseLRef(env, tm); + } + +cleanup: + RELEASE_LOCALS(loc, env); + return result; +} + +jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const char* targetHost) +{ + // X509TrustManager platformTrustManager = GetDefaultX509TrustManager(); + // DotnetProxyTrustManager dotnetProxyTrustManager = new DotnetProxyTrustManager(sslStreamProxyHandle, platformTrustManager, targetHost); // TrustManager[] trustManagers = new TrustManager[] { dotnetProxyTrustManager }; // return trustManagers; jobjectArray trustManagers = NULL; - INIT_LOCALS(loc, dotnetProxyTrustManager); + INIT_LOCALS(loc, platformTrustManager, dotnetProxyTrustManager); - loc[dotnetProxyTrustManager] = (*env)->NewObject(env, g_DotnetProxyTrustManager, g_DotnetProxyTrustManagerCtor, (jlong)sslStreamProxyHandle); + loc[platformTrustManager] = GetDefaultX509TrustManager(env); + abort_unless(loc[platformTrustManager] != NULL, "Failed to get default X509TrustManager"); + + jstring targetHostStr = targetHost != NULL ? make_java_string(env, targetHost) : NULL; + loc[dotnetProxyTrustManager] = (*env)->NewObject( + env, + g_DotnetProxyTrustManager, + g_DotnetProxyTrustManagerCtor, + (jlong)sslStreamProxyHandle, + loc[platformTrustManager], + targetHostStr); + ReleaseLRef(env, targetHostStr); ON_EXCEPTION_PRINT_AND_GOTO(cleanup); trustManagers = make_java_object_array(env, 1, g_TrustManager, loc[dotnetProxyTrustManager]); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); cleanup: RELEASE_LOCALS(loc, env); @@ -29,9 +80,9 @@ ARGS_NON_NULL_ALL jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamP } ARGS_NON_NULL_ALL jboolean Java_net_dot_android_crypto_DotnetProxyTrustManager_verifyRemoteCertificate( - JNIEnv* env, jobject thisHandle, jlong sslStreamProxyHandle) + JNIEnv* env, jobject thisHandle, jlong sslStreamProxyHandle, jboolean chainTrustedByPlatform) { RemoteCertificateValidationCallback verify = atomic_load(&verifyRemoteCertificate); abort_unless(verify, "verifyRemoteCertificate callback has not been registered"); - return verify((intptr_t)sslStreamProxyHandle); + return verify((intptr_t)sslStreamProxyHandle, (int32_t)chainTrustedByPlatform); } diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h index e4f09118492327..cfef56b8f64daf 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h @@ -1,10 +1,10 @@ #include "pal_jni.h" -typedef bool (*RemoteCertificateValidationCallback)(intptr_t); +typedef bool (*RemoteCertificateValidationCallback)(intptr_t, int32_t); PALEXPORT void AndroidCryptoNative_RegisterRemoteCertificateValidationCallback(RemoteCertificateValidationCallback callback); -jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle); +jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const char* targetHost); JNIEXPORT jboolean JNICALL Java_net_dot_android_crypto_DotnetProxyTrustManager_verifyRemoteCertificate( - JNIEnv *env, jobject thisHandle, jlong sslStreamProxyHandle); + JNIEnv *env, jobject thisHandle, jlong sslStreamProxyHandle, jboolean chainTrustedByPlatform); diff --git a/src/tasks/AndroidAppBuilder/AndroidAppBuilder.cs b/src/tasks/AndroidAppBuilder/AndroidAppBuilder.cs index 006b746ef0d010..54a07294d5950c 100644 --- a/src/tasks/AndroidAppBuilder/AndroidAppBuilder.cs +++ b/src/tasks/AndroidAppBuilder/AndroidAppBuilder.cs @@ -108,6 +108,18 @@ public class AndroidAppBuilderTask : Task public bool ForceInterpreter { get; set; } + /// + /// Path to a network_security_config.xml file to include in the APK. + /// When set, enables custom trust anchors and certificate pinning via Android's network security config. + /// + public string? NetworkSecurityConfig { get; set; } + + /// + /// Optional path to a resources directory containing additional files for the network security config + /// (e.g., res/raw/ with certificate files referenced by the config). + /// + public string? NetworkSecurityConfigResourcesDir { get; set; } + [Output] public string ApkBundlePath { get; set; } = ""!; @@ -141,6 +153,8 @@ public override bool Execute() apkBuilder.NativeDependencies = NativeDependencies; apkBuilder.ExtraLinkerArguments = ExtraLinkerArguments; apkBuilder.RuntimeFlavor = RuntimeFlavor; + apkBuilder.NetworkSecurityConfig = NetworkSecurityConfig; + apkBuilder.NetworkSecurityConfigResourcesDir = NetworkSecurityConfigResourcesDir; (ApkBundlePath, ApkPackageId) = apkBuilder.BuildApk(RuntimeIdentifier, MainLibraryFileName, RuntimeHeaders); return true; diff --git a/src/tasks/AndroidAppBuilder/ApkBuilder.cs b/src/tasks/AndroidAppBuilder/ApkBuilder.cs index df0a274e9d4895..5103a270b9f1bb 100644 --- a/src/tasks/AndroidAppBuilder/ApkBuilder.cs +++ b/src/tasks/AndroidAppBuilder/ApkBuilder.cs @@ -51,6 +51,8 @@ public partial class ApkBuilder public ITaskItem[] ExtraLinkerArguments { get; set; } = Array.Empty(); public string[] NativeDependencies { get; set; } = Array.Empty(); public string RuntimeFlavor { get; set; } = nameof(RuntimeFlavorEnum.Mono); + public string? NetworkSecurityConfig { get; set; } + public string? NetworkSecurityConfigResourcesDir { get; set; } private RuntimeFlavorEnum parsedRuntimeFlavor; private bool IsMono => parsedRuntimeFlavor == RuntimeFlavorEnum.Mono; @@ -460,11 +462,47 @@ public ApkBuilder(TaskLoggingHelper logger) File.WriteAllText(monoRunnerPath, monoRunner); + // Handle network security config + string networkSecurityConfigAttr = ""; + string resourceDirArg = ""; + if (!string.IsNullOrEmpty(NetworkSecurityConfig)) + { + if (!File.Exists(NetworkSecurityConfig)) + { + throw new ArgumentException($"NetworkSecurityConfig file not found: '{NetworkSecurityConfig}'"); + } + + string resXmlDir = Path.Combine(OutputDir, "res", "xml"); + Directory.CreateDirectory(resXmlDir); + File.Copy(NetworkSecurityConfig, Path.Combine(resXmlDir, "network_security_config.xml"), overwrite: true); + networkSecurityConfigAttr = "\n a:networkSecurityConfig=\"@xml/network_security_config\""; + resourceDirArg = "-S res "; + + // Copy additional resource files (e.g., res/raw/ for certificate files) + if (!string.IsNullOrEmpty(NetworkSecurityConfigResourcesDir)) + { + if (!Directory.Exists(NetworkSecurityConfigResourcesDir)) + { + throw new ArgumentException($"NetworkSecurityConfigResourcesDir directory not found: '{NetworkSecurityConfigResourcesDir}'"); + } + + string destResDir = Path.Combine(OutputDir, "res"); + foreach (string srcFile in Directory.GetFiles(NetworkSecurityConfigResourcesDir, "*", SearchOption.AllDirectories)) + { + string relativePath = Path.GetRelativePath(NetworkSecurityConfigResourcesDir, srcFile); + string destFile = Path.Combine(destResDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destFile)!); + File.Copy(srcFile, destFile, overwrite: true); + } + } + } + File.WriteAllText(Path.Combine(OutputDir, "AndroidManifest.xml"), Utils.GetEmbeddedResource("AndroidManifest.xml") .Replace("%PackageName%", packageId) .Replace("%MinSdkLevel%", MinApiLevel) - .Replace("%TargetSdkVersion%", TargetApiLevel)); + .Replace("%TargetSdkVersion%", TargetApiLevel) + .Replace("%NetworkSecurityConfig%", networkSecurityConfigAttr)); string javaCompilerArgs = $"-d obj -classpath src -bootclasspath {androidJar} -source 1.8 -target 1.8 "; Utils.RunProcess(logger, javac, javaCompilerArgs + javaActivityPath, workingDir: OutputDir); @@ -488,7 +526,7 @@ public ApkBuilder(TaskLoggingHelper logger) string debugModeArg = StripDebugSymbols ? string.Empty : "--debug-mode"; string apkFile = Path.Combine(OutputDir, "bin", $"{ProjectName}.unaligned.apk"); - Utils.RunProcess(logger, androidSdkHelper.AaptPath, $"package -f -m -F {apkFile} -A assets -M AndroidManifest.xml -I {androidJar} {debugModeArg}", workingDir: OutputDir); + Utils.RunProcess(logger, androidSdkHelper.AaptPath, $"package -f -m -F {apkFile} -A assets {resourceDirArg}-M AndroidManifest.xml -I {androidJar} {debugModeArg}", workingDir: OutputDir); var dynamicLibs = new List(); if (!IsNativeAOT) diff --git a/src/tasks/AndroidAppBuilder/Templates/AndroidManifest.xml b/src/tasks/AndroidAppBuilder/Templates/AndroidManifest.xml index d22e6b77278656..01acf1f7a256d4 100644 --- a/src/tasks/AndroidAppBuilder/Templates/AndroidManifest.xml +++ b/src/tasks/AndroidAppBuilder/Templates/AndroidManifest.xml @@ -9,7 +9,7 @@ + a:usesCleartextTraffic="true"%NetworkSecurityConfig%> From 6e0fe2287ccb1d15a5da08ecded4ff1c4becfdf5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 9 Feb 2026 16:06:50 +0100 Subject: [PATCH 02/17] Remove unnecessary API 24 check in DotnetProxyTrustManager X509TrustManagerExtensions.checkServerTrusted(chain, authType, host) is available since API 17, and our minimum supported API level is 21. The Build.VERSION.SDK_INT >= 24 guard was unnecessarily falling back to hostname-unaware validation on API 21-23. --- .../net/dot/android/crypto/DotnetProxyTrustManager.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java index 8eff37fc187b34..b8956092258f1e 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java +++ b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java @@ -4,7 +4,6 @@ package net.dot.android.crypto; import android.net.http.X509TrustManagerExtensions; -import android.os.Build; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import javax.net.ssl.X509TrustManager; @@ -45,11 +44,11 @@ public void checkServerTrusted(X509Certificate[] chain, String authType) private boolean isServerTrustedByPlatformTrustManager(X509Certificate[] chain, String authType) { try { - if (targetHost != null && Build.VERSION.SDK_INT >= 24) { - // Use hostname-aware validation (API 24+) for server certificates + if (targetHost != null) { + // X509TrustManagerExtensions.checkServerTrusted is available since API 17 + // and our minimum supported API level is 21 trustManagerExtensions.checkServerTrusted(chain, authType, targetHost); } else { - // Fallback for API 21-23: use basic validation without hostname platformTrustManager.checkServerTrusted(chain, authType); } return true; From 0088428e6d86f253c274eafff5c498a89844fc3b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 9 Feb 2026 16:27:19 +0100 Subject: [PATCH 03/17] Create X509TrustManagerExtensions inline instead of storing as field --- .../net/dot/android/crypto/DotnetProxyTrustManager.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java index b8956092258f1e..7cc07a7d0e18f3 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java +++ b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java @@ -16,14 +16,12 @@ public final class DotnetProxyTrustManager implements X509TrustManager { private final long sslStreamProxyHandle; private final X509TrustManager platformTrustManager; - private final X509TrustManagerExtensions trustManagerExtensions; private final String targetHost; public DotnetProxyTrustManager(long sslStreamProxyHandle, X509TrustManager platformTrustManager, String targetHost) { this.sslStreamProxyHandle = sslStreamProxyHandle; this.platformTrustManager = platformTrustManager; this.targetHost = targetHost; - this.trustManagerExtensions = new X509TrustManagerExtensions(platformTrustManager); } public void checkClientTrusted(X509Certificate[] chain, String authType) @@ -47,7 +45,8 @@ private boolean isServerTrustedByPlatformTrustManager(X509Certificate[] chain, S if (targetHost != null) { // X509TrustManagerExtensions.checkServerTrusted is available since API 17 // and our minimum supported API level is 21 - trustManagerExtensions.checkServerTrusted(chain, authType, targetHost); + X509TrustManagerExtensions extensions = new X509TrustManagerExtensions(platformTrustManager); + extensions.checkServerTrusted(chain, authType, targetHost); } else { platformTrustManager.checkServerTrusted(chain, authType); } From 798430ab519a79da202099ce0e1e9b11e3c14e21 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 9 Feb 2026 18:14:59 +0100 Subject: [PATCH 04/17] Address review comments: extract SNI setup, fix JNI error handling - Extract SNI server name setup into SetSNIServerName() with proper INIT_LOCALS/RELEASE_LOCALS to prevent JNI local ref leaks on error - Replace abort_unless with graceful LOG_ERROR + goto cleanup when platform X509TrustManager is unavailable - Add ON_EXCEPTION_PRINT_AND_GOTO after make_java_object_array - Remove unnecessary comment about API level in DotnetProxyTrustManager --- .../crypto/DotnetProxyTrustManager.java | 2 - .../pal_sslstream.c | 72 ++++++++++--------- .../pal_trust_manager.c | 7 +- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java index 7cc07a7d0e18f3..ed1d82dc7fb846 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java +++ b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java @@ -43,8 +43,6 @@ public void checkServerTrusted(X509Certificate[] chain, String authType) private boolean isServerTrustedByPlatformTrustManager(X509Certificate[] chain, String authType) { try { if (targetHost != null) { - // X509TrustManagerExtensions.checkServerTrusted is available since API 17 - // and our minimum supported API level is 21 X509TrustManagerExtensions extensions = new X509TrustManagerExtensions(platformTrustManager); extensions.checkServerTrusted(chain, authType, targetHost); } else { diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c index bac42e010c8fb2..752f485c5e93a6 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c @@ -684,6 +684,45 @@ ARGS_NON_NULL_ALL static int32_t ApplyLegacyAndroidSNIWorkaround(JNIEnv* env, SS return ret; } +// Sets the SNI server name on the SSLEngine so that the server name is included +// in the TLS client hello message. +ARGS_NON_NULL_ALL static int32_t SetSNIServerName(JNIEnv* env, SSLStream* sslStream, const char* peerHost) +{ + if (g_SNIHostName == NULL || g_SSLParametersSetServerNames == NULL) + { + // SNIHostName is only available since API 24 + // on APIs 21-23 we use a workaround to force the SSLEngine to use SNI + return ApplyLegacyAndroidSNIWorkaround(env, sslStream); + } + + int32_t ret = FAIL; + INIT_LOCALS(loc, hostStr, nameList, hostName, params); + + loc[hostStr] = make_java_string(env, peerHost); + loc[hostName] = (*env)->NewObject(env, g_SNIHostName, g_SNIHostNameCtor, loc[hostStr]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + loc[nameList] = (*env)->NewObject(env, g_ArrayListClass, g_ArrayListCtor); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + (*env)->CallBooleanMethod(env, loc[nameList], g_ArrayListAdd, loc[hostName]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + // SSLParameters params = sslEngine.getSSLParameters(); + // params.setServerNames(nameList); + // sslEngine.setSSLParameters(params); + loc[params] = (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSSLParameters); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + (*env)->CallVoidMethod(env, loc[params], g_SSLParametersSetServerNames, loc[nameList]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetSSLParameters, loc[params]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + ret = SUCCESS; + +cleanup: + RELEASE_LOCALS(loc, env); + return ret; +} + int32_t AndroidCryptoNative_SSLStreamInitialize( SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, char* peerHost) { @@ -718,38 +757,7 @@ int32_t AndroidCryptoNative_SSLStreamInitialize( if (peerHost && !isServer) { - if (g_SNIHostName != NULL && g_SSLParametersSetServerNames != NULL) - { - jstring peerHostStr = make_java_string(env, peerHost); - jobject sniHostName = (*env)->NewObject(env, g_SNIHostName, g_SNIHostNameCtor, peerHostStr); - ReleaseLRef(env, peerHostStr); - ON_EXCEPTION_PRINT_AND_GOTO(exit); - - jobject serverNames = (*env)->NewObject(env, g_ArrayListClass, g_ArrayListCtorWithCapacity, 1); - ON_EXCEPTION_PRINT_AND_GOTO(exit); - - (*env)->CallBooleanMethod(env, serverNames, g_ArrayListAdd, sniHostName); - ON_EXCEPTION_PRINT_AND_GOTO(exit); - - jobject sslParameters = (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSSLParameters); - ON_EXCEPTION_PRINT_AND_GOTO(exit); - - (*env)->CallVoidMethod(env, sslParameters, g_SSLParametersSetServerNames, serverNames); - ON_EXCEPTION_PRINT_AND_GOTO(exit); - - (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetSSLParameters, sslParameters); - ON_EXCEPTION_PRINT_AND_GOTO(exit); - - ReleaseLRef(env, sniHostName); - ReleaseLRef(env, serverNames); - ReleaseLRef(env, sslParameters); - } - else - { - // SNIHostName is only available since API 24 - // on APIs 21-23 we use a workaround to force the SSLEngine to use SNI - ApplyLegacyAndroidSNIWorkaround(env, sslStream); - } + SetSNIServerName(env, sslStream, peerHost); } // SSLSession sslSession = sslEngine.getSession(); diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c index 47b59ce9c2a3f2..eef84188a08f1e 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c @@ -59,7 +59,11 @@ jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const INIT_LOCALS(loc, platformTrustManager, dotnetProxyTrustManager); loc[platformTrustManager] = GetDefaultX509TrustManager(env); - abort_unless(loc[platformTrustManager] != NULL, "Failed to get default X509TrustManager"); + if (loc[platformTrustManager] == NULL) + { + LOG_ERROR("Failed to get default X509TrustManager"); + goto cleanup; + } jstring targetHostStr = targetHost != NULL ? make_java_string(env, targetHost) : NULL; loc[dotnetProxyTrustManager] = (*env)->NewObject( @@ -73,6 +77,7 @@ jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const ON_EXCEPTION_PRINT_AND_GOTO(cleanup); trustManagers = make_java_object_array(env, 1, g_TrustManager, loc[dotnetProxyTrustManager]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); cleanup: RELEASE_LOCALS(loc, env); From fdfe650751537bba6395e4e7cea20ff66185a67f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 11 Feb 2026 14:24:43 +0100 Subject: [PATCH 05/17] Use const char* for targetHost, improve ApkBuilder validation --- .../pal_sslstream.c | 8 ++++---- .../pal_sslstream.h | 8 ++++---- src/tasks/AndroidAppBuilder/ApkBuilder.cs | 10 ++++++++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c index 752f485c5e93a6..fa021f2d29aa71 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c @@ -463,7 +463,7 @@ ARGS_NON_NULL_ALL static jobject GetKeyStoreInstance(JNIEnv* env) return keyStore; } -SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, char* targetHost) +SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, const char* targetHost) { abort_unless(sslStreamProxyHandle != 0, "invalid pointer to the .NET SslStream proxy"); @@ -557,7 +557,7 @@ static int32_t AddCertChainToStore(JNIEnv* env, } SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStreamProxyHandle, - char* targetHost, + const char* targetHost, uint8_t* pkcs8PrivateKey, int32_t pkcs8PrivateKeyLen, PAL_KeyAlgorithm algorithm, @@ -618,7 +618,7 @@ SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStrea SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry( intptr_t sslStreamProxyHandle, - char* targetHost, + const char* targetHost, jobject privateKeyEntry) { abort_unless(sslStreamProxyHandle != 0, "invalid pointer to the .NET SslStream proxy"); @@ -724,7 +724,7 @@ ARGS_NON_NULL_ALL static int32_t SetSNIServerName(JNIEnv* env, SSLStream* sslStr } int32_t AndroidCryptoNative_SSLStreamInitialize( - SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, char* peerHost) + SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, const char* peerHost) { abort_if_invalid_pointer_argument (sslStream); abort_unless(sslStream->sslContext != NULL, "sslContext is NULL in SSL stream"); diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h index 0a4826c42f5bb5..bb985655f16a9b 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h @@ -46,7 +46,7 @@ Create an SSL context Returns NULL on failure */ -PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, char* targetHost); +PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, const char* targetHost); /* Create an SSL context with the specified certificates @@ -54,7 +54,7 @@ Create an SSL context with the specified certificates Returns NULL on failure */ PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStreamProxyHandle, - char* targetHost, + const char* targetHost, uint8_t* pkcs8PrivateKey, int32_t pkcs8PrivateKeyLen, PAL_KeyAlgorithm algorithm, @@ -68,7 +68,7 @@ Returns NULL on failure */ PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry( intptr_t sslStreamProxyHandle, - char* targetHost, + const char* targetHost, jobject privateKeyEntry); /* @@ -82,7 +82,7 @@ Initialize an SSL context Returns 1 on success, 0 otherwise */ PALEXPORT int32_t AndroidCryptoNative_SSLStreamInitialize( - SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, char* peerHost); + SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, const char* peerHost); /* Check if the local certificate has been sent to the peer during the TLS handshake. diff --git a/src/tasks/AndroidAppBuilder/ApkBuilder.cs b/src/tasks/AndroidAppBuilder/ApkBuilder.cs index 5103a270b9f1bb..a30d6e5999d67a 100644 --- a/src/tasks/AndroidAppBuilder/ApkBuilder.cs +++ b/src/tasks/AndroidAppBuilder/ApkBuilder.cs @@ -465,11 +465,17 @@ public ApkBuilder(TaskLoggingHelper logger) // Handle network security config string networkSecurityConfigAttr = ""; string resourceDirArg = ""; + if (!string.IsNullOrEmpty(NetworkSecurityConfigResourcesDir) && string.IsNullOrEmpty(NetworkSecurityConfig)) + { + throw new ArgumentException( + $"'{nameof(NetworkSecurityConfigResourcesDir)}' is set but '{nameof(NetworkSecurityConfig)}' is not. Both properties must be set together.", + nameof(NetworkSecurityConfigResourcesDir)); + } if (!string.IsNullOrEmpty(NetworkSecurityConfig)) { if (!File.Exists(NetworkSecurityConfig)) { - throw new ArgumentException($"NetworkSecurityConfig file not found: '{NetworkSecurityConfig}'"); + throw new ArgumentException($"NetworkSecurityConfig file not found: '{NetworkSecurityConfig}'", nameof(NetworkSecurityConfig)); } string resXmlDir = Path.Combine(OutputDir, "res", "xml"); @@ -483,7 +489,7 @@ public ApkBuilder(TaskLoggingHelper logger) { if (!Directory.Exists(NetworkSecurityConfigResourcesDir)) { - throw new ArgumentException($"NetworkSecurityConfigResourcesDir directory not found: '{NetworkSecurityConfigResourcesDir}'"); + throw new ArgumentException($"NetworkSecurityConfigResourcesDir directory not found: '{NetworkSecurityConfigResourcesDir}'", nameof(NetworkSecurityConfigResourcesDir)); } string destResDir = Path.Combine(OutputDir, "res"); From aceb23b88c63de6e6c908a3f4426da33b8841955 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 11 Feb 2026 22:52:59 +0100 Subject: [PATCH 06/17] Fix SNI ordering, client cert filtering, SIGSEGV crash, and CustomRootTrust validation - Move SSLStreamSetTargetHost to separate P/Invoke called after protocol setup to avoid Android's setSSLParameters overwriting enabled protocols - Return empty array from getAcceptedIssuers() to avoid filtering client certs by platform CA issuers - Add null check for managedContextCleanup in FreeSSLStream to prevent SIGSEGV when SSLStreamInitialize fails before setting the callback - Don't pre-inject RemoteCertificateChainErrors when platform rejects; let the managed chain builder independently determine chain errors. chainTrustedByPlatform is only used to strip spurious chain errors when the platform already validated the chain. - Replace openssl/PowerShell cert extraction with cross-platform MSBuild inline C# task - Remove paramName from ArgumentException throws in ApkBuilder (CA2208) - Pass peerHost including IP addresses for SSLEngine session caching --- .../Interop.Ssl.cs | 17 +++ .../Pal.Android/SafeDeleteSslContext.cs | 9 +- .../System/Net/Security/SslStream.Android.cs | 61 ++++---- .../AndroidPlatformTrustTests.cs | 77 ++++++++++ ...Security.AndroidPlatformTrust.Tests.csproj | 36 ++++- .../crypto/DotnetProxyTrustManager.java | 4 +- .../pal_sslstream.c | 143 +++++++++--------- .../pal_sslstream.h | 8 + src/tasks/AndroidAppBuilder/ApkBuilder.cs | 7 +- 9 files changed, 250 insertions(+), 112 deletions(-) diff --git a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs index b1db4e2328bca1..81aebb17cce913 100644 --- a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs +++ b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs @@ -17,6 +17,8 @@ internal static partial class Interop { internal static partial class AndroidCrypto { + private const int UNSUPPORTED_API_LEVEL = 2; + internal enum PAL_SSLStreamStatus { OK = 0, @@ -101,6 +103,21 @@ internal static unsafe void SSLStreamInitialize( throw new SslException(); } + [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamSetTargetHost")] + private static partial int SSLStreamSetTargetHostImpl( + SafeSslHandle sslHandle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string targetHost); + internal static void SSLStreamSetTargetHost( + SafeSslHandle sslHandle, + string targetHost) + { + int ret = SSLStreamSetTargetHostImpl(sslHandle, targetHost); + if (ret == UNSUPPORTED_API_LEVEL) + throw new PlatformNotSupportedException(SR.net_android_ssl_api_level_unsupported); + else if (ret != SUCCESS) + throw new SslException(); + } + [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamIsLocalCertificateUsed")] [return: MarshalAs(UnmanagedType.U1)] internal static partial bool SSLStreamIsLocalCertificateUsed(SafeSslHandle sslHandle); 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 7496c7086424cc..fb20c59a52d17d 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 @@ -297,9 +297,7 @@ private unsafe void InitializeSslContext( // Make sure the class instance is associated to the session and is provided in the Read/Write callback connection parameter // Additionally, all calls should be synchronous so there's no risk of the managed object being collected while native code is executing. IntPtr managedContextHandle = GCHandle.ToIntPtr(GCHandle.Alloc(this, GCHandleType.Weak)); - string? peerHost = !isServer && !string.IsNullOrEmpty(authOptions.TargetHost) && !IPAddress.IsValid(authOptions.TargetHost) - ? authOptions.TargetHost - : null; + string? peerHost = !isServer && !string.IsNullOrEmpty(authOptions.TargetHost) ? authOptions.TargetHost : null; Interop.AndroidCrypto.SSLStreamInitialize(handle, isServer, managedContextHandle, &ReadFromConnection, &WriteToConnection, &CleanupManagedContext, InitialBufferSize, peerHost); if (authOptions.EnabledSslProtocols != SslProtocols.None) @@ -325,6 +323,11 @@ private unsafe void InitializeSslContext( { Interop.AndroidCrypto.SSLStreamRequestClientAuthentication(handle); } + + if (!isServer && !string.IsNullOrEmpty(authOptions.TargetHost) && !IPAddress.IsValid(authOptions.TargetHost)) + { + Interop.AndroidCrypto.SSLStreamSetTargetHost(handle, authOptions.TargetHost); + } } } } 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 9818c092c64e82..72cecf48ef907a 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 @@ -11,18 +11,36 @@ public partial class SslStream { private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate(bool chainTrustedByPlatform) { - // If the platform's trust manager rejected the certificate chain, - // report RemoteCertificateChainErrors so the callback can handle it. - SslPolicyErrors sslPolicyErrors = chainTrustedByPlatform + // When the platform's trust manager rejected the chain AND the managed + // chain builder is using system trust (not CustomRootTrust), pre-inject + // RemoteCertificateChainErrors. This is critical for scenarios like certificate + // pinning via network-security-config.xml: the platform rejects the pin mismatch, + // but the managed chain builder (which doesn't know about pins) would accept the + // chain as valid. Without pre-injection, pinning would be silently bypassed. + // + // When CustomRootTrust is configured, the user has explicitly provided their own + // trust anchors. The platform's rejection is expected (it doesn't know about custom + // roots) and the managed chain builder's assessment should be authoritative. + bool ignorePlatformTrustManager = + _sslAuthenticationOptions.CertificateContext?.Trust is not null + || _sslAuthenticationOptions.CertificateChainPolicy?.TrustMode == X509ChainTrustMode.CustomRootTrust; + + SslPolicyErrors sslPolicyErrors = chainTrustedByPlatform || ignorePlatformTrustManager ? SslPolicyErrors.None : SslPolicyErrors.RemoteCertificateChainErrors; + bool platformTrustIsAuthoritative = chainTrustedByPlatform && !ignorePlatformTrustManager; + ProtocolToken alertToken = default; - RemoteCertificateValidationCallback? userCallback = _sslAuthenticationOptions.CertValidationDelegate; - RemoteCertificateValidationCallback? effectiveCallback = userCallback; + var isValid = VerifyRemoteCertificate( + _sslAuthenticationOptions.CertValidationDelegate, + _sslAuthenticationOptions.CertificateContext?.Trust, + ref alertToken, + ref sslPolicyErrors, + out X509ChainStatusFlags chainStatus); - if (chainTrustedByPlatform) + if (platformTrustIsAuthoritative) { // The platform's trust manager (which respects network-security-config.xml) // already validated the certificate chain. The managed X509 chain builder may @@ -30,27 +48,18 @@ private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate(bool // is not in the managed certificate store (e.g. PartialChain or UntrustedRoot). // Strip chain errors — the platform's assessment is authoritative for chain trust. // - // We wrap (or provide) the callback so that: - // 1. User callbacks see errors without spurious RemoteCertificateChainErrors. - // 2. When no user callback is set, the default "accept if no errors" logic - // doesn't reject connections that the platform already accepted. - effectiveCallback = userCallback is not null - ? (sender, certificate, chain, errors) => - userCallback(sender, certificate, chain, errors & ~SslPolicyErrors.RemoteCertificateChainErrors) - : (sender, certificate, chain, errors) => - (errors & ~SslPolicyErrors.RemoteCertificateChainErrors) == SslPolicyErrors.None; - } - - var isValid = VerifyRemoteCertificate( - effectiveCallback, - _sslAuthenticationOptions.CertificateContext?.Trust, - ref alertToken, - ref sslPolicyErrors, - out X509ChainStatusFlags chainStatus); - - if (chainTrustedByPlatform) - { + // When CustomRootTrust is configured, the managed chain builder's assessment + // is authoritative and its chain errors are real, not false positives. sslPolicyErrors &= ~SslPolicyErrors.RemoteCertificateChainErrors; + + if (!isValid && sslPolicyErrors == SslPolicyErrors.None) + { + // The connection was rejected only because of chain errors that the platform + // already validated. Re-evaluate: accept if there's no user callback, or + // re-invoke the user callback with the corrected errors. + isValid = _sslAuthenticationOptions.CertValidationDelegate?.Invoke( + this, _remoteCertificate, null, sslPolicyErrors) ?? true; + } } return new() diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs index 9d901a1a365c65..3a9988a6dd24a9 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs @@ -170,6 +170,83 @@ await Assert.ThrowsAsync(() => } } + [Fact] + public async Task SslStream_DomainNotInConfig_CallbackReceivesChainErrors() + { + // The server uses testservereku.contoso.com.pfx signed by the NDX Test Root CA. + // The client connects with a domain NOT listed in network_security_config.xml, + // so the base-config applies (system CAs only). The platform trust manager rejects + // because the NDX Test Root CA is not a system CA. This simulates a certificate + // pinning scenario: the cert chain is valid (signed by a known CA) but the platform + // rejects based on its trust configuration. + // + // The callback must receive RemoteCertificateChainErrors so the application + // knows the platform rejected the certificate. + + SslPolicyErrors? reportedErrors = null; + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetServerCertificate()) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "otherdomain.contoso.com", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + reportedErrors = sslPolicyErrors; + return true; + } + }; + + await Task.WhenAll( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)).WaitAsync(TimeSpan.FromSeconds(30)); + } + + Assert.NotNull(reportedErrors); + Assert.True( + (reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors) != 0, + $"Expected RemoteCertificateChainErrors but got: {reportedErrors.Value}"); + } + + [Fact] + public async Task SslStream_UntrustedCertificateWithoutCallback_ThrowsAuthenticationException() + { + // When the platform trust manager rejects and no callback is provided, + // the connection must fail. This verifies that platform trust rejection + // (e.g. certificate pinning) is enforced even without a user callback. + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (X509Certificate2 serverCertificate = Configuration.Certificates.GetSelfSignedServerCertificate()) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "testservereku.contoso.com", + }; + + Task serverTask = server.AuthenticateAsServerAsync(serverOptions); + await Assert.ThrowsAsync(() => + client.AuthenticateAsClientAsync(clientOptions)); + + try { await serverTask.WaitAsync(TimeSpan.FromSeconds(5)); } + catch { } + } + } + private static (SslStream client, SslStream server) GetConnectedSslStreams() { using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj index 875a790a5a9b5d..c23c661002c7b7 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj @@ -35,12 +35,8 @@ - - - - + + $(IntermediateOutputPath)network-security-config/res + + + + + + + + + + + + + diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java index ed1d82dc7fb846..05a84daaef152e 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java +++ b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java @@ -64,7 +64,9 @@ private boolean isClientTrustedByPlatformTrustManager(X509Certificate[] chain, S } public X509Certificate[] getAcceptedIssuers() { - return platformTrustManager.getAcceptedIssuers(); + // Return an empty array to avoid restricting which client certificates the TLS layer + // considers acceptable. The actual trust validation is done in checkServerTrusted/checkClientTrusted. + return new X509Certificate[0]; } static native boolean verifyRemoteCertificate(long sslStreamProxyHandle, boolean chainTrustedByPlatform); diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c index fa021f2d29aa71..783eb7deb516f0 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c @@ -656,73 +656,6 @@ SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry( return sslStream; } -// This method calls internal Android APIs that are specific to Android API 21-23 and it won't work -// on newer API levels. By calling the sslEngine.sslParameters.useSni(true) method, the SSLEngine -// will include the peerHost that was passed in to the SSLEngine factory method in the client hello -// message. -ARGS_NON_NULL_ALL static int32_t ApplyLegacyAndroidSNIWorkaround(JNIEnv* env, SSLStream* sslStream) -{ - if (g_ConscryptOpenSSLEngineImplClass == NULL || !(*env)->IsInstanceOf(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplClass)) - return FAIL; - - int32_t ret = FAIL; - INIT_LOCALS(loc, sslParameters); - - loc[sslParameters] = (*env)->GetObjectField(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplSslParametersField); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - if (!loc[sslParameters]) - goto cleanup; - - (*env)->CallVoidMethod(env, loc[sslParameters], g_ConscryptSSLParametersImplSetUseSni, true); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - ret = SUCCESS; - -cleanup: - RELEASE_LOCALS(loc, env); - return ret; -} - -// Sets the SNI server name on the SSLEngine so that the server name is included -// in the TLS client hello message. -ARGS_NON_NULL_ALL static int32_t SetSNIServerName(JNIEnv* env, SSLStream* sslStream, const char* peerHost) -{ - if (g_SNIHostName == NULL || g_SSLParametersSetServerNames == NULL) - { - // SNIHostName is only available since API 24 - // on APIs 21-23 we use a workaround to force the SSLEngine to use SNI - return ApplyLegacyAndroidSNIWorkaround(env, sslStream); - } - - int32_t ret = FAIL; - INIT_LOCALS(loc, hostStr, nameList, hostName, params); - - loc[hostStr] = make_java_string(env, peerHost); - loc[hostName] = (*env)->NewObject(env, g_SNIHostName, g_SNIHostNameCtor, loc[hostStr]); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - loc[nameList] = (*env)->NewObject(env, g_ArrayListClass, g_ArrayListCtor); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - (*env)->CallBooleanMethod(env, loc[nameList], g_ArrayListAdd, loc[hostName]); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - // SSLParameters params = sslEngine.getSSLParameters(); - // params.setServerNames(nameList); - // sslEngine.setSSLParameters(params); - loc[params] = (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSSLParameters); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - (*env)->CallVoidMethod(env, loc[params], g_SSLParametersSetServerNames, loc[nameList]); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetSSLParameters, loc[params]); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - ret = SUCCESS; - -cleanup: - RELEASE_LOCALS(loc, env); - return ret; -} - int32_t AndroidCryptoNative_SSLStreamInitialize( SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, const char* peerHost) { @@ -755,11 +688,6 @@ int32_t AndroidCryptoNative_SSLStreamInitialize( (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetUseClientMode, !isServer); ON_EXCEPTION_PRINT_AND_GOTO(exit); - if (peerHost && !isServer) - { - SetSNIServerName(env, sslStream, peerHost); - } - // SSLSession sslSession = sslEngine.getSession(); sslStream->sslSession = ToGRef(env, (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSession)); @@ -793,6 +721,77 @@ int32_t AndroidCryptoNative_SSLStreamInitialize( return ret; } +// This method calls internal Android APIs that are specific to Android API 21-23 and it won't work +// on newer API levels. By calling the sslEngine.sslParameters.useSni(true) method, the SSLEngine +// will include the peerHost that was passed in to the SSLEngine factory method in the client hello +// message. +ARGS_NON_NULL_ALL static int32_t ApplyLegacyAndroidSNIWorkaround(JNIEnv* env, SSLStream* sslStream) +{ + if (g_ConscryptOpenSSLEngineImplClass == NULL || !(*env)->IsInstanceOf(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplClass)) + return FAIL; + + int32_t ret = FAIL; + INIT_LOCALS(loc, sslParameters); + + loc[sslParameters] = (*env)->GetObjectField(env, sslStream->sslEngine, g_ConscryptOpenSSLEngineImplSslParametersField); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + if (!loc[sslParameters]) + goto cleanup; + + (*env)->CallVoidMethod(env, loc[sslParameters], g_ConscryptSSLParametersImplSetUseSni, true); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + ret = SUCCESS; + +cleanup: + RELEASE_LOCALS(loc, env); + return ret; +} + +int32_t AndroidCryptoNative_SSLStreamSetTargetHost(SSLStream* sslStream, const char* targetHost) +{ + abort_if_invalid_pointer_argument (sslStream); + abort_if_invalid_pointer_argument (targetHost); + + JNIEnv* env = GetJNIEnv(); + + if (g_SNIHostName == NULL || g_SSLParametersSetServerNames == NULL) + { + // SNIHostName is only available since API 24 + // on APIs 21-23 we use a workaround to force the SSLEngine to use SNI + return ApplyLegacyAndroidSNIWorkaround(env, sslStream); + } + + int32_t ret = FAIL; + INIT_LOCALS(loc, hostStr, nameList, hostName, params); + + // ArrayList nameList = new ArrayList(); + // SNIHostName hostName = new SNIHostName(targetHost); + // nameList.add(hostName); + loc[hostStr] = make_java_string(env, targetHost); + loc[nameList] = (*env)->NewObject(env, g_ArrayListClass, g_ArrayListCtor); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + loc[hostName] = (*env)->NewObject(env, g_SNIHostName, g_SNIHostNameCtor, loc[hostStr]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + (*env)->CallBooleanMethod(env, loc[nameList], g_ArrayListAdd, loc[hostName]); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + + // SSLParameters params = sslEngine.getSSLParameters(); + // params.setServerNames(nameList); + // sslEngine.setSSLParameters(params); + loc[params] = (*env)->CallObjectMethod(env, sslStream->sslEngine, g_SSLEngineGetSSLParameters); + ON_EXCEPTION_PRINT_AND_GOTO(cleanup); + (*env)->CallVoidMethod(env, loc[params], g_SSLParametersSetServerNames, loc[nameList]); + (*env)->CallVoidMethod(env, sslStream->sslEngine, g_SSLEngineSetSSLParameters, loc[params]); + + ret = SUCCESS; + +cleanup: + RELEASE_LOCALS(loc, env); + return ret; +} + PAL_SSLStreamStatus AndroidCryptoNative_SSLStreamHandshake(SSLStream* sslStream) { abort_if_invalid_pointer_argument (sslStream); diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h index bb985655f16a9b..cdfc6c32520b10 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h @@ -84,6 +84,14 @@ Returns 1 on success, 0 otherwise PALEXPORT int32_t AndroidCryptoNative_SSLStreamInitialize( SSLStream* sslStream, bool isServer, ManagedContextHandle managedContextHandle, STREAM_READER streamReader, STREAM_WRITER streamWriter, MANAGED_CONTEXT_CLEANUP managedContextCleanup, int32_t appBufferSize, const char* peerHost); +/* +Set target host + - targetHost : SNI host name + +Returns 1 on success, 0 otherwise +*/ +PALEXPORT int32_t AndroidCryptoNative_SSLStreamSetTargetHost(SSLStream* sslStream, const char* targetHost); + /* Check if the local certificate has been sent to the peer during the TLS handshake. diff --git a/src/tasks/AndroidAppBuilder/ApkBuilder.cs b/src/tasks/AndroidAppBuilder/ApkBuilder.cs index a30d6e5999d67a..211dc857eda0bd 100644 --- a/src/tasks/AndroidAppBuilder/ApkBuilder.cs +++ b/src/tasks/AndroidAppBuilder/ApkBuilder.cs @@ -468,14 +468,13 @@ public ApkBuilder(TaskLoggingHelper logger) if (!string.IsNullOrEmpty(NetworkSecurityConfigResourcesDir) && string.IsNullOrEmpty(NetworkSecurityConfig)) { throw new ArgumentException( - $"'{nameof(NetworkSecurityConfigResourcesDir)}' is set but '{nameof(NetworkSecurityConfig)}' is not. Both properties must be set together.", - nameof(NetworkSecurityConfigResourcesDir)); + $"'{nameof(NetworkSecurityConfigResourcesDir)}' is set but '{nameof(NetworkSecurityConfig)}' is not. Both properties must be set together."); } if (!string.IsNullOrEmpty(NetworkSecurityConfig)) { if (!File.Exists(NetworkSecurityConfig)) { - throw new ArgumentException($"NetworkSecurityConfig file not found: '{NetworkSecurityConfig}'", nameof(NetworkSecurityConfig)); + throw new ArgumentException($"NetworkSecurityConfig file not found: '{NetworkSecurityConfig}'"); } string resXmlDir = Path.Combine(OutputDir, "res", "xml"); @@ -489,7 +488,7 @@ public ApkBuilder(TaskLoggingHelper logger) { if (!Directory.Exists(NetworkSecurityConfigResourcesDir)) { - throw new ArgumentException($"NetworkSecurityConfigResourcesDir directory not found: '{NetworkSecurityConfigResourcesDir}'", nameof(NetworkSecurityConfigResourcesDir)); + throw new ArgumentException($"NetworkSecurityConfigResourcesDir directory not found: '{NetworkSecurityConfigResourcesDir}'"); } string destResDir = Path.Combine(OutputDir, "res"); From 3a0f51c3eb2e25d814ba7b6a63ef9f54f5add513 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 12 Feb 2026 20:30:59 +0100 Subject: [PATCH 07/17] Refactor SSLStreamCreate, add custom trust KeyStore, fix JNI ref management - Replace 3 SSLStreamCreate variants with 1 SSLStreamCreate + 2 key manager helpers - Key manager helpers return global refs; SSLStreamCreate releases after sslContext.init() - Managed side has try/catch to release keyManagers global ref on P/Invoke failure - Add CreateTrustKeyStore for custom root trust via SslCertificateTrust - GetX509TrustManager accepts optional custom KeyStore - Fix double-delete of ksType local ref in CreateTrustKeyStore - LOG_ERROR + return NULL when custom KeyStore creation fails (no silent fallback) - Simplify SslStream.Android.cs trust validation: pre-seed sslPolicyErrors from platform - Skip platform trust injection when CertificateChainPolicy uses CustomRootTrust without SslCertificateTrust (managed chain builder is authoritative) - Fix pre-existing SIGSEGV in FreeSSLStream when managedContextCleanup is NULL --- .../Interop.Ssl.cs | 41 +++---- .../Pal.Android/SafeDeleteSslContext.cs | 115 ++++++++++++++---- .../System/Net/Security/SslStream.Android.cs | 53 +++----- .../pal_sslstream.c | 95 ++++++--------- .../pal_sslstream.h | 31 ++--- .../pal_trust_manager.c | 72 +++++++++-- .../pal_trust_manager.h | 2 +- 7 files changed, 232 insertions(+), 177 deletions(-) diff --git a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs index 81aebb17cce913..e79cf2334e015e 100644 --- a/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs +++ b/src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs @@ -30,30 +30,31 @@ internal enum PAL_SSLStreamStatus [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreate")] private static partial SafeSslHandle SSLStreamCreate( - IntPtr sslStreamProxyHandle, - [MarshalAs(UnmanagedType.LPUTF8Str)] string? targetHost); - internal static SafeSslHandle SSLStreamCreate(SslStream.JavaProxy sslStreamProxy, string? targetHost) - => SSLStreamCreate(sslStreamProxy.Handle, targetHost); - - [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateWithCertificates")] - private static partial SafeSslHandle SSLStreamCreateWithCertificates( IntPtr sslStreamProxyHandle, [MarshalAs(UnmanagedType.LPUTF8Str)] string? targetHost, + IntPtr[]? trustCerts, + int trustCertsLen, + IntPtr keyManagers); + internal static SafeSslHandle SSLStreamCreate( + SslStream.JavaProxy sslStreamProxy, + string? targetHost, + IntPtr[]? trustCerts, + IntPtr keyManagers = 0) + => SSLStreamCreate(sslStreamProxy.Handle, targetHost, trustCerts, trustCerts?.Length ?? 0, keyManagers); + + [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateKeyManagers")] + private static partial IntPtr SSLStreamCreateKeyManagersImpl( ref byte pkcs8PrivateKey, int pkcs8PrivateKeyLen, PAL_KeyAlgorithm algorithm, IntPtr[] certs, int certsLen); - internal static SafeSslHandle SSLStreamCreateWithCertificates( - SslStream.JavaProxy sslStreamProxy, - string? targetHost, + internal static IntPtr SSLStreamCreateKeyManagers( ReadOnlySpan pkcs8PrivateKey, PAL_KeyAlgorithm algorithm, IntPtr[] certificates) { - return SSLStreamCreateWithCertificates( - sslStreamProxy.Handle, - targetHost, + return SSLStreamCreateKeyManagersImpl( ref MemoryMarshal.GetReference(pkcs8PrivateKey), pkcs8PrivateKey.Length, algorithm, @@ -61,18 +62,8 @@ ref MemoryMarshal.GetReference(pkcs8PrivateKey), certificates.Length); } - [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry")] - private static partial SafeSslHandle SSLStreamCreateWithKeyStorePrivateKeyEntry( - IntPtr sslStreamProxyHandle, - [MarshalAs(UnmanagedType.LPUTF8Str)] string? targetHost, - IntPtr keyStorePrivateKeyEntryHandle); - internal static SafeSslHandle SSLStreamCreateWithKeyStorePrivateKeyEntry( - SslStream.JavaProxy sslStreamProxy, - string? targetHost, - IntPtr keyStorePrivateKeyEntryHandle) - { - return SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy.Handle, targetHost, keyStorePrivateKeyEntryHandle); - } + [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateKeyManagersFromKeyStoreEntry")] + internal static partial IntPtr SSLStreamCreateKeyManagersFromKeyStoreEntry(IntPtr privateKeyEntryHandle); [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_RegisterRemoteCertificateValidationCallback")] internal static unsafe partial void RegisterRemoteCertificateValidationCallback( 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 fb20c59a52d17d..03c3385f44c5da 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 @@ -209,43 +209,106 @@ internal int ReadPendingWrites(byte[] buf, int offset, int count) private static SafeSslHandle CreateSslContext(SslStream.JavaProxy sslStreamProxy, SslAuthenticationOptions authOptions) { - string? targetHost = GetTargetHostIfAvailable(authOptions); - if (authOptions.CertificateContext == null) - { - return Interop.AndroidCrypto.SSLStreamCreate(sslStreamProxy, targetHost); - } + string? targetHost = !authOptions.IsServer + && !string.IsNullOrEmpty(authOptions.TargetHost) + && !IPAddress.IsValid(authOptions.TargetHost) + ? authOptions.TargetHost + : null; - SslStreamCertificateContext context = authOptions.CertificateContext; - X509Certificate2 cert = context.TargetCertificate; - Debug.Assert(context.TargetCertificate.HasPrivateKey); + IntPtr[]? trustCerts = GetTrustCertHandles(authOptions); + IntPtr keyManagers = authOptions.CertificateContext is not null + ? CreateKeyManagers(authOptions.CertificateContext) + : IntPtr.Zero; - if (Interop.AndroidCrypto.IsKeyStorePrivateKeyEntry(cert.Handle)) + try { - return Interop.AndroidCrypto.SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy, targetHost, cert.Handle); + return Interop.AndroidCrypto.SSLStreamCreate(sslStreamProxy, targetHost, trustCerts, keyManagers); } - - PAL_KeyAlgorithm algorithm; - byte[] keyBytes; - using (AsymmetricAlgorithm key = GetPrivateKeyAlgorithm(cert, out algorithm)) + finally { - keyBytes = key.ExportPkcs8PrivateKey(); + // keyManagers is a JNI global ref that was created to survive across + // P/Invoke boundaries. Release it now that SSLStreamCreate has consumed it. + if (keyManagers != IntPtr.Zero) + { + Interop.JObjectLifetime.DeleteGlobalReference(keyManagers); + } } - IntPtr[] ptrs = new IntPtr[context.IntermediateCertificates.Count + 1]; - ptrs[0] = cert.Handle; - for (int i = 0; i < context.IntermediateCertificates.Count; i++) + + static IntPtr CreateKeyManagers(SslStreamCertificateContext context) { - ptrs[i + 1] = context.IntermediateCertificates[i].Handle; + X509Certificate2 cert = context.TargetCertificate; + Debug.Assert(cert.HasPrivateKey); + + IntPtr keyManagers; + if (Interop.AndroidCrypto.IsKeyStorePrivateKeyEntry(cert.Handle)) + { + keyManagers = Interop.AndroidCrypto.SSLStreamCreateKeyManagersFromKeyStoreEntry(cert.Handle); + } + else + { + PAL_KeyAlgorithm algorithm; + byte[] keyBytes; + using (AsymmetricAlgorithm key = GetPrivateKeyAlgorithm(cert, out algorithm)) + { + keyBytes = key.ExportPkcs8PrivateKey(); + } + IntPtr[] ptrs = new IntPtr[context.IntermediateCertificates.Count + 1]; + ptrs[0] = cert.Handle; + for (int i = 0; i < context.IntermediateCertificates.Count; i++) + { + ptrs[i + 1] = context.IntermediateCertificates[i].Handle; + } + + keyManagers = Interop.AndroidCrypto.SSLStreamCreateKeyManagers(keyBytes, algorithm, ptrs); + } + + if (keyManagers == IntPtr.Zero) + { + throw new Interop.AndroidCrypto.SslException(); + } + + return keyManagers; } - return Interop.AndroidCrypto.SSLStreamCreateWithCertificates(sslStreamProxy, targetHost, keyBytes, algorithm, ptrs); + static IntPtr[]? GetTrustCertHandles(SslAuthenticationOptions authOptions) + { + // Collect custom trust root certificates to pass to the platform's + // TrustManagerFactory. There are two mutually exclusive sources — when + // CertificateChainPolicy is set it takes precedence (see SslStream.Protocol.cs): + // + // 1. CertificateChainPolicy.CustomTrustStore (when TrustMode is CustomRootTrust) + // 2. SslCertificateTrust (via CertificateContext.Trust) — older API + // + // Note: for CertificateChainPolicy.CustomRootTrust, the platform may still + // fail validation (e.g. missing intermediates that are only in ExtraStore), + // so the managed chain builder's verdict is authoritative in that case + // (see VerifyRemoteCertificate in SslStream.Android.cs). + X509Certificate2Collection? certs; + if (authOptions.CertificateChainPolicy is not null) + { + certs = authOptions.CertificateChainPolicy.TrustMode == X509ChainTrustMode.CustomRootTrust + ? authOptions.CertificateChainPolicy.CustomTrustStore + : null; + } + else + { + var trust = authOptions.CertificateContext?.Trust; + certs = trust?._store?.Certificates ?? trust?._trustList; + } + + if (certs is null || certs.Count == 0) + { + return null; + } - static string? GetTargetHostIfAvailable(SslAuthenticationOptions authOptions) - => !authOptions.IsServer && IsValidTargetHost(authOptions.TargetHost) - ? authOptions.TargetHost - : null; + IntPtr[] handles = new IntPtr[certs.Count]; + for (int i = 0; i < certs.Count; i++) + { + handles[i] = certs[i].Handle; + } - static bool IsValidTargetHost(string? targetHost) - => !string.IsNullOrEmpty(targetHost) && !IPAddress.IsValid(targetHost); + return handles; + } } private static AsymmetricAlgorithm GetPrivateKeyAlgorithm(X509Certificate2 cert, out PAL_KeyAlgorithm algorithm) 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 72cecf48ef907a..ee42101e8ceabe 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 @@ -11,26 +11,23 @@ public partial class SslStream { private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate(bool chainTrustedByPlatform) { - // When the platform's trust manager rejected the chain AND the managed - // chain builder is using system trust (not CustomRootTrust), pre-inject - // RemoteCertificateChainErrors. This is critical for scenarios like certificate - // pinning via network-security-config.xml: the platform rejects the pin mismatch, - // but the managed chain builder (which doesn't know about pins) would accept the - // chain as valid. Without pre-injection, pinning would be silently bypassed. - // - // When CustomRootTrust is configured, the user has explicitly provided their own - // trust anchors. The platform's rejection is expected (it doesn't know about custom - // roots) and the managed chain builder's assessment should be authoritative. - bool ignorePlatformTrustManager = - _sslAuthenticationOptions.CertificateContext?.Trust is not null - || _sslAuthenticationOptions.CertificateChainPolicy?.TrustMode == X509ChainTrustMode.CustomRootTrust; - - SslPolicyErrors sslPolicyErrors = chainTrustedByPlatform || ignorePlatformTrustManager + // TODO: Investigate whether we can avoid this bypass by also passing ExtraStore + // intermediates to the platform's KeyStore (without elevating them to trust anchors), + // or by implementing a custom X509TrustManager that performs AIA fetching. + // Currently, when CertificateChainPolicy specifies CustomRootTrust (without + // SslCertificateTrust), the platform may reject the chain because it doesn't have + // the intermediate certs (which are only in ExtraStore) or because the hostname + // verification flags differ from managed settings. The managed chain builder — + // which has access to both CustomTrustStore and ExtraStore — is authoritative + // in this case. + bool managedTrustOnly = + _sslAuthenticationOptions.CertificateContext?.Trust is null + && _sslAuthenticationOptions.CertificateChainPolicy?.TrustMode == X509ChainTrustMode.CustomRootTrust; + + SslPolicyErrors sslPolicyErrors = chainTrustedByPlatform || managedTrustOnly ? SslPolicyErrors.None : SslPolicyErrors.RemoteCertificateChainErrors; - bool platformTrustIsAuthoritative = chainTrustedByPlatform && !ignorePlatformTrustManager; - ProtocolToken alertToken = default; var isValid = VerifyRemoteCertificate( @@ -40,28 +37,6 @@ private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate(bool ref sslPolicyErrors, out X509ChainStatusFlags chainStatus); - if (platformTrustIsAuthoritative) - { - // The platform's trust manager (which respects network-security-config.xml) - // already validated the certificate chain. The managed X509 chain builder may - // report RemoteCertificateChainErrors because the root CA trusted by the platform - // is not in the managed certificate store (e.g. PartialChain or UntrustedRoot). - // Strip chain errors — the platform's assessment is authoritative for chain trust. - // - // When CustomRootTrust is configured, the managed chain builder's assessment - // is authoritative and its chain errors are real, not false positives. - sslPolicyErrors &= ~SslPolicyErrors.RemoteCertificateChainErrors; - - if (!isValid && sslPolicyErrors == SslPolicyErrors.None) - { - // The connection was rejected only because of chain errors that the platform - // already validated. Re-evaluate: accept if there's no user callback, or - // re-invoke the user callback with the corrected errors. - isValid = _sslAuthenticationOptions.CertValidationDelegate?.Invoke( - this, _remoteCertificate, null, sslPolicyErrors) ?? true; - } - } - return new() { IsValid = isValid, diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c index 783eb7deb516f0..b2e4a637e3b850 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c @@ -412,7 +412,11 @@ ARGS_NON_NULL_ALL static void FreeSSLStream(JNIEnv* env, SSLStream* sslStream) ReleaseGRef(env, sslStream->netInBuffer); ReleaseGRef(env, sslStream->appInBuffer); - sslStream->managedContextCleanup(sslStream->managedContextHandle); + // managedContextCleanup can be NULL when SSLStreamInitialize was never called + // (e.g. InitializeSslContext threw before reaching the P/Invoke). In that case + // managedContextHandle is also NULL, so there is nothing to clean up. + if (sslStream->managedContextCleanup) + sslStream->managedContextCleanup(sslStream->managedContextHandle); free(sslStream); } @@ -463,7 +467,12 @@ ARGS_NON_NULL_ALL static jobject GetKeyStoreInstance(JNIEnv* env) return keyStore; } -SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, const char* targetHost) +SSLStream* AndroidCryptoNative_SSLStreamCreate( + intptr_t sslStreamProxyHandle, + const char* targetHost, + jobject* trustCerts, + int32_t trustCertsLen, + jobjectArray keyManagers) { abort_unless(sslStreamProxyHandle != 0, "invalid pointer to the .NET SslStream proxy"); @@ -476,12 +485,12 @@ SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, co if (!loc[sslContext]) goto cleanup; - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle, targetHost); + loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle, targetHost, trustCerts, trustCertsLen); if (!loc[trustManagers]) goto cleanup; - // sslContext.init(null, trustManagers, null); - (*env)->CallVoidMethod(env, loc[sslContext], g_SSLContextInitMethod, NULL, loc[trustManagers], NULL); + // sslContext.init(keyManagers, trustManagers, null); + (*env)->CallVoidMethod(env, loc[sslContext], g_SSLContextInitMethod, keyManagers, loc[trustManagers], NULL); ON_EXCEPTION_PRINT_AND_GOTO(cleanup); sslStream = xcalloc(1, sizeof(SSLStream)); @@ -556,24 +565,17 @@ static int32_t AddCertChainToStore(JNIEnv* env, return ret; } -SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStreamProxyHandle, - const char* targetHost, - uint8_t* pkcs8PrivateKey, - int32_t pkcs8PrivateKeyLen, - PAL_KeyAlgorithm algorithm, - jobject* /*X509Certificate[]*/ certs, - int32_t certsLen) +jobjectArray AndroidCryptoNative_SSLStreamCreateKeyManagers( + uint8_t* pkcs8PrivateKey, + int32_t pkcs8PrivateKeyLen, + PAL_KeyAlgorithm algorithm, + jobject* /*X509Certificate[]*/ certs, + int32_t certsLen) { - abort_unless(sslStreamProxyHandle != 0, "invalid pointer to the .NET SslStream proxy"); - - SSLStream* sslStream = NULL; + jobjectArray keyManagers = NULL; JNIEnv* env = GetJNIEnv(); - INIT_LOCALS(loc, sslContext, keyStore, kmfType, kmf, keyManagers, trustManagers); - - loc[sslContext] = GetSSLContextInstance(env); - if (!loc[sslContext]) - goto cleanup; + INIT_LOCALS(loc, keyStore, kmfType, kmf); loc[keyStore] = GetKeyStoreInstance(env); if (!loc[keyStore]) @@ -595,65 +597,38 @@ SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStrea ON_EXCEPTION_PRINT_AND_GOTO(cleanup); // KeyManager[] keyManagers = kmf.getKeyManagers(); - loc[keyManagers] = (*env)->CallObjectMethod(env, loc[kmf], g_KeyManagerFactoryGetKeyManagers); + keyManagers = (*env)->CallObjectMethod(env, loc[kmf], g_KeyManagerFactoryGetKeyManagers); ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - // TrustManager[] trustManagers = GetTrustManagers(sslStreamProxyHandle); - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle, targetHost); - if (!loc[trustManagers]) - goto cleanup; - - // sslContext.init(keyManagers, trustManagers, null); - (*env)->CallVoidMethod(env, loc[sslContext], g_SSLContextInitMethod, loc[keyManagers], loc[trustManagers], NULL); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - sslStream = xcalloc(1, sizeof(SSLStream)); - sslStream->sslContext = ToGRef(env, loc[sslContext]); - loc[sslContext] = NULL; + // Convert to a global ref so that the returned handle survives the JNI local ref frame + // of this P/Invoke call. The caller (SSLStreamCreate) is responsible for releasing it. + keyManagers = ToGRef(env, keyManagers); cleanup: RELEASE_LOCALS(loc, env); - return sslStream; + return keyManagers; } -SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry( - intptr_t sslStreamProxyHandle, - const char* targetHost, - jobject privateKeyEntry) +jobjectArray AndroidCryptoNative_SSLStreamCreateKeyManagersFromKeyStoreEntry(jobject privateKeyEntry) { - abort_unless(sslStreamProxyHandle != 0, "invalid pointer to the .NET SslStream proxy"); - - SSLStream* sslStream = NULL; + jobjectArray keyManagers = NULL; JNIEnv* env = GetJNIEnv(); - INIT_LOCALS(loc, sslContext, dotnetX509KeyManager, keyManagers, trustManagers); - - loc[sslContext] = GetSSLContextInstance(env); - if (!loc[sslContext]) - goto cleanup; + INIT_LOCALS(loc, dotnetX509KeyManager); loc[dotnetX509KeyManager] = (*env)->NewObject(env, g_DotnetX509KeyManager, g_DotnetX509KeyManagerCtor, privateKeyEntry); ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - loc[keyManagers] = make_java_object_array(env, 1, g_KeyManager, loc[dotnetX509KeyManager]); + keyManagers = make_java_object_array(env, 1, g_KeyManager, loc[dotnetX509KeyManager]); ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - // TrustManager[] trustManagers = GetTrustManagers(sslStreamProxyHandle); - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle, targetHost); - if (!loc[trustManagers]) - goto cleanup; - - // sslContext.init(keyManagers, trustManagers, null); - (*env)->CallVoidMethod(env, loc[sslContext], g_SSLContextInitMethod, loc[keyManagers], loc[trustManagers], NULL); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - sslStream = xcalloc(1, sizeof(SSLStream)); - sslStream->sslContext = ToGRef(env, loc[sslContext]); - loc[sslContext] = NULL; + // Convert to a global ref so that the returned handle survives the JNI local ref frame + // of this P/Invoke call. The caller (SSLStreamCreate) is responsible for releasing it. + keyManagers = ToGRef(env, keyManagers); cleanup: RELEASE_LOCALS(loc, env); - return sslStream; + return keyManagers; } int32_t AndroidCryptoNative_SSLStreamInitialize( diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h index cdfc6c32520b10..bb93eb9e3ce9a4 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h @@ -46,30 +46,33 @@ Create an SSL context Returns NULL on failure */ -PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle, const char* targetHost); +PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreate( + intptr_t sslStreamProxyHandle, + const char* targetHost, + jobject* /*X509Certificate[]*/ trustCerts, + int32_t trustCertsLen, + jobjectArray keyManagers); /* -Create an SSL context with the specified certificates +Create key managers from a PKCS8 private key and certificate chain. +The returned KeyManager[] should be passed to SSLStreamCreate. Returns NULL on failure */ -PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStreamProxyHandle, - const char* targetHost, - uint8_t* pkcs8PrivateKey, - int32_t pkcs8PrivateKeyLen, - PAL_KeyAlgorithm algorithm, - jobject* /*X509Certificate[]*/ certs, - int32_t certsLen); +PALEXPORT jobjectArray AndroidCryptoNative_SSLStreamCreateKeyManagers( + uint8_t* pkcs8PrivateKey, + int32_t pkcs8PrivateKeyLen, + PAL_KeyAlgorithm algorithm, + jobject* /*X509Certificate[]*/ certs, + int32_t certsLen); /* -Create an SSL context with the specified certificates and private key from KeyChain +Create key managers from a KeyStore PrivateKeyEntry. +The returned KeyManager[] should be passed to SSLStreamCreate. Returns NULL on failure */ -PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry( - intptr_t sslStreamProxyHandle, - const char* targetHost, - jobject privateKeyEntry); +PALEXPORT jobjectArray AndroidCryptoNative_SSLStreamCreateKeyManagersFromKeyStoreEntry(jobject privateKeyEntry); /* Initialize an SSL context diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c index eef84188a08f1e..8a157f0e4e343e 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c @@ -1,5 +1,6 @@ #include "pal_trust_manager.h" #include +#include static _Atomic RemoteCertificateValidationCallback verifyRemoteCertificate; @@ -8,8 +9,9 @@ ARGS_NON_NULL_ALL void AndroidCryptoNative_RegisterRemoteCertificateValidationCa atomic_store(&verifyRemoteCertificate, callback); } -// Gets the default X509TrustManager from the system TrustManagerFactory -static jobject GetDefaultX509TrustManager(JNIEnv* env) +// Gets the X509TrustManager from TrustManagerFactory, optionally initialized +// with a custom KeyStore containing trusted certificates. +static jobject GetX509TrustManager(JNIEnv* env, jobject customTrustKeyStore) { jobject result = NULL; INIT_LOCALS(loc, algorithm, tmf, trustManagers); @@ -22,8 +24,8 @@ static jobject GetDefaultX509TrustManager(JNIEnv* env) loc[tmf] = (*env)->CallStaticObjectMethod(env, g_TrustManagerFactory, g_TrustManagerFactoryGetInstance, loc[algorithm]); ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - // tmf.init((KeyStore)null) -> use default system key store - (*env)->CallVoidMethod(env, loc[tmf], g_TrustManagerFactoryInit, NULL); + // tmf.init(keyStore) -> NULL for system defaults, or custom KeyStore for custom trust roots + (*env)->CallVoidMethod(env, loc[tmf], g_TrustManagerFactoryInit, customTrustKeyStore); ON_EXCEPTION_PRINT_AND_GOTO(cleanup); // TrustManager[] tms = tmf.getTrustManagers(); @@ -48,20 +50,66 @@ static jobject GetDefaultX509TrustManager(JNIEnv* env) return result; } -jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const char* targetHost) +// Creates a KeyStore containing the given trusted certificates. +// Returns NULL if trustCerts is NULL or trustCertsLen is 0. +static jobject CreateTrustKeyStore(JNIEnv* env, jobject* trustCerts, int32_t trustCertsLen) { - // X509TrustManager platformTrustManager = GetDefaultX509TrustManager(); - // DotnetProxyTrustManager dotnetProxyTrustManager = new DotnetProxyTrustManager(sslStreamProxyHandle, platformTrustManager, targetHost); - // TrustManager[] trustManagers = new TrustManager[] { dotnetProxyTrustManager }; - // return trustManagers; + if (trustCerts == NULL || trustCertsLen <= 0) + return NULL; + jobject keyStore = NULL; + jstring ksType = NULL; + + // KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + // keyStore.load(null, null); + ksType = (*env)->CallStaticObjectMethod(env, g_KeyStoreClass, g_KeyStoreGetDefaultType); + ON_EXCEPTION_PRINT_AND_GOTO(error); + keyStore = (*env)->CallStaticObjectMethod(env, g_KeyStoreClass, g_KeyStoreGetInstance, ksType); + ON_EXCEPTION_PRINT_AND_GOTO(error); + (*env)->CallVoidMethod(env, keyStore, g_KeyStoreLoad, NULL, NULL); + ON_EXCEPTION_PRINT_AND_GOTO(error); + + ReleaseLRef(env, ksType); + ksType = NULL; + + for (int32_t i = 0; i < trustCertsLen; i++) + { + char alias[32]; + snprintf(alias, sizeof(alias), "trust_%d", i); + jstring aliasStr = make_java_string(env, alias); + + // keyStore.setCertificateEntry(alias, cert); + (*env)->CallVoidMethod(env, keyStore, g_KeyStoreSetCertificateEntry, aliasStr, trustCerts[i]); + ReleaseLRef(env, aliasStr); + ON_EXCEPTION_PRINT_AND_GOTO(error); + } + + return keyStore; + +error: + ReleaseLRef(env, ksType); + ReleaseLRef(env, keyStore); + return NULL; +} + +jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const char* targetHost, jobject* trustCerts, int32_t trustCertsLen) +{ jobjectArray trustManagers = NULL; - INIT_LOCALS(loc, platformTrustManager, dotnetProxyTrustManager); + INIT_LOCALS(loc, trustKeyStore, platformTrustManager, dotnetProxyTrustManager); + + loc[trustKeyStore] = CreateTrustKeyStore(env, trustCerts, trustCertsLen); + // If custom trust certs were requested but KeyStore creation failed, propagate the + // failure rather than silently falling back to system trust (security downgrade). + if (loc[trustKeyStore] == NULL && trustCerts != NULL && trustCertsLen > 0) + { + LOG_ERROR("Failed to create custom trust KeyStore"); + goto cleanup; + } - loc[platformTrustManager] = GetDefaultX509TrustManager(env); + loc[platformTrustManager] = GetX509TrustManager(env, loc[trustKeyStore]); if (loc[platformTrustManager] == NULL) { - LOG_ERROR("Failed to get default X509TrustManager"); + LOG_ERROR("Failed to get X509TrustManager"); goto cleanup; } diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h index cfef56b8f64daf..2e836b93e85b54 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h @@ -4,7 +4,7 @@ typedef bool (*RemoteCertificateValidationCallback)(intptr_t, int32_t); PALEXPORT void AndroidCryptoNative_RegisterRemoteCertificateValidationCallback(RemoteCertificateValidationCallback callback); -jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const char* targetHost); +jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const char* targetHost, jobject* trustCerts, int32_t trustCertsLen); JNIEXPORT jboolean JNICALL Java_net_dot_android_crypto_DotnetProxyTrustManager_verifyRemoteCertificate( JNIEnv *env, jobject thisHandle, jlong sslStreamProxyHandle, jboolean chainTrustedByPlatform); From 162d8f49ba315fc18d87af119c0905ee093e7923 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 13 Feb 2026 12:31:54 +0100 Subject: [PATCH 08/17] Document trust model: platform + managed = more strict, never less --- .../Pal.Android/SafeDeleteSslContext.cs | 5 ++++ .../System/Net/Security/SslStream.Android.cs | 18 ++++++++++----- .../crypto/DotnetProxyTrustManager.java | 23 ++++++++++++++++--- .../pal_trust_manager.c | 18 ++++++++++++++- 4 files changed, 54 insertions(+), 10 deletions(-) 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 03c3385f44c5da..f7fef57df2b793 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 @@ -209,12 +209,17 @@ internal int ReadPendingWrites(byte[] buf, int offset, int count) private static SafeSslHandle CreateSslContext(SslStream.JavaProxy sslStreamProxy, SslAuthenticationOptions authOptions) { + // targetHost is passed to the platform's DotnetProxyTrustManager for hostname-aware + // certificate validation. IP literals are excluded because SNIHostName doesn't accept them. string? targetHost = !authOptions.IsServer && !string.IsNullOrEmpty(authOptions.TargetHost) && !IPAddress.IsValid(authOptions.TargetHost) ? authOptions.TargetHost : null; + // Custom trust roots are passed to the platform's TrustManagerFactory KeyStore. + // The platform's trust verdict is combined with managed validation to be more strict + // (see VerifyRemoteCertificate in SslStream.Android.cs). IntPtr[]? trustCerts = GetTrustCertHandles(authOptions); IntPtr keyManagers = authOptions.CertificateContext is not null ? CreateKeyManagers(authOptions.CertificateContext) 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 ee42101e8ceabe..8587d115433e74 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 @@ -11,15 +11,21 @@ public partial class SslStream { private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate(bool chainTrustedByPlatform) { + // The platform's trust verdict is combined with managed validation to be MORE strict, + // never less. If the platform rejects the chain, sslPolicyErrors is pre-seeded with + // RemoteCertificateChainErrors and managed validation cannot clear it. If the platform + // accepts the chain, managed validation (X509Chain.Build) can still independently + // introduce RemoteCertificateChainErrors. + // + // Exception: when CertificateChainPolicy specifies CustomRootTrust without + // SslCertificateTrust, the platform's verdict is ignored because it lacks the + // intermediate certs from ExtraStore and would produce false rejections. The managed + // chain builder — which has access to both CustomTrustStore and ExtraStore — is + // authoritative in this case. + // // TODO: Investigate whether we can avoid this bypass by also passing ExtraStore // intermediates to the platform's KeyStore (without elevating them to trust anchors), // or by implementing a custom X509TrustManager that performs AIA fetching. - // Currently, when CertificateChainPolicy specifies CustomRootTrust (without - // SslCertificateTrust), the platform may reject the chain because it doesn't have - // the intermediate certs (which are only in ExtraStore) or because the hostname - // verification flags differ from managed settings. The managed chain builder — - // which has access to both CustomTrustStore and ExtraStore — is authoritative - // in this case. bool managedTrustOnly = _sslAuthenticationOptions.CertificateContext?.Trust is null && _sslAuthenticationOptions.CertificateChainPolicy?.TrustMode == X509ChainTrustMode.CustomRootTrust; diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java index 05a84daaef152e..4ed40635117b9c 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java +++ b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java @@ -9,9 +9,20 @@ import javax.net.ssl.X509TrustManager; /** - * This class wraps the platform's default X509TrustManager to first consult - * Android's trust infrastructure (which respects network-security-config.xml), - * then delegates to the managed SslStream code for final validation. + * Wraps the platform's X509TrustManager so that Android's trust infrastructure + * (including network-security-config.xml) is consulted during TLS handshakes. + * + * Trust model: the platform's verdict is combined with managed (.NET) validation + * to be MORE strict, never less: + * + * - Platform rejects the chain -> chainTrustedByPlatform=false is passed to the + * managed callback, which pre-seeds sslPolicyErrors with RemoteCertificateChainErrors. + * Managed validation cannot clear this flag. + * + * - Platform accepts the chain -> chainTrustedByPlatform=true, but managed validation + * (X509Chain.Build) still runs independently and can introduce its own errors. + * + * The RemoteCertificateValidationCallback always receives the union of both assessments. */ public final class DotnetProxyTrustManager implements X509TrustManager { private final long sslStreamProxyHandle; @@ -40,6 +51,12 @@ public void checkServerTrusted(X509Certificate[] chain, String authType) } } + /** + * Checks the server's certificate chain against the platform trust manager. + * Returns true if the platform trusts the chain, false otherwise. + * A false result does NOT abort the handshake — it is forwarded to the managed + * SslStream validation code as the chainTrustedByPlatform flag. + */ private boolean isServerTrustedByPlatformTrustManager(X509Certificate[] chain, String authType) { try { if (targetHost != null) { diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c index 8a157f0e4e343e..b14e64cea3cd9a 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c @@ -10,7 +10,10 @@ ARGS_NON_NULL_ALL void AndroidCryptoNative_RegisterRemoteCertificateValidationCa } // Gets the X509TrustManager from TrustManagerFactory, optionally initialized -// with a custom KeyStore containing trusted certificates. +// with a custom KeyStore containing trusted certificates. When customTrustKeyStore +// is NULL, the system default trust store is used. When non-NULL, only the +// certificates in the custom KeyStore are trusted (Java's KeyStore.setCertificateEntry +// treats every entry as a trust anchor). static jobject GetX509TrustManager(JNIEnv* env, jobject customTrustKeyStore) { jobject result = NULL; @@ -51,6 +54,10 @@ static jobject GetX509TrustManager(JNIEnv* env, jobject customTrustKeyStore) } // Creates a KeyStore containing the given trusted certificates. +// Every certificate added via setCertificateEntry becomes a trust anchor — +// there is no Java equivalent of .NET's ExtraStore (chain-building helpers +// that are NOT trust anchors). This is why only root certificates should be +// passed here, not intermediates. // Returns NULL if trustCerts is NULL or trustCertsLen is 0. static jobject CreateTrustKeyStore(JNIEnv* env, jobject* trustCerts, int32_t trustCertsLen) { @@ -92,6 +99,11 @@ static jobject CreateTrustKeyStore(JNIEnv* env, jobject* trustCerts, int32_t tru return NULL; } +// Creates a DotnetProxyTrustManager wrapping the platform's X509TrustManager. +// The proxy consults Android's trust infrastructure first, then delegates to the +// managed SslStream validation callback. The platform's verdict (chainTrustedByPlatform) +// is passed to the managed side to be combined with managed validation — making +// the overall result more strict, never less (see SslStream.Android.cs). jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const char* targetHost, jobject* trustCerts, int32_t trustCertsLen) { jobjectArray trustManagers = NULL; @@ -132,6 +144,10 @@ jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const return trustManagers; } +// JNI entry point called from DotnetProxyTrustManager.verifyRemoteCertificate(). +// Forwards the platform's trust verdict to the managed SslStream validation callback. +// The managed side combines this with its own X509Chain.Build result — the callback +// always receives the union of both assessments (more strict, never less). ARGS_NON_NULL_ALL jboolean Java_net_dot_android_crypto_DotnetProxyTrustManager_verifyRemoteCertificate( JNIEnv* env, jobject thisHandle, jlong sslStreamProxyHandle, jboolean chainTrustedByPlatform) { From 8c44e4d1cb061d462102a7c53e8e85b589438ddc Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 13 Feb 2026 13:04:23 +0100 Subject: [PATCH 09/17] Refine managedTrustOnly: bypass platform only when user-provided ExtraStore exists --- .../Pal.Android/SafeDeleteSslContext.cs | 8 ++--- .../System/Net/Security/SslStream.Android.cs | 33 ++++++++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) 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 f7fef57df2b793..3aa58b41dd105b 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 @@ -284,10 +284,10 @@ static IntPtr CreateKeyManagers(SslStreamCertificateContext context) // 1. CertificateChainPolicy.CustomTrustStore (when TrustMode is CustomRootTrust) // 2. SslCertificateTrust (via CertificateContext.Trust) — older API // - // Note: for CertificateChainPolicy.CustomRootTrust, the platform may still - // fail validation (e.g. missing intermediates that are only in ExtraStore), - // so the managed chain builder's verdict is authoritative in that case - // (see VerifyRemoteCertificate in SslStream.Android.cs). + // Note: CertificateChainPolicy.ExtraStore intermediates are NOT passed to the + // platform because Java's KeyStore.setCertificateEntry would elevate them to + // trust anchors. When ExtraStore is populated, the managed chain builder is + // authoritative (see VerifyRemoteCertificate in SslStream.Android.cs). X509Certificate2Collection? certs; if (authOptions.CertificateChainPolicy is not null) { 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 8587d115433e74..0ed5e4e5c999ea 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 @@ -17,22 +17,25 @@ private JavaProxy.RemoteCertificateValidationResult VerifyRemoteCertificate(bool // accepts the chain, managed validation (X509Chain.Build) can still independently // introduce RemoteCertificateChainErrors. // - // Exception: when CertificateChainPolicy specifies CustomRootTrust without - // SslCertificateTrust, the platform's verdict is ignored because it lacks the - // intermediate certs from ExtraStore and would produce false rejections. The managed - // chain builder — which has access to both CustomTrustStore and ExtraStore — is - // authoritative in this case. + // The platform's verdict is ignored when the user provided intermediate certificates + // via CertificateChainPolicy.ExtraStore. The platform does not have access to these + // intermediates (Java's KeyStore.setCertificateEntry would elevate them to trust + // anchors) and may produce false rejections for chains that require them. The managed + // chain builder has full access to ExtraStore and is authoritative in this case. // - // TODO: Investigate whether we can avoid this bypass by also passing ExtraStore - // intermediates to the platform's KeyStore (without elevating them to trust anchors), - // or by implementing a custom X509TrustManager that performs AIA fetching. - bool managedTrustOnly = - _sslAuthenticationOptions.CertificateContext?.Trust is null - && _sslAuthenticationOptions.CertificateChainPolicy?.TrustMode == X509ChainTrustMode.CustomRootTrust; - - SslPolicyErrors sslPolicyErrors = chainTrustedByPlatform || managedTrustOnly - ? SslPolicyErrors.None - : SslPolicyErrors.RemoteCertificateChainErrors; + // Note: ExtraStore is also populated later (in SslStream.Protocol.cs) with peer + // certificates received during the TLS handshake. Those are the same certificates + // the platform already has, so they don't affect this decision. At this point, + // ExtraStore.Count reflects only user-provided certificates because + // SslAuthenticationOptions.UpdateOptions clones the user's CertificateChainPolicy + // for each handshake — peer certs from previous handshakes are never carried over. + bool managedTrustOnly = _sslAuthenticationOptions.CertificateChainPolicy?.ExtraStore?.Count > 0; + + SslPolicyErrors sslPolicyErrors = SslPolicyErrors.None; + if (!managedTrustOnly && !chainTrustedByPlatform) + { + sslPolicyErrors = SslPolicyErrors.RemoteCertificateChainErrors; + } ProtocolToken alertToken = default; From d39b4d264b029ae5a74cd5a5d49a3c0508aa13e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Rozs=C3=ADval?= Date: Fri, 13 Feb 2026 13:19:53 +0100 Subject: [PATCH 10/17] Update src/tasks/AndroidAppBuilder/ApkBuilder.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tasks/AndroidAppBuilder/ApkBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tasks/AndroidAppBuilder/ApkBuilder.cs b/src/tasks/AndroidAppBuilder/ApkBuilder.cs index 211dc857eda0bd..6c2a26351e8b91 100644 --- a/src/tasks/AndroidAppBuilder/ApkBuilder.cs +++ b/src/tasks/AndroidAppBuilder/ApkBuilder.cs @@ -468,7 +468,7 @@ public ApkBuilder(TaskLoggingHelper logger) if (!string.IsNullOrEmpty(NetworkSecurityConfigResourcesDir) && string.IsNullOrEmpty(NetworkSecurityConfig)) { throw new ArgumentException( - $"'{nameof(NetworkSecurityConfigResourcesDir)}' is set but '{nameof(NetworkSecurityConfig)}' is not. Both properties must be set together."); + $"'{nameof(NetworkSecurityConfigResourcesDir)}' cannot be set without '{nameof(NetworkSecurityConfig)}'. Set '{nameof(NetworkSecurityConfig)}' first."); } if (!string.IsNullOrEmpty(NetworkSecurityConfig)) { From f3ffdc90a02e2b9950e3b3685d54b0bea512cc3b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 13 Feb 2026 13:21:17 +0100 Subject: [PATCH 11/17] Use CopyFilesToOutputDirectory instead of Publish for _PrepareNetworkSecurityConfig --- .../System.Net.Security.AndroidPlatformTrust.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj index c23c661002c7b7..d4919010311a0e 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj @@ -25,7 +25,7 @@ + DependsOnTargets="CopyFilesToOutputDirectory"> <_CaCertP7bPath>$(OutputPath)TestData/contoso.com.p7b From 13307266d2778ae5ceb13c512a5f7c63ba0c3121 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 13 Feb 2026 14:40:55 +0100 Subject: [PATCH 12/17] Add ExtraStore bypass tests for AndroidPlatformTrust --- .../AndroidPlatformTrustTests.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs index 3a9988a6dd24a9..7cce287cc8fe31 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs @@ -3,6 +3,7 @@ using System.Net.Sockets; using System.Security.Authentication; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Xunit; @@ -247,6 +248,153 @@ await Assert.ThrowsAsync(() => } } + [Fact] + public async Task SslStream_CustomRootTrustWithoutExtraStore_PlatformRejects() + { + // Generate a dynamic PKI (root → intermediate → leaf) that is NOT in network_security_config.xml. + // CustomRootTrust has the dynamic root, but ExtraStore is empty — the intermediate is missing. + // The platform rejects because the dynamic root is not a trusted anchor in the config. + // managedTrustOnly is false (no ExtraStore) so the platform's rejection is honored. + // The managed chain builder also cannot build the chain (missing intermediate). + // Result: RemoteCertificateChainErrors reported. + + (X509Certificate2 rootCert, X509Certificate2 intermediateCert, X509Certificate2 serverCert) = GenerateCertificateChain(); + + SslPolicyErrors? reportedErrors = null; + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (rootCert) + using (intermediateCert) + using (serverCert) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCert, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "testservereku.contoso.com", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + reportedErrors = sslPolicyErrors; + return true; + }, + CertificateChainPolicy = new X509ChainPolicy + { + RevocationMode = X509RevocationMode.NoCheck, + TrustMode = X509ChainTrustMode.CustomRootTrust, + CustomTrustStore = { rootCert }, + }, + }; + + await Task.WhenAll( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)).WaitAsync(TimeSpan.FromSeconds(30)); + } + + Assert.NotNull(reportedErrors); + Assert.True( + (reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors) != 0, + $"Expected RemoteCertificateChainErrors but got: {reportedErrors.Value}"); + } + + [Fact] + public async Task SslStream_CustomRootTrustWithExtraStore_ManagedOverridesPlatform() + { + // Same dynamic PKI as above, but now ExtraStore contains the intermediate CA. + // The platform would reject (dynamic root not in network_security_config.xml), + // but managedTrustOnly is true (ExtraStore is non-empty) so the platform's verdict is bypassed. + // The managed chain builder has root (CustomTrustStore) + intermediate (ExtraStore) and succeeds. + // Result: no RemoteCertificateChainErrors — managed validation is authoritative. + + (X509Certificate2 rootCert, X509Certificate2 intermediateCert, X509Certificate2 serverCert) = GenerateCertificateChain(); + + SslPolicyErrors? reportedErrors = null; + + (SslStream client, SslStream server) = GetConnectedSslStreams(); + using (client) + using (server) + using (rootCert) + using (intermediateCert) + using (serverCert) + { + var serverOptions = new SslServerAuthenticationOptions + { + ServerCertificate = serverCert, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = "testservereku.contoso.com", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + reportedErrors = sslPolicyErrors; + return true; + }, + CertificateChainPolicy = new X509ChainPolicy + { + RevocationMode = X509RevocationMode.NoCheck, + TrustMode = X509ChainTrustMode.CustomRootTrust, + CustomTrustStore = { rootCert }, + ExtraStore = { intermediateCert }, + }, + }; + + await Task.WhenAll( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)).WaitAsync(TimeSpan.FromSeconds(30)); + } + + Assert.NotNull(reportedErrors); + Assert.Equal(SslPolicyErrors.None, reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors); + } + + /// + /// Generates a certificate chain: root CA → intermediate CA → leaf cert. + /// The root is NOT the NDX Test Root CA from network_security_config.xml, + /// so the platform will not trust this chain. + /// + private static (X509Certificate2 root, X509Certificate2 intermediate, X509Certificate2 leaf) GenerateCertificateChain() + { + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddDays(-1); + DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddYears(1); + byte[] serialNumber = new byte[8]; + + using RSA rootKey = RSA.Create(2048); + var rootRequest = new CertificateRequest("CN=Test Root CA", rootKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true)); + var rootSkid = new X509SubjectKeyIdentifierExtension(rootRequest.PublicKey, critical: false); + rootRequest.CertificateExtensions.Add(rootSkid); + X509Certificate2 rootCert = rootRequest.CreateSelfSigned(notBefore, notAfter); + + using RSA intermediateKey = RSA.Create(2048); + var intermediateRequest = new CertificateRequest("CN=Test Intermediate CA", intermediateKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + intermediateRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true)); + var intermediateSkid = new X509SubjectKeyIdentifierExtension(intermediateRequest.PublicKey, critical: false); + intermediateRequest.CertificateExtensions.Add(intermediateSkid); + intermediateRequest.CertificateExtensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(rootSkid)); + RandomNumberGenerator.Fill(serialNumber); + X509Certificate2 intermediateCert = intermediateRequest.Create(rootCert, notBefore, notAfter, serialNumber); + intermediateCert = intermediateCert.CopyWithPrivateKey(intermediateKey); + + using RSA leafKey = RSA.Create(2048); + var leafRequest = new CertificateRequest("CN=testservereku.contoso.com", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + leafRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority: false, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true)); + leafRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, critical: false)); + leafRequest.CertificateExtensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(intermediateSkid)); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("testservereku.contoso.com"); + leafRequest.CertificateExtensions.Add(sanBuilder.Build()); + RandomNumberGenerator.Fill(serialNumber); + X509Certificate2 leafCert = leafRequest.Create(intermediateCert, notBefore, notAfter, serialNumber); + leafCert = leafCert.CopyWithPrivateKey(leafKey); + + return (X509CertificateLoader.LoadCertificate(rootCert.Export(X509ContentType.Cert)), X509CertificateLoader.LoadCertificate(intermediateCert.Export(X509ContentType.Cert)), leafCert); + } + private static (SslStream client, SslStream server) GetConnectedSslStreams() { using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); From 2cf562a51368513080a1038c7a8eea7f14da82ab Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 13 Feb 2026 17:13:41 +0100 Subject: [PATCH 13/17] Add network_security_config.xml regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new tests that verify network_security_config.xml is loaded and effective on Android: - NetworkSecurityConfig_CleartextTrafficBlocked_ForConfiguredDomain: Proves the XML config is loaded by checking per-domain cleartext traffic policy via NetworkSecurityPolicy API. - NetworkSecurityConfig_TrustAnchors_RootCATrustedForConfiguredDomain: Proves per-domain trust anchors work by checking the platform TrustManager directly — the NDX root CA is trusted for the configured domain but not for other domains. Also update SslStream_CertificateSignedByTrustedCA_NoChainErrors to use CustomRootTrust so managed validation also accepts the chain, making the test resilient to running in environments where the NDX root is not a system CA. --- .../AndroidPlatformTrustTests.cs | 81 ++++++++++++++++++- .../network_security_config.xml | 4 +- .../crypto/DotnetProxyTrustManager.java | 39 +++++++++ .../pal_jni.c | 4 + .../pal_jni.h | 2 + .../pal_trust_manager.c | 30 +++++++ .../pal_trust_manager.h | 3 + 7 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs index 7cce287cc8fe31..114f023df0b69f 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.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.IO; using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography; @@ -30,10 +31,12 @@ public class AndroidPlatformTrustTests [Fact] public async Task SslStream_CertificateSignedByTrustedCA_NoChainErrors() { - // The server uses testservereku.contoso.com.pfx which is signed by the NDX Test Root CA. + // The server uses testservereku.contoso.com.pfx signed by the NDX Test Root CA. // The network_security_config.xml trusts that root CA for "testservereku.contoso.com". - // The platform trust manager should accept this chain, so no chain errors are reported. + // CustomRootTrust is configured with the NDX root so that managed validation also accepts. + // If network_security_config.xml is effective, the platform accepts too → no chain errors. + using X509Certificate2 rootCertificate = GetRootCertificate(); SslPolicyErrors? reportedErrors = null; (SslStream client, SslStream server) = GetConnectedSslStreams(); @@ -53,7 +56,13 @@ public async Task SslStream_CertificateSignedByTrustedCA_NoChainErrors() { reportedErrors = sslPolicyErrors; return true; - } + }, + CertificateChainPolicy = new X509ChainPolicy + { + RevocationMode = X509RevocationMode.NoCheck, + TrustMode = X509ChainTrustMode.CustomRootTrust, + CustomTrustStore = { rootCertificate }, + }, }; await Task.WhenAll( @@ -65,6 +74,53 @@ await Task.WhenAll( Assert.Equal(SslPolicyErrors.None, reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors); } + [Fact] + public void NetworkSecurityConfig_CleartextTrafficBlocked_ForConfiguredDomain() + { + // Simplest possible test of network_security_config.xml: + // The config sets cleartextTrafficPermitted="false" for "blocked.example.com" + // and the base-config allows cleartext. If the config is loaded, + // isCleartextTrafficPermitted("blocked.example.com") must return false. + // + // Note: the AndroidManifest.xml template sets usesCleartextTraffic="true" globally, + // but network_security_config.xml takes precedence when present (per Android docs). + // This test implicitly verifies that override behavior. + + bool blockedAllowed = IsCleartextTrafficPermitted("blocked.example.com"); + bool otherAllowed = IsCleartextTrafficPermitted("other.example.com"); + + Assert.False(blockedAllowed, "Cleartext should be blocked for blocked.example.com per network_security_config.xml"); + Assert.True(otherAllowed, "Cleartext should be allowed for other.example.com (base-config allows it)"); + } + + [System.Runtime.InteropServices.DllImport("System.Security.Cryptography.Native.Android", EntryPoint = "AndroidCryptoNative_IsCleartextTrafficPermitted")] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool IsCleartextTrafficPermitted([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPUTF8Str)] string hostname); + + [System.Runtime.InteropServices.DllImport("System.Security.Cryptography.Native.Android", EntryPoint = "AndroidCryptoNative_IsCertificateTrustedForHost")] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool IsCertificateTrustedForHost(byte[] certDer, int certDerLen, [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPUTF8Str)] string hostname); + + [Fact] + public void NetworkSecurityConfig_TrustAnchors_RootCATrustedForConfiguredDomain() + { + // The network_security_config.xml trusts our NDX root CA for "testservereku.contoso.com". + // This test checks directly (via the platform TrustManager) whether the root CA + // is trusted for that domain vs. an unconfigured domain. + + using X509Certificate2 rootCert = GetRootCertificate(); + byte[] rootDer = rootCert.RawData; + + bool trustedForConfiguredDomain = IsCertificateTrustedForHost(rootDer, rootDer.Length, "testservereku.contoso.com"); + bool trustedForOtherDomain = IsCertificateTrustedForHost(rootDer, rootDer.Length, "other.example.com"); + bool trustedForDotNet = IsCertificateTrustedForHost(rootDer, rootDer.Length, "dot.net"); + + // If XML trust anchors work, root should be trusted for testservereku.contoso.com but not for others + Assert.True(trustedForConfiguredDomain, "NDX root should be trusted for testservereku.contoso.com per network_security_config.xml"); + Assert.False(trustedForOtherDomain, "NDX root should NOT be trusted for other.example.com"); + Assert.False(trustedForDotNet, "NDX root should NOT be trusted for dot.net"); + } + [Fact] public async Task SslStream_CertificateNotSignedByTrustedCA_ReportsChainErrors() { @@ -395,6 +451,25 @@ private static (X509Certificate2 root, X509Certificate2 intermediate, X509Certif return (X509CertificateLoader.LoadCertificate(rootCert.Export(X509ContentType.Cert)), X509CertificateLoader.LoadCertificate(intermediateCert.Export(X509ContentType.Cert)), leafCert); } + /// + /// Extracts the NDX Test Root CA (self-signed) from the contoso.com.p7b PKCS#7 bundle. + /// + private static X509Certificate2 GetRootCertificate() + { +#pragma warning disable SYSLIB0057 // X509Certificate2Collection.Import is obsolete + var collection = new X509Certificate2Collection(); + collection.Import(File.ReadAllBytes(Path.Combine("TestData", "contoso.com.p7b"))); +#pragma warning restore SYSLIB0057 + foreach (X509Certificate2 cert in collection) + { + if (cert.Subject == cert.Issuer) + return cert; + cert.Dispose(); + } + + throw new InvalidOperationException("Root CA not found in contoso.com.p7b"); + } + private static (SslStream client, SslStream server) GetConnectedSslStreams() { using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml index c0aa6bb18de5ae..571f0220216115 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml @@ -1,10 +1,12 @@ + + blocked.example.com + testservereku.contoso.com - diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java index 4ed40635117b9c..cdf5c6f36c0226 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java +++ b/src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java @@ -87,4 +87,43 @@ public X509Certificate[] getAcceptedIssuers() { } static native boolean verifyRemoteCertificate(long sslStreamProxyHandle, boolean chainTrustedByPlatform); + + /** + * Checks if cleartext traffic is permitted for the given hostname + * according to the platform's NetworkSecurityPolicy (reads network_security_config.xml). + */ + public static boolean isCleartextTrafficPermitted(String hostname) { + return android.security.NetworkSecurityPolicy.getInstance() + .isCleartextTrafficPermitted(hostname); + } + + /** + * Checks whether the given DER-encoded certificate is trusted for the given hostname + * by the platform's default trust manager (from network_security_config.xml). + */ + public static boolean isCertificateTrustedForHost(byte[] certDer, String hostname) { + try { + java.security.cert.CertificateFactory cf = java.security.cert.CertificateFactory.getInstance("X.509"); + java.security.cert.X509Certificate cert = (java.security.cert.X509Certificate) + cf.generateCertificate(new java.io.ByteArrayInputStream(certDer)); + + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((java.security.KeyStore) null); + javax.net.ssl.TrustManager[] tms = tmf.getTrustManagers(); + for (javax.net.ssl.TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + X509TrustManagerExtensions ext = new X509TrustManagerExtensions((X509TrustManager) tm); + ext.checkServerTrusted( + new java.security.cert.X509Certificate[] { cert }, + "RSA", + hostname); + return true; + } + } + } catch (Exception e) { + // rejected + } + return false; + } } diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c index a460e7a312d84f..3db88698edfabf 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c @@ -500,6 +500,8 @@ jclass g_X509TrustManager; // net/dot/android/crypto/DotnetProxyTrustManager jclass g_DotnetProxyTrustManager; jmethodID g_DotnetProxyTrustManagerCtor; +jmethodID g_DotnetProxyTrustManagerIsCleartextTrafficPermitted; +jmethodID g_DotnetProxyTrustManagerIsCertificateTrustedForHost; // net/dot/android/crypto/DotnetX509KeyManager jclass g_DotnetX509KeyManager; @@ -1123,6 +1125,8 @@ jint AndroidCryptoNative_InitLibraryOnLoad (JavaVM *vm, void *reserved) g_DotnetProxyTrustManager = GetClassGRef(env, "net/dot/android/crypto/DotnetProxyTrustManager"); g_DotnetProxyTrustManagerCtor = GetMethod(env, false, g_DotnetProxyTrustManager, "", "(JLjavax/net/ssl/X509TrustManager;Ljava/lang/String;)V"); + g_DotnetProxyTrustManagerIsCleartextTrafficPermitted = GetMethod(env, true, g_DotnetProxyTrustManager, "isCleartextTrafficPermitted", "(Ljava/lang/String;)Z"); + g_DotnetProxyTrustManagerIsCertificateTrustedForHost = GetMethod(env, true, g_DotnetProxyTrustManager, "isCertificateTrustedForHost", "([BLjava/lang/String;)Z"); g_DotnetX509KeyManager = GetClassGRef(env, "net/dot/android/crypto/DotnetX509KeyManager"); g_DotnetX509KeyManagerCtor = GetMethod(env, false, g_DotnetX509KeyManager, "", "(Ljava/security/KeyStore$PrivateKeyEntry;)V"); diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h index 5534a1f44e34e8..068cf7c217d037 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h @@ -514,6 +514,8 @@ extern jclass g_X509TrustManager; // net/dot/android/crypto/DotnetProxyTrustManager extern jclass g_DotnetProxyTrustManager; extern jmethodID g_DotnetProxyTrustManagerCtor; +extern jmethodID g_DotnetProxyTrustManagerIsCleartextTrafficPermitted; +extern jmethodID g_DotnetProxyTrustManagerIsCertificateTrustedForHost; // net/dot/android/crypto/DotnetX509KeyManager extern jclass g_DotnetX509KeyManager; diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c index b14e64cea3cd9a..e9b0251b5a0420 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c @@ -144,6 +144,36 @@ jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const return trustManagers; } +int32_t AndroidCryptoNative_IsCleartextTrafficPermitted(const char* hostname) +{ + JNIEnv* env = GetJNIEnv(); + jstring hostnameStr = make_java_string(env, hostname); + jboolean result = (*env)->CallStaticBooleanMethod( + env, + g_DotnetProxyTrustManager, + g_DotnetProxyTrustManagerIsCleartextTrafficPermitted, + hostnameStr); + ReleaseLRef(env, hostnameStr); + return (int32_t)result; +} + +int32_t AndroidCryptoNative_IsCertificateTrustedForHost(const uint8_t* certDer, int32_t certDerLen, const char* hostname) +{ + JNIEnv* env = GetJNIEnv(); + jbyteArray certArray = make_java_byte_array(env, certDerLen); + (*env)->SetByteArrayRegion(env, certArray, 0, certDerLen, (const jbyte*)certDer); + jstring hostnameStr = make_java_string(env, hostname); + jboolean result = (*env)->CallStaticBooleanMethod( + env, + g_DotnetProxyTrustManager, + g_DotnetProxyTrustManagerIsCertificateTrustedForHost, + certArray, + hostnameStr); + ReleaseLRef(env, certArray); + ReleaseLRef(env, hostnameStr); + return (int32_t)result; +} + // JNI entry point called from DotnetProxyTrustManager.verifyRemoteCertificate(). // Forwards the platform's trust verdict to the managed SslStream validation callback. // The managed side combines this with its own X509Chain.Build result — the callback diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h index 2e836b93e85b54..926bcde88ecebb 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h @@ -8,3 +8,6 @@ jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle, const JNIEXPORT jboolean JNICALL Java_net_dot_android_crypto_DotnetProxyTrustManager_verifyRemoteCertificate( JNIEnv *env, jobject thisHandle, jlong sslStreamProxyHandle, jboolean chainTrustedByPlatform); + +PALEXPORT int32_t AndroidCryptoNative_IsCleartextTrafficPermitted(const char* hostname); +PALEXPORT int32_t AndroidCryptoNative_IsCertificateTrustedForHost(const uint8_t* certDer, int32_t certDerLen, const char* hostname); From 81743df4cf0ee52fd78f84b069fb67694231d82e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 13 Feb 2026 17:21:49 +0100 Subject: [PATCH 14/17] Add certificate pinning test via network_security_config.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test that connects to dot.net over TLS with a bogus SHA-256 pin in network_security_config.xml. The platform trust manager rejects the connection (pin mismatch) even though dot.net's cert is signed by a trusted system CA, proving that pin-set enforcement works end-to-end through our SslStream → SSLEngine → DotnetProxyTrustManager path. --- .../AndroidPlatformTrustTests.cs | 33 +++++++++++++++++++ .../network_security_config.xml | 6 ++++ 2 files changed, 39 insertions(+) diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs index 114f023df0b69f..dc8b87b5500558 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs @@ -121,6 +121,39 @@ public void NetworkSecurityConfig_TrustAnchors_RootCATrustedForConfiguredDomain( Assert.False(trustedForDotNet, "NDX root should NOT be trusted for dot.net"); } + [Fact] + public async Task NetworkSecurityConfig_CertificatePinning_BlocksConnectionWithWrongPin() + { + // The network_security_config.xml sets a bogus SHA-256 pin for "dot.net". + // When we connect to dot.net over TLS, the platform trust manager should + // reject the connection because the server's certificate chain doesn't + // match the configured pin — even though dot.net's certificate is signed + // by a trusted system CA. + + SslPolicyErrors? reportedErrors = null; + + using var tcp = new System.Net.Sockets.TcpClient(); + await tcp.ConnectAsync("dot.net", 443); + using var sslStream = new SslStream(tcp.GetStream(), leaveInnerStreamOpen: false); + + var options = new SslClientAuthenticationOptions + { + TargetHost = "dot.net", + RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + reportedErrors = sslPolicyErrors; + return true; + } + }; + + await sslStream.AuthenticateAsClientAsync(options); + + Assert.NotNull(reportedErrors); + Assert.True( + (reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors) != 0, + $"Expected RemoteCertificateChainErrors due to pin mismatch but got: {reportedErrors.Value}"); + } + [Fact] public async Task SslStream_CertificateNotSignedByTrustedCA_ReportsChainErrors() { diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml index 571f0220216115..333d36b8c0ff5c 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml @@ -10,4 +10,10 @@ + + dot.net + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + From 730c0a6e7e4ab67573109381bd88e9ef709b8bb3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 14 Feb 2026 01:24:44 +0100 Subject: [PATCH 15/17] Address Copilot review feedback in Android trust tests - Replace external dot.net pinning test with deterministic local pinning check - Add proper certificate disposal in GetRootCertificate error/success paths - Add proper certificate disposal in ExtractRootCA inline task Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AndroidPlatformTrustTests.cs | 65 ++++++++++--------- ...Security.AndroidPlatformTrust.Tests.csproj | 28 +++++--- .../network_security_config.xml | 7 +- 3 files changed, 58 insertions(+), 42 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs index dc8b87b5500558..cba5873936701f 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs @@ -122,36 +122,21 @@ public void NetworkSecurityConfig_TrustAnchors_RootCATrustedForConfiguredDomain( } [Fact] - public async Task NetworkSecurityConfig_CertificatePinning_BlocksConnectionWithWrongPin() + public void NetworkSecurityConfig_CertificatePinning_BlocksConnectionWithWrongPin() { - // The network_security_config.xml sets a bogus SHA-256 pin for "dot.net". - // When we connect to dot.net over TLS, the platform trust manager should - // reject the connection because the server's certificate chain doesn't - // match the configured pin — even though dot.net's certificate is signed - // by a trusted system CA. + // The network_security_config.xml configures a domain that trusts test_ca + // and also sets an intentionally wrong pin for that same domain. + // The certificate is trusted for testservereku.contoso.com (no pin), but + // rejected for pinned.example.com because the configured pin does not match. - SslPolicyErrors? reportedErrors = null; - - using var tcp = new System.Net.Sockets.TcpClient(); - await tcp.ConnectAsync("dot.net", 443); - using var sslStream = new SslStream(tcp.GetStream(), leaveInnerStreamOpen: false); - - var options = new SslClientAuthenticationOptions - { - TargetHost = "dot.net", - RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => - { - reportedErrors = sslPolicyErrors; - return true; - } - }; + using X509Certificate2 rootCert = GetRootCertificate(); + byte[] rootDer = rootCert.RawData; - await sslStream.AuthenticateAsClientAsync(options); + bool trustedForReferenceDomain = IsCertificateTrustedForHost(rootDer, rootDer.Length, "testservereku.contoso.com"); + bool trustedForPinnedDomain = IsCertificateTrustedForHost(rootDer, rootDer.Length, "pinned.example.com"); - Assert.NotNull(reportedErrors); - Assert.True( - (reportedErrors.Value & SslPolicyErrors.RemoteCertificateChainErrors) != 0, - $"Expected RemoteCertificateChainErrors due to pin mismatch but got: {reportedErrors.Value}"); + Assert.True(trustedForReferenceDomain, "NDX root should be trusted for testservereku.contoso.com"); + Assert.False(trustedForPinnedDomain, "NDX root should be rejected for pinned.example.com due to pin mismatch"); } [Fact] @@ -493,14 +478,32 @@ private static X509Certificate2 GetRootCertificate() var collection = new X509Certificate2Collection(); collection.Import(File.ReadAllBytes(Path.Combine("TestData", "contoso.com.p7b"))); #pragma warning restore SYSLIB0057 - foreach (X509Certificate2 cert in collection) + byte[]? rootRawData = null; + try + { + foreach (X509Certificate2 cert in collection) + { + if (cert.Subject == cert.Issuer) + { + rootRawData = cert.Export(X509ContentType.Cert); + break; + } + } + } + finally + { + foreach (X509Certificate2 cert in collection) + { + cert.Dispose(); + } + } + + if (rootRawData is null) { - if (cert.Subject == cert.Issuer) - return cert; - cert.Dispose(); + throw new InvalidOperationException("Root CA not found in contoso.com.p7b"); } - throw new InvalidOperationException("Root CA not found in contoso.com.p7b"); + return X509CertificateLoader.LoadCertificate(rootRawData); } private static (SslStream client, SslStream server) GetConnectedSslStreams() diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj index d4919010311a0e..ca15652007efdc 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj @@ -60,19 +60,29 @@ diff --git a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml index 333d36b8c0ff5c..1decf9423f682c 100644 --- a/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml @@ -10,8 +10,11 @@ - - dot.net + + pinned.example.com + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= From 5b3545c7d43bc8670a7a4d29fd845e71c30ba9f3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 16 Feb 2026 18:29:24 +0100 Subject: [PATCH 16/17] Register JNI native method for NativeAOT compatibility Use RegisterNatives to explicitly register the verifyRemoteCertificate native method with the JVM. This is needed when the native crypto library is statically linked into the final binary (NativeAOT) because the JVM cannot find the symbol via dlsym when it is not exported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pal_jni.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c index 3db88698edfabf..417c71113d8a17 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #include "pal_jni.h" +#include "pal_trust_manager.h" #include JavaVM* gJvm; @@ -1128,6 +1129,15 @@ jint AndroidCryptoNative_InitLibraryOnLoad (JavaVM *vm, void *reserved) g_DotnetProxyTrustManagerIsCleartextTrafficPermitted = GetMethod(env, true, g_DotnetProxyTrustManager, "isCleartextTrafficPermitted", "(Ljava/lang/String;)Z"); g_DotnetProxyTrustManagerIsCertificateTrustedForHost = GetMethod(env, true, g_DotnetProxyTrustManager, "isCertificateTrustedForHost", "([BLjava/lang/String;)Z"); + // Register native methods explicitly so the JVM can find them when the + // native crypto library is statically linked into the final binary + // (NativeAOT). Without this, the JVM relies on symbol lookup via the + // JNI naming convention which fails when the linker strips the symbol. + JNINativeMethod trustManagerMethods[] = { + { "verifyRemoteCertificate", "(JZ)Z", (void*)Java_net_dot_android_crypto_DotnetProxyTrustManager_verifyRemoteCertificate }, + }; + (*env)->RegisterNatives(env, g_DotnetProxyTrustManager, trustManagerMethods, 1); + g_DotnetX509KeyManager = GetClassGRef(env, "net/dot/android/crypto/DotnetX509KeyManager"); g_DotnetX509KeyManagerCtor = GetMethod(env, false, g_DotnetX509KeyManager, "", "(Ljava/security/KeyStore$PrivateKeyEntry;)V"); From 13ffcdda00f9306078fc8cd7334581710b0ffff5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 16 Feb 2026 18:53:02 +0100 Subject: [PATCH 17/17] Fix JNI local reference leak in GetX509TrustManager Create a new local ref for the returned trust manager and release the original array element ref to avoid leaking JNI local references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pal_trust_manager.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c index e9b0251b5a0420..1c7c12769cdb12 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c +++ b/src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c @@ -42,10 +42,11 @@ static jobject GetX509TrustManager(JNIEnv* env, jobject customTrustKeyStore) jobject tm = (*env)->GetObjectArrayElement(env, loc[trustManagers], i); if ((*env)->IsInstanceOf(env, tm, g_X509TrustManager)) { - result = tm; - break; + result = (*env)->NewLocalRef(env, tm); } ReleaseLRef(env, tm); + if (result != NULL) + break; } cleanup: