From acfc93da31f3de2248c26c4d762e5296cd35c9d5 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 29 Apr 2022 17:41:42 +0200 Subject: [PATCH] split view model --- .../authenticator/api/HDialogApiImpl.kt | 9 - .../com/yubico/authenticator/AppContext.kt | 19 +- .../com/yubico/authenticator/DialogManager.kt | 50 ++ .../com/yubico/authenticator/FlutterLog.kt | 7 + .../com/yubico/authenticator/MainActivity.kt | 58 +- .../com/yubico/authenticator/MainViewModel.kt | 546 ------------------ .../com/yubico/authenticator/YubiKeyAction.kt | 9 + .../yubico/authenticator/oath/OathApiImpl.kt | 62 -- .../yubico/authenticator/oath/OathManager.kt | 537 +++++++++++++++++ 9 files changed, 633 insertions(+), 664 deletions(-) delete mode 100644 android/app/src/main/java/com/yubico/authenticator/api/HDialogApiImpl.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/YubiKeyAction.kt delete mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/oath/OathApiImpl.kt create mode 100644 android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt diff --git a/android/app/src/main/java/com/yubico/authenticator/api/HDialogApiImpl.kt b/android/app/src/main/java/com/yubico/authenticator/api/HDialogApiImpl.kt deleted file mode 100644 index 2fcebc12..00000000 --- a/android/app/src/main/java/com/yubico/authenticator/api/HDialogApiImpl.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.yubico.authenticator.api - -import com.yubico.authenticator.MainViewModel - -class HDialogApiImpl(private val viewModel: MainViewModel) : Pigeon.HDialogApi { - override fun dialogClosed(result: Pigeon.Result) { - viewModel.onDialogClosed(result) - } -} \ No newline at end of file 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 af4aa0e4..80c5dd19 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt @@ -1,7 +1,9 @@ package com.yubico.authenticator +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import com.yubico.authenticator.api.Pigeon -import com.yubico.yubikit.core.Logger +import io.flutter.plugin.common.BinaryMessenger enum class OperationContext(val value: Long) { Oath(0), Yubikey(1), Invalid(-1); @@ -11,18 +13,17 @@ enum class OperationContext(val value: Long) { } } -class AppContext : Pigeon.AppApi { +class AppContext(messenger: BinaryMessenger) : Pigeon.AppApi { + private var _appContext = MutableLiveData(OperationContext.Oath) + val appContext: LiveData = _appContext - private var _operationContext = OperationContext.Oath - - fun getContext() : OperationContext { - return _operationContext + init { + Pigeon.AppApi.setup(messenger, this) } override fun setContext(subPageIndex: Long, result: Pigeon.Result) { - _operationContext = OperationContext.getByValue(subPageIndex) - Logger.d("Operation context is now $_operationContext") + _appContext.value = OperationContext.getByValue(subPageIndex) + FlutterLog.d("App context is now $_appContext") result.success(null) } - } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt new file mode 100644 index 00000000..cdeb64dc --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt @@ -0,0 +1,50 @@ +package com.yubico.authenticator + +import com.yubico.authenticator.api.Pigeon.* +import io.flutter.plugin.common.BinaryMessenger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +typealias OnDialogClosed = () -> Unit +typealias OnDialogCancelled = () -> Unit + +class DialogManager(messenger: BinaryMessenger, private var coroutineScope: CoroutineScope) : + HDialogApi { + + private val _fDialogApi = FDialogApi(messenger) + + private var onCancelled: OnDialogCancelled? = null + + init { + HDialogApi.setup(messenger, this) + } + + fun showDialog(message: String, cancelled: OnDialogCancelled?) = + coroutineScope.launch(Dispatchers.Main) { + _fDialogApi.showDialogApi(message) { } + }.also { + onCancelled = cancelled + } + + fun closeDialog(onClosed: OnDialogClosed) { + _fDialogApi.closeDialogApi { + coroutineScope.launch(Dispatchers.Main) { + onClosed() + } + } + } + + override fun dialogClosed(result: Result) { + coroutineScope.launch { + try { + onCancelled?.invoke() + result.success(null) + } catch (cause: Throwable) { + FlutterLog.d("Failed to close dialog during User cancel action") + result.error(Exception("Failed to close dialog during User cancel action")) + } + } + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/FlutterLog.kt b/android/app/src/main/kotlin/com/yubico/authenticator/FlutterLog.kt index 6c428e63..868a6525 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/FlutterLog.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/FlutterLog.kt @@ -16,30 +16,37 @@ class FlutterLog(messenger: BinaryMessenger, private val activity: MainActivity) instance = FlutterLog(messenger, activity) } + @Suppress("unused") fun t(message: String, error: String? = null) { instance.log("t", message, error) } + @Suppress("unused") fun d(message: String, error: String? = null) { instance.log("d", message, error) } + @Suppress("unused") fun i(message: String, error: String? = null) { instance.log("i", message, error) } + @Suppress("unused") fun w(message: String, error: String? = null) { instance.log("w", message, error) } + @Suppress("unused") fun e(message: String, error: String? = null) { instance.log("e", message, error) } + @Suppress("unused") fun wtf(message: String, error: String? = null) { instance.log("wtf", message, error) } + @Suppress("unused") fun v(message: String, error: String? = null) { instance.log("v", message, error) } 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 d55fb6b2..3f160d16 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -3,9 +3,7 @@ package com.yubico.authenticator import android.os.Bundle import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope -import com.yubico.authenticator.api.HDialogApiImpl -import com.yubico.authenticator.oath.OathApiImpl -import com.yubico.authenticator.api.Pigeon +import com.yubico.authenticator.oath.OathManager import com.yubico.yubikit.android.YubiKitManager import com.yubico.yubikit.android.transport.nfc.NfcConfiguration import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable @@ -13,9 +11,9 @@ import com.yubico.yubikit.android.transport.usb.UsbConfiguration import com.yubico.yubikit.core.Logger import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.BinaryMessenger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlin.properties.Delegates class MainActivity : FlutterFragmentActivity() { @@ -55,42 +53,9 @@ class MainActivity : FlutterFragmentActivity() { yubikit.stopUsbDiscovery() } } - - viewModel.yubiKeyDevice.observe(this) { yubikey -> - - lifecycleScope.launch(Dispatchers.Main) { - withContext(Dispatchers.Main) { - if (yubikey != null) { - Logger.d("A device was connected: $yubikey") - viewModel.yubikeyAttached(yubikey) - - } else { - Logger.d("A device was disconnected") - viewModel.yubikeyDetached() - } - } - } - } } - - val appContext = AppContext() - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - - val messenger = flutterEngine.dartExecutor.binaryMessenger - - viewModel.setAppContext(appContext) - viewModel.setFOathApi(Pigeon.FOathApi(messenger)) - viewModel.setFManagementApi(Pigeon.FManagementApi(messenger)) - viewModel.setFDialogApi(Pigeon.FDialogApi(messenger)) - Pigeon.OathApi.setup(messenger, OathApiImpl(viewModel)) - Pigeon.AppApi.setup(messenger, appContext) - Pigeon.HDialogApi.setup(messenger, HDialogApiImpl(viewModel)) - - - // simple logger for yubikit + private fun initializeLogger(messenger: BinaryMessenger) { Logger.setLogger(object : Logger() { init { FlutterLog.create(messenger, this@MainActivity) @@ -104,6 +69,23 @@ class MainActivity : FlutterFragmentActivity() { FlutterLog.e(message, throwable.message ?: throwable.toString()) } }) + } + + private lateinit var appContext: AppContext + private lateinit var oathManager: OathManager + private lateinit var dialogManager: DialogManager + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + + appContext = AppContext(messenger) + dialogManager = DialogManager(messenger, this.lifecycleScope) + + oathManager = OathManager(this, messenger, appContext, viewModel, dialogManager) + + initializeLogger(messenger) } 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 e5e8f6c3..4a011eb5 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt @@ -3,558 +3,12 @@ package com.yubico.authenticator import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.yubico.authenticator.api.Pigeon -import com.yubico.authenticator.data.device.toJson -import com.yubico.authenticator.oath.* -import com.yubico.authenticator.oath.keystore.ClearingMemProvider -import com.yubico.authenticator.oath.keystore.KeyStoreProvider -import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice -import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice -import com.yubico.yubikit.core.Logger import com.yubico.yubikit.core.YubiKeyDevice -import com.yubico.yubikit.core.smartcard.SmartCardConnection -import com.yubico.yubikit.core.util.Result -import com.yubico.yubikit.oath.* -import com.yubico.yubikit.support.DeviceUtil -import kotlinx.coroutines.* -import java.lang.IllegalStateException -import java.net.URI -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -data class YubiKeyAction( - val message: String, - val action: suspend (Result) -> Unit -) - - class MainViewModel : ViewModel() { - private val _dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val _handleYubiKey = MutableLiveData(true) val handleYubiKey: LiveData = _handleYubiKey val yubiKeyDevice = MutableLiveData() - private var _isUsbKey: Boolean = false - private var _previousNfcDeviceId = "" - - private val _memoryKeyProvider = ClearingMemProvider() - private val _keyManager = KeyManager(KeyStoreProvider(), _memoryKeyProvider) - - - - private lateinit var _fOathApi: Pigeon.FOathApi - private lateinit var _fManagementApi: Pigeon.FManagementApi - private lateinit var _fDialogApi: Pigeon.FDialogApi - - - private lateinit var _appContext : AppContext - - fun setAppContext(appContext: AppContext) { - _appContext = appContext - } - - fun setFOathApi(oathApi: Pigeon.FOathApi) { - _fOathApi = oathApi - } - - fun setFManagementApi(managementApi: Pigeon.FManagementApi) { - _fManagementApi = managementApi - } - - fun setFDialogApi(dialogApi: Pigeon.FDialogApi) { - _fDialogApi = dialogApi - } - - private suspend fun sendDeviceInfo(device: YubiKeyDevice) { - - val deviceInfoData = suspendCoroutine { - device.requestConnection(SmartCardConnection::class.java) { result -> - try { - val pid = (device as? UsbYubiKeyDevice)?.pid - val deviceInfo = DeviceUtil.readInfo(result.value, pid) - val name = DeviceUtil.getName(deviceInfo, pid?.type) - - val deviceInfoData = deviceInfo - .toJson(name, device is NfcYubiKeyDevice) - .toString() - it.resume(deviceInfoData) - } catch (cause: Throwable) { - Logger.e("Failed to get device info", cause) - it.resumeWithException(cause) - } - } - } - - _fManagementApi.updateDeviceInfo(deviceInfoData) {} - } - - private suspend fun sendOathInfo(device: YubiKeyDevice) { - - val oathSessionData = suspendCoroutine { - device.requestConnection(SmartCardConnection::class.java) { result -> - val oathSession = OathSession(result.value) - - val deviceId = oathSession.deviceId - - _previousNfcDeviceId = if (device is NfcYubiKeyDevice) { - if (deviceId != _previousNfcDeviceId) { - // devices are different, clear access key for previous device - _memoryKeyProvider.removeKey(_previousNfcDeviceId) - } - deviceId - } else { - "" - } - - // calling unlock session will remove invalid access keys - tryToUnlockOathSession(oathSession) - val isRemembered = _keyManager.isRemembered(oathSession.deviceId) - - val oathSessionData = oathSession - .toJson(isRemembered) - .toString() - it.resume(oathSessionData) - } - } - - _fOathApi.updateSession(oathSessionData) {} - } - - private suspend fun sendOathCodes(device: YubiKeyDevice) { - val sendOathCodes = suspendCoroutine { - device.requestConnection(SmartCardConnection::class.java) { result -> - val session = OathSession(result.value) - if (tryToUnlockOathSession(session)) { - val resultJson = calculateOathCodes(session) - .toJson(session.deviceId) - .toString() - it.resume(resultJson) - } - } - } - - _fOathApi.updateOathCredentials(sendOathCodes) {} - } - - private val _pendingYubiKeyAction = MutableLiveData() - private val pendingYubiKeyAction: LiveData = _pendingYubiKeyAction - - private suspend fun provideYubiKey(result: Result) = - withContext(_dispatcher) { - pendingYubiKeyAction.value?.let { - _pendingYubiKeyAction.postValue(null) - it.action.invoke(result) - } - } - - suspend fun yubikeyAttached(device: YubiKeyDevice) { - - _isUsbKey = device is UsbYubiKeyDevice - - try { - - withContext(_dispatcher) { - if (pendingYubiKeyAction.value != null) { - provideYubiKey(Result.success(device)) - } else { - withContext(Dispatchers.Main) { - when (_appContext.getContext()) { - OperationContext.Oath -> { - sendDeviceInfo(device) - sendOathInfo(device) - sendOathCodes(device) - } - OperationContext.Yubikey -> { - sendDeviceInfo(device) - } - - else -> {} - } - } - } - } - } catch (illegalStateException: IllegalStateException) { - // ignored - } - } - - fun yubikeyDetached() { - if (_isUsbKey) { - // clear keys from memory - _memoryKeyProvider.clearAll() - _pendingYubiKeyAction.postValue(null) - _fManagementApi.updateDeviceInfo("") {} - } - } - - fun onDialogClosed(result: Pigeon.Result) { - viewModelScope.launch { - try { - provideYubiKey(Result.failure(Exception("User canceled"))) - result.success(null) - } catch (cause: Throwable) { - Logger.d("failed") - result.error(Exception("Failed to close dialog during User cancel action")) - } - } - } - - // requests flutter to show a dialog - private fun requestShowDialog(message: String) = - _fDialogApi.showDialogApi(message) { } - - private fun withUnlockedSession(session: OathSession, block: (OathSession) -> T): T { - if (!tryToUnlockOathSession(session)) { - throw Exception("Session is locked") - } - return block(session) - } - - /** - * Returns Steam code or standard TOTP code based on the credential. - * @param session OathSession which calculates the TOTP code - * @param credential - * @param timestamp time for TOTP calculation - * - * @return calculated Code - */ - private fun calculateCode( - session: OathSession, - credential: Credential, - timestamp: Long - ) = - if (credential.isSteamCredential()) { - session.calculateSteamCode(credential, timestamp) - } else { - session.calculateCode(credential, timestamp) - } - - private fun getOathCredential(oathSession: OathSession, credentialId: String) = - oathSession.credentials.firstOrNull { credential -> - (credential != null) && credential.idAsString() == credentialId - } ?: throw Exception("Failed to find account to delete") - - - fun deleteAccount(credentialId: String, result: Pigeon.Result) { - viewModelScope.launch(_dispatcher) { - useOathSession("Delete account", true) { session -> - withUnlockedSession(session) { - val credential = getOathCredential(session, credentialId) - session.deleteCredential(credential) - returnSuccess(result) - } - } - } - } - - fun addAccount(otpUri: String, requireTouch: Boolean, result: Pigeon.Result) { - - viewModelScope.launch(_dispatcher) { - try { - useOathSession("Add account", true) { session -> - withUnlockedSession(session) { - val credentialData: CredentialData = - CredentialData.parseUri(URI.create(otpUri)) - - val credential = session.putCredential(credentialData, requireTouch) - - val code = - if (credentialData.oathType == OathType.TOTP && !requireTouch) { - // recalculate the code - calculateCode(session, credential, System.currentTimeMillis()) - } else null - - val jsonResult = Pair(credential, code) - .toJson(session.deviceId) - .toString() - - returnSuccess(result, jsonResult) - } - } - } catch (cause: Throwable) { - returnError(result, cause) - } - } - } - - fun renameCredential( - credentialId: String, - name: String, - issuer: String?, - result: Pigeon.Result - ) { - - viewModelScope.launch(_dispatcher) { - try { - useOathSession("Rename", true) { session -> - withUnlockedSession(session) { - val credential = getOathCredential(session, credentialId) - - val jsonResult = - session.renameCredential(credential, name, issuer) - .toJson(session.deviceId) - .toString() - - returnSuccess(result, jsonResult) - } - } - } catch (cause: Throwable) { - returnError(result, cause) - } - } - } - - fun setOathPassword(current: String?, password: String, result: Pigeon.Result) { - viewModelScope.launch(_dispatcher) { - try { - useOathSession("Set password", true) { session -> - if (session.isAccessKeySet) { - if (current == null) { - throw Exception("Must provide current password to be able to change it") - } - // test current password sent by the user - if (!session.unlock(current.toCharArray())) { - throw Exception("Provided current password is invalid") - } - } - val accessKey = session.deriveAccessKey(password.toCharArray()) - session.setAccessKey(accessKey) - _keyManager.addKey(session.deviceId, accessKey, false) - Logger.d("Successfully set password") - returnSuccess(result) - } - } catch (cause: Throwable) { - returnError(result, cause) - } - } - } - - - fun unsetOathPassword(currentPassword: String, result: Pigeon.Result) { - - viewModelScope.launch(_dispatcher) { - try { - useOathSession("Unset password", true) { session -> - if (session.isAccessKeySet) { - // test current password sent by the user - if (session.unlock(currentPassword.toCharArray())) { - session.deleteAccessKey() - _keyManager.removeKey(session.deviceId) - Logger.d("Successfully unset password") - returnSuccess(result) - return@useOathSession - } - } - returnError(result, Exception("Unset password failed")) - } - } catch (cause: Throwable) { - returnError(result, cause) - } - } - } - - private fun calculateOathCodes(session: OathSession): Map { - val timeStamp = System.currentTimeMillis() - return session.calculateCodes(timeStamp).map { (credential, code) -> - Pair(credential, if (credential.isSteamCredential()) { - session.calculateSteamCode(credential, timeStamp) - } else { - code - }) - }.toMap() - } - - fun refreshOathCodes(result: Pigeon.Result) { - viewModelScope.launch(_dispatcher) { - try { - if (!_isUsbKey) { - throw Exception("Cannot refresh for nfc key") - } - - useOathSession("Refresh codes", false) { - withUnlockedSession(it) { session -> - val resultJson = calculateOathCodes(session) - .toJson(session.deviceId) - .toString() - returnSuccess(result, resultJson) - } - } - } catch (cause: Throwable) { - returnError(result, cause) - } - } - } - - fun calculate(credentialId: String, result: Pigeon.Result) { - viewModelScope.launch(_dispatcher) { - try { - useOathSession("Calculate", true) { - withUnlockedSession(it) { session -> - - val credential = getOathCredential(session, credentialId) - - val resultJson = calculateCode(session, credential, System.currentTimeMillis()) - .toJson() - .toString() - - returnSuccess(result, resultJson) - } - } - } catch (cause: Throwable) { - returnError(result, cause) - } - } - } - - fun unlockOathSession( - password: String, - remember: Boolean, - result: Pigeon.Result - ) { - - viewModelScope.launch(_dispatcher) { - try { - var codes: String? = null - useOathSession("Unlocking", true) { - val accessKey = it.deriveAccessKey(password.toCharArray()) - _keyManager.addKey(it.deviceId, accessKey, remember) - - val response = Pigeon.UnlockResponse().apply { - isUnlocked = tryToUnlockOathSession(it) - isRemembered = _keyManager.isRemembered(it.deviceId) - } - if (response.isUnlocked == true) { - codes = calculateOathCodes(it) - .toJson(it.deviceId) - .toString() - } - returnSuccess(result, response) - } - - codes?.let { - viewModelScope.launch(Dispatchers.Main) { - _fOathApi.updateOathCredentials(it) {} - } - } - - } catch (cause: Throwable) { - returnError(result, cause) - } - } - } - - fun resetOathSession(result: Pigeon.Result) { - viewModelScope.launch(_dispatcher) { - try { - useOathSession("Reset YubiKey", true) { - // note, it is ok to reset locked session - it.reset() - _keyManager.removeKey(it.deviceId) - returnSuccess(result) - } - } catch (e: Throwable) { - returnError(result, e) - } - } - } - - private suspend fun useOathSession( - title: String, - queryUserToTap: Boolean, - action: (OathSession) -> T - ) = suspendCoroutine { outer -> - if (queryUserToTap && !_isUsbKey) { - viewModelScope.launch(Dispatchers.Main) { - requestShowDialog(title) - } - } - _pendingYubiKeyAction.postValue(YubiKeyAction(title) { yubiKey -> - outer.resumeWith(runCatching { - suspendCoroutine { inner -> - yubiKey.value.requestConnection(SmartCardConnection::class.java) { - inner.resumeWith(runCatching { - action.invoke(OathSession(it.value)) - }) - } - } - }) - }) - - yubiKeyDevice.value?.let { - viewModelScope.launch(_dispatcher) { - provideYubiKey(Result.success(it)) - } - } - } - - /** - * Tries to unlocks [OathSession] with [AccessKey] stored in [KeyManager]. On failure clears - * relevant access keys from [KeyManager] - * - * @return true if we the session is not locked or it was successfully unlocked, false otherwise - */ - private fun tryToUnlockOathSession(session: OathSession): Boolean { - if (!session.isLocked) { - return true - } - - val deviceId = session.deviceId - val accessKey = _keyManager.getKey(deviceId) - ?: return false // we have no access key to unlock the session - - val unlockSucceed = session.unlock(accessKey) - - if (unlockSucceed) { - return true - } - - _keyManager.removeKey(deviceId) // remove invalid access keys from [KeyManager] - return false // the unlock did not work, session is locked - } - - fun forgetPassword(result: Pigeon.Result) { - _keyManager.clearAll() - Logger.d("Cleared all keys.") - returnSuccess(result) - } - - - /// for nfc connection waits for the dialog to be closed and then returns success data - /// for usb connection returns success data directly - private fun returnSuccess(result: Pigeon.Result, data: T? = null) { - viewModelScope.launch(Dispatchers.Main) { - if (!_isUsbKey) { - _fDialogApi.closeDialogApi { - viewModelScope.launch(Dispatchers.Main) { - result.success(data) - } - } - } else { - result.success(data) - } - } - } - - /// for nfc connection waits for the dialog to be closed and then returns error - /// for usb connection returns error directly - private fun returnError(result: Pigeon.Result, error: Throwable) { - viewModelScope.launch(Dispatchers.Main) { - if (!_isUsbKey) { - _fDialogApi.closeDialogApi { - viewModelScope.launch(Dispatchers.Main) { - result.error(error) - } - } - } else { - result.error(error) - } - } - } - } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/YubiKeyAction.kt b/android/app/src/main/kotlin/com/yubico/authenticator/YubiKeyAction.kt new file mode 100644 index 00000000..2798e61e --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/YubiKeyAction.kt @@ -0,0 +1,9 @@ +package com.yubico.authenticator + +import com.yubico.yubikit.core.YubiKeyDevice +import com.yubico.yubikit.core.util.Result + +data class YubiKeyAction( + val message: String, + val action: suspend (Result) -> Unit +) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathApiImpl.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathApiImpl.kt deleted file mode 100644 index 64195467..00000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathApiImpl.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.yubico.authenticator.oath - -import com.yubico.authenticator.MainViewModel -import com.yubico.authenticator.api.Pigeon.OathApi -import com.yubico.authenticator.api.Pigeon.Result -import com.yubico.authenticator.api.Pigeon.UnlockResponse - -class OathApiImpl(private val viewModel: MainViewModel) : OathApi { - - override fun reset(result: Result) { - viewModel.resetOathSession(result) - } - - override fun unlock( - password: String, - remember: Boolean, - result: Result - ) { - viewModel.unlockOathSession(password, remember, result) - } - - override fun setPassword( - currentPassword: String?, - newPassword: String, - result: Result - ) { - viewModel.setOathPassword(currentPassword, newPassword, result) - } - - override fun unsetPassword(currentPassword: String, result: Result) { - viewModel.unsetOathPassword(currentPassword, result) - } - - override fun forgetPassword(result: Result) { - viewModel.forgetPassword(result) - } - - override fun addAccount( - uri: String, - requireTouch: Boolean, - result: Result - ) { - viewModel.addAccount(uri, requireTouch, result) - } - - override fun renameAccount(uri: String, name: String, issuer: String?, result: Result) { - viewModel.renameCredential(uri, name, issuer, result) - } - - override fun deleteAccount(uri: String, result: Result) { - viewModel.deleteAccount(uri, result) - } - - override fun refreshCodes(result: Result) { - viewModel.refreshOathCodes(result) - } - - override fun calculate(uri: String, result: Result) { - viewModel.calculate(uri, result) - } - -} \ No newline at end of file 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 new file mode 100644 index 00000000..15043f23 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -0,0 +1,537 @@ +package com.yubico.authenticator.oath + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import com.yubico.authenticator.* +import com.yubico.authenticator.api.Pigeon.* +import com.yubico.authenticator.data.device.toJson +import com.yubico.authenticator.oath.keystore.ClearingMemProvider +import com.yubico.authenticator.oath.keystore.KeyStoreProvider +import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice +import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice +import com.yubico.yubikit.core.Logger +import com.yubico.yubikit.core.YubiKeyDevice +import com.yubico.yubikit.core.smartcard.SmartCardConnection +import com.yubico.yubikit.oath.* +import com.yubico.yubikit.support.DeviceUtil +import io.flutter.plugin.common.BinaryMessenger +import kotlinx.coroutines.* +import java.net.URI +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class OathManager( + private val lifecycleOwner: LifecycleOwner, + messenger: BinaryMessenger, + appContext: AppContext, + private val appViewModel: MainViewModel, + private val dialogManager: DialogManager +) : OathApi { + + private val _dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val coroutineScope = CoroutineScope(SupervisorJob() + _dispatcher) + + private val _fOathApi: FOathApi = FOathApi(messenger) + private val _fManagementApi: FManagementApi = FManagementApi(messenger) + + private val _memoryKeyProvider = ClearingMemProvider() + private val _keyManager = KeyManager(KeyStoreProvider(), _memoryKeyProvider) + private var _previousNfcDeviceId = "" + + private val _pendingYubiKeyAction = MutableLiveData() + private val pendingYubiKeyAction: LiveData = _pendingYubiKeyAction + + init { + OathApi.setup(messenger, this) + + appContext.appContext.observe(lifecycleOwner) { + if (it == OperationContext.Oath) { + installObservers() + } else { + uninstallObservers() + } + } + } + + private val deviceObserver = + Observer { yubiKeyDevice -> + if (yubiKeyDevice != null) { + yubikeyAttached(yubiKeyDevice) + } else { + yubikeyDetached() + } + } + + private fun installObservers() { + FlutterLog.d("Installed oath observers") + appViewModel.yubiKeyDevice.observe(lifecycleOwner, deviceObserver) + } + + private fun uninstallObservers() { + appViewModel.yubiKeyDevice.removeObserver(deviceObserver) + FlutterLog.d("Uninstalled oath observers") + } + + private suspend fun provideYubiKey(result: com.yubico.yubikit.core.util.Result) = + pendingYubiKeyAction.value?.let { + _pendingYubiKeyAction.postValue(null) + it.action.invoke(result) + } + + private var _isUsbKey = false + private fun yubikeyAttached(device: YubiKeyDevice) { + FlutterLog.d("Device connected") + + _isUsbKey = device is UsbYubiKeyDevice + + try { + coroutineScope.launch { + if (pendingYubiKeyAction.value != null) { + provideYubiKey(com.yubico.yubikit.core.util.Result.success(device)) + } else { + withContext(Dispatchers.Main) { + sendDeviceInfo(device) + sendOathInfo(device) + sendOathCodes(device) + } + } + } + } catch (illegalStateException: IllegalStateException) { + // ignored + } + } + + private fun yubikeyDetached() { + if (_isUsbKey) { + FlutterLog.d("Device disconnected") + // clear keys from memory + _memoryKeyProvider.clearAll() + _pendingYubiKeyAction.postValue(null) + _fManagementApi.updateDeviceInfo("") {} + } + } + + override fun reset(result: Result) { + coroutineScope.launch { + try { + useOathSession("Reset YubiKey", true) { + // note, it is ok to reset locked session + it.reset() + _keyManager.removeKey(it.deviceId) + returnSuccess(result) + } + } catch (e: Throwable) { + returnError(result, e) + } + } + } + + override fun unlock( + password: String, + remember: Boolean, + result: Result + ) { + coroutineScope.launch { + try { + var codes: String? = null + useOathSession("Unlocking", true) { + val accessKey = it.deriveAccessKey(password.toCharArray()) + _keyManager.addKey(it.deviceId, accessKey, remember) + + val response = UnlockResponse().apply { + isUnlocked = tryToUnlockOathSession(it) + isRemembered = _keyManager.isRemembered(it.deviceId) + } + if (response.isUnlocked == true) { + codes = calculateOathCodes(it) + .toJson(it.deviceId) + .toString() + } + returnSuccess(result, response) + } + + codes?.let { + coroutineScope.launch(Dispatchers.Main) { + _fOathApi.updateOathCredentials(it) {} + } + } + + } catch (cause: Throwable) { + returnError(result, cause) + } + } + } + + override fun setPassword( + currentPassword: String?, + newPassword: String, + result: Result + ) { + coroutineScope.launch { + try { + useOathSession("Set password", true) { session -> + if (session.isAccessKeySet) { + if (currentPassword == null) { + throw Exception("Must provide current password to be able to change it") + } + // test current password sent by the user + if (!session.unlock(currentPassword.toCharArray())) { + throw Exception("Provided current password is invalid") + } + } + val accessKey = session.deriveAccessKey(newPassword.toCharArray()) + session.setAccessKey(accessKey) + _keyManager.addKey(session.deviceId, accessKey, false) + Logger.d("Successfully set password") + returnSuccess(result) + } + } catch (cause: Throwable) { + returnError(result, cause) + } + } + } + + override fun unsetPassword(currentPassword: String, result: Result) { + coroutineScope.launch { + try { + useOathSession("Unset password", true) { session -> + if (session.isAccessKeySet) { + // test current password sent by the user + if (session.unlock(currentPassword.toCharArray())) { + session.deleteAccessKey() + _keyManager.removeKey(session.deviceId) + Logger.d("Successfully unset password") + returnSuccess(result) + return@useOathSession + } + } + returnError(result, Exception("Unset password failed")) + } + } catch (cause: Throwable) { + returnError(result, cause) + } + } + } + + override fun forgetPassword(result: Result) { + _keyManager.clearAll() + Logger.d("Cleared all keys.") + returnSuccess(result) + } + + override fun addAccount( + uri: String, + requireTouch: Boolean, + result: Result + ) { + coroutineScope.launch { + try { + useOathSession("Add account", true) { session -> + withUnlockedSession(session) { + val credentialData: CredentialData = + CredentialData.parseUri(URI.create(uri)) + + val credential = session.putCredential(credentialData, requireTouch) + + val code = + if (credentialData.oathType == OathType.TOTP && !requireTouch) { + // recalculate the code + calculateCode(session, credential, System.currentTimeMillis()) + } else null + + val jsonResult = Pair(credential, code) + .toJson(session.deviceId) + .toString() + + returnSuccess(result, jsonResult) + } + } + } catch (cause: Throwable) { + returnError(result, cause) + } + } + } + + override fun renameAccount(uri: String, name: String, issuer: String?, result: Result) { + coroutineScope.launch { + try { + useOathSession("Rename", true) { session -> + withUnlockedSession(session) { + val credential = getOathCredential(session, uri) + + val jsonResult = + session.renameCredential(credential, name, issuer) + .toJson(session.deviceId) + .toString() + + returnSuccess(result, jsonResult) + } + } + } catch (cause: Throwable) { + returnError(result, cause) + } + } + } + + override fun deleteAccount(uri: String, result: Result) { + coroutineScope.launch { + useOathSession("Delete account", true) { session -> + withUnlockedSession(session) { + val credential = getOathCredential(session, uri) + session.deleteCredential(credential) + returnSuccess(result) + } + } + } + } + + override fun refreshCodes(result: Result) { + coroutineScope.launch { + try { + if (!_isUsbKey) { + throw Exception("Cannot refresh for nfc key") + } + + useOathSession("Refresh codes", false) { + withUnlockedSession(it) { session -> + val resultJson = calculateOathCodes(session) + .toJson(session.deviceId) + .toString() + returnSuccess(result, resultJson) + } + } + } catch (cause: Throwable) { + returnError(result, cause) + } + } + } + + override fun calculate(uri: String, result: Result) { + coroutineScope.launch { + try { + useOathSession("Calculate", true) { + withUnlockedSession(it) { session -> + + val credential = getOathCredential(session, uri) + + val resultJson = + calculateCode(session, credential, System.currentTimeMillis()) + .toJson() + .toString() + + returnSuccess(result, resultJson) + } + } + } catch (cause: Throwable) { + returnError(result, cause) + } + } + } + + /** + * Returns Steam code or standard TOTP code based on the credential. + * @param session OathSession which calculates the TOTP code + * @param credential + * @param timestamp time for TOTP calculation + * + * @return calculated Code + */ + private fun calculateCode( + session: OathSession, + credential: Credential, + timestamp: Long + ) = + if (credential.isSteamCredential()) { + session.calculateSteamCode(credential, timestamp) + } else { + session.calculateCode(credential, timestamp) + } + + private suspend fun sendDeviceInfo(device: YubiKeyDevice) { + + val deviceInfoData = suspendCoroutine { + device.requestConnection(SmartCardConnection::class.java) { result -> + try { + val pid = (device as? UsbYubiKeyDevice)?.pid + val deviceInfo = DeviceUtil.readInfo(result.value, pid) + val name = DeviceUtil.getName(deviceInfo, pid?.type) + + val deviceInfoData = deviceInfo + .toJson(name, device is NfcYubiKeyDevice) + .toString() + it.resume(deviceInfoData) + } catch (cause: Throwable) { + Logger.e("Failed to get device info", cause) + it.resumeWithException(cause) + } + } + } + + _fManagementApi.updateDeviceInfo(deviceInfoData) {} + } + + private suspend fun sendOathInfo(device: YubiKeyDevice) { + + val oathSessionData = suspendCoroutine { + device.requestConnection(SmartCardConnection::class.java) { result -> + val oathSession = OathSession(result.value) + + val deviceId = oathSession.deviceId + + _previousNfcDeviceId = if (device is NfcYubiKeyDevice) { + if (deviceId != _previousNfcDeviceId) { + // devices are different, clear access key for previous device + _memoryKeyProvider.removeKey(_previousNfcDeviceId) + } + deviceId + } else { + "" + } + + // calling unlock session will remove invalid access keys + tryToUnlockOathSession(oathSession) + val isRemembered = _keyManager.isRemembered(oathSession.deviceId) + + val oathSessionData = oathSession + .toJson(isRemembered) + .toString() + it.resume(oathSessionData) + } + } + + _fOathApi.updateSession(oathSessionData) {} + } + + private suspend fun sendOathCodes(device: YubiKeyDevice) { + val sendOathCodes = suspendCoroutine { + device.requestConnection(SmartCardConnection::class.java) { result -> + val session = OathSession(result.value) + if (tryToUnlockOathSession(session)) { + val resultJson = calculateOathCodes(session) + .toJson(session.deviceId) + .toString() + it.resume(resultJson) + } + } + } + + _fOathApi.updateOathCredentials(sendOathCodes) {} + } + + /** + * Tries to unlocks [OathSession] with [AccessKey] stored in [KeyManager]. On failure clears + * relevant access keys from [KeyManager] + * + * @return true if we the session is not locked or it was successfully unlocked, false otherwise + */ + private fun tryToUnlockOathSession(session: OathSession): Boolean { + if (!session.isLocked) { + return true + } + + val deviceId = session.deviceId + val accessKey = _keyManager.getKey(deviceId) + ?: return false // we have no access key to unlock the session + + val unlockSucceed = session.unlock(accessKey) + + if (unlockSucceed) { + return true + } + + _keyManager.removeKey(deviceId) // remove invalid access keys from [KeyManager] + return false // the unlock did not work, session is locked + } + + private fun calculateOathCodes(session: OathSession): Map { + val timeStamp = System.currentTimeMillis() + return session.calculateCodes(timeStamp).map { (credential, code) -> + Pair( + credential, if (credential.isSteamCredential()) { + session.calculateSteamCode(credential, timeStamp) + } else { + code + } + ) + }.toMap() + } + + private fun withUnlockedSession(session: OathSession, block: (OathSession) -> T): T { + if (!tryToUnlockOathSession(session)) { + throw Exception("Session is locked") + } + return block(session) + } + + private suspend fun useOathSession( + title: String, + queryUserToTap: Boolean, + action: (OathSession) -> T + ) = suspendCoroutine { outer -> + if (queryUserToTap && !_isUsbKey) { + dialogManager.showDialog(title) { + coroutineScope.launch(Dispatchers.Main) { + FlutterLog.d("Cancelled Dialog $title") + provideYubiKey(com.yubico.yubikit.core.util.Result.failure(Exception("User canceled"))) + } + } + } + _pendingYubiKeyAction.postValue(YubiKeyAction(title) { yubiKey -> + outer.resumeWith(runCatching { + suspendCoroutine { inner -> + yubiKey.value.requestConnection(SmartCardConnection::class.java) { + inner.resumeWith(runCatching { + action.invoke(OathSession(it.value)) + }) + } + } + }) + }) + + if (_isUsbKey) { + appViewModel.yubiKeyDevice.value?.let { + coroutineScope.launch { + provideYubiKey(com.yubico.yubikit.core.util.Result.success(it)) + } + } + } + } + + private fun getOathCredential(oathSession: OathSession, credentialId: String) = + oathSession.credentials.firstOrNull { credential -> + (credential != null) && credential.idAsString() == credentialId + } ?: throw Exception("Failed to find account to delete") + + + /// for nfc connection waits for the dialog to be closed and then returns success data + /// for usb connection returns success data directly + private fun returnSuccess(result: Result, data: T? = null) { + coroutineScope.launch(Dispatchers.Main) { + if (!_isUsbKey) { + dialogManager.closeDialog { + result.success(data) + } + } else { + result.success(data) + } + } + } + + /// for nfc connection waits for the dialog to be closed and then returns error + /// for usb connection returns error directly + private fun returnError(result: Result, error: Throwable) { + coroutineScope.launch(Dispatchers.Main) { + if (!_isUsbKey) { + dialogManager.closeDialog { + result.error(error) + } + } else { + result.error(error) + } + } + } +} \ No newline at end of file