reset FIDO app over USB and NFC

This commit is contained in:
Adam Velebil 2024-01-30 15:06:30 +01:00
parent 8e304c53f9
commit eddee700b3
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
6 changed files with 349 additions and 155 deletions

View File

@ -0,0 +1,95 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
import com.yubico.authenticator.DialogIcon
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.DialogTitle
import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.authenticator.yubikit.withConnection
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
import com.yubico.yubikit.core.fido.FidoConnection
import com.yubico.yubikit.core.util.Result
import org.slf4j.LoggerFactory
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.suspendCoroutine
class FidoConnectionHelper(
private val appViewModel: MainViewModel,
private val dialogManager: DialogManager
) {
private var pendingAction: FidoAction? = null
fun invokePending(fidoSession: YubiKitFidoSession) {
pendingAction?.let { action ->
action.invoke(Result.success(fidoSession))
pendingAction = null
}
}
suspend fun <T> useSession(
actionDescription: FidoActionDescription,
action: (YubiKitFidoSession) -> T
): T {
return appViewModel.connectedYubiKey.value?.let {
useSessionUsb(it, action)
} ?: useSessionNfc(actionDescription, action)
}
suspend fun <T> useSessionUsb(
device: UsbYubiKeyDevice,
block: (YubiKitFidoSession) -> T
): T = device.withConnection<FidoConnection, T> {
block(YubiKitFidoSession(it))
}
suspend fun <T> useSessionNfc(
actionDescription: FidoActionDescription,
block: (YubiKitFidoSession) -> T
): T {
try {
val result = suspendCoroutine { outer ->
pendingAction = {
outer.resumeWith(runCatching {
block.invoke(it.value)
})
}
dialogManager.showDialog(
DialogIcon.Nfc,
DialogTitle.TapKey,
actionDescription.id
) {
logger.debug("Cancelled Dialog {}", actionDescription.name)
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
}
return result
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
throw error
} finally {
dialogManager.closeDialog()
}
}
companion object {
private val logger = LoggerFactory.getLogger(FidoConnectionHelper::class.java)
}
}

View File

@ -20,16 +20,12 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import com.yubico.authenticator.AppContextManager
import com.yubico.authenticator.DialogIcon
import com.yubico.authenticator.DialogManager
import com.yubico.authenticator.DialogTitle
import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NULL
import com.yubico.authenticator.asString
import com.yubico.authenticator.device.Info
import com.yubico.authenticator.device.UnknownDevice
import com.yubico.authenticator.fido.data.FidoCredential
import com.yubico.authenticator.fido.data.FidoResetState
import com.yubico.authenticator.fido.data.Session
import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.authenticator.setHandler
@ -59,38 +55,19 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.util.Arrays
import java.util.concurrent.Executors
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.suspendCoroutine
typealias FidoAction = (Result<YubiKitFidoSession, Exception>) -> Unit
class FidoPinStore {
private var pin: CharArray? = null
fun hasPin() : Boolean {
return pin != null
}
fun getPin() : CharArray {
return pin!!
}
fun setPin(newPin: CharArray?) {
pin = newPin?.clone()
}
}
class FidoManager(
private val lifecycleOwner: LifecycleOwner,
messenger: BinaryMessenger,
private val appViewModel: MainViewModel,
private val fidoViewModel: FidoViewModel,
private val dialogManager: DialogManager,
dialogManager: DialogManager,
) : AppContextManager {
companion object {
@ -113,20 +90,27 @@ class FidoManager(
}
}
private val connectionHelper = FidoConnectionHelper(appViewModel, dialogManager)
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
private val fidoChannel = MethodChannel(messenger, "android.fido.methods")
private val logger = LoggerFactory.getLogger(FidoManager::class.java)
private var pendingAction: FidoAction? = null
private var pinStore = FidoPinStore()
private val pinStore = FidoPinStore()
private val resetHelper =
FidoResetHelper(appViewModel, fidoViewModel, connectionHelper, pinStore)
private val lifecycleObserver = object : DefaultLifecycleObserver {
private var startTimeMs: Long = -1
override fun onPause(owner: LifecycleOwner) {
// cancel any FIDO reset flow which might be in progress
resetHelper.cancelReset()
startTimeMs = currentTimeMs
super.onPause(owner)
}
@ -152,19 +136,22 @@ class FidoManager(
private val usbObserver = Observer<UsbYubiKeyDevice?> {
if (it == null) {
appViewModel.setDeviceInfo(null)
fidoViewModel.setSessionState(null)
if (!resetHelper.inProgress) {
// only reset the view model if there is no FIDO reset in progress
appViewModel.setDeviceInfo(null)
fidoViewModel.setSessionState(null)
}
}
}
init {
appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver)
//fidoViewModel.credentials.observe(lifecycleOwner, credentialObserver)
// FIDO methods callable from Flutter:
fidoChannel.setHandler(coroutineScope) { method, args ->
when (method) {
"reset" -> reset()
"reset" -> resetHelper.reset()
"cancelReset" -> resetHelper.cancelReset()
"unlock" -> unlock(
(args["pin"] as String).toCharArray()
@ -194,45 +181,7 @@ class FidoManager(
coroutineScope.cancel()
}
private suspend fun prepareReset() {
if (appViewModel.connectedYubiKey.value != null) {
// USB connection
fidoViewModel.updateResetState(FidoResetState.Remove)
delay(1000)
fidoViewModel.updateResetState(FidoResetState.Insert)
delay(1000)
fidoViewModel.updateResetState(FidoResetState.Touch)
delay(1000)
} else {
// NFC connection
fidoViewModel.updateResetState(FidoResetState.Insert)
}
}
private suspend fun reset(): String {
prepareReset();
return useSession(FidoActionDescription.Reset) { fidoSession ->
try {
// TODO: verify that the session is started with the original key
fidoSession.reset(null)
// there was no exception, reset UI state
pinStore.setPin(null)
fidoViewModel.setSessionState(Session(fidoSession.info, true))
fidoViewModel.updateCredentials(emptyList())
NULL
} finally {
}
}
}
override suspend fun processYubiKey(device: YubiKeyDevice) {
try {
if (device.supportsConnection(FidoConnection::class.java)) {
device.withConnection<FidoConnection, Unit> { connection ->
@ -285,20 +234,7 @@ class FidoManager(
)
if (sessionAaguid == previousAaguid && device is NfcYubiKeyDevice) {
// Run any pending action
pendingAction?.let { action ->
action.invoke(Result.success(fidoSession))
pendingAction = null
}
// not possible to reuse token in new session
// token?.let {
// // read creds
//
// val credentials = getCredentials(fidoSession, clientPin!!, it)
// logger.debug("Creds: {}", credentials)
// fidoViewModel.updateCredentials(credentials)
// }
connectionHelper.invokePending(fidoSession)
} else {
if (sessionAaguid != previousAaguid) {
@ -328,7 +264,7 @@ class FidoManager(
}
}
private fun getPermissions(fidoSession: YubiKitFidoSession) : Int {
private fun getPermissions(fidoSession: YubiKitFidoSession): Int {
val permissions =
if (CredentialManagement.isSupported(fidoSession.cachedInfo))
ClientPin.PIN_PERMISSION_CM
@ -392,7 +328,7 @@ class FidoManager(
}
private suspend fun unlock(pin: CharArray): String =
useSession(FidoActionDescription.Unlock) { fidoSession ->
connectionHelper.useSession(FidoActionDescription.Unlock) { fidoSession ->
try {
val clientPin =
@ -424,7 +360,7 @@ class FidoManager(
}
private suspend fun setPin(pin: CharArray?, newPin: CharArray): String =
useSession(FidoActionDescription.SetPin) { fidoSession ->
connectionHelper.useSession(FidoActionDescription.SetPin) { fidoSession ->
try {
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -470,7 +406,7 @@ class FidoManager(
}
private suspend fun deleteCredential(rpId: String, credentialId: String): String =
useSession(FidoActionDescription.DeleteCredential) { fidoSession ->
connectionHelper.useSession(FidoActionDescription.DeleteCredential) { fidoSession ->
val clientPin =
ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo))
@ -503,66 +439,4 @@ class FidoManager(
)
).toString()
}
private suspend fun <T> useSession(
actionDescription: FidoActionDescription,
action: (YubiKitFidoSession) -> T
): T {
return appViewModel.connectedYubiKey.value?.let {
useSessionUsb(it, action)
} ?: useSessionNfc(actionDescription, action)
}
private suspend fun <T> useSessionUsb(
device: UsbYubiKeyDevice,
block: (YubiKitFidoSession) -> T
): T = device.withConnection<FidoConnection, T> {
block(YubiKitFidoSession(it))
}
private suspend fun <T> useSessionNfc(
actionDescription: FidoActionDescription,
block: (YubiKitFidoSession) -> T
): T {
try {
val result = suspendCoroutine { outer ->
pendingAction = {
outer.resumeWith(runCatching {
block.invoke(it.value)
})
}
dialogManager.showDialog(
DialogIcon.Nfc,
DialogTitle.TapKey,
actionDescription.id
) {
logger.debug("Cancelled Dialog {}", actionDescription.name)
pendingAction?.invoke(Result.failure(CancellationException()))
pendingAction = null
}
}
// Personally I find it better to not have the dialog updates for FIDO
// dialogManager.updateDialogState(
// dialogIcon = DialogIcon.Success,
// dialogTitle = DialogTitle.OperationSuccessful
// )
// // TODO: This delays the closing of the dialog, but also the return value
// delay(500)
return result
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
// Personally I find it better to not have the dialog updates for FIDO
// dialogManager.updateDialogState(
// dialogIcon = DialogIcon.Failure,
// dialogTitle = DialogTitle.OperationFailed,
// dialogDescriptionId = FidoActionDescription.ActionFailure.id
// )
// // TODO: This delays the closing of the dialog, but also the return value
// delay(1500)
throw error
} finally {
dialogManager.closeDialog()
}
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
class FidoPinStore {
private var pin: CharArray? = null
fun hasPin(): Boolean {
return pin != null
}
fun getPin(): CharArray {
return pin!!
}
fun setPin(newPin: CharArray?) {
pin = newPin?.clone()
}
}

View File

@ -0,0 +1,190 @@
/*
* Copyright (C) 2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yubico.authenticator.fido
import androidx.lifecycle.viewModelScope
import com.yubico.authenticator.MainViewModel
import com.yubico.authenticator.NULL
import com.yubico.authenticator.fido.data.FidoResetState
import com.yubico.authenticator.fido.data.Session
import com.yubico.authenticator.fido.data.YubiKitFidoSession
import com.yubico.yubikit.core.application.CommandState
import com.yubico.yubikit.core.fido.CtapException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class FidoResetHelper(
private val appViewModel: MainViewModel,
private val fidoViewModel: FidoViewModel,
private val connectionHelper: FidoConnectionHelper,
private val pinStore: FidoPinStore
) {
var inProgress = false
private val coroutineScope = fidoViewModel.viewModelScope
private var resetCommandState: CommandState? = null
private var cancelReset: Boolean = false
suspend fun reset(): String {
try {
inProgress = true
fidoViewModel.updateResetState(FidoResetState.Remove)
val usb = appViewModel.connectedYubiKey.value != null
if (usb) {
resetOverUSB()
} else {
resetOverNfc()
}
logger.info("FIDO reset complete")
} catch (e: CancellationException) {
logger.debug("FIDO reset cancelled")
} finally {
inProgress = false
if (appViewModel.connectedYubiKey.value == null) {
appViewModel.setDeviceInfo(null)
fidoViewModel.setSessionState(null)
fidoViewModel.updateCredentials(emptyList())
}
}
return NULL
}
fun cancelReset(): String {
cancelReset = true
resetCommandState?.cancel()
inProgress = false
return NULL
}
private suspend fun waitForUsbDisconnect() = suspendCoroutine { continuation ->
coroutineScope.launch {
cancelReset = false
while (appViewModel.connectedYubiKey.value != null) {
if (cancelReset) {
logger.debug("Reset was cancelled")
continuation.resumeWithException(CancellationException())
return@launch
}
logger.debug("Waiting for YubiKey to be disconnected")
delay(300)
}
continuation.resume(Unit)
}
}
private suspend fun waitForConnection() = suspendCoroutine { continuation ->
coroutineScope.launch {
fidoViewModel.updateResetState(FidoResetState.Insert)
cancelReset = false
while (appViewModel.connectedYubiKey.value == null) {
if (cancelReset) {
logger.debug("Reset was cancelled")
continuation.resumeWithException(CancellationException())
return@launch
}
logger.debug("Waiting for YubiKey to be connected")
delay(300)
}
continuation.resume(Unit)
}
}
private suspend fun resetAfterTouch() = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.Main) {
fidoViewModel.updateResetState(FidoResetState.Touch)
logger.debug("Waiting for touch")
connectionHelper.useSessionUsb(appViewModel.connectedYubiKey.value!!) { fidoSession ->
resetCommandState = CommandState()
try {
doReset(fidoSession)
continuation.resume(Unit)
} catch (e: CtapException) {
when (e.ctapError) {
CtapException.ERR_KEEPALIVE_CANCEL -> {
logger.debug("Received ERR_KEEPALIVE_CANCEL during FIDO reset")
}
CtapException.ERR_ACTION_TIMEOUT -> {
logger.debug("Received ERR_ACTION_TIMEOUT during FIDO reset")
}
else -> {
logger.error("Received CtapException during FIDO reset: ", e)
}
}
continuation.resumeWithException(CancellationException())
} catch (e: IOException) {
// communication error, key was removed?
logger.error("IOException during FIDO reset: ", e)
// treat it as cancellation
continuation.resumeWithException(CancellationException())
} finally {
resetCommandState = null
}
}
}
}
private suspend fun resetOverUSB() {
waitForUsbDisconnect()
waitForConnection()
resetAfterTouch()
}
private suspend fun resetOverNfc() = suspendCoroutine { continuation ->
fidoViewModel.updateResetState(FidoResetState.Insert)
coroutineScope.launch {
fidoViewModel.updateResetState(FidoResetState.Touch)
try {
connectionHelper.useSessionNfc(FidoActionDescription.Reset) { fidoSession ->
doReset(fidoSession)
continuation.resume(Unit)
}
} catch (e: CancellationException) {
logger.debug("FIDO reset over NFC was cancelled")
continuation.resumeWithException(e)
} catch (e: Throwable) {
logger.error("FIDO reset over NFC failed with exception: ", e)
continuation.resumeWithException(e)
}
}
}
private fun doReset(fidoSession: YubiKitFidoSession) {
logger.debug("Calling FIDO reset")
fidoSession.reset(resetCommandState)
pinStore.setPin(null)
fidoViewModel.setSessionState(Session(fidoSession.info, true))
fidoViewModel.updateCredentials(emptyList())
}
companion object {
private val logger = LoggerFactory.getLogger(FidoResetHelper::class.java)
}
}

View File

@ -19,10 +19,9 @@ package com.yubico.authenticator.fido
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.yubico.authenticator.fido.data.Session
import com.yubico.authenticator.fido.data.FidoCredential
import com.yubico.authenticator.fido.data.FidoResetState
import com.yubico.authenticator.fido.data.Session
class FidoViewModel : ViewModel() {
private val _sessionState = MutableLiveData<Session?>()
@ -45,7 +44,7 @@ class FidoViewModel : ViewModel() {
})
}
private val _resetState = MutableLiveData<String>()
private val _resetState = MutableLiveData(FidoResetState.Remove.value)
val resetState: LiveData<String> = _resetState
fun updateResetState(resetState: FidoResetState) {

View File

@ -62,7 +62,9 @@ class _FidoStateNotifier extends FidoStateNotifier {
Stream<InteractionEvent> reset() {
final controller = StreamController<InteractionEvent>();
const resetEvents = EventChannel('android.fido.reset');
final resetSub = resetEvents.receiveBroadcastStream().listen((event) {
final resetSub =
resetEvents.receiveBroadcastStream().skip(1).listen((event) {
_log.debug('Received event: \'$event\'');
if (event is String && event.isNotEmpty) {
controller.sink.add(InteractionEvent.values
@ -70,9 +72,10 @@ class _FidoStateNotifier extends FidoStateNotifier {
}
});
controller.onCancel = () {
controller.onCancel = () async {
await _methods.invokeMethod('cancelReset');
if (!controller.isClosed) {
resetSub.cancel();
await resetSub.cancel();
}
};