From e5435e78a6c38a3815bf6ad0aa74266a2b7f189b Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 27 Mar 2024 16:42:56 +0100 Subject: [PATCH] Add access code dialog for OTP --- helper/helper/yubiotp.py | 15 ++- lib/desktop/otp/state.dart | 13 +- lib/l10n/app_de.arb | 13 ++ lib/l10n/app_en.arb | 13 ++ lib/l10n/app_fr.arb | 13 ++ lib/l10n/app_ja.arb | 13 ++ lib/l10n/app_pl.arb | 13 ++ lib/otp/state.dart | 4 +- lib/otp/views/access_code_dialog.dart | 135 +++++++++++++++++++ lib/otp/views/configure_chalresp_dialog.dart | 50 ++++--- lib/otp/views/configure_hotp_dialog.dart | 47 ++++--- lib/otp/views/configure_static_dialog.dart | 52 ++++--- lib/otp/views/configure_yubiotp_dialog.dart | 64 +++++---- lib/otp/views/delete_slot_dialog.dart | 38 ++++-- lib/otp/views/swap_slots_dialog.dart | 19 ++- 15 files changed, 396 insertions(+), 106 deletions(-) create mode 100644 lib/otp/views/access_code_dialog.dart diff --git a/helper/helper/yubiotp.py b/helper/helper/yubiotp.py index 390cb86c..0f9eaf59 100644 --- a/helper/helper/yubiotp.py +++ b/helper/helper/yubiotp.py @@ -65,7 +65,10 @@ class YubiOtpNode(RpcNode): @action def swap(self, params, event, signal): - self.session.swap_slots() + try: + self.session.swap_slots() + except CommandError: + raise ValueError(_FAIL_MSG) return dict() @child @@ -148,7 +151,9 @@ class SlotNode(RpcNode): @action(condition=lambda self: self._maybe_configured(self.slot)) def delete(self, params, event, signal): try: - self.session.delete_slot(self.slot, params.pop("cur_acc_code", None)) + access_code = params.pop("curr_acc_code", None) + access_code = bytes.fromhex(access_code) if access_code else None + self.session.delete_slot(self.slot, access_code) except CommandError: raise ValueError(_FAIL_MSG) @@ -218,6 +223,8 @@ class SlotNode(RpcNode): def put(self, params, event, signal): type = params.pop("type") options = params.pop("options", {}) + access_code = params.pop("curr_acc_code", None) + access_code = bytes.fromhex(access_code) if access_code else None args = params config = self._get_config(type, **args) @@ -226,8 +233,8 @@ class SlotNode(RpcNode): self.session.put_configuration( self.slot, config, - params.pop("acc_code", None), - params.pop("cur_acc_code", None), + access_code, + access_code, ) return dict() except CommandError: diff --git a/lib/desktop/otp/state.dart b/lib/desktop/otp/state.dart index 82ca8d46..423a6557 100644 --- a/lib/desktop/otp/state.dart +++ b/lib/desktop/otp/state.dart @@ -117,16 +117,21 @@ class _DesktopOtpStateNotifier extends OtpStateNotifier { } @override - Future deleteSlot(SlotId slot) async { - await _session.command('delete', target: [..._subpath, slot.id]); + Future deleteSlot(SlotId slot, {String? accessCode}) async { + await _session.command('delete', + target: [..._subpath, slot.id], + params: accessCode != null ? {'curr_acc_code': accessCode} : null); ref.invalidateSelf(); } @override Future configureSlot(SlotId slot, - {required SlotConfiguration configuration}) async { + {required SlotConfiguration configuration, String? accessCode}) async { await _session.command('put', - target: [..._subpath, slot.id], params: configuration.toJson()); + target: [..._subpath, slot.id], + params: accessCode != null + ? {...configuration.toJson(), 'curr_acc_code': accessCode} + : configuration.toJson()); ref.invalidateSelf(); } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f4d7a3fc..a0a2f51f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -701,6 +701,19 @@ "slot": {} } }, + "p_otp_swap_error": null, + "l_wrong_access_code": null, + + "@_otp_access_code": {}, + "s_access_code": null, + "s_show_access_code": null, + "s_hide_access_code": null, + "p_enter_access_code": null, + "@p_enter_access_code": { + "placeholders": { + "slot": {} + } + }, "@_permissions": {}, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2687e551..b044a4b4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -701,6 +701,19 @@ "slot": {} } }, + "p_otp_swap_error": "Failed to swap slots! Make sure the YubiKey does not have restrictive access.", + "l_wrong_access_code": "Wrong access code", + + "@_otp_access_code": {}, + "s_access_code": "Access code", + "s_show_access_code": "Show access code", + "s_hide_access_code": "Hide access code", + "p_enter_access_code": "Enter access code for slot {slot}.", + "@p_enter_access_code": { + "placeholders": { + "slot": {} + } + }, "@_permissions": {}, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b667f00d..5572b7d3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -701,6 +701,19 @@ "slot": {} } }, + "p_otp_swap_error": null, + "l_wrong_access_code": null, + + "@_otp_access_code": {}, + "s_access_code": null, + "s_show_access_code": null, + "s_hide_access_code": null, + "p_enter_access_code": null, + "@p_enter_access_code": { + "placeholders": { + "slot": {} + } + }, "@_permissions": {}, diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d1f6d8cc..9f829b9b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -701,6 +701,19 @@ "slot": {} } }, + "p_otp_swap_error": null, + "l_wrong_access_code": null, + + "@_otp_access_code": {}, + "s_access_code": null, + "s_show_access_code": null, + "s_hide_access_code": null, + "p_enter_access_code": null, + "@p_enter_access_code": { + "placeholders": { + "slot": {} + } + }, "@_permissions": {}, diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 4ca19a9a..0b868681 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -701,6 +701,19 @@ "slot": {} } }, + "p_otp_swap_error": null, + "l_wrong_access_code": null, + + "@_otp_access_code": {}, + "s_access_code": null, + "s_show_access_code": null, + "s_hide_access_code": null, + "p_enter_access_code": null, + "@p_enter_access_code": { + "placeholders": { + "slot": {} + } + }, "@_permissions": {}, diff --git a/lib/otp/state.dart b/lib/otp/state.dart index ccdfb15d..787ff4d7 100644 --- a/lib/otp/state.dart +++ b/lib/otp/state.dart @@ -47,6 +47,6 @@ abstract class OtpStateNotifier extends ApplicationStateNotifier { int serial, String publicId, String privateId, String key); Future swapSlots(); Future configureSlot(SlotId slot, - {required SlotConfiguration configuration}); - Future deleteSlot(SlotId slot); + {required SlotConfiguration configuration, String? accessCode}); + Future deleteSlot(SlotId slot, {String? accessCode}); } diff --git a/lib/otp/views/access_code_dialog.dart b/lib/otp/views/access_code_dialog.dart new file mode 100644 index 00000000..74650697 --- /dev/null +++ b/lib/otp/views/access_code_dialog.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../app/models.dart'; +import '../../core/models.dart'; +import '../../widgets/app_input_decoration.dart'; +import '../../widgets/app_text_field.dart'; +import '../../widgets/responsive_dialog.dart'; +import '../models.dart'; + +class AccessCodeDialog extends ConsumerStatefulWidget { + final DevicePath devicePath; + final OtpSlot otpSlot; + final Future Function(String accessCode) action; + const AccessCodeDialog( + {super.key, + required this.devicePath, + required this.otpSlot, + required this.action}); + + @override + ConsumerState createState() => _AccessCodeDialogState(); +} + +class _AccessCodeDialogState extends ConsumerState { + final _accessCodeController = TextEditingController(); + final _accessCodeFocus = FocusNode(); + bool _accessCodeIsWrong = false; + String _accessCodeError = ''; + bool _isObscure = true; + final accessCodeLength = 12; + + @override + void dispose() { + _accessCodeController.dispose(); + _accessCodeFocus.dispose(); + super.dispose(); + } + + void _submit() async { + final l10n = AppLocalizations.of(context)!; + if (!Format.hex.isValid(_accessCodeController.text)) { + _accessCodeController.selection = TextSelection( + baseOffset: 0, extentOffset: _accessCodeController.text.length); + _accessCodeFocus.requestFocus(); + setState(() { + _accessCodeError = + l10n.l_invalid_format_allowed_chars(Format.hex.allowedCharacters); + _accessCodeIsWrong = true; + }); + return; + } + try { + final navigator = Navigator.of(context); + await widget.action(_accessCodeController.text); + navigator.pop(true); + } catch (e) { + _accessCodeController.selection = TextSelection( + baseOffset: 0, extentOffset: _accessCodeController.text.length); + _accessCodeFocus.requestFocus(); + setState(() { + _accessCodeIsWrong = true; + _accessCodeError = l10n.l_wrong_access_code; + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final accessCode = _accessCodeController.text.replaceAll(' ', ''); + final accessCodeLengthValid = + accessCode.isNotEmpty && accessCode.length == accessCodeLength; + return ResponsiveDialog( + title: Text(l10n.s_access_code), + actions: [ + TextButton( + onPressed: accessCodeLengthValid ? _submit : null, + child: Text(l10n.s_unlock), + ) + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.p_enter_access_code( + widget.otpSlot.slot.numberId.toString())), + AppTextField( + autofocus: true, + obscureText: _isObscure, + maxLength: accessCodeLength, + autofillHints: const [AutofillHints.password], + controller: _accessCodeController, + focusNode: _accessCodeFocus, + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_access_code, + errorText: _accessCodeIsWrong ? _accessCodeError : null, + errorMaxLines: 3, + prefixIcon: const Icon(Symbols.pin), + suffixIcon: IconButton( + icon: Icon(_isObscure + ? Symbols.visibility + : Symbols.visibility_off), + onPressed: () { + setState(() { + _isObscure = !_isObscure; + }); + }, + tooltip: _isObscure + ? l10n.s_show_access_code + : l10n.s_hide_access_code, + ), + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _accessCodeIsWrong = false; + }); + }, + onSubmitted: (_) => _submit(), + ).init(), + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + )), + ); + } +} diff --git a/lib/otp/views/configure_chalresp_dialog.dart b/lib/otp/views/configure_chalresp_dialog.dart index 2170d54e..1b04899f 100644 --- a/lib/otp/views/configure_chalresp_dialog.dart +++ b/lib/otp/views/configure_chalresp_dialog.dart @@ -34,6 +34,7 @@ import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; +import 'access_code_dialog.dart'; import 'overwrite_confirm_dialog.dart'; final _log = Logger('otp.view.configure_chalresp_dialog'); @@ -91,30 +92,45 @@ class _ConfigureChalrespDialogState final otpNotifier = ref.read(otpStateProvider(widget.devicePath).notifier); + final configuration = SlotConfiguration.chalresp( + key: secret, + options: SlotConfigurationOptions( + requireTouch: _requireTouch)); + + bool configurationSucceded = false; try { await otpNotifier.configureSlot(widget.otpSlot.slot, - configuration: SlotConfiguration.chalresp( - key: secret, - options: SlotConfigurationOptions( - requireTouch: _requireTouch))); + configuration: configuration); + configurationSucceded = true; + } catch (e) { + _log.error('Failed to program credential', e); + // Access code required await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(); + final result = await showBlurDialog( + context: context, + builder: (context) => AccessCodeDialog( + devicePath: widget.devicePath, + otpSlot: widget.otpSlot, + action: (accessCode) async { + await otpNotifier.configureSlot( + widget.otpSlot.slot, + configuration: configuration, + accessCode: accessCode); + }, + )); + configurationSucceded = result ?? false; + }); + } + + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(); + if (configurationSucceded) { showMessage( context, l10n.l_slot_credential_configured( l10n.s_challenge_response)); - }); - } catch (e) { - _log.error('Failed to program credential', e); - await ref.read(withContextProvider)((context) async { - showMessage( - context, - l10n.p_otp_slot_configuration_error( - widget.otpSlot.slot.getDisplayName(l10n)), - duration: const Duration(seconds: 4), - ); - }); - } + } + }); } : null, child: Text(l10n.s_save), diff --git a/lib/otp/views/configure_hotp_dialog.dart b/lib/otp/views/configure_hotp_dialog.dart index 3c7b4910..5149db41 100644 --- a/lib/otp/views/configure_hotp_dialog.dart +++ b/lib/otp/views/configure_hotp_dialog.dart @@ -34,6 +34,7 @@ import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; +import 'access_code_dialog.dart'; import 'overwrite_confirm_dialog.dart'; final _log = Logger('otp.view.configure_hotp_dialog'); @@ -90,29 +91,43 @@ class _ConfigureHotpDialogState extends ConsumerState { final otpNotifier = ref.read(otpStateProvider(widget.devicePath).notifier); + final configuration = SlotConfiguration.hotp( + key: secret, + options: SlotConfigurationOptions( + digits8: _digits == 8, appendCr: _appendEnter)); + + bool configurationSucceded = false; try { await otpNotifier.configureSlot(widget.otpSlot.slot, - configuration: SlotConfiguration.hotp( - key: secret, - options: SlotConfigurationOptions( - digits8: _digits == 8, - appendCr: _appendEnter))); - await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(); - showMessage(context, - l10n.l_slot_credential_configured(l10n.s_hotp)); - }); + configuration: configuration); + configurationSucceded = true; } catch (e) { _log.error('Failed to program credential', e); + // Access code required await ref.read(withContextProvider)((context) async { - showMessage( - context, - l10n.p_otp_slot_configuration_error( - widget.otpSlot.slot.getDisplayName(l10n)), - duration: const Duration(seconds: 4), - ); + final result = await showBlurDialog( + context: context, + builder: (context) => AccessCodeDialog( + devicePath: widget.devicePath, + otpSlot: widget.otpSlot, + action: (accessCode) async { + await otpNotifier.configureSlot( + widget.otpSlot.slot, + configuration: configuration, + accessCode: accessCode); + }, + )); + configurationSucceded = result ?? false; }); } + + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(); + if (configurationSucceded) { + showMessage(context, + l10n.l_slot_credential_configured(l10n.s_hotp)); + } + }); } : null, child: Text(l10n.s_save), diff --git a/lib/otp/views/configure_static_dialog.dart b/lib/otp/views/configure_static_dialog.dart index 2a1491dd..264a9556 100644 --- a/lib/otp/views/configure_static_dialog.dart +++ b/lib/otp/views/configure_static_dialog.dart @@ -32,6 +32,7 @@ import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; +import 'access_code_dialog.dart'; import 'overwrite_confirm_dialog.dart'; final _log = Logger('otp.view.configure_static_dialog'); @@ -110,31 +111,46 @@ class _ConfigureStaticDialogState extends ConsumerState { final otpNotifier = ref.read(otpStateProvider(widget.devicePath).notifier); + final configuration = SlotConfiguration.static( + password: password, + keyboardLayout: _keyboardLayout, + options: + SlotConfigurationOptions(appendCr: _appendEnter)); + + bool configurationSucceded = false; try { await otpNotifier.configureSlot(widget.otpSlot.slot, - configuration: SlotConfiguration.static( - password: password, - keyboardLayout: _keyboardLayout, - options: SlotConfigurationOptions( - appendCr: _appendEnter))); + configuration: configuration); + configurationSucceded = true; + } catch (e) { + _log.error('Failed to program credential', e); + // Access code required await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(); + final result = await showBlurDialog( + context: context, + builder: (context) => AccessCodeDialog( + devicePath: widget.devicePath, + otpSlot: widget.otpSlot, + action: (accessCode) async { + await otpNotifier.configureSlot( + widget.otpSlot.slot, + configuration: configuration, + accessCode: accessCode); + }, + )); + configurationSucceded = result ?? false; + }); + } + + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(); + if (configurationSucceded) { showMessage( context, l10n.l_slot_credential_configured( l10n.s_static_password)); - }); - } catch (e) { - _log.error('Failed to program credential', e); - await ref.read(withContextProvider)((context) async { - showMessage( - context, - l10n.p_otp_slot_configuration_error( - widget.otpSlot.slot.getDisplayName(l10n)), - duration: const Duration(seconds: 4), - ); - }); - } + } + }); } : null, child: Text(l10n.s_save), diff --git a/lib/otp/views/configure_yubiotp_dialog.dart b/lib/otp/views/configure_yubiotp_dialog.dart index e15ea369..ac027fb0 100644 --- a/lib/otp/views/configure_yubiotp_dialog.dart +++ b/lib/otp/views/configure_yubiotp_dialog.dart @@ -39,6 +39,7 @@ import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; +import 'access_code_dialog.dart'; import 'overwrite_confirm_dialog.dart'; final _log = Logger('otp.view.configure_yubiotp_dialog'); @@ -153,14 +154,39 @@ class _ConfigureYubiOtpDialogState final otpNotifier = ref.read(otpStateProvider(widget.devicePath).notifier); + final configuration = SlotConfiguration.yubiotp( + publicId: publicId, + privateId: privateId, + key: secret, + options: + SlotConfigurationOptions(appendCr: _appendEnter)); + + bool configurationSucceded = false; try { await otpNotifier.configureSlot(widget.otpSlot.slot, - configuration: SlotConfiguration.yubiotp( - publicId: publicId, - privateId: privateId, - key: secret, - options: SlotConfigurationOptions( - appendCr: _appendEnter))); + configuration: configuration); + configurationSucceded = true; + } catch (e) { + _log.error('Failed to program credential', e); + // Access code required + await ref.read(withContextProvider)((context) async { + final result = await showBlurDialog( + context: context, + builder: (context) => AccessCodeDialog( + devicePath: widget.devicePath, + otpSlot: widget.otpSlot, + action: (accessCode) async { + await otpNotifier.configureSlot( + widget.otpSlot.slot, + configuration: configuration, + accessCode: accessCode); + }, + )); + configurationSucceded = result ?? false; + }); + } + + if (configurationSucceded) { if (outputFile != null) { final csv = await otpNotifier.formatYubiOtpCsv( info!.serial!, publicId, privateId, secret); @@ -169,8 +195,10 @@ class _ConfigureYubiOtpDialogState '$csv${Platform.lineTerminator}', mode: FileMode.append); } - await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(); + } + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(); + if (configurationSucceded) { showMessage( context, outputFile != null @@ -179,24 +207,8 @@ class _ConfigureYubiOtpDialogState outputFile.uri.pathSegments.last) : l10n.l_slot_credential_configured( l10n.s_capability_otp)); - }); - } catch (e) { - _log.error('Failed to program credential', e); - await ref.read(withContextProvider)((context) async { - final String errorMessage; - if (e is PathNotFoundException) { - errorMessage = '${e.message} ${e.path.toString()}'; - } else { - errorMessage = l10n.p_otp_slot_configuration_error( - widget.otpSlot.slot.getDisplayName(l10n)); - } - showMessage( - context, - errorMessage, - duration: const Duration(seconds: 4), - ); - }); - } + } + }); } : null, child: Text(l10n.s_save), diff --git a/lib/otp/views/delete_slot_dialog.dart b/lib/otp/views/delete_slot_dialog.dart index 0844d301..b3e3e273 100644 --- a/lib/otp/views/delete_slot_dialog.dart +++ b/lib/otp/views/delete_slot_dialog.dart @@ -25,6 +25,7 @@ import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; +import 'access_code_dialog.dart'; class DeleteSlotDialog extends ConsumerWidget { final DevicePath devicePath; @@ -40,25 +41,34 @@ class DeleteSlotDialog extends ConsumerWidget { TextButton( key: keys.deleteButton, onPressed: () async { + final otpStateNotifier = + ref.read(otpStateProvider(devicePath).notifier); + bool deleteSucceeded = false; try { - await ref - .read(otpStateProvider(devicePath).notifier) - .deleteSlot(otpSlot.slot); - await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(true); - showMessage(context, l10n.l_slot_deleted); - }); + await otpStateNotifier.deleteSlot(otpSlot.slot); + deleteSucceeded = true; } catch (e) { + // Access code required await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(true); - showMessage( - context, - l10n.p_otp_slot_configuration_error( - otpSlot.slot.getDisplayName(l10n)), - duration: const Duration(seconds: 4), - ); + final result = await showBlurDialog( + context: context, + builder: (context) => AccessCodeDialog( + devicePath: devicePath, + otpSlot: otpSlot, + action: (accessCode) async { + await otpStateNotifier.deleteSlot(otpSlot.slot, + accessCode: accessCode); + }, + )); + deleteSucceeded = result ?? false; }); } + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(true); + if (deleteSucceeded) { + showMessage(context, l10n.l_slot_deleted); + } + }); }, child: Text(l10n.s_delete), ) diff --git a/lib/otp/views/swap_slots_dialog.dart b/lib/otp/views/swap_slots_dialog.dart index 6bc4559c..d1de6e1a 100644 --- a/lib/otp/views/swap_slots_dialog.dart +++ b/lib/otp/views/swap_slots_dialog.dart @@ -38,11 +38,20 @@ class SwapSlotsDialog extends ConsumerWidget { TextButton( key: swapButton, onPressed: () async { - await ref.read(otpStateProvider(devicePath).notifier).swapSlots(); - await ref.read(withContextProvider)((context) async { - Navigator.of(context).pop(); - showMessage(context, l10n.l_slots_swapped); - }); + try { + await ref + .read(otpStateProvider(devicePath).notifier) + .swapSlots(); + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(); + showMessage(context, l10n.l_slots_swapped); + }); + } catch (e) { + await ref.read(withContextProvider)((context) async { + Navigator.of(context).pop(); + showMessage(context, l10n.p_otp_swap_error); + }); + } }, child: Text(l10n.s_swap)) ],