Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
)

val webViewClient: HAWebViewClient = webViewClientFactory.create(
validationScope = viewModelScope,
currentUrlFlow = urlFlow,
onFrontendError = ::onError,
onCrash = ::onRetry,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ internal class ConnectionViewModel @VisibleForTesting constructor(
}

val webViewClient: HAWebViewClient = webViewClientFactory.create(
validationScope = viewModelScope,
currentUrlFlow = urlFlow,
onFrontendError = ::onError,
onUrlIntercepted = ::interceptRedirectIfRequired,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand All @@ -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].
Expand All @@ -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<String?>,
onFrontendError: (FrontendConnectionError) -> Unit,
onCrash: (() -> Unit)? = null,
Expand All @@ -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,
Expand All @@ -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<String?>,
private val onFrontendError: (FrontendConnectionError) -> Unit,
private val onCrash: (() -> Unit)?,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/*
Expand All @@ -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

Expand All @@ -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()
Comment thread
cryptomilk marked this conversation as resolved.
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<X509Certificate>? = null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Comment thread
cryptomilk marked this conversation as resolved.
view: WebView?,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,7 @@ class FrontendViewModelTest {
var capturedPageFinished: (() -> Unit)? = null
every {
webViewClientFactory.create(
validationScope = any(),
currentUrlFlow = any(),
onFrontendError = any(),
onCrash = any(),
Expand All @@ -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)
}

Expand Down Expand Up @@ -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(),
Expand Down
Loading