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 onError() {}
open fun onError(e: Exception) {}
}
class ContextDisposedException : Exception()

View File

@ -79,6 +79,7 @@ import kotlinx.coroutines.launch
import org.json.JSONObject
import org.slf4j.LoggerFactory
import java.io.Closeable
import java.io.IOException
import java.security.NoSuchAlgorithmException
import java.util.concurrent.Executors
import javax.crypto.Mac
@ -310,43 +311,55 @@ class MainActivity : FlutterFragmentActivity() {
}
private suspend fun processYubiKey(device: YubiKeyDevice) {
val deviceInfo = getDeviceInfo(device)
val deviceInfo = try {
if (deviceInfo == null) {
deviceManager.setDeviceInfo(null)
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
if (device is NfcYubiKeyDevice) {
appMethodChannel.nfcStateChanged(NfcState.ONGOING)
}
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

View File

@ -42,14 +42,6 @@ class FidoConnectionHelper(private val deviceManager: DeviceManager) {
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() {
pendingAction?.let { action ->
action.invoke(Result.failure(CancellationException()))
@ -80,7 +72,7 @@ class FidoConnectionHelper(private val deviceManager: DeviceManager) {
block(YubiKitFidoSession(it))
}.also {
if (updateDeviceInfo) {
deviceManager.setDeviceInfo(getDeviceInfo(device))
deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull())
}
}

View File

@ -174,9 +174,9 @@ class FidoManager(
}
}
override fun onError() {
super.onError()
logger.debug("Cancel any pending action because of upstream error")
override fun onError(e: Exception) {
super.onError(e)
logger.error("Cancelling pending action. Cause: ", e)
connectionHelper.cancelPending()
}
@ -204,13 +204,12 @@ class FidoManager(
}
if (updateDeviceInfo.getAndSet(false)) {
deviceManager.setDeviceInfo(getDeviceInfo(device))
deviceManager.setDeviceInfo(runCatching { getDeviceInfo(device) }.getOrNull())
}
} 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) {
// we don't clear the session on IOExceptions so that the session is ready for
@ -240,7 +239,7 @@ class FidoManager(
currentSession
)
val sameDevice = currentSession == previousSession
val sameDevice = currentSession.sameDevice(previousSession)
if (device is NfcYubiKeyDevice && (sameDevice || resetHelper.inProgress)) {
requestHandled = connectionHelper.invokePending(fidoSession)

View File

@ -41,6 +41,19 @@ data class Options(
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 {
private fun InfoData.getOptionsBoolean(
key: String
@ -67,6 +80,21 @@ data class SessionInfo(
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 {
if (this === other) return true
if (javaClass != other?.javaClass) return false

View File

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

View File

@ -47,17 +47,17 @@ class DeviceInfoHelper {
private val restrictedNfcBytes =
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)
var deviceInfo = readDeviceInfo(device)
if (deviceInfo?.version?.major == 0.toByte()) {
if (deviceInfo.version.major == 0.toByte()) {
SessionVersionOverride.set(Version(5, 7, 2))
deviceInfo = readDeviceInfo(device)
}
return deviceInfo
}
private suspend fun readDeviceInfo(device: YubiKeyDevice): Info? {
private suspend fun readDeviceInfo(device: YubiKeyDevice): Info {
val pid = (device as? UsbYubiKeyDevice)?.pid
val deviceInfo = runCatching {
@ -106,8 +106,8 @@ class DeviceInfoHelper {
}
} catch (e: Exception) {
// no smart card connectivity
logger.error("Failure getting device info", e)
return null
logger.error("Failure getting device info: ", e)
throw e
}
}

View File

@ -365,9 +365,8 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
var decodedException = pe.decode();
if (decodedException is CancellationException) {
_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 =
prev?.hasValue == true ? prev?.value?.info.serial : null;
if ((serial != null && serial == prevSerial) ||
(next.hasValue && (prev != null && prev.isLoading))) {
(next.hasValue && (prev != null && prev.isLoading)) ||
next.isLoading) {
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");
* 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/models.dart';
import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
@ -57,15 +58,19 @@ class DeleteCredentialDialog extends ConsumerWidget {
actions: [
TextButton(
onPressed: () async {
await ref
.read(credentialProvider(devicePath).notifier)
.deleteCredential(credential);
await ref.read(withContextProvider)(
(context) async {
Navigator.of(context).pop(true);
showMessage(context, l10n.s_passkey_deleted);
},
);
try {
await ref
.read(credentialProvider(devicePath).notifier)
.deleteCredential(credential);
await ref.read(withContextProvider)(
(context) async {
Navigator.of(context).pop(true);
showMessage(context, l10n.s_passkey_deleted);
},
);
} on CancellationException catch (_) {
// ignored
}
},
child: Text(l10n.s_delete),
),

View File

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

View File

@ -67,49 +67,15 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
OathCredential credential,
List<(String? issuer, String name)> existing) {
return RenameAccountDialog(
devicePath: devicePath,
issuer: credential.issuer,
name: credential.name,
oathType: credential.oathType,
period: credential.period,
existing: existing,
rename: (issuer, name) async {
final withContext = ref.read(withContextProvider);
try {
// 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;
}
},
);
devicePath: devicePath,
issuer: credential.issuer,
name: credential.name,
oathType: credential.oathType,
period: credential.period,
existing: existing,
rename: (issuer, name) async => await ref
.read(credentialListProvider(devicePath).notifier)
.renameAccount(credential, issuer, name));
}
}
@ -138,9 +104,39 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
_issuerFocus.unfocus();
_nameFocus.unfocus();
final nav = Navigator.of(context);
final renamed =
await widget.rename(_issuer.isNotEmpty ? _issuer : null, _name);
nav.pop(renamed);
final withContext = ref.read(withContextProvider);
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