Skip to content

Commit

Permalink
Add Wear OS TLS client certificate authentication (TLS CCA) support
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
kleest committed Oct 8, 2023
1 parent 26c6bcd commit 26f2532
Show file tree
Hide file tree
Showing 19 changed files with 352 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,12 +198,21 @@ class SettingsWearViewModel @Inject constructor(
}
}

private fun readUriData(uri: String): ByteArray {
if (uri.isEmpty()) return ByteArray(0)
return getApplication<HomeAssistantApplication>().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 {
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -40,6 +41,7 @@ open class HomeAssistantApplication : Application() {
lateinit var prefsRepository: PrefsRepository

@Inject
@Named("keyChainRepository")
lateinit var keyChainRepository: KeyChainRepository

@Inject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
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),
defaultDeviceName = intent.getStringExtra(EXTRA_DEFAULT_DEVICE_NAME) ?: Build.MODEL,
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)
)
}

Expand All @@ -40,15 +42,18 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
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(
val url: String,
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 {
Expand All @@ -57,6 +62,22 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
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()
)
}
}
}
Expand All @@ -69,6 +90,7 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
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)
}
}

Expand All @@ -77,17 +99,6 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class OnboardingActivity : BaseActivity() {
}
viewModel.deviceIsWatch = input.isWatch
viewModel.discoveryOptions = input.discoveryOptions
viewModel.mayRequireTlsClientCertificate = input.mayRequireTlsClientCertificate

if (savedInstanceState == null) {
supportFragmentManager.commit {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,6 +55,7 @@ class AuthenticationFragment : Fragment() {
lateinit var themesManager: ThemesManager

@Inject
@Named("keyChainRepository")
lateinit var keyChainRepository: KeyChainRepository

@SuppressLint("SetJavaScriptEnabled")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<OnboardingViewModel>()
Expand All @@ -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
)
}
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 26f2532

Please sign in to comment.