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 2106df02..de80d7c5 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt @@ -37,7 +37,7 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, pri } } - private suspend fun setContext(subPageIndex: Int): String { + private fun setContext(subPageIndex: Int): String { val appContext = OperationContext.getByValue(subPageIndex) appViewModel.setAppContext(appContext) logger.debug("App context is now {}", appContext) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt index 66281d0b..243278ea 100755 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt @@ -16,12 +16,37 @@ package com.yubico.authenticator +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.yubico.yubikit.core.YubiKeyDevice /** * Provides behavior to run when a YubiKey is inserted/tapped for a specific view of the app. */ -interface AppContextManager { - suspend fun processYubiKey(device: YubiKeyDevice) - fun dispose() +abstract class AppContextManager( + private val lifecycleOwner: LifecycleOwner +) { + abstract suspend fun processYubiKey(device: YubiKeyDevice) + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onPause(owner: LifecycleOwner) { + onPause() + } + + override fun onResume(owner: LifecycleOwner) { + onResume() + } + } + + init { + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + } + + open fun dispose() { + lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) + } + + open fun onPause() {} + + open fun onResume() {} } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index 12377c73..6c7cb302 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -39,12 +39,14 @@ import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.color.DynamicColors +import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.fido.FidoManager import com.yubico.authenticator.fido.FidoViewModel import com.yubico.authenticator.logging.FlutterLog import com.yubico.authenticator.oath.AppLinkMethodChannel import com.yubico.authenticator.oath.OathManager import com.yubico.authenticator.oath.OathViewModel +import com.yubico.authenticator.oath.keystore.ClearingMemProvider import com.yubico.yubikit.android.YubiKitManager import com.yubico.yubikit.android.transport.nfc.NfcConfiguration import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable @@ -265,8 +267,22 @@ class MainActivity : FlutterFragmentActivity() { } private fun processYubiKey(device: YubiKeyDevice) { - contextManager?.let { - lifecycleScope.launch { + lifecycleScope.launch { + // verify that current context supports connection provided by the YubiKey + // if not, switch to a context which supports the connection + val supportedApps = DeviceManager.getSupportedContexts(device) + logger.debug("Connected key supports: {}", supportedApps) + if (!supportedApps.contains(viewModel.appContext.value)) { + val preferredContext = DeviceManager.getPreferredContext(supportedApps) + logger.debug( + "Current context ({}) is not supported by the key. Using preferred context {}", + viewModel.appContext.value, + preferredContext + ) + switchContext(preferredContext) + } + + contextManager?.let { try { it.processYubiKey(device) } catch (e: Throwable) { @@ -277,6 +293,7 @@ class MainActivity : FlutterFragmentActivity() { } private var contextManager: AppContextManager? = null + private lateinit var deviceManager: DeviceManager private lateinit var appContext: AppContext private lateinit var dialogManager: DialogManager private lateinit var appPreferences: AppPreferences @@ -284,13 +301,14 @@ class MainActivity : FlutterFragmentActivity() { private lateinit var flutterStreams: List private lateinit var appMethodChannel: AppMethodChannel private lateinit var appLinkMethodChannel: AppLinkMethodChannel + private lateinit var messenger: BinaryMessenger override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - val messenger = flutterEngine.dartExecutor.binaryMessenger - + messenger = flutterEngine.dartExecutor.binaryMessenger flutterLog = FlutterLog(messenger) + deviceManager = DeviceManager(this, viewModel) appContext = AppContext(messenger, this.lifecycleScope, viewModel) dialogManager = DialogManager(messenger, this.lifecycleScope) appPreferences = AppPreferences(this) @@ -307,31 +325,39 @@ class MainActivity : FlutterFragmentActivity() { ) viewModel.appContext.observe(this) { - contextManager?.dispose() - contextManager = when (it) { - OperationContext.Oath -> OathManager( - this, - messenger, - viewModel, - oathViewModel, - dialogManager, - appPreferences - ) - OperationContext.FidoPasskeys -> FidoManager( - this, - messenger, - viewModel, - fidoViewModel, - dialogManager - ) - else -> null - } + switchContext(it) viewModel.connectedYubiKey.value?.let(::processYubiKey) } } + private fun switchContext(appContext: OperationContext) { + contextManager?.dispose() + contextManager = when (appContext) { + OperationContext.Oath -> OathManager( + this, + messenger, + deviceManager, + oathViewModel, + dialogManager, + appPreferences + ) + + OperationContext.FidoPasskeys -> FidoManager( + this, + messenger, + deviceManager, + fidoViewModel, + dialogManager + ) + + else -> null + } + } + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { flutterStreams.forEach { it.close() } + contextManager?.dispose() + deviceManager.dispose() super.cleanUpFlutterEngine(flutterEngine) } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt new file mode 100644 index 00000000..a0e7c802 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt @@ -0,0 +1,173 @@ +package com.yubico.authenticator.device + +import androidx.collection.ArraySet +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import com.yubico.authenticator.MainViewModel +import com.yubico.authenticator.OperationContext +import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice +import com.yubico.yubikit.core.YubiKeyDevice +import com.yubico.yubikit.core.fido.FidoConnection +import com.yubico.yubikit.core.smartcard.SmartCardConnection +import com.yubico.yubikit.fido.ctap.Ctap2Session +import com.yubico.yubikit.oath.OathSession +import org.slf4j.LoggerFactory + +interface DeviceListener { + // a USB device is connected + fun onConnected(device: YubiKeyDevice) {} + + // a USB device is disconnected + fun onDisconnected() {} + + // the app has been paused for more than DeviceManager.NFC_DATA_CLEANUP_DELAY + fun onTimeout() {} +} + +class DeviceManager( + private val lifecycleOwner: LifecycleOwner, + private val appViewModel: MainViewModel +) { + var clearDeviceInfoOnDisconnect: Boolean = true + + private val deviceListeners = HashSet() + + fun addDeviceListener(listener: DeviceListener) { + deviceListeners.add(listener) + } + + fun removeDeviceListener(listener: DeviceListener) { + deviceListeners.remove(listener) + } + + companion object { + const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s + private val logger = LoggerFactory.getLogger(DeviceManager::class.java) + + fun getSupportedContexts(device: YubiKeyDevice) : ArraySet { + + val operationContexts = ArraySet() + + if (device.supportsConnection(SmartCardConnection::class.java)) { + // try which apps are available + device.openConnection(SmartCardConnection::class.java).use { + try { + OathSession(it) + operationContexts.add(OperationContext.Oath) + } catch (e: Throwable) { // ignored + } + + try { + Ctap2Session(it) + operationContexts.add(OperationContext.FidoPasskeys) + operationContexts.add(OperationContext.FidoFingerprints) + } catch (e: Throwable) { // ignored + } + + } + } + + if (device.supportsConnection(FidoConnection::class.java)) { + device.openConnection(FidoConnection::class.java).use { + try { + Ctap2Session(it) + operationContexts.add(OperationContext.FidoPasskeys) + operationContexts.add(OperationContext.FidoFingerprints) + } catch (e: Throwable) { // ignored + } + } + } + + logger.debug("Device supports following contexts: {}", operationContexts) + return operationContexts + } + + fun getPreferredContext(contexts: ArraySet) : OperationContext { + // custom sort + for(context in contexts) { + if (context == OperationContext.Oath) { + return context + } else if (context == OperationContext.FidoPasskeys) { + return context + } + } + + return OperationContext.Oath + } + } + + private val lifecycleObserver = object : DefaultLifecycleObserver { + private var startTimeMs: Long = -1 + + override fun onPause(owner: LifecycleOwner) { + startTimeMs = currentTimeMs + super.onPause(owner) + } + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + if (canInvoke) { + if (appViewModel.connectedYubiKey.value == null) { + // no USB YubiKey is connected, reset known data on resume + logger.debug("Removing NFC data after resume.") + if (clearDeviceInfoOnDisconnect) { + appViewModel.setDeviceInfo(null) + } + deviceListeners.forEach { listener -> + listener.onTimeout() + } + } + } + } + + private val currentTimeMs + get() = System.currentTimeMillis() + + private val canInvoke: Boolean + get() = startTimeMs != -1L && currentTimeMs - startTimeMs > NFC_DATA_CLEANUP_DELAY + } + + private val usbObserver = Observer { yubiKeyDevice -> + if (yubiKeyDevice == null) { + deviceListeners.forEach { listener -> + listener.onDisconnected() + } + if (clearDeviceInfoOnDisconnect) { + appViewModel.setDeviceInfo(null) + } + } else { + deviceListeners.forEach { listener -> + listener.onConnected(yubiKeyDevice) + } + } + } + + init { + appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver) + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + } + + fun dispose() { + lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) + appViewModel.connectedYubiKey.removeObserver(usbObserver) + } + + fun setDeviceInfo(deviceInfo: Info?) { + appViewModel.setDeviceInfo(deviceInfo) + } + + fun isUsbKeyConnected(): Boolean { + return appViewModel.connectedYubiKey.value != null + } + + suspend fun withKey(onUsb: suspend (UsbYubiKeyDevice) -> T) = + appViewModel.connectedYubiKey.value?.let { + onUsb(it) + } + + suspend fun withKey(onNfc: suspend () -> T, onUsb: suspend (UsbYubiKeyDevice) -> T) = + appViewModel.connectedYubiKey.value?.let { + onUsb(it) + } ?: onNfc() +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt index 47380d0f..641b8d75 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt @@ -20,6 +20,7 @@ import com.yubico.authenticator.DialogIcon import com.yubico.authenticator.DialogManager import com.yubico.authenticator.DialogTitle import com.yubico.authenticator.MainViewModel +import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.fido.data.YubiKitFidoSession import com.yubico.authenticator.yubikit.withConnection import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice @@ -30,7 +31,7 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.suspendCoroutine class FidoConnectionHelper( - private val appViewModel: MainViewModel, + private val deviceManager: DeviceManager, private val dialogManager: DialogManager ) { private var pendingAction: FidoAction? = null @@ -46,9 +47,9 @@ class FidoConnectionHelper( actionDescription: FidoActionDescription, action: (YubiKitFidoSession) -> T ): T { - return appViewModel.connectedYubiKey.value?.let { - useSessionUsb(it, action) - } ?: useSessionNfc(actionDescription, action) + return deviceManager.withKey( + onNfc = { useSessionNfc(actionDescription,action) }, + onUsb = { useSessionUsb(it, action) }) } suspend fun useSessionUsb( diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt index 2102e69e..b4d256a3 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt @@ -16,13 +16,12 @@ package com.yubico.authenticator.fido -import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.Observer import com.yubico.authenticator.AppContextManager import com.yubico.authenticator.DialogManager -import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.asString +import com.yubico.authenticator.device.DeviceListener +import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.device.Info import com.yubico.authenticator.device.UnknownDevice import com.yubico.authenticator.fido.data.FidoCredential @@ -63,16 +62,14 @@ import java.util.concurrent.Executors typealias FidoAction = (Result) -> Unit class FidoManager( - private val lifecycleOwner: LifecycleOwner, + lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, - private val appViewModel: MainViewModel, + private val deviceManager: DeviceManager, private val fidoViewModel: FidoViewModel, dialogManager: DialogManager, -) : AppContextManager { +) : AppContextManager(lifecycleOwner), DeviceListener { companion object { - const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s - fun getPreferredPinUvAuthProtocol(infoData: InfoData): PinUvAuthProtocol { val pinUvAuthProtocols = infoData.pinUvAuthProtocols val pinSupported = infoData.options["clientPin"] != null @@ -90,7 +87,7 @@ class FidoManager( } } - private val connectionHelper = FidoConnectionHelper(appViewModel, dialogManager) + private val connectionHelper = FidoConnectionHelper(deviceManager, dialogManager) private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @@ -102,50 +99,15 @@ class FidoManager( private val pinStore = FidoPinStore() private val resetHelper = - FidoResetHelper(appViewModel, fidoViewModel, connectionHelper, pinStore) + FidoResetHelper(deviceManager, 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) - } - - override fun onResume(owner: LifecycleOwner) { - super.onResume(owner) - if (canInvoke) { - if (appViewModel.connectedYubiKey.value == null) { - // no USB YubiKey is connected, reset known data on resume - logger.debug("Removing NFC data after resume.") - appViewModel.setDeviceInfo(null) - fidoViewModel.setSessionState(null) - } - } - } - - private val currentTimeMs - get() = System.currentTimeMillis() - - private val canInvoke: Boolean - get() = startTimeMs != -1L && currentTimeMs - startTimeMs > NFC_DATA_CLEANUP_DELAY - } - - private val usbObserver = Observer { - if (it == null) { - if (!resetHelper.inProgress) { - // only reset the view model if there is no FIDO reset in progress - appViewModel.setDeviceInfo(null) - fidoViewModel.setSessionState(null) - } - } + override fun onPause() { + // cancel any FIDO reset flow which might be in progress + resetHelper.cancelReset() } init { - appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver) + deviceManager.addDeviceListener(this) fidoChannel.setHandler(coroutineScope) { method, args -> when (method) { @@ -171,12 +133,18 @@ class FidoManager( } } - lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + if (!deviceManager.isUsbKeyConnected()) { + // for NFC connections require extra tap when switching context + if (fidoViewModel.sessionState.value == null) { + fidoViewModel.setSessionState(Session.uninitialized) + } + } + } override fun dispose() { - lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) - appViewModel.connectedYubiKey.removeObserver(usbObserver) + super.dispose() + deviceManager.removeDeviceListener(this) fidoChannel.setMethodCallHandler(null) coroutineScope.cancel() } @@ -207,7 +175,7 @@ class FidoManager( } logger.debug("Setting device info: {}", deviceInfo) - appViewModel.setDeviceInfo(deviceInfo) + deviceManager.setDeviceInfo(deviceInfo) } // Clear any cached FIDO state @@ -253,7 +221,7 @@ class FidoManager( // Update deviceInfo since the deviceId has changed val pid = (device as? UsbYubiKeyDevice)?.pid val deviceInfo = DeviceUtil.readInfo(connection, pid) - appViewModel.setDeviceInfo( + deviceManager.setDeviceInfo( Info( name = DeviceUtil.getName(deviceInfo, pid?.type), isNfc = device.transport == Transport.NFC, @@ -439,4 +407,14 @@ class FidoManager( ) ).toString() } + + override fun onDisconnected() { + if (!resetHelper.inProgress) { + fidoViewModel.setSessionState(null) + } + } + + override fun onTimeout() { + fidoViewModel.setSessionState(null) + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt index 89da770a..a4743cd5 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt @@ -19,6 +19,7 @@ package com.yubico.authenticator.fido import androidx.lifecycle.viewModelScope import com.yubico.authenticator.MainViewModel import com.yubico.authenticator.NULL +import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.fido.data.FidoResetState import com.yubico.authenticator.fido.data.Session import com.yubico.authenticator.fido.data.YubiKitFidoSession @@ -35,7 +36,7 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine class FidoResetHelper( - private val appViewModel: MainViewModel, + private val deviceManager: DeviceManager, private val fidoViewModel: FidoViewModel, private val connectionHelper: FidoConnectionHelper, private val pinStore: FidoPinStore @@ -49,9 +50,10 @@ class FidoResetHelper( suspend fun reset(): String { try { + deviceManager.clearDeviceInfoOnDisconnect = false inProgress = true fidoViewModel.updateResetState(FidoResetState.Remove) - val usb = appViewModel.connectedYubiKey.value != null + val usb = deviceManager.isUsbKeyConnected() if (usb) { resetOverUSB() } else { @@ -62,8 +64,8 @@ class FidoResetHelper( logger.debug("FIDO reset cancelled") } finally { inProgress = false - if (appViewModel.connectedYubiKey.value == null) { - appViewModel.setDeviceInfo(null) + deviceManager.clearDeviceInfoOnDisconnect = true + if (!deviceManager.isUsbKeyConnected()) { fidoViewModel.setSessionState(null) fidoViewModel.updateCredentials(emptyList()) } @@ -81,7 +83,7 @@ class FidoResetHelper( private suspend fun waitForUsbDisconnect() = suspendCoroutine { continuation -> coroutineScope.launch { cancelReset = false - while (appViewModel.connectedYubiKey.value != null) { + while (deviceManager.isUsbKeyConnected()) { if (cancelReset) { logger.debug("Reset was cancelled") continuation.resumeWithException(CancellationException()) @@ -98,7 +100,7 @@ class FidoResetHelper( coroutineScope.launch { fidoViewModel.updateResetState(FidoResetState.Insert) cancelReset = false - while (appViewModel.connectedYubiKey.value == null) { + while (!deviceManager.isUsbKeyConnected()) { if (cancelReset) { logger.debug("Reset was cancelled") continuation.resumeWithException(CancellationException()) @@ -111,45 +113,45 @@ class FidoResetHelper( } } - 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") + deviceManager.withKey { usbYubiKeyDevice -> + connectionHelper.useSessionUsb(usbYubiKeyDevice) { 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) + } } - 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 } - - 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() @@ -186,5 +188,4 @@ class FidoResetHelper( companion object { private val logger = LoggerFactory.getLogger(FidoResetHelper::class.java) } - } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt index 8e93385d..98964aab 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt @@ -24,7 +24,7 @@ import com.yubico.authenticator.fido.data.FidoResetState import com.yubico.authenticator.fido.data.Session class FidoViewModel : ViewModel() { - private val _sessionState = MutableLiveData() + private val _sessionState = MutableLiveData(null) val sessionState: LiveData = _sessionState fun setSessionState(sessionState: Session?) { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt index bea55486..37150c8d 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt @@ -87,10 +87,30 @@ data class SessionInfo( data class Session( @SerialName("info") val info: SessionInfo, - @SerialName("unlocked") - val unlocked: Boolean + val unlocked: Boolean, + val initialized: Boolean ) { constructor(infoData: InfoData, unlocked: Boolean) : this( - SessionInfo(infoData), unlocked + SessionInfo(infoData), unlocked, true ) + + companion object { + val uninitialized = Session( + SessionInfo( + Options( + clientPin = false, + credMgmt = false, + credentialMgmtPreview = false, + bioEnroll = null, + alwaysUv = false + ), + aaguid = ByteArray(0), + minPinLength = 0, + forcePinChange = false + ), + unlocked = false, + initialized = false + ) + } + } \ 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 index 4d53d359..a0df7302 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -19,12 +19,13 @@ package com.yubico.authenticator.oath import android.annotation.TargetApi import android.content.Context import android.os.Build -import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer import com.yubico.authenticator.* import com.yubico.authenticator.device.Capabilities +import com.yubico.authenticator.device.DeviceListener +import com.yubico.authenticator.device.DeviceManager import com.yubico.authenticator.device.Info import com.yubico.authenticator.device.UnknownDevice import com.yubico.authenticator.oath.data.Code @@ -73,13 +74,14 @@ typealias OathAction = (Result) -> Unit class OathManager( private val lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, - private val appViewModel: MainViewModel, + private val deviceManager: DeviceManager, private val oathViewModel: OathViewModel, private val dialogManager: DialogManager, private val appPreferences: AppPreferences, -) : AppContextManager { +) : AppContextManager(lifecycleOwner), DeviceListener { + companion object { - const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s + private val memoryKeyProvider = ClearingMemProvider() } private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() @@ -87,7 +89,6 @@ class OathManager( private val oathChannel = MethodChannel(messenger, "android.oath.methods") - private val memoryKeyProvider = ClearingMemProvider() private val keyManager by lazy { KeyManager( compatUtil.from(Build.VERSION_CODES.M) { @@ -108,60 +109,23 @@ class OathManager( private var refreshJob: Job? = null private var addToAny = false - // provides actions for lifecycle events - private val lifecycleObserver = object : DefaultLifecycleObserver { - - private var startTimeMs: Long = -1 - - override fun onPause(owner: LifecycleOwner) { - startTimeMs = currentTimeMs - - // cancel any pending actions, except for addToAny - if (!addToAny) { - pendingAction?.let { - logger.debug("Cancelling pending action/closing nfc dialog.") - it.invoke(Result.failure(CancellationException())) - coroutineScope.launch { - dialogManager.closeDialog() - } - pendingAction = null + override fun onPause() { + // cancel any pending actions, except for addToAny + if (!addToAny) { + pendingAction?.let { + logger.debug("Cancelling pending action/closing nfc dialog.") + it.invoke(Result.failure(CancellationException())) + coroutineScope.launch { + dialogManager.closeDialog() } + pendingAction = null } - - super.onPause(owner) - } - - override fun onResume(owner: LifecycleOwner) { - super.onResume(owner) - if (canInvoke) { - if (appViewModel.connectedYubiKey.value == null) { - // no USB YubiKey is connected, reset known data on resume - logger.debug("Removing NFC data after resume.") - appViewModel.setDeviceInfo(null) - oathViewModel.setSessionState(null) - } - } - } - - - private val currentTimeMs - get() = System.currentTimeMillis() - - private val canInvoke: Boolean - get() = startTimeMs != -1L && currentTimeMs - startTimeMs > NFC_DATA_CLEANUP_DELAY - } - - private val usbObserver = Observer { - refreshJob?.cancel() - if (it == null) { - appViewModel.setDeviceInfo(null) - oathViewModel.setSessionState(null) } } private val credentialObserver = Observer?> { codes -> refreshJob?.cancel() - if (codes != null && appViewModel.connectedYubiKey.value != null) { + if (codes != null && deviceManager.isUsbKeyConnected()) { val expirations = codes .filter { it.credential.codeType == CodeType.TOTP && !it.credential.touchRequired } .mapNotNull { it.code?.validTo } @@ -190,7 +154,7 @@ class OathManager( } init { - appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver) + deviceManager.addDeviceListener(this) oathViewModel.credentials.observe(lifecycleOwner, credentialObserver) // OATH methods callable from Flutter: @@ -237,12 +201,17 @@ class OathManager( } } - lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + if (!deviceManager.isUsbKeyConnected()) { + // for NFC connections require extra tap when switching context + if (oathViewModel.sessionState.value == null) { + oathViewModel.setSessionState(Session.uninitialized) + } + } } override fun dispose() { - lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) - appViewModel.connectedYubiKey.removeObserver(usbObserver) + super.dispose() + deviceManager.removeDeviceListener(this) oathViewModel.credentials.removeObserver(credentialObserver) oathChannel.setMethodCallHandler(null) coroutineScope.cancel() @@ -307,7 +276,7 @@ class OathManager( logger.error("Failed to recognize this OATH device.") // we know this is NFC device and it supports OATH val oathCapabilities = Capabilities(nfc = 0x20) - appViewModel.setDeviceInfo( + deviceManager.setDeviceInfo( UnknownDevice.copy( config = UnknownDevice.config.copy(enabledCapabilities = oathCapabilities), name = "Unknown OATH device", @@ -322,7 +291,7 @@ class OathManager( // Update deviceInfo since the deviceId has changed val pid = (device as? UsbYubiKeyDevice)?.pid val deviceInfo = DeviceUtil.readInfo(connection, pid) - appViewModel.setDeviceInfo( + deviceManager.setDeviceInfo( Info( name = DeviceUtil.getName(deviceInfo, pid?.type), isNfc = device.transport == Transport.NFC, @@ -330,7 +299,6 @@ class OathManager( deviceInfo = deviceInfo ) ) - } } logger.debug( @@ -351,7 +319,7 @@ class OathManager( } logger.debug("Setting device info: {}", deviceInfo) - appViewModel.setDeviceInfo(deviceInfo) + deviceManager.setDeviceInfo(deviceInfo) } // Clear any cached OATH state @@ -450,7 +418,7 @@ class OathManager( oathViewModel.setSessionState(Session(it, remembered)) // fetch credentials after unlocking only if the YubiKey is connected over USB - if ( appViewModel.connectedYubiKey.value != null) { + if (deviceManager.isUsbKeyConnected()) { oathViewModel.updateCredentials(calculateOathCodes(it)) } } @@ -495,7 +463,7 @@ class OathManager( throw Exception("Unset password failed") } - private suspend fun forgetPassword(): String { + private fun forgetPassword(): String { keyManager.clearAll() logger.debug("Cleared all keys.") oathViewModel.sessionState.value?.let { @@ -563,7 +531,7 @@ class OathManager( } ?: emptyMap()) } - appViewModel.connectedYubiKey.value?.let { usbYubiKeyDevice -> + deviceManager.withKey { usbYubiKeyDevice -> try { useOathSessionUsb(usbYubiKeyDevice) { session -> try { @@ -688,7 +656,7 @@ class OathManager( private fun calculateOathCodes(session: YubiKitOathSession): Map { - val isUsbKey = appViewModel.connectedYubiKey.value != null + val isUsbKey = deviceManager.isUsbKeyConnected() var timestamp = System.currentTimeMillis() if (!isUsbKey) { // NFC, need to pad timer to avoid immediate expiration @@ -717,9 +685,10 @@ class OathManager( // callers can decide whether the session should be unlocked first unlockOnConnect.set(unlock) - return appViewModel.connectedYubiKey.value?.let { - useOathSessionUsb(it, action) - } ?: useOathSessionNfc(oathActionDescription, action) + return deviceManager.withKey( + onUsb = { useOathSessionUsb(it, action) }, + onNfc = { useOathSessionNfc(oathActionDescription, action) } + ) } private suspend fun useOathSessionUsb( @@ -775,5 +744,16 @@ class OathManager( (credential != null) && credential.id.asString() == credentialId } ?: throw Exception("Failed to find account") + override fun onConnected(device: YubiKeyDevice) { + refreshJob?.cancel() + } + override fun onDisconnected() { + refreshJob?.cancel() + oathViewModel.setSessionState(null) + } + + override fun onTimeout() { + oathViewModel.setSessionState(null) + } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt index 905e04ea..eaff2554 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt @@ -34,7 +34,8 @@ data class Session( @SerialName("remembered") val isRemembered: Boolean, @SerialName("locked") - val isLocked: Boolean + val isLocked: Boolean, + val initialized: Boolean ) { @SerialName("keystore") @Suppress("unused") @@ -50,6 +51,18 @@ data class Session( ), oathSession.isAccessKeySet, isRemembered, - oathSession.isLocked + oathSession.isLocked, + initialized = true ) + + companion object { + val uninitialized = Session( + deviceId = "", + version = Version(0, 0, 0), + isAccessKeySet = false, + isRemembered = false, + isLocked = false, + initialized = false + ) + } } \ No newline at end of file diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt index c367bc07..22b23889 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt @@ -44,7 +44,8 @@ class ModelTest { Version(1, 2, 3), isAccessKeySet = false, isRemembered = false, - isLocked = false + isLocked = false, + initialized = true ) ) } diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt index 0c833721..6fde4bcf 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt @@ -40,7 +40,8 @@ class SerializationTest { Version(1, 2, 3), isAccessKeySet = false, isRemembered = false, - isLocked = false + isLocked = false, + initialized = true ) @Test diff --git a/lib/android/init.dart b/lib/android/init.dart index 5d81279b..c10a6288 100644 --- a/lib/android/init.dart +++ b/lib/android/init.dart @@ -70,8 +70,15 @@ Future initialize() async { oathStateProvider.overrideWithProvider(androidOathStateProvider.call), credentialListProvider .overrideWithProvider(androidCredentialListProvider.call), - currentAppProvider.overrideWith( - (ref) => AndroidSubPageNotifier(ref.watch(supportedAppsProvider))), + currentAppProvider.overrideWith((ref) { + final notifier = + AndroidSubPageNotifier(ref.watch(supportedAppsProvider)); + ref.listen>(currentDeviceDataProvider, + (_, data) { + notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); + }, fireImmediately: true); + return notifier; + }), managementStateProvider.overrideWithProvider(androidManagementState.call), currentDeviceProvider.overrideWith( () => AndroidCurrentDeviceNotifier(), diff --git a/lib/android/state.dart b/lib/android/state.dart index f9d0e537..82baa653 100644 --- a/lib/android/state.dart +++ b/lib/android/state.dart @@ -104,6 +104,12 @@ class AndroidSubPageNotifier extends CurrentAppNotifier { void _handleSubPage(Application subPage) async { await _contextChannel.invokeMethod('setContext', {'index': subPage.index}); } + + @override + void notifyDeviceChanged(YubiKeyData? data) { + super.notifyDeviceChanged(data); + _handleSubPage(state); + } } class AndroidAttachedDevicesNotifier extends AttachedDevicesNotifier { diff --git a/lib/app/state.dart b/lib/app/state.dart index affde3cd..3a0cebb6 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -209,7 +209,7 @@ final currentAppProvider = StateNotifierProvider((ref) { final notifier = CurrentAppNotifier(ref.watch(supportedAppsProvider)); ref.listen>(currentDeviceDataProvider, (_, data) { - notifier._notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); + notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); }, fireImmediately: true); return notifier; }); @@ -223,7 +223,7 @@ class CurrentAppNotifier extends StateNotifier { state = app; } - void _notifyDeviceChanged(YubiKeyData? data) { + void notifyDeviceChanged(YubiKeyData? data) { if (data == null || state.getAvailability(data) != Availability.unsupported) { // Keep current app diff --git a/lib/desktop/models.freezed.dart b/lib/desktop/models.freezed.dart index 6d8a20e2..147e5859 100644 --- a/lib/desktop/models.freezed.dart +++ b/lib/desktop/models.freezed.dart @@ -179,7 +179,7 @@ class _$SuccessImpl implements Success { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$SuccessImpl && @@ -358,7 +358,7 @@ class _$SignalImpl implements Signal { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$SignalImpl && @@ -547,7 +547,7 @@ class _$RpcErrorImpl implements RpcError { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$RpcErrorImpl && @@ -773,7 +773,7 @@ class _$RpcStateImpl implements _RpcState { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$RpcStateImpl && diff --git a/lib/fido/models.dart b/lib/fido/models.dart index 79b4d1cf..3f3b2577 100755 --- a/lib/fido/models.dart +++ b/lib/fido/models.dart @@ -27,7 +27,8 @@ class FidoState with _$FidoState { factory FidoState( {required Map info, - required bool unlocked}) = _FidoState; + required bool unlocked, + @Default(true) bool initialized}) = _FidoState; factory FidoState.fromJson(Map json) => _$FidoStateFromJson(json); diff --git a/lib/fido/models.freezed.dart b/lib/fido/models.freezed.dart index b92fbd38..f82ed883 100644 --- a/lib/fido/models.freezed.dart +++ b/lib/fido/models.freezed.dart @@ -22,6 +22,7 @@ FidoState _$FidoStateFromJson(Map json) { mixin _$FidoState { Map get info => throw _privateConstructorUsedError; bool get unlocked => throw _privateConstructorUsedError; + bool get initialized => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -34,7 +35,7 @@ abstract class $FidoStateCopyWith<$Res> { factory $FidoStateCopyWith(FidoState value, $Res Function(FidoState) then) = _$FidoStateCopyWithImpl<$Res, FidoState>; @useResult - $Res call({Map info, bool unlocked}); + $Res call({Map info, bool unlocked, bool initialized}); } /// @nodoc @@ -52,6 +53,7 @@ class _$FidoStateCopyWithImpl<$Res, $Val extends FidoState> $Res call({ Object? info = null, Object? unlocked = null, + Object? initialized = null, }) { return _then(_value.copyWith( info: null == info @@ -62,6 +64,10 @@ class _$FidoStateCopyWithImpl<$Res, $Val extends FidoState> ? _value.unlocked : unlocked // ignore: cast_nullable_to_non_nullable as bool, + initialized: null == initialized + ? _value.initialized + : initialized // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -74,7 +80,7 @@ abstract class _$$FidoStateImplCopyWith<$Res> __$$FidoStateImplCopyWithImpl<$Res>; @override @useResult - $Res call({Map info, bool unlocked}); + $Res call({Map info, bool unlocked, bool initialized}); } /// @nodoc @@ -90,6 +96,7 @@ class __$$FidoStateImplCopyWithImpl<$Res> $Res call({ Object? info = null, Object? unlocked = null, + Object? initialized = null, }) { return _then(_$FidoStateImpl( info: null == info @@ -100,6 +107,10 @@ class __$$FidoStateImplCopyWithImpl<$Res> ? _value.unlocked : unlocked // ignore: cast_nullable_to_non_nullable as bool, + initialized: null == initialized + ? _value.initialized + : initialized // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -108,7 +119,9 @@ class __$$FidoStateImplCopyWithImpl<$Res> @JsonSerializable() class _$FidoStateImpl extends _FidoState { _$FidoStateImpl( - {required final Map info, required this.unlocked}) + {required final Map info, + required this.unlocked, + this.initialized = true}) : _info = info, super._(); @@ -125,26 +138,31 @@ class _$FidoStateImpl extends _FidoState { @override final bool unlocked; + @override + @JsonKey() + final bool initialized; @override String toString() { - return 'FidoState(info: $info, unlocked: $unlocked)'; + return 'FidoState(info: $info, unlocked: $unlocked, initialized: $initialized)'; } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$FidoStateImpl && const DeepCollectionEquality().equals(other._info, _info) && (identical(other.unlocked, unlocked) || - other.unlocked == unlocked)); + other.unlocked == unlocked) && + (identical(other.initialized, initialized) || + other.initialized == initialized)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash( - runtimeType, const DeepCollectionEquality().hash(_info), unlocked); + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_info), unlocked, initialized); @JsonKey(ignore: true) @override @@ -163,7 +181,8 @@ class _$FidoStateImpl extends _FidoState { abstract class _FidoState extends FidoState { factory _FidoState( {required final Map info, - required final bool unlocked}) = _$FidoStateImpl; + required final bool unlocked, + final bool initialized}) = _$FidoStateImpl; _FidoState._() : super._(); factory _FidoState.fromJson(Map json) = @@ -174,6 +193,8 @@ abstract class _FidoState extends FidoState { @override bool get unlocked; @override + bool get initialized; + @override @JsonKey(ignore: true) _$$FidoStateImplCopyWith<_$FidoStateImpl> get copyWith => throw _privateConstructorUsedError; @@ -265,7 +286,7 @@ class _$PinSuccessImpl implements _PinSuccess { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$PinSuccessImpl); } @@ -392,7 +413,7 @@ class _$PinFailureImpl implements _PinFailure { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$PinFailureImpl && @@ -594,7 +615,7 @@ class _$FingerprintImpl extends _Fingerprint { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$FingerprintImpl && @@ -750,7 +771,7 @@ class _$EventCaptureImpl implements _EventCapture { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$EventCaptureImpl && @@ -900,7 +921,7 @@ class _$EventCompleteImpl implements _EventComplete { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$EventCompleteImpl && @@ -1040,7 +1061,7 @@ class _$EventErrorImpl implements _EventError { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$EventErrorImpl && @@ -1274,7 +1295,7 @@ class _$FidoCredentialImpl implements _FidoCredential { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$FidoCredentialImpl && diff --git a/lib/fido/models.g.dart b/lib/fido/models.g.dart index 42ed350b..aa2f2b9e 100644 --- a/lib/fido/models.g.dart +++ b/lib/fido/models.g.dart @@ -10,12 +10,14 @@ _$FidoStateImpl _$$FidoStateImplFromJson(Map json) => _$FidoStateImpl( info: json['info'] as Map, unlocked: json['unlocked'] as bool, + initialized: json['initialized'] as bool? ?? true, ); Map _$$FidoStateImplToJson(_$FidoStateImpl instance) => { 'info': instance.info, 'unlocked': instance.unlocked, + 'initialized': instance.initialized, }; _$FingerprintImpl _$$FingerprintImplFromJson(Map json) => diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index 46c858b8..93af7943 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -43,6 +43,7 @@ import 'pin_entry_form.dart'; class PasskeysScreen extends ConsumerWidget { final YubiKeyData deviceData; + const PasskeysScreen(this.deviceData, {super.key}); @override @@ -73,13 +74,30 @@ class PasskeysScreen extends ConsumerWidget { ); }, data: (fidoState) { - return fidoState.unlocked - ? _FidoUnlockedPage(deviceData.node, fidoState) - : _FidoLockedPage(deviceData.node, fidoState); + return fidoState.initialized + ? fidoState.unlocked + ? _FidoUnlockedPage(deviceData.node, fidoState) + : _FidoLockedPage(deviceData.node, fidoState) + : const _FidoInsertTapPage(); }); } } +class _FidoInsertTapPage extends ConsumerWidget { + const _FidoInsertTapPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + return MessagePage( + title: l10n.s_passkeys, + centered: false, + capabilities: const [Capability.fido2], + header: l10n.l_insert_or_tap_yk, + ); + } +} + class _FidoLockedPage extends ConsumerWidget { final DeviceNode node; final FidoState state; diff --git a/lib/oath/models.dart b/lib/oath/models.dart index 3707fe93..05d81589 100755 --- a/lib/oath/models.dart +++ b/lib/oath/models.dart @@ -110,6 +110,7 @@ class OathState with _$OathState { required bool remembered, required bool locked, required KeystoreState keystore, + @Default(true) bool initialized, }) = _OathState; factory OathState.fromJson(Map json) => diff --git a/lib/oath/models.freezed.dart b/lib/oath/models.freezed.dart index 4c225dec..424ba085 100644 --- a/lib/oath/models.freezed.dart +++ b/lib/oath/models.freezed.dart @@ -639,6 +639,7 @@ mixin _$OathState { bool get remembered => throw _privateConstructorUsedError; bool get locked => throw _privateConstructorUsedError; KeystoreState get keystore => throw _privateConstructorUsedError; + bool get initialized => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -657,7 +658,8 @@ abstract class $OathStateCopyWith<$Res> { bool hasKey, bool remembered, bool locked, - KeystoreState keystore}); + KeystoreState keystore, + bool initialized}); $VersionCopyWith<$Res> get version; } @@ -681,6 +683,7 @@ class _$OathStateCopyWithImpl<$Res, $Val extends OathState> Object? remembered = null, Object? locked = null, Object? keystore = null, + Object? initialized = null, }) { return _then(_value.copyWith( deviceId: null == deviceId @@ -707,6 +710,10 @@ class _$OathStateCopyWithImpl<$Res, $Val extends OathState> ? _value.keystore : keystore // ignore: cast_nullable_to_non_nullable as KeystoreState, + initialized: null == initialized + ? _value.initialized + : initialized // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } @@ -733,7 +740,8 @@ abstract class _$$OathStateImplCopyWith<$Res> bool hasKey, bool remembered, bool locked, - KeystoreState keystore}); + KeystoreState keystore, + bool initialized}); @override $VersionCopyWith<$Res> get version; @@ -756,6 +764,7 @@ class __$$OathStateImplCopyWithImpl<$Res> Object? remembered = null, Object? locked = null, Object? keystore = null, + Object? initialized = null, }) { return _then(_$OathStateImpl( null == deviceId @@ -782,6 +791,10 @@ class __$$OathStateImplCopyWithImpl<$Res> ? _value.keystore : keystore // ignore: cast_nullable_to_non_nullable as KeystoreState, + initialized: null == initialized + ? _value.initialized + : initialized // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -793,7 +806,8 @@ class _$OathStateImpl implements _OathState { {required this.hasKey, required this.remembered, required this.locked, - required this.keystore}); + required this.keystore, + this.initialized = true}); factory _$OathStateImpl.fromJson(Map json) => _$$OathStateImplFromJson(json); @@ -810,10 +824,13 @@ class _$OathStateImpl implements _OathState { final bool locked; @override final KeystoreState keystore; + @override + @JsonKey() + final bool initialized; @override String toString() { - return 'OathState(deviceId: $deviceId, version: $version, hasKey: $hasKey, remembered: $remembered, locked: $locked, keystore: $keystore)'; + return 'OathState(deviceId: $deviceId, version: $version, hasKey: $hasKey, remembered: $remembered, locked: $locked, keystore: $keystore, initialized: $initialized)'; } @override @@ -829,13 +846,15 @@ class _$OathStateImpl implements _OathState { other.remembered == remembered) && (identical(other.locked, locked) || other.locked == locked) && (identical(other.keystore, keystore) || - other.keystore == keystore)); + other.keystore == keystore) && + (identical(other.initialized, initialized) || + other.initialized == initialized)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash( - runtimeType, deviceId, version, hasKey, remembered, locked, keystore); + int get hashCode => Object.hash(runtimeType, deviceId, version, hasKey, + remembered, locked, keystore, initialized); @JsonKey(ignore: true) @override @@ -856,7 +875,8 @@ abstract class _OathState implements OathState { {required final bool hasKey, required final bool remembered, required final bool locked, - required final KeystoreState keystore}) = _$OathStateImpl; + required final KeystoreState keystore, + final bool initialized}) = _$OathStateImpl; factory _OathState.fromJson(Map json) = _$OathStateImpl.fromJson; @@ -874,6 +894,8 @@ abstract class _OathState implements OathState { @override KeystoreState get keystore; @override + bool get initialized; + @override @JsonKey(ignore: true) _$$OathStateImplCopyWith<_$OathStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/oath/models.g.dart b/lib/oath/models.g.dart index e4f577cf..43014af6 100644 --- a/lib/oath/models.g.dart +++ b/lib/oath/models.g.dart @@ -70,6 +70,7 @@ _$OathStateImpl _$$OathStateImplFromJson(Map json) => remembered: json['remembered'] as bool, locked: json['locked'] as bool, keystore: $enumDecode(_$KeystoreStateEnumMap, json['keystore']), + initialized: json['initialized'] as bool? ?? true, ); Map _$$OathStateImplToJson(_$OathStateImpl instance) => @@ -80,6 +81,7 @@ Map _$$OathStateImplToJson(_$OathStateImpl instance) => 'remembered': instance.remembered, 'locked': instance.locked, 'keystore': _$KeystoreStateEnumMap[instance.keystore]!, + 'initialized': instance.initialized, }; const _$KeystoreStateEnumMap = { diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 4f19cc94..9b5c75f7 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -65,13 +65,30 @@ class OathScreen extends ConsumerWidget { error: (error, _) => AppFailurePage( cause: error, ), - data: (oathState) => oathState.locked - ? _LockedView(devicePath, oathState) - : _UnlockedView(devicePath, oathState), + data: (oathState) => oathState.initialized + ? oathState.locked + ? _LockedView(devicePath, oathState) + : _UnlockedView(devicePath, oathState) + : const _InsertTapView(), ); } } +class _InsertTapView extends ConsumerWidget { + const _InsertTapView(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + return MessagePage( + title: AppLocalizations.of(context)!.s_accounts, + centered: false, + capabilities: const [Capability.oath], + header: l10n.l_insert_or_tap_yk, + ); + } +} + class _LockedView extends ConsumerWidget { final DevicePath devicePath; final OathState oathState; diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index a9d58c8e..a01ec9d7 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -28,7 +28,6 @@ import '../state.dart'; class PinDialog extends ConsumerStatefulWidget { final DevicePath devicePath; - const PinDialog(this.devicePath, {super.key}); @override