switch YubiKey applets

This commit is contained in:
Adam Velebil 2024-02-01 16:53:17 +01:00
parent 5a6e87028d
commit b74cbe3bcb
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
26 changed files with 549 additions and 234 deletions

View File

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

View File

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

View File

@ -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 {
// 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<Closeable>
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) {
switchContext(it)
viewModel.connectedYubiKey.value?.let(::processYubiKey)
}
}
private fun switchContext(appContext: OperationContext) {
contextManager?.dispose()
contextManager = when (it) {
contextManager = when (appContext) {
OperationContext.Oath -> OathManager(
this,
messenger,
viewModel,
deviceManager,
oathViewModel,
dialogManager,
appPreferences
)
OperationContext.FidoPasskeys -> FidoManager(
this,
messenger,
viewModel,
deviceManager,
fidoViewModel,
dialogManager
)
else -> null
}
viewModel.connectedYubiKey.value?.let(::processYubiKey)
}
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
flutterStreams.forEach { it.close() }
contextManager?.dispose()
deviceManager.dispose()
super.cleanUpFlutterEngine(flutterEngine)
}

View File

@ -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<DeviceListener>()
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<OperationContext> {
val operationContexts = ArraySet<OperationContext>()
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>) : 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<UsbYubiKeyDevice?> { 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 <T> withKey(onUsb: suspend (UsbYubiKeyDevice) -> T) =
appViewModel.connectedYubiKey.value?.let {
onUsb(it)
}
suspend fun <T> withKey(onNfc: suspend () -> T, onUsb: suspend (UsbYubiKeyDevice) -> T) =
appViewModel.connectedYubiKey.value?.let {
onUsb(it)
} ?: onNfc()
}

View File

@ -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 <T> useSessionUsb(

View File

@ -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<YubiKitFidoSession, Exception>) -> 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) {
override fun onPause() {
// 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<UsbYubiKeyDevice?> {
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)
}
}
}
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)
}
}

View File

