This commit is contained in:
Elias Bonnici 2023-12-15 14:40:10 +01:00
commit 624f4aae4f
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
53 changed files with 4759 additions and 262 deletions

View File

@ -413,7 +413,9 @@ class ConnectionNode(RpcNode):
or ( # SmartCardConnection can be used over NFC, or on 5.3 and later.
isinstance(self._connection, SmartCardConnection)
and (
self._transport == TRANSPORT.NFC or self._info.version >= (5, 3, 0)
self._transport == TRANSPORT.NFC
or self._info.version >= (5, 3, 0)
or self._info.version[0] == 3
)
)
)

View File

@ -14,7 +14,8 @@
from .base import RpcNode, action, child
from yubikit.core import NotSupportedError
from yubikit.core import NotSupportedError, CommandError
from yubikit.core.otp import modhex_encode, modhex_decode
from yubikit.yubiotp import (
YubiOtpSession,
SLOT,
@ -25,7 +26,17 @@ from yubikit.yubiotp import (
YubiOtpSlotConfiguration,
StaticTicketSlotConfiguration,
)
from ykman.otp import generate_static_pw, format_csv
from yubikit.oath import parse_b32_key
from ykman.scancodes import KEYBOARD_LAYOUT, encode
from typing import Dict
import struct
_FAIL_MSG = (
"Failed to write to the YubiKey. Make sure the device does not "
"have restricted access"
)
class YubiOtpNode(RpcNode):
@ -65,6 +76,29 @@ class YubiOtpNode(RpcNode):
def two(self):
return SlotNode(self.session, SLOT.TWO)
@action(closes_child=False)
def serial_modhex(self, params, event, signal):
serial = params["serial"]
return dict(encoded=modhex_encode(b"\xff\x00" + struct.pack(b">I", serial)))
@action(closes_child=False)
def generate_static(self, params, event, signal):
layout, length = params["layout"], int(params["length"])
return dict(password=generate_static_pw(length, KEYBOARD_LAYOUT[layout]))
@action(closes_child=False)
def keyboard_layouts(self, params, event, signal):
return {layout.name: [sc for sc in layout.value] for layout in KEYBOARD_LAYOUT}
@action(closes_child=False)
def format_yubiotp_csv(self, params, even, signal):
serial = params["serial"]
public_id = modhex_decode(params["public_id"])
private_id = bytes.fromhex(params["private_id"])
key = bytes.fromhex(params["key"])
return dict(csv=format_csv(serial, public_id, private_id, key))
_CONFIG_TYPES = dict(
hmac_sha1=HmacSha1SlotConfiguration,
@ -113,7 +147,10 @@ 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))
except CommandError:
raise ValueError(_FAIL_MSG)
@action(condition=lambda self: self._can_calculate(self.slot))
def calculate(self, params, event, signal):
@ -121,7 +158,7 @@ class SlotNode(RpcNode):
response = self.session.calculate_hmac_sha1(self.slot, challenge, event)
return dict(response=response)
def _apply_config(self, config, params):
def _apply_options(self, config, options):
for option in (
"serial_api_visible",
"serial_usb_visible",
@ -140,32 +177,52 @@ class SlotNode(RpcNode):
"short_ticket",
"manual_update",
):
if option in params:
getattr(config, option)(params.pop(option))
if option in options:
getattr(config, option)(options.pop(option))
for option in ("tabs", "delay", "pacing", "strong_password"):
if option in params:
getattr(config, option)(*params.pop(option))
if option in options:
getattr(config, option)(*options.pop(option))
if "token_id" in params:
token_id, *args = params.pop("token_id")
if "token_id" in options:
token_id, *args = options.pop("token_id")
config.token_id(bytes.fromhex(token_id), *args)
return config
def _get_config(self, type, **kwargs):
config = None
if type in _CONFIG_TYPES:
if type == "hmac_sha1":
config = _CONFIG_TYPES[type](bytes.fromhex(kwargs["key"]))
elif type == "hotp":
config = _CONFIG_TYPES[type](parse_b32_key(kwargs["key"]))
elif type == "static_password":
config = _CONFIG_TYPES[type](
encode(
kwargs["password"], KEYBOARD_LAYOUT[kwargs["keyboard_layout"]]
)
)
elif type == "yubiotp":
config = _CONFIG_TYPES[type](
fixed=modhex_decode(kwargs["public_id"]),
uid=bytes.fromhex(kwargs["private_id"]),
key=bytes.fromhex(kwargs["key"]),
)
else:
raise ValueError("No supported configuration type provided.")
return config
@action
def put(self, params, event, signal):
config = None
for key in _CONFIG_TYPES:
if key in params:
if config is not None:
raise ValueError("Only one configuration type can be provided.")
config = _CONFIG_TYPES[key](
*(bytes.fromhex(arg) for arg in params.pop(key))
)
if config is None:
raise ValueError("No supported configuration type provided.")
self._apply_config(config, params)
type = params.pop("type")
options = params.pop("options", {})
args = params
config = self._get_config(type, **args)
self._apply_options(config, options)
try:
self.session.put_configuration(
self.slot,
config,
@ -173,6 +230,8 @@ class SlotNode(RpcNode):
params.pop("cur_acc_code", None),
)
return dict()
except CommandError:
raise ValueError(_FAIL_MSG)
@action(
condition=lambda self: self._state.version >= (2, 2, 0)
@ -180,7 +239,7 @@ class SlotNode(RpcNode):
)
def update(self, params, event, signal):
config = UpdateConfiguration()
self._apply_config(config, params)
self._apply_options(config, params)
self.session.update_configuration(
self.slot,
config,

View File

@ -0,0 +1,34 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@Tags(['android', 'desktop', 'oath'])
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:yubico_authenticator/app/views/keys.dart';
import 'utils/test_util.dart';
void main() {
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
group('OTP UI tests', () {
appTest('OTP menu items exist', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer));
await tester.shortWait();
});
});
}

View File

