From 1f0103b9b95eb345c954d39819dd7b401ce6ccb4 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Fri, 19 Aug 2022 13:21:09 +0200 Subject: [PATCH] Android: Drop LiveData for YubiKey devices. --- .../com/yubico/authenticator/AppContext.kt | 17 +- .../yubico/authenticator/AppContextManager.kt | 7 + .../com/yubico/authenticator/MainActivity.kt | 53 ++-- .../com/yubico/authenticator/MainViewModel.kt | 21 +- .../yubico/authenticator/oath/OathManager.kt | 229 +++++++----------- lib/android/oath/state.dart | 3 +- 6 files changed, 138 insertions(+), 192 deletions(-) create mode 100755 android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt b/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt index 767cf056..efd51203 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt @@ -6,18 +6,8 @@ import com.yubico.authenticator.logging.Log import io.flutter.plugin.common.BinaryMessenger import kotlinx.coroutines.CoroutineScope -enum class OperationContext(val value: Int) { - Oath(0), Yubikey(1), Invalid(-1); - - companion object { - fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: Invalid - } -} - -class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope) { +class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, private val appViewModel: MainViewModel) { private val channel = FlutterChannel(messenger, "android.state.appContext") - private var _appContext = MutableLiveData(OperationContext.Oath) - val appContext: LiveData = _appContext init { channel.setHandler(coroutineScope) { method, args -> @@ -30,8 +20,9 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope) { private suspend fun setContext(subPageIndex: Int): String { - _appContext.value = OperationContext.getByValue(subPageIndex) - Log.d(TAG, "App context is now ${_appContext.value}") + val appContext = OperationContext.getByValue(subPageIndex) + appViewModel.setContext(appContext) + Log.d(TAG, "App context is now ${appContext}") return FlutterChannel.NULL } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt new file mode 100755 index 00000000..60b3e66f --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt @@ -0,0 +1,7 @@ +package com.yubico.authenticator + +import com.yubico.yubikit.core.YubiKeyDevice + +interface AppContextManager { + suspend fun processYubiKey(device: YubiKeyDevice) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index 8a38db65..b0cb33ae 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -9,7 +9,6 @@ import android.nfc.Tag import android.os.Bundle import android.view.WindowManager import androidx.activity.viewModels -import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import com.yubico.authenticator.logging.FlutterLog import com.yubico.authenticator.logging.Log @@ -21,12 +20,9 @@ import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice import com.yubico.yubikit.android.transport.usb.UsbConfiguration import com.yubico.yubikit.core.Logger -import com.yubico.yubikit.core.YubiKeyDevice -import com.yubico.yubikit.core.smartcard.SmartCardConnection import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.concurrent.Executors import kotlin.properties.Delegates @@ -70,8 +66,12 @@ class MainActivity : FlutterFragmentActivity() { if (it) { Log.d(TAG, "Starting usb discovery") yubikit.startUsbDiscovery(UsbConfiguration()) { device -> - viewModel.yubiKeyDevice.postValue(device) - device.setOnClosed { viewModel.yubiKeyDevice.postValue(null) } + viewModel.setConnectedYubiKey(device) + contextManager?.let { + lifecycleScope.launch { + it.processYubiKey(device) + } + } } hasNfc = startNfcDiscovery() } else { @@ -86,10 +86,9 @@ class MainActivity : FlutterFragmentActivity() { try { Log.d(TAG, "Starting nfc discovery") yubikit.startNfcDiscovery(nfcConfiguration, this) { device -> - viewModel.yubiKeyDevice.apply { - lifecycleScope.launch(Dispatchers.Main) { - value = device - postValue(null) + contextManager?.let { + lifecycleScope.launch { + it.processYubiKey(device) } } } @@ -138,36 +137,27 @@ class MainActivity : FlutterFragmentActivity() { override fun onResume() { super.onResume() + // Handle existing tag when launched from NDEF val tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) if(tag != null) { intent.removeExtra(NfcAdapter.EXTRA_TAG) val executor = Executors.newSingleThreadExecutor() val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor) - viewModel.yubiKeyDevice.value = device - viewModel.yubiKeyDevice.observe(this, object: Observer { - override fun onChanged(it: YubiKeyDevice?) { - if(it == null) { - viewModel.yubiKeyDevice.removeObserver(this) - device.requestConnection(SmartCardConnection::class.java) { - Log.d(TAG, "Await NFC removal...") - device.remove { - executor.shutdown() - startNfcDiscovery() - } - } - } + lifecycleScope.launch { + contextManager?.processYubiKey(device) + device.remove { + executor.shutdown() + startNfcDiscovery() } - - }) - viewModel.yubiKeyDevice.postValue(null) + } } else { startNfcDiscovery() } } private lateinit var appContext: AppContext - private lateinit var oathManager: OathManager + private var contextManager: AppContextManager? = null private lateinit var dialogManager: DialogManager private lateinit var appPreferences: AppPreferences private lateinit var flutterLog: FlutterLog @@ -178,7 +168,7 @@ class MainActivity : FlutterFragmentActivity() { val messenger = flutterEngine.dartExecutor.binaryMessenger flutterLog = FlutterLog(messenger) - appContext = AppContext(messenger, this.lifecycleScope) + appContext = AppContext(messenger, this.lifecycleScope, viewModel) dialogManager = DialogManager(messenger, this.lifecycleScope) appPreferences = AppPreferences(this) @@ -187,7 +177,12 @@ class MainActivity : FlutterFragmentActivity() { oathViewModel.sessionState.streamTo(this, EventChannel(messenger, "android.oath.sessionState")) oathViewModel.credentials.streamTo(this, EventChannel(messenger, "android.oath.credentials")) - oathManager = OathManager(this, messenger, appContext, viewModel, oathViewModel, dialogManager, appPreferences) + viewModel.appContext.observe(this) { + contextManager = when(it) { + OperationContext.Oath -> OathManager(messenger, viewModel, oathViewModel, dialogManager, appPreferences) + else -> null + } + } } companion object { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt index bce7343d..98e31251 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt @@ -4,13 +4,30 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.yubico.authenticator.device.Info -import com.yubico.yubikit.core.YubiKeyDevice +import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice + +enum class OperationContext(val value: Int) { + Oath(0), Yubikey(1), Invalid(-1); + + companion object { + fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: Invalid + } +} class MainViewModel : ViewModel() { private val _handleYubiKey = MutableLiveData(true) val handleYubiKey: LiveData = _handleYubiKey - val yubiKeyDevice = MutableLiveData() + private var _appContext = MutableLiveData(OperationContext.Oath) + val appContext: LiveData = _appContext + fun setContext(appContext: OperationContext) = _appContext.postValue(appContext) + + private val _connectedYubiKey = MutableLiveData() + val connectedYubiKey: LiveData = _connectedYubiKey + fun setConnectedYubiKey(device: UsbYubiKeyDevice) { + _connectedYubiKey.postValue(device) + device.setOnClosed { _connectedYubiKey.postValue(null) } + } private val _deviceInfo = MutableLiveData() val deviceInfo: LiveData = _deviceInfo diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt index c6ffa3e8..32334a9a 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -1,7 +1,5 @@ package com.yubico.authenticator.oath -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.Observer import com.yubico.authenticator.* import com.yubico.authenticator.logging.Log import com.yubico.authenticator.management.model @@ -27,14 +25,15 @@ import kotlin.coroutines.suspendCoroutine typealias OathAction = (Result) -> Unit class OathManager( - private val lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, - appContext: AppContext, private val appViewModel: MainViewModel, private val oathViewModel: OathViewModel, private val dialogManager: DialogManager, private val appPreferences: AppPreferences, -) { +): AppContextManager { + companion object { + const val TAG = "OathManager" + } private val _dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val coroutineScope = CoroutineScope(SupervisorJob() + _dispatcher) @@ -47,14 +46,6 @@ class OathManager( private var pendingAction: OathAction? = null init { - appContext.appContext.observe(lifecycleOwner) { - if (it == OperationContext.Oath) { - installObservers() - } else { - uninstallObservers() - } - } - // OATH methods callable from Flutter: oathChannel.setHandler(coroutineScope) { method, args -> when (method) { @@ -86,119 +77,78 @@ class OathManager( } } - companion object { - const val TAG = "OathManager" - } + override suspend fun processYubiKey(device: YubiKeyDevice) { + try { + device.withConnection { + val oath = OathSession(it) + tryToUnlockOathSession(oath) - private val deviceObserver = - Observer { yubiKeyDevice -> - try { - if (yubiKeyDevice != null) { - yubikeyAttached(yubiKeyDevice) - } else { - yubikeyDetached() - } - } catch (e: Throwable) { - Log.e(TAG, "Error in device observer", e.toString()) - } - } + val previousId = oathViewModel.sessionState.value?.deviceId + if (oath.deviceId == previousId) { + // Run any pending action + pendingAction?.let { action -> + action.invoke(Result.success(oath)) + pendingAction = null + } - private fun installObservers() { - Log.d(TAG, "Installed oath observers") - appViewModel.yubiKeyDevice.observe(lifecycleOwner, deviceObserver) - } - - private fun uninstallObservers() { - appViewModel.yubiKeyDevice.removeObserver(deviceObserver) - Log.d(TAG, "Uninstalled oath observers") - } - - private var _isUsbKey = false - - private fun yubikeyAttached(device: YubiKeyDevice) { - _isUsbKey = device.transport == Transport.USB - coroutineScope.launch { - try { - device.withConnection { - val oath = OathSession(it) - tryToUnlockOathSession(oath) - - val previousId = oathViewModel.sessionState.value?.deviceId - if (oath.deviceId == previousId) { - // Run any pending action - pendingAction?.let { action -> - action.invoke(Result.success(oath)) - pendingAction = null - } - - // Refresh codes - if (!oath.isLocked) { - try { - oathViewModel.updateCredentials( - calculateOathCodes(oath).model(oath.deviceId) - ) - } catch (error: Exception) { - Log.e(TAG, "Failed to refresh codes", error.toString()) - } - } - } else { - // Awaiting an action for a different device? Fail it and stop processing. - pendingAction?.let { action -> - action.invoke(Result.failure(IllegalStateException("Wrong deviceId"))) - pendingAction = null - return@withConnection - } - - // Clear in-memory password for any previous device - if (it.transport == Transport.NFC && previousId != null) { - _memoryKeyProvider.removeKey(previousId) - } - - // Update the OATH state - oathViewModel.setSessionState(oath.model(_keyManager.isRemembered(oath.deviceId))) - if(!oath.isLocked) { + // Refresh codes + if (!oath.isLocked) { + try { oathViewModel.updateCredentials( calculateOathCodes(oath).model(oath.deviceId) ) + } catch (error: Exception) { + Log.e(TAG, "Failed to refresh codes", error.toString()) } - - // Update deviceInfo since the deviceId has changed - val pid = (device as? UsbYubiKeyDevice)?.pid - val deviceInfo = DeviceUtil.readInfo(it, pid) - appViewModel.setDeviceInfo(deviceInfo.model( - DeviceUtil.getName(deviceInfo, pid?.type), - device.transport == Transport.NFC, - pid?.value - )) } - } - Log.d(TAG, "Successfully read Oath session info (and credentials if unlocked) from connected key") - } catch (e: Exception) { - // OATH not enabled/supported, try to get DeviceInfo over other USB interfaces - Log.e(TAG, "Failed to connect to CCID", e.toString()) - if (device.transport == Transport.USB || e is ApplicationNotAvailableException) { - val deviceInfoData = getDeviceInfo(device) - Log.d(TAG, "Sending device info: $deviceInfoData") - appViewModel.setDeviceInfo(deviceInfoData) - } + } else { + // Awaiting an action for a different device? Fail it and stop processing. + pendingAction?.let { action -> + action.invoke(Result.failure(IllegalStateException("Wrong deviceId"))) + pendingAction = null + return@withConnection + } - // Clear any cached OATH state - oathViewModel.setSessionState(null) + // Clear in-memory password for any previous device + if (it.transport == Transport.NFC && previousId != null) { + _memoryKeyProvider.removeKey(previousId) + } + + // Update the OATH state + oathViewModel.setSessionState(oath.model(_keyManager.isRemembered(oath.deviceId))) + if(!oath.isLocked) { + oathViewModel.updateCredentials( + calculateOathCodes(oath).model(oath.deviceId) + ) + } + + // Update deviceInfo since the deviceId has changed + val pid = (device as? UsbYubiKeyDevice)?.pid + val deviceInfo = DeviceUtil.readInfo(it, pid) + appViewModel.setDeviceInfo(deviceInfo.model( + DeviceUtil.getName(deviceInfo, pid?.type), + device.transport == Transport.NFC, + pid?.value + )) + } + } + Log.d(TAG, "Successfully read Oath session info (and credentials if unlocked) from connected key") + } catch (e: Exception) { + // OATH not enabled/supported, try to get DeviceInfo over other USB interfaces + Log.e(TAG, "Failed to connect to CCID", e.toString()) + if (device.transport == Transport.USB || e is ApplicationNotAvailableException) { + val deviceInfoData = getDeviceInfo(device) + Log.d(TAG, "Sending device info: $deviceInfoData") + appViewModel.setDeviceInfo(deviceInfoData) } - } - } - private fun yubikeyDetached() { - if (_isUsbKey) { - Log.d(TAG, "Device disconnected") - // clear keys from memory - _memoryKeyProvider.clearAll() - pendingAction = null - appViewModel.setDeviceInfo(null) + // Clear any cached OATH state oathViewModel.setSessionState(null) } } + //private var _isUsbKey = false + private suspend fun reset(): String { useOathSession("Reset YubiKey") { // note, it is ok to reset locked session @@ -209,7 +159,6 @@ class OathManager( return FlutterChannel.NULL } - private suspend fun unlock(password: String, remember: Boolean): String = useOathSession("Unlocking") { val accessKey = it.deriveAccessKey(password.toCharArray()) @@ -316,16 +265,15 @@ class OathManager( } private suspend fun requestRefresh(): String { - if (!_isUsbKey) { - throw IllegalStateException("Cannot refresh for nfc key") - } - - return useOathSession("Refresh codes") { session -> - oathViewModel.updateCredentials( - calculateOathCodes(session).model(session.deviceId) - ) - FlutterChannel.NULL + appViewModel.connectedYubiKey.value?.let { + useOathSessionUsb(it) { + oathViewModel.updateCredentials( + calculateOathCodes(it).model(it.deviceId) + ) + } + return FlutterChannel.NULL } + throw throw IllegalStateException("Cannot refresh for nfc key") } private suspend fun calculate(credentialId: String): String = @@ -388,12 +336,13 @@ class OathManager( } private fun calculateOathCodes(session: OathSession): Map { + val isUsbKey = appViewModel.connectedYubiKey.value != null var timestamp = System.currentTimeMillis() - if (!_isUsbKey) { + if (!isUsbKey) { // NFC, need to pad timer to avoid immediate expiration timestamp += 10000 } - val bypassTouch = appPreferences.bypassTouchOnNfcTap && !_isUsbKey + val bypassTouch = appPreferences.bypassTouchOnNfcTap && !isUsbKey return session.calculateCodes(timestamp).map { (credential, code) -> Pair( credential, if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) { @@ -407,19 +356,20 @@ class OathManager( }.toMap() } - private suspend fun useOathSessionUsb( + private suspend fun useOathSession( title: String, action: (OathSession) -> T ): T { - appViewModel.yubiKeyDevice.value?.let { yubiKey -> - Log.d(TAG, "Executing action on usb key: $title") - return yubiKey.withConnection { - action.invoke(OathSession(it)) - } - } + return appViewModel.connectedYubiKey.value?.let { + useOathSessionUsb(it, action) + } ?: useOathSessionNfc(title, action) + } - Log.e(TAG, "USB Key not found for action: $title") - throw IllegalStateException("USB Key not found for action: $title") + private suspend fun useOathSessionUsb( + device: UsbYubiKeyDevice, + block: (OathSession) -> T + ): T = device.withConnection { + block(OathSession(it)) } private suspend fun useOathSessionNfc( @@ -462,19 +412,6 @@ class OathManager( } } - private suspend fun useOathSession( - title: String, - action: (OathSession) -> T - ): T { - return if (_isUsbKey) { - // Uses the connected YubiKey directly - useOathSessionUsb(title, action) - } else { - // Prompts for NFC tap - useOathSessionNfc(title, action) - } - } - private fun getOathCredential(oathSession: OathSession, credentialId: String) = oathSession.credentials.firstOrNull { credential -> (credential != null) && credential.id.asString() == credentialId diff --git a/lib/android/oath/state.dart b/lib/android/oath/state.dart index 391a1407..6ab5ef5c 100755 --- a/lib/android/oath/state.dart +++ b/lib/android/oath/state.dart @@ -35,7 +35,6 @@ class _AndroidOathStateNotifier extends OathStateNotifier { state = const AsyncValue.loading(); } else { final oathState = OathState.fromJson(json); - _log.debug('STATE: $oathState'); state = AsyncValue.data(oathState); } } @@ -139,8 +138,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier { ? List.unmodifiable( (json as List).map((e) => OathPair.fromJson(e)).toList()) : null; + _scheduleRefresh(); }); - _scheduleRefresh(); } void _notifyWindowState(WindowState windowState) {