This commit is contained in:
Dain Nilsson 2022-03-24 15:01:18 +01:00
commit 0ec78278cd
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
34 changed files with 2305 additions and 804 deletions

View File

@ -7,8 +7,8 @@ import '../../app/models.dart';
import '../../app/state.dart';
import '../../management/state.dart';
final androidManagementState = StateNotifierProvider.autoDispose.family<
ManagementStateNotifier, ApplicationStateResult<DeviceInfo>, DevicePath>(
final androidManagementState = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
(ref, devicePath) {
// Make sure to rebuild if currentDevice changes (as on reboot)
ref.watch(currentDeviceProvider);
@ -34,7 +34,7 @@ class _AndroidManagementStateNotifier extends ManagementStateNotifier {
String newLockCode = '',
bool reboot = false}) async {
if (reboot) {
unsetState();
state = const AsyncValue.loading();
}
_ref.read(attachedDevicesProvider.notifier).refresh();

View File

@ -22,7 +22,7 @@ class CancelException implements Exception {}
final oathApiProvider = StateProvider((_) => OathApi());
final androidOathStateProvider = StateNotifierProvider.autoDispose
.family<OathStateNotifier, ApplicationStateResult<OathState>, DevicePath>(
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
(ref, devicePath) => _AndroidOathStateNotifier(
ref.watch(androidStateProvider), ref.watch(oathApiProvider)));
@ -31,7 +31,7 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
_AndroidOathStateNotifier(OathState? newState, this._api) : super() {
if (newState != null) {
setState(newState);
setData(newState);
}
}
@ -52,7 +52,7 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
if (unlockSuccess) {
_log.config('applet unlocked');
setState(requireState().copyWith(locked: false));
setData(state.value!.copyWith(locked: false));
}
return Pair(unlockSuccess, false); // TODO: provide correct second param
} on PlatformException catch (e) {
@ -99,8 +99,8 @@ final androidCredentialListProvider = StateNotifierProvider.autoDispose
var notifier = _AndroidCredentialListNotifier(
ref.watch(oathApiProvider),
ref.watch(androidCredentialsProvider),
ref.watch(oathStateProvider(devicePath).select(
(r) => r.whenOrNull(success: (state) => state.locked) ?? true)),
ref.watch(oathStateProvider(devicePath)
.select((r) => r.whenOrNull(data: (state) => state.locked) ?? true)),
);
ref.listen<WindowState>(windowStateProvider, (_, windowState) {
notifier._notifyWindowState(windowState);

View File

@ -74,13 +74,6 @@ extension Applications on Application {
}
}
@freezed
class ApplicationStateResult<T> with _$ApplicationStateResult {
factory ApplicationStateResult.none() = _None;
factory ApplicationStateResult.failure(String reason) = _Failure;
factory ApplicationStateResult.success(T state) = _Success;
}
@freezed
class YubiKeyData with _$YubiKeyData {
factory YubiKeyData(DeviceNode node, String name, DeviceInfo info) =

View File

@ -13,482 +13,6 @@ T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
class _$ApplicationStateResultTearOff {
const _$ApplicationStateResultTearOff();
_None<T> none<T>() {
return _None<T>();
}
_Failure<T> failure<T>(String reason) {
return _Failure<T>(
reason,
);
}
_Success<T> success<T>(T state) {
return _Success<T>(
state,
);
}
}
/// @nodoc
const $ApplicationStateResult = _$ApplicationStateResultTearOff();
/// @nodoc
mixin _$ApplicationStateResult<T> {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() none,
required TResult Function(String reason) failure,
required TResult Function(T state) success,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_None<T> value) none,
required TResult Function(_Failure<T> value) failure,
required TResult Function(_Success<T> value) success,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ApplicationStateResultCopyWith<T, $Res> {
factory $ApplicationStateResultCopyWith(ApplicationStateResult<T> value,
$Res Function(ApplicationStateResult<T>) then) =
_$ApplicationStateResultCopyWithImpl<T, $Res>;
}
/// @nodoc
class _$ApplicationStateResultCopyWithImpl<T, $Res>
implements $ApplicationStateResultCopyWith<T, $Res> {
_$ApplicationStateResultCopyWithImpl(this._value, this._then);
final ApplicationStateResult<T> _value;
// ignore: unused_field
final $Res Function(ApplicationStateResult<T>) _then;
}
/// @nodoc
abstract class _$NoneCopyWith<T, $Res> {
factory _$NoneCopyWith(_None<T> value, $Res Function(_None<T>) then) =
__$NoneCopyWithImpl<T, $Res>;
}
/// @nodoc
class __$NoneCopyWithImpl<T, $Res>
extends _$ApplicationStateResultCopyWithImpl<T, $Res>
implements _$NoneCopyWith<T, $Res> {
__$NoneCopyWithImpl(_None<T> _value, $Res Function(_None<T>) _then)
: super(_value, (v) => _then(v as _None<T>));
@override
_None<T> get _value => super._value as _None<T>;
}
/// @nodoc
class _$_None<T> implements _None<T> {
_$_None();
@override
String toString() {
return 'ApplicationStateResult<$T>.none()';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is _None<T>);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() none,
required TResult Function(String reason) failure,
required TResult Function(T state) success,
}) {
return none();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
}) {
return none?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
required TResult orElse(),
}) {
if (none != null) {
return none();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_None<T> value) none,
required TResult Function(_Failure<T> value) failure,
required TResult Function(_Success<T> value) success,
}) {
return none(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
}) {
return none?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
required TResult orElse(),
}) {
if (none != null) {
return none(this);
}
return orElse();
}
}
abstract class _None<T> implements ApplicationStateResult<T> {
factory _None() = _$_None<T>;
}
/// @nodoc
abstract class _$FailureCopyWith<T, $Res> {
factory _$FailureCopyWith(
_Failure<T> value, $Res Function(_Failure<T>) then) =
__$FailureCopyWithImpl<T, $Res>;
$Res call({String reason});
}
/// @nodoc
class __$FailureCopyWithImpl<T, $Res>
extends _$ApplicationStateResultCopyWithImpl<T, $Res>
implements _$FailureCopyWith<T, $Res> {
__$FailureCopyWithImpl(_Failure<T> _value, $Res Function(_Failure<T>) _then)
: super(_value, (v) => _then(v as _Failure<T>));
@override
_Failure<T> get _value => super._value as _Failure<T>;
@override
$Res call({
Object? reason = freezed,
}) {
return _then(_Failure<T>(
reason == freezed
? _value.reason
: reason // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class _$_Failure<T> implements _Failure<T> {
_$_Failure(this.reason);
@override
final String reason;
@override
String toString() {
return 'ApplicationStateResult<$T>.failure(reason: $reason)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _Failure<T> &&
const DeepCollectionEquality().equals(other.reason, reason));
}
@override
int get hashCode =>
Object.hash(runtimeType, const DeepCollectionEquality().hash(reason));
@JsonKey(ignore: true)
@override
_$FailureCopyWith<T, _Failure<T>> get copyWith =>
__$FailureCopyWithImpl<T, _Failure<T>>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() none,
required TResult Function(String reason) failure,
required TResult Function(T state) success,
}) {
return failure(reason);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
}) {
return failure?.call(reason);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
required TResult orElse(),
}) {
if (failure != null) {
return failure(reason);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_None<T> value) none,
required TResult Function(_Failure<T> value) failure,
required TResult Function(_Success<T> value) success,
}) {
return failure(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
}) {
return failure?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
required TResult orElse(),
}) {
if (failure != null) {
return failure(this);
}
return orElse();
}
}
abstract class _Failure<T> implements ApplicationStateResult<T> {
factory _Failure(String reason) = _$_Failure<T>;
String get reason;
@JsonKey(ignore: true)
_$FailureCopyWith<T, _Failure<T>> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$SuccessCopyWith<T, $Res> {
factory _$SuccessCopyWith(
_Success<T> value, $Res Function(_Success<T>) then) =
__$SuccessCopyWithImpl<T, $Res>;
$Res call({T state});
}
/// @nodoc
class __$SuccessCopyWithImpl<T, $Res>
extends _$ApplicationStateResultCopyWithImpl<T, $Res>
implements _$SuccessCopyWith<T, $Res> {
__$SuccessCopyWithImpl(_Success<T> _value, $Res Function(_Success<T>) _then)
: super(_value, (v) => _then(v as _Success<T>));
@override
_Success<T> get _value => super._value as _Success<T>;
@override
$Res call({
Object? state = freezed,
}) {
return _then(_Success<T>(
state == freezed
? _value.state
: state // ignore: cast_nullable_to_non_nullable
as T,
));
}
}
/// @nodoc
class _$_Success<T> implements _Success<T> {
_$_Success(this.state);
@override
final T state;
@override
String toString() {
return 'ApplicationStateResult<$T>.success(state: $state)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _Success<T> &&
const DeepCollectionEquality().equals(other.state, state));
}
@override
int get hashCode =>
Object.hash(runtimeType, const DeepCollectionEquality().hash(state));
@JsonKey(ignore: true)
@override
_$SuccessCopyWith<T, _Success<T>> get copyWith =>
__$SuccessCopyWithImpl<T, _Success<T>>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() none,
required TResult Function(String reason) failure,
required TResult Function(T state) success,
}) {
return success(state);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
}) {
return success?.call(state);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? none,
TResult Function(String reason)? failure,
TResult Function(T state)? success,
required TResult orElse(),
}) {
if (success != null) {
return success(state);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_None<T> value) none,
required TResult Function(_Failure<T> value) failure,
required TResult Function(_Success<T> value) success,
}) {
return success(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
}) {
return success?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_None<T> value)? none,
TResult Function(_Failure<T> value)? failure,
TResult Function(_Success<T> value)? success,
required TResult orElse(),
}) {
if (success != null) {
return success(this);
}
return orElse();
}
}
abstract class _Success<T> implements ApplicationStateResult<T> {
factory _Success(T state) = _$_Success<T>;
T get state;
@JsonKey(ignore: true)
_$SuccessCopyWith<T, _Success<T>> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
class _$YubiKeyDataTearOff {
const _$YubiKeyDataTearOff();

View File

@ -5,8 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app/models.dart';
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
final isAndroid = Platform.isAndroid;
@ -28,33 +26,21 @@ class LogLevelNotifier extends StateNotifier<Level> {
}
abstract class ApplicationStateNotifier<T>
extends StateNotifier<ApplicationStateResult<T>> {
ApplicationStateNotifier() : super(ApplicationStateResult.none());
extends StateNotifier<AsyncValue<T>> {
ApplicationStateNotifier() : super(const AsyncValue.loading());
@protected
T requireState() => state.maybeWhen(
success: (state) => state,
orElse: () => throw UnsupportedError('State is not available'),
);
@protected
void setState(T value) {
Future<void> updateState(Future<T> Function() guarded) async {
final result = await AsyncValue.guard(guarded);
if (mounted) {
state = ApplicationStateResult.success(value);
state = result;
}
}
@protected
void setFailure(String reason) {
void setData(T value) {
if (mounted) {
state = ApplicationStateResult.failure(reason);
}
}
@protected
void unsetState() {
if (mounted) {
state = ApplicationStateResult.none();
state = AsyncValue.data(value);
}
}
}

View File

@ -13,14 +13,22 @@ import '../state.dart';
final _log = Logger('desktop.fido.state');
final _pinProvider = StateProvider.autoDispose.family<String?, DevicePath>(
(ref, _) => null,
);
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(ref, devicePath) =>
RpcNodeSession(ref.watch(rpcProvider), devicePath, ['fido', 'ctap2']),
(ref, devicePath) {
// Make sure the pinProvider is held for the duration of the session.
ref.watch(_pinProvider(devicePath));
return RpcNodeSession(
ref.watch(rpcProvider), devicePath, ['fido', 'ctap2']);
},
);
final desktopFidoState = StateNotifierProvider.autoDispose
.family<FidoStateNotifier, ApplicationStateResult<FidoState>, DevicePath>(
.family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>(
(ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final notifier = _DesktopFidoStateNotifier(session);
@ -38,17 +46,11 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
final RpcNodeSession _session;
_DesktopFidoStateNotifier(this._session) : super();
Future<void> refresh() async {
try {
var result = await _session.command('get');
_log.config('application status', jsonEncode(result));
var fidoState = FidoState.fromJson(result['data']);
setState(fidoState);
} catch (error) {
_log.severe('Unable to update FIDO state', jsonEncode(error));
setFailure('Failed to update FIDO');
}
}
Future<void> refresh() => updateState(() async {
final result = await _session.command('get');
_log.config('application status', jsonEncode(result));
return FidoState.fromJson(result['data']);
});
@override
Stream<InteractionEvent> reset() {
@ -93,12 +95,235 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
}
rethrow;
}
// TODO: Update state
}
}
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<
FidoFingerprintsNotifier,
AsyncValue<List<Fingerprint>>,
DevicePath>((ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final notifier = _DesktopFidoFingerprintsNotifier(
session,
ref.watch(_pinProvider(devicePath).notifier),
);
session.setErrorHandler('auth-required', (_) async {
final pin = ref.read(_pinProvider(devicePath));
if (pin != null) {
await notifier._unlock(pin);
}
});
ref.onDispose(() {
session.unsetErrorHandler('auth-required');
});
return notifier;
});
class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
final RpcNodeSession _session;
final StateController<String?> _pinNotifier;
_DesktopFidoFingerprintsNotifier(this._session, this._pinNotifier) {
final pin = _pinNotifier.state;
if (pin != null) {
_unlock(pin);
} else {
state = const AsyncValue.error('locked');
}
}
Future<void> _unlock(String pin) async {
try {
await _session.command(
'unlock',
target: ['fingerprints'],
params: {'pin': pin},
);
await _refresh();
} on RpcError catch (e) {
if (e.status == 'pin-validation') {
_pinNotifier.state = null;
} else {
rethrow;
}
}
}
Future<void> _refresh() async {
final result = await _session.command('fingerprints');
setItems((result['children'] as Map<String, dynamic>)
.entries
.map((e) => Fingerprint(e.key, e.value['name']))
.toList());
}
@override
Future<PinResult> unlock(String pin) {
// TODO: implement unlock
throw UnimplementedError();
Future<void> deleteFingerprint(Fingerprint fingerprint) async {
await _session
.command('delete', target: ['fingerprints', fingerprint.templateId]);
await _refresh();
}
@override
Stream<FingerprintEvent> registerFingerprint({String? name}) {
final controller = StreamController<FingerprintEvent>();
final signaler = Signaler();
signaler.signals.listen((signal) {
switch (signal.status) {
case 'capture':
controller.sink
.add(FingerprintEvent.capture(signal.body['remaining']));
break;
case 'capture-error':
controller.sink.add(FingerprintEvent.error(signal.body['code']));
break;
}
});
controller.onCancel = () {
if (!controller.isClosed) {
signaler.cancel();
}
};
controller.onListen = () async {
try {
final result = await _session.command(
'add',
target: ['fingerprints'],
params: {'name': name},
signal: signaler,
);
controller.sink
.add(FingerprintEvent.complete(Fingerprint.fromJson(result)));
await _refresh();
await controller.sink.close();
} catch (e) {
controller.sink.addError(e);
}
};
return controller.stream;
}
@override
Future<Fingerprint> renameFingerprint(
Fingerprint fingerprint, String name) async {
await _session.command('rename',
target: ['fingerprints', fingerprint.templateId],
params: {'name': name});
final renamed = fingerprint.copyWith(name: name);
await _refresh();
return renamed;
}
}
final desktopCredentialProvider = StateNotifierProvider.autoDispose.family<
FidoCredentialsNotifier,
AsyncValue<List<FidoCredential>>,
DevicePath>((ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final notifier = _DesktopFidoCredentialsNotifier(
session,
ref.watch(_pinProvider(devicePath).notifier),
);
session.setErrorHandler('auth-required', (_) async {
final pin = ref.read(_pinProvider(devicePath));
if (pin != null) {
await notifier._unlock(pin);
}
});
ref.onDispose(() {
session.unsetErrorHandler('auth-required');
});
return notifier;
});
class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
final RpcNodeSession _session;
final StateController<String?> _pinNotifier;
_DesktopFidoCredentialsNotifier(this._session, this._pinNotifier) {
final pin = _pinNotifier.state;
if (pin != null) {
_unlock(pin);
} else {
state = const AsyncValue.error('locked');
}
}
Future<void> _unlock(String pin) async {
try {
await _session.command(
'unlock',
target: ['credentials'],
params: {'pin': pin},
);
await _refresh();
} on RpcError catch (e) {
if (e.status == 'pin-validation') {
_pinNotifier.state = null;
} else {
rethrow;
}
}
}
Future<void> _refresh() async {
final List<FidoCredential> creds = [];
final rps = await _session.command('credentials');
for (final rpId in (rps['children'] as Map<String, dynamic>).keys) {
final result = await _session.command(rpId, target: ['credentials']);
for (final e in (result['children'] as Map<String, dynamic>).entries) {
creds.add(FidoCredential(
rpId: rpId,
credentialId: e.key,
userId: e.value['user_id'],
userName: e.value['user_name']));
}
}
setItems(creds);
}
@override
Future<void> deleteCredential(FidoCredential credential) async {
await _session.command('delete', target: [
'credentials',
credential.rpId,
credential.credentialId,
]);
await _refresh();
}
}

View File

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

View File

@ -18,8 +18,8 @@ final _sessionProvider =
(ref, devicePath) => RpcNodeSession(ref.watch(rpcProvider), devicePath, []),
);
final desktopManagementState = StateNotifierProvider.autoDispose.family<
ManagementStateNotifier, ApplicationStateResult<DeviceInfo>, DevicePath>(
final desktopManagementState = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
(ref, devicePath) {
// Make sure to rebuild if currentDevice changes (as on reboot)
ref.watch(currentDeviceProvider);
@ -41,36 +41,30 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
List<String> _subpath = [];
_DesktopManagementStateNotifier(this._ref, this._session) : super();
void refresh() async {
try {
final result = await _session.command('get');
final info = DeviceInfo.fromJson(result['data']['info']);
final interfaces = (result['children'] as Map).keys.toSet();
for (final iface in [
// This is the preferred order
UsbInterface.ccid,
UsbInterface.otp,
UsbInterface.fido,
]) {
if (interfaces.contains(iface.name)) {
final path = [iface.name, 'management'];
try {
await _session.command('get', target: path);
_subpath = path;
_log.config('Using transport $iface for management');
setState(info);
return;
} catch (e) {
_log.warning('Failed connecting to management via $iface');
Future<void> refresh() => updateState(() async {
final result = await _session.command('get');
final info = DeviceInfo.fromJson(result['data']['info']);
final interfaces = (result['children'] as Map).keys.toSet();
for (final iface in [
// This is the preferred order
UsbInterface.ccid,
UsbInterface.otp,
UsbInterface.fido,
]) {
if (interfaces.contains(iface.name)) {
final path = [iface.name, 'management'];
try {
await _session.command('get', target: path);
_subpath = path;
_log.config('Using transport $iface for management');
return info;
} catch (e) {
_log.warning('Failed connecting to management via $iface');
}
}
}
}
setFailure('Failed connecting over all interfaces');
} catch (error) {
_log.severe('Failed getting device info');
setFailure('Failed to connect');
}
}
throw 'Failed connection over all interfaces';
});
@override
Future<void> setMode(int mode,
@ -88,7 +82,7 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
String newLockCode = '',
bool reboot = false}) async {
if (reboot) {
unsetState();
state = const AsyncValue.loading();
}
await _session.command('configure', target: _subpath, params: {
...config.toJson(),

View File

@ -44,7 +44,7 @@ class _LockKeyNotifier extends StateNotifier<String?> {
}
final desktopOathState = StateNotifierProvider.autoDispose
.family<OathStateNotifier, ApplicationStateResult<OathState>, DevicePath>(
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
(ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final notifier = _DesktopOathStateNotifier(session, ref);
@ -69,28 +69,24 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
final Ref _ref;
_DesktopOathStateNotifier(this._session, this._ref) : super();
refresh() async {
try {
var result = await _session.command('get');
_log.config('application status', jsonEncode(result));
var oathState = OathState.fromJson(result['data']);
final key = _ref.read(_oathLockKeyProvider(_session.devicePath));
if (oathState.locked && key != null) {
final result = await _session.command('validate', params: {'key': key});
if (result['valid']) {
oathState = oathState.copyWith(locked: false);
} else {
_ref
.read(_oathLockKeyProvider(_session.devicePath).notifier)
.unsetKey();
refresh() => updateState(() async {
final result = await _session.command('get');
_log.config('application status', jsonEncode(result));
var oathState = OathState.fromJson(result['data']);
final key = _ref.read(_oathLockKeyProvider(_session.devicePath));
if (oathState.locked && key != null) {
final result =
await _session.command('validate', params: {'key': key});
if (result['valid']) {
oathState = oathState.copyWith(locked: false);
} else {
_ref
.read(_oathLockKeyProvider(_session.devicePath).notifier)
.unsetKey();
}
}
}
setState(oathState);
} catch (error) {
_log.severe('Unable to update OATH state', jsonEncode(error));
setFailure('Failed to update OATH');
}
}
return oathState;
});
@override
Future<void> reset() async {
@ -112,7 +108,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
if (valid) {
_log.config('applet unlocked');
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
setState(requireState().copyWith(
setData(state.value!.copyWith(
locked: false,
remembered: remembered,
));
@ -128,7 +124,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
@override
Future<bool> setPassword(String? current, String password) async {
final oathState = requireState();
final oathState = state.value!;
if (oathState.hasKey) {
if (current != null) {
if (!await _checkPassword(current)) {
@ -154,14 +150,14 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
_log.config('OATH key set');
if (!oathState.hasKey) {
setState(oathState.copyWith(hasKey: true));
setData(oathState.copyWith(hasKey: true));
}
return true;
}
@override
Future<bool> unsetPassword(String current) async {
final oathState = requireState();
final oathState = state.value!;
if (oathState.hasKey) {
if (!await _checkPassword(current)) {
return false;
@ -169,7 +165,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
}
await _session.command('unset_key');
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
setState(oathState.copyWith(hasKey: false, locked: false));
setData(oathState.copyWith(hasKey: false, locked: false));
return true;
}
@ -177,7 +173,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
Future<void> forgetPassword() async {
await _session.command('forget');
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
setState(requireState().copyWith(remembered: false));
setData(state.value!.copyWith(remembered: false));
}
}
@ -186,8 +182,8 @@ final desktopOathCredentialListProvider = StateNotifierProvider.autoDispose
(ref, devicePath) {
var notifier = _DesktopCredentialListNotifier(
ref.watch(_sessionProvider(devicePath)),
ref.watch(oathStateProvider(devicePath).select(
(r) => r.whenOrNull(success: (state) => state.locked) ?? true)),
ref.watch(oathStateProvider(devicePath)
.select((r) => r.whenOrNull(data: (state) => state.locked) ?? true)),
);
ref.listen<WindowState>(windowStateProvider, (_, windowState) {
notifier._notifyWindowState(windowState);

View File

@ -16,6 +16,8 @@ class Signaler {
Stream<Signal> get signals => _recv.stream;
Stream<String> get _sendStream => _send.stream;
void cancel() {
_send.add('cancel');
}
@ -122,14 +124,13 @@ class RpcSession {
void _send(Map data) {
_log.fine('SEND', jsonEncode(data));
_process.stdin.writeln(jsonEncode(data));
_process.stdin.flush();
}
void _pump() async {
await for (final request in _requests.stream) {
_send(request.toJson());
request.signal?._send.stream.listen((status) {
final signalSubscription = request.signal?._sendStream.listen((status) {
_send({'kind': 'signal', 'status': status});
});
@ -157,6 +158,7 @@ class RpcSession {
);
}
await signalSubscription?.cancel();
request.signal?._close();
}
}
@ -164,6 +166,24 @@ class RpcSession {
typedef ErrorHandler = Future<void> Function(RpcError e);
class _MultiSignaler extends Signaler {
final Signaler delegate;
@override
final Stream<String> _sendStream;
_MultiSignaler(this.delegate)
: _sendStream = delegate._send.stream.asBroadcastStream() {
signals.listen(delegate._recv.sink.add);
}
@override
void _close() {}
void _reallyClose() {
super._close();
}
}
class RpcNodeSession {
final RpcSession _rpc;
final DevicePath devicePath;
@ -186,7 +206,12 @@ class RpcNodeSession {
Map<dynamic, dynamic>? params,
Signaler? signal,
}) async {
bool wrapped = false;
try {
if (signal != null && signal is! _MultiSignaler) {
signal = _MultiSignaler(signal);
wrapped = true;
}
return await _rpc.command(
action,
devicePath.segments + subPath + target,
@ -198,9 +223,18 @@ class RpcNodeSession {
if (handler != null) {
_log.info('Attempting recovery on "${e.status}"');
await handler(e);
return command(action, target: target, params: params, signal: signal);
return await command(
action,
target: target,
params: params,
signal: signal,
);
}
rethrow;
} finally {
if (wrapped) {
(signal as _MultiSignaler)._reallyClose();
}
}
}
}

View File

@ -5,14 +5,13 @@ part 'models.g.dart';
enum InteractionEvent { remove, insert, touch }
enum SubPage { main, fingerprints, credentials }
@freezed
class FidoState with _$FidoState {
const FidoState._();
factory FidoState({
required Map<String, dynamic> info,
required bool locked,
}) = _FidoState;
factory FidoState({required Map<String, dynamic> info}) = _FidoState;
factory FidoState.fromJson(Map<String, dynamic> json) =>
_$FidoStateFromJson(json);
@ -30,6 +29,37 @@ class FidoState with _$FidoState {
@freezed
class PinResult with _$PinResult {
factory PinResult.success() = _Success;
factory PinResult.failed(int retries, bool authBlocked) = _Failure;
factory PinResult.success() = _PinSuccess;
factory PinResult.failed(int retries, bool authBlocked) = _PinFailure;
}
@freezed
class Fingerprint with _$Fingerprint {
const Fingerprint._();
factory Fingerprint(String templateId, String? name) = _Fingerprint;
factory Fingerprint.fromJson(Map<String, dynamic> json) =>
_$FingerprintFromJson(json);
String get label => name ?? 'Unnamed (ID: $templateId)';
}
@freezed
class FingerprintEvent with _$FingerprintEvent {
factory FingerprintEvent.capture(int remaining) = _EventCapture;
factory FingerprintEvent.complete(Fingerprint fingerprint) = _EventComplete;
factory FingerprintEvent.error(int code) = _EventError;
}
@freezed
class FidoCredential with _$FidoCredential {
factory FidoCredential({
required String rpId,
required String credentialId,
required String userId,
required String userName,
}) = _FidoCredential;
factory FidoCredential.fromJson(Map<String, dynamic> json) =>
_$FidoCredentialFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@ -8,11 +8,37 @@ part of 'models.dart';
_$_FidoState _$$_FidoStateFromJson(Map<String, dynamic> json) => _$_FidoState(
info: json['info'] as Map<String, dynamic>,
locked: json['locked'] as bool,
);
Map<String, dynamic> _$$_FidoStateToJson(_$_FidoState instance) =>
<String, dynamic>{
'info': instance.info,
'locked': instance.locked,
};
_$_Fingerprint _$$_FingerprintFromJson(Map<String, dynamic> json) =>
_$_Fingerprint(
json['template_id'] as String,
json['name'] as String?,
);
Map<String, dynamic> _$$_FingerprintToJson(_$_Fingerprint instance) =>
<String, dynamic>{
'template_id': instance.templateId,
'name': instance.name,
};
_$_FidoCredential _$$_FidoCredentialFromJson(Map<String, dynamic> json) =>
_$_FidoCredential(
rpId: json['rp_id'] as String,
credentialId: json['credential_id'] as String,
userId: json['user_id'] as String,
userName: json['user_name'] as String,
);
Map<String, dynamic> _$$_FidoCredentialToJson(_$_FidoCredential instance) =>
<String, dynamic>{
'rp_id': instance.rpId,
'credential_id': instance.credentialId,
'user_id': instance.userId,
'user_name': instance.userName,
};

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart';
@ -5,12 +6,55 @@ import '../core/state.dart';
import 'models.dart';
final fidoStateProvider = StateNotifierProvider.autoDispose
.family<FidoStateNotifier, ApplicationStateResult<FidoState>, DevicePath>(
.family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
);
abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
Stream<InteractionEvent> reset();
Future<PinResult> unlock(String pin);
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>
extends StateNotifier<AsyncValue<List<T>>> {
LockedCollectionNotifier() : super(const AsyncValue.loading());
@protected
void setItems(List<T> items) {
if (mounted) {
state = AsyncValue.data(List.unmodifiable(items));
}
}
}
final fingerprintProvider = StateNotifierProvider.autoDispose.family<
FidoFingerprintsNotifier, AsyncValue<List<Fingerprint>>, DevicePath>(
(ref, arg) => throw UnimplementedError(),
);
abstract class FidoFingerprintsNotifier
extends LockedCollectionNotifier<Fingerprint> {
Stream<FingerprintEvent> registerFingerprint({String? name});
Future<Fingerprint> renameFingerprint(Fingerprint fingerprint, String name);
Future<void> deleteFingerprint(Fingerprint fingerprint);
}
final credentialProvider = StateNotifierProvider.autoDispose.family<
FidoCredentialsNotifier, AsyncValue<List<FidoCredential>>, DevicePath>(
(ref, arg) => throw UnimplementedError(),
);
abstract class FidoCredentialsNotifier
extends LockedCollectionNotifier<FidoCredential> {
Future<void> deleteCredential(FidoCredential credential);
}

View File

@ -0,0 +1,196 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../state.dart';
import '../../app/views/responsive_dialog.dart';
import '../../fido/models.dart';
import '../../app/models.dart';
import '../../app/state.dart';
final _log = Logger('fido.views.add_fingerprint_dialog');
class AddFingerprintDialog extends ConsumerStatefulWidget {
final DeviceNode node;
const AddFingerprintDialog(this.node, {Key? key}) : super(key: key);
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_AddFingerprintDialogState();
}
class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
with SingleTickerProviderStateMixin {
late FocusNode _nameFocus;
late AnimationController _animator;
late Animation<Color?> _color;
late StreamSubscription<FingerprintEvent> _subscription;
int _samples = 0;
int _remaining = 5;
Fingerprint? _fingerprint;
String _label = '';
@override
void dispose() {
_animator.dispose();
_nameFocus.dispose();
super.dispose();
}
Animation<Color?> _animateColor(Color color,
{Function? atPeak, bool reverse = true}) {
final animation =
ColorTween(begin: Colors.black, end: color).animate(_animator);
_animator.forward().then((_) {
if (reverse) {
atPeak?.call();
_animator.reverse();
}
});
return animation;
}
@override
void initState() {
super.initState();
_nameFocus = FocusNode();
_animator = AnimationController(
vsync: this, duration: const Duration(milliseconds: 250));
_color =
ColorTween(begin: Colors.black, end: Colors.black).animate(_animator);
_subscription = ref
.read(fingerprintProvider(widget.node.path).notifier)
.registerFingerprint()
.listen((event) {
setState(() {
event.when(capture: (remaining) {
_color = _animateColor(Colors.lightGreenAccent, atPeak: () {
setState(() {
_samples += 1;
_remaining = remaining;
});
}, reverse: remaining > 0);
}, complete: (fingerprint) {
_remaining = 0;
_fingerprint = fingerprint;
// This needs a short delay to ensure the field is enabled first
Timer(const Duration(milliseconds: 100), _nameFocus.requestFocus);
}, error: (code) {
_log.config('Fingerprint capture error (code: $code)');
_color = _animateColor(Colors.redAccent);
});
});
}, onError: (error, stacktrace) {
_log.severe('Error adding fingerprint', error, stacktrace);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Error adding fingerprint'),
duration: Duration(seconds: 2),
),
);
});
}
String _getMessage() {
if (_samples == 0) {
return 'Press your finger against the YubiKey to begin.';
}
if (_fingerprint == null) {
return 'Keep touching your YubiKey repeatedly...';
} else {
return 'Fingerprint captured successfully!';
}
}
@override
Widget build(BuildContext context) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
// Prevent over-popping if reset causes currentDevice to change.
Navigator.of(context).popUntil((route) => route.isFirst);
});
final progress = _samples == 0 ? 0.0 : _samples / (_samples + _remaining);
return ResponsiveDialog(
title: const Text('Add fingerprint'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Step 1/2: Capture fingerprint'),
Card(
child: Column(
children: [
AnimatedBuilder(
animation: _color,
builder: (context, _) {
return Icon(
_fingerprint == null ? Icons.fingerprint : Icons.check,
size: 200.0,
color: _color.value,
);
},
),
LinearProgressIndicator(value: progress),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(_getMessage()),
),
],
),
),
const Text('Step 2/2: Name fingerprint'),
TextFormField(
focusNode: _nameFocus,
maxLength: 15,
autofocus: true,
decoration: InputDecoration(
enabled: _fingerprint != null,
border: const OutlineInputBorder(),
labelText: 'Name',
),
onChanged: (value) {
setState(() {
_label = value.trim();
});
},
),
]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
),
onCancel: () {
_subscription.cancel();
},
actions: [
TextButton(
onPressed: _fingerprint != null && _label.isNotEmpty
? () async {
await ref
.read(fingerprintProvider(widget.node.path).notifier)
.renameFingerprint(_fingerprint!, _label);
Navigator.of(context).pop(true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fingerprint added'),
duration: Duration(seconds: 2),
),
);
}
: null,
child: const Text('Save'),
),
],
);
}
}

View File

@ -0,0 +1,51 @@
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';
class CredentialPage extends ConsumerWidget {
final DeviceNode node;
final FidoState state;
const CredentialPage(this.node, this.state, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(credentialProvider(node.path)).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => AppFailureScreen('$error'),
data: (credentials) => ListView(
children: [
ListTile(
title: Text(
'CREDENTIALS',
style: Theme.of(context).textTheme.bodyText2,
),
),
...credentials.map((cred) => ListTile(
title: Text(cred.userName),
subtitle: Text(cred.rpId),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) =>
DeleteCredentialDialog(node, cred),
);
},
icon: const Icon(Icons.delete)),
],
),
)),
],
),
);
}
}

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/views/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../../app/models.dart';
import '../../app/state.dart';
class DeleteCredentialDialog extends ConsumerWidget {
final DeviceNode device;
final FidoCredential credential;
const DeleteCredentialDialog(this.device, this.credential, {Key? key})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
Navigator.of(context).pop(false);
});
final label = credential.userName;
return ResponsiveDialog(
title: const Text('Delete credential'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('This will delete the credential from your YubiKey.'),
Text('Credential: $label'),
]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
),
actions: [
TextButton(
onPressed: () async {
await ref
.read(credentialProvider(device.path).notifier)
.deleteCredential(credential);
Navigator.of(context).pop(true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Credential deleted'),
duration: Duration(seconds: 2),
),
);
},
child: const Text('Delete'),
),
],
);
}
}

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/views/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../../app/models.dart';
import '../../app/state.dart';
class DeleteFingerprintDialog extends ConsumerWidget {
final DeviceNode device;
final Fingerprint fingerprint;
const DeleteFingerprintDialog(this.device, this.fingerprint, {Key? key})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// If current device changes, we need to pop back to the main Page.
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
Navigator.of(context).pop(false);
});
final label = fingerprint.label;
return ResponsiveDialog(
title: const Text('Delete fingerprint'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('This will delete the fingerprint from your YubiKey.'),
Text('Fingerprint: $label'),
]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
),
actions: [
TextButton(
onPressed: () async {
await ref
.read(fingerprintProvider(device.path).notifier)
.deleteFingerprint(fingerprint);
Navigator.of(context).pop(true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fingerprint deleted'),
duration: Duration(seconds: 2),
),
);
},
child: const Text('Delete'),
),
],
);
}
}

View File

@ -4,13 +4,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_loading_screen.dart';
import '../../desktop/state.dart';
import '../../management/models.dart';
import '../models.dart';
import '../state.dart';
import 'pin_dialog.dart';
import 'reset_dialog.dart';
import 'credential_page.dart';
import 'fingerprint_page.dart';
import 'main_page.dart';
final _subPageProvider = StateProvider<SubPage>((ref) {
// Reset whenever the device changes.
ref.watch(currentDeviceProvider);
return SubPage.main;
});
class FidoScreen extends ConsumerWidget {
final YubiKeyData deviceData;
@ -19,8 +28,8 @@ class FidoScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) =>
ref.watch(fidoStateProvider(deviceData.node.path)).when(
none: () => const AppLoadingScreen(),
failure: (reason) {
loading: () => const AppLoadingScreen(),
error: (error, _) {
final supported = deviceData
.info.supportedCapabilities[deviceData.node.transport]!;
if (Capability.fido2.value & supported == 0) {
@ -34,56 +43,51 @@ class FidoScreen extends ConsumerWidget {
'WebAuthn management requires elevated privileges.\nRestart this app as administrator.');
}
}
return AppFailureScreen(reason);
return AppFailureScreen('$error');
},
success: (state) => ListView(
children: [
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'),
),
ListTile(
leading: const CircleAvatar(
child: Icon(Icons.delete_forever),
),
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),
);
},
),
],
));
data: (state) {
setSubPage(value) {
ref.read(_subPageProvider.notifier).state = value;
}
switch (ref.watch(_subPageProvider)) {
case SubPage.fingerprints:
return WithBackButton(
goBack: () {
setSubPage(SubPage.main);
},
child: FingerprintPage(deviceData.node, state),
);
case SubPage.credentials:
return WithBackButton(
goBack: () {
setSubPage(SubPage.main);
},
child: CredentialPage(deviceData.node, state),
);
default:
return FidoMainPage(
deviceData.node,
state,
setSubPage: setSubPage,
);
}
});
}
// TODO: Replace this with the AppBar back button
class WithBackButton extends StatelessWidget {
final Function() goBack;
final Widget child;
const WithBackButton({Key? key, required this.goBack, required this.child})
: super(key: key);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TextButton(onPressed: goBack, child: const Text('Back')),
Expanded(child: child),
],
);
}

