[Android] Respect platform trust manager in SslStream#124173
[Android] Respect platform trust manager in SslStream#124173simonrozsival wants to merge 14 commits intodotnet:mainfrom
Conversation
8cf3eb9 to
7f1bf1f
Compare
There was a problem hiding this comment.
Pull request overview
Updates Android SslStream certificate validation so it consults the platform trust infrastructure (including network-security-config.xml) before delegating to managed validation, aligning Android behavior more closely with other platforms and correctly surfacing SslPolicyErrors in callbacks.
Changes:
- Wrap Android’s default
X509TrustManagerinDotnetProxyTrustManagerand pass platform-trust outcome into managed validation. - Plumb
targetHostthrough native/managed layers (for hostname-aware checks on newer Android APIs) and move SNI setup intoSSLStreamInitialize. - Add build/test infrastructure to include
network_security_config.xml(and related resources) and introduce Android-focused tests.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tasks/AndroidAppBuilder/Templates/AndroidManifest.xml | Adds a templated placeholder for networkSecurityConfig on the <application> element. |
| src/tasks/AndroidAppBuilder/ApkBuilder.cs | Copies network_security_config.xml + optional resources into res/ and passes -S res to aapt. |
| src/tasks/AndroidAppBuilder/AndroidAppBuilder.cs | Exposes MSBuild task properties for network security config inputs. |
| src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.h | Extends callback/get-trust-managers APIs to include platform-trust flag and targetHost. |
| src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c | Fetches default platform X509TrustManager via TrustManagerFactory and constructs the proxy trust manager with hostname. |
| src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h | Threads targetHost into SSLStream create APIs; removes SSLStreamSetTargetHost. |
| src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c | Passes targetHost into trust manager creation; moves SNI handling into SSLStreamInitialize. |
| src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.h | Adds cached JNI handles for TrustManagerFactory and X509TrustManager. |
| src/native/libs/System.Security.Cryptography.Native.Android/pal_jni.c | Initializes JNI refs/method IDs for platform trust manager discovery and updated proxy TM ctor sig. |
| src/native/libs/System.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java | Wraps platform trust manager and performs platform trust checks (hostname-aware on API 24+). |
| src/mono/msbuild/android/build/AndroidBuild.targets | Plumbs NetworkSecurityConfig* MSBuild properties into AndroidAppBuilder. |
| src/libraries/System.Net.Security/tests/FunctionalTests/System.Net.Security.Tests.csproj | Adds Android TFM and includes Android-only functional test source. |
| src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamPlatformTrustManagerTests.Android.cs | Adds Android functional tests validating platform-trust reporting/override behavior. |
| src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/network_security_config.xml | Adds a network security config used by Android-only tests to validate custom trust anchors. |
| src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj | New Android-only test project that prepares/embeds network security config and CA resource. |
| src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs | New tests validating that platform trust anchors affect SslPolicyErrors. |
| src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs | Changes VerifyRemoteCertificate to take SslPolicyErrors by ref (caller-initialized). |
| src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs | Initializes sslPolicyErrors before calling the ref-based VerifyRemoteCertificate. |
| src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs | Incorporates platform-trust result into managed validation and avoids reintroducing chain errors when platform already trusted. |
| src/libraries/System.Net.Security/src/System/Net/Security/Pal.Android/SafeDeleteSslContext.cs | Passes targetHost into native SSLStream creation (for trust manager hostname-aware validation); removes explicit SSLStreamSetTargetHost call. |
| src/libraries/Common/src/Interop/Android/System.Security.Cryptography.Native.Android/Interop.Ssl.cs | Updates P/Invokes for new targetHost parameters and removes SSLStreamSetTargetHost interop. |
src/libraries/System.Net.Security/tests/AndroidPlatformTrustTests/AndroidPlatformTrustTests.cs
Show resolved
Hide resolved
src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c
Outdated
Show resolved
Hide resolved
7f1bf1f to
6243d5b
Compare
|
Tagging subscribers to 'arch-android': @vitek-karas, @simonrozsival, @steveisok, @akoeplinger |
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 dotnet#107695
6243d5b to
06ea804
Compare
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.
src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c
Outdated
Show resolved
Hide resolved
src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c
Show resolved
Hide resolved
src/native/libs/System.Security.Cryptography.Native.Android/pal_trust_manager.c
Outdated
Show resolved
Hide resolved
...tem.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java
Outdated
Show resolved
Hide resolved
- 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
...tem.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java
Show resolved
Hide resolved
src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h
Outdated
Show resolved
Hide resolved
src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h
Outdated
Show resolved
Hide resolved
src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.h
Outdated
Show resolved
Hide resolved
🤖 Copilot Code Review — PR #124173Holistic AssessmentMotivation: This PR addresses a real security and correctness bug where Android's Approach: Wrapping the platform's default Net positive: ✅ This is a significant correctness and security fix for Android apps. The design is clean and the tests are comprehensive. Detailed Findings✅ Correctness — Trust Integration & Callback BehaviorThe new ✅ Correctness — JNI Lifecycle & Exception HandlingThe ✅ Correctness — Managed-Side API ConsistencyChanging ✅ Test Coverage — GoodThe tests validate key behaviors: untrusted self-signed certs report chain errors, callbacks can override platform failures, callbacks rejecting cause 💡 Performance — Consider Caching X509TrustManagerExtensionsFile:
A new (Flagged by multiple models) 💡 Performance — TrustManagerFactory InitializationFile:
(Flagged by multiple models) 💡 Behavioral Change — SNI Setup Exception TypeFiles: Previously, The retained SummaryLGTM. The security fix is essential, the design is sound, and JNI wiring and exception handling are correct. Consider addressing the minor performance suggestions (caching Review generated with input from multiple AI models (Gemini 3 Pro, Claude Opus 4.5). Claude Sonnet 4 (primary). |
steveisok
left a comment
There was a problem hiding this comment.
LGTM - I would add more security people to review.
src/libraries/System.Net.Security/src/System/Net/Security/Pal.Android/SafeDeleteSslContext.cs
Outdated
Show resolved
Hide resolved
…gement - 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
c188118 to
3a0f51c
Compare
...curity/tests/AndroidPlatformTrustTests/System.Net.Security.AndroidPlatformTrust.Tests.csproj
Outdated
Show resolved
Hide resolved
src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Android.cs
Show resolved
Hide resolved
...tem.Security.Cryptography.Native.Android/net/dot/android/crypto/DotnetProxyTrustManager.java
Show resolved
Hide resolved
fc167da to
cc7ee3a
Compare
cc7ee3a to
8c44e4d
Compare
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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.
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.
| 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}"); | ||
| } | ||
|
|
There was a problem hiding this comment.
This test connects to an external server (dot.net) over the network. This makes the test:
- Flaky - depends on external network availability and could fail in CI environments with restricted network access
- Slow - network round-trip adds latency to test execution
- Non-deterministic - the certificate presented by dot.net could change over time, potentially invalidating the test
Consider using a local test server with a known certificate (similar to other tests in this file) instead of relying on an external host. This would make the test more reliable and faster.
| 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}"); | |
| } |
| 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"); |
There was a problem hiding this comment.
In GetRootCertificate, non-root certificates are disposed in the loop, but if no root certificate is found (the throw case), the last certificate checked is never disposed, causing a memory leak in that error path. Additionally, the returned root certificate should be explicitly exported and reloaded (similar to line 484) to avoid potential handle aliasing issues with the collection's internal references.
Suggest wrapping the entire collection enumeration in a try-finally that disposes all certificates in the collection, or use a pattern that ensures all certs are disposed regardless of the control flow path.
| 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"); | |
| X509Certificate2? rootCertificate = null; | |
| try | |
| { | |
| foreach (X509Certificate2 cert in collection) | |
| { | |
| if (cert.Subject == cert.Issuer) | |
| { | |
| rootCertificate = new X509Certificate2(cert.Export(X509ContentType.Cert)); | |
| break; | |
| } | |
| } | |
| } | |
| finally | |
| { | |
| foreach (X509Certificate2 cert in collection) | |
| { | |
| if (!ReferenceEquals(cert, rootCertificate)) | |
| { | |
| cert.Dispose(); | |
| } | |
| } | |
| } | |
| if (rootCertificate is null) | |
| { | |
| throw new InvalidOperationException("Root CA not found in contoso.com.p7b"); | |
| } | |
| return rootCertificate; |
| collection.Import(P7bPath); | ||
| X509Certificate2 root = null; | ||
| foreach (var cert in collection) | ||
| { | ||
| if (cert.Subject == cert.Issuer) | ||
| { | ||
| root = cert; | ||
| break; | ||
| } | ||
| } | ||
| if (root == null) | ||
| throw new Exception("Root CA not found in " + P7bPath); | ||
| System.IO.File.WriteAllBytes(OutputPath, root.RawData); |
There was a problem hiding this comment.
The ExtractRootCA inline MSBuild task has the same certificate disposal issue as GetRootCertificate in the test file. When a non-root certificate is found in the loop, it's never disposed. If no root is found, the last certificate checked is also never disposed. Additionally, the collection itself should be disposed after use.
Wrap the code in proper try-finally blocks to ensure all certificates are disposed, or iterate with explicit disposal of each certificate.
| collection.Import(P7bPath); | |
| X509Certificate2 root = null; | |
| foreach (var cert in collection) | |
| { | |
| if (cert.Subject == cert.Issuer) | |
| { | |
| root = cert; | |
| break; | |
| } | |
| } | |
| if (root == null) | |
| throw new Exception("Root CA not found in " + P7bPath); | |
| System.IO.File.WriteAllBytes(OutputPath, root.RawData); | |
| try | |
| { | |
| collection.Import(P7bPath); | |
| X509Certificate2 root = null; | |
| foreach (X509Certificate2 cert in collection) | |
| { | |
| if (cert.Subject == cert.Issuer) | |
| { | |
| root = cert; | |
| break; | |
| } | |
| } | |
| if (root == null) | |
| { | |
| throw new Exception("Root CA not found in " + P7bPath); | |
| } | |
| System.IO.File.WriteAllBytes(OutputPath, root.RawData); | |
| } | |
| finally | |
| { | |
| foreach (X509Certificate2 cert in collection) | |
| { | |
| cert.Dispose(); | |
| } | |
| } |
Problem
On Android,
SslStreambypassed the platform's trust infrastructure entirely. TheDotnetProxyTrustManagerdid not consult Android'sX509TrustManager, sonetwork_security_config.xml— used for certificate pinning and custom trust anchors — was ignored.RemoteCertificateValidationCallbackalways receivedSslPolicyErrors.Noneeven when the platform would have rejected the certificate chain (e.g. due to a pin mismatch).This is the exact scenario reported in #107695: an app configures SSL pinning via
network_security_config.xmlwith an intentionally wrong pin, butSslPolicyErrors.Noneis still reported.Fix
This PR wraps the platform's
X509TrustManager(obtained viaTrustManagerFactory) insideDotnetProxyTrustManager. During TLS handshakes, the platform's trust infrastructure — includingnetwork_security_config.xml— is now consulted. The platform's verdict is combined with managed (.NET) validation to be more strict, never less:sslPolicyErrorsis pre-seeded withRemoteCertificateChainErrors. Managed validation cannot clear this flag.X509Chain.Build) still runs independently and can add its own errors.RemoteCertificateValidationCallbackalways receives the union of both assessments.This is consistent with iOS behavior, where the platform trust manager is always consulted.
What's tested
The
AndroidPlatformTrustTestsproject is bundled into an APK with anetwork_security_config.xmlthat configures per-domain trust anchors and certificate pinning. Tests verified on a physical device (Samsung SM-A165F, API 36):NetworkSecurityConfig_CleartextTrafficBlocked_ForConfiguredDomainNetworkSecurityConfig_TrustAnchors_RootCATrustedForConfiguredDomainNetworkSecurityConfig_CertificatePinning_BlocksConnectionWithWrongPindot.netin the XML causesRemoteCertificateChainErrors, even thoughdot.net's cert is signed by a valid system CASslStream_CertificateSignedByTrustedCA_NoChainErrorsSslStream_CertificateNotSignedByTrustedCA_ReportsChainErrorsSslStream_DomainNotInConfig_CallbackReceivesChainErrorsSslStream_CallbackCanOverridePlatformTrustFailuretruecan still accept despite platform rejectionSslStream_CallbackRejectingUntrustedCertificate_ThrowsAuthenticationExceptionfalse→AuthenticationExceptionSslStream_UntrustedCertificateWithoutCallback_ThrowsAuthenticationExceptionAuthenticationExceptionSslStream_CustomRootTrustWithoutExtraStore_PlatformRejectsSslStream_CustomRootTrustWithExtraStore_ManagedOverridesPlatformChanges
Java (
DotnetProxyTrustManager)X509TrustManagerobtained viaTrustManagerFactoryX509TrustManagerExtensions.checkServerTrusted(chain, authType, hostname)for hostname-aware validation (required fornetwork_security_config.xmlper-domain dispatch)chainTrustedByPlatformboolean to native callbackgetAcceptedIssuers()returns empty array to avoid restricting client certificate selectionNative C (
pal_sslstream.c,pal_trust_manager.c)SSLStreamCreate: replaced 3 variants with a single function acceptingtargetHost,trustCerts, andkeyManagersSSLStreamCreateKeyManagersandSSLStreamCreateKeyManagersFromKeyStoreEntryGetX509TrustManager()obtains the platform trust manager, optionally initialized with a customKeyStoretargetHostflows throughSSLStreamCreate→GetTrustManagers→DotnetProxyTrustManagerconstructorFreeSSLStreamwhenSSLStreamInitializewas never calledManaged C# (
SslStream.Android.cs)CreateSslContextrefactored: key manager creation extracted;keyManagersglobal ref released infinallyGetTrustCertHandlescollects custom root certs fromCertificateChainPolicy.CustomTrustStoreorSslCertificateTrustVerifyRemoteCertificatereceiveschainTrustedByPlatformflag and pre-seedssslPolicyErrorsBuild infrastructure (
AndroidAppBuilder)NetworkSecurityConfigandNetworkSecurityConfigResourcesDirfor includingnetwork_security_config.xmlin APKsBehavioral change
Apps using managed-only custom trust roots for CAs not in the Android system store will now see
RemoteCertificateChainErrorsin the callback (previouslySslPolicyErrors.None). This is correct — the callback now reflects the platform's trust assessment. Apps can still accept the connection by returningtruefromRemoteCertificateValidationCallback.Exception: When
CertificateChainPolicyspecifiesCustomRootTrustwithExtraStore, the platform's verdict is bypassed because it lacks intermediate certs fromExtraStoreand would produce false rejections. The managed chain builder is authoritative in this case.Fixes #107695