mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
FIDO unlock & setPin
This commit is contained in:
parent
c2624592cd
commit
7254e8ef10
@ -99,7 +99,6 @@ dependencies {
|
||||
api "com.yubico.yubikit:fido:$project.yubiKitVersion"
|
||||
api "com.yubico.yubikit:support:$project.yubiKitVersion"
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2'
|
||||
|
||||
// Lifecycle
|
||||
@ -109,7 +108,7 @@ dependencies {
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
|
||||
implementation 'com.google.android.material:material:1.10.0'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
|
||||
implementation 'com.github.tony19:logback-android:3.0.0'
|
||||
|
||||
|
@ -39,6 +39,8 @@ 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.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
|
||||
@ -62,6 +64,7 @@ import java.util.concurrent.Executors
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private val oathViewModel: OathViewModel by viewModels()
|
||||
private val fidoViewModel: FidoViewModel by viewModels()
|
||||
|
||||
private val nfcConfiguration = NfcConfiguration()
|
||||
|
||||
@ -298,6 +301,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"),
|
||||
oathViewModel.sessionState.streamTo(this, messenger, "android.oath.sessionState"),
|
||||
oathViewModel.credentials.streamTo(this, messenger, "android.oath.credentials"),
|
||||
fidoViewModel.sessionState.streamTo(this, messenger, "android.fido.sessionState"),
|
||||
)
|
||||
|
||||
viewModel.appContext.observe(this) {
|
||||
@ -311,6 +315,14 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
dialogManager,
|
||||
appPreferences
|
||||
)
|
||||
OperationContext.Fido -> FidoManager(
|
||||
this,
|
||||
messenger,
|
||||
viewModel,
|
||||
fidoViewModel,
|
||||
dialogManager,
|
||||
appPreferences
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
viewModel.connectedYubiKey.value?.let(::processYubiKey)
|
||||
|
@ -23,26 +23,33 @@ import com.yubico.authenticator.device.Info
|
||||
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
||||
|
||||
enum class OperationContext(val value: Int) {
|
||||
Oath(0), Yubikey(1), Invalid(-1);
|
||||
Oath(0),
|
||||
Fido(1),
|
||||
YubiOtp(2),
|
||||
Piv(3),
|
||||
OpenPgp(4),
|
||||
HsmAuth(5),
|
||||
Management(6),
|
||||
Invalid(-1);
|
||||
|
||||
companion object {
|
||||
fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: Invalid
|
||||
fun getByValue(value: Int) = entries.firstOrNull { it.value == value } ?: Invalid
|
||||
}
|
||||
}
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
private var _appContext = MutableLiveData(OperationContext.Oath)
|
||||
private var _appContext = MutableLiveData(OperationContext.Fido)
|
||||
val appContext: LiveData<OperationContext> = _appContext
|
||||
fun setAppContext(appContext: OperationContext) {
|
||||
// Don't reset the context unless it actually changes
|
||||
if(appContext != _appContext.value) {
|
||||
if (appContext != _appContext.value) {
|
||||
_appContext.postValue(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
private val _connectedYubiKey = MutableLiveData<UsbYubiKeyDevice?>()
|
||||
val connectedYubiKey: LiveData<UsbYubiKeyDevice?> = _connectedYubiKey
|
||||
fun setConnectedYubiKey(device: UsbYubiKeyDevice, onDisconnect: () -> Unit ) {
|
||||
fun setConnectedYubiKey(device: UsbYubiKeyDevice, onDisconnect: () -> Unit) {
|
||||
_connectedYubiKey.postValue(device)
|
||||
device.setOnClosed {
|
||||
_connectedYubiKey.postValue(null)
|
||||
|
@ -0,0 +1,13 @@
|
||||
package com.yubico.authenticator.fido
|
||||
|
||||
const val dialogDescriptionOathIndex = 200
|
||||
|
||||
enum class FidoActionDescription(private val value: Int) {
|
||||
Reset(0),
|
||||
Unlock(1),
|
||||
SetPin(2),
|
||||
ActionFailure(3);
|
||||
|
||||
val id: Int
|
||||
get() = value + dialogDescriptionOathIndex
|
||||
}
|
@ -0,0 +1,394 @@
|
||||
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.AppPreferences
|
||||
import com.yubico.authenticator.DialogIcon
|
||||
import com.yubico.authenticator.DialogManager
|
||||
import com.yubico.authenticator.DialogTitle
|
||||
import com.yubico.authenticator.MainViewModel
|
||||
import com.yubico.authenticator.asString
|
||||
import com.yubico.authenticator.device.Info
|
||||
import com.yubico.authenticator.device.UnknownDevice
|
||||
import com.yubico.authenticator.fido.data.Session
|
||||
import com.yubico.authenticator.fido.data.YubiKitFidoSession
|
||||
import com.yubico.authenticator.setHandler
|
||||
import com.yubico.authenticator.yubikit.getDeviceInfo
|
||||
import com.yubico.authenticator.yubikit.withConnection
|
||||
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
||||
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
||||
import com.yubico.yubikit.core.Transport
|
||||
import com.yubico.yubikit.core.YubiKeyDevice
|
||||
import com.yubico.yubikit.core.application.ApplicationNotAvailableException
|
||||
import com.yubico.yubikit.core.fido.CtapException
|
||||
import com.yubico.yubikit.core.smartcard.SmartCardConnection
|
||||
import com.yubico.yubikit.core.util.Result
|
||||
import com.yubico.yubikit.fido.ctap.ClientPin
|
||||
import com.yubico.yubikit.fido.ctap.CredentialManagement
|
||||
import com.yubico.yubikit.fido.ctap.Ctap2Session
|
||||
import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData
|
||||
import com.yubico.yubikit.fido.ctap.PinUvAuthDummyProtocol
|
||||
import com.yubico.yubikit.fido.ctap.PinUvAuthProtocol
|
||||
import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV1
|
||||
import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV2
|
||||
import com.yubico.yubikit.support.DeviceUtil
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.Arrays
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
typealias FidoAction = (Result<YubiKitFidoSession, Exception>) -> Unit
|
||||
|
||||
class FidoManager(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
messenger: BinaryMessenger,
|
||||
private val appViewModel: MainViewModel,
|
||||
private val fidoViewModel: FidoViewModel,
|
||||
private val dialogManager: DialogManager,
|
||||
private val appPreferences: AppPreferences,
|
||||
) : AppContextManager {
|
||||
|
||||
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
|
||||
if (pinSupported) {
|
||||
for (protocol in pinUvAuthProtocols) {
|
||||
if (protocol == PinUvAuthProtocolV1.VERSION) {
|
||||
return PinUvAuthProtocolV1()
|
||||
}
|
||||
if (protocol == PinUvAuthProtocolV2.VERSION) {
|
||||
return PinUvAuthProtocolV2()
|
||||
}
|
||||
}
|
||||
}
|
||||
return PinUvAuthDummyProtocol()
|
||||
}
|
||||
}
|
||||
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
private val fidoChannel = MethodChannel(messenger, "android.fido.methods")
|
||||
|
||||
private val logger = LoggerFactory.getLogger(FidoManager::class.java)
|
||||
private var pendingAction: FidoAction? = null
|
||||
private var token: ByteArray? = null
|
||||
|
||||
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.")
|
||||
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) {
|
||||
appViewModel.setDeviceInfo(null)
|
||||
fidoViewModel.setSessionState(null)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver)
|
||||
//fidoViewModel.credentials.observe(lifecycleOwner, credentialObserver)
|
||||
|
||||
// FIDO methods callable from Flutter:
|
||||
fidoChannel.setHandler(coroutineScope) { method, args ->
|
||||
when (method) {
|
||||
"reset" -> noop()
|
||||
|
||||
"unlock" -> unlock(
|
||||
(args["pin"] as String).toCharArray()
|
||||
)
|
||||
|
||||
"set_pin" -> setPin(
|
||||
(args["pin"] as String?)?.toCharArray(),
|
||||
(args["new_pin"] as String).toCharArray(),
|
||||
)
|
||||
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
|
||||
appViewModel.connectedYubiKey.removeObserver(usbObserver)
|
||||
// oathViewModel.credentials.removeObserver(credentialObserver)
|
||||
fidoChannel.setMethodCallHandler(null)
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
private fun noop(): String = ""
|
||||
|
||||
override suspend fun processYubiKey(device: YubiKeyDevice) {
|
||||
try {
|
||||
device.withConnection<SmartCardConnection, Unit> { connection ->
|
||||
val session = YubiKitFidoSession(connection)
|
||||
|
||||
val previousAaguid = fidoViewModel.sessionState.value?.info?.aaguid?.asString()
|
||||
val sessionAaguid = session.cachedInfo.aaguid.asString()
|
||||
|
||||
logger.debug(
|
||||
"Previous aaguid: {}, current aaguid: {}",
|
||||
previousAaguid,
|
||||
sessionAaguid
|
||||
)
|
||||
|
||||
if (sessionAaguid == previousAaguid && device is NfcYubiKeyDevice) {
|
||||
// Run any pending action
|
||||
pendingAction?.let { action ->
|
||||
action.invoke(Result.success(session))
|
||||
pendingAction = null
|
||||
}
|
||||
} else {
|
||||
|
||||
if (sessionAaguid != previousAaguid) {
|
||||
// different key
|
||||
logger.debug("This is a different key than previous, invalidating the PIN token")
|
||||
if (token != null) {
|
||||
Arrays.fill(token!!, 0.toByte())
|
||||
token = null
|
||||
}
|
||||
}
|
||||
|
||||
fidoViewModel.setSessionState(
|
||||
Session(
|
||||
session,
|
||||
token != null
|
||||
)
|
||||
)
|
||||
|
||||
// Update deviceInfo since the deviceId has changed
|
||||
val pid = (device as? UsbYubiKeyDevice)?.pid
|
||||
val deviceInfo = DeviceUtil.readInfo(connection, pid)
|
||||
appViewModel.setDeviceInfo(
|
||||
Info(
|
||||
name = DeviceUtil.getName(deviceInfo, pid?.type),
|
||||
isNfc = device.transport == Transport.NFC,
|
||||
usbPid = pid?.value,
|
||||
deviceInfo = deviceInfo
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
|
||||
logger.error("Failed to connect to CCID", e)
|
||||
if (device.transport == Transport.USB || e is ApplicationNotAvailableException) {
|
||||
val deviceInfo = try {
|
||||
getDeviceInfo(device)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.debug("Device was not recognized")
|
||||
UnknownDevice.copy(isNfc = device.transport == Transport.NFC)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failure getting device info", e)
|
||||
null
|
||||
}
|
||||
|
||||
logger.debug("Setting device info: {}", deviceInfo)
|
||||
appViewModel.setDeviceInfo(deviceInfo)
|
||||
}
|
||||
|
||||
// Clear any cached OATH state
|
||||
fidoViewModel.setSessionState(null)
|
||||
} finally {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun unlockSession(
|
||||
ctap2Session: Ctap2Session,
|
||||
clientPin: ClientPin,
|
||||
pin: CharArray
|
||||
): String {
|
||||
val permissions =
|
||||
if (CredentialManagement.isSupported(ctap2Session.cachedInfo))
|
||||
ClientPin.PIN_PERMISSION_CM
|
||||
else
|
||||
0
|
||||
// TODO: Add bio Enrollment permissions if supported
|
||||
|
||||
if (permissions != 0) {
|
||||
token = clientPin.getPinToken(pin, permissions, "")
|
||||
} else {
|
||||
clientPin.getPinToken(pin, permissions, "yubico-authenticator.example.com")
|
||||
}
|
||||
|
||||
fidoViewModel.setSessionState(
|
||||
Session(
|
||||
ctap2Session,
|
||||
token != null
|
||||
)
|
||||
)
|
||||
return JSONObject(mapOf("success" to true)).toString()
|
||||
}
|
||||
|
||||
private fun catchPinErrors(clientPin: ClientPin, block: () -> String): String =
|
||||
try {
|
||||
block()
|
||||
} catch (ctapException: CtapException) {
|
||||
if (ctapException.ctapError == CtapException.ERR_PIN_INVALID ||
|
||||
ctapException.ctapError == CtapException.ERR_PIN_BLOCKED ||
|
||||
ctapException.ctapError == CtapException.ERR_PIN_AUTH_BLOCKED
|
||||
) {
|
||||
token = null
|
||||
val pinRetriesResult = clientPin.pinRetries
|
||||
JSONObject(
|
||||
mapOf(
|
||||
"success" to false,
|
||||
"pinRetries" to pinRetriesResult.count,
|
||||
"authBlocked" to (ctapException.ctapError == CtapException.ERR_PIN_AUTH_BLOCKED)
|
||||
)
|
||||
).toString()
|
||||
} else {
|
||||
throw ctapException
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unlock(pin: CharArray): String =
|
||||
useSession(FidoActionDescription.Unlock) { ctap2Session ->
|
||||
val clientPin =
|
||||
ClientPin(ctap2Session, getPreferredPinUvAuthProtocol(ctap2Session.cachedInfo))
|
||||
try {
|
||||
catchPinErrors(clientPin) {
|
||||
unlockSession(ctap2Session, clientPin, pin)
|
||||
}
|
||||
|
||||
} finally {
|
||||
Arrays.fill(pin, 0.toChar())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setOrChangePin(
|
||||
ctap2Session: Ctap2Session,
|
||||
clientPin: ClientPin,
|
||||
pin: CharArray?,
|
||||
newPin: CharArray
|
||||
) {
|
||||
val infoData = ctap2Session.cachedInfo
|
||||
val hasPin = infoData.options["clientPin"] == true
|
||||
|
||||
if (hasPin) {
|
||||
clientPin.changePin(pin!!, newPin)
|
||||
} else {
|
||||
clientPin.setPin(newPin)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setPin(pin: CharArray?, newPin: CharArray): String =
|
||||
useSession(FidoActionDescription.SetPin) { ctap2Session ->
|
||||
val clientPin =
|
||||
ClientPin(ctap2Session, getPreferredPinUvAuthProtocol(ctap2Session.cachedInfo))
|
||||
try {
|
||||
catchPinErrors(clientPin) {
|
||||
setOrChangePin(ctap2Session, clientPin, pin, newPin)
|
||||
unlockSession(ctap2Session, clientPin, newPin)
|
||||
}
|
||||
} finally {
|
||||
Arrays.fill(newPin, 0.toChar())
|
||||
pin?.let {
|
||||
Arrays.fill(it, 0.toChar())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> useSession(
|
||||
actionDescription: FidoActionDescription,
|
||||
action: (YubiKitFidoSession) -> T
|
||||
): T {
|
||||
return appViewModel.connectedYubiKey.value?.let {
|
||||
useSessionUsb(it, action)
|
||||
} ?: useSessionNfc(actionDescription, action)
|
||||
}
|
||||
|
||||
private suspend fun <T> useSessionUsb(
|
||||
device: UsbYubiKeyDevice,
|
||||
block: (YubiKitFidoSession) -> T
|
||||
): T = device.withConnection<SmartCardConnection, T> {
|
||||
block(YubiKitFidoSession(it))
|
||||
}
|
||||
|
||||
private suspend fun <T> useSessionNfc(
|
||||
actionDescription: FidoActionDescription,
|
||||
block: (YubiKitFidoSession) -> T
|
||||
): T {
|
||||
try {
|
||||
val result = suspendCoroutine { outer ->
|
||||
pendingAction = {
|
||||
outer.resumeWith(runCatching {
|
||||
block.invoke(it.value)
|
||||
})
|
||||
}
|
||||
dialogManager.showDialog(
|
||||
DialogIcon.Nfc,
|
||||
DialogTitle.TapKey,
|
||||
actionDescription.id
|
||||
) {
|
||||
logger.debug("Cancelled Dialog {}", actionDescription.name)
|
||||
pendingAction?.invoke(Result.failure(CancellationException()))
|
||||
pendingAction = null
|
||||
}
|
||||
}
|
||||
// Personally I find it better to not have the dialog updates for FIDO
|
||||
// dialogManager.updateDialogState(
|
||||
// dialogIcon = DialogIcon.Success,
|
||||
// dialogTitle = DialogTitle.OperationSuccessful
|
||||
// )
|
||||
// // TODO: This delays the closing of the dialog, but also the return value
|
||||
// delay(500)
|
||||
return result
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
// Personally I find it better to not have the dialog updates for FIDO
|
||||
// dialogManager.updateDialogState(
|
||||
// dialogIcon = DialogIcon.Failure,
|
||||
// dialogTitle = DialogTitle.OperationFailed,
|
||||
// dialogDescriptionId = FidoActionDescription.ActionFailure.id
|
||||
// )
|
||||
// // TODO: This delays the closing of the dialog, but also the return value
|
||||
// delay(1500)
|
||||
throw error
|
||||
} finally {
|
||||
dialogManager.closeDialog()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package com.yubico.authenticator.fido
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
import com.yubico.authenticator.fido.data.Session
|
||||
|
||||
class FidoViewModel : ViewModel() {
|
||||
private val _sessionState = MutableLiveData<Session?>()
|
||||
val sessionState: LiveData<Session?> = _sessionState
|
||||
|
||||
fun setSessionState(sessionState: Session?) {
|
||||
_sessionState.postValue(sessionState)
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
package com.yubico.authenticator.fido.data
|
||||
|
||||
import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData
|
||||
import kotlinx.serialization.*
|
||||
|
||||
typealias YubiKitFidoSession = com.yubico.yubikit.fido.ctap.Ctap2Session
|
||||
|
||||
@Serializable
|
||||
data class Options(
|
||||
val clientPin: Boolean,
|
||||
val credMgmt: Boolean,
|
||||
val credentialMgmtPreview: Boolean,
|
||||
val bioEnroll: Boolean?,
|
||||
val alwaysUv: Boolean
|
||||
)
|
||||
|
||||
fun Map<String, Any?>.getBoolean(
|
||||
key: String,
|
||||
default: Boolean = false
|
||||
): Boolean = get(key) as? Boolean ?: default
|
||||
|
||||
fun Map<String, Any?>.getOptionalBoolean(
|
||||
key: String
|
||||
): Boolean? = get(key) as? Boolean
|
||||
|
||||
@Serializable
|
||||
data class SessionInfo(
|
||||
val options: Options,
|
||||
val aaguid: ByteArray,
|
||||
@SerialName("min_pin_length")
|
||||
val minPinLength: Int,
|
||||
@SerialName("force_pin_change")
|
||||
val forcePinChange: Boolean
|
||||
) {
|
||||
constructor(infoData: InfoData) : this(
|
||||
Options(
|
||||
infoData.options.getBoolean("clientPin"),
|
||||
infoData.options.getBoolean("credMgmt"),
|
||||
infoData.options.getBoolean("credentialMgmtPreview"),
|
||||
infoData.options.getOptionalBoolean("bioEnroll"),
|
||||
infoData.options.getBoolean("alwaysUv")
|
||||
),
|
||||
infoData.aaguid,
|
||||
infoData.minPinLength,
|
||||
infoData.forcePinChange
|
||||
)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as SessionInfo
|
||||
|
||||
if (options != other.options) return false
|
||||
if (!aaguid.contentEquals(other.aaguid)) return false
|
||||
if (minPinLength != other.minPinLength) return false
|
||||
return forcePinChange == other.forcePinChange
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = options.hashCode()
|
||||
result = 31 * result + aaguid.contentHashCode()
|
||||
result = 31 * result + minPinLength
|
||||
result = 31 * result + forcePinChange.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Session(
|
||||
@SerialName("info")
|
||||
val info: SessionInfo,
|
||||
@SerialName("unlocked")
|
||||
val unlocked: Boolean
|
||||
) {
|
||||
constructor(fidoSession: YubiKitFidoSession, unlocked: Boolean) : this(
|
||||
SessionInfo(fidoSession.cachedInfo), unlocked
|
||||
)
|
||||
}
|
@ -50,6 +50,7 @@ import com.yubico.yubikit.core.Transport
|
||||
import com.yubico.yubikit.core.YubiKeyDevice
|
||||
import com.yubico.yubikit.core.application.ApplicationNotAvailableException
|
||||
import com.yubico.yubikit.core.smartcard.ApduException
|
||||
import com.yubico.yubikit.core.smartcard.AppId
|
||||
import com.yubico.yubikit.core.smartcard.SW
|
||||
import com.yubico.yubikit.core.smartcard.SmartCardConnection
|
||||
import com.yubico.yubikit.core.smartcard.SmartCardProtocol
|
||||
@ -79,7 +80,6 @@ class OathManager(
|
||||
) : AppContextManager {
|
||||
companion object {
|
||||
const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s
|
||||
val OTP_AID = byteArrayOf(0xa0.toByte(), 0x00, 0x00, 0x05, 0x27, 0x20, 0x01, 0x01)
|
||||
}
|
||||
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
@ -302,7 +302,7 @@ class OathManager(
|
||||
if (session.version.isLessThan(4, 0, 0) && connection.transport == Transport.NFC) {
|
||||
// NEO over NFC, select OTP applet before reading info
|
||||
try {
|
||||
SmartCardProtocol(connection).select(OTP_AID)
|
||||
SmartCardProtocol(connection).select(AppId.OTP)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to recognize this OATH device.")
|
||||
// we know this is NFC device and it supports OATH
|
||||
|
@ -1,12 +1,12 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.21'
|
||||
ext.kotlin_version = '1.9.22'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.4'
|
||||
classpath 'com.android.tools.build:gradle:8.2.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||
classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6'
|
||||
|
146
lib/android/fido/state.dart
Normal file
146
lib/android/fido/state.dart
Normal file
@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../fido/models.dart';
|
||||
import '../../fido/state.dart';
|
||||
|
||||
final _log = Logger('android.fido.state');
|
||||
|
||||
const _methods = MethodChannel('android.fido.methods');
|
||||
|
||||
final androidFidoStateProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<FidoStateNotifier, FidoState, DevicePath>(_FidoStateNotifier.new);
|
||||
|
||||
class _FidoStateNotifier extends FidoStateNotifier {
|
||||
late StateController<String?> _pinController;
|
||||
final _events = const EventChannel('android.fido.sessionState');
|
||||
late StreamSubscription _sub;
|
||||
|
||||
@override
|
||||
FutureOr<FidoState> build(DevicePath devicePath) async {
|
||||
_sub = _events.receiveBroadcastStream().listen((event) {
|
||||
final json = jsonDecode(event);
|
||||
if (json == null) {
|
||||
state = const AsyncValue.loading();
|
||||
} else {
|
||||
final fidoState = FidoState.fromJson(json);
|
||||
state = AsyncValue.data(fidoState);
|
||||
}
|
||||
}, onError: (err, stackTrace) {
|
||||
state = AsyncValue.error(err, stackTrace);
|
||||
});
|
||||
|
||||
ref.onDispose(_sub.cancel);
|
||||
|
||||
return Completer<FidoState>().future;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<InteractionEvent> reset() {
|
||||
final controller = StreamController<InteractionEvent>();
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PinResult> setPin(String newPin, {String? oldPin}) async {
|
||||
try {
|
||||
final setPinResponse = jsonDecode(await _methods.invokeMethod('set_pin', {
|
||||
'pin': oldPin,
|
||||
'new_pin': newPin,
|
||||
}));
|
||||
if (setPinResponse['success'] == true) {
|
||||
_log.debug('FIDO pin set/change successful');
|
||||
return PinResult.success();
|
||||
}
|
||||
|
||||
_log.debug('FIDO pin set/change failed');
|
||||
return PinResult.failed(
|
||||
setPinResponse['pinRetries'], setPinResponse['authBlocked']);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PinResult> unlock(String pin) async {
|
||||
try {
|
||||
final unlockResponse =
|
||||
jsonDecode(await _methods.invokeMethod('unlock', {'pin': pin}));
|
||||
|
||||
if (unlockResponse['success'] == true) {
|
||||
_log.debug('FIDO applet unlocked');
|
||||
return PinResult.success();
|
||||
}
|
||||
|
||||
_log.debug('FIDO applet unlock failed');
|
||||
return PinResult.failed(
|
||||
unlockResponse['pinRetries'], unlockResponse['authBlocked']);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final androidFingerprintProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<FidoFingerprintsNotifier, List<Fingerprint>, DevicePath>(
|
||||
_FidoFingerprintsNotifier.new);
|
||||
|
||||
class _FidoFingerprintsNotifier extends FidoFingerprintsNotifier {
|
||||
@override
|
||||
FutureOr<List<Fingerprint>> build(DevicePath devicePath) async {
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<FingerprintEvent> registerFingerprint({String? name}) {
|
||||
final controller = StreamController<FingerprintEvent>();
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Fingerprint> renameFingerprint(
|
||||
Fingerprint fingerprint, String name) async {
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFingerprint(Fingerprint fingerprint) async {}
|
||||
}
|
||||
|
||||
final androidCredentialProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<FidoCredentialsNotifier, List<FidoCredential>, DevicePath>(
|
||||
_FidoCredentialsNotifier.new);
|
||||
|
||||
class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
|
||||
@override
|
||||
FutureOr<List<FidoCredential>> build(DevicePath devicePath) async {
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCredential(FidoCredential credential) async {}
|
||||
}
|
@ -30,9 +30,11 @@ import '../app/models.dart';
|
||||
import '../app/state.dart';
|
||||
import '../app/views/main_page.dart';
|
||||
import '../core/state.dart';
|
||||
import '../fido/state.dart';
|
||||
import '../management/state.dart';
|
||||
import '../oath/state.dart';
|
||||
import 'app_methods.dart';
|
||||
import 'fido/state.dart';
|
||||
import 'logger.dart';
|
||||
import 'management/state.dart';
|
||||
import 'oath/otp_auth_link_handler.dart';
|
||||
@ -55,6 +57,7 @@ Future<Widget> initialize() async {
|
||||
overrides: [
|
||||
supportedAppsProvider.overrideWith(implementedApps([
|
||||
Application.accounts,
|
||||
Application.webauthn,
|
||||
])),
|
||||
prefProvider.overrideWithValue(await SharedPreferences.getInstance()),
|
||||
logLevelProvider.overrideWith((ref) => AndroidLogger()),
|
||||
@ -86,6 +89,11 @@ Future<Widget> initialize() async {
|
||||
(ref) => ref.watch(androidSupportedThemesProvider),
|
||||
),
|
||||
defaultColorProvider.overrideWithValue(await getPrimaryColor()),
|
||||
|
||||
// FIDO
|
||||
fidoStateProvider.overrideWithProvider(androidFidoStateProvider.call),
|
||||
fingerprintProvider.overrideWithProvider(androidFingerprintProvider.call),
|
||||
credentialProvider.overrideWithProvider(androidCredentialProvider.call),
|
||||
],
|
||||
child: DismissKeyboard(
|
||||
child: YubicoAuthenticatorApp(page: Consumer(
|
||||
|
@ -73,22 +73,33 @@ enum _DDesc {
|
||||
oathCalculateCode,
|
||||
oathActionFailure,
|
||||
oathAddMultipleAccounts,
|
||||
// FIDO descriptions
|
||||
fidoResetApplet,
|
||||
fidoUnlockSession,
|
||||
fidoSetPin,
|
||||
fidoActionFailure,
|
||||
// Others
|
||||
invalid;
|
||||
|
||||
static const int dialogDescriptionOathIndex = 100;
|
||||
static const int dialogDescriptionFidoIndex = 200;
|
||||
|
||||
static _DDesc fromId(int? id) =>
|
||||
const {
|
||||
dialogDescriptionOathIndex + 0: _DDesc.oathResetApplet,
|
||||
dialogDescriptionOathIndex + 1: _DDesc.oathUnlockSession,
|
||||
dialogDescriptionOathIndex + 2: _DDesc.oathSetPassword,
|
||||
dialogDescriptionOathIndex + 3: _DDesc.oathUnsetPassword,
|
||||
dialogDescriptionOathIndex + 4: _DDesc.oathAddAccount,
|
||||
dialogDescriptionOathIndex + 5: _DDesc.oathRenameAccount,
|
||||
dialogDescriptionOathIndex + 6: _DDesc.oathDeleteAccount,
|
||||
dialogDescriptionOathIndex + 7: _DDesc.oathCalculateCode,
|
||||
dialogDescriptionOathIndex + 8: _DDesc.oathActionFailure,
|
||||
dialogDescriptionOathIndex + 9: _DDesc.oathAddMultipleAccounts
|
||||
dialogDescriptionOathIndex + 0: oathResetApplet,
|
||||
dialogDescriptionOathIndex + 1: oathUnlockSession,
|
||||
dialogDescriptionOathIndex + 2: oathSetPassword,
|
||||
dialogDescriptionOathIndex + 3: oathUnsetPassword,
|
||||
dialogDescriptionOathIndex + 4: oathAddAccount,
|
||||
dialogDescriptionOathIndex + 5: oathRenameAccount,
|
||||
dialogDescriptionOathIndex + 6: oathDeleteAccount,
|
||||
dialogDescriptionOathIndex + 7: oathCalculateCode,
|
||||
dialogDescriptionOathIndex + 8: oathActionFailure,
|
||||
dialogDescriptionOathIndex + 9: oathAddMultipleAccounts,
|
||||
dialogDescriptionFidoIndex + 0: fidoResetApplet,
|
||||
dialogDescriptionFidoIndex + 1: fidoUnlockSession,
|
||||
dialogDescriptionFidoIndex + 2: fidoSetPin,
|
||||
dialogDescriptionFidoIndex + 3: fidoActionFailure,
|
||||
}[id] ??
|
||||
_DDesc.invalid;
|
||||
}
|
||||
@ -162,6 +173,10 @@ class _DialogProvider {
|
||||
_DDesc.oathActionFailure => l10n.s_nfc_dialog_oath_failure,
|
||||
_DDesc.oathAddMultipleAccounts =>
|
||||
l10n.s_nfc_dialog_oath_add_multiple_accounts,
|
||||
_DDesc.fidoResetApplet => l10n.s_nfc_dialog_fido_reset,
|
||||
_DDesc.fidoUnlockSession => l10n.s_nfc_dialog_fido_unlock,
|
||||
_DDesc.fidoSetPin => l10n.s_nfc_dialog_fido_set_pin,
|
||||
_DDesc.fidoActionFailure => l10n.s_nfc_dialog_fido_failure,
|
||||
_ => ''
|
||||
};
|
||||
}
|
||||
|
@ -722,6 +722,11 @@
|
||||
"s_nfc_dialog_oath_failure": "OATH operation failed",
|
||||
"s_nfc_dialog_oath_add_multiple_accounts": "Action: add multiple accounts",
|
||||
|
||||
"s_nfc_dialog_fido_reset": "Action: reset FIDO applet",
|
||||
"s_nfc_dialog_fido_unlock": "Action: unlock FIDO applet",
|
||||
"s_nfc_dialog_fido_set_pin": "Action: set or change FIDO applet PIN",
|
||||
"s_nfc_dialog_fido_failure": "FIDO operation failed",
|
||||
|
||||
"@_ndef": {},
|
||||
"p_ndef_set_otp": "Successfully copied OTP code from YubiKey to clipboard.",
|
||||
"p_ndef_set_password": "Successfully copied password from YubiKey to clipboard.",
|
||||
|
Loading…
Reference in New Issue
Block a user