split view model

This commit is contained in:
Adam Velebil 2022-04-29 17:41:42 +02:00
parent 02b0e331b8
commit acfc93da31
No known key found for this signature in database
GPG Key ID: AC6D6B9D715FC084
9 changed files with 633 additions and 664 deletions

View File

@ -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<Void>) {
viewModel.onDialogClosed(result)
}
}

View File

@ -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<OperationContext> = _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<Void>) {
_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)
}
}

View File

@ -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<Void>) {
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"))
}
}
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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<YubiKeyDevice, Exception>) -> Unit
)
class MainViewModel : ViewModel() {
private val _dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val _handleYubiKey = MutableLiveData(true)
val handleYubiKey: LiveData<Boolean> = _handleYubiKey
val yubiKeyDevice = MutableLiveData<YubiKeyDevice?>()
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<String> {
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<String> {
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<String> {
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<YubiKeyAction?>()
private val pendingYubiKeyAction: LiveData<YubiKeyAction?> = _pendingYubiKeyAction
private suspend fun provideYubiKey(result: Result<YubiKeyDevice, Exception>) =
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<Void>) {
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 <T> 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<Void>) {
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<String>) {
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?>(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<String>
) {
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<Void>) {
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<Void>) {
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<Credential, Code> {
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<String>) {
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<String>) {
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<Pigeon.UnlockResponse>
) {
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<Void>) {
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 <T> useOathSession(
title: String,
queryUserToTap: Boolean,
action: (OathSession) -> T
) = suspendCoroutine<T> { 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<Void>) {
_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 <T> returnSuccess(result: Pigeon.Result<T>, 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 <T> returnError(result: Pigeon.Result<T>, error: Throwable) {
viewModelScope.launch(Dispatchers.Main) {
if (!_isUsbKey) {
_fDialogApi.closeDialogApi {
viewModelScope.launch(Dispatchers.Main) {
result.error(error)
}
}
} else {
result.error(error)
}
}
}
}

View File

@ -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<YubiKeyDevice, Exception>) -> Unit
)

View File

@ -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<Void>) {
viewModel.resetOathSession(result)
}
override fun unlock(
password: String,
remember: Boolean,
result: Result<UnlockResponse>
) {
viewModel.unlockOathSession(password, remember, result)
}
override fun setPassword(
currentPassword: String?,
newPassword: String,
result: Result<Void>
) {
viewModel.setOathPassword(currentPassword, newPassword, result)
}
override fun unsetPassword(currentPassword: String, result: Result<Void>) {
viewModel.unsetOathPassword(currentPassword, result)
}
override fun forgetPassword(result: Result<Void>) {
viewModel.forgetPassword(result)
}
override fun addAccount(
uri: String,
requireTouch: Boolean,
result: Result<String>
) {
viewModel.addAccount(uri, requireTouch, result)
}
override fun renameAccount(uri: String, name: String, issuer: String?, result: Result<String>) {
viewModel.renameCredential(uri, name, issuer, result)
}
override fun deleteAccount(uri: String, result: Result<Void>) {
viewModel.deleteAccount(uri, result)
}
override fun refreshCodes(result: Result<String>) {
viewModel.refreshOathCodes(result)
}
override fun calculate(uri: String, result: Result<String>) {
viewModel.calculate(uri, result)
}
}

View File

@ -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<YubiKeyAction?>()
private val pendingYubiKeyAction: LiveData<YubiKeyAction?> = _pendingYubiKeyAction
init {
OathApi.setup(messenger, this)
appContext.appContext.observe(lifecycleOwner) {
if (it == OperationContext.Oath) {
installObservers()
} else {
uninstallObservers()
}
}
}
private val deviceObserver =
Observer<YubiKeyDevice?> { 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<YubiKeyDevice, Exception>) =
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<Void>) {
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<UnlockResponse>
) {
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<Void>
) {
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<Void>) {
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<Void>) {
_keyManager.clearAll()
Logger.d("Cleared all keys.")
returnSuccess(result)
}
override fun addAccount(
uri: String,
requireTouch: Boolean,
result: Result<String>
) {
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?>(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<String>) {
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<Void>) {
coroutineScope.launch {
useOathSession("Delete account", true) { session ->
withUnlockedSession(session) {
val credential = getOathCredential(session, uri)
session.deleteCredential(credential)
returnSuccess(result)
}
}
}
}
override fun refreshCodes(result: Result<String>) {
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<String>) {
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<String> {
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<String> {
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<String> {
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<Credential, Code> {
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 <T> withUnlockedSession(session: OathSession, block: (OathSession) -> T): T {
if (!tryToUnlockOathSession(session)) {
throw Exception("Session is locked")
}
return block(session)
}
private suspend fun <T> useOathSession(
title: String,
queryUserToTap: Boolean,
action: (OathSession) -> T
) = suspendCoroutine<T> { 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 <T> returnSuccess(result: Result<T>, 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 <T> returnError(result: Result<T>, error: Throwable) {
coroutineScope.launch(Dispatchers.Main) {
if (!_isUsbKey) {
dialogManager.closeDialog {
result.error(error)
}
} else {
result.error(error)
}
}
}
}