Enhance error handling in OTP.

This commit is contained in:
Elias Bonnici 2023-11-23 16:25:11 +01:00
parent 23ebcb6955
commit 63bb18b2be
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
11 changed files with 324 additions and 205 deletions

View File

@ -89,7 +89,7 @@ extension on Application {
IconData get _icon => switch (this) { IconData get _icon => switch (this) {
Application.oath => Icons.supervisor_account_outlined, Application.oath => Icons.supervisor_account_outlined,
Application.fido => Icons.security_outlined, Application.fido => Icons.security_outlined,
Application.otp => Icons.password_outlined, Application.otp => Icons.touch_app_outlined,
Application.piv => Icons.approval_outlined, Application.piv => Icons.approval_outlined,
Application.management => Icons.construction_outlined, Application.management => Icons.construction_outlined,
Application.openpgp => Icons.key_outlined, Application.openpgp => Icons.key_outlined,
@ -99,7 +99,7 @@ extension on Application {
IconData get _filledIcon => switch (this) { IconData get _filledIcon => switch (this) {
Application.oath => Icons.supervisor_account, Application.oath => Icons.supervisor_account,
Application.fido => Icons.security, Application.fido => Icons.security,
Application.otp => Icons.password, Application.otp => Icons.touch_app,
Application.piv => Icons.approval, Application.piv => Icons.approval,
Application.management => Icons.construction, Application.management => Icons.construction,
Application.openpgp => Icons.key, Application.openpgp => Icons.key,

View File

@ -155,3 +155,18 @@ class Version with _$Version implements Comparable<Version> {
} }
final DateFormat dateFormatter = DateFormat('yyyy-MM-dd'); final DateFormat dateFormatter = DateFormat('yyyy-MM-dd');
enum Format {
base32('a-z2-7'),
hex('abcdef0123456789'),
modhex('cbdefghijklnrtuv');
final String allowedCharacters;
const Format(this.allowedCharacters);
bool isValid(String input) {
return RegExp('^[$allowedCharacters]+\$', caseSensitive: false)
.hasMatch(input);
}
}

View File

@ -61,9 +61,9 @@
"s_manage": "Manage", "s_manage": "Manage",
"s_setup": "Setup", "s_setup": "Setup",
"s_settings": "Settings", "s_settings": "Settings",
"s_piv": "PIV", "s_piv": "Certificates",
"s_webauthn": "WebAuthn", "s_webauthn": "WebAuthn",
"s_otp": "OTP", "s_otp": "Slots",
"s_help_and_about": "Help and about", "s_help_and_about": "Help and about",
"s_help_and_feedback": "Help and feedback", "s_help_and_feedback": "Help and feedback",
"s_send_feedback": "Send us feedback", "s_send_feedback": "Send us feedback",
@ -80,6 +80,13 @@
"s_private_key": "Private key", "s_private_key": "Private key",
"s_invalid_length": "Invalid length", "s_invalid_length": "Invalid length",
"s_invalid_format": "Invalid format", "s_invalid_format": "Invalid format",
"l_invalid_format_allowed_chars": "Invalid format, allowed characters: {characters}",
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": "Invalid characters for selected keyboard",
"s_require_touch": "Require touch", "s_require_touch": "Require touch",
"q_have_account_info": "Have account info?", "q_have_account_info": "Have account info?",
"s_run_diagnostics": "Run diagnostics", "s_run_diagnostics": "Run diagnostics",
@ -508,27 +515,20 @@
"@_otp_slots": {}, "@_otp_slots": {},
"s_otp_slots": "Slots", "s_otp_slots": "Slots",
"s_otp_slot_display_name": "{name} (Slot {id})",
"@s_otp_slot_display_name" : {
"placeholders": {
"name": {},
"id": {}
}
},
"s_otp_slot_one": "Short touch", "s_otp_slot_one": "Short touch",
"s_otp_slot_two": "Long touch", "s_otp_slot_two": "Long touch",
"l_otp_slot_not_programmed": "Slot is empty", "l_otp_slot_empty": "Slot is empty",
"l_otp_slot_programmed": "Slot is programmed", "l_otp_slot_configured": "Slot is configured",
"@_otp_slot_configurations": {}, "@_otp_slot_configurations": {},
"s_yubiotp": "Yubico OTP", "s_yubiotp": "Yubico OTP",
"l_yubiotp_desc": "YubiCloud verified OTP's", "l_yubiotp_desc": "Program a Yubico OTP credential",
"s_challenge_response": "Challenge-response", "s_challenge_response": "Challenge-response",
"l_challenge_response_desc": "Show me yours and I'll show you mine", "l_challenge_response_desc": "Program a challenge-response credential",
"s_static_password": "Static password", "s_static_password": "Static password",
"l_static_password_desc": "Use complex passwords with a touch", "l_static_password_desc": "Configure a static password",
"s_hotp": "OATH-HOTP", "s_hotp": "OATH-HOTP",
"l_hotp_desc": "HMAC based OTP's", "l_hotp_desc": "Program a HMAC-SHA1 based credential",
"s_public_id": "Public ID", "s_public_id": "Public ID",
"s_private_id": "Private ID", "s_private_id": "Private ID",
"s_allow_any_character": "Allow any character", "s_allow_any_character": "Allow any character",
@ -538,34 +538,34 @@
"s_generate_passowrd": "Generate password", "s_generate_passowrd": "Generate password",
"@_otp_slot_actions": {}, "@_otp_slot_actions": {},
"s_delete_slot": "Delete slot", "s_delete_slot": "Delete credential",
"l_delete_slot_desc": "Delete configuration in slot", "l_delete_slot_desc": "Remove credential in slot",
"p_warning_delete_slot_configuration": "Warning! This action will delete the configuration in OTP slot {slot}.", "p_warning_delete_slot_configuration": "Warning! This action will permanently remove the credential from slot {slot_id}.",
"@p_warning_delete_slot_configuration": { "@p_warning_delete_slot_configuration": {
"placeholders": { "placeholders": {
"slot": {} "slot_id": {}
} }
}, },
"l_slot_deleted": "Slot configuration deleted", "l_slot_deleted": "Credential deleted",
"s_swap": "Swap", "s_swap": "Swap",
"s_swap_slots": "Swap slots", "s_swap_slots": "Swap slots",
"l_swap_slots_desc": "Swap short/long touch", "l_swap_slots_desc": "Swap short/long touch",
"p_swap_slots_desc": "This will swap the configuration of the two slots.", "p_swap_slots_desc": "This will swap the configuration of the two slots.",
"l_slots_swapped": "Slot configurations swapped", "l_slots_swapped": "Slot configurations swapped",
"l_slot_configuration_programmed": "Configured {type} credential", "l_slot_credential_configured": "Configured {type} credential",
"@l_slot_configuration_programmed": { "@l_slot_credential_configured": {
"placeholders": { "placeholders": {
"type": {} "type": {}
} }
}, },
"l_slot_configuration_programmed_and_exported": "Configured {type} credential and exported to {file}", "l_slot_credential_configured_and_exported": "Configured {type} credential and exported to {file}",
"@l_slot_configuration_programmed_and_exported": { "@l_slot_credential_configured_and_exported": {
"placeholders": { "placeholders": {
"type": {}, "type": {},
"file": {} "file": {}
} }
}, },
"s_append_enter": "Append enter", "s_append_enter": "Append ",
"l_append_enter_desc": "Append an Enter keystroke after emitting the OTP", "l_append_enter_desc": "Append an Enter keystroke after emitting the OTP",
"@_otp_errors": {}, "@_otp_errors": {},
@ -576,6 +576,7 @@
} }
}, },
"@_permissions": {}, "@_permissions": {},
"s_enable_nfc": "Enable NFC", "s_enable_nfc": "Enable NFC",
"s_permission_denied": "Permission denied", "s_permission_denied": "Permission denied",

View File

@ -29,10 +29,9 @@ enum SlotId {
const SlotId(this.id, this.numberId); const SlotId(this.id, this.numberId);
String getDisplayName(AppLocalizations l10n) { String getDisplayName(AppLocalizations l10n) {
String nameFor(String name) => l10n.s_otp_slot_display_name(name, numberId);
return switch (this) { return switch (this) {
SlotId.one => nameFor(l10n.s_otp_slot_one), SlotId.one => l10n.s_otp_slot_one,
SlotId.two => nameFor(l10n.s_otp_slot_two) SlotId.two => l10n.s_otp_slot_two
}; };
} }

View File

@ -17,10 +17,10 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/app/logging.dart'; import 'package:yubico_authenticator/app/logging.dart';
import 'package:yubico_authenticator/core/models.dart';
import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/core/state.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -49,6 +49,7 @@ class _ConfigureChalrespDialogState
extends ConsumerState<ConfigureChalrespDialog> { extends ConsumerState<ConfigureChalrespDialog> {
final _secretController = TextEditingController(); final _secretController = TextEditingController();
bool _validateSecretLength = false; bool _validateSecretLength = false;
bool _validateSecretFormat = false;
bool _requireTouch = false; bool _requireTouch = false;
final int secretMaxLength = 40; final int secretMaxLength = 40;
@ -62,9 +63,11 @@ class _ConfigureChalrespDialogState
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final secret = _secretController.text.replaceAll(' ', ''); final secret = _secretController.text;
final secretLengthValid = final secretLengthValid = secret.isNotEmpty &&
secret.isNotEmpty && secret.length <= secretMaxLength; secret.length % 2 == 0 &&
secret.length <= secretMaxLength;
final secretFormatValid = Format.hex.isValid(secret);
return ResponsiveDialog( return ResponsiveDialog(
title: Text(l10n.s_challenge_response), title: Text(l10n.s_challenge_response),
@ -79,6 +82,12 @@ class _ConfigureChalrespDialogState
}); });
return; return;
} }
if (!secretFormatValid) {
setState(() {
_validateSecretFormat = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) { if (!await confirmOverwrite(context, widget.otpSlot)) {
return; return;
@ -94,8 +103,10 @@ class _ConfigureChalrespDialogState
requireTouch: _requireTouch))); requireTouch: _requireTouch)));
await ref.read(withContextProvider)((context) async { await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop(); Navigator.of(context).pop();
showMessage(context, showMessage(
l10n.l_slot_configuration_programmed(l10n.s_hotp)); context,
l10n.l_slot_credential_configured(
l10n.s_challenge_response));
}); });
} catch (e) { } catch (e) {
_log.error('Failed to program credential', e); _log.error('Failed to program credential', e);
@ -125,36 +136,48 @@ class _ConfigureChalrespDialogState
autofillHints: isAndroid ? [] : const [AutofillHints.password], autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: secretMaxLength, maxLength: secretMaxLength,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: IconButton( suffixIcon: Wrap(
tooltip: l10n.s_generate_secret_key, crossAxisAlignment: WrapCrossAlignment.center,
icon: const Icon(Icons.refresh), children: [
onPressed: () { IconButton(
final random = Random.secure(); icon: const Icon(Icons.refresh),
final key = List.generate( onPressed: () {
20, setState(() {
(_) => random final random = Random.secure();
.nextInt(256) final key = List.generate(
.toRadixString(16) 20,
.padLeft(2, '0')).join(); (_) => random
setState(() { .nextInt(256)
_secretController.text = key; .toRadixString(16)
}); .padLeft(2, '0')).join();
}, setState(() {
_secretController.text = key;
});
});
},
),
if (_validateSecretLength || _validateSecretFormat) ...[
const Icon(Icons.error_outlined),
const SizedBox(
width: 8.0,
)
]
],
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined), prefixIcon: const Icon(Icons.key_outlined),
labelText: l10n.s_secret_key, labelText: l10n.s_secret_key,
errorText: _validateSecretLength && !secretLengthValid errorText: _validateSecretLength && !secretLengthValid
? l10n.s_invalid_length ? l10n.s_invalid_length
: null), : _validateSecretFormat && !secretFormatValid
inputFormatters: <TextInputFormatter>[ ? l10n.l_invalid_format_allowed_chars(
FilteringTextInputFormatter.allow( Format.hex.allowedCharacters)
RegExp('[a-f0-9]', caseSensitive: false)) : null),
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_validateSecretLength = false; _validateSecretLength = false;
_validateSecretFormat = false;
}); });
}, },
), ),

