diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt index 6daaf6a6975..1177e01643a 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt @@ -6,6 +6,7 @@ import android.os.strictmode.DiskWriteViolation import android.os.strictmode.IncorrectContextUseViolation import android.os.strictmode.Violation import androidx.annotation.RequiresApi +import io.homeassistant.companion.android.common.data.HomeAssistantApis import io.homeassistant.companion.android.common.util.IgnoreViolationRule val vmPolicyIgnoredViolationRules = listOf( @@ -28,6 +29,7 @@ val threadPolicyIgnoredViolationRules = listOf( IgnoreMiuiTurboSchedMonitorDiskRead, IgnoreChromiumKeyStoreDiskWrite, IgnoreAppCompatPersistLocalesDiskReadWrite, + IgnoreConfigureOkHttpClientDiskRead, ) /** @@ -327,3 +329,21 @@ private data object IgnoreChromiumKeyStoreDiskWrite : IgnoreViolationRule { } } } + +/** + * Ignores a [DiskReadViolation] raised while [HomeAssistantApis] configures its OkHttpClient: + * building the TLSHelper reads CA keystores from disk on some devices. + * + * Solved by https://github.com/home-assistant/android/pull/7042 + */ +private data object IgnoreConfigureOkHttpClientDiskRead : IgnoreViolationRule { + @RequiresApi(Build.VERSION_CODES.P) + override fun shouldIgnore(violation: Violation): Boolean { + if (violation !is DiskReadViolation) return false + + return violation.stackTrace.any { + it.className == HomeAssistantApis::class.java.name && + it.methodName == "configureOkHttpClient" + } + } +} diff --git a/automotive/lint-baseline.xml b/automotive/lint-baseline.xml index 31d9fb83f10..9760c3673cd 100644 --- a/automotive/lint-baseline.xml +++ b/automotive/lint-baseline.xml @@ -349,7 +349,7 @@ errorLine2=" ^"> @@ -360,7 +360,7 @@ errorLine2=" ^"> @@ -371,7 +371,7 @@ errorLine2=" ^"> @@ -778,7 +778,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -789,7 +789,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -800,7 +800,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -811,7 +811,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -822,7 +822,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -833,7 +833,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -844,7 +844,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -855,7 +855,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -866,7 +866,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -877,7 +877,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -888,7 +888,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -899,7 +899,18 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + @@ -910,7 +921,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt new file mode 100644 index 00000000000..b0d74478e11 --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt @@ -0,0 +1,91 @@ +package io.homeassistant.companion.android.common.data + +import android.annotation.SuppressLint +import android.os.Build +import androidx.annotation.RequiresApi +import java.net.Socket +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedTrustManager + +/** + * An [X509ExtendedTrustManager] that trusts a server certificate if either the [primary] or the + * [fallback] accepts it. The [primary] is always tried first; the [fallback] is only asked when the + * [primary] rejects the chain with a [CertificateException]. + * + * This is deliberately more permissive than the [primary] alone: a certificate the [primary] rejects + * becomes trusted as soon as the [fallback] accepts it. It can never be stricter than the [primary], + * so it only ever adds acceptances and never introduces new rejections. + * + * Client-trust checks ([checkClientTrusted]) and [getAcceptedIssuers] decide which client + * certificates to accept, which is unrelated to widening server-certificate trust, so they delegate + * to the [primary] only. + * + * Requires API 24: [X509ExtendedTrustManager] and its hostname-aware overloads don't exist below it. + */ +@RequiresApi(Build.VERSION_CODES.N) +@SuppressLint("CustomX509TrustManager") +internal class CompositeX509ExtendedTrustManager( + private val primary: X509ExtendedTrustManager, + private val fallback: X509ExtendedTrustManager, +) : X509ExtendedTrustManager() { + + /** + * Runs [check] against the [primary], and only if it rejects with a [CertificateException] + * retries against the [fallback]. If both reject, the [primary]'s exception is rethrown (its + * verdict wins) with the [fallback]'s attached via [Throwable.addSuppressed]. + * + * Only [CertificateException] triggers the fallback; anything else propagates unchanged. + */ + private inline fun checkServerOrFallback(check: (X509ExtendedTrustManager) -> Unit) { + try { + check(primary) + } catch (primaryError: CertificateException) { + try { + check(fallback) + } catch (fallbackError: CertificateException) { + primaryError.addSuppressed(fallbackError) + throw primaryError + } + } + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + checkServerOrFallback { it.checkServerTrusted(chain, authType) } + } + + override fun checkServerTrusted(chain: Array?, authType: String?, socket: Socket?) { + checkServerOrFallback { it.checkServerTrusted(chain, authType, socket) } + } + + override fun checkServerTrusted(chain: Array?, authType: String?, engine: SSLEngine?) { + checkServerOrFallback { it.checkServerTrusted(chain, authType, engine) } + } + + /** + * Client trust uses the primary only (see class doc). + */ + override fun checkClientTrusted(chain: Array?, authType: String?) { + primary.checkClientTrusted(chain, authType) + } + + /** + * Client trust uses the primary only (see class doc). + */ + override fun checkClientTrusted(chain: Array?, authType: String?, socket: Socket?) { + primary.checkClientTrusted(chain, authType, socket) + } + + /** + * Client trust uses the primary only (see class doc). + */ + override fun checkClientTrusted(chain: Array?, authType: String?, engine: SSLEngine?) { + primary.checkClientTrusted(chain, authType, engine) + } + + /** Returns the [primary]'s accepted issuers only; the [fallback] has no role in client authentication. */ + override fun getAcceptedIssuers(): Array { + return primary.acceptedIssuers + } +} 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..3786d6976a8 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 @@ -1,9 +1,16 @@ package io.homeassistant.companion.android.common.data +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository import io.homeassistant.companion.android.common.data.keychain.NamedKeyChain import io.homeassistant.companion.android.common.data.keychain.NamedKeyStore +import io.homeassistant.companion.android.common.util.FailFast +import io.homeassistant.companion.android.common.util.SdkVersion +import java.io.IOException import java.net.Socket +import java.security.GeneralSecurityException import java.security.KeyStore import java.security.Principal import java.security.PrivateKey @@ -12,34 +19,83 @@ import javax.inject.Inject import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509ExtendedKeyManager +import javax.net.ssl.X509ExtendedTrustManager import javax.net.ssl.X509TrustManager import okhttp3.OkHttpClient import timber.log.Timber +/** + * Helper to configure an [OkHttpClient] for server-certificate validation (with a + * user-installed CA fallback) and the client certificate for mutual TLS (mTLS). + */ class TLSHelper @Inject constructor( @NamedKeyChain private val keyChainRepository: KeyChainRepository, @NamedKeyStore private val keyStore: KeyChainRepository, ) { + /** + * Configures [builder] to validate server certificates. + * + * It uses Android's default trust manager, which honors `network_security_config.xml` (system and + * user CAs) and can rebuild an incomplete chain itself (see + * https://github.com/home-assistant/android/issues/6810). + * + * Some ROMs (e.g. /e/OS, https://github.com/home-assistant/android/issues/5565) don't honor + * user-installed CAs through that default path even though their browser and WebView do. To cover + * them, the handshake also falls back to a trust manager holding only the user-installed CAs + * whenever the default rejects a certificate (see [withUserInstalledCaFallback]). This relies on + * the app opting into user CAs via `` in `network_security_config.xml`. + */ fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) { - 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 - // system store, which can bypass user-CA trust configured in - // network_security_config.xml (#5565). - val androidCaStore: KeyStore? = try { - KeyStore.getInstance("AndroidCAStore").apply { load(null) } - } catch (e: Throwable) { - Timber.w(e, "AndroidCAStore unavailable, falling back to system trust store") - null + val platformTrustManager = defaultX509TrustManager() ?: run { + FailFast.fail { "No default X509 trust manager available" } + return } - trustManagerFactory.init(androidCaStore) - val trustManagers = trustManagerFactory.trustManagers + val handshakeTrustManager = withUserInstalledCaFallback(platformTrustManager) val sslContext = SSLContext.getInstance("TLS") - sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), trustManagers, null) + sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), arrayOf(handshakeTrustManager), null) - builder.sslSocketFactory(sslContext.socketFactory, trustManagers[0] as X509TrustManager) + builder.sslSocketFactory(sslContext.socketFactory, platformTrustManager) + } + + /** + * Wraps [trustManager] so user-installed CAs are honored as a fallback (see + * https://github.com/home-assistant/android/issues/5565). + * + * Returns [trustManager] unchanged when there is nothing to add: + * - before Android N, where user-installed CAs are already trusted by default; + * - when [trustManager] is not an [X509ExtendedTrustManager] (the composite needs the + * hostname-aware overloads); or + * - when there are no user-installed CAs. + */ + private fun withUserInstalledCaFallback(trustManager: X509TrustManager): X509TrustManager { + if (!SdkVersion.isAtLeast(Build.VERSION_CODES.N)) return trustManager + val extended = trustManager as? X509ExtendedTrustManager ?: return trustManager + val userCaTrustManager = userInstalledCaTrustManager() ?: return trustManager + return CompositeX509ExtendedTrustManager(primary = extended, fallback = userCaTrustManager) + } + + private fun defaultX509TrustManager(keyStore: KeyStore? = null): X509TrustManager? { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(keyStore) + return factory.trustManagers.filterIsInstance().firstOrNull() + } + + /** + * Builds a trust manager from the user-installed CAs (see [userInstalledCaKeyStore]), or `null` + * when there are none or AndroidCAStore can't be read. + */ + @RequiresApi(Build.VERSION_CODES.N) + private fun userInstalledCaTrustManager(): X509ExtendedTrustManager? = try { + val androidCaStore = loadKeyStore("AndroidCAStore") + userInstalledCaKeyStore(androidCaStore)?.let { defaultX509TrustManager(it) as? X509ExtendedTrustManager } + } catch (e: GeneralSecurityException) { + Timber.w(e, "Could not read user-installed CAs, user-CA fallback disabled") + null + } catch (e: IOException) { + Timber.w(e, "Could not read user-installed CAs, user-CA fallback disabled") + null } private fun getMTLSKeyManagerForOKHTTP(): X509ExtendedKeyManager { @@ -70,3 +126,26 @@ class TLSHelper @Inject constructor( } } } + +/** + * Returns a new key store holding only the user-installed CA certificates of [caStore], + * or `null` if there are none. + */ +@VisibleForTesting +internal fun userInstalledCaKeyStore(caStore: KeyStore): KeyStore? { + val userCaStore = loadKeyStore() + + for (alias in caStore.aliases()) { + if (alias.startsWith("user:")) { + userCaStore.setCertificateEntry(alias, caStore.getCertificate(alias)) + } + } + return userCaStore.takeIf { it.size() > 0 } +} + +/** + * Returns a key store of the given [type] initialized with `load(null, null)`. + */ +@VisibleForTesting +internal fun loadKeyStore(type: String = KeyStore.getDefaultType()): KeyStore = + KeyStore.getInstance(type).apply { load(null, null) } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManagerTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManagerTest.kt new file mode 100644 index 00000000000..01f56eaab4c --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManagerTest.kt @@ -0,0 +1,135 @@ +package io.homeassistant.companion.android.common.data + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.net.Socket +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedTrustManager +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +class CompositeX509ExtendedTrustManagerTest { + + private lateinit var primary: X509ExtendedTrustManager + private lateinit var fallback: X509ExtendedTrustManager + private lateinit var composite: CompositeX509ExtendedTrustManager + + @BeforeEach + fun setUp() { + primary = mockk(relaxed = true) + fallback = mockk(relaxed = true) + composite = CompositeX509ExtendedTrustManager(primary = primary, fallback = fallback) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("serverChecks") + fun `Given primary accepts when checking server certificate then fallback is not consulted`(check: ServerCheck) { + check.run(composite) + + verify(exactly = 1) { check.run(primary) } + verify(exactly = 0) { check.run(fallback) } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("serverChecks") + fun `Given primary rejects and fallback accepts when checking server certificate then it is trusted`( + check: ServerCheck, + ) { + every { check.run(primary) } throws CertificateException("primary rejected") + + // Must not throw: the fallback (relaxed mock) accepts the certificate. + check.run(composite) + + verify(exactly = 1) { check.run(fallback) } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("serverChecks") + fun `Given both reject when checking server certificate then primary error is thrown with fallback suppressed`( + check: ServerCheck, + ) { + val primaryError = CertificateException("primary rejected") + val fallbackError = CertificateException("fallback rejected") + every { check.run(primary) } throws primaryError + every { check.run(fallback) } throws fallbackError + + val thrown = assertThrows(CertificateException::class.java) { check.run(composite) } + + assertSame(primaryError, thrown) + assertTrue(thrown.suppressed.contains(fallbackError)) + } + + @Test + fun `Given primary throws a non-certificate error when checking server then it propagates without fallback`() { + every { primary.checkServerTrusted(CHAIN, AUTH_TYPE) } throws IllegalStateException("boom") + + assertThrows(IllegalStateException::class.java) { composite.checkServerTrusted(CHAIN, AUTH_TYPE) } + + verify(exactly = 0) { fallback.checkServerTrusted(CHAIN, AUTH_TYPE) } + } + + @Test + fun `When checking client certificate then it delegates to the primary only`() { + composite.checkClientTrusted(CHAIN, AUTH_TYPE) + composite.checkClientTrusted(CHAIN, AUTH_TYPE, SOCKET) + composite.checkClientTrusted(CHAIN, AUTH_TYPE, ENGINE) + + verify { + primary.checkClientTrusted(CHAIN, AUTH_TYPE) + primary.checkClientTrusted(CHAIN, AUTH_TYPE, SOCKET) + primary.checkClientTrusted(CHAIN, AUTH_TYPE, ENGINE) + } + verify(exactly = 0) { + fallback.checkClientTrusted(any(), any()) + fallback.checkClientTrusted(any(), any(), any()) + fallback.checkClientTrusted(any(), any(), any()) + } + } + + @Test + fun `When getting accepted issuers then only the primary issuers are returned`() { + val issuers = arrayOf(mockk()) + every { primary.acceptedIssuers } returns issuers + + assertSame(issuers, composite.acceptedIssuers) + + verify(exactly = 0) { fallback.acceptedIssuers } + } + + /** + * One of the three `checkServerTrusted` overloads, used to run each test against all of them. + * [run] calls the overload on any [X509ExtendedTrustManager]: the composite under test, or a mock + * inside a MockK `every`/`verify` block. + */ + data class ServerCheck(private val label: String, val run: (X509ExtendedTrustManager) -> Unit) { + override fun toString(): String = label + } + + companion object { + private const val AUTH_TYPE = "RSA" + private val CHAIN = arrayOf(mockk(relaxed = true)) + private val SOCKET = mockk(relaxed = true) + private val ENGINE = mockk(relaxed = true) + + @JvmStatic + fun serverChecks() = listOf( + ServerCheck("checkServerTrusted(chain, authType)") { + it.checkServerTrusted(CHAIN, AUTH_TYPE) + }, + ServerCheck("checkServerTrusted(chain, authType, socket)") { + it.checkServerTrusted(CHAIN, AUTH_TYPE, SOCKET) + }, + ServerCheck("checkServerTrusted(chain, authType, engine)") { + it.checkServerTrusted(CHAIN, AUTH_TYPE, ENGINE) + }, + ) + } +} diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt new file mode 100644 index 00000000000..136334bacfe --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt @@ -0,0 +1,98 @@ +package io.homeassistant.companion.android.common.data + +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull + +class TLSHelperTest { + + @Test + fun `Given user and system CAs when filtering then only the user CA is kept`() { + val store = keyStoreOf( + "user:1a2b.0" to USER_CA, + "system:3c4d.0" to SYSTEM_CA, + ) + + val userCaStore = userInstalledCaKeyStore(store) + + assertNotNull(userCaStore) + assertEquals(1, userCaStore.size()) + assertNotNull(userCaStore.getCertificateAlias(USER_CA)) + assertNull(userCaStore.getCertificateAlias(SYSTEM_CA)) + } + + @Test + fun `Given several user CAs when filtering then all of them are kept`() { + val store = keyStoreOf( + "user:1a2b.0" to USER_CA, + "user:5e6f.0" to USER_CA, + "system:3c4d.0" to SYSTEM_CA, + ) + + val userCaStore = userInstalledCaKeyStore(store) + + assertNotNull(userCaStore) + assertEquals(2, userCaStore.size()) + } + + @Test + fun `Given only system CAs when filtering then it returns null`() { + val store = keyStoreOf("system:3c4d.0" to SYSTEM_CA) + + assertNull(userInstalledCaKeyStore(store)) + } + + @Test + fun `Given an empty store when filtering then it returns null`() { + assertNull(userInstalledCaKeyStore(keyStoreOf())) + } + + private fun keyStoreOf(vararg entries: Pair): KeyStore { + return loadKeyStore().apply { + entries.forEach { (alias, certificate) -> setCertificateEntry(alias, certificate) } + } + } + + companion object { + private val USER_CA = parseCertificate( + """ + -----BEGIN CERTIFICATE----- + MIIBgjCCASmgAwIBAgIUK3+GgehOO2LIwFaGYCqBPSfr9gAwCgYIKoZIzj0EAwIw + FzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMB4XDTI2MDYxNTA5MTYxMloXDTM2MDYx + MjA5MTYxMlowFzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMFkwEwYHKoZIzj0CAQYI + KoZIzj0DAQcDQgAEuWKQFJagdiv0NG8ROZUjviXdxMAYZVIPiBRtPKZ4xEdZ3TSy + 7gFe5R6ZRGD65P/554JCVv99Z5UiUdrn3A0Dt6NTMFEwHQYDVR0OBBYEFNTSQ/2z + oR4mJW8BfJjWudET1UR+MB8GA1UdIwQYMBaAFNTSQ/2zoR4mJW8BfJjWudET1UR+ + MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDRwAwRAIgWRDzptriiK+L11Em + KRPW95Y/AAevho3810p++8sAiK8CIBL/aktDSszIPhvngN/YObx9Rsa1X0GhbCur + WttX27wB + -----END CERTIFICATE----- + """.trimIndent(), + ) + + private val SYSTEM_CA = parseCertificate( + """ + -----BEGIN CERTIFICATE----- + MIIBhjCCAS2gAwIBAgIULM9Sv5gifek7RA04cDsow+5DUC8wCgYIKoZIzj0EAwIw + GTEXMBUGA1UEAwwOVGVzdCBTeXN0ZW0gQ0EwHhcNMjYwNjE1MDkxNjEyWhcNMzYw + NjEyMDkxNjEyWjAZMRcwFQYDVQQDDA5UZXN0IFN5c3RlbSBDQTBZMBMGByqGSM49 + AgEGCCqGSM49AwEHA0IABIoDRdoHkgePxBTI/FrxNtwmlmw2ASjf0nDsXrGzvaCe + Hmopa+Kk+XidaLBFM7uxs01pYfMwfsVM8EFuK4OOsxajUzBRMB0GA1UdDgQWBBR5 + 9mR1Ojk9OqBRX1pZSuaNsSnkuTAfBgNVHSMEGDAWgBR59mR1Ojk9OqBRX1pZSuaN + sSnkuTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFWTMdMCTVkn + 0sVpgRzAofWK821vZgz9M2c9KOWLYrQNAiAAlkVuqpbn6Z+KXNvkMteeAyr0sTwn + 8x92kn3oJNr6WA== + -----END CERTIFICATE----- + """.trimIndent(), + ) + + private fun parseCertificate(pem: String): X509Certificate { + return CertificateFactory.getInstance("X.509") + .generateCertificate(pem.byteInputStream()) as X509Certificate + } + } +} diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt index 5861f980a8b..362fa2466d3 100644 --- a/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -21,6 +21,7 @@ import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.common.util.configureComposeDiagnosticStackTrace import io.homeassistant.companion.android.complications.ComplicationReceiver import io.homeassistant.companion.android.sensors.SensorReceiver +import io.homeassistant.companion.android.util.threadPolicyIgnoredViolationRules import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -45,7 +46,7 @@ open class HomeAssistantApplication : Application() { BuildConfig.DEBUG && !BuildConfig.NO_STRICT_MODE ) { - HAStrictMode.enable() + HAStrictMode.enable(threadPolicyIgnoredViolationRules = threadPolicyIgnoredViolationRules) } Timber.i("Running ${BuildConfig.VERSION_NAME} on SDK $SdkVersion") diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt new file mode 100644 index 00000000000..ffd687e0067 --- /dev/null +++ b/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt @@ -0,0 +1,30 @@ +package io.homeassistant.companion.android.util + +import android.os.Build +import android.os.strictmode.DiskReadViolation +import android.os.strictmode.Violation +import androidx.annotation.RequiresApi +import io.homeassistant.companion.android.common.data.HomeAssistantApis +import io.homeassistant.companion.android.common.util.IgnoreViolationRule + +val threadPolicyIgnoredViolationRules: List = listOf( + IgnoreConfigureOkHttpClientDiskRead, +) + +/** + * Ignores a [DiskReadViolation] raised while [HomeAssistantApis] configures its OkHttpClient: + * building the TLSHelper trust managers reads CA keystores from disk on some devices. + * + * Tracked by https://github.com/home-assistant/android/pull/7042 + */ +private data object IgnoreConfigureOkHttpClientDiskRead : IgnoreViolationRule { + @RequiresApi(Build.VERSION_CODES.P) + override fun shouldIgnore(violation: Violation): Boolean { + if (violation !is DiskReadViolation) return false + + return violation.stackTrace.any { + it.className == HomeAssistantApis::class.java.name && + it.methodName == "configureOkHttpClient" + } + } +}