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..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 @@ -29,26 +29,32 @@ 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); - - [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateWithCertificates")] - private static partial SafeSslHandle SSLStreamCreateWithCertificates( + private static partial SafeSslHandle SSLStreamCreate( 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, + internal static IntPtr SSLStreamCreateKeyManagers( ReadOnlySpan pkcs8PrivateKey, PAL_KeyAlgorithm algorithm, IntPtr[] certificates) { - return SSLStreamCreateWithCertificates( - sslStreamProxy.Handle, + return SSLStreamCreateKeyManagersImpl( ref MemoryMarshal.GetReference(pkcs8PrivateKey), pkcs8PrivateKey.Length, algorithm, @@ -56,20 +62,12 @@ ref MemoryMarshal.GetReference(pkcs8PrivateKey), certificates.Length); } - [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamCreateWithKeyStorePrivateKeyEntry")] - private static partial SafeSslHandle SSLStreamCreateWithKeyStorePrivateKeyEntry( - IntPtr sslStreamProxyHandle, - IntPtr keyStorePrivateKeyEntryHandle); - internal static SafeSslHandle SSLStreamCreateWithKeyStorePrivateKeyEntry( - SslStream.JavaProxy sslStreamProxy, - IntPtr keyStorePrivateKeyEntryHandle) - { - return SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy.Handle, 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( - delegate* unmanaged verifyRemoteCertificate); + delegate* unmanaged verifyRemoteCertificate); [LibraryImport(Interop.Libraries.AndroidCryptoNative, EntryPoint = "AndroidCryptoNative_SSLStreamInitialize")] private static unsafe partial int SSLStreamInitializeImpl( 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..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 @@ -209,34 +209,111 @@ internal int ReadPendingWrites(byte[] buf, int offset, int count) private static SafeSslHandle CreateSslContext(SslStream.JavaProxy sslStreamProxy, SslAuthenticationOptions authOptions) { - if (authOptions.CertificateContext == null) + // 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) + : IntPtr.Zero; + + try { - return Interop.AndroidCrypto.SSLStreamCreate(sslStreamProxy); + return Interop.AndroidCrypto.SSLStreamCreate(sslStreamProxy, targetHost, trustCerts, keyManagers); } - - SslStreamCertificateContext context = authOptions.CertificateContext; - X509Certificate2 cert = context.TargetCertificate; - Debug.Assert(context.TargetCertificate.HasPrivateKey); - - if (Interop.AndroidCrypto.IsKeyStorePrivateKeyEntry(cert.Handle)) + finally { - return Interop.AndroidCrypto.SSLStreamCreateWithKeyStorePrivateKeyEntry(sslStreamProxy, cert.Handle); + // 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); + } } - PAL_KeyAlgorithm algorithm; - byte[] keyBytes; - using (AsymmetricAlgorithm key = GetPrivateKeyAlgorithm(cert, out algorithm)) + static IntPtr CreateKeyManagers(SslStreamCertificateContext context) { - keyBytes = key.ExportPkcs8PrivateKey(); + 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; } - IntPtr[] ptrs = new IntPtr[context.IntermediateCertificates.Count + 1]; - ptrs[0] = cert.Handle; - for (int i = 0; i < context.IntermediateCertificates.Count; i++) + + static IntPtr[]? GetTrustCertHandles(SslAuthenticationOptions authOptions) { - ptrs[i + 1] = context.IntermediateCertificates[i].Handle; - } + // 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: 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) + { + 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; + } - return Interop.AndroidCrypto.SSLStreamCreateWithCertificates(sslStreamProxy, keyBytes, algorithm, ptrs); + IntPtr[] handles = new IntPtr[certs.Count]; + for (int i = 0; i < certs.Count; i++) + { + handles[i] = certs[i].Handle; + } + + 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 317375cec0b1d4..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 @@ -1,25 +1,49 @@ // 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) { + // 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. + // + // 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. + // + // 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; + var isValid = VerifyRemoteCertificate( _sslAuthenticationOptions.CertValidationDelegate, _sslAuthenticationOptions.CertificateContext?.Trust, ref alertToken, - out SslPolicyErrors sslPolicyErrors, + ref sslPolicyErrors, out X509ChainStatusFlags chainStatus); return new() @@ -80,7 +104,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 +112,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..cba5873936701f --- /dev/null +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs @@ -0,0 +1,526 @@ +// 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; +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 signed by the NDX Test Root CA. + // The network_security_config.xml trusts that root CA for "testservereku.contoso.com". + // 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(); + 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; + }, + CertificateChainPolicy = new X509ChainPolicy + { + RevocationMode = X509RevocationMode.NoCheck, + TrustMode = X509ChainTrustMode.CustomRootTrust, + CustomTrustStore = { rootCertificate }, + }, + }; + + 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 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 void NetworkSecurityConfig_CertificatePinning_BlocksConnectionWithWrongPin() + { + // 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. + + using X509Certificate2 rootCert = GetRootCertificate(); + byte[] rootDer = rootCert.RawData; + + bool trustedForReferenceDomain = IsCertificateTrustedForHost(rootDer, rootDer.Length, "testservereku.contoso.com"); + bool trustedForPinnedDomain = IsCertificateTrustedForHost(rootDer, rootDer.Length, "pinned.example.com"); + + 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] + 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 { } + } + } + + [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 { } + } + } + + [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); + } + + /// + /// 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 + 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) + { + throw new InvalidOperationException("Root CA not found in contoso.com.p7b"); + } + + return X509CertificateLoader.LoadCertificate(rootRawData); + } + + 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..ca15652007efdc --- /dev/null +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj @@ -0,0 +1,91 @@ + + + + $(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..1decf9423f682c --- /dev/null +++ b/src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml @@ -0,0 +1,22 @@ + + + + + blocked.example.com + + + testservereku.contoso.com + + + + + + pinned.example.com + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + + 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..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 @@ -3,40 +3,127 @@ package net.dot.android.crypto; +import android.net.http.X509TrustManagerExtensions; 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. + * 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; + private final X509TrustManager platformTrustManager; + 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; } 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(); } } + /** + * 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) { + X509TrustManagerExtensions extensions = new X509TrustManagerExtensions(platformTrustManager); + extensions.checkServerTrusted(chain, authType, targetHost); + } else { + 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 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); + 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 ecb0c1f8ec43f9..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; @@ -487,9 +488,21 @@ 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; +jmethodID g_DotnetProxyTrustManagerIsCleartextTrafficPermitted; +jmethodID g_DotnetProxyTrustManagerIsCertificateTrustedForHost; // net/dot/android/crypto/DotnetX509KeyManager jclass g_DotnetX509KeyManager; @@ -1103,8 +1116,27 @@ 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_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"); 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..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 @@ -501,9 +501,21 @@ 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; +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_sslstream.c b/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c index a7fda9f371fbd5..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) +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) if (!loc[sslContext]) goto cleanup; - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle); + 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,23 +565,17 @@ static int32_t AddCertChainToStore(JNIEnv* env, return ret; } -SSLStream* AndroidCryptoNative_SSLStreamCreateWithCertificates(intptr_t sslStreamProxyHandle, - 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]) @@ -594,66 +597,42 @@ 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); - ON_EXCEPTION_PRINT_AND_GOTO(cleanup); - - // TrustManager[] trustManagers = GetTrustManagers(sslStreamProxyHandle); - loc[trustManagers] = GetTrustManagers(env, sslStreamProxyHandle); - if (!loc[trustManagers]) - goto cleanup; - - // sslContext.init(keyManagers, trustManagers, null); - (*env)->CallVoidMethod(env, loc[sslContext], g_SSLContextInitMethod, loc[keyManagers], loc[trustManagers], NULL); + keyManagers = (*env)->CallObjectMethod(env, loc[kmf], g_KeyManagerFactoryGetKeyManagers); 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, 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); - 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( - 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"); @@ -745,7 +724,7 @@ ARGS_NON_NULL_ALL static int32_t ApplyLegacyAndroidSNIWorkaround(JNIEnv* env, SS return ret; } -int32_t AndroidCryptoNative_SSLStreamSetTargetHost(SSLStream* sslStream, char* targetHost) +int32_t AndroidCryptoNative_SSLStreamSetTargetHost(SSLStream* sslStream, const char* targetHost) { abort_if_invalid_pointer_argument (sslStream); abort_if_invalid_pointer_argument (targetHost); 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..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,26 +46,33 @@ Create an SSL context Returns NULL on failure */ -PALEXPORT SSLStream* AndroidCryptoNative_SSLStreamCreate(intptr_t sslStreamProxyHandle); +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, - 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, jobject privateKeyEntry); +PALEXPORT jobjectArray AndroidCryptoNative_SSLStreamCreateKeyManagersFromKeyStoreEntry(jobject privateKeyEntry); /* Initialize an SSL context @@ -78,7 +85,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); /* Set target host @@ -86,7 +93,7 @@ Set target host Returns 1 on success, 0 otherwise */ -PALEXPORT int32_t AndroidCryptoNative_SSLStreamSetTargetHost(SSLStream* sslStream, char* targetHost); +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/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..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 @@ -1,5 +1,6 @@ #include "pal_trust_manager.h" #include +#include static _Atomic RemoteCertificateValidationCallback verifyRemoteCertificate; @@ -8,16 +9,132 @@ ARGS_NON_NULL_ALL void AndroidCryptoNative_RegisterRemoteCertificateValidationCa atomic_store(&verifyRemoteCertificate, callback); } -ARGS_NON_NULL_ALL jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamProxyHandle) +// Gets the X509TrustManager from TrustManagerFactory, optionally initialized +// 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) { - // X509TrustManager dotnetProxyTrustManager = new DotnetProxyTrustManager(sslStreamProxyHandle); - // TrustManager[] trustManagers = new TrustManager[] { dotnetProxyTrustManager }; - // return trustManagers; + 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 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(); + 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 = (*env)->NewLocalRef(env, tm); + } + ReleaseLRef(env, tm); + if (result != NULL) + break; + } + +cleanup: + RELEASE_LOCALS(loc, env); + return result; +} + +// 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) +{ + 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; +} + +// 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; - INIT_LOCALS(loc, dotnetProxyTrustManager); + INIT_LOCALS(loc, trustKeyStore, platformTrustManager, dotnetProxyTrustManager); - loc[dotnetProxyTrustManager] = (*env)->NewObject(env, g_DotnetProxyTrustManager, g_DotnetProxyTrustManagerCtor, (jlong)sslStreamProxyHandle); + 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] = GetX509TrustManager(env, loc[trustKeyStore]); + if (loc[platformTrustManager] == NULL) + { + LOG_ERROR("Failed to get X509TrustManager"); + goto cleanup; + } + + 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]); @@ -28,10 +145,44 @@ ARGS_NON_NULL_ALL jobjectArray GetTrustManagers(JNIEnv* env, intptr_t sslStreamP 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 +// 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) + 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..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 @@ -1,10 +1,13 @@ #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, jobject* trustCerts, int32_t trustCertsLen); JNIEXPORT jboolean JNICALL Java_net_dot_android_crypto_DotnetProxyTrustManager_verifyRemoteCertificate( - JNIEnv *env, jobject thisHandle, jlong sslStreamProxyHandle); + 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); 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..6c2a26351e8b91 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,52 @@ public ApkBuilder(TaskLoggingHelper logger) File.WriteAllText(monoRunnerPath, monoRunner); + // Handle network security config + string networkSecurityConfigAttr = ""; + string resourceDirArg = ""; + if (!string.IsNullOrEmpty(NetworkSecurityConfigResourcesDir) && string.IsNullOrEmpty(NetworkSecurityConfig)) + { + throw new ArgumentException( + $"'{nameof(NetworkSecurityConfigResourcesDir)}' cannot be set without '{nameof(NetworkSecurityConfig)}'. Set '{nameof(NetworkSecurityConfig)}' first."); + } + 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 +531,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%>