View File

@ -15,10 +15,10 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/app/logging.dart'; import 'package:yubico_authenticator/app/logging.dart';
import 'package:yubico_authenticator/core/models.dart';
import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/core/state.dart';
import 'package:yubico_authenticator/oath/models.dart'; import 'package:yubico_authenticator/oath/models.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -48,6 +48,7 @@ class ConfigureHotpDialog extends ConsumerStatefulWidget {
class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> { class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
final _secretController = TextEditingController(); final _secretController = TextEditingController();
bool _validateSecretLength = false; bool _validateSecretLength = false;
bool _validateSecretFormat = false;
int _digits = defaultDigits; int _digits = defaultDigits;
final List<int> _digitsValues = [6, 8]; final List<int> _digitsValues = [6, 8];
bool _appendEnter = true; bool _appendEnter = true;
@ -65,6 +66,7 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
final secret = _secretController.text.replaceAll(' ', ''); final secret = _secretController.text.replaceAll(' ', '');
final secretLengthValid = secret.isNotEmpty && secret.length * 5 % 8 < 5; final secretLengthValid = secret.isNotEmpty && secret.length * 5 % 8 < 5;
final secretFormatValid = Format.base32.isValid(secret);
return ResponsiveDialog( return ResponsiveDialog(
title: Text(l10n.s_hotp), title: Text(l10n.s_hotp),
@ -79,6 +81,12 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
}); });
return; return;
} }
if (!secretFormatValid) {
setState(() {
_validateSecretFormat = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) { if (!await confirmOverwrite(context, widget.otpSlot)) {
return; return;
@ -96,7 +104,7 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
await ref.read(withContextProvider)((context) async { await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop(); Navigator.of(context).pop();
showMessage(context, showMessage(context,
l10n.l_slot_configuration_programmed(l10n.s_hotp)); l10n.l_slot_credential_configured(l10n.s_hotp));
}); });
} catch (e) { } catch (e) {
_log.error('Failed to program credential', e); _log.error('Failed to program credential', e);
@ -124,42 +132,66 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
controller: _secretController, controller: _secretController,
obscureText: _isObscure, obscureText: _isObscure,
autofillHints: isAndroid ? [] : const [AutofillHints.password], autofillHints: isAndroid ? [] : const [AutofillHints.password],
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(
'[abcdefghijklmnopqrstuvwxyz234567 ]',
caseSensitive: false))
],
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: IconButton( suffixIcon: Wrap(
icon: Icon( crossAxisAlignment: WrapCrossAlignment.center,
_isObscure ? Icons.visibility : Icons.visibility_off, children: [
color: IconTheme.of(context).color, IconButton(
), icon: Icon(
onPressed: () { _isObscure
setState(() { ? Icons.visibility
_isObscure = !_isObscure; : Icons.visibility_off,
}); color: !(_validateSecretLength ||
}, _validateSecretFormat)
? IconTheme.of(context).color
: null),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
),
if (_validateSecretLength || _validateSecretFormat) ...[
const Icon(Icons.error_outlined),
const SizedBox(
width: 8.0,
)
]
],
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined), prefixIcon: const Icon(Icons.key_outlined),
labelText: l10n.s_secret_key, labelText: l10n.s_secret_key,
helperText: '', // Prevents resizing when errorText shown
errorText: _validateSecretLength && !secretLengthValid errorText: _validateSecretLength && !secretLengthValid
? l10n.s_invalid_length ? l10n.s_invalid_length
: null), : _validateSecretFormat && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.base32.allowedCharacters)
: null),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_validateSecretLength = false; _validateSecretLength = false;
_validateSecretFormat = false;
}); });
}, },
), ),
const SizedBox(height: 8),
Wrap( Wrap(
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0, spacing: 4.0,
runSpacing: 8.0, runSpacing: 8.0,
children: [ children: [
FilterChip(
label: Text(l10n.s_append_enter),
tooltip: l10n.l_append_enter_desc,
selected: _appendEnter,
onSelected: (value) {
setState(() {
_appendEnter = value;
});
},
),
ChoiceFilterChip<int>( ChoiceFilterChip<int>(
items: _digitsValues, items: _digitsValues,
value: _digits, value: _digits,
@ -170,16 +202,6 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
_digits = digits; _digits = digits;
}); });
}), }),
FilterChip(
label: Text(l10n.s_append_enter),
tooltip: l10n.l_append_enter_desc,
selected: _appendEnter,
onSelected: (value) {
setState(() {
_appendEnter = value;
});
},
)
], ],
) )
] ]

