From 1dd5b1f4ccf45aadafabf2695e7c5092f47b00fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Pacho=C5=82ek?= Date: Mon, 15 Jun 2026 10:27:41 +0200 Subject: [PATCH 1/9] Fix server certificate validation while honoring user-installed CAs PR #6753 changed the OkHttp trust manager from the platform default (TrustManagerFactory.init(null)) to one seeded from AndroidCAStore, which bypasses Android's network_security_config-aware validation and rejects otherwise-valid server certificates. Use the platform default trust manager as the authoritative path again, and add a composite fallback that trusts only user-installed CAs, consulted solely for certificates the platform rejects. This restores correct certificate validation while still honoring user-installed CAs on ROMs that do not surface them through the default path. Fixes #6810 Re #5565 --- .../data/CompositeX509ExtendedTrustManager.kt | 92 ++++++++++++ .../android/common/data/TLSHelper.kt | 98 +++++++++++-- .../CompositeX509ExtendedTrustManagerTest.kt | 135 ++++++++++++++++++ .../android/common/data/TLSHelperTest.kt | 75 ++++++++++ common/src/test/resources/tls/system-ca.b64 | 1 + common/src/test/resources/tls/user-ca.b64 | 1 + 6 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt create mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManagerTest.kt create mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt create mode 100644 common/src/test/resources/tls/system-ca.b64 create mode 100644 common/src/test/resources/tls/user-ca.b64 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..6a223344fce --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt @@ -0,0 +1,92 @@ +package io.homeassistant.companion.android.common.data + +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. + * + * The [primary] is Android's default trust manager (`TrustManagerFactory.init(null)`), which honors + * `network_security_config.xml` and builds the certificate chain. Some ROMs (e.g. /e/OS, #5565) + * don't trust user-installed CAs through that path even though the browser and WebView do, so + * [TLSHelper] gives us a [fallback] holding only the user-installed CAs to cover that case. + * + * This is deliberately more permissive than the [primary]: it re-trusts user-installed CAs the + * platform refused, which widens the trust surface to whatever CAs the user (or an attacker who + * tricks them) has installed. It can't do worse than that: the [fallback] has no system anchors, so + * it can't override a rejected system-rooted certificate, and it can't accept a certificate that + * chains to no trusted anchor (e.g. self-signed). And since the [fallback] only adds acceptances, it + * can't bring back the over-strict rejection of #6810. + * + * Client-trust checks and [getAcceptedIssuers] use the [primary] only; the app is a TLS client, so + * the [fallback] has no role there. + * + * Requires API 24; below it [X509ExtendedTrustManager] doesn't exist and user CAs are trusted by + * default anyway. + */ +@RequiresApi(Build.VERSION_CODES.N) +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) + } + + override fun checkClientTrusted(chain: Array?, authType: String?, socket: Socket?) { + primary.checkClientTrusted(chain, authType, socket) + } + + override fun checkClientTrusted(chain: Array?, authType: String?, engine: SSLEngine?) { + primary.checkClientTrusted(chain, authType, engine) + } + + /** + * Returns the [primary]'s issuers only. This list is the CAs accepted for client authentication, + * which the [fallback] has nothing to do with. + */ + 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..996a8f66d9d 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,45 +1,96 @@ package io.homeassistant.companion.android.common.data +import android.os.Build +import androidx.annotation.RequiresApi 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 java.io.IOException import java.net.Socket +import java.security.GeneralSecurityException import java.security.KeyStore import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate +import java.util.Collections 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 +private const val ANDROID_CA_STORE = "AndroidCAStore" + +// AndroidCAStore prefixes user-installed CA aliases with "user:" and system ones with "system:". +private const val USER_CA_ALIAS_PREFIX = "user:" + class TLSHelper @Inject constructor( @NamedKeyChain private val keyChainRepository: KeyChainRepository, @NamedKeyStore private val keyStore: KeyChainRepository, ) { 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 - } - trustManagerFactory.init(androidCaStore) - val trustManagers = trustManagerFactory.trustManagers + // Default trust manager. init(null) gives us Android's default, which honors + // network_security_config.xml (system + user CAs) and builds the certificate chain itself. + // Passing an explicit KeyStore here instead breaks validation of valid certificates (#6810). + val platformTrustManager = defaultX509TrustManager(keyStore = null) + + // Some ROMs (e.g. /e/OS, #5565) don't trust user-installed CAs through the default path even + // though the browser and WebView do. On those we fall back to a trust manager holding only + // the user-installed CAs, consulted only when the default rejects a certificate. It can add + // acceptances but never cause a rejection, so it can't bring back #6810. See + // [CompositeX509ExtendedTrustManager] for the trust trade-off. + 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) + // OkHttp only uses this trust manager to build a chain cleaner for certificate pinning, which + // the app doesn't use, so it doesn't affect the handshake (handshakeTrustManager decides + // that). We still pass the default one so OkHttp keeps using Android's chain cleaner if + // pinning is ever added. + builder.sslSocketFactory(sslContext.socketFactory, platformTrustManager) + } + + /** + * Wraps [platformTrustManager] so user-installed CAs are honored as a fallback (see #5565). + * + * Returns it unchanged, with no fallback, when: + * - running before Android N, where user-installed CAs are trusted by default; + * - the platform trust manager is not an [X509ExtendedTrustManager] (the composite needs the + * hostname-aware overloads); or + * - there are no user-installed CAs to fall back to. + */ + private fun withUserInstalledCaFallback(platformTrustManager: X509TrustManager): X509TrustManager { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return platformTrustManager + val extendedPlatform = platformTrustManager as? X509ExtendedTrustManager ?: return platformTrustManager + val userCaTrustManager = userInstalledCaTrustManager() ?: return platformTrustManager + return CompositeX509ExtendedTrustManager(primary = extendedPlatform, fallback = userCaTrustManager) + } + + private fun defaultX509TrustManager(keyStore: KeyStore?): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(keyStore) + return factory.trustManagers.filterIsInstance().first() + } + + /** + * Builds a trust manager from the user-installed CAs (see [userInstalledCaKeyStore]), or `null` + * when there are none or the AndroidCAStore can't be read. + */ + @RequiresApi(Build.VERSION_CODES.N) + private fun userInstalledCaTrustManager(): X509ExtendedTrustManager? = try { + val androidCaStore = KeyStore.getInstance(ANDROID_CA_STORE).apply { load(null, null) } + 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 +121,20 @@ class TLSHelper @Inject constructor( } } } + +/** + * Returns a key store holding only the user-installed CA certificates of [androidCaStore] (the + * entries aliased with [USER_CA_ALIAS_PREFIX]), or `null` if there are none. + * + * The preinstalled system CAs are left out so the fallback can only trust CAs the user added, never + * override the platform's rejection of a system-rooted certificate (e.g. one from a distrusted CA). + */ +internal fun userInstalledCaKeyStore(androidCaStore: KeyStore): KeyStore? { + val userCaAliases = Collections.list(androidCaStore.aliases()) + .filter { it.startsWith(USER_CA_ALIAS_PREFIX) } + if (userCaAliases.isEmpty()) return null + return KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + userCaAliases.forEach { alias -> setCertificateEntry(alias, androidCaStore.getCertificate(alias)) } + } +} 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..1a57d55c65f --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt @@ -0,0 +1,75 @@ +package io.homeassistant.companion.android.common.data + +import java.io.ByteArrayInputStream +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 +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.Test + +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 SYSTEM_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 KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + entries.forEach { (alias, certificate) -> setCertificateEntry(alias, certificate) } + } + } + + companion object { + private val USER_CA = loadCertificate("/tls/user-ca.b64") + private val SYSTEM_CA = loadCertificate("/tls/system-ca.b64") + + private fun loadCertificate(resource: String): X509Certificate { + val base64 = TLSHelperTest::class.java.getResourceAsStream(resource)!! + .bufferedReader().use { it.readText() }.trim() + val der = Base64.getDecoder().decode(base64) + return CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(der)) as X509Certificate + } + } +} diff --git a/common/src/test/resources/tls/system-ca.b64 b/common/src/test/resources/tls/system-ca.b64 new file mode 100644 index 00000000000..7b691de5156 --- /dev/null +++ b/common/src/test/resources/tls/system-ca.b64 @@ -0,0 +1 @@ +MIIBhjCCAS2gAwIBAgIULM9Sv5gifek7RA04cDsow+5DUC8wCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwOVGVzdCBTeXN0ZW0gQ0EwHhcNMjYwNjE1MDkxNjEyWhcNMzYwNjEyMDkxNjEyWjAZMRcwFQYDVQQDDA5UZXN0IFN5c3RlbSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIoDRdoHkgePxBTI/FrxNtwmlmw2ASjf0nDsXrGzvaCeHmopa+Kk+XidaLBFM7uxs01pYfMwfsVM8EFuK4OOsxajUzBRMB0GA1UdDgQWBBR59mR1Ojk9OqBRX1pZSuaNsSnkuTAfBgNVHSMEGDAWgBR59mR1Ojk9OqBRX1pZSuaNsSnkuTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFWTMdMCTVkn0sVpgRzAofWK821vZgz9M2c9KOWLYrQNAiAAlkVuqpbn6Z+KXNvkMteeAyr0sTwn8x92kn3oJNr6WA== \ No newline at end of file diff --git a/common/src/test/resources/tls/user-ca.b64 b/common/src/test/resources/tls/user-ca.b64 new file mode 100644 index 00000000000..01519f6032e --- /dev/null +++ b/common/src/test/resources/tls/user-ca.b64 @@ -0,0 +1 @@ +MIIBgjCCASmgAwIBAgIUK3+GgehOO2LIwFaGYCqBPSfr9gAwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMB4XDTI2MDYxNTA5MTYxMloXDTM2MDYxMjA5MTYxMlowFzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuWKQFJagdiv0NG8ROZUjviXdxMAYZVIPiBRtPKZ4xEdZ3TSy7gFe5R6ZRGD65P/554JCVv99Z5UiUdrn3A0Dt6NTMFEwHQYDVR0OBBYEFNTSQ/2zoR4mJW8BfJjWudET1UR+MB8GA1UdIwQYMBaAFNTSQ/2zoR4mJW8BfJjWudET1UR+MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDRwAwRAIgWRDzptriiK+L11EmKRPW95Y/AAevho3810p++8sAiK8CIBL/aktDSszIPhvngN/YObx9Rsa1X0GhbCurWttX27wB \ No newline at end of file From 8e427d93ead3ee7edf55bf41734da1d48a08a873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Pacho=C5=82ek?= Date: Mon, 15 Jun 2026 12:05:38 +0200 Subject: [PATCH 2/9] Document the user-CA fallback's network_security_config assumption --- .../homeassistant/companion/android/common/data/TLSHelper.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 996a8f66d9d..b4598ec59fd 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 @@ -43,6 +43,10 @@ class TLSHelper @Inject constructor( // the user-installed CAs, consulted only when the default rejects a certificate. It can add // acceptances but never cause a rejection, so it can't bring back #6810. See // [CompositeX509ExtendedTrustManager] for the trust trade-off. + // + // This relies on the app intending to trust user-installed CAs, which network_security_config.xml + // opts into with . If that policy is ever removed, this fallback would + // keep trusting user CAs against the new policy, so it must be removed or gated alongside it. val handshakeTrustManager = withUserInstalledCaFallback(platformTrustManager) val sslContext = SSLContext.getInstance("TLS") From 36d9baf288d0c08eca846ecc046bef701d4619ed Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:15:04 +0200 Subject: [PATCH 3/9] Adjust tests and remove base64 files --- .../android/common/data/TLSHelperTest.kt | 50 ++++++++++++++----- common/src/test/resources/tls/system-ca.b64 | 1 - common/src/test/resources/tls/user-ca.b64 | 1 - 3 files changed, 37 insertions(+), 15 deletions(-) delete mode 100644 common/src/test/resources/tls/system-ca.b64 delete mode 100644 common/src/test/resources/tls/user-ca.b64 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 index 1a57d55c65f..2fe7ec8dfc5 100644 --- 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 @@ -1,14 +1,12 @@ package io.homeassistant.companion.android.common.data -import java.io.ByteArrayInputStream import java.security.KeyStore import java.security.cert.CertificateFactory import java.security.cert.X509Certificate -import java.util.Base64 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.Test +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull class TLSHelperTest { @@ -22,7 +20,7 @@ class TLSHelperTest { val userCaStore = userInstalledCaKeyStore(store) assertNotNull(userCaStore) - assertEquals(1, userCaStore!!.size()) + assertEquals(1, userCaStore.size()) assertNotNull(userCaStore.getCertificateAlias(USER_CA)) assertNull(userCaStore.getCertificateAlias(SYSTEM_CA)) } @@ -38,7 +36,7 @@ class TLSHelperTest { val userCaStore = userInstalledCaKeyStore(store) assertNotNull(userCaStore) - assertEquals(2, userCaStore!!.size()) + assertEquals(2, userCaStore.size()) } @Test @@ -61,15 +59,41 @@ class TLSHelperTest { } companion object { - private val USER_CA = loadCertificate("/tls/user-ca.b64") - private val SYSTEM_CA = loadCertificate("/tls/system-ca.b64") + 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 loadCertificate(resource: String): X509Certificate { - val base64 = TLSHelperTest::class.java.getResourceAsStream(resource)!! - .bufferedReader().use { it.readText() }.trim() - val der = Base64.getDecoder().decode(base64) + private fun parseCertificate(pem: String): X509Certificate { return CertificateFactory.getInstance("X.509") - .generateCertificate(ByteArrayInputStream(der)) as X509Certificate + .generateCertificate(pem.byteInputStream()) as X509Certificate } } } diff --git a/common/src/test/resources/tls/system-ca.b64 b/common/src/test/resources/tls/system-ca.b64 deleted file mode 100644 index 7b691de5156..00000000000 --- a/common/src/test/resources/tls/system-ca.b64 +++ /dev/null @@ -1 +0,0 @@ -MIIBhjCCAS2gAwIBAgIULM9Sv5gifek7RA04cDsow+5DUC8wCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwOVGVzdCBTeXN0ZW0gQ0EwHhcNMjYwNjE1MDkxNjEyWhcNMzYwNjEyMDkxNjEyWjAZMRcwFQYDVQQDDA5UZXN0IFN5c3RlbSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIoDRdoHkgePxBTI/FrxNtwmlmw2ASjf0nDsXrGzvaCeHmopa+Kk+XidaLBFM7uxs01pYfMwfsVM8EFuK4OOsxajUzBRMB0GA1UdDgQWBBR59mR1Ojk9OqBRX1pZSuaNsSnkuTAfBgNVHSMEGDAWgBR59mR1Ojk9OqBRX1pZSuaNsSnkuTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFWTMdMCTVkn0sVpgRzAofWK821vZgz9M2c9KOWLYrQNAiAAlkVuqpbn6Z+KXNvkMteeAyr0sTwn8x92kn3oJNr6WA== \ No newline at end of file diff --git a/common/src/test/resources/tls/user-ca.b64 b/common/src/test/resources/tls/user-ca.b64 deleted file mode 100644 index 01519f6032e..00000000000 --- a/common/src/test/resources/tls/user-ca.b64 +++ /dev/null @@ -1 +0,0 @@ -MIIBgjCCASmgAwIBAgIUK3+GgehOO2LIwFaGYCqBPSfr9gAwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMB4XDTI2MDYxNTA5MTYxMloXDTM2MDYxMjA5MTYxMlowFzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuWKQFJagdiv0NG8ROZUjviXdxMAYZVIPiBRtPKZ4xEdZ3TSy7gFe5R6ZRGD65P/554JCVv99Z5UiUdrn3A0Dt6NTMFEwHQYDVR0OBBYEFNTSQ/2zoR4mJW8BfJjWudET1UR+MB8GA1UdIwQYMBaAFNTSQ/2zoR4mJW8BfJjWudET1UR+MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDRwAwRAIgWRDzptriiK+L11EmKRPW95Y/AAevho3810p++8sAiK8CIBL/aktDSszIPhvngN/YObx9Rsa1X0GhbCurWttX27wB \ No newline at end of file From cd70f6c8ed2d2e93c76529a9b1c5769e65c0d638 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:10:47 +0200 Subject: [PATCH 4/9] Simplify documentation --- .../data/CompositeX509ExtendedTrustManager.kt | 31 ++--- .../android/common/data/TLSHelper.kt | 107 ++++++++++-------- .../android/common/data/TLSHelperTest.kt | 3 +- 3 files changed, 69 insertions(+), 72 deletions(-) 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 index 6a223344fce..6965fd5d1da 100644 --- 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 @@ -1,5 +1,6 @@ package io.homeassistant.companion.android.common.data +import android.annotation.SuppressLint import android.os.Build import androidx.annotation.RequiresApi import java.net.Socket @@ -11,27 +12,20 @@ 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. + * [primary] rejects the chain with a [CertificateException]. * - * The [primary] is Android's default trust manager (`TrustManagerFactory.init(null)`), which honors - * `network_security_config.xml` and builds the certificate chain. Some ROMs (e.g. /e/OS, #5565) - * don't trust user-installed CAs through that path even though the browser and WebView do, so - * [TLSHelper] gives us a [fallback] holding only the user-installed CAs to cover that case. + * 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. * - * This is deliberately more permissive than the [primary]: it re-trusts user-installed CAs the - * platform refused, which widens the trust surface to whatever CAs the user (or an attacker who - * tricks them) has installed. It can't do worse than that: the [fallback] has no system anchors, so - * it can't override a rejected system-rooted certificate, and it can't accept a certificate that - * chains to no trusted anchor (e.g. self-signed). And since the [fallback] only adds acceptances, it - * can't bring back the over-strict rejection of #6810. + * 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. * - * Client-trust checks and [getAcceptedIssuers] use the [primary] only; the app is a TLS client, so - * the [fallback] has no role there. - * - * Requires API 24; below it [X509ExtendedTrustManager] doesn't exist and user CAs are trusted by - * default anyway. + * 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, @@ -82,10 +76,7 @@ internal class CompositeX509ExtendedTrustManager( primary.checkClientTrusted(chain, authType, engine) } - /** - * Returns the [primary]'s issuers only. This list is the CAs accepted for client authentication, - * which the [fallback] has nothing to do with. - */ + /** 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 b4598ec59fd..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 @@ -2,9 +2,12 @@ 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 @@ -12,7 +15,6 @@ import java.security.KeyStore import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate -import java.util.Collections import javax.inject.Inject import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory @@ -22,72 +24,71 @@ import javax.net.ssl.X509TrustManager import okhttp3.OkHttpClient import timber.log.Timber -private const val ANDROID_CA_STORE = "AndroidCAStore" - -// AndroidCAStore prefixes user-installed CA aliases with "user:" and system ones with "system:". -private const val USER_CA_ALIAS_PREFIX = "user:" - +/** + * 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) { - // Default trust manager. init(null) gives us Android's default, which honors - // network_security_config.xml (system + user CAs) and builds the certificate chain itself. - // Passing an explicit KeyStore here instead breaks validation of valid certificates (#6810). - val platformTrustManager = defaultX509TrustManager(keyStore = null) - - // Some ROMs (e.g. /e/OS, #5565) don't trust user-installed CAs through the default path even - // though the browser and WebView do. On those we fall back to a trust manager holding only - // the user-installed CAs, consulted only when the default rejects a certificate. It can add - // acceptances but never cause a rejection, so it can't bring back #6810. See - // [CompositeX509ExtendedTrustManager] for the trust trade-off. - // - // This relies on the app intending to trust user-installed CAs, which network_security_config.xml - // opts into with . If that policy is ever removed, this fallback would - // keep trusting user CAs against the new policy, so it must be removed or gated alongside it. + val platformTrustManager = defaultX509TrustManager() ?: run { + FailFast.fail { "No default X509 trust manager available" } + return + } val handshakeTrustManager = withUserInstalledCaFallback(platformTrustManager) val sslContext = SSLContext.getInstance("TLS") sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), arrayOf(handshakeTrustManager), null) - // OkHttp only uses this trust manager to build a chain cleaner for certificate pinning, which - // the app doesn't use, so it doesn't affect the handshake (handshakeTrustManager decides - // that). We still pass the default one so OkHttp keeps using Android's chain cleaner if - // pinning is ever added. builder.sslSocketFactory(sslContext.socketFactory, platformTrustManager) } /** - * Wraps [platformTrustManager] so user-installed CAs are honored as a fallback (see #5565). + * Wraps [trustManager] so user-installed CAs are honored as a fallback (see + * https://github.com/home-assistant/android/issues/5565). * - * Returns it unchanged, with no fallback, when: - * - running before Android N, where user-installed CAs are trusted by default; - * - the platform trust manager is not an [X509ExtendedTrustManager] (the composite needs the + * 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 - * - there are no user-installed CAs to fall back to. + * - when there are no user-installed CAs. */ - private fun withUserInstalledCaFallback(platformTrustManager: X509TrustManager): X509TrustManager { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return platformTrustManager - val extendedPlatform = platformTrustManager as? X509ExtendedTrustManager ?: return platformTrustManager - val userCaTrustManager = userInstalledCaTrustManager() ?: return platformTrustManager - return CompositeX509ExtendedTrustManager(primary = extendedPlatform, fallback = userCaTrustManager) + 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?): X509TrustManager { + private fun defaultX509TrustManager(keyStore: KeyStore? = null): X509TrustManager? { val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) factory.init(keyStore) - return factory.trustManagers.filterIsInstance().first() + return factory.trustManagers.filterIsInstance().firstOrNull() } /** * Builds a trust manager from the user-installed CAs (see [userInstalledCaKeyStore]), or `null` - * when there are none or the AndroidCAStore can't be read. + * when there are none or AndroidCAStore can't be read. */ @RequiresApi(Build.VERSION_CODES.N) private fun userInstalledCaTrustManager(): X509ExtendedTrustManager? = try { - val androidCaStore = KeyStore.getInstance(ANDROID_CA_STORE).apply { load(null, null) } + 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") @@ -127,18 +128,24 @@ class TLSHelper @Inject constructor( } /** - * Returns a key store holding only the user-installed CA certificates of [androidCaStore] (the - * entries aliased with [USER_CA_ALIAS_PREFIX]), or `null` if there are none. - * - * The preinstalled system CAs are left out so the fallback can only trust CAs the user added, never - * override the platform's rejection of a system-rooted certificate (e.g. one from a distrusted CA). + * Returns a new key store holding only the user-installed CA certificates of [caStore], + * or `null` if there are none. */ -internal fun userInstalledCaKeyStore(androidCaStore: KeyStore): KeyStore? { - val userCaAliases = Collections.list(androidCaStore.aliases()) - .filter { it.startsWith(USER_CA_ALIAS_PREFIX) } - if (userCaAliases.isEmpty()) return null - return KeyStore.getInstance(KeyStore.getDefaultType()).apply { - load(null, null) - userCaAliases.forEach { alias -> setCertificateEntry(alias, androidCaStore.getCertificate(alias)) } +@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/TLSHelperTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt index 2fe7ec8dfc5..21b2956c789 100644 --- 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 @@ -52,8 +52,7 @@ class TLSHelperTest { } private fun keyStoreOf(vararg entries: Pair): KeyStore { - return KeyStore.getInstance(KeyStore.getDefaultType()).apply { - load(null, null) + return loadKeyStore().apply { entries.forEach { (alias, certificate) -> setCertificateEntry(alias, certificate) } } } From 01a925c7804dcb6903c8f14fbdb351084917f616 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:23:18 +0200 Subject: [PATCH 5/9] Ignore DiskReadViolation --- .../android/util/IgnoreViolationRules.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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" + } + } +} From cfc9109db3f78f273d835329d69cc9d819b223a1 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:31:59 +0200 Subject: [PATCH 6/9] Apply suggestion --- .../common/data/CompositeX509ExtendedTrustManager.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index 6965fd5d1da..b0d74478e11 100644 --- 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 @@ -63,15 +63,23 @@ internal class CompositeX509ExtendedTrustManager( checkServerOrFallback { it.checkServerTrusted(chain, authType, engine) } } - // Client trust uses the primary only (see class doc). + /** + * 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) } From 8b64fcee4c6db22697ca8f2ea274bd55adaad8d1 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:37:12 +0200 Subject: [PATCH 7/9] FIX test --- .../companion/android/common/data/TLSHelperTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 21b2956c789..136334bacfe 100644 --- 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 @@ -29,7 +29,7 @@ class TLSHelperTest { 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 SYSTEM_CA, + "user:5e6f.0" to USER_CA, "system:3c4d.0" to SYSTEM_CA, ) From 0afbe62b299d1d7359a26cda33c5a306b2f41b91 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:07:31 +0200 Subject: [PATCH 8/9] Update automotive baseline --- automotive/lint-baseline.xml | 43 ++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) 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=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> From 40d6b0e3edbb3815a47b6c8cb7599fd994f814c9 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:10:29 +0200 Subject: [PATCH 9/9] Ignore DiskReadViolation on wear --- .../android/HomeAssistantApplication.kt | 3 +- .../android/util/IgnoreViolationRules.kt | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt 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" + } + } +}