Android: Drop LiveData for YubiKey devices.

This commit is contained in:
Dain Nilsson 2022-08-19 13:21:09 +02:00
parent e0696da422
commit 1f0103b9b9
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
6 changed files with 138 additions and 192 deletions

View File

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

View File

@ -0,0 +1,7 @@
package com.yubico.authenticator
import com.yubico.yubikit.core.YubiKeyDevice
interface AppContextManager {
suspend fun processYubiKey(device: YubiKeyDevice)
}

View File

@ -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<Tag>(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<YubiKeyDevice?> {
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 {

View File

@ -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<Boolean> = _handleYubiKey
val yubiKeyDevice = MutableLiveData<YubiKeyDevice?>()
private var _appContext = MutableLiveData(OperationContext.Oath)
val appContext: LiveData<OperationContext> = _appContext
fun setContext(appContext: OperationContext) = _appContext.postValue(appContext)
private val _connectedYubiKey = MutableLiveData<UsbYubiKeyDevice?>()
val connectedYubiKey: LiveData<UsbYubiKeyDevice?> = _connectedYubiKey
fun setConnectedYubiKey(device: UsbYubiKeyDevice) {
_connectedYubiKey.postValue(device)
device.setOnClosed { _connectedYubiKey.postValue(null) }
}
private val _deviceInfo = MutableLiveData<Info?>()
val deviceInfo: LiveData<Info?> = _deviceInfo

View File

@ -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<OathSession, Exception>) -> 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<SmartCardConnection, Unit> {
val oath = OathSession(it)
tryToUnlockOathSession(oath)
private val deviceObserver =
Observer<YubiKeyDevice?> { 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<SmartCardConnection, Unit> {
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<Credential, Code> {
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 <T> useOathSessionUsb(
private suspend fun <T> useOathSession(
title: String,
action: (OathSession) -> T
): T {
appViewModel.yubiKeyDevice.value?.let { yubiKey ->
Log.d(TAG, "Executing action on usb key: $title")
return yubiKey.withConnection<SmartCardConnection, T> {
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 <T> useOathSessionUsb(
device: UsbYubiKeyDevice,
block: (OathSession) -> T
): T = device.withConnection<SmartCardConnection, T> {
block(OathSession(it))
}
private suspend fun <T> useOathSessionNfc(
@ -462,19 +412,6 @@ class OathManager(
}
}
private suspend fun <T> 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

View File

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