View File

@ -15,7 +15,6 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/app/logging.dart'; import 'package:yubico_authenticator/app/logging.dart';
@ -69,13 +68,13 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
super.dispose(); super.dispose();
} }
String generateFormatterPattern(String layout) { RegExp generateFormatterPattern(String layout) {
final allowedCharacters = widget.keyboardLayouts[layout] ?? []; final allowedCharacters = widget.keyboardLayouts[layout] ?? [];
final pattern = final pattern =
allowedCharacters.map((char) => RegExp.escape(char)).join(''); allowedCharacters.map((char) => RegExp.escape(char)).join('');
return '[$pattern]'; return RegExp('^[$pattern]+\$', caseSensitive: false);
} }
@override @override
@ -85,10 +84,8 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
final password = _passwordController.text.replaceAll(' ', ''); final password = _passwordController.text.replaceAll(' ', '');
final passwordLengthValid = final passwordLengthValid =
password.isNotEmpty && password.length <= passwordMaxLength; password.isNotEmpty && password.length <= passwordMaxLength;
final passwordFormatValid =
final layoutPattern = generateFormatterPattern(_keyboardLayout); generateFormatterPattern(_keyboardLayout).hasMatch(password);
final regex = RegExp('^$layoutPattern', caseSensitive: false);
final passwordFormatValid = regex.hasMatch(password);
return ResponsiveDialog( return ResponsiveDialog(
title: Text(l10n.s_static_password), title: Text(l10n.s_static_password),
@ -121,7 +118,7 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
Navigator.of(context).pop(); Navigator.of(context).pop();
showMessage( showMessage(
context, context,
l10n.l_slot_configuration_programmed( l10n.l_slot_credential_configured(
l10n.s_static_password)); l10n.s_static_password));
}); });
} catch (e) { } catch (e) {
@ -152,18 +149,31 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
autofillHints: isAndroid ? [] : const [AutofillHints.password], autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: passwordMaxLength, maxLength: passwordMaxLength,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: IconButton( suffixIcon: Wrap(
tooltip: l10n.s_generate_passowrd, crossAxisAlignment: WrapCrossAlignment.center,
icon: const Icon(Icons.refresh), children: [
onPressed: () async { IconButton(
final password = await ref tooltip: l10n.s_generate_passowrd,
.read(otpStateProvider(widget.devicePath).notifier) icon: const Icon(Icons.refresh),
.generateStaticPassword( onPressed: () async {
passwordMaxLength, _keyboardLayout); final password = await ref
setState(() { .read(
_passwordController.text = password; otpStateProvider(widget.devicePath).notifier)
}); .generateStaticPassword(
}, passwordMaxLength, _keyboardLayout);
setState(() {
_validatePassword = false;
_passwordController.text = password;
});
},
),
if (_validatePassword) ...[
const Icon(Icons.error_outlined),
const SizedBox(
width: 8.0,
)
]
],
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined), prefixIcon: const Icon(Icons.key_outlined),
@ -175,13 +185,8 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
: _validatePassword && : _validatePassword &&
passwordLengthValid && passwordLengthValid &&
!passwordFormatValid !passwordFormatValid
? l10n.s_invalid_format ? l10n.l_invalid_keyboard_character
: null), : null),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(
generateFormatterPattern(_keyboardLayout),
caseSensitive: false))
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@ -194,17 +199,6 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
spacing: 4.0, spacing: 4.0,
runSpacing: 8.0, runSpacing: 8.0,
children: [ children: [
ChoiceFilterChip(
items: widget.keyboardLayouts.keys.toList(),
value: _keyboardLayout,
selected: _keyboardLayout != _defaultKeyboardLayout,
itemBuilder: (value) => Text(value),
onChanged: (layout) {
setState(() {
_keyboardLayout = layout;
_validatePassword = false;
});
}),
FilterChip( FilterChip(
label: Text(l10n.s_append_enter), label: Text(l10n.s_append_enter),
tooltip: l10n.l_append_enter_desc, tooltip: l10n.l_append_enter_desc,
@ -214,7 +208,19 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
_appendEnter = value; _appendEnter = value;
}); });
}, },
) ),
ChoiceFilterChip(
items: widget.keyboardLayouts.keys.toList(),
value: _keyboardLayout,
selected: _keyboardLayout != _defaultKeyboardLayout,
labelBuilder: (value) => Text('Keyboard $value'),
itemBuilder: (value) => Text(value),
onChanged: (layout) {
setState(() {
_keyboardLayout = layout;
_validatePassword = false;
});
}),
], ],
) )
] ]

