This commit is contained in:
Adam Velebil 2024-09-13 08:52:15 +02:00
commit 5a24f57e0b
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
12 changed files with 179 additions and 140 deletions

View File

@ -28,7 +28,7 @@ abstract class AppContextManager {
open fun onPause() {} open fun onPause() {}
open fun onError() {} open fun onError(e: Exception) {}
} }
class ContextDisposedException : Exception() class ContextDisposedException : Exception()

View File

@ -79,6 +79,7 @@ import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.Closeable import java.io.Closeable
import java.io.IOException
import java.security.NoSuchAlgorithmException import java.security.NoSuchAlgorithmException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import javax.crypto.Mac import javax.crypto.Mac
@ -310,43 +311,55 @@ class MainActivity : FlutterFragmentActivity() {
} }
private suspend fun processYubiKey(device: YubiKeyDevice) { private suspend fun processYubiKey(device: YubiKeyDevice) {
val deviceInfo = getDeviceInfo(device) val deviceInfo = try {
if (deviceInfo == null) { if (device is NfcYubiKeyDevice) {
deviceManager.setDeviceInfo(null) appMethodChannel.nfcStateChanged(NfcState.ONGOING)
return
}
if (device is NfcYubiKeyDevice) {
appMethodChannel.nfcStateChanged(NfcState.ONGOING)
}
deviceManager.scpKeyParams = null
// If NFC and FIPS check for SCP11b key
if (device.transport == Transport.NFC && deviceInfo.fipsCapable != 0) {
logger.debug("Checking for usable SCP11b key...")
deviceManager.scpKeyParams = try {
device.withConnection<SmartCardConnection, ScpKeyParams?> { connection ->
val scp = SecurityDomainSession(connection)
val keyRef = scp.keyInformation.keys.firstOrNull { it.kid == ScpKid.SCP11b }
keyRef?.let {
val certs = scp.getCertificateBundle(it)
if (certs.isNotEmpty()) Scp11KeyParams(
keyRef,
certs[certs.size - 1].publicKey
) else null
}?.also {
logger.debug("Found SCP11b key: {}", keyRef)
}
}
} catch (e: Exception) {
logger.debug("Exception while getting scp keys: ", e)
contextManager?.onError()
if (device is NfcYubiKeyDevice) {
appMethodChannel.nfcStateChanged(NfcState.FAILURE)
}
null
} }
val deviceInfo = getDeviceInfo(device)
deviceManager.scpKeyParams = null
// If NFC and FIPS check for SCP11b key
if (device.transport == Transport.NFC && deviceInfo.fipsCapable != 0) {
logger.debug("Checking for usable SCP11b key...")
deviceManager.scpKeyParams = try {
device.withConnection<SmartCardConnection, ScpKeyParams?> { connection ->
val scp = SecurityDomainSession(connection)
val keyRef = scp.keyInformation.keys.firstOrNull { it.kid == ScpKid.SCP11b }
keyRef?.let {
val certs = scp.getCertificateBundle(it)
if (certs.isNotEmpty()) Scp11KeyParams(
keyRef,
certs[certs.size - 1].publicKey
) else null
}?.also {
logger.debug("Found SCP11b key: {}", keyRef)
}
}
} catch (e: Exception) {
logger.error("Exception when reading SCP key information: ", e)
// we throw IO exception to unify handling failures as we don't want
// th clear device info
throw IOException("Failure getting SCP keys")
}
}
deviceInfo
} catch (e: Exception) {
logger.debug("Exception while getting device info and scp keys: ", e)
contextManager?.onError(e)
if (device is NfcYubiKeyDevice) {
appMethodChannel.nfcStateChanged(NfcState.FAILURE)
}
// do not clear deviceInfo on IOExceptions,
// this allows for retries of failed actions
if (e !is IOException) {
logger.debug("Resetting device info")
deviceManager.setDeviceInfo(null)
}
return
} }
// this YubiKey provides SCP11b key but the phone cannot perform AESCMAC // this YubiKey provides SCP11b key but the phone cannot perform AESCMAC

View File

@ -42,14 +42,6 @@ class FidoConnectionHelper(private val deviceManager: DeviceManager) {
return requestHandled return requestHandled
} }
fun failPending(e: Exception) {
pendingAction?.let { action ->
logger.error("Failing pending action with {}", e.message)
action.invoke(Result.failure(e))
pendingAction = null
}
}
fun cancelPending() { fun cancelPending() {
pendingAction?.let { action -> pendingAction?.let { action ->
action.invoke(Result.failure(CancellationException())) action.invoke(Result.failure(CancellationException()))
@ -80,7 +72,7 @@ class FidoConnectionHelper(private val deviceManager: DeviceManager) {
block(YubiKitFidoSession(it)) block(YubiKitFidoSession(it))
}.also { }.also {
if (updateDeviceInfo) { if (updateDeviceInfo) {
deviceManager.setDeviceInfo(getDeviceInfo(device)) deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull())
} }
} }

View File

@ -174,9 +174,9 @@ class FidoManager(
} }
} }
override fun onError() { override fun onError(e: Exception) {
super.onError() super.onError(e)
logger.debug("Cancel any pending action because of upstream error") logger.error("Cancelling pending action. Cause: ", e)
connectionHelper.cancelPending() connectionHelper.cancelPending()
} }
@ -204,13 +204,12 @@ class FidoManager(
} }
if (updateDeviceInfo.getAndSet(false)) { if (updateDeviceInfo.getAndSet(false)) {
deviceManager.setDeviceInfo(getDeviceInfo(device)) deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull())
} }
} catch (e: Exception) { } catch (e: Exception) {
// something went wrong, try to get DeviceInfo from any available connection type
logger.error("Failure when processing YubiKey: ", e)
connectionHelper.failPending(e) logger.error("Cancelling pending action. Cause: ", e)
connectionHelper.cancelPending()
if (e !is IOException) { if (e !is IOException) {
// we don't clear the session on IOExceptions so that the session is ready for // we don't clear the session on IOExceptions so that the session is ready for
@ -240,7 +239,7 @@ class FidoManager(
currentSession currentSession
) )
val sameDevice = currentSession == previousSession val sameDevice = currentSession.sameDevice(previousSession)
if (device is NfcYubiKeyDevice && (sameDevice || resetHelper.inProgress)) { if (device is NfcYubiKeyDevice && (sameDevice || resetHelper.inProgress)) {
requestHandled = connectionHelper.invokePending(fidoSession) requestHandled = connectionHelper.invokePending(fidoSession)

View File

@ -41,6 +41,19 @@ data class Options(
infoData.getOptionsBoolean("ep"), infoData.getOptionsBoolean("ep"),
) )
fun sameDevice(other: Options) : Boolean {
if (this === other) return true
if (clientPin != other.clientPin) return false
if (credMgmt != other.credMgmt) return false
if (credentialMgmtPreview != other.credentialMgmtPreview) return false
if (bioEnroll != other.bioEnroll) return false
// alwaysUv may differ
// ep may differ
return true
}
companion object { companion object {
private fun InfoData.getOptionsBoolean( private fun InfoData.getOptionsBoolean(
key: String key: String
@ -67,6 +80,21 @@ data class SessionInfo(
infoData.remainingDiscoverableCredentials infoData.remainingDiscoverableCredentials
) )
// this is a more permissive comparison, which does not take in an account properties,
// which might change by using the FIDO authenticator
fun sameDevice(other: SessionInfo?): Boolean {
if (other == null) return false
if (this === other) return true
if (!options.sameDevice(other.options)) return false
if (!aaguid.contentEquals(other.aaguid)) return false
// minPinLength may differ
// forcePinChange may differ
// remainingDiscoverableCredentials may differ
return true
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false

View File

@ -110,9 +110,9 @@ class OathManager(
private val updateDeviceInfo = AtomicBoolean(false) private val updateDeviceInfo = AtomicBoolean(false)
private var deviceInfoTimer: TimerTask? = null private var deviceInfoTimer: TimerTask? = null
override fun onError() { override fun onError(e: Exception) {
super.onError() super.onError(e)
logger.debug("Cancel any pending action because of upstream error") logger.error("Cancelling pending action in onError. Cause: ", e)
pendingAction?.let { action -> pendingAction?.let { action ->
action.invoke(Result.failure(CancellationException())) action.invoke(Result.failure(CancellationException()))
pendingAction = null pendingAction = null
@ -343,16 +343,16 @@ class OathManager(
) )
if (updateDeviceInfo.getAndSet(false)) { if (updateDeviceInfo.getAndSet(false)) {
deviceManager.setDeviceInfo(getDeviceInfo(device)) deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull())
} }
} catch (e: Exception) { } catch (e: Exception) {
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces // OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
logger.error("Exception during SmartCard connection/OATH session creation: ", e) logger.error("Exception during SmartCard connection/OATH session creation: ", e)
// Remove any pending action // Cancel any pending action
pendingAction?.let { action -> pendingAction?.let { action ->
logger.error("Failing pending action with {}", e.message) logger.error("Cancelling pending action. Cause: ", e)
action.invoke(Result.failure(e)) action.invoke(Result.failure(CancellationException()))
pendingAction = null pendingAction = null
} }
@ -782,7 +782,7 @@ class OathManager(
block(getOathSession(it)) block(getOathSession(it))
}.also { }.also {
if (updateDeviceInfo) { if (updateDeviceInfo) {
deviceManager.setDeviceInfo(getDeviceInfo(device)) deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull())
} }
} }

View File

@ -47,17 +47,17 @@ class DeviceInfoHelper {
private val restrictedNfcBytes = private val restrictedNfcBytes =
byteArrayOf(0x00, 0x1F, 0xD1.toByte(), 0x01, 0x1b, 0x55, 0x04) + uri byteArrayOf(0x00, 0x1F, 0xD1.toByte(), 0x01, 0x1b, 0x55, 0x04) + uri
suspend fun getDeviceInfo(device: YubiKeyDevice): Info? { suspend fun getDeviceInfo(device: YubiKeyDevice): Info {
SessionVersionOverride.set(null) SessionVersionOverride.set(null)
var deviceInfo = readDeviceInfo(device) var deviceInfo = readDeviceInfo(device)
if (deviceInfo?.version?.major == 0.toByte()) { if (deviceInfo.version.major == 0.toByte()) {
SessionVersionOverride.set(Version(5, 7, 2)) SessionVersionOverride.set(Version(5, 7, 2))
deviceInfo = readDeviceInfo(device) deviceInfo = readDeviceInfo(device)
} }
return deviceInfo return deviceInfo
} }
private suspend fun readDeviceInfo(device: YubiKeyDevice): Info? { private suspend fun readDeviceInfo(device: YubiKeyDevice): Info {
val pid = (device as? UsbYubiKeyDevice)?.pid val pid = (device as? UsbYubiKeyDevice)?.pid
val deviceInfo = runCatching { val deviceInfo = runCatching {
@ -106,8 +106,8 @@ class DeviceInfoHelper {
} }
} catch (e: Exception) { } catch (e: Exception) {
// no smart card connectivity // no smart card connectivity
logger.error("Failure getting device info", e) logger.error("Failure getting device info: ", e)
return null throw e
} }
} }

View File

@ -365,9 +365,8 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
var decodedException = pe.decode(); var decodedException = pe.decode();
if (decodedException is CancellationException) { if (decodedException is CancellationException) {
_log.debug('User cancelled delete credential FIDO operation'); _log.debug('User cancelled delete credential FIDO operation');
} else {
throw decodedException;
} }
throw decodedException;
} }
} }
} }

View File

@ -60,7 +60,8 @@ class MainPage extends ConsumerWidget {
final prevSerial = final prevSerial =
prev?.hasValue == true ? prev?.value?.info.serial : null; prev?.hasValue == true ? prev?.value?.info.serial : null;
if ((serial != null && serial == prevSerial) || if ((serial != null && serial == prevSerial) ||
(next.hasValue && (prev != null && prev.isLoading))) { (next.hasValue && (prev != null && prev.isLoading)) ||
next.isLoading) {
return; return;
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2022 Yubico. * Copyright (C) 2022-2024 Yubico.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -23,6 +23,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
@ -57,15 +58,19 @@ class DeleteCredentialDialog extends ConsumerWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ref try {
.read(credentialProvider(devicePath).notifier) await ref
.deleteCredential(credential); .read(credentialProvider(devicePath).notifier)
await ref.read(withContextProvider)( .deleteCredential(credential);
(context) async { await ref.read(withContextProvider)(
Navigator.of(context).pop(true); (context) async {
showMessage(context, l10n.s_passkey_deleted); Navigator.of(context).pop(true);
}, showMessage(context, l10n.s_passkey_deleted);
); },
);
} on CancellationException catch (_) {
// ignored
}
}, },
child: Text(l10n.s_delete), child: Text(l10n.s_delete),
), ),

View File

@ -22,6 +22,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
@ -71,23 +72,28 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
_submit() async { _submit() async {
_removeFocus(); _removeFocus();
final result = await ref try {
.read(oathStateProvider(widget.path).notifier) final result = await ref
.setPassword(_currentPasswordController.text, _newPassword); .read(oathStateProvider(widget.path).notifier)
if (result) { .setPassword(_currentPasswordController.text, _newPassword);
if (mounted) { if (result) {
await ref.read(withContextProvider)((context) async { if (mounted) {
Navigator.of(context).pop(); await ref.read(withContextProvider)((context) async {
showMessage(context, AppLocalizations.of(context)!.s_password_set); Navigator.of(context).pop();
showMessage(context, AppLocalizations.of(context)!.s_password_set);
});
}
} else {
_currentPasswordController.selection = TextSelection(
baseOffset: 0,
extentOffset: _currentPasswordController.text.length);
_currentPasswordFocus.requestFocus();
setState(() {
_currentIsWrong = true;
}); });
} }
} else { } on CancellationException catch (_) {
_currentPasswordController.selection = TextSelection( // ignored
baseOffset: 0, extentOffset: _currentPasswordController.text.length);
_currentPasswordFocus.requestFocus();
setState(() {
_currentIsWrong = true;
});
} }
} }

View File

@ -67,49 +67,15 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
OathCredential credential, OathCredential credential,
List<(String? issuer, String name)> existing) { List<(String? issuer, String name)> existing) {
return RenameAccountDialog( return RenameAccountDialog(
devicePath: devicePath, devicePath: devicePath,
issuer: credential.issuer, issuer: credential.issuer,
name: credential.name, name: credential.name,
oathType: credential.oathType, oathType: credential.oathType,
period: credential.period, period: credential.period,
existing: existing, existing: existing,
rename: (issuer, name) async { rename: (issuer, name) async => await ref
final withContext = ref.read(withContextProvider); .read(credentialListProvider(devicePath).notifier)
try { .renameAccount(credential, issuer, name));
// Rename credentials
final renamed = await ref
.read(credentialListProvider(devicePath).notifier)
.renameAccount(credential, issuer, name);
// Update favorite
ref
.read(favoritesProvider.notifier)
.renameCredential(credential.id, renamed.id);
await withContext((context) async => showMessage(
context, AppLocalizations.of(context)!.s_account_renamed));
return renamed;
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to rename account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
await withContext((context) async => showMessage(
context,
AppLocalizations.of(context)!
.l_rename_account_failed(errorMessage),
duration: const Duration(seconds: 4),
));
return null;
}
},
);
} }
} }
@ -138,9 +104,39 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
_issuerFocus.unfocus(); _issuerFocus.unfocus();
_nameFocus.unfocus(); _nameFocus.unfocus();
final nav = Navigator.of(context); final nav = Navigator.of(context);
final renamed = final withContext = ref.read(withContextProvider);
await widget.rename(_issuer.isNotEmpty ? _issuer : null, _name);
nav.pop(renamed); try {
// Rename credentials
final renamed =
await widget.rename(_issuer.isNotEmpty ? _issuer : null, _name);
// Update favorite
ref
.read(favoritesProvider.notifier)
.renameCredential(renamed.id, renamed.id);
await withContext((context) async => showMessage(
context, AppLocalizations.of(context)!.s_account_renamed));
nav.pop(renamed);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to rename account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
await withContext((context) async => showMessage(
context,
AppLocalizations.of(context)!.l_rename_account_failed(errorMessage),
duration: const Duration(seconds: 4),
));
}
} }
@override @override