From 26f25325a348cdf35a9e6dce8c0ce8ef52c4794d Mon Sep 17 00:00:00 2001 From: Steffen Klee Date: Sun, 8 Oct 2023 18:18:17 +0200 Subject: [PATCH] Add Wear OS TLS client certificate authentication (TLS CCA) support Wear OS does not currently allow the user to install certificates to the system-wide KeyChain for TLS CCA support. This commit adds support for using certificates from the app-specific Android KeyStore with UI for setting up a certificate during the Wear OS onboarding process. The manual step in the onboarding process is required since we cannot transmit certificates of the Android KeyChain because they are not extractable. In particular, this commit adds the following changes: * KeyStoreImpl as an additional KeyChainRepository interface implementation for loading and storing keys to the application's KeyStore. TLSHelper uses KeyStoreImpl as a fallback key manager. * UI for selecting a certificate file with GET_CONTENT intent during Wear OS onboarding in OnboardingActivity if it is detected that the Home Assistant may require TLS CCA. The UI includes a password check for the PKCS12 container. * During onboarding the app sends the raw PKCS12 data to Wear OS together with the container password. The connection is assumed to be encrypted and trusted so that no additional encryption is necessary. --- .../settings/wear/SettingsWearViewModel.kt | 14 ++- .../wear/views/SettingsWearMainView.kt | 8 +- .../android/HomeAssistantApplication.kt | 2 + .../android/onboarding/OnboardApp.kt | 41 +++++--- .../android/onboarding/OnboardingActivity.kt | 1 + .../android/onboarding/OnboardingViewModel.kt | 10 +- .../authentication/AuthenticationFragment.kt | 2 + .../MobileAppIntegrationFragment.kt | 41 ++++++++ .../integration/MobileAppIntegrationView.kt | 74 +++++++++++++- .../android/util/TLSWebViewClient.kt | 3 +- .../android/webview/WebViewActivity.kt | 2 + .../android/common/data/DataModule.kt | 7 ++ .../android/common/data/TLSHelper.kt | 10 +- .../data/keychain/KeyChainRepository.kt | 2 + .../data/keychain/KeyChainRepositoryImpl.kt | 5 + .../common/data/keychain/KeyStoreImpl.kt | 99 +++++++++++++++++++ common/src/main/res/values/strings.xml | 3 + .../android/HomeAssistantApplication.kt | 17 ++++ .../android/phone/PhoneSettingsListener.kt | 36 +++++++ 19 files changed, 352 insertions(+), 25 deletions(-) create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyStoreImpl.kt diff --git a/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt b/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt index 7aab8b8ddef..11799d5f849 100644 --- a/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt +++ b/app/src/full/java/io/homeassistant/companion/android/settings/wear/SettingsWearViewModel.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.core.net.toUri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.fasterxml.jackson.databind.JsonMappingException @@ -197,12 +198,21 @@ class SettingsWearViewModel @Inject constructor( } } + private fun readUriData(uri: String): ByteArray { + if (uri.isEmpty()) return ByteArray(0) + return getApplication().contentResolver.openInputStream(uri.toUri())!!.buffered().use { + it.readBytes() + } + } + fun sendAuthToWear( url: String, authCode: String, deviceName: String, deviceTrackingEnabled: Boolean, - notificationsEnabled: Boolean + notificationsEnabled: Boolean, + tlsClientCertificateUri: String, + tlsClientCertificatePassword: String ) { _hasData.value = false // Show loading indicator val putDataRequest = PutDataMapRequest.create("/authenticate").run { @@ -213,6 +223,8 @@ class SettingsWearViewModel @Inject constructor( dataMap.putString("DeviceName", deviceName) dataMap.putBoolean("LocationTracking", deviceTrackingEnabled) dataMap.putBoolean("Notifications", notificationsEnabled) + dataMap.putByteArray("TLSClientCertificateData", readUriData(tlsClientCertificateUri)) + dataMap.putString("TLSClientCertificatePassword", tlsClientCertificatePassword) setUrgent() asPutDataRequest() } diff --git a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearMainView.kt b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearMainView.kt index d9e8d647f19..864829a3713 100644 --- a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearMainView.kt +++ b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearMainView.kt @@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.google.android.gms.wearable.Node import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.HomeAssistantApplication import io.homeassistant.companion.android.onboarding.OnboardApp import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel import kotlinx.coroutines.cancel @@ -71,15 +72,16 @@ class SettingsWearMainView : AppCompatActivity() { locationTrackingPossible = false, notificationsPossible = false, isWatch = true, - discoveryOptions = OnboardApp.DiscoveryOptions.ADD_EXISTING_EXTERNAL + discoveryOptions = OnboardApp.DiscoveryOptions.ADD_EXISTING_EXTERNAL, + mayRequireTlsClientCertificate = (application as HomeAssistantApplication).keyChainRepository.getPrivateKey() != null ) // While notifications are technically possible, the app can't handle this for the Wear device ) } private fun onOnboardingComplete(result: OnboardApp.Output?) { if (result != null) { - val (url, authCode, deviceName, deviceTrackingEnabled, _) = result - settingsWearViewModel.sendAuthToWear(url, authCode, deviceName, deviceTrackingEnabled, true) + val (url, authCode, deviceName, deviceTrackingEnabled, _, tlsCertificateUri, tlsCertificatePassword) = result + settingsWearViewModel.sendAuthToWear(url, authCode, deviceName, deviceTrackingEnabled, true, tlsCertificateUri, tlsCertificatePassword) } else { Log.e(TAG, "onOnboardingComplete: Activity result returned null intent data") } diff --git a/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt b/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt index 680e7faee5c..a6f44f5e862 100644 --- a/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Named @HiltAndroidApp open class HomeAssistantApplication : Application() { @@ -40,6 +41,7 @@ open class HomeAssistantApplication : Application() { lateinit var prefsRepository: PrefsRepository @Inject + @Named("keyChainRepository") lateinit var keyChainRepository: KeyChainRepository @Inject diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardApp.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardApp.kt index 101ede8a2c3..031df6b01ad 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardApp.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardApp.kt @@ -15,6 +15,7 @@ class OnboardApp : ActivityResultContract( private const val EXTRA_NOTIFICATIONS_POSSIBLE = "notifications_possible" private const val EXTRA_IS_WATCH = "extra_is_watch" private const val EXTRA_DISCOVERY_OPTIONS = "extra_discovery_options" + private const val EXTRA_MAY_REQUIRE_TLS_CLIENT_CERTIFICATE = "may_require_tls_client_certificate" fun parseInput(intent: Intent): Input = Input( url = intent.getStringExtra(EXTRA_URL), @@ -22,7 +23,8 @@ class OnboardApp : ActivityResultContract( locationTrackingPossible = intent.getBooleanExtra(EXTRA_LOCATION_TRACKING_POSSIBLE, false), notificationsPossible = intent.getBooleanExtra(EXTRA_NOTIFICATIONS_POSSIBLE, true), isWatch = intent.getBooleanExtra(EXTRA_IS_WATCH, false), - discoveryOptions = intent.getStringExtra(EXTRA_DISCOVERY_OPTIONS)?.let { DiscoveryOptions.valueOf(it) } + discoveryOptions = intent.getStringExtra(EXTRA_DISCOVERY_OPTIONS)?.let { DiscoveryOptions.valueOf(it) }, + mayRequireTlsClientCertificate = intent.getBooleanExtra(EXTRA_MAY_REQUIRE_TLS_CLIENT_CERTIFICATE, false) ) } @@ -40,7 +42,8 @@ class OnboardApp : ActivityResultContract( val locationTrackingPossible: Boolean = BuildConfig.FLAVOR == "full", val notificationsPossible: Boolean = true, val isWatch: Boolean = false, - val discoveryOptions: DiscoveryOptions? = null + val discoveryOptions: DiscoveryOptions? = null, + val mayRequireTlsClientCertificate: Boolean = false ) data class Output( @@ -48,7 +51,9 @@ class OnboardApp : ActivityResultContract( val authCode: String, val deviceName: String, val deviceTrackingEnabled: Boolean, - val notificationsEnabled: Boolean + val notificationsEnabled: Boolean, + val tlsClientCertificateUri: String, + val tlsClientCertificatePassword: String ) { fun toIntent(): Intent { return Intent().apply { @@ -57,6 +62,22 @@ class OnboardApp : ActivityResultContract( putExtra("DeviceName", deviceName) putExtra("LocationTracking", deviceTrackingEnabled) putExtra("Notifications", notificationsEnabled) + putExtra("TLSClientCertificateUri", tlsClientCertificateUri) + putExtra("TLSClientCertificatePassword", tlsClientCertificatePassword) + } + } + + companion object { + fun fromIntent(intent: Intent): Output { + return Output( + url = intent.getStringExtra("URL").toString(), + authCode = intent.getStringExtra("AuthCode").toString(), + deviceName = intent.getStringExtra("DeviceName").toString(), + deviceTrackingEnabled = intent.getBooleanExtra("LocationTracking", false), + notificationsEnabled = intent.getBooleanExtra("Notifications", true), + tlsClientCertificateUri = intent.getStringExtra("TLSClientCertificateUri").toString(), + tlsClientCertificatePassword = intent.getStringExtra("TLSClientCertificatePassword").toString() + ) } } } @@ -69,6 +90,7 @@ class OnboardApp : ActivityResultContract( putExtra(EXTRA_NOTIFICATIONS_POSSIBLE, input.notificationsPossible) putExtra(EXTRA_IS_WATCH, input.isWatch) putExtra(EXTRA_DISCOVERY_OPTIONS, input.discoveryOptions?.toString()) + putExtra(EXTRA_MAY_REQUIRE_TLS_CLIENT_CERTIFICATE, input.mayRequireTlsClientCertificate) } } @@ -77,17 +99,6 @@ class OnboardApp : ActivityResultContract( return null } - val url = intent.getStringExtra("URL").toString() - val authCode = intent.getStringExtra("AuthCode").toString() - val deviceName = intent.getStringExtra("DeviceName").toString() - val deviceTrackingEnabled = intent.getBooleanExtra("LocationTracking", false) - val notificationsEnabled = intent.getBooleanExtra("Notifications", true) - return Output( - url = url, - authCode = authCode, - deviceName = deviceName, - deviceTrackingEnabled = deviceTrackingEnabled, - notificationsEnabled = notificationsEnabled - ) + return Output.fromIntent(intent) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingActivity.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingActivity.kt index 6a5af07ab12..9b0ea77a650 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingActivity.kt @@ -44,6 +44,7 @@ class OnboardingActivity : BaseActivity() { } viewModel.deviceIsWatch = input.isWatch viewModel.discoveryOptions = input.discoveryOptions + viewModel.mayRequireTlsClientCertificate = input.mayRequireTlsClientCertificate if (savedInstanceState == null) { supportFragmentManager.commit { diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingViewModel.kt index 3f4e109b298..67c761bea6a 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/OnboardingViewModel.kt @@ -1,6 +1,7 @@ package io.homeassistant.companion.android.onboarding import android.app.Application +import android.net.Uri import android.util.Log import android.webkit.URLUtil import android.widget.Toast @@ -52,6 +53,11 @@ class OnboardingViewModel @Inject constructor( var locationTrackingEnabled by mutableStateOf(false) val notificationsPossible = mutableStateOf(true) var notificationsEnabled by mutableStateOf(false) + var mayRequireTlsClientCertificate by mutableStateOf(false) + var tlsClientCertificateUri: Uri? by mutableStateOf(null) + var tlsClientCertificateFilename by mutableStateOf("") + var tlsClientCertificatePassword by mutableStateOf("") + var tlsClientCertificatePasswordCorrect by mutableStateOf(false) private var authCode = "" @@ -81,7 +87,9 @@ class OnboardingViewModel @Inject constructor( authCode = authCode, deviceName = deviceName.value, deviceTrackingEnabled = locationTrackingEnabled, - notificationsEnabled = notificationsEnabled + notificationsEnabled = notificationsEnabled, + tlsClientCertificateUri = tlsClientCertificateUri?.toString() ?: "", + tlsClientCertificatePassword = tlsClientCertificatePassword ) fun onDiscoveryActive() { diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt index 1da92365431..5ee2c243aee 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt @@ -36,6 +36,7 @@ import io.homeassistant.companion.android.util.isStarted import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import javax.inject.Inject +import javax.inject.Named import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint @@ -54,6 +55,7 @@ class AuthenticationFragment : Fragment() { lateinit var themesManager: ThemesManager @Inject + @Named("keyChainRepository") lateinit var keyChainRepository: KeyChainRepository @SuppressLint("SetJavaScriptEnabled") diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationFragment.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationFragment.kt index 2f846edae09..0d2a59a56a2 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationFragment.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager +import android.provider.OpenableColumns import android.provider.Settings import android.view.LayoutInflater import android.view.View @@ -25,6 +26,8 @@ import io.homeassistant.companion.android.common.util.DisabledLocationHandler import io.homeassistant.companion.android.onboarding.OnboardingViewModel import io.homeassistant.companion.android.onboarding.notifications.NotificationPermissionFragment import io.homeassistant.companion.android.sensors.LocationSensorManager +import java.io.IOException +import java.security.KeyStore import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint @@ -33,6 +36,9 @@ class MobileAppIntegrationFragment : Fragment() { private val requestLocationPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { onLocationPermissionResult(it) } + private val getContent = registerForActivityResult(ActivityResultContracts.GetContent()) { + onGetContentResult(it) + } private var dialog: AlertDialog? = null private val viewModel by activityViewModels() @@ -49,6 +55,8 @@ class MobileAppIntegrationFragment : Fragment() { onboardingViewModel = viewModel, openPrivacyPolicy = this@MobileAppIntegrationFragment::openPrivacyPolicy, onLocationTrackingChanged = this@MobileAppIntegrationFragment::onLocationTrackingChanged, + onSelectTLSCertificateClicked = this@MobileAppIntegrationFragment::onSelectTLSCertificateClicked, + onCheckPassword = this@MobileAppIntegrationFragment::onCheckTLSCertificatePassword, onFinishClicked = this@MobileAppIntegrationFragment::onComplete ) } @@ -88,6 +96,39 @@ class MobileAppIntegrationFragment : Fragment() { viewModel.setLocationTracking(checked) } + private fun onSelectTLSCertificateClicked() { + getContent.launch("*/*") + } + + private fun onCheckTLSCertificatePassword(password: String) { + var ok: Boolean + context?.contentResolver?.openInputStream(viewModel.tlsClientCertificateUri!!)!!.buffered().use { + val keystore = KeyStore.getInstance("PKCS12") + ok = try { + keystore.load(it, password.toCharArray()) + true + } catch (e: IOException) { + // we cannot determine if it failed due to wrong password or other reasons, since e.cause is not set to UnrecoverableKeyException + false + } + } + viewModel.tlsClientCertificatePasswordCorrect = ok + } + + @SuppressLint("Range") + private fun onGetContentResult(uri: Uri?) { + if (uri != null) { + context?.contentResolver?.query(uri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + + viewModel.tlsClientCertificateUri = uri + viewModel.tlsClientCertificateFilename = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + // check with empty password + onCheckTLSCertificatePassword("") + } + } + private fun requestPermissions(sensorId: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { requestLocationPermissions.launch( diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationView.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationView.kt index 251e5ce444f..92523231827 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationView.kt @@ -3,19 +3,26 @@ package io.homeassistant.companion.android.onboarding.integration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -26,6 +33,8 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.onboarding.OnboardingHeaderView @@ -38,6 +47,8 @@ fun MobileAppIntegrationView( onboardingViewModel: OnboardingViewModel, openPrivacyPolicy: () -> Unit, onLocationTrackingChanged: (Boolean) -> Unit, + onSelectTLSCertificateClicked: () -> Unit, + onCheckPassword: (String) -> Unit, onFinishClicked: () -> Unit ) { val scrollState = rememberScrollState() @@ -97,6 +108,64 @@ fun MobileAppIntegrationView( fontWeight = FontWeight.Light ) } + if (onboardingViewModel.deviceIsWatch && onboardingViewModel.mayRequireTlsClientCertificate) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(id = commonR.string.tls_cert_onboarding_title), + style = MaterialTheme.typography.h6 + ) + Text( + text = stringResource(id = commonR.string.tls_cert_onboarding_description), + fontWeight = FontWeight.Light + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button(onClick = onSelectTLSCertificateClicked) { + Text(text = stringResource(id = commonR.string.select_file)) + } + Text( + text = onboardingViewModel.tlsClientCertificateFilename, + modifier = Modifier + .align(Alignment.CenterVertically) + ) + } + if (onboardingViewModel.tlsClientCertificateUri != null) { + Row { + TextField( + value = onboardingViewModel.tlsClientCertificatePassword, + onValueChange = { + onboardingViewModel.tlsClientCertificatePassword = it + onCheckPassword(onboardingViewModel.tlsClientCertificatePassword) + }, + label = { Text(text = stringResource(id = commonR.string.password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + } + ), + trailingIcon = { + Icon( + imageVector = if (onboardingViewModel.tlsClientCertificatePasswordCorrect) Icons.Filled.CheckCircle else Icons.Filled.Error, + tint = if (!onboardingViewModel.tlsClientCertificatePasswordCorrect) colorResource(commonR.color.colorWarning) else colorResource(commonR.color.colorOnBackground), + contentDescription = null + ) + }, + isError = !onboardingViewModel.tlsClientCertificatePasswordCorrect, + modifier = Modifier + .weight(1f) + ) + } + } + } TextButton(onClick = openPrivacyPolicy) { Text(stringResource(id = commonR.string.privacy_url)) } @@ -108,7 +177,10 @@ fun MobileAppIntegrationView( .fillMaxWidth(), horizontalArrangement = Arrangement.End ) { - Button(onClick = onFinishClicked) { + Button( + onClick = onFinishClicked, + enabled = !onboardingViewModel.deviceIsWatch || !onboardingViewModel.mayRequireTlsClientCertificate || onboardingViewModel.mayRequireTlsClientCertificate && onboardingViewModel.tlsClientCertificatePasswordCorrect + ) { Text(stringResource(id = commonR.string.continue_connect)) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/util/TLSWebViewClient.kt b/app/src/main/java/io/homeassistant/companion/android/util/TLSWebViewClient.kt index 5bc8e935de6..706e895b2a2 100644 --- a/app/src/main/java/io/homeassistant/companion/android/util/TLSWebViewClient.kt +++ b/app/src/main/java/io/homeassistant/companion/android/util/TLSWebViewClient.kt @@ -19,8 +19,9 @@ import java.security.PrivateKey import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.inject.Inject +import javax.inject.Named -open class TLSWebViewClient @Inject constructor(private var keyChainRepository: KeyChainRepository) : WebViewClient() { +open class TLSWebViewClient @Inject constructor(@Named("keyChainRepository") private var keyChainRepository: KeyChainRepository) : WebViewClient() { var isTLSClientAuthNeeded = false private set diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index 75c7c678587..5460de26f79 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -115,6 +115,7 @@ import org.chromium.net.CronetEngine import org.json.JSONObject import java.util.concurrent.Executors import javax.inject.Inject +import javax.inject.Named import io.homeassistant.companion.android.common.R as commonR @OptIn(androidx.media3.common.util.UnstableApi::class) @@ -188,6 +189,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi lateinit var authenticationDao: AuthenticationDao @Inject + @Named("keyChainRepository") lateinit var keyChainRepository: KeyChainRepository private lateinit var binding: ActivityWebviewBinding diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt index 701558b6aa4..c5268a6da5a 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt @@ -18,6 +18,7 @@ import io.homeassistant.companion.android.common.data.authentication.impl.Authen import io.homeassistant.companion.android.common.data.integration.impl.IntegrationService import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository import io.homeassistant.companion.android.common.data.keychain.KeyChainRepositoryImpl +import io.homeassistant.companion.android.common.data.keychain.KeyStoreImpl import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.common.data.prefs.PrefsRepositoryImpl import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository @@ -159,8 +160,14 @@ abstract class DataModule { @Binds @Singleton + @Named("keyChainRepository") abstract fun bindKeyChainRepository(keyChainRepository: KeyChainRepositoryImpl): KeyChainRepository + @Binds + @Singleton + @Named("keyStore") + abstract fun bindKeyStore(keyStore: KeyStoreImpl): KeyChainRepository + @Binds @Singleton abstract fun bindServerManager(serverManager: ServerManagerImpl): ServerManager diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/TLSHelper.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/TLSHelper.kt index 713f8e2373e..34ff2588e84 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/TLSHelper.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/TLSHelper.kt @@ -9,12 +9,16 @@ import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate import javax.inject.Inject +import javax.inject.Named import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509ExtendedKeyManager import javax.net.ssl.X509TrustManager -class TLSHelper @Inject constructor(private val keyChainRepository: KeyChainRepository) { +class TLSHelper @Inject constructor( + @Named("keyChainRepository") private val keyChainRepository: KeyChainRepository, + @Named("keyStore") private val keyStore: KeyChainRepository +) { fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) { val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) @@ -64,7 +68,7 @@ class TLSHelper @Inject constructor(private val keyChainRepository: KeyChainRepo // block until a chain is provided via the TLSWebView runBlocking { - chain = keyChainRepository.getCertificateChain() + chain = keyChainRepository.getCertificateChain() ?: keyStore.getCertificateChain() } return chain @@ -75,7 +79,7 @@ class TLSHelper @Inject constructor(private val keyChainRepository: KeyChainRepo // block until a key is provided via the TLSWebView runBlocking { - key = keyChainRepository.getPrivateKey() + key = keyChainRepository.getPrivateKey() ?: keyStore.getPrivateKey() } return key diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepository.kt index 10e85e804b9..acf09438d83 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepository.kt @@ -12,6 +12,8 @@ interface KeyChainRepository { suspend fun load(context: Context) + suspend fun setData(alias: String, privateKey: PrivateKey, certificateChain: Array) + fun getAlias(): String? fun getPrivateKey(): PrivateKey? diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepositoryImpl.kt index ce6dd5b2f41..8dad643d6dc 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyChainRepositoryImpl.kt @@ -6,6 +6,7 @@ import android.util.Log import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.lang.UnsupportedOperationException import java.security.PrivateKey import java.security.cert.X509Certificate import javax.inject.Inject @@ -40,6 +41,10 @@ class KeyChainRepositoryImpl @Inject constructor( doLoad(context) } + override suspend fun setData(alias: String, privateKey: PrivateKey, certificateChain: Array) { + throw UnsupportedOperationException("setData not supported for KeyChainRepositoryImpl") + } + override fun getAlias(): String? { return alias } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyStoreImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyStoreImpl.kt new file mode 100644 index 00000000000..fd64082ccdc --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/keychain/KeyStoreImpl.kt @@ -0,0 +1,99 @@ +package io.homeassistant.companion.android.common.data.keychain + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.security.KeyStore +import java.security.KeyStore.PrivateKeyEntry +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.inject.Inject + +class KeyStoreImpl @Inject constructor() : KeyChainRepository { + companion object { + private const val TAG = "KeyStoreRepository" + } + + private var alias: String? = null + private var key: PrivateKey? = null + private var chain: Array? = null + + override suspend fun clear() { + alias = null + } + + override suspend fun load(context: Context, alias: String) = withContext(Dispatchers.IO) { + this@KeyStoreImpl.alias = alias + doLoad() + } + + override suspend fun load(context: Context) { + throw IllegalArgumentException("Key alias cannot be null.") + } + + override suspend fun setData(alias: String, privateKey: PrivateKey, certificateChain: Array) = withContext(Dispatchers.IO) { + doStore(alias, privateKey, certificateChain) + } + + override fun getAlias(): String? { + return alias + } + + override fun getPrivateKey(): PrivateKey? { + return key + } + + override fun getCertificateChain(): Array? { + return chain + } + + @Synchronized + private fun doLoad() { + if (alias != null && alias?.isNotEmpty() == true) { + val aks = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + if (!containsAlias(alias)) return + } + val entry = try { + aks.getEntry(alias, null) as PrivateKeyEntry + } catch (e: Exception) { + Log.e(TAG, "Exception getting KeyStore.Entry", e) + null + } + if (entry != null) { + if (chain == null) { + chain = try { + entry.certificateChain as Array + } catch (e: Exception) { + Log.e(TAG, "Exception getting certificate chain", e) + null + } + } + if (key == null) { + key = try { + entry.privateKey + } catch (e: Exception) { + Log.e(TAG, "Exception getting private key", e) + null + } + } + } + } + } + + @Synchronized + private fun doStore(alias: String, key: PrivateKey, chain: Array) { + try { + KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + setEntry(alias, PrivateKeyEntry(key, chain), null) + } + } catch (e: Exception) { + Log.e(TAG, "Exception storing KeyStore.Entry", e) + return + } + this.alias = alias + doLoad() + } +} diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index c5ab7eb860a..6a525773205 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -549,6 +549,7 @@ Security Select Select entity to display + Select file Select your Home Assistant server Total count of active notifications that are visible to the user including silent, persistent and the Sensor Worker notifications. If the app is in the foreground, background or any other state it can be @@ -1090,6 +1091,8 @@ TLS client certificate error The remote site requires a client certificate.\nPlease install the required credential on your phone and try again. The certificate is not yet or no longer valid.\nPlease install a new credential on your phone and try again. + If your Home Assistant instance requires TLS client certificate authentication, select a TLS client certificate file to install it on your Wear OS device. + TLS client certificate Battery power Require authentication Error authenticating - is an authentication method configured? diff --git a/wear/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt b/wear/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt index 2d0297475ee..73517ac7563 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -11,14 +11,31 @@ import android.nfc.NfcAdapter import android.os.Build import android.os.PowerManager import dagger.hilt.android.HiltAndroidApp +import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository import io.homeassistant.companion.android.complications.ComplicationReceiver import io.homeassistant.companion.android.sensors.SensorReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Named @HiltAndroidApp open class HomeAssistantApplication : Application() { + private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job()) + + @Inject + @Named("keyStore") + lateinit var keyStore: KeyChainRepository + override fun onCreate() { super.onCreate() + ioScope.launch { + keyStore.load(applicationContext, "TLSClientCertificate") + } + val sensorReceiver = SensorReceiver() // This will cause the sensor to be updated every time the OS broadcasts that a cable was plugged/unplugged. // This should be nearly instantaneous allowing automations to fire immediately when a phone is plugged diff --git a/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt b/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt index 6ad8bfefc55..991abde2d5a 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt @@ -18,6 +18,7 @@ import com.google.android.gms.wearable.WearableListenerService import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.common.data.integration.DeviceRegistration +import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.util.WearDataMessages @@ -42,7 +43,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +import java.io.IOException +import java.security.KeyStore +import java.security.PrivateKey +import java.security.cert.X509Certificate import javax.inject.Inject +import javax.inject.Named @AndroidEntryPoint @SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111 @@ -57,6 +63,14 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange @Inject lateinit var favoritesDao: FavoritesDao + @Inject + @Named("keyChainRepository") + lateinit var keyChainRepository: KeyChainRepository + + @Inject + @Named("keyStore") + lateinit var keyStore: KeyChainRepository + private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job()) private val objectMapper = jacksonObjectMapper() @@ -135,6 +149,28 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange val deviceName = dataMap.getString("DeviceName") val deviceTrackingEnabled = dataMap.getBoolean("LocationTracking") val notificationsEnabled = dataMap.getBoolean("Notifications") + val tlsClientCertificateData = dataMap.getByteArray("TLSClientCertificateData") + val tlsClientCertificatePassword = dataMap.getString("TLSClientCertificatePassword").orEmpty().toCharArray() + + // load TLS key + if (tlsClientCertificateData != null && tlsClientCertificateData.isNotEmpty()) { + KeyStore.getInstance("PKCS12").apply { + try { + load(tlsClientCertificateData.inputStream(), tlsClientCertificatePassword) + + val alias = aliases().nextElement() + val certificateChain = getCertificateChain(alias).filterIsInstance().toTypedArray() + val privateKey = getKey(alias, tlsClientCertificatePassword) as PrivateKey + + // we store the TLS Client key under a static alias because there is currently + // no way to ask the user for the correct alias + keyStore.setData("TLSClientCertificate", privateKey, certificateChain) + keyChainRepository.load(applicationContext) + } catch (e: IOException) { + Log.e(TAG, "Cannot load TLS client certificate", e) + } + } + } val formattedUrl = UrlUtil.formattedUrlString(url) val server = Server(