@ -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,12 +113,12 @@ 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 ->
deviceManager.withKey { usbYubiKeyDevice ->
connectionHelper.useSessionUsb(usbYubiKeyDevice) { fidoSession ->
resetCommandState = CommandState()
try {
doReset(fidoSession)
@ -148,7 +150,7 @@ class FidoResetHelper(
}
}
}
}
private suspend fun resetOverUSB() {
waitForUsbDisconnect()
@ -186,5 +188,4 @@ class FidoResetHelper(
companion object {
private val logger = LoggerFactory.getLogger(FidoResetHelper::class.java)
}
}

View File

@ -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<Session?>()
private val _sessionState = MutableLiveData<Session?>(null)
val sessionState: LiveData<Session?> = _sessionState
fun setSessionState(sessionState: Session?) {

View File

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

View File

@ -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<YubiKitOathSession, Exception>) -> 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,14 +109,7 @@ 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
override fun onPause() {
// cancel any pending actions, except for addToAny
if (!addToAny) {
pendingAction?.let {
@ -127,41 +121,11 @@ class OathManager(
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<UsbYubiKeyDevice?> {
refreshJob?.cancel()
if (it == null) {
appViewModel.setDeviceInfo(null)
oathViewModel.setSessionState(null)
}
}
private val credentialObserver = Observer<List<CredentialWithCode>?> { 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<Credential, Code?> {
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 <T> 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)
}
}

View File

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

View File

@ -44,7 +44,8 @@ class ModelTest {
Version(1, 2, 3),
isAccessKeySet = false,
isRemembered = false,
isLocked = false
isLocked = false,
initialized = true
)
)
}

View File

@ -40,7 +40,8 @@ class SerializationTest {
Version(1, 2, 3),
isAccessKeySet = false,
isRemembered = false,
isLocked = false
isLocked = false,
initialized = true
)
@Test

View File

@ -70,8 +70,15 @@ Future<Widget> 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<AsyncValue<YubiKeyData>>(currentDeviceDataProvider,
(_, data) {
notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data)));
}, fireImmediately: true);
return notifier;
}),
managementStateProvider.overrideWithProvider(androidManagementState.call),
currentDeviceProvider.overrideWith(
() => AndroidCurrentDeviceNotifier(),

View File

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

View File

@ -209,7 +209,7 @@ final currentAppProvider =
StateNotifierProvider<CurrentAppNotifier, Application>((ref) {
final notifier = CurrentAppNotifier(ref.watch(supportedAppsProvider));
ref.listen<AsyncValue<YubiKeyData>>(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<Application> {
state = app;
}
void _notifyDeviceChanged(YubiKeyData? data) {
void notifyDeviceChanged(YubiKeyData? data) {
if (data == null ||
state.getAvailability(data) != Availability.unsupported) {
// Keep current app

View File

@ -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 &&

View File

@ -27,7 +27,8 @@ class FidoState with _$FidoState {
factory FidoState(
{required Map<String, dynamic> info,
required bool unlocked}) = _FidoState;
required bool unlocked,
@Default(true) bool initialized}) = _FidoState;
factory FidoState.fromJson(Map<String, dynamic> json) =>
_$FidoStateFromJson(json);

View File

@ -22,6 +22,7 @@ FidoState _$FidoStateFromJson(Map<String, dynamic> json) {
mixin _$FidoState {
Map<String, dynamic> get info => throw _privateConstructorUsedError;
bool get unlocked => throw _privateConstructorUsedError;
bool get initialized => throw _privateConstructorUsedError;
Map<String, dynamic> 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<String, dynamic> info, bool unlocked});
$Res call({Map<String, dynamic> 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<String, dynamic> info, bool unlocked});
$Res call({Map<String, dynamic> 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<String, dynamic> info, required this.unlocked})
{required final Map<String, dynamic> 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<String, dynamic> info,
required final bool unlocked}) = _$FidoStateImpl;
required final bool unlocked,
final bool initialized}) = _$FidoStateImpl;
_FidoState._() : super._();
factory _FidoState.fromJson(Map<String, dynamic> 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 &&

View File

@ -10,12 +10,14 @@ _$FidoStateImpl _$$FidoStateImplFromJson(Map<String, dynamic> json) =>
_$FidoStateImpl(
info: json['info'] as Map<String, dynamic>,
unlocked: json['unlocked'] as bool,
initialized: json['initialized'] as bool? ?? true,
);
Map<String, dynamic> _$$FidoStateImplToJson(_$FidoStateImpl instance) =>
<String, dynamic>{
'info': instance.info,
'unlocked': instance.unlocked,
'initialized': instance.initialized,
};
_$FingerprintImpl _$$FingerprintImplFromJson(Map<String, dynamic> json) =>

View File

@ -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
return fidoState.initialized
? fidoState.unlocked
? _FidoUnlockedPage(deviceData.node, fidoState)
: _FidoLockedPage(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;

View File

@ -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<String, dynamic> json) =>

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;

View File

@ -70,6 +70,7 @@ _$OathStateImpl _$$OathStateImplFromJson(Map<String, dynamic> json) =>
remembered: json['remembered'] as bool,
locked: json['locked'] as bool,
keystore: $enumDecode(_$KeystoreStateEnumMap, json['keystore']),
initialized: json['initialized'] as bool? ?? true,
);
Map<String, dynamic> _$$OathStateImplToJson(_$OathStateImpl instance) =>
@ -80,6 +81,7 @@ Map<String, dynamic> _$$OathStateImplToJson(_$OathStateImpl instance) =>
'remembered': instance.remembered,
'locked': instance.locked,
'keystore': _$KeystoreStateEnumMap[instance.keystore]!,
'initialized': instance.initialized,
};
const _$KeystoreStateEnumMap = {

View File

@ -65,9 +65,26 @@ class OathScreen extends ConsumerWidget {
error: (error, _) => AppFailurePage(
cause: error,
),
data: (oathState) => oathState.locked
data: (oathState) => oathState.initialized
? oathState.locked
? _LockedView(devicePath, oathState)
: _UnlockedView(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,
);
}
}

View File

@ -28,7 +28,6 @@ import '../state.dart';
class PinDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
const PinDialog(this.devicePath, {super.key});
@override