@ -158,7 +158,7 @@ void main() {
const shortmanagementkey =
'aaaabbbbccccaaaabbbbccccaaaabbbbccccaaaabbbbccc';
appTest('Bad managementkey key', (WidgetTester tester) async {
appTest('Out of bounds managementkey key', (WidgetTester tester) async {
await tester.configurePiv();
await tester.shortWait();
await tester.tap(find.byKey(manageManagementKeyAction).hitTestable());
@ -169,12 +169,22 @@ void main() {
await tester.longWait();
await tester.tap(find.byKey(saveButton).hitTestable());
await tester.longWait();
expect(tester.isTextButtonEnabled(saveButton), true);
// TODO assert that errorText and errorIcon are shown
});
appTest('Short managementkey key', (WidgetTester tester) async {
await tester.configurePiv();
await tester.shortWait();
await tester.tap(find.byKey(manageManagementKeyAction).hitTestable());
await tester.longWait();
// testing too short management key does not work
await tester.enterText(
find.byKey(newPinPukField).hitTestable(), shortmanagementkey);
await tester.longWait();
expect(tester.isTextButtonEnabled(saveButton), false);
});
appTest('Change managementkey key', (WidgetTester tester) async {
await tester.configurePiv();
await tester.shortWait();

View File

@ -54,7 +54,8 @@ enum Application {
String getDisplayName(AppLocalizations l10n) => switch (this) {
Application.oath => l10n.s_authenticator,
Application.fido => l10n.s_webauthn,
Application.piv => l10n.s_piv,
Application.piv => l10n.s_certificates,
Application.otp => l10n.s_slots,
_ => name.substring(0, 1).toUpperCase() + name.substring(1),
};

View File

@ -25,6 +25,7 @@ import '../../core/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../fido/views/fido_screen.dart';
import '../../oath/views/oath_screen.dart';
import '../../otp/views/otp_screen.dart';
import '../../piv/views/piv_screen.dart';
import '../../widgets/custom_icons.dart';
import '../models.dart';
@ -150,6 +151,7 @@ class MainPage extends ConsumerWidget {
Application.oath => OathScreen(data.node.path),
Application.fido => FidoScreen(data),
Application.piv => PivScreen(data.node.path),
Application.otp => OtpScreen(data.node.path),
_ => MessagePage(
header: l10n.s_app_not_supported,
message: l10n.l_app_not_supported_desc,

View File

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

View File

@ -155,3 +155,18 @@ class Version with _$Version implements Comparable<Version> {
}
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

@ -42,12 +42,14 @@ import '../core/state.dart';
import '../fido/state.dart';
import '../management/state.dart';
import '../oath/state.dart';
import '../otp/state.dart';
import '../piv/state.dart';
import '../version.dart';
import 'devices.dart';
import 'fido/state.dart';
import 'management/state.dart';
import 'oath/state.dart';
import 'otp/state.dart';
import 'piv/state.dart';
import 'qr_scanner.dart';
import 'rpc.dart';
@ -189,6 +191,7 @@ Future<Widget> initialize(List<String> argv) async {
Application.fido,
Application.piv,
Application.management,
Application.otp
])),
prefProvider.overrideWithValue(prefs),
rpcProvider.overrideWith((_) => rpcFuture),
@ -226,6 +229,8 @@ Future<Widget> initialize(List<String> argv) async {
// PIV
pivStateProvider.overrideWithProvider(desktopPivState.call),
pivSlotsProvider.overrideWithProvider(desktopPivSlots.call),
// OTP
otpStateProvider.overrideWithProvider(desktopOtpState.call)
],
child: YubicoAuthenticatorApp(
page: Consumer(

132
lib/desktop/otp/state.dart Normal file
View File

@ -0,0 +1,132 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/models.dart';
import '../../core/models.dart';
import '../../otp/models.dart';
import '../../otp/state.dart';
import '../rpc.dart';
import '../state.dart';
final _log = Logger('desktop.otp.state');
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(ref, devicePath) =>
RpcNodeSession(ref.watch(rpcProvider).requireValue, devicePath, []),
);
final desktopOtpState = AsyncNotifierProvider.autoDispose
.family<OtpStateNotifier, OtpState, DevicePath>(
_DesktopOtpStateNotifier.new);
class _DesktopOtpStateNotifier extends OtpStateNotifier {
late RpcNodeSession _session;
List<String> _subpath = [];
@override
FutureOr<OtpState> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
_session.setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
});
ref.onDispose(() {
_session.unsetErrorHandler('state-reset');
});
final result = await _session.command('get');
final interfaces = (result['children'] as Map).keys.toSet();
// Will try to connect over ccid first
for (final iface in [UsbInterface.otp, UsbInterface.ccid]) {
if (interfaces.contains(iface.name)) {
final path = [iface.name, 'yubiotp'];
try {
final otpStateResult = await _session.command('get', target: path);
_subpath = path;
_log.debug('Using transport $iface for yubiotp');
_log.debug('application status', jsonEncode(result));
return OtpState.fromJson(otpStateResult['data']);
} catch (e) {
_log.warning('Failed connecting to yubiotp via $iface');
}
}
}
throw 'Failed connecting over ${UsbInterface.ccid.name} and ${UsbInterface.otp.name}';
}
@override
Future<void> swapSlots() async {
await _session.command('swap', target: _subpath);
ref.invalidate(_sessionProvider(_session.devicePath));
}
@override
Future<String> generateStaticPassword(int length, String layout) async {
final result = await _session.command('generate_static',
target: _subpath, params: {'length': length, 'layout': layout});
return result['password'];
}
@override
Future<String> modhexEncodeSerial(int serial) async {
final result = await _session
.command('serial_modhex', target: _subpath, params: {'serial': serial});
return result['encoded'];
}
@override
Future<Map<String, List<String>>> getKeyboardLayouts() async {
final result = await _session.command('keyboard_layouts', target: _subpath);
return Map<String, List<String>>.from(result.map((key, value) =>
MapEntry(key, (value as List<dynamic>).cast<String>().toList())));
}
@override
Future<String> formatYubiOtpCsv(
int serial, String publicId, String privateId, String key) async {
final result = await _session.command('format_yubiotp_csv',
target: _subpath,
params: {
'serial': serial,
'public_id': publicId,
'private_id': privateId,
'key': key
});
return result['csv'];
}
@override
Future<void> deleteSlot(SlotId slot) async {
await _session.command('delete', target: [..._subpath, slot.id]);
ref.invalidateSelf();
}
@override
Future<void> configureSlot(SlotId slot,
{required SlotConfiguration configuration}) async {
await _session.command('put',
target: [..._subpath, slot.id], params: configuration.toJson());
ref.invalidateSelf();
}
}

View File

@ -28,6 +28,7 @@ import '../../app/message.dart';
import '../../app/models.dart';
import '../../desktop/models.dart';
import '../../fido/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart';
@ -206,7 +207,7 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
inputFormatters: [limitBytesLength(15)],
buildCounter: buildByteCounterFor(_label),
autofocus: true,
decoration: InputDecoration(
decoration: AppInputDecoration(
enabled: _fingerprint != null,
border: const OutlineInputBorder(),
labelText: l10n.s_name,

View File

@ -23,6 +23,7 @@ import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../features.dart' as features;
import '../models.dart';
@ -166,7 +167,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
obscureText: _isObscure,
autofillHints: const [AutofillHints.password],
controller: _pinController,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
helperText: '', // Prevents dialog resizing
@ -176,8 +177,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color,
),
color: !_pinIsWrong ? IconTheme.of(context).color : null),
onPressed: () {
setState(() {
_isObscure = !_isObscure;

View File

@ -24,6 +24,7 @@ import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../desktop/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
@ -48,6 +49,9 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
String? _newPinError;
bool _currentIsWrong = false;
bool _newIsWrong = false;
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
@override
Widget build(BuildContext context) {
@ -76,14 +80,26 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
AppTextFormField(
initialValue: _currentPin,
autofocus: true,
obscureText: true,
obscureText: _isObscureCurrent,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_current_pin,
errorText: _currentIsWrong ? _currentPinError : null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureCurrent
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureCurrent = !_isObscureCurrent;
});
},
tooltip:
_isObscureCurrent ? l10n.s_show_pin : l10n.s_hide_pin,
),
),
onChanged: (value) {
setState(() {
@ -98,15 +114,25 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
AppTextFormField(
initialValue: _newPin,
autofocus: !hasPin,
obscureText: true,
obscureText: _isObscureNew,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_new_pin,
enabled: !hasPin || _currentPin.isNotEmpty,
errorText: _newIsWrong ? _newPinError : null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscureNew ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureNew = !_isObscureNew;
});
},
tooltip: _isObscureNew ? l10n.s_show_pin : l10n.s_hide_pin,
),
),
onChanged: (value) {
setState(() {
@ -117,12 +143,24 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
),
AppTextFormField(
initialValue: _confirmPin,
obscureText: true,
obscureText: _isObscureConfirm,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_confirm_pin,
prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureConfirm
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureConfirm = !_isObscureConfirm;
});
},
tooltip:
_isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin,
),
enabled:
(!hasPin || _currentPin.isNotEmpty) && _newPin.isNotEmpty,
),

View File

@ -21,6 +21,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../desktop/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart';
@ -95,7 +96,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameFingerprintDialog> {
maxLength: 15,
inputFormatters: [limitBytesLength(15)],
buildCounter: buildByteCounterFor(_label),
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_label,
prefixIcon: const Icon(Icons.fingerprint_outlined),

View File

@ -43,29 +43,38 @@ class ResetDialog extends ConsumerStatefulWidget {
class _ResetDialogState extends ConsumerState<ResetDialog> {
StreamSubscription<InteractionEvent>? _subscription;
InteractionEvent? _interaction;
int _currentStep = -1;
final _totalSteps = 3;
String _getMessage() {
final l10n = AppLocalizations.of(context)!;
final nfc = widget.node.transport == Transport.nfc;
if (_currentStep == 3) {
return l10n.l_fido_app_reset;
}
return switch (_interaction) {
InteractionEvent.remove =>
nfc ? l10n.l_remove_yk_from_reader : l10n.l_unplug_yk,
InteractionEvent.insert =>
nfc ? l10n.l_replace_yk_on_reader : l10n.l_reinsert_yk,
InteractionEvent.touch => l10n.l_touch_button_now,
null => l10n.l_press_reset_to_begin
null => ''
};
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
double progress = _currentStep == -1 ? 0.0 : _currentStep / (_totalSteps);
return ResponsiveDialog(
title: Text(l10n.s_factory_reset),
onCancel: () {
onCancel: _currentStep < 3
? () {
_subscription?.cancel();
},
actions: [
}
: null,
actions: _currentStep < 3
? [
TextButton(
onPressed: _subscription == null
? () async {
@ -74,12 +83,14 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
.reset()
.listen((event) {
setState(() {
_currentStep++;
_interaction = event;
});
}, onDone: () {
setState(() {
_currentStep++;
});
_subscription = null;
Navigator.of(context).pop();
showMessage(context, l10n.l_fido_app_reset);
}, onError: (e) {
_log.error('Error performing FIDO reset', e);
Navigator.of(context).pop();
@ -100,7 +111,8 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
: null,
child: Text(l10n.s_reset),
),
],
]
: [],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
@ -113,10 +125,10 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
Text(
l10n.p_warning_disable_accounts,
),
Center(
child: Text(_getMessage(),
style: Theme.of(context).textTheme.titleLarge),
),
if (_currentStep > -1) ...[
Text('${l10n.s_status}: ${_getMessage()}'),
LinearProgressIndicator(value: progress)
],
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -29,6 +29,7 @@
"s_close": "Schließen",
"s_delete": "Löschen",
"s_quit": "Beenden",
"s_status": null,
"s_unlock": "Entsperren",
"s_calculate": "Berechnen",
"s_import": null,
@ -61,8 +62,9 @@
"s_manage": "Verwalten",
"s_setup": "Einrichten",
"s_settings": "Einstellungen",
"s_piv": null,
"s_certificates": null,
"s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "Hilfe und Über",
"s_help_and_feedback": "Hilfe und Feedback",
"s_send_feedback": "Senden Sie uns Feedback",
@ -78,6 +80,14 @@
"s_hide_secret_key": null,
"s_private_key": null,
"s_invalid_length": "Ungültige Länge",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "Berührung ist erforderlich",
"q_have_account_info": "Haben Sie Konto-Informationen?",
"s_run_diagnostics": "Diagnose ausführen",
@ -189,6 +199,8 @@
"s_change_puk": null,
"s_show_pin": null,
"s_hide_pin": null,
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "Derzeitige PIN",
"s_current_puk": null,
"s_new_pin": "Neue PIN",
@ -286,6 +298,8 @@
"s_management_key": null,
"s_current_management_key": null,
"s_new_management_key": null,
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": null,
"p_change_management_key_desc": null,
"l_management_key_changed": null,
@ -429,7 +443,6 @@
"@_certificates": {},
"s_certificate": null,
"s_certificates": null,
"s_csr": null,
"s_subject": null,
"l_export_csr_file": null,
@ -504,6 +517,74 @@
"s_slot_9d": null,
"s_slot_9e": null,
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {},
"s_enable_nfc": "NFC aktivieren",
"s_permission_denied": "Zugriff verweigert",
@ -539,7 +620,6 @@
"l_oath_application_reset": "OATH Anwendung zurücksetzen",
"s_reset_fido": "FIDO zurücksetzen",
"l_fido_app_reset": "FIDO Anwendung zurückgesetzt",
"l_press_reset_to_begin": "Drücken Sie Zurücksetzen um zu beginnen\u2026",
"l_reset_failed": "Fehler beim Zurücksetzen: {message}",
"@l_reset_failed": {
"placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "Close",
"s_delete": "Delete",
"s_quit": "Quit",
"s_status": "Status",
"s_unlock": "Unlock",
"s_calculate": "Calculate",
"s_import": "Import",
@ -61,8 +62,9 @@
"s_manage": "Manage",
"s_setup": "Setup",
"s_settings": "Settings",
"s_piv": "PIV",
"s_certificates": "Certificates",
"s_webauthn": "WebAuthn",
"s_slots": "Slots",
"s_help_and_about": "Help and about",
"s_help_and_feedback": "Help and feedback",
"s_send_feedback": "Send us feedback",
@ -78,6 +80,14 @@
"s_hide_secret_key": "Hide secret key",
"s_private_key": "Private key",
"s_invalid_length": "Invalid length",
"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",
"q_have_account_info": "Have account info?",
"s_run_diagnostics": "Run diagnostics",
@ -189,6 +199,8 @@
"s_change_puk": "Change PUK",
"s_show_pin": "Show PIN",
"s_hide_pin": "Hide PIN",
"s_show_puk": "Show PUK",
"s_hide_puk": "Hide PUK",
"s_current_pin": "Current PIN",
"s_current_puk": "Current PUK",
"s_new_pin": "New PIN",
@ -286,6 +298,8 @@
"s_management_key": "Management key",
"s_current_management_key": "Current management key",
"s_new_management_key": "New management key",
"s_show_management_key": "Show management key",
"s_hide_management_key": "Hide management key",
"l_change_management_key": "Change management key",
"p_change_management_key_desc": "Change your management key. You can optionally choose to allow the PIN to be used instead of the management key.",
"l_management_key_changed": "Management key changed",
@ -429,7 +443,6 @@
"@_certificates": {},
"s_certificate": "Certificate",
"s_certificates": "Certificates",
"s_csr": "CSR",
"s_subject": "Subject",
"l_export_csr_file": "Save CSR to file",
@ -504,6 +517,74 @@
"s_slot_9d": "Key Management",
"s_slot_9e": "Card Authentication",
"@_otp_slots": {},
"s_otp_slot_one": "Short touch",
"s_otp_slot_two": "Long touch",
"l_otp_slot_empty": "Slot is empty",
"l_otp_slot_configured": "Slot is configured",
"@_otp_slot_configurations": {},
"s_yubiotp": "Yubico OTP",
"l_yubiotp_desc": "Program a Yubico OTP credential",
"s_challenge_response": "Challenge-response",
"l_challenge_response_desc": "Program a challenge-response credential",
"s_static_password": "Static password",
"l_static_password_desc": "Configure a static password",
"s_hotp": "OATH-HOTP",
"l_hotp_desc": "Program a HMAC-SHA1 based credential",
"s_public_id": "Public ID",
"s_private_id": "Private ID",
"s_allow_any_character": "Allow any character",
"s_use_serial": "Use serial",
"s_generate_private_id": "Generate private ID",
"s_generate_secret_key": "Generate secret key",
"s_generate_passowrd": "Generate password",
"l_select_file": "Select file",
"l_no_export_file": "No export file",
"s_no_export": "No export",
"s_export": "Export",
"l_export_configuration_file": "Export configuration to file",
"@_otp_slot_actions": {},
"s_delete_slot": "Delete credential",
"l_delete_slot_desc": "Remove credential in slot",
"p_warning_delete_slot_configuration": "Warning! This action will permanently remove the credential from slot {slot_id}.",
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": "Credential deleted",
"s_swap": "Swap",
"s_swap_slots": "Swap slots",
"l_swap_slots_desc": "Swap short/long touch",
"p_swap_slots_desc": "This will swap the configuration of the two slots.",
"l_slots_swapped": "Slot configurations swapped",
"l_slot_credential_configured": "Configured {type} credential",
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": "Configured {type} credential and exported to {file}",
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": "Append ⏎",
"l_append_enter_desc": "Append an Enter keystroke after emitting the OTP",
"@_otp_errors": {},
"p_otp_slot_configuration_error": "Failed to modify {slot}! Make sure the YubiKey does not have restrictive access.",
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {},
"s_enable_nfc": "Enable NFC",
"s_permission_denied": "Permission denied",
@ -539,7 +620,6 @@
"l_oath_application_reset": "OATH application reset",
"s_reset_fido": "Reset FIDO",
"l_fido_app_reset": "FIDO application reset",
"l_press_reset_to_begin": "Press reset to begin\u2026",
"l_reset_failed": "Error performing reset: {message}",
"@l_reset_failed": {
"placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "Fermer",
"s_delete": "Supprimer",
"s_quit": "Quitter",
"s_status": null,
"s_unlock": "Déverrouiller",
"s_calculate": "Calculer",
"s_import": "Importer",
@ -61,8 +62,9 @@
"s_manage": "Gérer",
"s_setup": "Configuration",
"s_settings": "Paramètres",
"s_piv": "PIV",
"s_certificates": "Certificats",
"s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "Aide et à propos",
"s_help_and_feedback": "Aide et retours",
"s_send_feedback": "Envoyer nous un retour",
@ -78,6 +80,14 @@
"s_hide_secret_key": null,
"s_private_key": "Clé privée",
"s_invalid_length": "Longueur invalide",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "Touché requis",
"q_have_account_info": "Avez-vous des informations de compte?",
"s_run_diagnostics": "Exécuter un diagnostique",
@ -189,6 +199,8 @@
"s_change_puk": "Changez PUK",
"s_show_pin": null,
"s_hide_pin": null,
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "PIN actuel",
"s_current_puk": "PUK actuel",
"s_new_pin": "Nouveau PIN",
@ -286,6 +298,8 @@
"s_management_key": "Gestion des clés",
"s_current_management_key": "Clé actuelle de gestion",
"s_new_management_key": "Nouvelle clé de gestion",
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": "Changer la clé de gestion",
"p_change_management_key_desc": "Changer votre clé de gestion. Vous pouvez optionnellement autoriser le PIN à être utilisé à la place de la clé de gestion.",
"l_management_key_changed": "Ché de gestion changée",
@ -429,7 +443,6 @@
"@_certificates": {},
"s_certificate": "Certificat",
"s_certificates": "Certificats",
"s_csr": "CSR",
"s_subject": "Sujet",
"l_export_csr_file": "Sauvegarder le CSR vers un fichier",
@ -504,6 +517,74 @@
"s_slot_9d": "Gestion des clés",
"s_slot_9e": "Authentification par carte",
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {},
"s_enable_nfc": "Activer le NFC",
"s_permission_denied": "Permission refusée",
@ -539,7 +620,6 @@
"l_oath_application_reset": "L'application OATH à été réinitialisée",
"s_reset_fido": "Réinitialiser le FIDO",
"l_fido_app_reset": "L'application FIDO à été réinitialisée",
"l_press_reset_to_begin": "Appuyez sur réinitialiser pour commencer\u2026",
"l_reset_failed": "Erreur pendant la réinitialisation: {message}",
"@l_reset_failed": {
"placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "閉じる",
"s_delete": "消去",
"s_quit": "終了",
"s_status": null,
"s_unlock": "ロック解除",
"s_calculate": "計算",
"s_import": "インポート",
@ -61,8 +62,9 @@
"s_manage": "管理",
"s_setup": "セットアップ",
"s_settings": "設定",
"s_piv": "PIV",
"s_certificates": "証明書",
"s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "ヘルプと概要",
"s_help_and_feedback": "ヘルプとフィードバック",
"s_send_feedback": "フィードバックの送信",
@ -78,6 +80,14 @@
"s_hide_secret_key": null,
"s_private_key": "秘密鍵",
"s_invalid_length": "無効な長さです",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "タッチが必要",
"q_have_account_info": "アカウント情報をお持ちですか?",
"s_run_diagnostics": "診断を実行する",
@ -189,6 +199,8 @@
"s_change_puk": "PUKを変更する",
"s_show_pin": null,
"s_hide_pin": null,
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "現在のPIN",
"s_current_puk": "現在のPUK",
"s_new_pin": "新しいPIN",
@ -286,6 +298,8 @@
"s_management_key": "Management key",
"s_current_management_key": "現在のManagement key",
"s_new_management_key": "新しいManagement key",
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": "Management keyの変更",
"p_change_management_key_desc": "Management keyを変更してください。Management keyの代わりにPINを使用することも可能です",
"l_management_key_changed": "Management keyは変更されました",
@ -429,7 +443,6 @@
"@_certificates": {},
"s_certificate": "証明書",
"s_certificates": "証明書",
"s_csr": "CSR",
"s_subject": "サブジェクト",
"l_export_csr_file": "CSRをファイルに保存",
@ -504,6 +517,74 @@
"s_slot_9d": "鍵の管理",
"s_slot_9e": "カード認証",
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {},
"s_enable_nfc": "NFCを有効にする",
"s_permission_denied": "権限がありません",
@ -539,7 +620,6 @@
"l_oath_application_reset": "OATHアプリケーションのリセット",
"s_reset_fido": "FIDOのリセット",
"l_fido_app_reset": "FIDOアプリケーションのリセット",
"l_press_reset_to_begin": "リセットを押して開始してください\u2026",
"l_reset_failed": "リセット実行中のエラー:{message}",
"@l_reset_failed": {
"placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "Zamknij",
"s_delete": "Usuń",
"s_quit": "Wyjdź",
"s_status": null,
"s_unlock": "Odblokuj",
"s_calculate": "Oblicz",
"s_import": "Importuj",
@ -61,8 +62,9 @@
"s_manage": "Zarządzaj",
"s_setup": "Konfiguruj",
"s_settings": "Ustawienia",
"s_piv": "PIV",
"s_certificates": "Certyfikaty",
"s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "Pomoc i informacje",
"s_help_and_feedback": "Pomoc i opinie",
"s_send_feedback": "Prześlij opinię",
@ -78,6 +80,14 @@
"s_hide_secret_key": "Ukryj tajny klucz",
"s_private_key": "Klucz prywatny",
"s_invalid_length": "Nieprawidłowa długość",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "Wymagaj dotknięcia",
"q_have_account_info": "Masz dane konta?",
"s_run_diagnostics": "Uruchom diagnostykę",
@ -189,6 +199,8 @@
"s_change_puk": "Zmień PUK",
"s_show_pin": "Pokaż PIN",
"s_hide_pin": "Ukryj PIN",
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "Aktualny PIN",
"s_current_puk": "Aktualny PUK",
"s_new_pin": "Nowy PIN",
@ -286,6 +298,8 @@
"s_management_key": "Klucz zarządzania",
"s_current_management_key": "Aktualny klucz zarządzania",
"s_new_management_key": "Nowy klucz zarządzania",
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": "Zmień klucz zarządzania",
"p_change_management_key_desc": "Zmień swój klucz zarządzania. Opcjonalnie możesz zezwolić na używanie kodu PIN zamiast klucza zarządzania.",
"l_management_key_changed": "Zmieniono klucz zarządzania",
@ -429,7 +443,6 @@
"@_certificates": {},
"s_certificate": "Certyfikat",
"s_certificates": "Certyfikaty",
"s_csr": "CSR",
"s_subject": "Temat",
"l_export_csr_file": "Zapisz CSR do pliku",
@ -504,6 +517,74 @@
"s_slot_9d": "Menedżer kluczy",
"s_slot_9e": "Autoryzacja karty",
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {},
"s_enable_nfc": "Włącz NFC",
"s_permission_denied": "Odmowa dostępu",
@ -539,7 +620,6 @@
"l_oath_application_reset": "Reset funkcji OATH",
"s_reset_fido": "Zresetuj FIDO",
"l_fido_app_reset": "Reset funkcji FIDO",
"l_press_reset_to_begin": "Naciśnij reset, aby rozpocząć\u2026",
"l_reset_failed": "Błąd podczas resetowania: {message}",
"@l_reset_failed": {
"placeholders": {

View File

@ -19,7 +19,6 @@ import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
@ -30,11 +29,13 @@ import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/user_interaction.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../desktop/models.dart';
import '../../exception/apdu_exception.dart';
import '../../exception/cancellation_exception.dart';
import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/file_drop_target.dart';
@ -49,9 +50,6 @@ import 'utils.dart';
final _log = Logger('oath.view.add_account_page');
final _secretFormatterPattern =
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
class OathAddAccountPage extends ConsumerStatefulWidget {
final DevicePath? devicePath;
final OathState? state;
@ -83,7 +81,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
int _digits = defaultDigits;
int _counter = defaultCounter;
bool _validateSecretLength = false;
bool _validateSecret = false;
bool _dataLoaded = false;
bool _isObscure = true;
List<int> _periodValues = [20, 30, 45, 60];
@ -235,6 +233,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final secret = _secretController.text.replaceAll(' ', '');
final secretLengthValid = secret.length * 5 % 8 < 5;
final secretFormatValid = Format.base32.isValid(secret);
// is this credentials name/issuer pair different from all other?
final isUnique = _credentials
@ -271,7 +270,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}
void submit() async {
if (secretLengthValid) {
if (secretLengthValid && secretFormatValid) {
final cred = CredentialData(
issuer: issuerText.isEmpty ? null : issuerText,
name: nameText,
@ -304,7 +303,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}
} else {
setState(() {
_validateSecretLength = true;
_validateSecret = true;
});
}
}
@ -365,17 +364,17 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
limitBytesLength(issuerRemaining),
],
buildCounter: buildByteCounterFor(issuerText),
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_issuer_optional,
helperText: '',
// Prevents dialog resizing when disabled
prefixIcon: const Icon(Icons.business_outlined),
helperText:
'', // Prevents dialog resizing when disabled
errorText: (byteLength(issuerText) > issuerMaxLength)
? '' // needs empty string to render as error
: issuerNoColon
? null
: l10n.l_invalid_character_issuer,
prefixIcon: const Icon(Icons.business_outlined),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
@ -393,9 +392,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
maxLength: nameMaxLength,
buildCounter: buildByteCounterFor(nameText),
inputFormatters: [limitBytesLength(nameRemaining)],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
labelText: l10n.s_account_name,
helperText: '',
// Prevents dialog resizing when disabled
@ -404,6 +402,11 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
: isUnique
? null
: l10n.l_name_already_exists,
prefixIcon: const Icon(Icons.person_outline),
suffixIcon:
(!isUnique || byteLength(nameText) > nameMaxLength)
? const Icon(Icons.error)
: null,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
@ -423,18 +426,24 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
// would hint to use saved passwords for this field
autofillHints:
isAndroid ? [] : const [AutofillHints.password],
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
_secretFormatterPattern)
],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_secret_key,
errorText: _validateSecret && !secretLengthValid
? l10n.s_invalid_length
: _validateSecret && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.base32.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure
? Icons.visibility
: Icons.visibility_off,
color: IconTheme.of(context).color,
),
color: !_validateSecret
? IconTheme.of(context).color
: null),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
@ -443,18 +452,12 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
tooltip: _isObscure
? l10n.s_show_secret_key
: l10n.s_hide_secret_key,
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined),
labelText: l10n.s_secret_key,
errorText: _validateSecretLength && !secretLengthValid
? l10n.s_invalid_length
: null),
)),
readOnly: _dataLoaded,
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
_validateSecretLength = false;
_validateSecret = false;
});
},
onSubmitted: (_) {

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart';
@ -42,6 +43,9 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
String _newPassword = '';
String _confirmPassword = '';
bool _currentIsWrong = false;
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
_submit() async {
FocusUtils.unfocus(context);
@ -85,15 +89,28 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
Text(l10n.p_enter_current_password_or_reset),
AppTextField(
autofocus: true,
obscureText: true,
obscureText: _isObscureCurrent,
autofillHints: const [AutofillHints.password],
key: keys.currentPasswordField,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_current_password,
prefixIcon: const Icon(Icons.password_outlined),
errorText: _currentIsWrong ? l10n.s_wrong_password : null,
errorMaxLines: 3),
errorMaxLines: 3,
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureCurrent
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureCurrent = !_isObscureCurrent;
});
},
tooltip: _isObscureCurrent
? l10n.s_show_password
: l10n.s_hide_password),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
@ -145,12 +162,24 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
AppTextField(
key: keys.newPasswordField,
autofocus: !widget.state.hasKey,
obscureText: true,
obscureText: _isObscureNew,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_new_password,
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureNew
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureNew = !_isObscureNew;
});
},
tooltip: _isObscureNew
? l10n.s_show_password
: l10n.s_hide_password),
enabled: !widget.state.hasKey || _currentPassword.isNotEmpty,
),
textInputAction: TextInputAction.next,
@ -167,12 +196,24 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
),
AppTextField(
key: keys.confirmPasswordField,
obscureText: true,
obscureText: _isObscureConfirm,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_confirm_password,
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureConfirm
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureConfirm = !_isObscureConfirm;
});
},
tooltip: _isObscureConfirm
? l10n.s_show_password
: l10n.s_hide_password),
enabled:
(!widget.state.hasKey || _currentPassword.isNotEmpty) &&
_newPassword.isNotEmpty,

