diff --git a/lib/app/views/responsive_dialog.dart b/lib/app/views/responsive_dialog.dart index 9ea80332..82395889 100755 --- a/lib/app/views/responsive_dialog.dart +++ b/lib/app/views/responsive_dialog.dart @@ -4,9 +4,14 @@ class ResponsiveDialog extends StatelessWidget { final Widget? title; final Widget child; final List actions; + final Function()? onCancel; const ResponsiveDialog( - {Key? key, required this.child, this.title, this.actions = const []}) + {Key? key, + required this.child, + this.title, + this.actions = const [], + this.onCancel}) : super(key: key); @override @@ -20,6 +25,12 @@ class ResponsiveDialog extends StatelessWidget { appBar: AppBar( title: title, actions: actions, + leading: BackButton( + onPressed: () { + onCancel?.call(); + Navigator.of(context).pop(); + }, + ), ), body: SingleChildScrollView( padding: const EdgeInsets.all(18.0), @@ -40,6 +51,7 @@ class ResponsiveDialog extends StatelessWidget { TextButton( child: const Text('Cancel'), onPressed: () { + onCancel?.call(); Navigator.of(context).pop(); }, ), diff --git a/lib/desktop/fido/state.dart b/lib/desktop/fido/state.dart index c0cdcd1c..3e790ce0 100755 --- a/lib/desktop/fido/state.dart +++ b/lib/desktop/fido/state.dart @@ -1,12 +1,13 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; -import 'package:yubico_authenticator/desktop/models.dart'; import '../../app/models.dart'; import '../../fido/models.dart'; import '../../fido/state.dart'; +import '../models.dart'; import '../rpc.dart'; import '../state.dart'; @@ -50,9 +51,30 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier { } @override - Future reset() async { - // TODO: implement reset - throw UnimplementedError(); + Stream reset() { + final signaler = Signaler(); + final controller = StreamController(); + + controller.onCancel = () { + if (!controller.isClosed) { + signaler.cancel(); + } + }; + controller.onListen = () async { + try { + await _session.command('reset', signal: signaler); + await refresh(); + await controller.sink.close(); + } catch (e) { + controller.sink.addError(e); + } + }; + controller.sink.addStream(signaler.signals + .where((s) => s.status == 'reset') + .map((signal) => InteractionEvent.values + .firstWhere((e) => e.name == signal.body['state']))); + + return controller.stream; } @override @@ -62,6 +84,7 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier { 'pin': oldPin, 'new_pin': newPin, }); + await refresh(); return PinResult.success(); } on RpcError catch (e) { if (e.status == 'pin-validation') { diff --git a/lib/desktop/rpc.dart b/lib/desktop/rpc.dart index 23095e86..5af47385 100644 --- a/lib/desktop/rpc.dart +++ b/lib/desktop/rpc.dart @@ -139,7 +139,12 @@ class RpcSession { _log.fine('RECV', jsonEncode(response)); response.map( signal: (signal) { - request.signal?._recv.sink.add(signal); + final signaler = request.signal; + if (signaler != null) { + signaler._recv.sink.add(signal); + } else { + _log.warning('Received unhandled signal: $signal'); + } }, success: (success) { request.completer.complete(success.body); diff --git a/lib/fido/models.dart b/lib/fido/models.dart index 16905974..56212f28 100755 --- a/lib/fido/models.dart +++ b/lib/fido/models.dart @@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'models.freezed.dart'; part 'models.g.dart'; +enum InteractionEvent { remove, insert, touch } + @freezed class FidoState with _$FidoState { const FidoState._(); diff --git a/lib/fido/state.dart b/lib/fido/state.dart index 719e820c..4781aa9d 100755 --- a/lib/fido/state.dart +++ b/lib/fido/state.dart @@ -10,7 +10,7 @@ final fidoStateProvider = StateNotifierProvider.autoDispose ); abstract class FidoStateNotifier extends ApplicationStateNotifier { - Future reset(); + Stream reset(); Future unlock(String pin); Future setPin(String newPin, {String? oldPin}); } diff --git a/lib/fido/views/fido_screen.dart b/lib/fido/views/fido_screen.dart index 2f4d2499..ecf27f3e 100755 --- a/lib/fido/views/fido_screen.dart +++ b/lib/fido/views/fido_screen.dart @@ -10,6 +10,7 @@ import '../../app/models.dart'; import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_loading_screen.dart'; import '../state.dart'; +import 'reset_dialog.dart'; class FidoScreen extends ConsumerWidget { final YubiKeyData deviceData; @@ -76,12 +77,18 @@ class FidoScreen extends ConsumerWidget { title: Text('Credentials'), subtitle: Text('Manage stored credentials on key'), ), - const ListTile( - leading: CircleAvatar( + ListTile( + leading: const CircleAvatar( child: Icon(Icons.delete_forever), ), - title: Text('Factory reset'), - subtitle: Text('Delete all data and remove PIN'), + title: const Text('Factory reset'), + subtitle: const Text('Delete all data and remove PIN'), + onTap: () async { + await showDialog( + context: context, + builder: (context) => ResetDialog(deviceData.node), + ); + }, ), ], )); diff --git a/lib/fido/views/reset_dialog.dart b/lib/fido/views/reset_dialog.dart new file mode 100755 index 00000000..7897a363 --- /dev/null +++ b/lib/fido/views/reset_dialog.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yubico_authenticator/core/models.dart'; + +import '../state.dart'; +import '../../app/views/responsive_dialog.dart'; +import '../../fido/models.dart'; +import '../../app/models.dart'; +import '../../app/state.dart'; + +class ResetDialog extends ConsumerStatefulWidget { + final DeviceNode node; + const ResetDialog(this.node, {Key? key}) : super(key: key); + + @override + ConsumerState createState() => _ResetDialogState(); +} + +class _ResetDialogState extends ConsumerState { + StreamSubscription? _subscription; + InteractionEvent? _interaction; + + String _getMessage() { + final nfc = widget.node.transport == Transport.nfc; + switch (_interaction) { + case InteractionEvent.remove: + return nfc + ? 'Remove your YubiKey from the NFC reader' + : 'Unplug your YubiKey'; + case InteractionEvent.insert: + return nfc + ? 'Place your YubiKey back on the reader' + : 'Re-insert your YubiKey'; + case InteractionEvent.touch: + return 'Touch your YubiKey now'; + case null: + return 'Press reset to begin...'; + } + } + + @override + Widget build(BuildContext context) { + // If current device changes, we need to pop back to the main Page. + ref.listen(currentDeviceProvider, (previous, next) { + Navigator.of(context).pop(); + }); + + return ResponsiveDialog( + title: const Text('Factory reset'), + child: Column( + children: [ + const Text( + 'Warning! This will irrevocably delete all U2F and FIDO2 accounts from your YubiKey.'), + Text( + 'Your credentials, as well as any PIN set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.', + style: Theme.of(context).textTheme.bodyText1, + ), + Text(_getMessage(), style: Theme.of(context).textTheme.headline6), + ] + .map((e) => Padding( + child: e, + padding: const EdgeInsets.symmetric(vertical: 8.0), + )) + .toList(), + ), + onCancel: () { + _subscription?.cancel(); + }, + actions: [ + TextButton( + onPressed: _subscription == null + ? () async { + _subscription = ref + .read(fidoStateProvider(widget.node.path).notifier) + .reset() + .listen( + (event) { + setState(() { + _interaction = event; + }); + }, + onDone: () { + _subscription = null; + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('FIDO application reset'), + duration: Duration(seconds: 2), + ), + ); + }, + ); + } + : null, + child: const Text('Reset'), + ), + ], + ); + } +} diff --git a/ykman-rpc/rpc/device.py b/ykman-rpc/rpc/device.py index 81a5bbd3..47ffbe09 100644 --- a/ykman-rpc/rpc/device.py +++ b/ykman-rpc/rpc/device.py @@ -245,7 +245,7 @@ class AbstractDeviceNode(RpcNode): info=asdict(self._info), ) except Exception: - logger.error(f"Unable to connect via {conn_type}", exc_info=True) + logger.warning(f"Unable to connect via {conn_type}", exc_info=True) raise ValueError("No supported connections") diff --git a/ykman-rpc/rpc/fido.py b/ykman-rpc/rpc/fido.py index a8fe54b9..1b4c71ed 100644 --- a/ykman-rpc/rpc/fido.py +++ b/ykman-rpc/rpc/fido.py @@ -26,7 +26,7 @@ # POSSIBILITY OF SUCH DAMAGE. -from .base import RpcNode, action, child, RpcException +from .base import RpcNode, action, child, RpcException, TimeoutException from fido2.ctap import CtapError from fido2.ctap2 import ( Ctap2, @@ -35,7 +35,14 @@ from fido2.ctap2 import ( FPBioEnrollment, CaptureError, ) +from fido2.pcsc import CtapPcscDevice +from yubikit.core.fido import FidoConnection +from ykman.hid import list_ctap_devices as list_ctap +from ykman.pcsc import list_devices as list_ccid +from smartcard.Exceptions import NoCardException, CardConnectionException + from dataclasses import asdict +from time import sleep import logging logger = logging.getLogger(__name__) @@ -50,6 +57,10 @@ class PinValidationException(RpcException): ) +def _ctap_id(ctap): + return (ctap.info.aaguid, ctap.info.firmware_version) + + class Ctap2Node(RpcNode): def __init__(self, connection): super().__init__() @@ -79,9 +90,68 @@ class Ctap2Node(RpcNode): data.update(uv_retries=uv_retries) return data + def _prepare_reset_nfc(self, event, signal): + reader_name = self.ctap.device._name + devices = list_ccid(reader_name) + if not devices or devices[0].reader.name != reader_name: + raise ValueError("Unable to isolate NFC reader") + dev = devices[0] + logger.debug(f"Reset over NFC using reader: {dev.reader.name}") + + signal("reset", dict(state="remove")) + removed = False + while not event.wait(0.5): + sleep(0.5) + try: + with dev.open_connection(FidoConnection): + if removed: + sleep(1.0) # Wait for the device to settle + return dev.open_connection(FidoConnection) + except CardConnectionException: + pass # Expected, ignore + except NoCardException: + if not removed: + signal("reset", dict(state="insert")) + removed = True + + raise TimeoutException() + + def _prepare_reset_usb(self, event, signal): + target = _ctap_id(self.ctap) + logger.debug(f"Reset over USB: {target}") + + # TODO: Filter on target + n_keys = len(list_ctap()) + if n_keys > 1: + raise ValueError("Only one YubiKey can be connected to perform a reset.") + + signal("reset", dict(state="remove")) + removed = False + while not event.wait(0.5): + sleep(0.5) + keys = list_ctap() + if not keys: + if not removed: + signal("reset", dict(state="insert")) + removed = True + elif removed and len(keys) == 1: + connection = keys[0].open_connection(FidoConnection) + signal("reset", dict(state="touch")) + return connection + + raise TimeoutException() + @action def reset(self, params, event, signal): + if isinstance(self.ctap.device, CtapPcscDevice): + connection = self._prepare_reset_nfc(event, signal) + else: + connection = self._prepare_reset_usb(event, signal) + + logger.debug("Performing reset...") + self.ctap = Ctap2(connection) self.ctap.reset(event) + self._info = self.ctap.get_info() self._pin = None self._auth_blocked = False return dict()