Refactor FIDO PIN handling.

This commit is contained in:
Dain Nilsson 2022-03-24 12:39:49 +01:00
parent 6b3bd585ba
commit 37883a1427
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
9 changed files with 194 additions and 116 deletions

View File

@ -46,17 +46,11 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
final RpcNodeSession _session; final RpcNodeSession _session;
_DesktopFidoStateNotifier(this._session) : super(); _DesktopFidoStateNotifier(this._session) : super();
Future<void> refresh() async { Future<void> refresh() => updateState(() async {
try {
final result = await _session.command('get'); final result = await _session.command('get');
_log.config('application status', jsonEncode(result)); _log.config('application status', jsonEncode(result));
final fidoState = FidoState.fromJson(result['data']); return FidoState.fromJson(result['data']);
setState(fidoState); });
} catch (error) {
_log.severe('Unable to update FIDO state', jsonEncode(error));
setFailure('Failed to update FIDO');
}
}
@override @override
Stream<InteractionEvent> reset() { Stream<InteractionEvent> reset() {
@ -104,6 +98,39 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
} }
} }
final desktopFidoPinProvider = StateNotifierProvider.autoDispose
.family<PinNotifier, bool, DevicePath>((ref, devicePath) {
return _DesktopPinNotifier(ref.watch(_sessionProvider(devicePath)),
ref.watch(_pinProvider(devicePath).notifier));
});
class _DesktopPinNotifier extends PinNotifier {
final RpcNodeSession _session;
final StateController<String?> _pinController;
_DesktopPinNotifier(this._session, this._pinController)
: super(_pinController.state != null);
@override
Future<PinResult> 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< final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family<
FidoFingerprintsNotifier, FidoFingerprintsNotifier,
AsyncValue<List<Fingerprint>>, AsyncValue<List<Fingerprint>>,
@ -116,7 +143,7 @@ final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family<
session.setErrorHandler('auth-required', (_) async { session.setErrorHandler('auth-required', (_) async {
final pin = ref.read(_pinProvider(devicePath)); final pin = ref.read(_pinProvider(devicePath));
if (pin != null) { if (pin != null) {
await notifier.unlock(pin, remember: false); await notifier._unlock(pin);
} }
}); });
ref.onDispose(() { ref.onDispose(() {
@ -127,40 +154,33 @@ final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family<
class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier { class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
final RpcNodeSession _session; final RpcNodeSession _session;
final StateController<String?> _pin; final StateController<String?> _pinNotifier;
bool locked = true;
_DesktopFidoFingerprintsNotifier(this._session, this._pin) { _DesktopFidoFingerprintsNotifier(this._session, this._pinNotifier) {
final pin = _pin.state; final pin = _pinNotifier.state;
if (pin != null) { if (pin != null) {
unlock(pin, remember: false); _unlock(pin);
} else { } else {
state = const AsyncValue.error('locked'); state = const AsyncValue.error('locked');
} }
} }
@override Future<void> _unlock(String pin) async {
Future<PinResult> unlock(String pin, {bool remember = true}) async {
try { try {
await _session.command( await _session.command(
'unlock', 'unlock',
target: ['fingerprints'], target: ['fingerprints'],
params: {'pin': pin}, params: {'pin': pin},
); );
locked = false;
if (remember) {
_pin.state = pin;
}
await _refresh(); await _refresh();
return PinResult.success();
} on RpcError catch (e) { } on RpcError catch (e) {
if (e.status == 'pin-validation') { if (e.status == 'pin-validation') {
_pin.state = null; _pinNotifier.state = null;
return PinResult.failed(e.body['retries'], e.body['auth_blocked']); } else {
}
rethrow; rethrow;
} }
} }
}
Future<void> _refresh() async { Future<void> _refresh() async {
final result = await _session.command('fingerprints'); final result = await _session.command('fingerprints');
@ -242,7 +262,7 @@ final desktopCredentialProvider = StateNotifierProvider.autoDispose.family<
session.setErrorHandler('auth-required', (_) async { session.setErrorHandler('auth-required', (_) async {
final pin = ref.read(_pinProvider(devicePath)); final pin = ref.read(_pinProvider(devicePath));
if (pin != null) { if (pin != null) {
await notifier.unlock(pin, remember: false); await notifier._unlock(pin);
} }
}); });
ref.onDispose(() { ref.onDispose(() {
@ -253,40 +273,33 @@ final desktopCredentialProvider = StateNotifierProvider.autoDispose.family<
class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier { class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
final RpcNodeSession _session; final RpcNodeSession _session;
final StateController<String?> _pin; final StateController<String?> _pinNotifier;
bool locked = true;
_DesktopFidoCredentialsNotifier(this._session, this._pin) { _DesktopFidoCredentialsNotifier(this._session, this._pinNotifier) {
final pin = _pin.state; final pin = _pinNotifier.state;
if (pin != null) { if (pin != null) {
unlock(pin, remember: false); _unlock(pin);
} else { } else {
state = const AsyncValue.error('locked'); state = const AsyncValue.error('locked');
} }
} }
@override Future<void> _unlock(String pin) async {
Future<PinResult> unlock(String pin, {bool remember = true}) async {
try { try {
await _session.command( await _session.command(
'unlock', 'unlock',
target: ['credentials'], target: ['credentials'],
params: {'pin': pin}, params: {'pin': pin},
); );
locked = false;
if (remember) {
_pin.state = pin;
}
await _refresh(); await _refresh();
return PinResult.success();
} on RpcError catch (e) { } on RpcError catch (e) {
if (e.status == 'pin-validation') { if (e.status == 'pin-validation') {
_pin.state = null; _pinNotifier.state = null;
return PinResult.failed(e.body['retries'], e.body['auth_blocked']); } else {
}
rethrow; rethrow;
} }
} }
}
Future<void> _refresh() async { Future<void> _refresh() async {
final List<FidoCredential> creds = []; final List<FidoCredential> creds = [];

View File

@ -100,6 +100,7 @@ Future<List<Override>> initializeAndGetOverrides(
qrScannerProvider.overrideWithProvider(desktopQrScannerProvider), qrScannerProvider.overrideWithProvider(desktopQrScannerProvider),
managementStateProvider.overrideWithProvider(desktopManagementState), managementStateProvider.overrideWithProvider(desktopManagementState),
fidoStateProvider.overrideWithProvider(desktopFidoState), fidoStateProvider.overrideWithProvider(desktopFidoState),
fidoPinProvider.overrideWithProvider(desktopFidoPinProvider),
fingerprintProvider.overrideWithProvider(desktopFingerprintProvider), fingerprintProvider.overrideWithProvider(desktopFingerprintProvider),
credentialProvider.overrideWithProvider(desktopCredentialProvider), credentialProvider.overrideWithProvider(desktopCredentialProvider),
currentDeviceProvider.overrideWithProvider(desktopCurrentDeviceProvider) currentDeviceProvider.overrideWithProvider(desktopCurrentDeviceProvider)

View File

@ -15,10 +15,19 @@ abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
Future<PinResult> setPin(String newPin, {String? oldPin}); Future<PinResult> setPin(String newPin, {String? oldPin});
} }
final fidoPinProvider =
StateNotifierProvider.autoDispose.family<PinNotifier, bool, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
);
abstract class PinNotifier extends StateNotifier<bool> {
PinNotifier(bool unlocked) : super(unlocked);
Future<PinResult> unlock(String pin);
}
abstract class LockedCollectionNotifier<T> abstract class LockedCollectionNotifier<T>
extends StateNotifier<AsyncValue<List<T>>> { extends StateNotifier<AsyncValue<List<T>>> {
LockedCollectionNotifier() : super(const AsyncValue.loading()); LockedCollectionNotifier() : super(const AsyncValue.loading());
Future<PinResult> unlock(String pin);
@protected @protected
void setItems(List<T> items) { void setItems(List<T> items) {

View File

@ -41,12 +41,15 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
super.dispose(); super.dispose();
} }
Animation<Color?> _animateColor(Color color, {Function? atPeak}) { Animation<Color?> _animateColor(Color color,
{Function? atPeak, bool reverse = true}) {
final animation = final animation =
ColorTween(begin: Colors.black, end: color).animate(_animator); ColorTween(begin: Colors.black, end: color).animate(_animator);
_animator.forward().then((_) { _animator.forward().then((_) {
if (reverse) {
atPeak?.call(); atPeak?.call();
_animator.reverse(); _animator.reverse();
}
}); });
return animation; return animation;
} }
@ -72,7 +75,7 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
_samples += 1; _samples += 1;
_remaining = remaining; _remaining = remaining;
}); });
}); }, reverse: remaining > 0);
}, complete: (fingerprint) { }, complete: (fingerprint) {
_remaining = 0; _remaining = 0;
_fingerprint = fingerprint; _fingerprint = fingerprint;

View File

@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/views/app_failure_screen.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'delete_credential_dialog.dart'; import 'delete_credential_dialog.dart';
import 'rename_credential_dialog.dart'; import 'rename_credential_dialog.dart';
import 'unlock_view.dart';
class CredentialPage extends ConsumerWidget { class CredentialPage extends ConsumerWidget {
final DeviceNode node; final DeviceNode node;
@ -18,13 +18,7 @@ class CredentialPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(credentialProvider(node.path)).when( return ref.watch(credentialProvider(node.path)).when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => UnlockView( error: (error, _) => AppFailureScreen('$error'),
onUnlock: (pin) async {
return ref
.read(credentialProvider(node.path).notifier)
.unlock(pin);
},
),
data: (credentials) => ListView( data: (credentials) => ListView(
children: [ children: [
ListTile( ListTile(

View File

@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/views/app_failure_screen.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'add_fingerprint_dialog.dart'; import 'add_fingerprint_dialog.dart';
import 'delete_fingerprint_dialog.dart'; import 'delete_fingerprint_dialog.dart';
import 'rename_fingerprint_dialog.dart'; import 'rename_fingerprint_dialog.dart';
import 'unlock_view.dart';
class FingerprintPage extends ConsumerWidget { class FingerprintPage extends ConsumerWidget {
final DeviceNode node; final DeviceNode node;
@ -19,13 +19,7 @@ class FingerprintPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(fingerprintProvider(node.path)).when( return ref.watch(fingerprintProvider(node.path)).when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => UnlockView( error: (error, _) => AppFailureScreen('$error'),
onUnlock: (pin) async {
return ref
.read(fingerprintProvider(node.path).notifier)
.unlock(pin);
},
),
data: (fingerprints) => ListView( data: (fingerprints) => ListView(
children: [ children: [
ListTile( ListTile(

View File

@ -1,11 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart';
import 'pin_dialog.dart'; import 'pin_dialog.dart';
import 'pin_entry_dialog.dart';
import 'reset_dialog.dart'; import 'reset_dialog.dart';
class FidoMainPage extends StatelessWidget { class FidoMainPage extends ConsumerWidget {
final DeviceNode node; final DeviceNode node;
final FidoState state; final FidoState state;
final Function(SubPage page) setSubPage; final Function(SubPage page) setSubPage;
@ -14,8 +17,21 @@ class FidoMainPage extends StatelessWidget {
{required this.setSubPage, Key? key}) {required this.setSubPage, Key? key})
: super(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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return ListView( return ListView(
children: [ children: [
ListTile( ListTile(
@ -41,7 +57,7 @@ class FidoMainPage extends StatelessWidget {
? 'Fingerprints have been registered' ? 'Fingerprints have been registered'
: 'No fingerprints registered'), : 'No fingerprints registered'),
onTap: () { onTap: () {
setSubPage(SubPage.fingerprints); _openLockedPage(context, ref, SubPage.fingerprints);
}, },
), ),
if (state.credMgmt) if (state.credMgmt)
@ -50,14 +66,13 @@ class FidoMainPage extends StatelessWidget {
child: Icon(Icons.account_box), child: Icon(Icons.account_box),
), ),
title: const Text('Credentials'), title: const Text('Credentials'),
enabled: state.hasPin,
subtitle: Text(state.hasPin subtitle: Text(state.hasPin
? 'Manage stored credentials on key' ? 'Manage stored credentials on key'
: 'Set a PIN to manage credentials'), : 'Set a PIN to manage credentials'),
onTap: state.hasPin onTap: () {
? () { _openLockedPage(context, ref, SubPage.credentials);
setSubPage(SubPage.credentials); },
}
: null,
), ),
ListTile( ListTile(
leading: const CircleAvatar( leading: const CircleAvatar(

View File

@ -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<PinEntryDialog> createState() => _PinEntryDialogState();
}
class _PinEntryDialogState extends ConsumerState<PinEntryDialog> {
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<DeviceNode?>(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,
),
],
);
}
}

View File

@ -1,44 +0,0 @@
import 'package:flutter/material.dart';
import '../models.dart';
class UnlockView extends StatelessWidget {
final Future<PinResult> 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);
},
),
],
),
),
],
);
}
}