From b71d17386a13aadf422e1fd131af1f8eb79d852c Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Thu, 17 Mar 2022 13:06:48 +0100 Subject: [PATCH] Add FIDO PIN management. --- lib/desktop/fido/state.dart | 22 ++- lib/fido/models.dart | 14 ++ lib/fido/models.freezed.dart | 321 ++++++++++++++++++++++++++++++++ lib/fido/state.dart | 4 +- lib/fido/views/fido_screen.dart | 55 +++++- lib/fido/views/pin_dialog.dart | 147 +++++++++++++++ ykman-rpc/rpc/fido.py | 68 +++++-- 7 files changed, 605 insertions(+), 26 deletions(-) create mode 100755 lib/fido/views/pin_dialog.dart diff --git a/lib/desktop/fido/state.dart b/lib/desktop/fido/state.dart index 7bfafd4d..c0cdcd1c 100755 --- a/lib/desktop/fido/state.dart +++ b/lib/desktop/fido/state.dart @@ -2,6 +2,7 @@ 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'; @@ -49,19 +50,30 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier { } @override - Future reset() { + Future reset() async { // TODO: implement reset throw UnimplementedError(); } @override - Future setPin(String newPin, {String? oldPin}) { - // TODO: implement setPin - throw UnimplementedError(); + Future setPin(String newPin, {String? oldPin}) async { + try { + await _session.command('set_pin', params: { + 'pin': oldPin, + 'new_pin': newPin, + }); + return PinResult.success(); + } on RpcError catch (e) { + if (e.status == 'pin-validation') { + return PinResult.failed(e.body['retries'], e.body['auth_blocked']); + } + rethrow; + } + // TODO: Update state } @override - Future unlock(String pin) { + Future unlock(String pin) { // TODO: implement unlock throw UnimplementedError(); } diff --git a/lib/fido/models.dart b/lib/fido/models.dart index 9a75bb13..16905974 100755 --- a/lib/fido/models.dart +++ b/lib/fido/models.dart @@ -16,4 +16,18 @@ class FidoState with _$FidoState { _$FidoStateFromJson(json); bool get hasPin => info['options']['clientPin'] == true; + + int get minPinLength => info['min_pin_length'] as int; + + bool get credMgmt => + info['options']['credMgmt'] == true || + info['options']['credentialMgmtPreview'] == true; + + bool? get bioEnroll => info['options']['bioEnroll']; +} + +@freezed +class PinResult with _$PinResult { + factory PinResult.success() = _Success; + factory PinResult.failed(int retries, bool authBlocked) = _Failure; } diff --git a/lib/fido/models.freezed.dart b/lib/fido/models.freezed.dart index 0285184a..99bfd3aa 100755 --- a/lib/fido/models.freezed.dart +++ b/lib/fido/models.freezed.dart @@ -178,3 +178,324 @@ abstract class _FidoState extends FidoState { _$FidoStateCopyWith<_FidoState> get copyWith => throw _privateConstructorUsedError; } + +/// @nodoc +class _$PinResultTearOff { + const _$PinResultTearOff(); + + _Success success() { + return _Success(); + } + + _Failure failed(int retries, bool authBlocked) { + return _Failure( + retries, + authBlocked, + ); + } +} + +/// @nodoc +const $PinResult = _$PinResultTearOff(); + +/// @nodoc +mixin _$PinResult { + @optionalTypeArgs + TResult when({ + required TResult Function() success, + required TResult Function(int retries, bool authBlocked) failed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult Function()? success, + TResult Function(int retries, bool authBlocked)? failed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? success, + TResult Function(int retries, bool authBlocked)? failed, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_Success value) success, + required TResult Function(_Failure value) failed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult Function(_Success value)? success, + TResult Function(_Failure value)? failed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Success value)? success, + TResult Function(_Failure value)? failed, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PinResultCopyWith<$Res> { + factory $PinResultCopyWith(PinResult value, $Res Function(PinResult) then) = + _$PinResultCopyWithImpl<$Res>; +} + +/// @nodoc +class _$PinResultCopyWithImpl<$Res> implements $PinResultCopyWith<$Res> { + _$PinResultCopyWithImpl(this._value, this._then); + + final PinResult _value; + // ignore: unused_field + final $Res Function(PinResult) _then; +} + +/// @nodoc +abstract class _$SuccessCopyWith<$Res> { + factory _$SuccessCopyWith(_Success value, $Res Function(_Success) then) = + __$SuccessCopyWithImpl<$Res>; +} + +/// @nodoc +class __$SuccessCopyWithImpl<$Res> extends _$PinResultCopyWithImpl<$Res> + implements _$SuccessCopyWith<$Res> { + __$SuccessCopyWithImpl(_Success _value, $Res Function(_Success) _then) + : super(_value, (v) => _then(v as _Success)); + + @override + _Success get _value => super._value as _Success; +} + +/// @nodoc + +class _$_Success implements _Success { + _$_Success(); + + @override + String toString() { + return 'PinResult.success()'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _Success); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() success, + required TResult Function(int retries, bool authBlocked) failed, + }) { + return success(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult Function()? success, + TResult Function(int retries, bool authBlocked)? failed, + }) { + return success?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? success, + TResult Function(int retries, bool authBlocked)? failed, + required TResult orElse(), + }) { + if (success != null) { + return success(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Success value) success, + required TResult Function(_Failure value) failed, + }) { + return success(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult Function(_Success value)? success, + TResult Function(_Failure value)? failed, + }) { + return success?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Success value)? success, + TResult Function(_Failure value)? failed, + required TResult orElse(), + }) { + if (success != null) { + return success(this); + } + return orElse(); + } +} + +abstract class _Success implements PinResult { + factory _Success() = _$_Success; +} + +/// @nodoc +abstract class _$FailureCopyWith<$Res> { + factory _$FailureCopyWith(_Failure value, $Res Function(_Failure) then) = + __$FailureCopyWithImpl<$Res>; + $Res call({int retries, bool authBlocked}); +} + +/// @nodoc +class __$FailureCopyWithImpl<$Res> extends _$PinResultCopyWithImpl<$Res> + implements _$FailureCopyWith<$Res> { + __$FailureCopyWithImpl(_Failure _value, $Res Function(_Failure) _then) + : super(_value, (v) => _then(v as _Failure)); + + @override + _Failure get _value => super._value as _Failure; + + @override + $Res call({ + Object? retries = freezed, + Object? authBlocked = freezed, + }) { + return _then(_Failure( + retries == freezed + ? _value.retries + : retries // ignore: cast_nullable_to_non_nullable + as int, + authBlocked == freezed + ? _value.authBlocked + : authBlocked // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$_Failure implements _Failure { + _$_Failure(this.retries, this.authBlocked); + + @override + final int retries; + @override + final bool authBlocked; + + @override + String toString() { + return 'PinResult.failed(retries: $retries, authBlocked: $authBlocked)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _Failure && + const DeepCollectionEquality().equals(other.retries, retries) && + const DeepCollectionEquality() + .equals(other.authBlocked, authBlocked)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(retries), + const DeepCollectionEquality().hash(authBlocked)); + + @JsonKey(ignore: true) + @override + _$FailureCopyWith<_Failure> get copyWith => + __$FailureCopyWithImpl<_Failure>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() success, + required TResult Function(int retries, bool authBlocked) failed, + }) { + return failed(retries, authBlocked); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult Function()? success, + TResult Function(int retries, bool authBlocked)? failed, + }) { + return failed?.call(retries, authBlocked); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? success, + TResult Function(int retries, bool authBlocked)? failed, + required TResult orElse(), + }) { + if (failed != null) { + return failed(retries, authBlocked); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Success value) success, + required TResult Function(_Failure value) failed, + }) { + return failed(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult Function(_Success value)? success, + TResult Function(_Failure value)? failed, + }) { + return failed?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Success value)? success, + TResult Function(_Failure value)? failed, + required TResult orElse(), + }) { + if (failed != null) { + return failed(this); + } + return orElse(); + } +} + +abstract class _Failure implements PinResult { + factory _Failure(int retries, bool authBlocked) = _$_Failure; + + int get retries; + bool get authBlocked; + @JsonKey(ignore: true) + _$FailureCopyWith<_Failure> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/fido/state.dart b/lib/fido/state.dart index bf558220..719e820c 100755 --- a/lib/fido/state.dart +++ b/lib/fido/state.dart @@ -11,6 +11,6 @@ final fidoStateProvider = StateNotifierProvider.autoDispose abstract class FidoStateNotifier extends ApplicationStateNotifier { Future reset(); - Future unlock(String pin); - Future setPin(String newPin, {String? oldPin}); + 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 73fa963e..2f4d2499 100755 --- a/lib/fido/views/fido_screen.dart +++ b/lib/fido/views/fido_screen.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yubico_authenticator/desktop/state.dart'; +import 'package:yubico_authenticator/fido/views/pin_dialog.dart'; +import 'package:yubico_authenticator/management/models.dart'; import '../../app/models.dart'; import '../../app/views/app_failure_screen.dart'; @@ -18,6 +20,14 @@ class FidoScreen extends ConsumerWidget { ref.watch(fidoStateProvider(deviceData.node.path)).when( none: () => const AppLoadingScreen(), failure: (reason) { + final fido2 = deviceData.info + .supportedCapabilities[deviceData.node.transport]! & + Capability.fido2.value != + 0; + if (!fido2) { + return const AppFailureScreen( + 'WebAuthn is supported by this device, but there are no management options available.'); + } if (Platform.isWindows) { if (!ref .watch(rpcStateProvider.select((state) => state.isAdmin))) { @@ -25,11 +35,54 @@ class FidoScreen extends ConsumerWidget { 'WebAuthn management requires elevated privileges.\nRestart this app as administrator.'); } } + if (deviceData.info + .supportedCapabilities[deviceData.node.transport]! & + Capability.fido2.value == + 0) {} return AppFailureScreen(reason); }, success: (state) => ListView( children: [ - Text('${state.info}'), + ListTile( + leading: const CircleAvatar( + child: Icon(Icons.pin), + ), + title: const Text('PIN'), + subtitle: + Text(state.hasPin ? 'Change your PIN' : 'Set a PIN'), + onTap: () { + showDialog( + context: context, + builder: (context) => + FidoPinDialog(deviceData.node.path, state), + ); + }, + ), + if (state.bioEnroll != null) + ListTile( + leading: const CircleAvatar( + child: Icon(Icons.fingerprint), + ), + title: const Text('Fingerprints'), + subtitle: Text(state.bioEnroll == true + ? 'Fingerprints have been registered' + : 'No fingerprints registered'), + ), + if (state.credMgmt) + const ListTile( + leading: CircleAvatar( + child: Icon(Icons.account_box), + ), + title: Text('Credentials'), + subtitle: Text('Manage stored credentials on key'), + ), + const ListTile( + leading: CircleAvatar( + child: Icon(Icons.delete_forever), + ), + title: Text('Factory reset'), + subtitle: Text('Delete all data and remove PIN'), + ), ], )); } diff --git a/lib/fido/views/pin_dialog.dart b/lib/fido/views/pin_dialog.dart new file mode 100755 index 00000000..ce4a41b1 --- /dev/null +++ b/lib/fido/views/pin_dialog.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../../app/views/responsive_dialog.dart'; +import '../models.dart'; +import '../state.dart'; + +class FidoPinDialog extends ConsumerStatefulWidget { + final DevicePath devicePath; + final FidoState state; + const FidoPinDialog(this.devicePath, this.state, {Key? key}) + : super(key: key); + + @override + ConsumerState createState() => _FidoPinDialogState(); +} + +class _FidoPinDialogState extends ConsumerState { + String _currentPin = ''; + String _newPin = ''; + String _confirmPin = ''; + String? _currentPinError; + String? _newPinError; + + @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(); + }); + + final minPinLength = widget.state.minPinLength; + final hasPin = widget.state.hasPin; + + return ResponsiveDialog( + title: Text(hasPin ? 'Change PIN' : 'Set PIN'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasPin) ...[ + Text( + 'Current PIN', + style: Theme.of(context).textTheme.headline6, + ), + TextFormField( + initialValue: _currentPin, + autofocus: true, + obscureText: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'Current PIN', + errorText: _currentPinError, + ), + onChanged: (value) { + setState(() { + _currentPin = value; + }); + }, + ), + ], + Text( + 'New PIN', + style: Theme.of(context).textTheme.headline6, + ), + TextFormField( + initialValue: _newPin, + autofocus: !hasPin, + obscureText: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'New password', + enabled: !hasPin || _currentPin.isNotEmpty, + errorText: _newPinError, + ), + onChanged: (value) { + setState(() { + _newPin = value; + }); + }, + ), + TextFormField( + initialValue: _confirmPin, + obscureText: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'Confirm password', + enabled: _newPin.isNotEmpty, + ), + onChanged: (value) { + setState(() { + _confirmPin = value; + }); + }, + ), + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), + actions: [ + TextButton( + child: const Text('Save'), + onPressed: _newPin.isNotEmpty && + _newPin == _confirmPin && + (!hasPin || _currentPin.isNotEmpty) + ? () async { + final oldPin = _currentPin.isNotEmpty ? _currentPin : null; + if (_newPin.length < minPinLength) { + setState(() { + _newPinError = + 'New PIN must be at least $minPinLength characters'; + }); + return; + } + final result = await ref + .read(fidoStateProvider(widget.devicePath).notifier) + .setPin(_newPin, oldPin: oldPin); + result.when(success: () { + Navigator.of(context).pop(true); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('PIN set'), + duration: Duration(seconds: 2), + ), + ); + }, failed: (retries, authBlocked) { + setState(() { + if (authBlocked) { + _currentPinError = + 'PIN has been blocked until the YubiKey is removed and reinserted'; + } else { + _currentPinError = + 'Wrong PIN ($retries tries remaining)'; + } + }); + }); + } + : null, + ), + ], + ); + } +} diff --git a/ykman-rpc/rpc/fido.py b/ykman-rpc/rpc/fido.py index 9f066bbb..a8fe54b9 100644 --- a/ykman-rpc/rpc/fido.py +++ b/ykman-rpc/rpc/fido.py @@ -26,7 +26,8 @@ # POSSIBILITY OF SUCH DAMAGE. -from .base import RpcNode, action, child +from .base import RpcNode, action, child, RpcException +from fido2.ctap import CtapError from fido2.ctap2 import ( Ctap2, ClientPin, @@ -40,6 +41,15 @@ import logging logger = logging.getLogger(__name__) +class PinValidationException(RpcException): + def __init__(self, retries, auth_blocked): + super().__init__( + "pin-validation", + "Authentication is required", + dict(retries=retries, auth_blocked=auth_blocked), + ) + + class Ctap2Node(RpcNode): def __init__(self, connection): super().__init__() @@ -47,11 +57,14 @@ class Ctap2Node(RpcNode): self._info = self.ctap.info self.client_pin = ClientPin(self.ctap) self._pin = None + self._auth_blocked = False def get_data(self): self._info = self.ctap.get_info() logger.debug(f"Info: {self._info}") - data = dict(info=asdict(self._info), locked=False) + data = dict( + info=asdict(self._info), locked=False, auth_blocked=self._auth_blocked + ) if self._info.options.get("clientPin"): data["locked"] = self._pin is None pin_retries, power_cycle = self.client_pin.get_pin_retries() @@ -70,31 +83,50 @@ class Ctap2Node(RpcNode): def reset(self, params, event, signal): self.ctap.reset(event) self._pin = None + self._auth_blocked = False return dict() + def _handle_pin_error(self, e): + if e.code in ( + CtapError.ERR.PIN_INVALID, + CtapError.ERR.PIN_BLOCKED, + CtapError.ERR.PIN_AUTH_BLOCKED, + ): + pin_retries, _ = self.client_pin.get_pin_retries() + raise PinValidationException( + pin_retries, e.code == CtapError.ERR.PIN_AUTH_BLOCKED + ) + raise e + @action(condition=lambda self: self._info.options["clientPin"]) def verify_pin(self, params, event, signal): pin = params.pop("pin") - self.client_pin.get_pin_token( - pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com" - ) - self._pin = pin - return dict() + try: + self.client_pin.get_pin_token( + pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com" + ) + self._pin = pin + return dict() + except CtapError as e: + return self._handle_pin_error(e) @action def set_pin(self, params, event, signal): has_pin = self.ctap.get_info().options["clientPin"] - if has_pin: - self.client_pin.change_pin( - params.pop("pin"), - params.pop("new_pin"), - ) - else: - self.client_pin.set_pin( - params.pop("new_pin"), - ) - self._pin = None - return dict() + try: + if has_pin: + self.client_pin.change_pin( + params.pop("pin"), + params.pop("new_pin"), + ) + else: + self.client_pin.set_pin( + params.pop("new_pin"), + ) + self._pin = None + return dict() + except CtapError as e: + return self._handle_pin_error(e) @child(condition=lambda self: "bioEnroll" in self._info.options and self._pin) def fingerprints(self):