diff --git a/lib/desktop/fido/state.dart b/lib/desktop/fido/state.dart index d7a01fe7..6e8ab8d5 100755 --- a/lib/desktop/fido/state.dart +++ b/lib/desktop/fido/state.dart @@ -46,17 +46,11 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier { final RpcNodeSession _session; _DesktopFidoStateNotifier(this._session) : super(); - Future refresh() async { - try { - final result = await _session.command('get'); - _log.config('application status', jsonEncode(result)); - final fidoState = FidoState.fromJson(result['data']); - setState(fidoState); - } catch (error) { - _log.severe('Unable to update FIDO state', jsonEncode(error)); - setFailure('Failed to update FIDO'); - } - } + Future refresh() => updateState(() async { + final result = await _session.command('get'); + _log.config('application status', jsonEncode(result)); + return FidoState.fromJson(result['data']); + }); @override Stream reset() { @@ -104,6 +98,39 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier { } } +final desktopFidoPinProvider = StateNotifierProvider.autoDispose + .family((ref, devicePath) { + return _DesktopPinNotifier(ref.watch(_sessionProvider(devicePath)), + ref.watch(_pinProvider(devicePath).notifier)); +}); + +class _DesktopPinNotifier extends PinNotifier { + final RpcNodeSession _session; + final StateController _pinController; + + _DesktopPinNotifier(this._session, this._pinController) + : super(_pinController.state != null); + + @override + Future unlock(String pin) async { + try { + await _session.command( + 'verify_pin', + params: {'pin': pin}, + ); + _pinController.state = pin; + + return PinResult.success(); + } on RpcError catch (e) { + if (e.status == 'pin-validation') { + _pinController.state = null; + return PinResult.failed(e.body['retries'], e.body['auth_blocked']); + } + rethrow; + } + } +} + final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family< FidoFingerprintsNotifier, AsyncValue>, @@ -116,7 +143,7 @@ final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family< session.setErrorHandler('auth-required', (_) async { final pin = ref.read(_pinProvider(devicePath)); if (pin != null) { - await notifier.unlock(pin, remember: false); + await notifier._unlock(pin); } }); ref.onDispose(() { @@ -127,38 +154,31 @@ final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family< class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier { final RpcNodeSession _session; - final StateController _pin; - bool locked = true; + final StateController _pinNotifier; - _DesktopFidoFingerprintsNotifier(this._session, this._pin) { - final pin = _pin.state; + _DesktopFidoFingerprintsNotifier(this._session, this._pinNotifier) { + final pin = _pinNotifier.state; if (pin != null) { - unlock(pin, remember: false); + _unlock(pin); } else { state = const AsyncValue.error('locked'); } } - @override - Future unlock(String pin, {bool remember = true}) async { + Future _unlock(String pin) async { try { await _session.command( 'unlock', target: ['fingerprints'], params: {'pin': pin}, ); - locked = false; - if (remember) { - _pin.state = pin; - } await _refresh(); - return PinResult.success(); } on RpcError catch (e) { if (e.status == 'pin-validation') { - _pin.state = null; - return PinResult.failed(e.body['retries'], e.body['auth_blocked']); + _pinNotifier.state = null; + } else { + rethrow; } - rethrow; } } @@ -242,7 +262,7 @@ final desktopCredentialProvider = StateNotifierProvider.autoDispose.family< session.setErrorHandler('auth-required', (_) async { final pin = ref.read(_pinProvider(devicePath)); if (pin != null) { - await notifier.unlock(pin, remember: false); + await notifier._unlock(pin); } }); ref.onDispose(() { @@ -253,38 +273,31 @@ final desktopCredentialProvider = StateNotifierProvider.autoDispose.family< class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier { final RpcNodeSession _session; - final StateController _pin; - bool locked = true; + final StateController _pinNotifier; - _DesktopFidoCredentialsNotifier(this._session, this._pin) { - final pin = _pin.state; + _DesktopFidoCredentialsNotifier(this._session, this._pinNotifier) { + final pin = _pinNotifier.state; if (pin != null) { - unlock(pin, remember: false); + _unlock(pin); } else { state = const AsyncValue.error('locked'); } } - @override - Future unlock(String pin, {bool remember = true}) async { + Future _unlock(String pin) async { try { await _session.command( 'unlock', target: ['credentials'], params: {'pin': pin}, ); - locked = false; - if (remember) { - _pin.state = pin; - } await _refresh(); - return PinResult.success(); } on RpcError catch (e) { if (e.status == 'pin-validation') { - _pin.state = null; - return PinResult.failed(e.body['retries'], e.body['auth_blocked']); + _pinNotifier.state = null; + } else { + rethrow; } - rethrow; } } diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart index 789645e3..b824e682 100755 --- a/lib/desktop/init.dart +++ b/lib/desktop/init.dart @@ -100,6 +100,7 @@ Future> initializeAndGetOverrides( qrScannerProvider.overrideWithProvider(desktopQrScannerProvider), managementStateProvider.overrideWithProvider(desktopManagementState), fidoStateProvider.overrideWithProvider(desktopFidoState), + fidoPinProvider.overrideWithProvider(desktopFidoPinProvider), fingerprintProvider.overrideWithProvider(desktopFingerprintProvider), credentialProvider.overrideWithProvider(desktopCredentialProvider), currentDeviceProvider.overrideWithProvider(desktopCurrentDeviceProvider) diff --git a/lib/fido/state.dart b/lib/fido/state.dart index dbf39395..6ee5b22a 100755 --- a/lib/fido/state.dart +++ b/lib/fido/state.dart @@ -15,10 +15,19 @@ abstract class FidoStateNotifier extends ApplicationStateNotifier { Future setPin(String newPin, {String? oldPin}); } +final fidoPinProvider = + StateNotifierProvider.autoDispose.family( + (ref, devicePath) => throw UnimplementedError(), +); + +abstract class PinNotifier extends StateNotifier { + PinNotifier(bool unlocked) : super(unlocked); + Future unlock(String pin); +} + abstract class LockedCollectionNotifier extends StateNotifier>> { LockedCollectionNotifier() : super(const AsyncValue.loading()); - Future unlock(String pin); @protected void setItems(List items) { diff --git a/lib/fido/views/add_fingerprint_dialog.dart b/lib/fido/views/add_fingerprint_dialog.dart index 2e5c9a70..33cda114 100755 --- a/lib/fido/views/add_fingerprint_dialog.dart +++ b/lib/fido/views/add_fingerprint_dialog.dart @@ -41,12 +41,15 @@ class _AddFingerprintDialogState extends ConsumerState super.dispose(); } - Animation _animateColor(Color color, {Function? atPeak}) { + Animation _animateColor(Color color, + {Function? atPeak, bool reverse = true}) { final animation = ColorTween(begin: Colors.black, end: color).animate(_animator); _animator.forward().then((_) { - atPeak?.call(); - _animator.reverse(); + if (reverse) { + atPeak?.call(); + _animator.reverse(); + } }); return animation; } @@ -72,7 +75,7 @@ class _AddFingerprintDialogState extends ConsumerState _samples += 1; _remaining = remaining; }); - }); + }, reverse: remaining > 0); }, complete: (fingerprint) { _remaining = 0; _fingerprint = fingerprint; diff --git a/lib/fido/views/credential_page.dart b/lib/fido/views/credential_page.dart index ac4baac2..60115ba8 100755 --- a/lib/fido/views/credential_page.dart +++ b/lib/fido/views/credential_page.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/models.dart'; +import '../../app/views/app_failure_screen.dart'; import '../models.dart'; import '../state.dart'; import 'delete_credential_dialog.dart'; import 'rename_credential_dialog.dart'; -import 'unlock_view.dart'; class CredentialPage extends ConsumerWidget { final DeviceNode node; @@ -18,13 +18,7 @@ class CredentialPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return ref.watch(credentialProvider(node.path)).when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => UnlockView( - onUnlock: (pin) async { - return ref - .read(credentialProvider(node.path).notifier) - .unlock(pin); - }, - ), + error: (error, _) => AppFailureScreen('$error'), data: (credentials) => ListView( children: [ ListTile( diff --git a/lib/fido/views/fingerprint_page.dart b/lib/fido/views/fingerprint_page.dart index e4984032..54f8bb6b 100755 --- a/lib/fido/views/fingerprint_page.dart +++ b/lib/fido/views/fingerprint_page.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/models.dart'; +import '../../app/views/app_failure_screen.dart'; import '../models.dart'; import '../state.dart'; import 'add_fingerprint_dialog.dart'; import 'delete_fingerprint_dialog.dart'; import 'rename_fingerprint_dialog.dart'; -import 'unlock_view.dart'; class FingerprintPage extends ConsumerWidget { final DeviceNode node; @@ -19,13 +19,7 @@ class FingerprintPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return ref.watch(fingerprintProvider(node.path)).when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => UnlockView( - onUnlock: (pin) async { - return ref - .read(fingerprintProvider(node.path).notifier) - .unlock(pin); - }, - ), + error: (error, _) => AppFailureScreen('$error'), data: (fingerprints) => ListView( children: [ ListTile( diff --git a/lib/fido/views/main_page.dart b/lib/fido/views/main_page.dart index b70b1e4b..e0c8fca7 100755 --- a/lib/fido/views/main_page.dart +++ b/lib/fido/views/main_page.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/models.dart'; import '../models.dart'; +import '../state.dart'; import 'pin_dialog.dart'; +import 'pin_entry_dialog.dart'; import 'reset_dialog.dart'; -class FidoMainPage extends StatelessWidget { +class FidoMainPage extends ConsumerWidget { final DeviceNode node; final FidoState state; final Function(SubPage page) setSubPage; @@ -14,8 +17,21 @@ class FidoMainPage extends StatelessWidget { {required this.setSubPage, Key? key}) : super(key: key); + _openLockedPage(BuildContext context, WidgetRef ref, SubPage subPage) async { + final unlocked = ref.read(fidoPinProvider(node.path)); + if (unlocked) { + setSubPage(subPage); + } else { + final result = await showDialog( + context: context, builder: (context) => PinEntryDialog(node.path)); + if (result == true) { + setSubPage(subPage); + } + } + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return ListView( children: [ ListTile( @@ -41,7 +57,7 @@ class FidoMainPage extends StatelessWidget { ? 'Fingerprints have been registered' : 'No fingerprints registered'), onTap: () { - setSubPage(SubPage.fingerprints); + _openLockedPage(context, ref, SubPage.fingerprints); }, ), if (state.credMgmt) @@ -50,14 +66,13 @@ class FidoMainPage extends StatelessWidget { child: Icon(Icons.account_box), ), title: const Text('Credentials'), + enabled: state.hasPin, subtitle: Text(state.hasPin ? 'Manage stored credentials on key' : 'Set a PIN to manage credentials'), - onTap: state.hasPin - ? () { - setSubPage(SubPage.credentials); - } - : null, + onTap: () { + _openLockedPage(context, ref, SubPage.credentials); + }, ), ListTile( leading: const CircleAvatar( diff --git a/lib/fido/views/pin_entry_dialog.dart b/lib/fido/views/pin_entry_dialog.dart new file mode 100755 index 00000000..8d871c96 --- /dev/null +++ b/lib/fido/views/pin_entry_dialog.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../state.dart'; + +class PinEntryDialog extends ConsumerStatefulWidget { + final DevicePath _devicePath; + const PinEntryDialog(this._devicePath, {Key? key}) : super(key: key); + + @override + ConsumerState createState() => _PinEntryDialogState(); +} + +class _PinEntryDialogState extends ConsumerState { + final _pinController = TextEditingController(); + bool _blocked = false; + int? _retries; + + void _submit() async { + final result = await ref + .read(fidoPinProvider(widget._devicePath).notifier) + .unlock(_pinController.text); + result.when(success: () { + Navigator.pop(context, true); + }, failed: (retries, authBlocked) { + setState(() { + _pinController.clear(); + _retries = retries; + _blocked = authBlocked; + }); + }); + } + + String? _getErrorText() { + if (_retries == 0) { + return 'PIN is blocked. Factory reset the FIDO application.'; + } + if (_blocked) { + return 'PIN temporarily blocked, remove and reinsert your YubiKey.'; + } + if (_retries != null) { + return 'Wrong PIN. $_retries attempts remaining.'; + } + return null; + } + + @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 AlertDialog( + scrollable: true, + title: const Text('Enter PIN'), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter the FIDO PIN for your YubiKey.', + ), + TextField( + autofocus: true, + obscureText: true, + controller: _pinController, + decoration: InputDecoration( + labelText: 'PIN', + errorText: _getErrorText(), + ), + onChanged: (_) => setState(() {}), // Update state on change + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Continue'), + onPressed: + _pinController.text.isNotEmpty && !_blocked ? _submit : null, + ), + ], + ); + } +} diff --git a/lib/fido/views/unlock_view.dart b/lib/fido/views/unlock_view.dart deleted file mode 100755 index 5922b6bf..00000000 --- a/lib/fido/views/unlock_view.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models.dart'; - -class UnlockView extends StatelessWidget { - final Future Function(String pin) onUnlock; - - const UnlockView({required this.onUnlock, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Text( - 'Enter PIN', - style: Theme.of(context).textTheme.headline5, - ), - ), - const Text( - 'Enter the FIDO PIN for your YubiKey. If you don\'t know your PIN, you\'ll need to reset the YubiKey.', - ), - TextField( - autofocus: true, - obscureText: true, - decoration: const InputDecoration(labelText: 'PIN'), - onSubmitted: (pin) async { - // TODO: Handle wrong PIN - await onUnlock(pin); - }, - ), - ], - ), - ), - ], - ); - } -}