View File

@ -26,6 +26,7 @@ import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../features.dart' as features;
import '../keys.dart' as keys;
@ -161,7 +162,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
decoration: InputDecoration(
decoration: AppInputDecoration(
hintText: l10n.s_search_accounts,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(32)),

View File

@ -25,6 +25,7 @@ import '../../app/models.dart';
import '../../app/state.dart';
import '../../desktop/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart';
@ -179,7 +180,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
buildCounter: buildByteCounterFor(_issuer),
inputFormatters: [limitBytesLength(issuerRemaining)],
key: keys.issuerField,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_issuer_optional,
helperText: '', // Prevents dialog resizing when disabled
@ -198,7 +199,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
inputFormatters: [limitBytesLength(nameRemaining)],
buildCounter: buildByteCounterFor(_name),
key: keys.nameField,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_account_name,
helperText: '', // Prevents dialog resizing when disabled

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../keys.dart' as keys;
import '../models.dart';
@ -79,7 +80,7 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
autofocus: true,
obscureText: _isObscure,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_password,
errorText: _passwordIsWrong ? l10n.s_wrong_password : null,
@ -88,8 +89,9 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color,
),
color: !_passwordIsWrong
? IconTheme.of(context).color
: null),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
@ -105,37 +107,48 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
}), // Update state on change
onSubmitted: (_) => _submit(),
),
],
),
),
const SizedBox(height: 8.0),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
keystoreFailed
? ListTile(
leading: const Icon(Icons.warning_amber_rounded),
title: Text(l10n.l_keystore_unavailable),
dense: true,
minLeadingWidth: 0,
? Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
const Icon(Icons.warning_amber_rounded),
Text(l10n.l_keystore_unavailable)
],
)
: CheckboxListTile(
title: Text(l10n.s_remember_password),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _remember,
onChanged: (value) {
: FilterChip(
label: Text(l10n.s_remember_password),
selected: _remember,
onSelected: (value) {
setState(() {
_remember = value ?? false;
_remember = value;
});
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
ElevatedButton.icon(
key: keys.unlockButton,
label: Text(l10n.s_unlock),
icon: const Icon(Icons.lock_open),
onPressed: _passwordController.text.isNotEmpty ? _submit : null,
onPressed: _passwordController.text.isNotEmpty
? _submit
: null,
),
],
),
],
),
],
),
),
],

