Add FIDO reset support.

This commit is contained in:
Dain Nilsson 2022-03-17 20:10:10 +01:00
parent b71d17386a
commit 69165a63c2
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
9 changed files with 234 additions and 13 deletions

View File

@ -4,9 +4,14 @@ class ResponsiveDialog extends StatelessWidget {
final Widget? title;
final Widget child;
final List<Widget> 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();
},
),

View File

@ -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<void> reset() async {
// TODO: implement reset
throw UnimplementedError();
Stream<InteractionEvent> reset() {
final signaler = Signaler();
final controller = StreamController<InteractionEvent>();
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') {

View File

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

View File

@ -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._();

View File

@ -10,7 +10,7 @@ final fidoStateProvider = StateNotifierProvider.autoDispose
);
abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
Future<void> reset();
Stream<InteractionEvent> reset();
Future<PinResult> unlock(String pin);
Future<PinResult> setPin(String newPin, {String? oldPin});
}

View File

@ -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),
);
},
),
],
));

102
lib/fido/views/reset_dialog.dart Executable file
View File

@ -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<ConsumerStatefulWidget> createState() => _ResetDialogState();
}
class _ResetDialogState extends ConsumerState<ResetDialog> {
StreamSubscription<InteractionEvent>? _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<DeviceNode?>(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'),
),
],
);
}
}

View File

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

View File

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