View File

@ -0,0 +1,78 @@
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';
class FingerprintPage extends ConsumerWidget {
final DeviceNode node;
final FidoState state;
const FingerprintPage(this.node, this.state, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(fingerprintProvider(node.path)).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => AppFailureScreen('$error'),
data: (fingerprints) => ListView(
children: [
ListTile(
title: Text(
'FINGERPRINTS',
style: Theme.of(context).textTheme.bodyText2,
),
),
...fingerprints.map((fp) => ListTile(
title: Text(fp.label),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) =>
RenameFingerprintDialog(node, fp),
);
},
icon: const Icon(Icons.edit)),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) =>
DeleteFingerprintDialog(node, fp),
);
},
icon: const Icon(Icons.delete)),
],
),
)),
Padding(
padding: const EdgeInsets.all(16.0),
child: Wrap(
children: [
OutlinedButton.icon(
icon: const Icon(Icons.fingerprint),
label: const Text('Add fingerprint'),
onPressed: () {
showDialog(
context: context,
builder: (context) => AddFingerprintDialog(node),
);
},
)
],
),
),
],
),
);
}
}

93
lib/fido/views/main_page.dart Executable file
View File

@ -0,0 +1,93 @@
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 ConsumerWidget {
final DeviceNode node;
final FidoState state;
final Function(SubPage page) setSubPage;
const FidoMainPage(this.node, this.state,
{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, WidgetRef ref) {
return ListView(
children: [
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(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'),
onTap: () {
_openLockedPage(context, ref, SubPage.fingerprints);
},
),
if (state.credMgmt)
ListTile(
leading: const CircleAvatar(
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: () {
_openLockedPage(context, ref, SubPage.credentials);
},
),
ListTile(
leading: const CircleAvatar(
child: Icon(Icons.delete_forever),
),
title: const Text('Factory reset'),
subtitle: const Text('Delete all data and remove PIN'),
onTap: () async {
await showDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
],
);
}
}

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

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/views/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import '../../app/models.dart';
import '../../app/state.dart';
class RenameFingerprintDialog extends ConsumerStatefulWidget {
final DeviceNode device;
final Fingerprint fingerprint;
const RenameFingerprintDialog(this.device, this.fingerprint, {Key? key})
: super(key: key);
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_RenameAccountDialogState();
}
class _RenameAccountDialogState extends ConsumerState<RenameFingerprintDialog> {
late String _label;
_RenameAccountDialogState();
@override
void initState() {
super.initState();
_label = widget.fingerprint.label;
}
@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 fingerprint = widget.fingerprint;
return ResponsiveDialog(
title: const Text('Rename fingerprint'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Rename ${fingerprint.label}?'),
const Text('This will change the label of the fingerprint.'),
TextFormField(
initialValue: _label,
maxLength: 15,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Label',
),
onChanged: (value) {
setState(() {
_label = value.trim();
});
},
),
]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
),
actions: [
TextButton(
onPressed: _label.isNotEmpty
? () async {
final renamed = await ref
.read(fingerprintProvider(widget.device.path).notifier)
.renameFingerprint(fingerprint, _label);
Navigator.of(context).pop(renamed);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fingerprint renamed'),
duration: Duration(seconds: 2),
),
);
}
: null,
child: const Text('Save'),
),
],
);
}
}

