From 038a7507c05a49b2c18ea5dfd6d75fe320811ea6 Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Mon, 11 May 2026 19:39:28 +0200 Subject: [PATCH] WebView: validate SSL_UNTRUSTED certs via OkHttp trust store When connecting to a Home Assistant instance using a private CA, the WebView's Chromium TLS stack reports SSL_UNTRUSTED (error code 3) even though network_security_config.xml includes user CAs. The app's OkHttpClient correctly trusts user-installed CAs via AndroidCAStore, so we use it as a second opinion before rejecting the connection. Observed log: onReceivedSslError: primary error: 3 certificate: Issued to: CN=antares.milli.ways; Issued by: CN=milly.ways CA Intermediate CA,O=milly.ways CA; on URL: https://homeassistant.milli.ways/auth/authorize?... TLSWebViewClient now overrides onReceivedSslError: for SSL_UNTRUSTED with a non-null URL it launches async validation via isTlsTrusted(); all other errors (IDMISMATCH, EXPIRED, etc.) are rejected immediately. Claude has been used to review the changes and it has written the tests. Co-Authored-By: Claude Opus 4.6 --- .../android/frontend/FrontendViewModel.kt | 1 + .../connection/ConnectionViewModel.kt | 1 + .../companion/android/util/HAWebViewClient.kt | 46 +++- .../android/util/TLSWebViewClient.kt | 130 ++++++++- .../android/webview/WebViewActivity.kt | 28 +- .../android/frontend/FrontendViewModelTest.kt | 6 +- .../connection/ConnectionViewModelTest.kt | 24 +- .../android/util/HAWebViewClientTest.kt | 155 ++++++++++- ...TLSWebViewClientSslErrorRobolectricTest.kt | 205 ++++++++++++++ .../util/TLSWebViewClientSslErrorTest.kt | 253 ++++++++++++++++++ .../android/common/data/TLSHelper.kt | 15 +- .../companion/android/di/DataModule.kt | 6 + 12 files changed, 844 insertions(+), 26 deletions(-) create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorRobolectricTest.kt create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorTest.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt index 41bc6a360f9..24163cdd85e 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt @@ -203,6 +203,7 @@ internal class FrontendViewModel @VisibleForTesting constructor( ) val webViewClient: HAWebViewClient = webViewClientFactory.create( + validationScope = viewModelScope, currentUrlFlow = urlFlow, onFrontendError = ::onError, onCrash = ::onRetry, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt index cf8b5aa0159..56f0a6b0d5c 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt @@ -108,6 +108,7 @@ internal class ConnectionViewModel @VisibleForTesting constructor( } val webViewClient: HAWebViewClient = webViewClientFactory.create( + validationScope = viewModelScope, currentUrlFlow = urlFlow, onFrontendError = ::onError, onUrlIntercepted = ::interceptRedirectIfRequired, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt index 454709e0c07..4e4f7da0c17 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt @@ -5,7 +5,6 @@ import android.net.Uri import android.net.http.SslError import android.webkit.HttpAuthHandler import android.webkit.RenderProcessGoneDetail -import android.webkit.SslErrorHandler import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -15,8 +14,14 @@ import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository import io.homeassistant.companion.android.common.data.keychain.NamedKeyChain import io.homeassistant.companion.android.frontend.error.FrontendConnectionError +import java.io.IOException import javax.inject.Inject +import javax.net.ssl.X509TrustManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow +import okhttp3.OkHttpClient import timber.log.Timber /** @@ -25,10 +30,17 @@ import timber.log.Timber * The created clients handle Home Assistant-specific concerns such as TLS client authentication, * error mapping to [FrontendConnectionError], and JavaScript injection into the WebView. */ -class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyChainRepository: KeyChainRepository) { +class HAWebViewClientFactory @Inject constructor( + @NamedKeyChain private val keyChainRepository: KeyChainRepository, + private val trustManager: X509TrustManager, + private val okHttpClient: OkHttpClient, +) { /** * Creates a new [HAWebViewClient] with the specified configuration. * + * @param validationScope Scope used for async TLS validation coroutines. Should be tied to the + * caller's lifecycle (e.g., `viewModelScope`) so in-flight validations are cancelled on + * destruction. * @param currentUrlFlow StateFlow providing the current URL being loaded. * Used to filter errors - only errors for this URL trigger [onFrontendError]. * @param onFrontendError Callback when a WebView error is mapped to a [FrontendConnectionError]. @@ -41,6 +53,7 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC * Receives the handler, host, the resource URL that triggered the request, and the realm. */ fun create( + validationScope: CoroutineScope, currentUrlFlow: StateFlow, onFrontendError: (FrontendConnectionError) -> Unit, onCrash: (() -> Unit)? = null, @@ -57,6 +70,9 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC ): HAWebViewClient { return HAWebViewClient( keyChainRepository = keyChainRepository, + validationScope = validationScope, + trustManager = trustManager, + okHttpClient = okHttpClient, currentUrlFlow = currentUrlFlow, onFrontendError = onFrontendError, onCrash = onCrash, @@ -75,6 +91,11 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC */ class HAWebViewClient internal constructor( keyChainRepository: KeyChainRepository, + validationScope: CoroutineScope, + trustManager: X509TrustManager, + /** Used as a fallback for API < 29, where [android.net.http.SslCertificate.x509Certificate] is unavailable. */ + okHttpClient: OkHttpClient, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val currentUrlFlow: StateFlow, private val onFrontendError: (FrontendConnectionError) -> Unit, private val onCrash: (() -> Unit)?, @@ -83,7 +104,7 @@ class HAWebViewClient internal constructor( private val onReceivedHttpAuthRequest: ( (handler: HttpAuthHandler, host: String, resource: String, realm: String) -> Unit )?, -) : TLSWebViewClient(keyChainRepository) { +) : TLSWebViewClient(keyChainRepository, validationScope, trustManager, okHttpClient, ioDispatcher) { /** Last resource URL loaded by the WebView, used to identify the resource requesting auth. */ private var lastResourceUrl: String? = null @@ -216,10 +237,23 @@ class HAWebViewClient internal constructor( onFrontendError(frontendConnectionError) } - override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { - super.onReceivedSslError(view, handler, error) - Timber.e("onReceivedSslError: $error") + override fun onTlsValidationNetworkError(error: SslError?) { + Timber.w("TLS validation network error on ${sensitive { error?.url.orEmpty() }}") + onFrontendError( + FrontendConnectionError.UnreachableError( + message = commonR.string.webview_error_CONNECT, + errorDetails = error.toString(), + rawErrorType = IOException::class.toString(), + ), + ) + } + override fun onSslErrorRejected(error: SslError?) { + Timber.e( + "SSL connection rejected: primary error ${error?.primaryError} on ${sensitive { + error?.url.orEmpty() + }}", + ) val messageRes = when (error?.primaryError) { SslError.SSL_DATE_INVALID -> commonR.string.webview_error_SSL_DATE_INVALID SslError.SSL_EXPIRED -> commonR.string.webview_error_SSL_EXPIRED diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/TLSWebViewClient.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/TLSWebViewClient.kt index c1f2e1b2bb4..d18d2b6e2e8 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/TLSWebViewClient.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/TLSWebViewClient.kt @@ -4,21 +4,35 @@ import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.ContextWrapper +import android.net.http.SslCertificate +import android.net.http.SslError +import android.os.Build import android.security.KeyChain import android.security.KeyChainAliasCallback import android.webkit.ClientCertRequest +import android.webkit.SslErrorHandler import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.VisibleForTesting import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository +import io.homeassistant.companion.android.util.sensitive +import java.io.IOException import java.lang.ref.WeakReference import java.security.PrivateKey import java.security.cert.CertificateException import java.security.cert.X509Certificate +import javax.net.ssl.SSLException +import javax.net.ssl.X509TrustManager +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request import timber.log.Timber /* @@ -27,7 +41,25 @@ import timber.log.Timber * we don't want the webview code in the wear app. */ -open class TLSWebViewClient(private var keyChainRepository: KeyChainRepository) : WebViewClient() { +// The server is already reachable (WebView obtained the certificate), so the call is +// mostly a TLS handshake. 3 seconds gives enough margin for slow connections +// without blocking the WebView handler indefinitely. +private val tlsValidationTimeout = 3.seconds + +private sealed interface TlsValidationResult { + data object Trusted : TlsValidationResult + data object Untrusted : TlsValidationResult + data object NetworkError : TlsValidationResult +} + +open class TLSWebViewClient( + private var keyChainRepository: KeyChainRepository, + private val validationScope: CoroutineScope, + private val trustManager: X509TrustManager, + /** Used as a fallback for API < 29, where [SslCertificate.x509Certificate] is unavailable. */ + okHttpClient: OkHttpClient, + @VisibleForTesting internal val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : WebViewClient() { var isTLSClientAuthNeeded = false @VisibleForTesting set @@ -37,6 +69,102 @@ open class TLSWebViewClient(private var keyChainRepository: KeyChainRepository) var isCertificateChainValid = false @VisibleForTesting set + // Used as fallback on API < 29 where SslCertificate.x509Certificate is unavailable + private val tlsValidationClient: OkHttpClient = okHttpClient.newBuilder() + .callTimeout(tlsValidationTimeout.toJavaDuration()) + .build() + + /** + * Called when an SSL error has been rejected and the WebView will not load the resource. + * + * Subclasses can override this to surface the error to the user. Not called when the + * server certificate is trusted and the connection proceeds. + */ + open fun onSslErrorRejected(error: SslError?) {} + + /** + * Called when TLS validation could not be completed due to a network error (e.g. timeout, + * DNS failure) rather than a genuine certificate rejection. + * + * The default implementation delegates to [onSslErrorRejected]. Subclasses can override to + * surface a connectivity error instead of an SSL error to the user. + */ + open fun onTlsValidationNetworkError(error: SslError?) { + onSslErrorRejected(error) + } + + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + Timber.w("onReceivedSslError: primary error ${error?.primaryError} on ${sensitive { error?.url.orEmpty() }}") + if (error == null) { + handler?.cancel() + onSslErrorRejected(null) + return + } + if (error.primaryError == SslError.SSL_UNTRUSTED && error.url != null) { + val url = error.url!! + val handlerRef = handler?.let { WeakReference(it) } + validationScope.launch { + val result = isTlsTrusted(error.certificate, url) + withContext(Dispatchers.Main) { + val handler = handlerRef?.get() + if (handler == null) { + Timber.w("SSL handler was collected before TLS validation completed") + return@withContext + } + when (result) { + TlsValidationResult.Trusted -> handler.proceed() + TlsValidationResult.Untrusted -> { + handler.cancel() + onSslErrorRejected(error) + } + TlsValidationResult.NetworkError -> { + handler.cancel() + onTlsValidationNetworkError(error) + } + } + } + } + } else { + handler?.cancel() + onSslErrorRejected(error) + } + } + + private suspend fun isTlsTrusted(sslCertificate: SslCertificate?, url: String): TlsValidationResult { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cert = sslCertificate?.x509Certificate ?: return TlsValidationResult.Untrusted + return withContext(ioDispatcher) { + try { + // SslCertificate.x509Certificate only exposes the leaf certificate, not the full + // chain. Use "UNKNOWN" for the auth type since the cipher suite is unavailable. + trustManager.checkServerTrusted(arrayOf(cert), "UNKNOWN") + TlsValidationResult.Trusted + } catch (e: CertificateException) { + // The leaf-only array is not enough when the server uses intermediate CAs. + // Fall through to OkHttp which performs a full TLS handshake and receives the + // complete chain from the server. + Timber.d(e, "Direct trust manager check failed, falling back to OkHttp for ${sensitive(url)}") + isTlsTrustedViaOkHttp(url) + } + } + } + return isTlsTrustedViaOkHttp(url) + } + + private suspend fun isTlsTrustedViaOkHttp(url: String): TlsValidationResult = withContext(ioDispatcher) { + try { + val request = Request.Builder().url(url).head().build() + tlsValidationClient.newCall(request).execute().use { } + TlsValidationResult.Trusted + } catch (e: SSLException) { + Timber.w(e, "TLS validation failed for ${sensitive(url)}") + TlsValidationResult.Untrusted + } catch (e: IOException) { + Timber.w(e, "Connection failed during TLS validation for ${sensitive(url)}") + TlsValidationResult.NetworkError + } + } + private var key: PrivateKey? = null private var chain: Array? = null diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index a67325e2117..19101d1ec97 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -28,7 +28,6 @@ import android.webkit.JavascriptInterface import android.webkit.JsResult import android.webkit.PermissionRequest import android.webkit.RenderProcessGoneDetail -import android.webkit.SslErrorHandler import android.webkit.URLUtil import android.webkit.ValueCallback import android.webkit.WebChromeClient @@ -159,6 +158,7 @@ import io.homeassistant.companion.android.webview.externalbus.NavigateTo import io.homeassistant.companion.android.webview.externalbus.ShowSidebar import io.homeassistant.companion.android.webview.insecure.BlockInsecureFragment import javax.inject.Inject +import javax.net.ssl.X509TrustManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -168,6 +168,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import okhttp3.OkHttpClient import org.json.JSONObject import timber.log.Timber @@ -269,6 +270,12 @@ class WebViewActivity : @Inject lateinit var dataSourceFactory: DataSource.Factory + @Inject + lateinit var okHttpClient: OkHttpClient + + @Inject + lateinit var trustManager: X509TrustManager + private lateinit var webView: WebView private var loadedUrl: Uri? = null private lateinit var decor: FrameLayout @@ -493,7 +500,7 @@ class WebViewActivity : } } - webViewClient = object : TLSWebViewClient(keyChainRepository) { + webViewClient = object : TLSWebViewClient(keyChainRepository, lifecycleScope, trustManager, okHttpClient) { @Deprecated("Deprecated in Java for SDK >= 23") override fun onReceivedError( view: WebView?, @@ -569,8 +576,17 @@ class WebViewActivity : authenticationDialog(handler, host, resourceURL, realm, authError) } - override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { - Timber.e("onReceivedSslError: $error") + override fun onTlsValidationNetworkError(error: SslError?) { + Timber.w("TLS validation network error on ${sensitive { error?.url.orEmpty() }}") + showError(ErrorType.TIMEOUT_GENERAL, error, null) + } + + override fun onSslErrorRejected(error: SslError?) { + Timber.e( + "onSslErrorRejected: primary error ${error?.primaryError} on ${sensitive { + error?.url.orEmpty() + }}", + ) showError( ErrorType.SSL, error, @@ -1841,7 +1857,9 @@ class WebViewActivity : } else if (errorType == ErrorType.SSL) { if (description != null) { alert.setMessage(getString(commonR.string.webview_error_description) + " " + description) - } else if (error!!.primaryError == SslError.SSL_DATE_INVALID) { + } else if (error == null) { + alert.setMessage(commonR.string.error_ssl) + } else if (error.primaryError == SslError.SSL_DATE_INVALID) { alert.setMessage(commonR.string.webview_error_SSL_DATE_INVALID) } else if (error.primaryError == SslError.SSL_EXPIRED) { alert.setMessage(commonR.string.webview_error_SSL_EXPIRED) diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt index 01abe514276..447c63ce7ae 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt @@ -915,6 +915,7 @@ class FrontendViewModelTest { var capturedPageFinished: (() -> Unit)? = null every { webViewClientFactory.create( + validationScope = any(), currentUrlFlow = any(), onFrontendError = any(), onCrash = any(), @@ -923,8 +924,8 @@ class FrontendViewModelTest { onReceivedHttpAuthRequest = any(), ) } answers { - // onPageFinished is the 5th of the 6 named arguments (zero-based index 4) - capturedPageFinished = arg(4) + // onPageFinished is the 6th of the 7 named arguments (zero-based index 5) + capturedPageFinished = arg(5) mockk(relaxed = true) } @@ -1014,6 +1015,7 @@ class FrontendViewModelTest { var capturedCallback: ((HttpAuthHandler, String, String, String) -> Unit)? = null every { webViewClientFactory.create( + validationScope = any(), currentUrlFlow = any(), onFrontendError = any(), onCrash = any(), diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt index 2462549da7f..8ef4f26a07b 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt @@ -23,14 +23,19 @@ import io.mockk.mockkStatic import io.mockk.slot import io.mockk.verify import java.net.URL +import javax.net.ssl.X509TrustManager import kotlin.reflect.KClass +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -53,24 +58,31 @@ import org.junit.jupiter.params.provider.ValueSource class ConnectionViewModelTest { private val keyChainRepository: KeyChainRepository = mockk(relaxed = true) + private val trustManager: X509TrustManager = mockk(relaxed = true) + private val okHttpClient: OkHttpClient = mockk(relaxed = true) private val webViewClientFactory: HAWebViewClientFactory = mockk { every { create( + validationScope = any(), currentUrlFlow = any>(), onFrontendError = any(), onCrash = any(), onUrlIntercepted = any(), onPageFinished = any(), + onReceivedHttpAuthRequest = any(), ) } answers { HAWebViewClient( keyChainRepository = keyChainRepository, - currentUrlFlow = firstArg(), - onFrontendError = secondArg(), - onCrash = thirdArg(), - onUrlIntercepted = arg(3), - onPageFinished = arg(4), - onReceivedHttpAuthRequest = arg(5), + validationScope = TestScope(UnconfinedTestDispatcher()), + trustManager = trustManager, + okHttpClient = okHttpClient, + currentUrlFlow = secondArg(), + onFrontendError = thirdArg(), + onCrash = arg(3), + onUrlIntercepted = arg(4), + onPageFinished = arg(5), + onReceivedHttpAuthRequest = arg(6), ) } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt index 62cfe208a05..0c5b279a665 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt @@ -2,6 +2,7 @@ package io.homeassistant.companion.android.util import android.net.http.SslError import android.webkit.HttpAuthHandler +import android.webkit.SslErrorHandler import android.webkit.WebResourceError import android.webkit.WebResourceResponse import android.webkit.WebView @@ -21,19 +22,39 @@ import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Exten import io.mockk.every import io.mockk.mockk import io.mockk.slot +import io.mockk.verify +import java.io.IOException +import javax.net.ssl.X509TrustManager import kotlin.reflect.KClass +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Response import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension -@ExtendWith(MainDispatcherJUnit5Extension::class, ConsoleLogExtension::class) +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(ConsoleLogExtension::class) class HAWebViewClientTest { + @JvmField + @RegisterExtension + val mainDispatcherExtension = MainDispatcherJUnit5Extension(UnconfinedTestDispatcher()) + private val keyChainRepository: KeyChainRepository = mockk(relaxed = true) + private val trustManager: X509TrustManager = mockk(relaxed = true) + private val okHttpClient: OkHttpClient = makeTrustedOkHttpClient() private val currentUrlFlow = MutableStateFlow(null) private var capturedError: FrontendConnectionError? = null @@ -44,6 +65,9 @@ class HAWebViewClientTest { capturedError = null webViewClient = HAWebViewClient( keyChainRepository = keyChainRepository, + validationScope = TestScope(UnconfinedTestDispatcher()), + trustManager = trustManager, + okHttpClient = okHttpClient, currentUrlFlow = currentUrlFlow, onFrontendError = { capturedError = it }, onCrash = null, @@ -79,10 +103,99 @@ class HAWebViewClientTest { } @Test - fun `Given SSL_UNTRUSTED error when onReceivedSslError then emits AuthenticationError`() { + fun `Given SSL_UNTRUSTED error with null URL when onReceivedSslError then emits AuthenticationError`() { testSslError(SslError.SSL_UNTRUSTED, commonR.string.webview_error_SSL_UNTRUSTED) } + @Test + fun `Given SSL_UNTRUSTED with non-null URL when isTlsTrusted succeeds then handler proceeds and no error emitted`() = runTest(mainDispatcherExtension.testDispatcher) { + val client = HAWebViewClient( + keyChainRepository = keyChainRepository, + validationScope = this, + trustManager = trustManager, + okHttpClient = okHttpClient, + currentUrlFlow = currentUrlFlow, + onFrontendError = { capturedError = it }, + onCrash = null, + onUrlIntercepted = null, + onPageFinished = null, + onReceivedHttpAuthRequest = null, + ) + val handler = mockk(relaxed = true) + val sslError = mockk { + every { primaryError } returns SslError.SSL_UNTRUSTED + every { url } returns "https://homeassistant.local" + every { certificate } returns mockk(relaxed = true) + every { toString() } returns "SSL_UNTRUSTED" + } + + client.onReceivedSslError(null, handler, sslError) + advanceUntilIdle() + + verify { handler.proceed() } + verify(exactly = 0) { handler.cancel() } + assertNull(capturedError) + } + + @Test + fun `Given SSL_UNTRUSTED when TLS validation network error then emits UnreachableError`() = runTest(mainDispatcherExtension.testDispatcher) { + val client = HAWebViewClient( + keyChainRepository = keyChainRepository, + validationScope = this, + trustManager = trustManager, + okHttpClient = makeNetworkErrorOkHttpClient(), + currentUrlFlow = currentUrlFlow, + onFrontendError = { capturedError = it }, + onCrash = null, + onUrlIntercepted = null, + onPageFinished = null, + onReceivedHttpAuthRequest = null, + ) + val handler = mockk(relaxed = true) + val sslError = mockk { + every { primaryError } returns SslError.SSL_UNTRUSTED + every { url } returns "https://homeassistant.local" + every { certificate } returns mockk(relaxed = true) + every { toString() } returns "SSL_UNTRUSTED" + } + + client.onReceivedSslError(null, handler, sslError) + advanceUntilIdle() + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertNotNull(capturedError) + assertTrue(capturedError is FrontendConnectionError.UnreachableError) + assertEquals(commonR.string.webview_error_CONNECT, capturedError?.message) + assertEquals(IOException::class.toString(), capturedError?.rawErrorType) + } + + @Test + fun `Given SSL_UNTRUSTED when onSslErrorRejected then emits AuthenticationError`() { + val errorDetails = "SSL_UNTRUSTED details" + val sslError = mockk { + every { primaryError } returns SslError.SSL_UNTRUSTED + every { toString() } returns errorDetails + } + + webViewClient.onSslErrorRejected(sslError) + + assertFrontendError( + commonR.string.webview_error_SSL_UNTRUSTED, + errorDetails, + SslError::class, + ) + } + + @Test + fun `Given null error when onSslErrorRejected then emits generic SSL error`() { + webViewClient.onSslErrorRejected(null) + + assertNotNull(capturedError) + assertTrue(capturedError is FrontendConnectionError.AuthenticationError) + assertEquals(commonR.string.error_ssl, capturedError?.message) + } + @Test fun `Given null SSL error when onReceivedSslError then emits generic SSL error`() { webViewClient.onReceivedSslError(null, null, null) @@ -96,6 +209,7 @@ class HAWebViewClientTest { val details = "SSL Error: $primaryError" val sslError = mockk { every { this@mockk.primaryError } returns primaryError + every { this@mockk.url } returns null every { this@mockk.toString() } returns details } @@ -373,6 +487,9 @@ class HAWebViewClientTest { var capturedRealm: String? = null val client = HAWebViewClient( keyChainRepository = keyChainRepository, + validationScope = TestScope(UnconfinedTestDispatcher()), + trustManager = trustManager, + okHttpClient = okHttpClient, currentUrlFlow = currentUrlFlow, onFrontendError = { capturedError = it }, onCrash = null, @@ -403,6 +520,40 @@ class HAWebViewClientTest { // No exception thrown } + /** + * JVM unit tests run with Build.VERSION.SDK_INT = 0 (< Q), so the OkHttp fallback + * path is used for TLS validation. A relaxed mock returning normally means "trusted". + */ + private fun makeTrustedOkHttpClient(): OkHttpClient { + val call = mockk(relaxed = true) { + every { execute() } returns mockk(relaxed = true) + } + val builtClient = mockk { + every { newCall(any()) } returns call + } + val builder = mockk() + every { builder.callTimeout(any()) } returns builder + every { builder.build() } returns builtClient + return mockk { + every { newBuilder() } returns builder + } + } + + private fun makeNetworkErrorOkHttpClient(): OkHttpClient { + val call = mockk(relaxed = true) { + every { execute() } throws IOException("Connection refused") + } + val builtClient = mockk { + every { newCall(any()) } returns call + } + val builder = mockk() + every { builder.callTimeout(any()) } returns builder + every { builder.build() } returns builtClient + return mockk { + every { newBuilder() } returns builder + } + } + private fun mockRequest(url: String) = mockk { every { this@mockk.url } returns mockk { every { this@mockk.toString() } returns url diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorRobolectricTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorRobolectricTest.kt new file mode 100644 index 00000000000..d4ab6ef4f86 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorRobolectricTest.kt @@ -0,0 +1,205 @@ +package io.homeassistant.companion.android.util + +import android.net.http.SslCertificate +import android.net.http.SslError +import android.os.Build +import android.webkit.SslErrorHandler +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository +import io.homeassistant.companion.android.testing.unit.ConsoleLogRule +import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit4Rule +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLException +import javax.net.ssl.X509TrustManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Response +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests the API >= 29 path in [TLSWebViewClient.isTlsTrusted] that uses [X509TrustManager] + * directly. JVM unit tests run with SDK 0, so this separate Robolectric test is required to + * exercise [SslCertificate.x509Certificate] and the trust manager validation branch. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class, sdk = [Build.VERSION_CODES.Q]) +class TLSWebViewClientSslErrorRobolectricTest { + + @get:Rule(order = 0) + var consoleLog = ConsoleLogRule() + + @get:Rule(order = 1) + val mainDispatcherRule = MainDispatcherJUnit4Rule(UnconfinedTestDispatcher()) + + private val keyChainRepository: KeyChainRepository = mockk(relaxed = true) + private val trustManager: X509TrustManager = mockk(relaxed = true) + private val okHttpClient: OkHttpClient = mockk(relaxed = true) + + @Test + fun `Given SSL_UNTRUSTED when X509TrustManager trusts cert then handler proceeds`() = runTest(mainDispatcherRule.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError( + primaryError = SslError.SSL_UNTRUSTED, + sslErrorUrl = "https://homeassistant.local", + x509Certificate = makeX509Certificate(), + ) + var rejectedError: SslError? = null + + val client = testClient( + validationScope = this, + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify { handler.proceed() } + verify(exactly = 0) { handler.cancel() } + assertNull(rejectedError) + // Verify "UNKNOWN" is passed as authType, not the public key algorithm + verify { trustManager.checkServerTrusted(any(), "UNKNOWN") } + } + + @Test + fun `Given SSL_UNTRUSTED when X509TrustManager rejects cert but OkHttp succeeds then handler proceeds`() = runTest(mainDispatcherRule.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError( + primaryError = SslError.SSL_UNTRUSTED, + sslErrorUrl = "https://homeassistant.local", + x509Certificate = makeX509Certificate(), + ) + var rejectedError: SslError? = null + + every { trustManager.checkServerTrusted(any(), any()) } throws CertificateException("Leaf-only chain rejected") + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClient(trusted = true), + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify { handler.proceed() } + verify(exactly = 0) { handler.cancel() } + assertNull(rejectedError) + } + + @Test + fun `Given SSL_UNTRUSTED when both X509TrustManager and OkHttp reject cert then handler cancels and reports error`() = runTest(mainDispatcherRule.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError( + primaryError = SslError.SSL_UNTRUSTED, + sslErrorUrl = "https://homeassistant.local", + x509Certificate = makeX509Certificate(), + ) + var rejectedError: SslError? = null + + every { trustManager.checkServerTrusted(any(), any()) } throws CertificateException("Untrusted") + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClient(trusted = false), + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertEquals(error, rejectedError) + } + + @Test + fun `Given SSL_UNTRUSTED with null x509Certificate on API 29 then handler cancels`() = runTest(mainDispatcherRule.testDispatcher) { + val handler = mockk(relaxed = true) + // SslCertificate.x509Certificate returns null + val error = makeSslError( + primaryError = SslError.SSL_UNTRUSTED, + sslErrorUrl = "https://homeassistant.local", + x509Certificate = null, + ) + var rejectedError: SslError? = null + + val client = testClient( + validationScope = this, + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertEquals(error, rejectedError) + } + + private fun testClient( + validationScope: CoroutineScope, + okHttpClient: OkHttpClient = this.okHttpClient, + onRejected: (SslError?) -> Unit = {}, + ): TLSWebViewClient { + return object : TLSWebViewClient( + keyChainRepository = keyChainRepository, + validationScope = validationScope, + trustManager = trustManager, + okHttpClient = okHttpClient, + ioDispatcher = Dispatchers.Unconfined, + ) { + override fun onSslErrorRejected(error: SslError?) { + onRejected(error) + } + } + } + + private fun makeOkHttpClient(trusted: Boolean): OkHttpClient { + val call = mockk(relaxed = true) { + if (trusted) { + every { execute() } returns mockk(relaxed = true) + } else { + every { execute() } throws SSLException("Untrusted certificate") + } + } + return makeOkHttpClientWithCall(call) + } + + private fun makeOkHttpClientWithCall(call: Call): OkHttpClient { + val builtClient = mockk { + every { newCall(any()) } returns call + } + val builder = mockk() + every { builder.callTimeout(any()) } returns builder + every { builder.build() } returns builtClient + + return mockk { + every { newBuilder() } returns builder + } + } + + private fun makeSslError(primaryError: Int, sslErrorUrl: String, x509Certificate: X509Certificate?): SslError { + val sslCertificate = mockk(relaxed = true) { + every { this@mockk.x509Certificate } returns x509Certificate + } + return mockk { + every { this@mockk.primaryError } returns primaryError + every { this@mockk.url } returns sslErrorUrl + every { this@mockk.certificate } returns sslCertificate + } + } + + private fun makeX509Certificate(): X509Certificate = mockk(relaxed = true) { + every { publicKey } returns mockk { every { algorithm } returns "RSA" } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorTest.kt new file mode 100644 index 00000000000..7bec6aa31cc --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/TLSWebViewClientSslErrorTest.kt @@ -0,0 +1,253 @@ +package io.homeassistant.companion.android.util + +import android.net.http.SslCertificate +import android.net.http.SslError +import android.webkit.SslErrorHandler +import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository +import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension +import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.io.IOException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLException +import javax.net.ssl.X509TrustManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Response +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(ConsoleLogExtension::class) +class TLSWebViewClientSslErrorTest { + + @JvmField + @RegisterExtension + val mainDispatcherExtension = MainDispatcherJUnit5Extension(UnconfinedTestDispatcher()) + + private val keyChainRepository: KeyChainRepository = mockk(relaxed = true) + private val trustManager: X509TrustManager = mockk(relaxed = true) + + @Test + fun `Given SSL_UNTRUSTED when TLS validation succeeds then handler proceeds`() = runTest(mainDispatcherExtension.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError(primaryError = SslError.SSL_UNTRUSTED, sslErrorUrl = "https://homeassistant.local") + var rejectedError: SslError? = null + + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClient(trusted = true), + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify { handler.proceed() } + verify(exactly = 0) { handler.cancel() } + assertNull(rejectedError) + } + + @Test + fun `Given SSL_UNTRUSTED when TLS validation fails then handler cancels and reports error`() = runTest(mainDispatcherExtension.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError(primaryError = SslError.SSL_UNTRUSTED, sslErrorUrl = "https://homeassistant.local") + var rejectedError: SslError? = null + + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClient(trusted = false), + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertEquals(error, rejectedError) + } + + @Test + fun `Given SSL_EXPIRED when onReceivedSslError then immediately cancels without async validation`() = runTest(mainDispatcherExtension.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError(primaryError = SslError.SSL_EXPIRED, sslErrorUrl = "https://homeassistant.local") + var rejectedError: SslError? = null + + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClient(trusted = true), + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertEquals(error, rejectedError) + } + + @Test + fun `Given SSL_IDMISMATCH when onReceivedSslError then immediately cancels without async validation`() = runTest(mainDispatcherExtension.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError(primaryError = SslError.SSL_IDMISMATCH, sslErrorUrl = "https://homeassistant.local") + var rejectedError: SslError? = null + + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClient(trusted = true), + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertEquals(error, rejectedError) + } + + @Test + fun `Given SSL_UNTRUSTED with null URL when onReceivedSslError then immediately cancels`() = runTest(mainDispatcherExtension.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError(primaryError = SslError.SSL_UNTRUSTED, sslErrorUrl = null) + var rejectedError: SslError? = null + var validationAttempted = false + + val tlsCall = mockk(relaxed = true) { + every { execute() } answers { + validationAttempted = true + mockk(relaxed = true) + } + } + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClientWithCall(tlsCall), + onRejected = { rejectedError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertFalse(validationAttempted) + assertEquals(error, rejectedError) + } + + @Test + fun `Given SSL_UNTRUSTED when OkHttp throws IOException then handler cancels and onTlsValidationNetworkError called`() = runTest(mainDispatcherExtension.testDispatcher) { + val handler = mockk(relaxed = true) + val error = makeSslError(primaryError = SslError.SSL_UNTRUSTED, sslErrorUrl = "https://homeassistant.local") + var rejectedError: SslError? = null + var networkError: SslError? = null + + val call = mockk(relaxed = true) { + every { execute() } throws IOException("Connection refused") + } + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClientWithCall(call), + onRejected = { rejectedError = it }, + onNetworkError = { networkError = it }, + ) + + client.onReceivedSslError(null, handler, error) + + verify(exactly = 0) { handler.proceed() } + verify { handler.cancel() } + assertNull(rejectedError) + assertEquals(error, networkError) + } + + @Test + fun `Given null handler and null error when onReceivedSslError then reports error`() = runTest(mainDispatcherExtension.testDispatcher) { + var rejectedCalled = false + var rejectedError: SslError? = null + val client = testClient( + validationScope = this, + okHttpClient = makeOkHttpClient(trusted = true), + onRejected = { + rejectedCalled = true + rejectedError = it + }, + ) + + client.onReceivedSslError(null, null, null) + + assertTrue(rejectedCalled) + assertNull(rejectedError) + } + + private fun testClient( + validationScope: CoroutineScope, + okHttpClient: OkHttpClient, + onRejected: (SslError?) -> Unit = {}, + onNetworkError: (SslError?) -> Unit = {}, + ): TLSWebViewClient { + return object : TLSWebViewClient( + keyChainRepository = keyChainRepository, + validationScope = validationScope, + trustManager = trustManager, + okHttpClient = okHttpClient, + ioDispatcher = Dispatchers.Unconfined, + ) { + override fun onSslErrorRejected(error: SslError?) { + onRejected(error) + } + + override fun onTlsValidationNetworkError(error: SslError?) { + onNetworkError(error) + } + } + } + + /** + * Creates an OkHttpClient mock that succeeds or fails at the TLS validation call. + * JVM unit tests run with Build.VERSION.SDK_INT = 0 (< Q), so the OkHttp fallback + * path is exercised rather than the X509TrustManager path. + */ + private fun makeOkHttpClient(trusted: Boolean): OkHttpClient { + val call = mockk(relaxed = true) { + if (trusted) { + every { execute() } returns mockk(relaxed = true) + } else { + every { execute() } throws SSLException("Untrusted certificate") + } + } + return makeOkHttpClientWithCall(call) + } + + private fun makeOkHttpClientWithCall(call: Call): OkHttpClient { + val builtClient = mockk { + every { newCall(any()) } returns call + } + val builder = mockk() + every { builder.callTimeout(any()) } returns builder + every { builder.build() } returns builtClient + + return mockk { + every { newBuilder() } returns builder + } + } + + private fun makeSslError(primaryError: Int, sslErrorUrl: String?): SslError { + val cert = mockk { + every { publicKey } returns mockk { every { algorithm } returns "RSA" } + } + val sslCertificate = mockk(relaxed = true) + return mockk { + every { this@mockk.primaryError } returns primaryError + every { this@mockk.url } returns sslErrorUrl + every { this@mockk.certificate } returns sslCertificate + } + } +} diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt index 3bb2db67d6b..a35bb159560 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt @@ -21,7 +21,12 @@ class TLSHelper @Inject constructor( @NamedKeyStore private val keyStore: KeyChainRepository, ) { - fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) { + /** + * The X509TrustManager used by this app's OkHttpClient, initialized with AndroidCAStore to + * include both system and user-installed CAs. Use this to validate server certificates without + * making a network call. + */ + val trustManager: X509TrustManager by lazy { val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) // Load AndroidCAStore explicitly to include user-installed CAs alongside // system CAs. On some Android builds, passing null may load only the @@ -34,12 +39,14 @@ class TLSHelper @Inject constructor( null } trustManagerFactory.init(androidCaStore) - val trustManagers = trustManagerFactory.trustManagers + trustManagerFactory.trustManagers[0] as X509TrustManager + } + fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) { val sslContext = SSLContext.getInstance("TLS") - sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), trustManagers, null) + sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), arrayOf(trustManager), null) - builder.sslSocketFactory(sslContext.socketFactory, trustManagers[0] as X509TrustManager) + builder.sslSocketFactory(sslContext.socketFactory, trustManager) } private fun getMTLSKeyManagerForOKHTTP(): X509ExtendedKeyManager { diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt b/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt index acc636914ee..a1568b42d9b 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt @@ -41,8 +41,10 @@ import io.homeassistant.companion.android.di.qualifiers.NamedOsVersion import io.homeassistant.companion.android.di.qualifiers.NamedSessionStorage import io.homeassistant.companion.android.di.qualifiers.NamedThemesStorage import io.homeassistant.companion.android.di.qualifiers.NamedWearStorage +import io.homeassistant.companion.android.common.data.TLSHelper import java.util.UUID import javax.inject.Singleton +import javax.net.ssl.X509TrustManager import okhttp3.OkHttpClient @Module @@ -64,6 +66,10 @@ internal abstract class DataModule { @Singleton fun providesOkHttpClient(homeAssistantApis: HomeAssistantApis): OkHttpClient = homeAssistantApis.okHttpClient + @Provides + @Singleton + fun providesTrustManager(tlsHelper: TLSHelper): X509TrustManager = tlsHelper.trustManager + @Provides @Singleton fun providesRealDataSourceFactory(