View File

@ -19,10 +19,10 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yubico_authenticator/app/logging.dart'; import 'package:yubico_authenticator/app/logging.dart';
import 'package:yubico_authenticator/core/models.dart';
import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/core/state.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:yubico_authenticator/widgets/choice_filter_chip.dart'; import 'package:yubico_authenticator/widgets/choice_filter_chip.dart';
@ -38,8 +38,6 @@ import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_yubiotp_dialog'); final _log = Logger('otp.view.configure_yubiotp_dialog');
final _modhexPattern = RegExp('[cbdefghijklnrtuv]', caseSensitive: false);
enum OutputActions { enum OutputActions {
selectFile, selectFile,
noOutput; noOutput;
@ -48,7 +46,7 @@ enum OutputActions {
String getDisplayName(AppLocalizations l10n) => switch (this) { String getDisplayName(AppLocalizations l10n) => switch (this) {
OutputActions.selectFile => 'Select file', OutputActions.selectFile => 'Select file',
OutputActions.noOutput => 'No output' OutputActions.noOutput => 'No export file'
}; };
} }
@ -67,11 +65,14 @@ class _ConfigureYubiOtpDialogState
final _secretController = TextEditingController(); final _secretController = TextEditingController();
final _publicIdController = TextEditingController(); final _publicIdController = TextEditingController();
final _privateIdController = TextEditingController(); final _privateIdController = TextEditingController();
OutputActions _action = OutputActions.noOutput;
bool _appendEnter = true;
bool _validateSecretFormat = false;
bool _validatePublicIdFormat = false;
bool _validatePrivateIdFormat = false;
final secretLength = 32; final secretLength = 32;
final publicIdLength = 12; final publicIdLength = 12;
final privateIdLength = 12; final privateIdLength = 12;
OutputActions _action = OutputActions.selectFile;
bool _appendEnter = true;
@override @override
void dispose() { void dispose() {
@ -87,16 +88,19 @@ class _ConfigureYubiOtpDialogState
final info = ref.watch(currentDeviceDataProvider).valueOrNull?.info; final info = ref.watch(currentDeviceDataProvider).valueOrNull?.info;
final secret = _secretController.text.replaceAll(' ', ''); final secret = _secretController.text;
final secretLengthValid = secret.length == secretLength; final secretLengthValid = secret.length == secretLength;
final secretFormatValid = Format.hex.isValid(secret);
final privateId = _privateIdController.text; final privateId = _privateIdController.text;
final privateIdLengthValid = privateId.length == privateIdLength; final privateIdLengthValid = privateId.length == privateIdLength;
final privatedIdFormatValid = Format.hex.isValid(privateId);
final publicId = _publicIdController.text; final publicId = _publicIdController.text;
final publicIdLengthValid = publicId.length == publicIdLength; final publicIdLengthValid = publicId.length == publicIdLength;
final publicIdFormatValid = Format.modhex.isValid(publicId);
final isValid = final lengthsValid =
secretLengthValid && privateIdLengthValid && publicIdLengthValid; secretLengthValid && privateIdLengthValid && publicIdLengthValid;
final outputFile = ref.read(yubiOtpOutputProvider); final outputFile = ref.read(yubiOtpOutputProvider);
@ -121,8 +125,19 @@ class _ConfigureYubiOtpDialogState
actions: [ actions: [
TextButton( TextButton(
key: keys.saveButton, key: keys.saveButton,
onPressed: isValid onPressed: lengthsValid
? () async { ? () async {
if (!secretFormatValid ||
!publicIdFormatValid ||
!privatedIdFormatValid) {
setState(() {
_validateSecretFormat = !secretFormatValid;
_validatePublicIdFormat = !publicIdFormatValid;
_validatePrivateIdFormat = !privatedIdFormatValid;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) { if (!await confirmOverwrite(context, widget.otpSlot)) {
return; return;
} }
@ -150,11 +165,10 @@ class _ConfigureYubiOtpDialogState
showMessage( showMessage(
context, context,
outputFile != null outputFile != null
? l10n ? l10n.l_slot_credential_configured_and_exported(
.l_slot_configuration_programmed_and_exported( l10n.s_yubiotp,
l10n.s_yubiotp, outputFile.uri.pathSegments.last)
outputFile.uri.pathSegments.last) : l10n.l_slot_credential_configured(
: l10n.l_slot_configuration_programmed(
l10n.s_yubiotp)); l10n.s_yubiotp));
}); });
} catch (e) { } catch (e) {
@ -191,31 +205,43 @@ class _ConfigureYubiOtpDialogState
autofillHints: isAndroid ? [] : const [AutofillHints.password], autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: publicIdLength, maxLength: publicIdLength,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: IconButton( suffixIcon: Wrap(
tooltip: l10n.s_use_serial, crossAxisAlignment: WrapCrossAlignment.center,
icon: const Icon(Icons.auto_awesome_outlined), children: [
onPressed: (info?.serial != null) IconButton(
? () async { tooltip: l10n.s_use_serial,
final publicId = await ref icon: const Icon(Icons.auto_awesome_outlined),
.read(otpStateProvider(widget.devicePath) onPressed: (info?.serial != null)
.notifier) ? () async {
.modhexEncodeSerial(info!.serial!); final publicId = await ref
setState(() { .read(otpStateProvider(widget.devicePath)
_publicIdController.text = publicId; .notifier)
}); .modhexEncodeSerial(info!.serial!);
} setState(() {
: null, _publicIdController.text = publicId;
});
}
: null,
),
if (_validatePublicIdFormat) ...[
const Icon(Icons.error_outlined),
const SizedBox(
width: 8.0,
)
]
],
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.public_outlined), prefixIcon: const Icon(Icons.public_outlined),
errorText: _validatePublicIdFormat && !publicIdFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.modhex.allowedCharacters)
: null,
labelText: l10n.s_public_id), labelText: l10n.s_public_id),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(_modhexPattern)
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
// Update lengths _validatePublicIdFormat = false;
}); });
}, },
), ),
@ -225,33 +251,44 @@ class _ConfigureYubiOtpDialogState
autofillHints: isAndroid ? [] : const [AutofillHints.password], autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: privateIdLength, maxLength: privateIdLength,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: IconButton( suffixIcon: Wrap(
tooltip: l10n.s_generate_private_id, crossAxisAlignment: WrapCrossAlignment.center,
icon: const Icon(Icons.refresh), children: [
onPressed: () { IconButton(
final random = Random.secure(); tooltip: l10n.s_generate_private_id,
final key = List.generate( icon: const Icon(Icons.refresh),
6, onPressed: () {
(_) => random final random = Random.secure();
.nextInt(256) final key = List.generate(
.toRadixString(16) 6,
.padLeft(2, '0')).join(); (_) => random
setState(() { .nextInt(256)
_privateIdController.text = key; .toRadixString(16)
}); .padLeft(2, '0')).join();
}, setState(() {
_privateIdController.text = key;
});
},
),
if (_validatePrivateIdFormat) ...[
const Icon(Icons.error_outlined),
const SizedBox(
width: 8.0,
)
]
],
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined), prefixIcon: const Icon(Icons.key_outlined),
errorText: _validatePrivateIdFormat && !privatedIdFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
labelText: l10n.s_private_id), labelText: l10n.s_private_id),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
// Update lengths _validatePrivateIdFormat = false;
}); });
}, },
), ),
@ -261,33 +298,44 @@ class _ConfigureYubiOtpDialogState
autofillHints: isAndroid ? [] : const [AutofillHints.password], autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: secretLength, maxLength: secretLength,
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: IconButton( suffixIcon: Wrap(
tooltip: l10n.s_generate_secret_key, crossAxisAlignment: WrapCrossAlignment.center,
icon: const Icon(Icons.refresh), children: [
onPressed: () { IconButton(
final random = Random.secure(); tooltip: l10n.s_generate_secret_key,
final key = List.generate( icon: const Icon(Icons.refresh),
16, onPressed: () {
(_) => random final random = Random.secure();
.nextInt(256) final key = List.generate(
.toRadixString(16) 16,
.padLeft(2, '0')).join(); (_) => random
setState(() { .nextInt(256)
_secretController.text = key; .toRadixString(16)
}); .padLeft(2, '0')).join();
}, setState(() {
_secretController.text = key;
});
},
),
if (_validateSecretFormat) ...[
const Icon(Icons.error_outlined),
const SizedBox(
width: 8.0,
)
]
],
), ),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined), prefixIcon: const Icon(Icons.key_outlined),
errorText: _validateSecretFormat && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
labelText: l10n.s_secret_key), labelText: l10n.s_secret_key),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
// Update lengths _validateSecretFormat = false;
}); });
}, },
), ),
@ -321,7 +369,9 @@ class _ConfigureYubiOtpDialogState
return Container( return Container(
constraints: const BoxConstraints(maxWidth: 140), constraints: const BoxConstraints(maxWidth: 140),
child: Text( child: Text(
'Output ${fileName ?? 'No output'}', fileName != null
? 'Export $fileName'
: _action.getDisplayName(l10n),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
); );

View File

@ -35,7 +35,7 @@ class DeleteSlotDialog extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog( return ResponsiveDialog(
title: Text(l10n.l_delete_certificate), title: Text(l10n.s_delete_slot),
actions: [ actions: [
TextButton( TextButton(
key: keys.deleteButton, key: keys.deleteButton,
@ -68,8 +68,8 @@ class DeleteSlotDialog extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.p_warning_delete_slot_configuration( Text(l10n
otpSlot.slot.getDisplayName(l10n))), .p_warning_delete_slot_configuration(otpSlot.slot.numberId)),
] ]
.map((e) => Padding( .map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -97,11 +97,10 @@ class _SlotListItem extends ConsumerWidget {
leading: CircleAvatar( leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary, foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary, backgroundColor: colorScheme.secondary,
child: const Icon(Icons.password_outlined)), child: Text(slot.numberId.toString())),
title: slot.getDisplayName(l10n), title: slot.getDisplayName(l10n),
subtitle: isConfigured subtitle:
? l10n.l_otp_slot_programmed isConfigured ? l10n.l_otp_slot_configured : l10n.l_otp_slot_empty,
: l10n.l_otp_slot_not_programmed,
trailing: OutlinedButton( trailing: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()), onPressed: Actions.handler(context, const OpenIntent()),
child: const Icon(Icons.more_horiz), child: const Icon(Icons.more_horiz),

View File

@ -72,13 +72,17 @@ class SlotDialog extends ConsumerWidget {
const Icon( const Icon(
Icons.touch_app, Icons.touch_app,
size: 100.0, size: 100.0,
) ),
const SizedBox(height: 8),
Text(otpSlot.isConfigured
? l10n.l_otp_slot_configured
: l10n.l_otp_slot_empty)
], ],
), ),
), ),
ActionListSection.fromMenuActions( ActionListSection.fromMenuActions(
context, context,
l10n.s_actions, l10n.s_setup,
actions: buildSlotActions(otpSlot.isConfigured, l10n), actions: buildSlotActions(otpSlot.isConfigured, l10n),
) )
], ],