View File

@ -4,8 +4,8 @@ import 'package:yubico_authenticator/management/models.dart';
import '../app/models.dart';
import '../core/state.dart';
final managementStateProvider = StateNotifierProvider.autoDispose.family<
ManagementStateNotifier, ApplicationStateResult<DeviceInfo>, DevicePath>(
final managementStateProvider = StateNotifierProvider.autoDispose
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
);

View File

@ -240,9 +240,9 @@ class _ManagementScreenState extends ConsumerState<ManagementScreen> {
title: const Text('Toggle applications'),
child:
ref.watch(managementStateProvider(widget.deviceData.node.path)).when(
none: () => const AppLoadingScreen(),
failure: (reason) => AppFailureScreen(reason),
success: (info) {
loading: () => const AppLoadingScreen(),
error: (error, _) => AppFailureScreen('$error'),
data: (info) {
// TODO: Check mode for < YK5 intead
changed = !_mapEquals(
_enabled,

View File

@ -13,7 +13,7 @@ List<MenuAction> buildOathMenuActions(AutoDisposeProviderRef ref) {
if (device != null) {
final state = ref.watch(oathStateProvider(device.path));
return state.whenOrNull(
success: (oathState) => [
data: (oathState) => [
if (!oathState.locked) ...[
MenuAction(
text: 'Add credential',

View File

@ -11,7 +11,7 @@ import '../core/state.dart';
import 'models.dart';
final oathStateProvider = StateNotifierProvider.autoDispose
.family<OathStateNotifier, ApplicationStateResult<OathState>, DevicePath>(
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
(ref, devicePath) => throw UnimplementedError(),
);

View File

@ -15,9 +15,9 @@ class OathScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(oathStateProvider(deviceData.node.path)).when(
none: () => const AppLoadingScreen(),
failure: (reason) => AppFailureScreen(reason),
success: (oathState) {
loading: () => const AppLoadingScreen(),
error: (error, _) => AppFailureScreen('$error'),
data: (oathState) {
if (oathState.locked) {
return ListView(
children: [

View File

@ -174,7 +174,7 @@ class ManagePasswordDialog extends ConsumerWidget {
});
return ref.watch(oathStateProvider(device.path)).maybeWhen(
success: (state) => ResponsiveDialog(
data: (state) => ResponsiveDialog(
title: const Text('Manage password'),
child: _ManagePasswordForm(
device.path,

View File

@ -7,7 +7,7 @@ authors = ["Dain Nilsson <dain@yubico.com>"]
[tool.poetry.dependencies]
python = "^3.8"
yubikey-manager = { git = "https://github.com/Yubico/yubikey-manager.git", rev = "22a72b2" }
fido2 = { git = "https://github.com/Yubico/python-fido2.git", rev = "53175db" }
fido2 = { git = "https://github.com/Yubico/python-fido2.git", rev = "fd30409" }
mss = "^6.1.0"
zxing-cpp = "^1.2.0"
Pillow = "^8|^9"

View File

@ -85,6 +85,7 @@ def _handle_incoming(event, recv, error, cmd_queue):
except RpcException as e:
error(e.status, e.message, e.body)
except Exception as e:
logger.exception("Unhandled exception")
error("exception", f"{e!r}")
event.set()
cmd_queue.put(None)
@ -126,6 +127,7 @@ def process(
except RpcException as e:
error(e.status, e.message, e.body)
except Exception as e:
logger.exception("Unhandled exception")
error("exception", f"{e!r}")
cmd_queue.task_done()

View File

@ -83,6 +83,11 @@ class TimeoutException(RpcException):
super().__init__("timeout", "Command timed out waiting for user action")
class AuthRequiredException(RpcException):
def __init__(self):
super().__init__("auth-required", "Authentication is required")
class ChildResetException(Exception):
def __init__(self, message):
self.message = message

View File

@ -26,15 +26,18 @@
# POSSIBILITY OF SUCH DAMAGE.
from .base import RpcNode, action, child, RpcException, TimeoutException
from fido2.ctap import CtapError
from fido2.ctap2 import (
Ctap2,
ClientPin,
CredentialManagement,
FPBioEnrollment,
CaptureError,
from .base import (
RpcNode,
action,
child,
RpcException,
TimeoutException,
AuthRequiredException,
)
from fido2.ctap import CtapError
from fido2.ctap2 import Ctap2, ClientPin
from fido2.ctap2.credman import CredentialManagement
from fido2.ctap2.bio import BioEnrollment, FPBioEnrollment, CaptureError
from fido2.pcsc import CtapPcscDevice
from yubikit.core.fido import FidoConnection
from ykman.hid import list_ctap_devices as list_ctap
@ -61,23 +64,32 @@ def _ctap_id(ctap):
return (ctap.info.aaguid, ctap.info.firmware_version)
def _handle_pin_error(e, client_pin):
if e.code in (
CtapError.ERR.PIN_INVALID,
CtapError.ERR.PIN_BLOCKED,
CtapError.ERR.PIN_AUTH_BLOCKED,
):
pin_retries, _ = client_pin.get_pin_retries()
raise PinValidationException(
pin_retries, e.code == CtapError.ERR.PIN_AUTH_BLOCKED
)
raise e
class Ctap2Node(RpcNode):
def __init__(self, connection):
super().__init__()
self.ctap = Ctap2(connection)
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, auth_blocked=self._auth_blocked
)
data = dict(info=asdict(self._info), 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()
data.update(
pin_retries=pin_retries,
@ -85,6 +97,7 @@ class Ctap2Node(RpcNode):
)
if self._info.options.get("bioEnroll"):
uv_retries = self.client_pin.get_uv_retries()
# For compatibility with python-fido2 < 1.0
if isinstance(uv_retries, tuple):
uv_retries = uv_retries[0]
data.update(uv_retries=uv_retries)
@ -155,22 +168,9 @@ class Ctap2Node(RpcNode):
raise ValueError("Re-inserted YubiKey does not match initial device")
self.ctap.reset(event)
self._info = self.ctap.get_info()
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")
@ -178,10 +178,9 @@ class Ctap2Node(RpcNode):
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)
return _handle_pin_error(e, self.client_pin)
@action
def set_pin(self, params, event, signal):
@ -196,22 +195,17 @@ class Ctap2Node(RpcNode):
self.client_pin.set_pin(
params.pop("new_pin"),
)
self._pin = None
return dict()
except CtapError as e:
return self._handle_pin_error(e)
return _handle_pin_error(e, self.client_pin)
@child(condition=lambda self: "bioEnroll" in self._info.options and self._pin)
@child(condition=lambda self: BioEnrollment.is_supported(self._info))
def fingerprints(self):
token = self.client_pin.get_pin_token(
self._pin, ClientPin.PERMISSION.BIO_ENROLL
)
bio = FPBioEnrollment(self.ctap, self.client_pin.protocol, token)
return FingerprintsNode(bio)
return FingerprintsNode(self.client_pin)
# TODO: Use CredentialManagement.is_supported when released
@child(condition=lambda self: self._pin)
@child(condition=lambda self: CredentialManagement.is_supported(self._info))
def credentials(self):
return CredentialsRpsNode(self.client_pin)
token = self.client_pin.get_pin_token(
self._pin, ClientPin.PERMISSION.CREDENTIAL_MGMT
)
@ -220,10 +214,29 @@ class Ctap2Node(RpcNode):
class CredentialsRpsNode(RpcNode):
def __init__(self, credman):
def __init__(self, client_pin):
super().__init__()
self.credman = credman
self.refresh()
self.client_pin = client_pin
self.credman = None
self._rps = {}
def get_data(self):
return dict(locked=self.credman is None)
@action
def unlock(self, params, event, signal):
pin = params.pop("pin")
try:
token = self.client_pin.get_pin_token(
pin, ClientPin.PERMISSION.CREDENTIAL_MGMT
)
self.credman = CredentialManagement(
self.client_pin.ctap, self.client_pin.protocol, token
)
self.refresh()
return dict()
except CtapError as e:
return _handle_pin_error(e, self.client_pin)
def refresh(self):
data = self.credman.get_metadata()
@ -296,13 +309,34 @@ class CredentialNode(RpcNode):
class FingerprintsNode(RpcNode):
def __init__(self, bio):
def __init__(self, client_pin):
super().__init__()
self.bio = bio
self.refresh()
self.client_pin = client_pin
self.bio = None
self._templates = {}
def get_data(self):
return dict(locked=self.bio is None)
@action
def unlock(self, params, event, signal):
pin = params.pop("pin")
try:
token = self.client_pin.get_pin_token(pin, ClientPin.PERMISSION.BIO_ENROLL)
self.bio = FPBioEnrollment(
self.client_pin.ctap, self.client_pin.protocol, token
)
self.refresh()
return dict()
except CtapError as e:
return _handle_pin_error(e, self.client_pin)
def refresh(self):
self._templates = self.bio.enumerate_enrollments()
self._templates = {
# Treat empty strings as None
k: v if v else None
for k, v in self.bio.enumerate_enrollments().items()
}
def list_children(self):
return {
@ -320,6 +354,8 @@ class FingerprintsNode(RpcNode):
@action
def add(self, params, event, signal):
if self.bio is None:
raise AuthRequiredException()
name = params.get("name", None)
enroller = self.bio.enroll()
template_id = None

View File

@ -32,7 +32,7 @@ from .base import (
child,
ChildResetException,
TimeoutException,
RpcException,
AuthRequiredException,
encode_bytes,
decode_bytes,
)
@ -50,11 +50,6 @@ import logging
logger = logging.getLogger(__name__)
class AuthRequiredException(RpcException):
def __init__(self):
super().__init__("auth-required", "Authentication is required")
@unique
class KEYSTORE(str, Enum):
UNKNOWN = "unknown"