30
lib/otp/features.dart Normal file
View File

@ -0,0 +1,30 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../app/features.dart';
final actions = otp.feature('actions');
final actionsSwap = actions.feature('swap');
final slots = otp.feature('slots');
final slotsConfigureChalResp = slots.feature('configureChalResp');
final slotsConfigureHotp = slots.feature('configureHotp');
final slotsConfigureStatic = slots.feature('configureSlots');
final slotsConfigureYubiOtp = slots.feature('configureYubiOtp');
final slotsDelete = slots.feature('delete');

38
lib/otp/keys.dart Normal file
View File

@ -0,0 +1,38 @@
/*
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
const _prefix = 'otp.keys';
const _keyAction = '$_prefix.actions';
const _slotAction = '$_prefix.slot.actions';
// Key actions
const swapSlots = Key('$_keyAction.swap_slots');
// Slot actions
const configureYubiOtp = Key('$_slotAction.configure_yubiotp');
const configureHotp = Key('$_slotAction.configure_hotp');
const configureStatic = Key('$_slotAction.configure_static');
const configureChalResp = Key('$_slotAction.configure_chal_resp');
const deleteAction = Key('$_slotAction.delete');
const saveButton = Key('$_prefix.save');
const deleteButton = Key('$_prefix.delete');
const secretField = Key('$_prefix.secret');
const publicIdField = Key('$_prefix.public_id');
const privateIdField = Key('$_prefix.private_id');

113
lib/otp/models.dart Normal file
View File

@ -0,0 +1,113 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'models.freezed.dart';
part 'models.g.dart';
enum SlotId {
one('one', 1),
two('two', 2);
final String id;
final int numberId;
const SlotId(this.id, this.numberId);
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
SlotId.one => l10n.s_otp_slot_one,
SlotId.two => l10n.s_otp_slot_two
};
}
factory SlotId.fromJson(String value) =>
SlotId.values.firstWhere((e) => e.id == value);
}
@freezed
class OtpState with _$OtpState {
const OtpState._();
factory OtpState({
required bool slot1Configured,
required bool slot2Configured,
}) = _OtpState;
factory OtpState.fromJson(Map<String, dynamic> json) =>
_$OtpStateFromJson(json);
List<OtpSlot> get slots => [
OtpSlot(slot: SlotId.one, isConfigured: slot1Configured),
OtpSlot(slot: SlotId.two, isConfigured: slot2Configured),
];
}
@freezed
class OtpSlot with _$OtpSlot {
factory OtpSlot({required SlotId slot, required bool isConfigured}) =
_OtpSlot;
}
@freezed
class SlotConfigurationOptions with _$SlotConfigurationOptions {
// ignore: invalid_annotation_target
@JsonSerializable(includeIfNull: false)
factory SlotConfigurationOptions(
{bool? digits8,
bool? requireTouch,
bool? appendCr}) = _SlotConfigurationOptions;
factory SlotConfigurationOptions.fromJson(Map<String, dynamic> json) =>
_$SlotConfigurationOptionsFromJson(json);
}
@Freezed(unionKey: 'type', unionValueCase: FreezedUnionCase.snake)
class SlotConfiguration with _$SlotConfiguration {
const SlotConfiguration._();
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.hotp(
{required String key,
SlotConfigurationOptions? options}) = _SlotConfigurationHotp;
@FreezedUnionValue('hmac_sha1')
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.chalresp(
{required String key,
SlotConfigurationOptions? options}) = _SlotConfigurationHmacSha1;
@FreezedUnionValue('static_password')
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.static(
{required String password,
required String keyboardLayout,
SlotConfigurationOptions? options}) = _SlotConfigurationStaticPassword;
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.yubiotp(
{required String publicId,
required String privateId,
required String key,
SlotConfigurationOptions? options}) = _SlotConfigurationYubiOtp;
factory SlotConfiguration.fromJson(Map<String, dynamic> json) =>
_$SlotConfigurationFromJson(json);
}

1491
lib/otp/models.freezed.dart Normal file

File diff suppressed because it is too large Load Diff

161
lib/otp/models.g.dart Normal file
View File

@ -0,0 +1,161 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$OtpStateImpl _$$OtpStateImplFromJson(Map<String, dynamic> json) =>
_$OtpStateImpl(
slot1Configured: json['slot1_configured'] as bool,
slot2Configured: json['slot2_configured'] as bool,
);
Map<String, dynamic> _$$OtpStateImplToJson(_$OtpStateImpl instance) =>
<String, dynamic>{
'slot1_configured': instance.slot1Configured,
'slot2_configured': instance.slot2Configured,
};
_$SlotConfigurationOptionsImpl _$$SlotConfigurationOptionsImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationOptionsImpl(
digits8: json['digits8'] as bool?,
requireTouch: json['require_touch'] as bool?,
appendCr: json['append_cr'] as bool?,
);
Map<String, dynamic> _$$SlotConfigurationOptionsImplToJson(
_$SlotConfigurationOptionsImpl instance) {
final val = <String, dynamic>{};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('digits8', instance.digits8);
writeNotNull('require_touch', instance.requireTouch);
writeNotNull('append_cr', instance.appendCr);
return val;
}
_$SlotConfigurationHotpImpl _$$SlotConfigurationHotpImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationHotpImpl(
key: json['key'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationHotpImplToJson(
_$SlotConfigurationHotpImpl instance) {
final val = <String, dynamic>{
'key': instance.key,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}
_$SlotConfigurationHmacSha1Impl _$$SlotConfigurationHmacSha1ImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationHmacSha1Impl(
key: json['key'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationHmacSha1ImplToJson(
_$SlotConfigurationHmacSha1Impl instance) {
final val = <String, dynamic>{
'key': instance.key,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}
_$SlotConfigurationStaticPasswordImpl
_$$SlotConfigurationStaticPasswordImplFromJson(Map<String, dynamic> json) =>
_$SlotConfigurationStaticPasswordImpl(
password: json['password'] as String,
keyboardLayout: json['keyboard_layout'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationStaticPasswordImplToJson(
_$SlotConfigurationStaticPasswordImpl instance) {
final val = <String, dynamic>{
'password': instance.password,
'keyboard_layout': instance.keyboardLayout,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}
_$SlotConfigurationYubiOtpImpl _$$SlotConfigurationYubiOtpImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationYubiOtpImpl(
publicId: json['public_id'] as String,
privateId: json['private_id'] as String,
key: json['key'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationYubiOtpImplToJson(
_$SlotConfigurationYubiOtpImpl instance) {
final val = <String, dynamic>{
'public_id': instance.publicId,
'private_id': instance.privateId,
'key': instance.key,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}

52
lib/otp/state.dart Normal file
View File

@ -0,0 +1,52 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart';
import '../core/state.dart';
import 'models.dart';
final yubiOtpOutputProvider =
StateNotifierProvider<YubiOtpOutputNotifier, File?>(
(ref) => YubiOtpOutputNotifier());
class YubiOtpOutputNotifier extends StateNotifier<File?> {
YubiOtpOutputNotifier() : super(null);
void setOutput(File? file) {
state = file;
}
}
final otpStateProvider = AsyncNotifierProvider.autoDispose
.family<OtpStateNotifier, OtpState, DevicePath>(
() => throw UnimplementedError(),
);
abstract class OtpStateNotifier extends ApplicationStateNotifier<OtpState> {
Future<String> generateStaticPassword(int length, String layout);
Future<String> modhexEncodeSerial(int serial);
Future<Map<String, List<String>>> getKeyboardLayouts();
Future<String> formatYubiOtpCsv(
int serial, String publicId, String privateId, String key);
Future<void> swapSlots();
Future<void> configureSlot(SlotId slot,
{required SlotConfiguration configuration});
Future<void> deleteSlot(SlotId slot);
}

175
lib/otp/views/actions.dart Normal file
View File

@ -0,0 +1,175 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../features.dart' as features;
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'configure_chalresp_dialog.dart';
import 'configure_hotp_dialog.dart';
import 'configure_static_dialog.dart';
import 'configure_yubiotp_dialog.dart';
import 'delete_slot_dialog.dart';
class ConfigureChalRespIntent extends Intent {
const ConfigureChalRespIntent();
}
class ConfigureHotpIntent extends Intent {
const ConfigureHotpIntent();
}
class ConfigureStaticIntent extends Intent {
const ConfigureStaticIntent();
}
class ConfigureYubiOtpIntent extends Intent {
const ConfigureYubiOtpIntent();
}
Widget registerOtpActions(
DevicePath devicePath,
OtpSlot otpSlot, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) {
final hasFeature = ref.watch(featureProvider);
return Actions(
actions: {
if (hasFeature(features.slotsConfigureChalResp))
ConfigureChalRespIntent:
CallbackAction<ConfigureChalRespIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureChalrespDialog(devicePath, otpSlot));
});
return null;
}),
if (hasFeature(features.slotsConfigureHotp))
ConfigureHotpIntent:
CallbackAction<ConfigureHotpIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => ConfigureHotpDialog(devicePath, otpSlot));
});
return null;
}),
if (hasFeature(features.slotsConfigureStatic))
ConfigureStaticIntent:
CallbackAction<ConfigureStaticIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final keyboardLayouts = await ref
.read(otpStateProvider(devicePath).notifier)
.getKeyboardLayouts();
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => ConfigureStaticDialog(
devicePath, otpSlot, keyboardLayouts));
});
return null;
}),
if (hasFeature(features.slotsConfigureYubiOtp))
ConfigureYubiOtpIntent:
CallbackAction<ConfigureYubiOtpIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureYubiOtpDialog(devicePath, otpSlot));
});
return null;
}),
if (hasFeature(features.slotsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final bool? deleted = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) =>
DeleteSlotDialog(devicePath, otpSlot)) ??
false);
return deleted;
}),
...actions,
},
child: Builder(builder: builder),
);
}
List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
return [
ActionItem(
key: keys.configureYubiOtp,
feature: features.slotsConfigureYubiOtp,
icon: const Icon(Icons.shuffle_outlined),
title: l10n.s_yubiotp,
subtitle: l10n.l_yubiotp_desc,
intent: const ConfigureYubiOtpIntent(),
),
ActionItem(
key: keys.configureChalResp,
feature: features.slotsConfigureChalResp,
icon: const Icon(Icons.key_outlined),
title: l10n.s_challenge_response,
subtitle: l10n.l_challenge_response_desc,
intent: const ConfigureChalRespIntent()),
ActionItem(
key: keys.configureStatic,
feature: features.slotsConfigureStatic,
icon: const Icon(Icons.password_outlined),
title: l10n.s_static_password,
subtitle: l10n.l_static_password_desc,
intent: const ConfigureStaticIntent()),
ActionItem(
key: keys.configureHotp,
feature: features.slotsConfigureHotp,
icon: const Icon(Icons.tag_outlined),
title: l10n.s_hotp,
subtitle: l10n.l_hotp_desc,
intent: const ConfigureHotpIntent()),
ActionItem(
key: keys.deleteAction,
feature: features.slotsDelete,
actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline),
title: l10n.s_delete_slot,
subtitle: l10n.l_delete_slot_desc,
intent: isConfigured ? const DeleteIntent() : null,
)
];
}

View File

@ -0,0 +1,187 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_chalresp_dialog');
class ConfigureChalrespDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const ConfigureChalrespDialog(this.devicePath, this.otpSlot, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureChalrespDialogState();
}
class _ConfigureChalrespDialogState
extends ConsumerState<ConfigureChalrespDialog> {
final _secretController = TextEditingController();
bool _validateSecret = false;
bool _requireTouch = false;
final int secretMaxLength = 40;
@override
void dispose() {
_secretController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final secret = _secretController.text;
final secretLengthValid = secret.isNotEmpty &&
secret.length % 2 == 0 &&
secret.length <= secretMaxLength;
final secretFormatValid = Format.hex.isValid(secret);
return ResponsiveDialog(
title: Text(l10n.s_challenge_response),
actions: [
TextButton(
key: keys.saveButton,
onPressed: !_validateSecret
? () async {
if (!secretLengthValid || !secretFormatValid) {
setState(() {
_validateSecret = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
try {
await otpNotifier.configureSlot(widget.otpSlot.slot,
configuration: SlotConfiguration.chalresp(
key: secret,
options: SlotConfigurationOptions(
requireTouch: _requireTouch)));
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
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),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.secretField,
autofocus: true,
controller: _secretController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: secretMaxLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_secret_key,
errorText: _validateSecret && !secretLengthValid
? l10n.s_invalid_length
: _validateSecret && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
setState(() {
final random = Random.secure();
final key = List.generate(
20,
(_) => random
.nextInt(256)
.toRadixString(16)
.padLeft(2, '0')).join();
setState(() {
_secretController.text = key;
});
});
},
tooltip: l10n.s_generate_random,
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validateSecret = false;
});
},
),
FilterChip(
label: Text(l10n.s_require_touch),
selected: _requireTouch,
onSelected: (value) {
setState(() {
_requireTouch = value;
});
},
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,200 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../oath/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_hotp_dialog');
class ConfigureHotpDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const ConfigureHotpDialog(this.devicePath, this.otpSlot, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureHotpDialogState();
}
class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
final _secretController = TextEditingController();
bool _validateSecret = false;
int _digits = defaultDigits;
final List<int> _digitsValues = [6, 8];
bool _appendEnter = true;
bool _isObscure = true;
@override
void dispose() {
_secretController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final secret = _secretController.text.replaceAll(' ', '');
final secretLengthValid = secret.isNotEmpty && secret.length * 5 % 8 < 5;
final secretFormatValid = Format.base32.isValid(secret);
return ResponsiveDialog(
title: Text(l10n.s_hotp),
actions: [
TextButton(
key: keys.saveButton,
onPressed: !_validateSecret
? () async {
if (!secretLengthValid || !secretFormatValid) {
setState(() {
_validateSecret = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
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));
});
} 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),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.secretField,
controller: _secretController,
obscureText: _isObscure,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_secret_key,
helperText: '', // Prevents resizing when errorText shown
errorText: _validateSecret && !secretLengthValid
? l10n.s_invalid_length
: _validateSecret && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.base32.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: !_validateSecret
? IconTheme.of(context).color
: null),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
tooltip: _isObscure
? l10n.s_show_secret_key
: l10n.s_hide_secret_key,
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validateSecret = false;
});
},
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
FilterChip(
label: Text(l10n.s_append_enter),
tooltip: l10n.l_append_enter_desc,
selected: _appendEnter,
onSelected: (value) {
setState(() {
_appendEnter = value;
});
},
),
ChoiceFilterChip<int>(
items: _digitsValues,
value: _digits,
selected: _digits != defaultDigits,
itemBuilder: (value) => Text(l10n.s_num_digits(value)),
onChanged: (digits) {
setState(() {
_digits = digits;
});
}),
],
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,222 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_static_dialog');
class ConfigureStaticDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
final Map<String, List<String>> keyboardLayouts;
const ConfigureStaticDialog(
this.devicePath, this.otpSlot, this.keyboardLayouts,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureStaticDialogState();
}
class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
final _passwordController = TextEditingController();
final passwordMaxLength = 38;
bool _validatePassword = false;
bool _appendEnter = true;
String _keyboardLayout = '';
String _defaultKeyboardLayout = '';
@override
void initState() {
super.initState();
final modhexLayout = widget.keyboardLayouts.keys.toList()[0];
_keyboardLayout = modhexLayout;
_defaultKeyboardLayout = modhexLayout;
}
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
RegExp generateFormatterPattern(String layout) {
final allowedCharacters = widget.keyboardLayouts[layout] ?? [];
final pattern =
allowedCharacters.map((char) => RegExp.escape(char)).join('');
return RegExp('^[$pattern]+\$', caseSensitive: false);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final password = _passwordController.text.replaceAll(' ', '');
final passwordLengthValid =
password.isNotEmpty && password.length <= passwordMaxLength;
final passwordFormatValid =
generateFormatterPattern(_keyboardLayout).hasMatch(password);
return ResponsiveDialog(
title: Text(l10n.s_static_password),
actions: [
TextButton(
key: keys.saveButton,
onPressed: !_validatePassword
? () async {
if (!passwordLengthValid || !passwordFormatValid) {
setState(() {
_validatePassword = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
try {
await otpNotifier.configureSlot(widget.otpSlot.slot,
configuration: SlotConfiguration.static(
password: password,
keyboardLayout: _keyboardLayout,
options: SlotConfigurationOptions(
appendCr: _appendEnter)));
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
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),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.secretField,
autofocus: true,
controller: _passwordController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: passwordMaxLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_password,
errorText: _validatePassword && !passwordLengthValid
? l10n.s_invalid_length
: _validatePassword && !passwordFormatValid
? l10n.l_invalid_keyboard_character
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
tooltip: l10n.s_generate_passowrd,
icon: const Icon(Icons.refresh),
onPressed: () async {
final password = await ref
.read(otpStateProvider(widget.devicePath).notifier)
.generateStaticPassword(
passwordMaxLength, _keyboardLayout);
setState(() {
_validatePassword = false;
_passwordController.text = password;
});
},
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validatePassword = false;
});
},
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
FilterChip(
label: Text(l10n.s_append_enter),
tooltip: l10n.l_append_enter_desc,
selected: _appendEnter,
onSelected: (value) {
setState(() {
_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;
});
}),
],
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,375 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_yubiotp_dialog');
enum OutputActions {
selectFile,
noOutput;
const OutputActions();
String getDisplayName(AppLocalizations l10n) => switch (this) {
OutputActions.selectFile => l10n.l_select_file,
OutputActions.noOutput => l10n.l_no_export_file
};
}
class ConfigureYubiOtpDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const ConfigureYubiOtpDialog(this.devicePath, this.otpSlot, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureYubiOtpDialogState();
}
class _ConfigureYubiOtpDialogState
extends ConsumerState<ConfigureYubiOtpDialog> {
final _secretController = TextEditingController();
final _publicIdController = TextEditingController();
final _privateIdController = TextEditingController();
OutputActions _action = OutputActions.noOutput;
bool _appendEnter = true;
bool _validateSecretFormat = false;
bool _validatePublicIdFormat = false;
bool _validatePrivateIdFormat = false;
final secretLength = 32;
final publicIdLength = 12;
final privateIdLength = 12;
@override
void dispose() {
_secretController.dispose();
_publicIdController.dispose();
_privateIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final info = ref.watch(currentDeviceDataProvider).valueOrNull?.info;
final secret = _secretController.text;
final secretLengthValid = secret.length == secretLength;
final secretFormatValid = Format.hex.isValid(secret);
final privateId = _privateIdController.text;
final privateIdLengthValid = privateId.length == privateIdLength;
final privatedIdFormatValid = Format.hex.isValid(privateId);
final publicId = _publicIdController.text;
final publicIdLengthValid = publicId.length == publicIdLength;
final publicIdFormatValid = Format.modhex.isValid(publicId);
final lengthsValid =
secretLengthValid && privateIdLengthValid && publicIdLengthValid;
final outputFile = ref.read(yubiOtpOutputProvider);
Future<bool> selectFile() async {
final filePath = await FilePicker.platform.saveFile(
dialogTitle: l10n.l_export_configuration_file,
allowedExtensions: ['csv'],
type: FileType.custom,
lockParentWindow: true);
if (filePath == null) {
return false;
}
ref.read(yubiOtpOutputProvider.notifier).setOutput(File(filePath));
return true;
}
return ResponsiveDialog(
title: Text(l10n.s_yubiotp),
actions: [
TextButton(
key: keys.saveButton,
onPressed: lengthsValid
? () async {
if (!secretFormatValid ||
!publicIdFormatValid ||
!privatedIdFormatValid) {
setState(() {
_validateSecretFormat = !secretFormatValid;
_validatePublicIdFormat = !publicIdFormatValid;
_validatePrivateIdFormat = !privatedIdFormatValid;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
try {
await otpNotifier.configureSlot(widget.otpSlot.slot,
configuration: SlotConfiguration.yubiotp(
publicId: publicId,
privateId: privateId,
key: secret,
options: SlotConfigurationOptions(
appendCr: _appendEnter)));
if (outputFile != null) {
final csv = await otpNotifier.formatYubiOtpCsv(
info!.serial!, publicId, privateId, secret);
await outputFile.writeAsString(
'$csv${Platform.lineTerminator}',
mode: FileMode.append);
}
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(
context,
outputFile != null
? l10n.l_slot_credential_configured_and_exported(
l10n.s_yubiotp,
outputFile.uri.pathSegments.last)
: l10n.l_slot_credential_configured(
l10n.s_yubiotp));
});
} 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),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.publicIdField,
autofocus: true,
controller: _publicIdController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: publicIdLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_public_id,
errorText: _validatePublicIdFormat && !publicIdFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.modhex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.public_outlined),
suffixIcon: IconButton(
tooltip: l10n.s_use_serial,
icon: const Icon(Icons.auto_awesome_outlined),
onPressed: (info?.serial != null)
? () async {
final publicId = await ref
.read(otpStateProvider(widget.devicePath)
.notifier)
.modhexEncodeSerial(info!.serial!);
setState(() {
_publicIdController.text = publicId;
});
}
: null,
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validatePublicIdFormat = false;
});
},
),
AppTextField(
key: keys.privateIdField,
controller: _privateIdController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: privateIdLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_private_id,
errorText: _validatePrivateIdFormat && !privatedIdFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
tooltip: l10n.s_generate_random,
icon: const Icon(Icons.refresh),
onPressed: () {
final random = Random.secure();
final key = List.generate(
6,
(_) => random
.nextInt(256)
.toRadixString(16)
.padLeft(2, '0')).join();
setState(() {
_privateIdController.text = key;
});
},
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validatePrivateIdFormat = false;
});
},
),
AppTextField(
key: keys.secretField,
controller: _secretController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: secretLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_secret_key,
errorText: _validateSecretFormat && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
tooltip: l10n.s_generate_random,
icon: const Icon(Icons.refresh),
onPressed: () {
final random = Random.secure();
final key = List.generate(
16,
(_) => random
.nextInt(256)
.toRadixString(16)
.padLeft(2, '0')).join();
setState(() {
_secretController.text = key;
});
},
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validateSecretFormat = false;
});
},
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,
runSpacing: 8.0,
children: [
FilterChip(
label: Text(l10n.s_append_enter),
tooltip: l10n.l_append_enter_desc,
selected: _appendEnter,
onSelected: (value) {
setState(() {
_appendEnter = value;
});
},
),
ChoiceFilterChip<OutputActions>(
tooltip: outputFile?.path ?? l10n.s_no_export,
selected: outputFile != null,
avatar: outputFile != null
? Icon(Icons.check,
color: Theme.of(context).colorScheme.secondary)
: null,
value: _action,
items: OutputActions.values,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
labelBuilder: (_) {
String? fileName = outputFile?.uri.pathSegments.last;
return Container(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
fileName != null
? '${l10n.s_export} $fileName'
: _action.getDisplayName(l10n),
overflow: TextOverflow.ellipsis,
),
);
},
onChanged: (value) async {
if (value == OutputActions.noOutput) {
ref.read(yubiOtpOutputProvider.notifier).setOutput(null);
setState(() {
_action = value;
});
} else if (value == OutputActions.selectFile) {
if (await selectFile()) {
setState(() {
_action = value;
});
}
}
},
),
],
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,83 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
class DeleteSlotDialog extends ConsumerWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const DeleteSlotDialog(this.devicePath, this.otpSlot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_delete_slot),
actions: [
TextButton(
key: keys.deleteButton,
onPressed: () async {
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);
});
} catch (e) {
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),
);
});
}
},
child: Text(l10n.s_delete),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n
.p_warning_delete_slot_configuration(otpSlot.slot.numberId)),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/action_list.dart';
import '../../app/views/fs_dialog.dart';
import '../features.dart' as features;
import '../keys.dart' as keys;
import '../models.dart';
import 'swap_slots_dialog.dart';
Widget otpBuildActions(BuildContext context, DevicePath devicePath,
OtpState otpState, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return FsDialog(
child: Column(
children: [
ActionListSection(l10n.s_manage, children: [
ActionListItem(
key: keys.swapSlots,
feature: features.actionsSwap,
title: l10n.s_swap_slots,
subtitle: l10n.l_swap_slots_desc,
icon: const Icon(Icons.swap_vert_outlined),
onTap: (otpState.slot1Configured || otpState.slot2Configured)
? (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => SwapSlotsDialog(devicePath));
}
: null,
)
])
],
),
);
}

View File

@ -0,0 +1,113 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_list_item.dart';
import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../../widgets/list_title.dart';
import '../features.dart' as features;
import '../models.dart';
import '../state.dart';
import 'actions.dart';
import 'key_actions.dart';
import 'slot_dialog.dart';
class OtpScreen extends ConsumerWidget {
final DevicePath devicePath;
const OtpScreen(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
return ref.watch(otpStateProvider(devicePath)).when(
loading: () => MessagePage(
title: Text(l10n.s_slots),
graphic: const CircularProgressIndicator(),
delayedContent: true,
),
error: (error, _) =>
AppFailurePage(title: Text(l10n.s_slots), cause: error),
data: (otpState) {
return AppPage(
title: Text(l10n.s_slots),
keyActionsBuilder: hasFeature(features.actions)
? (context) =>
otpBuildActions(context, devicePath, otpState, ref)
: null,
child: Column(children: [
ListTitle(l10n.s_slots),
...otpState.slots.map((e) => registerOtpActions(devicePath, e,
ref: ref,
actions: {
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) async {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => SlotDialog(e.slot),
);
return null;
}),
},
builder: (context) => _SlotListItem(e)))
]),
);
});
}
}
class _SlotListItem extends ConsumerWidget {
final OtpSlot otpSlot;
const _SlotListItem(this.otpSlot);
@override
Widget build(BuildContext context, WidgetRef ref) {
final slot = otpSlot.slot;
final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
final isConfigured = otpSlot.isConfigured;
final hasFeature = ref.watch(featureProvider);
return Semantics(
label: slot.getDisplayName(l10n),
child: AppListItem(
leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary,
child: Text(slot.numberId.toString())),
title: slot.getDisplayName(l10n),
subtitle:
isConfigured ? l10n.l_otp_slot_configured : l10n.l_otp_slot_empty,
trailing: OutlinedButton(
onPressed: Actions.handler(context, const OpenIntent()),
child: const Icon(Icons.more_horiz),
),
buildPopupActions: hasFeature(features.slots)
? (context) => buildSlotActions(isConfigured, l10n)
: null,
));
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
class _OverwriteConfirmDialog extends StatelessWidget {
final OtpSlot otpSlot;
const _OverwriteConfirmDialog({
required this.otpSlot,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_overwrite_slot),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(l10n.s_overwrite)),
],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_overwrite_slot_desc(otpSlot.slot.getDisplayName(l10n))),
const SizedBox(height: 12),
],
),
),
);
}
}
Future<bool> confirmOverwrite(BuildContext context, OtpSlot otpSlot) async {
if (otpSlot.isConfigured) {
return await showBlurDialog(
context: context,
builder: (context) => _OverwriteConfirmDialog(
otpSlot: otpSlot,
)) ??
false;
}
return true;
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/state.dart';
import '../../app/views/action_list.dart';
import '../../app/views/fs_dialog.dart';
import '../models.dart';
import '../state.dart';
import 'actions.dart';
class SlotDialog extends ConsumerWidget {
final SlotId slot;
const SlotDialog(this.slot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
if (node == null) {
// The rest of this method assumes there is a device, and will throw an exception if not.
// This will never be shown, as the dialog will be immediately closed
return const SizedBox();
}
final l10n = AppLocalizations.of(context)!;
final textTheme = Theme.of(context).textTheme;
final otpState = ref.watch(otpStateProvider(node.path)).valueOrNull;
final otpSlot =
otpState!.slots.firstWhereOrNull((element) => element.slot == slot);
if (otpSlot == null) {
return const FsDialog(child: CircularProgressIndicator());
}
return registerOtpActions(node.path, otpSlot,
ref: ref,
builder: (context) => FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 48, bottom: 16),
child: Column(
children: [
Text(
otpSlot.slot.getDisplayName(l10n),
style: textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Icon(
Icons.touch_app,
size: 100.0,
),
const SizedBox(height: 8),
Text(otpSlot.isConfigured
? l10n.l_otp_slot_configured
: l10n.l_otp_slot_empty)
],
),
),
ActionListSection.fromMenuActions(
context,
l10n.s_setup,
actions: buildSlotActions(otpSlot.isConfigured, l10n),
)
],
),
),
));
}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
class SwapSlotsDialog extends ConsumerWidget {
final DevicePath devicePath;
const SwapSlotsDialog(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_swap_slots),
actions: [
TextButton(
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);
});
},
child: Text(l10n.s_swap))
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_swap_slots_desc),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -15,12 +15,13 @@
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../core/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
@ -40,6 +41,7 @@ class AuthenticationDialog extends ConsumerStatefulWidget {
class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
bool _defaultKeyUsed = false;
bool _keyIsWrong = false;
bool _keyFormatInvalid = false;
final _keyController = TextEditingController();
@override
@ -56,6 +58,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
ManagementKeyType.tdes)
.keyLength *
2;
final keyFormatInvalid = !Format.hex.isValid(_keyController.text);
return ResponsiveDialog(
title: Text(l10n.l_unlock_piv_management),
actions: [
@ -63,6 +66,12 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
key: keys.unlockButton,
onPressed: _keyController.text.length == keyLen
? () async {
if (keyFormatInvalid) {
setState(() {
_keyFormatInvalid = true;
});
return;
}
final navigator = Navigator.of(context);
try {
final status = await ref
@ -99,19 +108,20 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
autofocus: true,
autofillHints: const [AutofillHints.password],
controller: _keyController,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? keyLen : null,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_management_key,
prefixIcon: const Icon(Icons.key_outlined),
errorText: _keyIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3,
helperText: _defaultKeyUsed ? l10n.l_default_key_used : null,
errorText: _keyIsWrong
? l10n.l_wrong_key
: _keyFormatInvalid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: hasMetadata
? null
: IconButton(
@ -121,6 +131,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
tooltip: l10n.s_use_default,
onPressed: () {
setState(() {
_keyFormatInvalid = false;
_defaultKeyUsed = !_defaultKeyUsed;
if (_defaultKeyUsed) {
_keyController.text = defaultManagementKey;
@ -135,6 +146,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
onChanged: (value) {
setState(() {
_keyIsWrong = false;
_keyFormatInvalid = false;
});
},
),

View File

@ -22,6 +22,7 @@ import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
@ -161,12 +162,13 @@ class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
AppTextField(
autofocus: true,
key: keys.subjectField,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_subject,
errorText: _subject.isNotEmpty && _invalidSubject
? l10n.l_rfc4514_invalid
: null),
: null,
),
textInputAction: TextInputAction.next,
enabled: !_generating,
onChanged: (value) {

View File

@ -23,6 +23,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
@ -51,6 +52,7 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
String _password = '';
bool _passwordIsWrong = false;
bool _importing = false;
bool _isObscure = true;
@override
void initState() {
@ -125,15 +127,27 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
Text(l10n.p_password_protected_file),
AppTextField(
autofocus: true,
obscureText: true,
obscureText: _isObscure,
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_password,
prefixIcon: const Icon(Icons.password_outlined),
errorText: _passwordIsWrong ? l10n.s_wrong_password : null,
errorMaxLines: 3),
errorMaxLines: 3,
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
tooltip: _isObscure
? l10n.s_show_password
: l10n.s_hide_password),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {

View File

@ -17,13 +17,14 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/choice_filter_chip.dart';
@ -49,10 +50,13 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
late bool _usesStoredKey;
late bool _storeKey;
bool _currentIsWrong = false;
bool _currentInvalidFormat = false;
bool _newInvalidFormat = false;
int _attemptsRemaining = -1;
ManagementKeyType _keyType = ManagementKeyType.tdes;
final _currentController = TextEditingController();
final _keyController = TextEditingController();
bool _isObscure = true;
@override
void initState() {
@ -76,6 +80,16 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
}
_submit() async {
final currentInvalidFormat = Format.hex.isValid(_currentController.text);
final newInvalidFormat = Format.hex.isValid(_keyController.text);
if (!currentInvalidFormat || !newInvalidFormat) {
setState(() {
_currentInvalidFormat = !currentInvalidFormat;
_newInvalidFormat = !newInvalidFormat;
});
return;
}
final notifier = ref.read(pivStateProvider(widget.path).notifier);
if (_usesStoredKey) {
final status = (await notifier.verifyPin(_currentController.text)).when(
@ -155,24 +169,37 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
if (protected)
AppTextField(
autofocus: true,
obscureText: true,
obscureText: _isObscure,
autofillHints: const [AutofillHints.password],
key: keys.pinPukField,
maxLength: 8,
controller: _currentController,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
prefixIcon: const Icon(Icons.pin_outlined),
errorText: _currentIsWrong
? l10n
.l_wrong_pin_attempts_remaining(_attemptsRemaining)
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: _currentInvalidFormat
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
errorMaxLines: 3),
errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
tooltip: _isObscure ? l10n.s_show_pin : l10n.s_hide_pin),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentInvalidFormat = false;
});
},
),
@ -184,13 +211,18 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
controller: _currentController,
readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_current_management_key,
prefixIcon: const Icon(Icons.key_outlined),
errorText: _currentIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3,
helperText: _defaultKeyUsed ? l10n.l_default_key_used : null,
errorText: _currentIsWrong
? l10n.l_wrong_key
: _currentInvalidFormat
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: _hasMetadata
? null
: IconButton(
@ -210,10 +242,6 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
},
),
),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
@ -227,15 +255,15 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
autofillHints: const [AutofillHints.newPassword],
maxLength: hexLength,
controller: _keyController,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_new_management_key,
prefixIcon: const Icon(Icons.key_outlined),
errorText: _newInvalidFormat
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
enabled: currentLenOk,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
key: keys.managementKeyRefresh,
icon: const Icon(Icons.refresh),
@ -251,6 +279,7 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
.padLeft(2, '0')).join();
setState(() {
_keyController.text = key;
_newInvalidFormat = false;
});
}
: null,

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
@ -44,6 +45,9 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
String _confirmPin = '';
bool _currentIsWrong = false;
int _attemptsRemaining = -1;
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
_submit() async {
final notifier = ref.read(pivStateProvider(widget.path).notifier);
@ -104,24 +108,38 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
: l10n.p_enter_current_puk_or_reset),
AppTextField(
autofocus: true,
obscureText: true,
obscureText: _isObscureCurrent,
maxLength: 8,
autofillHints: const [AutofillHints.password],
key: keys.pinPukField,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.pin
? l10n.s_current_pin
: l10n.s_current_puk,
prefixIcon: const Icon(Icons.password_outlined),
errorText: _currentIsWrong
? (widget.target == ManageTarget.pin
? l10n.l_wrong_pin_attempts_remaining(
_attemptsRemaining)
: l10n.l_wrong_puk_attempts_remaining(
_attemptsRemaining))
? l10n
.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: l10n
.l_wrong_puk_attempts_remaining(_attemptsRemaining))
: null,
errorMaxLines: 3),
errorMaxLines: 3,
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureCurrent
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureCurrent = !_isObscureCurrent;
});
},
tooltip: widget.target == ManageTarget.pin
? (_isObscureCurrent ? l10n.s_show_pin : l10n.s_hide_pin)
: (_isObscureCurrent ? l10n.s_show_puk : l10n.s_hide_puk),
),
),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
@ -134,15 +152,27 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)),
AppTextField(
key: keys.newPinPukField,
obscureText: true,
obscureText: _isObscureNew,
maxLength: 8,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.puk
? l10n.s_new_puk
: l10n.s_new_pin,
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscureNew ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureNew = !_isObscureNew;
});
},
tooltip: widget.target == ManageTarget.pin
? (_isObscureNew ? l10n.s_show_pin : l10n.s_hide_pin)
: (_isObscureNew ? l10n.s_show_puk : l10n.s_hide_puk),
),
// Old YubiKeys allowed a 4 digit PIN
enabled: _currentPin.length >= 4,
),
@ -160,15 +190,28 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
),
AppTextField(
key: keys.confirmPinPukField,
obscureText: true,
obscureText: _isObscureConfirm,
maxLength: 8,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.puk
? l10n.s_confirm_puk
: l10n.s_confirm_pin,
prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureConfirm
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureConfirm = !_isObscureConfirm;
});
},
tooltip: widget.target == ManageTarget.pin
? (_isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin)
: (_isObscureConfirm ? l10n.s_show_puk : l10n.s_hide_puk),
),
enabled: _currentPin.length >= 4 && _newPin.length >= 6,
),
textInputAction: TextInputAction.done,

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
@ -93,19 +94,18 @@ class _PinDialogState extends ConsumerState<PinDialog> {
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
controller: _pinController,
decoration: InputDecoration(
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
prefixIcon: const Icon(Icons.pin_outlined),
errorText: _pinIsWrong
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color,
),
color: !_pinIsWrong ? IconTheme.of(context).color : null),
onPressed: () {
setState(() {
_isObscure = !_isObscure;

View File

@ -46,18 +46,18 @@ class PivScreen extends ConsumerWidget {
final hasFeature = ref.watch(featureProvider);
return ref.watch(pivStateProvider(devicePath)).when(
loading: () => MessagePage(
title: Text(l10n.s_piv),
title: Text(l10n.s_certificates),
graphic: const CircularProgressIndicator(),
delayedContent: true,
),
error: (error, _) => AppFailurePage(
title: Text(l10n.s_piv),
title: Text(l10n.s_certificates),
cause: error,
),
data: (pivState) {
final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData;
return AppPage(
title: Text(l10n.s_piv),
title: Text(l10n.s_certificates),
keyActionsBuilder: hasFeature(features.actions)
? (context) =>
pivBuildActions(context, devicePath, pivState, ref)

View File

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
class AppInputDecoration extends InputDecoration {
final List<Widget>? suffixIcons;
const AppInputDecoration({
// allow multiple suffixIcons
this.suffixIcons,
// forward other TextField parameters
super.icon,
super.iconColor,
super.label,
super.labelText,
super.labelStyle,
super.floatingLabelStyle,
super.helperText,
super.helperStyle,
super.helperMaxLines,
super.hintText,
super.hintStyle,
super.hintTextDirection,
super.hintMaxLines,
super.hintFadeDuration,
super.error,
super.errorText,
super.errorStyle,
super.errorMaxLines,
super.floatingLabelBehavior,
super.floatingLabelAlignment,
super.isCollapsed,
super.isDense,
super.contentPadding,
super.prefixIcon,
super.prefixIconConstraints,
super.prefix,
super.prefixText,
super.prefixStyle,
super.prefixIconColor,
super.suffixIcon,
super.suffix,
super.suffixText,
super.suffixStyle,
super.suffixIconColor,
super.suffixIconConstraints,
super.counter,
super.counterText,
super.counterStyle,
super.filled,
super.fillColor,
super.focusColor,
super.hoverColor,
super.errorBorder,
super.focusedBorder,
super.focusedErrorBorder,
super.disabledBorder,
super.enabledBorder,
super.border,
super.enabled = true,
super.semanticCounterText,
super.alignLabelWithHint,
super.constraints,
}) : assert(!(suffixIcon != null && suffixIcons != null),
'Declaring both suffixIcon and suffixIcons is not supported.');
@override
Widget? get suffixIcon {
final icons = [
if (super.suffixIcon != null) super.suffixIcon!,
if (suffixIcons != null) ...suffixIcons!,
if (errorText != null) const Icon(Icons.error_outlined),
];
return switch (icons.length) {
0 => null,
1 => icons.single,
_ => Builder(
builder: (context) {
// Apply the constraints to *each* icon.
final constraints = suffixIconConstraints ??
Theme.of(context).visualDensity.effectiveConstraints(
const BoxConstraints(
minWidth: kMinInteractiveDimension,
minHeight: kMinInteractiveDimension,
),
);
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: [
for (Widget icon in icons)
ConstrainedBox(
constraints: constraints,
child: icon,
)
],
);
},
),
};
}
}

View File

@ -16,6 +16,8 @@
import 'package:flutter/material.dart';
import 'app_input_decoration.dart';
/// TextField without autocorrect and suggestions
class AppTextField extends TextField {
const AppTextField({
@ -28,7 +30,7 @@ class AppTextField extends TextField {
super.controller,
super.focusNode,
super.undoController,
super.decoration,
AppInputDecoration? decoration,
super.textInputAction,
super.textCapitalization,
super.style,
@ -83,5 +85,5 @@ class AppTextField extends TextField {
super.canRequestFocus,
super.spellCheckConfiguration,
super.magnifierConfiguration,
});
}) : super(decoration: decoration);
}

View File

@ -16,6 +16,8 @@
import 'package:flutter/material.dart';
import 'app_input_decoration.dart';
/// TextFormField without autocorrect and suggestions
class AppTextFormField extends TextFormField {
AppTextFormField({
@ -28,7 +30,7 @@ class AppTextFormField extends TextFormField {
super.controller,
super.initialValue,
super.focusNode,
super.decoration,
AppInputDecoration? decoration,
super.textCapitalization,
super.textInputAction,
super.style,
@ -87,5 +89,5 @@ class AppTextFormField extends TextFormField {
super.clipBehavior,
super.scribbleEnabled,
super.canRequestFocus,
});
}) : super(decoration: decoration);
}

View File

@ -21,6 +21,7 @@ import 'package:flutter/material.dart';
class ChoiceFilterChip<T> extends StatefulWidget {
final T value;
final List<T> items;
final String? tooltip;
final Widget Function(T value) itemBuilder;
final Widget Function(T value)? labelBuilder;
final void Function(T value)? onChanged;
@ -32,6 +33,7 @@ class ChoiceFilterChip<T> extends StatefulWidget {
required this.items,
required this.itemBuilder,
required this.onChanged,
this.tooltip,
this.avatar,
this.selected = false,
this.labelBuilder,
@ -57,7 +59,6 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
),
Offset.zero & overlay.size,
);
return await showMenu(
context: context,
position: position,
@ -79,6 +80,7 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
@override
Widget build(BuildContext context) {
return FilterChip(
tooltip: widget.tooltip,
avatar: widget.avatar,
labelPadding: const EdgeInsets.only(left: 4),
label: Row(