Add access code dialog for OTP

This commit is contained in:
Elias Bonnici 2024-03-27 16:42:56 +01:00
parent bb973bb508
commit e5435e78a6
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
15 changed files with 396 additions and 106 deletions

View File

@ -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:

View File

@ -117,16 +117,21 @@ class _DesktopOtpStateNotifier extends OtpStateNotifier {
}
@override
Future<void> deleteSlot(SlotId slot) async {
await _session.command('delete', target: [..._subpath, slot.id]);
Future<void> 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<void> 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();
}
}

View File

@ -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": {},

View File

@ -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": {},

View File

@ -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": {},

View File

@ -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": {},

View File

@ -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": {},

View File

@ -47,6 +47,6 @@ abstract class OtpStateNotifier extends ApplicationStateNotifier<OtpState> {
int serial, String publicId, String privateId, String key);
Future<void> swapSlots();
Future<void> configureSlot(SlotId slot,
{required SlotConfiguration configuration});
Future<void> deleteSlot(SlotId slot);
{required SlotConfiguration configuration, String? accessCode});
Future<void> deleteSlot(SlotId slot, {String? accessCode});
}

View File

@ -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<void> Function(String accessCode) action;
const AccessCodeDialog(
{super.key,
required this.devicePath,
required this.otpSlot,
required this.action});
@override
ConsumerState<AccessCodeDialog> createState() => _AccessCodeDialogState();
}
class _AccessCodeDialogState extends ConsumerState<AccessCodeDialog> {
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(),
)),
);
}
}

View File

@ -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),

View File

@ -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<ConfigureHotpDialog> {
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),

View File

@ -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<ConfigureStaticDialog> {
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),

View File

@ -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),

View File

@ -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),
)

View File

@ -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))
],