Add FIDO PIN management.

This commit is contained in:
Dain Nilsson 2022-03-17 13:06:48 +01:00
parent a864787329
commit b71d17386a
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
7 changed files with 605 additions and 26 deletions

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:yubico_authenticator/desktop/models.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../fido/models.dart'; import '../../fido/models.dart';
@ -49,19 +50,30 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
} }
@override @override
Future<void> reset() { Future<void> reset() async {
// TODO: implement reset // TODO: implement reset
throw UnimplementedError(); throw UnimplementedError();
} }
@override @override
Future<void> setPin(String newPin, {String? oldPin}) { Future<PinResult> setPin(String newPin, {String? oldPin}) async {
// TODO: implement setPin try {
throw UnimplementedError(); 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 @override
Future<void> unlock(String pin) { Future<PinResult> unlock(String pin) {
// TODO: implement unlock // TODO: implement unlock
throw UnimplementedError(); throw UnimplementedError();
} }

View File

@ -16,4 +16,18 @@ class FidoState with _$FidoState {
_$FidoStateFromJson(json); _$FidoStateFromJson(json);
bool get hasPin => info['options']['clientPin'] == true; 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;
} }

View File

@ -178,3 +178,324 @@ abstract class _FidoState extends FidoState {
_$FidoStateCopyWith<_FidoState> get copyWith => _$FidoStateCopyWith<_FidoState> get copyWith =>
throw _privateConstructorUsedError; 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<TResult extends Object?>({
required TResult Function() success,
required TResult Function(int retries, bool authBlocked) failed,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult Function()? success,
TResult Function(int retries, bool authBlocked)? failed,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? success,
TResult Function(int retries, bool authBlocked)? failed,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Success value) success,
required TResult Function(_Failure value) failed,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult Function(_Success value)? success,
TResult Function(_Failure value)? failed,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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<TResult extends Object?>({
required TResult Function() success,
required TResult Function(int retries, bool authBlocked) failed,
}) {
return success();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult Function()? success,
TResult Function(int retries, bool authBlocked)? failed,
}) {
return success?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? success,
TResult Function(int retries, bool authBlocked)? failed,
required TResult orElse(),
}) {
if (success != null) {
return success();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Success value) success,
required TResult Function(_Failure value) failed,
}) {
return success(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult Function(_Success value)? success,
TResult Function(_Failure value)? failed,
}) {
return success?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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<TResult extends Object?>({
required TResult Function() success,
required TResult Function(int retries, bool authBlocked) failed,
}) {
return failed(retries, authBlocked);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult Function()? success,
TResult Function(int retries, bool authBlocked)? failed,
}) {
return failed?.call(retries, authBlocked);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
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<TResult extends Object?>({
required TResult Function(_Success value) success,
required TResult Function(_Failure value) failed,
}) {
return failed(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult Function(_Success value)? success,
TResult Function(_Failure value)? failed,
}) {
return failed?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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;
}

View File

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

View File

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/desktop/state.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/models.dart';
import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_failure_screen.dart';
@ -18,6 +20,14 @@ class FidoScreen extends ConsumerWidget {
ref.watch(fidoStateProvider(deviceData.node.path)).when( ref.watch(fidoStateProvider(deviceData.node.path)).when(
none: () => const AppLoadingScreen(), none: () => const AppLoadingScreen(),
failure: (reason) { 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 (Platform.isWindows) {
if (!ref if (!ref
.watch(rpcStateProvider.select((state) => state.isAdmin))) { .watch(rpcStateProvider.select((state) => state.isAdmin))) {
@ -25,11 +35,54 @@ class FidoScreen extends ConsumerWidget {
'WebAuthn management requires elevated privileges.\nRestart this app as administrator.'); 'WebAuthn management requires elevated privileges.\nRestart this app as administrator.');
} }
} }
if (deviceData.info
.supportedCapabilities[deviceData.node.transport]! &
Capability.fido2.value ==
0) {}
return AppFailureScreen(reason); return AppFailureScreen(reason);
}, },
success: (state) => ListView( success: (state) => ListView(
children: [ 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'),
),
], ],
)); ));
} }

147
lib/fido/views/pin_dialog.dart Executable file
View File

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

View File

@ -26,7 +26,8 @@
# POSSIBILITY OF SUCH DAMAGE. # 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 ( from fido2.ctap2 import (
Ctap2, Ctap2,
ClientPin, ClientPin,
@ -40,6 +41,15 @@ import logging
logger = logging.getLogger(__name__) 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): class Ctap2Node(RpcNode):
def __init__(self, connection): def __init__(self, connection):
super().__init__() super().__init__()
@ -47,11 +57,14 @@ class Ctap2Node(RpcNode):
self._info = self.ctap.info self._info = self.ctap.info
self.client_pin = ClientPin(self.ctap) self.client_pin = ClientPin(self.ctap)
self._pin = None self._pin = None
self._auth_blocked = False
def get_data(self): def get_data(self):
self._info = self.ctap.get_info() self._info = self.ctap.get_info()
logger.debug(f"Info: {self._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"): if self._info.options.get("clientPin"):
data["locked"] = self._pin is None data["locked"] = self._pin is None
pin_retries, power_cycle = self.client_pin.get_pin_retries() pin_retries, power_cycle = self.client_pin.get_pin_retries()
@ -70,20 +83,37 @@ class Ctap2Node(RpcNode):
def reset(self, params, event, signal): def reset(self, params, event, signal):
self.ctap.reset(event) self.ctap.reset(event)
self._pin = None self._pin = None
self._auth_blocked = False
return dict() 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"]) @action(condition=lambda self: self._info.options["clientPin"])
def verify_pin(self, params, event, signal): def verify_pin(self, params, event, signal):
pin = params.pop("pin") pin = params.pop("pin")
try:
self.client_pin.get_pin_token( self.client_pin.get_pin_token(
pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com" pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com"
) )
self._pin = pin self._pin = pin
return dict() return dict()
except CtapError as e:
return self._handle_pin_error(e)
@action @action
def set_pin(self, params, event, signal): def set_pin(self, params, event, signal):
has_pin = self.ctap.get_info().options["clientPin"] has_pin = self.ctap.get_info().options["clientPin"]
try:
if has_pin: if has_pin:
self.client_pin.change_pin( self.client_pin.change_pin(
params.pop("pin"), params.pop("pin"),
@ -95,6 +125,8 @@ class Ctap2Node(RpcNode):
) )
self._pin = None self._pin = None
return dict() return dict()
except CtapError as e:
return self._handle_pin_error(e)
@child(condition=lambda self: "bioEnroll" in self._info.options and self._pin) @child(condition=lambda self: "bioEnroll" in self._info.options and self._pin)
def fingerprints(self): def fingerprints(self):