diff --git a/helper/helper/base.py b/helper/helper/base.py index ca2a940f6..78b9b653e 100644 --- a/helper/helper/base.py +++ b/helper/helper/base.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from yubikit.core import InvalidPinError from functools import partial import logging @@ -123,6 +124,8 @@ def __call__(self, action, target, params, event, signal, traversed=None): except ChildResetException as e: self._close_child() raise StateResetException(e.message, traversed) + except InvalidPinError: + raise # Prevent catching this as a ValueError below except ValueError as e: raise InvalidParametersException(e) raise NoSuchActionException(action) diff --git a/helper/helper/piv.py b/helper/helper/piv.py index a6ae55115..2478aa789 100644 --- a/helper/helper/piv.py +++ b/helper/helper/piv.py @@ -21,13 +21,12 @@ TimeoutException, AuthRequiredException, ) -from yubikit.core import NotSupportedError, BadResponseError +from yubikit.core import NotSupportedError, BadResponseError, InvalidPinError from yubikit.core.smartcard import ApduError, SW from yubikit.piv import ( PivSession, OBJECT_ID, MANAGEMENT_KEY_TYPE, - InvalidPinError, SLOT, require_version, KEY_TYPE, @@ -43,6 +42,7 @@ generate_self_signed_certificate, generate_csr, generate_chuid, + parse_rfc4514_string, ) from ykman.util import ( parse_certificates, @@ -234,6 +234,34 @@ def reset(self, params, event, signal): def slots(self): return SlotsNode(self.session) + @action(closes_child=False) + def examine_file(self, params, event, signal): + data = bytes.fromhex(params.pop("data")) + password = params.pop("password", None) + try: + private_key, certs = _parse_file(data, password) + certificate = _choose_cert(certs) + + return dict( + status=True, + password=password is not None, + key_type=KEY_TYPE.from_public_key(private_key.public_key()) + if private_key + else None, + cert_info=_get_cert_info(certificate), + ) + except InvalidPasswordError: + logger.debug("Invalid or missing password", exc_info=True) + return dict(status=False) + + @action(closes_child=False) + def validate_rfc4514(self, params, event, signal): + try: + parse_rfc4514_string(params.pop("data")) + return dict(status=True) + except ValueError: + return dict(status=False) + def _slot_for(name): return SLOT(int(name, base=16)) @@ -255,6 +283,29 @@ def _parse_file(data, password=None): return private_key, certs +def _choose_cert(certs): + if certs: + if len(certs) > 1: + leafs = get_leaf_certificates(certs) + return leafs[0] + else: + return certs[0] + return None + + +def _get_cert_info(cert): + if cert is None: + return None + return dict( + subject=cert.subject.rfc4514_string(), + issuer=cert.issuer.rfc4514_string(), + serial=hex(cert.serial_number)[2:], + not_valid_before=cert.not_valid_before.isoformat(), + not_valid_after=cert.not_valid_after.isoformat(), + fingerprint=cert.fingerprint(hashes.SHA256()), + ) + + class SlotsNode(RpcNode): def __init__(self, session): super().__init__() @@ -290,16 +341,7 @@ def list_children(self): slot=int(slot), name=slot.name, has_key=metadata is not None if self._has_metadata else None, - cert_info=dict( - subject=cert.subject.rfc4514_string(), - issuer=cert.issuer.rfc4514_string(), - serial=hex(cert.serial_number)[2:], - not_valid_before=cert.not_valid_before.isoformat(), - not_valid_after=cert.not_valid_after.isoformat(), - fingerprint=cert.fingerprint(hashes.SHA256()), - ) - if cert - else None, + cert_info=_get_cert_info(cert), ) for slot, (metadata, cert) in self._slots.items() } @@ -311,22 +353,6 @@ def create_child(self, name): return SlotNode(self.session, slot, metadata, certificate, self.refresh) return super().create_child(name) - @action - def examine_file(self, params, event, signal): - data = bytes.fromhex(params.pop("data")) - password = params.pop("password", None) - try: - private_key, certs = _parse_file(data, password) - return dict( - status=True, - password=password is not None, - private_key=bool(private_key), - certificates=len(certs), - ) - except InvalidPasswordError: - logger.debug("Invalid or missing password", exc_info=True) - return dict(status=False) - class SlotNode(RpcNode): def __init__(self, session, slot, metadata, certificate, refresh): @@ -382,12 +408,8 @@ def import_file(self, params, event, signal): except (ApduError, BadResponseError): pass - if certs: - if len(certs) > 1: - leafs = get_leaf_certificates(certs) - certificate = leafs[0] - else: - certificate = certs[0] + certificate = _choose_cert(certs) + if certificate: self.session.put_certificate(self.slot, certificate) self.session.put_object(OBJECT_ID.CHUID, generate_chuid()) self.certificate = certificate @@ -414,7 +436,9 @@ def generate(self, params, event, signal): pin_policy = PIN_POLICY(params.pop("pin_policy", PIN_POLICY.DEFAULT)) touch_policy = TOUCH_POLICY(params.pop("touch_policy", TOUCH_POLICY.DEFAULT)) subject = params.pop("subject") - generate_type = GENERATE_TYPE(params.pop("generate_type", GENERATE_TYPE.CERTIFICATE)) + generate_type = GENERATE_TYPE( + params.pop("generate_type", GENERATE_TYPE.CERTIFICATE) + ) public_key = self.session.generate_key( self.slot, key_type, pin_policy, touch_policy ) diff --git a/lib/desktop/piv/state.dart b/lib/desktop/piv/state.dart index c5f86b57c..955a4743a 100644 --- a/lib/desktop/piv/state.dart +++ b/lib/desktop/piv/state.dart @@ -380,9 +380,7 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier { @override Future examine(String data, {String? password}) async { - final result = await _session.command('examine_file', target: [ - 'slots', - ], params: { + final result = await _session.command('examine_file', params: { 'data': data, 'password': password, }); @@ -394,6 +392,14 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier { } } + @override + Future validateRfc4514(String value) async { + final result = await _session.command('validate_rfc4514', params: { + 'data': value, + }); + return result['status']; + } + @override Future import(SlotId slot, String data, {String? password, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7434e1cc4..32b00d5b8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -30,10 +30,12 @@ "s_unlock": "Unlock", "s_calculate": "Calculate", "s_import": "Import", + "s_overwrite": "Overwrite", "s_label": "Label", "s_name": "Name", "s_usb": "USB", "s_nfc": "NFC", + "s_options": "Options", "s_show_window": "Show window", "s_hide_window": "Hide window", "q_rename_target": "Rename {label}?", @@ -48,14 +50,9 @@ "item": {} } }, - "s_definition": "{item}:", - "@s_definition" : { - "placeholders": { - "item": {} - } - }, "s_about": "About", + "s_algorithm": "Algorithm", "s_appearance": "Appearance", "s_authenticator": "Authenticator", "s_actions": "Actions", @@ -281,6 +278,8 @@ "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", "l_default_key_used": "Default management key used", + "s_generate_random": "Generate random", + "s_use_default": "Use default", "l_warning_default_key": "Warning: Default key used", "s_protect_key": "Protect with PIN", "l_pin_protected_key": "PIN can be used instead", @@ -457,12 +456,26 @@ }, "l_certificate_deleted": "Certificate deleted", "p_password_protected_file": "The selected file is password protected. Enter the password to proceed.", - "p_import_items_desc": "The following items will be imported into PIV slot {slot}.", + "p_import_items_desc": "The following item(s) will be imported into PIV slot {slot}.", "@p_import_items_desc" : { "placeholders": { "slot": {} } }, + "p_subject_desc": "A distinguished name (DN) formatted in accordance to the RFC 4514 specification.", + "l_rfc4514_invalid": "Invalid RFC 4514 format", + "rfc4514_examples": "Examples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net", + "p_cert_options_desc": "Key algorithm to use, output format, and expiration date (certificate only).", + "s_overwrite_slot": "Overwrite slot", + "p_overwrite_slot_desc": "This will permanently overwrite existing content in slot {slot}.", + "@p_overwrite_slot_desc" : { + "placeholders": { + "slot": {} + } + }, + "l_overwrite_cert": "The certificate will be overwritten", + "l_overwrite_key": "The private key will be overwritten", + "l_overwrite_key_maybe": "Any existing private key in the slot will be overwritten", "@_piv_slots": {}, "s_slot_display_name": "{name} ({hexid})", diff --git a/lib/piv/models.dart b/lib/piv/models.dart index d42ab88f9..d54eaeee2 100644 --- a/lib/piv/models.dart +++ b/lib/piv/models.dart @@ -266,8 +266,8 @@ class PivSlot with _$PivSlot { class PivExamineResult with _$PivExamineResult { factory PivExamineResult.result({ required bool password, - required bool privateKey, - required int certificates, + required KeyType? keyType, + required CertInfo? certInfo, }) = _ExamineResult; factory PivExamineResult.invalidPassword() = _InvalidPassword; diff --git a/lib/piv/models.freezed.dart b/lib/piv/models.freezed.dart index 8a1637f50..9352b2d4f 100644 --- a/lib/piv/models.freezed.dart +++ b/lib/piv/models.freezed.dart @@ -1860,20 +1860,23 @@ PivExamineResult _$PivExamineResultFromJson(Map json) { mixin _$PivExamineResult { @optionalTypeArgs TResult when({ - required TResult Function(bool password, bool privateKey, int certificates) + required TResult Function( + bool password, KeyType? keyType, CertInfo? certInfo) result, required TResult Function() invalidPassword, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(bool password, bool privateKey, int certificates)? result, + TResult? Function(bool password, KeyType? keyType, CertInfo? certInfo)? + result, TResult? Function()? invalidPassword, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ - TResult Function(bool password, bool privateKey, int certificates)? result, + TResult Function(bool password, KeyType? keyType, CertInfo? certInfo)? + result, TResult Function()? invalidPassword, required TResult orElse(), }) => @@ -1924,7 +1927,9 @@ abstract class _$$_ExamineResultCopyWith<$Res> { _$_ExamineResult value, $Res Function(_$_ExamineResult) then) = __$$_ExamineResultCopyWithImpl<$Res>; @useResult - $Res call({bool password, bool privateKey, int certificates}); + $Res call({bool password, KeyType? keyType, CertInfo? certInfo}); + + $CertInfoCopyWith<$Res>? get certInfo; } /// @nodoc @@ -1939,24 +1944,36 @@ class __$$_ExamineResultCopyWithImpl<$Res> @override $Res call({ Object? password = null, - Object? privateKey = null, - Object? certificates = null, + Object? keyType = freezed, + Object? certInfo = freezed, }) { return _then(_$_ExamineResult( password: null == password ? _value.password : password // ignore: cast_nullable_to_non_nullable as bool, - privateKey: null == privateKey - ? _value.privateKey - : privateKey // ignore: cast_nullable_to_non_nullable - as bool, - certificates: null == certificates - ? _value.certificates - : certificates // ignore: cast_nullable_to_non_nullable - as int, + keyType: freezed == keyType + ? _value.keyType + : keyType // ignore: cast_nullable_to_non_nullable + as KeyType?, + certInfo: freezed == certInfo + ? _value.certInfo + : certInfo // ignore: cast_nullable_to_non_nullable + as CertInfo?, )); } + + @override + @pragma('vm:prefer-inline') + $CertInfoCopyWith<$Res>? get certInfo { + if (_value.certInfo == null) { + return null; + } + + return $CertInfoCopyWith<$Res>(_value.certInfo!, (value) { + return _then(_value.copyWith(certInfo: value)); + }); + } } /// @nodoc @@ -1964,8 +1981,8 @@ class __$$_ExamineResultCopyWithImpl<$Res> class _$_ExamineResult implements _ExamineResult { _$_ExamineResult( {required this.password, - required this.privateKey, - required this.certificates, + required this.keyType, + required this.certInfo, final String? $type}) : $type = $type ?? 'result'; @@ -1975,16 +1992,16 @@ class _$_ExamineResult implements _ExamineResult { @override final bool password; @override - final bool privateKey; + final KeyType? keyType; @override - final int certificates; + final CertInfo? certInfo; @JsonKey(name: 'runtimeType') final String $type; @override String toString() { - return 'PivExamineResult.result(password: $password, privateKey: $privateKey, certificates: $certificates)'; + return 'PivExamineResult.result(password: $password, keyType: $keyType, certInfo: $certInfo)'; } @override @@ -1994,16 +2011,14 @@ class _$_ExamineResult implements _ExamineResult { other is _$_ExamineResult && (identical(other.password, password) || other.password == password) && - (identical(other.privateKey, privateKey) || - other.privateKey == privateKey) && - (identical(other.certificates, certificates) || - other.certificates == certificates)); + (identical(other.keyType, keyType) || other.keyType == keyType) && + (identical(other.certInfo, certInfo) || + other.certInfo == certInfo)); } @JsonKey(ignore: true) @override - int get hashCode => - Object.hash(runtimeType, password, privateKey, certificates); + int get hashCode => Object.hash(runtimeType, password, keyType, certInfo); @JsonKey(ignore: true) @override @@ -2014,31 +2029,34 @@ class _$_ExamineResult implements _ExamineResult { @override @optionalTypeArgs TResult when({ - required TResult Function(bool password, bool privateKey, int certificates) + required TResult Function( + bool password, KeyType? keyType, CertInfo? certInfo) result, required TResult Function() invalidPassword, }) { - return result(password, privateKey, certificates); + return result(password, keyType, certInfo); } @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(bool password, bool privateKey, int certificates)? result, + TResult? Function(bool password, KeyType? keyType, CertInfo? certInfo)? + result, TResult? Function()? invalidPassword, }) { - return result?.call(password, privateKey, certificates); + return result?.call(password, keyType, certInfo); } @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(bool password, bool privateKey, int certificates)? result, + TResult Function(bool password, KeyType? keyType, CertInfo? certInfo)? + result, TResult Function()? invalidPassword, required TResult orElse(), }) { if (result != null) { - return result(password, privateKey, certificates); + return result(password, keyType, certInfo); } return orElse(); } @@ -2085,15 +2103,15 @@ class _$_ExamineResult implements _ExamineResult { abstract class _ExamineResult implements PivExamineResult { factory _ExamineResult( {required final bool password, - required final bool privateKey, - required final int certificates}) = _$_ExamineResult; + required final KeyType? keyType, + required final CertInfo? certInfo}) = _$_ExamineResult; factory _ExamineResult.fromJson(Map json) = _$_ExamineResult.fromJson; bool get password; - bool get privateKey; - int get certificates; + KeyType? get keyType; + CertInfo? get certInfo; @JsonKey(ignore: true) _$$_ExamineResultCopyWith<_$_ExamineResult> get copyWith => throw _privateConstructorUsedError; @@ -2145,7 +2163,8 @@ class _$_InvalidPassword implements _InvalidPassword { @override @optionalTypeArgs TResult when({ - required TResult Function(bool password, bool privateKey, int certificates) + required TResult Function( + bool password, KeyType? keyType, CertInfo? certInfo) result, required TResult Function() invalidPassword, }) { @@ -2155,7 +2174,8 @@ class _$_InvalidPassword implements _InvalidPassword { @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(bool password, bool privateKey, int certificates)? result, + TResult? Function(bool password, KeyType? keyType, CertInfo? certInfo)? + result, TResult? Function()? invalidPassword, }) { return invalidPassword?.call(); @@ -2164,7 +2184,8 @@ class _$_InvalidPassword implements _InvalidPassword { @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(bool password, bool privateKey, int certificates)? result, + TResult Function(bool password, KeyType? keyType, CertInfo? certInfo)? + result, TResult Function()? invalidPassword, required TResult orElse(), }) { diff --git a/lib/piv/models.g.dart b/lib/piv/models.g.dart index 109f280d1..763d8393f 100644 --- a/lib/piv/models.g.dart +++ b/lib/piv/models.g.dart @@ -168,16 +168,18 @@ const _$SlotIdEnumMap = { _$_ExamineResult _$$_ExamineResultFromJson(Map json) => _$_ExamineResult( password: json['password'] as bool, - privateKey: json['private_key'] as bool, - certificates: json['certificates'] as int, + keyType: $enumDecodeNullable(_$KeyTypeEnumMap, json['key_type']), + certInfo: json['cert_info'] == null + ? null + : CertInfo.fromJson(json['cert_info'] as Map), $type: json['runtimeType'] as String?, ); Map _$$_ExamineResultToJson(_$_ExamineResult instance) => { 'password': instance.password, - 'private_key': instance.privateKey, - 'certificates': instance.certificates, + 'key_type': _$KeyTypeEnumMap[instance.keyType], + 'cert_info': instance.certInfo, 'runtimeType': instance.$type, }; diff --git a/lib/piv/state.dart b/lib/piv/state.dart index 8ebb628c5..ec3361b50 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -50,6 +50,7 @@ final pivSlotsProvider = AsyncNotifierProvider.autoDispose abstract class PivSlotsNotifier extends AutoDisposeFamilyAsyncNotifier, DevicePath> { Future examine(String data, {String? password}); + Future validateRfc4514(String value); Future<(SlotMetadata?, String?)> read(SlotId slot); Future generate( SlotId slot, diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 19bebbc2d..47ac2a32a 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -47,23 +47,23 @@ class ExportIntent extends Intent { } Future _authenticate( - WidgetRef ref, DevicePath devicePath, PivState pivState) async { - final withContext = ref.read(withContextProvider); - return await withContext((context) async => - await showBlurDialog( + BuildContext context, DevicePath devicePath, PivState pivState) async { + return await showBlurDialog( context: context, - builder: (context) => AuthenticationDialog( - devicePath, - pivState, - ), + builder: (context) => pivState.protectedKey + ? PinDialog(devicePath) + : AuthenticationDialog( + devicePath, + pivState, + ), ) ?? - false); + false; } Future _authIfNeeded( - WidgetRef ref, DevicePath devicePath, PivState pivState) async { + BuildContext context, DevicePath devicePath, PivState pivState) async { if (pivState.needsAuth) { - return await _authenticate(ref, devicePath, pivState); + return await _authenticate(context, devicePath, pivState); } return true; } @@ -80,13 +80,13 @@ Widget registerPivActions( actions: { GenerateIntent: CallbackAction(onInvoke: (intent) async { + final withContext = ref.read(withContextProvider); if (!pivState.protectedKey && - !await _authIfNeeded(ref, devicePath, pivState)) { + !await withContext( + (context) => _authIfNeeded(context, devicePath, pivState))) { return false; } - final withContext = ref.read(withContextProvider); - // TODO: Avoid asking for PIN if not needed? final verified = await withContext((context) async => await showBlurDialog( @@ -130,12 +130,13 @@ Widget registerPivActions( }); }), ImportIntent: CallbackAction(onInvoke: (intent) async { - if (!await _authIfNeeded(ref, devicePath, pivState)) { + final withContext = ref.read(withContextProvider); + + if (!await withContext( + (context) => _authIfNeeded(context, devicePath, pivState))) { return false; } - final withContext = ref.read(withContextProvider); - final picked = await withContext( (context) async { final l10n = AppLocalizations.of(context)!; @@ -198,10 +199,12 @@ Widget registerPivActions( return true; }), DeleteIntent: CallbackAction(onInvoke: (_) async { - if (!await _authIfNeeded(ref, devicePath, pivState)) { + final withContext = ref.read(withContextProvider); + if (!await withContext( + (context) => _authIfNeeded(context, devicePath, pivState))) { return false; } - final withContext = ref.read(withContextProvider); + final bool? deleted = await withContext((context) async => await showBlurDialog( context: context, diff --git a/lib/piv/views/authentication_dialog.dart b/lib/piv/views/authentication_dialog.dart index 2ae4a45a5..7132910ec 100644 --- a/lib/piv/views/authentication_dialog.dart +++ b/lib/piv/views/authentication_dialog.dart @@ -37,12 +37,20 @@ class AuthenticationDialog extends ConsumerStatefulWidget { } class _AuthenticationDialogState extends ConsumerState { - String _managementKey = ''; + bool _defaultKeyUsed = false; bool _keyIsWrong = false; + final _keyController = TextEditingController(); + + @override + void dispose() { + _keyController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final hasMetadata = widget.pivState.metadata != null; final keyLen = (widget.pivState.metadata?.managementKeyMetadata.keyType ?? ManagementKeyType.tdes) .keyLength * @@ -52,13 +60,13 @@ class _AuthenticationDialogState extends ConsumerState { actions: [ TextButton( key: keys.unlockButton, - onPressed: _managementKey.length == keyLen + onPressed: _keyController.text.length == keyLen ? () async { final navigator = Navigator.of(context); try { final status = await ref .read(pivStateProvider(widget.devicePath).notifier) - .authenticate(_managementKey); + .authenticate(_keyController.text); if (status) { navigator.pop(true); } else { @@ -88,24 +96,44 @@ class _AuthenticationDialogState extends ConsumerState { TextField( key: keys.managementKeyField, autofocus: true, - maxLength: keyLen, autofillHints: const [AutofillHints.password], + controller: _keyController, inputFormatters: [ FilteringTextInputFormatter.allow( RegExp('[a-f0-9]', caseSensitive: false)) ], + readOnly: _defaultKeyUsed, + maxLength: !_defaultKeyUsed ? keyLen : null, decoration: InputDecoration( 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, + suffixIcon: hasMetadata + ? null + : IconButton( + icon: Icon(_defaultKeyUsed + ? Icons.auto_awesome + : Icons.auto_awesome_outlined), + tooltip: l10n.s_use_default, + onPressed: () { + setState(() { + _defaultKeyUsed = !_defaultKeyUsed; + if (_defaultKeyUsed) { + _keyController.text = defaultManagementKey; + } else { + _keyController.clear(); + } + }); + }, + ), ), textInputAction: TextInputAction.next, onChanged: (value) { setState(() { _keyIsWrong = false; - _managementKey = value; }); }, ), diff --git a/lib/piv/views/cert_info_view.dart b/lib/piv/views/cert_info_view.dart new file mode 100644 index 000000000..013dc7426 --- /dev/null +++ b/lib/piv/views/cert_info_view.dart @@ -0,0 +1,99 @@ +/* + * 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:intl/intl.dart'; +import 'package:yubico_authenticator/app/message.dart'; +import 'package:yubico_authenticator/app/state.dart'; +import 'package:yubico_authenticator/piv/models.dart'; +import 'package:yubico_authenticator/widgets/tooltip_if_truncated.dart'; + +class CertInfoTable extends ConsumerWidget { + final CertInfo certInfo; + + const CertInfoTable(this.certInfo, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final textTheme = Theme.of(context).textTheme; + // This is what ListTile uses for subtitle + final subtitleStyle = textTheme.bodyMedium!.copyWith( + color: textTheme.bodySmall!.color, + ); + final dateFormat = DateFormat.yMMMEd(); + final clipboard = ref.watch(clipboardProvider); + final withContext = ref.watch(withContextProvider); + + Widget header(String title) => Text( + title, + textAlign: TextAlign.right, + ); + + Widget body(String title, String value) => GestureDetector( + onDoubleTap: () async { + await clipboard.setText(value); + if (!clipboard.platformGivesFeedback()) { + await withContext((context) async { + showMessage(context, l10n.p_target_copied_clipboard(title)); + }); + } + }, + child: TooltipIfTruncated( + text: value, + style: subtitleStyle, + tooltip: value.replaceAllMapped( + RegExp(r',([A-Z]+)='), (match) => '\n${match[1]}='), + ), + ); + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + header(l10n.s_subject), + header(l10n.s_issuer), + header(l10n.s_serial), + header(l10n.s_certificate_fingerprint), + header(l10n.s_valid_from), + header(l10n.s_valid_to), + ], + ), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + body(l10n.s_subject, certInfo.subject), + body(l10n.s_issuer, certInfo.issuer), + body(l10n.s_serial, certInfo.serial), + body(l10n.s_certificate_fingerprint, certInfo.fingerprint), + body(l10n.s_valid_from, + dateFormat.format(DateTime.parse(certInfo.notValidBefore))), + body(l10n.s_valid_to, + dateFormat.format(DateTime.parse(certInfo.notValidAfter))), + ], + ), + ), + ], + ); + } +} diff --git a/lib/piv/views/generate_key_dialog.dart b/lib/piv/views/generate_key_dialog.dart index 4399427b4..77d174f09 100644 --- a/lib/piv/views/generate_key_dialog.dart +++ b/lib/piv/views/generate_key_dialog.dart @@ -27,6 +27,7 @@ import '../../widgets/responsive_dialog.dart'; import '../models.dart'; import '../state.dart'; import '../keys.dart' as keys; +import 'overwrite_confirm_dialog.dart'; class GenerateKeyDialog extends ConsumerStatefulWidget { final DevicePath devicePath; @@ -42,6 +43,7 @@ class GenerateKeyDialog extends ConsumerStatefulWidget { class _GenerateKeyDialogState extends ConsumerState { String _subject = ''; + bool _invalidSubject = true; GenerateType _generateType = defaultGenerateType; KeyType _keyType = defaultKeyType; late DateTime _validFrom; @@ -64,6 +66,11 @@ class _GenerateKeyDialogState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final textTheme = Theme.of(context).textTheme; + // This is what ListTile uses for subtitle + final subtitleStyle = textTheme.bodyMedium!.copyWith( + color: textTheme.bodySmall!.color, + ); return ResponsiveDialog( allowCancel: !_generating, @@ -71,36 +78,56 @@ class _GenerateKeyDialogState extends ConsumerState { actions: [ TextButton( key: keys.saveButton, - onPressed: _generating || _subject.isEmpty + onPressed: _generating || _invalidSubject ? null : () async { + if (!await confirmOverwrite( + context, + widget.pivSlot, + writeKey: true, + writeCert: _generateType == GenerateType.certificate, + )) { + return; + } + setState(() { _generating = true; }); - Function()? close; + final pivNotifier = + ref.read(pivSlotsProvider(widget.devicePath).notifier); + final withContext = ref.read(withContextProvider); + + if (!await pivNotifier.validateRfc4514(_subject)) { + setState(() { + _generating = false; + }); + _invalidSubject = true; + return; + } + + void Function()? close; final PivGenerateResult result; try { - close = showMessage( - context, - l10n.l_generating_private_key, - duration: const Duration(seconds: 30), + close = await withContext( + (context) async => showMessage( + context, + l10n.l_generating_private_key, + duration: const Duration(seconds: 30), + )); + result = await pivNotifier.generate( + widget.pivSlot.slot, + _keyType, + parameters: switch (_generateType) { + GenerateType.certificate => + PivGenerateParameters.certificate( + subject: _subject, + validFrom: _validFrom, + validTo: _validTo), + GenerateType.csr => + PivGenerateParameters.csr(subject: _subject), + }, ); - result = await ref - .read(pivSlotsProvider(widget.devicePath).notifier) - .generate( - widget.pivSlot.slot, - _keyType, - parameters: switch (_generateType) { - GenerateType.certificate => - PivGenerateParameters.certificate( - subject: _subject, - validFrom: _validFrom, - validTo: _validTo), - GenerateType.csr => - PivGenerateParameters.csr(subject: _subject), - }, - ); } finally { close?.call(); } @@ -123,53 +150,68 @@ class _GenerateKeyDialogState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + l10n.p_generate_desc(widget.pivSlot.slot.getDisplayName(l10n))), + Text( + l10n.s_subject, + style: textTheme.bodyLarge, + ), + Text(l10n.p_subject_desc), TextField( autofocus: true, key: keys.subjectField, decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.s_subject, - ), + border: const OutlineInputBorder(), + labelText: l10n.s_subject, + errorText: _subject.isNotEmpty && _invalidSubject + ? l10n.l_rfc4514_invalid + : null), textInputAction: TextInputAction.next, enabled: !_generating, onChanged: (value) { setState(() { - if (value.isEmpty) { - _subject = ''; - } else { - _subject = value.contains('=') ? value : 'CN=$value'; - } + _invalidSubject = value.isEmpty; + _subject = value; }); }, ), + Text( + l10n.rfc4514_examples, + style: subtitleStyle, + ), + Text( + l10n.s_options, + style: textTheme.bodyLarge, + ), + Text(l10n.p_cert_options_desc), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, runSpacing: 8.0, children: [ - ChoiceFilterChip( - items: GenerateType.values, - value: _generateType, - selected: _generateType != defaultGenerateType, + ChoiceFilterChip( + items: KeyType.values, + value: _keyType, + selected: _keyType != defaultKeyType, itemBuilder: (value) => Text(value.getDisplayName(l10n)), onChanged: _generating ? null : (value) { setState(() { - _generateType = value; + _keyType = value; }); }, ), - ChoiceFilterChip( - items: KeyType.values, - value: _keyType, - selected: _keyType != defaultKeyType, + ChoiceFilterChip( + items: GenerateType.values, + value: _generateType, + selected: _generateType != defaultGenerateType, itemBuilder: (value) => Text(value.getDisplayName(l10n)), onChanged: _generating ? null : (value) { setState(() { - _keyType = value; + _generateType = value; }); }, ), diff --git a/lib/piv/views/import_file_dialog.dart b/lib/piv/views/import_file_dialog.dart index 1af99ef99..c11fdba73 100644 --- a/lib/piv/views/import_file_dialog.dart +++ b/lib/piv/views/import_file_dialog.dart @@ -25,6 +25,8 @@ import '../../widgets/responsive_dialog.dart'; import '../models.dart'; import '../state.dart'; import '../keys.dart' as keys; +import 'cert_info_view.dart'; +import 'overwrite_confirm_dialog.dart'; class ImportFileDialog extends ConsumerStatefulWidget { final DevicePath devicePath; @@ -77,6 +79,11 @@ class _ImportFileDialogState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final textTheme = Theme.of(context).textTheme; + // This is what ListTile uses for subtitle + final subtitleStyle = textTheme.bodyMedium!.copyWith( + color: textTheme.bodySmall!.color, + ); final state = _state; if (state == null) { return ResponsiveDialog( @@ -141,26 +148,39 @@ class _ImportFileDialogState extends ConsumerState { ), ), ), - result: (_, privateKey, certificates) => ResponsiveDialog( + result: (_, keyType, certInfo) => ResponsiveDialog( title: Text(l10n.l_import_file), actions: [ TextButton( key: keys.unlockButton, - onPressed: () async { - final navigator = Navigator.of(context); - try { - await ref - .read(pivSlotsProvider(widget.devicePath).notifier) - .import(widget.pivSlot.slot, _data, - password: _password.isNotEmpty ? _password : null); - navigator.pop(true); - } catch (_) { - // TODO: More error cases - setState(() { - _passwordIsWrong = true; - }); - } - }, + onPressed: (keyType == null && certInfo == null) + ? null + : () async { + final navigator = Navigator.of(context); + + if (!await confirmOverwrite( + context, + widget.pivSlot, + writeKey: keyType != null, + writeCert: certInfo != null, + )) { + return; + } + + try { + await ref + .read(pivSlotsProvider(widget.devicePath).notifier) + .import(widget.pivSlot.slot, _data, + password: + _password.isNotEmpty ? _password : null); + navigator.pop(true); + } catch (err) { + // TODO: More error cases + setState(() { + _passwordIsWrong = true; + }); + } + }, child: Text(l10n.s_import), ), ], @@ -171,8 +191,37 @@ class _ImportFileDialogState extends ConsumerState { children: [ Text(l10n.p_import_items_desc( widget.pivSlot.slot.getDisplayName(l10n))), - if (privateKey) Text(l10n.l_bullet(l10n.s_private_key)), - if (certificates > 0) Text(l10n.l_bullet(l10n.s_certificate)), + if (keyType != null) ...[ + Text( + l10n.s_private_key, + style: textTheme.bodyLarge, + softWrap: true, + textAlign: TextAlign.center, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.s_algorithm), + const SizedBox(width: 8), + Text( + keyType.name.toUpperCase(), + style: subtitleStyle, + ), + ], + ) + ], + if (certInfo != null) ...[ + Text( + l10n.s_certificate, + style: textTheme.bodyLarge, + softWrap: true, + textAlign: TextAlign.center, + ), + SizedBox( + height: 120, // Needed for layout, adapt if text sizes changes + child: CertInfoTable(certInfo), + ), + ] ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/piv/views/manage_key_dialog.dart b/lib/piv/views/manage_key_dialog.dart index 4c307ef48..fdb2e6ae4 100644 --- a/lib/piv/views/manage_key_dialog.dart +++ b/lib/piv/views/manage_key_dialog.dart @@ -42,24 +42,26 @@ class ManageKeyDialog extends ConsumerStatefulWidget { } class _ManageKeyDialogState extends ConsumerState { + late bool _hasMetadata; late bool _defaultKeyUsed; late bool _usesStoredKey; late bool _storeKey; - String _currentKeyOrPin = ''; bool _currentIsWrong = false; int _attemptsRemaining = -1; ManagementKeyType _keyType = ManagementKeyType.tdes; + final _currentController = TextEditingController(); final _keyController = TextEditingController(); @override void initState() { super.initState(); + _hasMetadata = widget.pivState.metadata != null; _defaultKeyUsed = widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false; _usesStoredKey = widget.pivState.protectedKey; if (!_usesStoredKey && _defaultKeyUsed) { - _currentKeyOrPin = defaultManagementKey; + _currentController.text = defaultManagementKey; } _storeKey = _usesStoredKey; } @@ -67,13 +69,14 @@ class _ManageKeyDialogState extends ConsumerState { @override void dispose() { _keyController.dispose(); + _currentController.dispose(); super.dispose(); } _submit() async { final notifier = ref.read(pivStateProvider(widget.path).notifier); if (_usesStoredKey) { - final status = (await notifier.verifyPin(_currentKeyOrPin)).when( + final status = (await notifier.verifyPin(_currentController.text)).when( success: () => true, failure: (attemptsRemaining) { setState(() { @@ -87,7 +90,7 @@ class _ManageKeyDialogState extends ConsumerState { return; } } else { - if (!await notifier.authenticate(_currentKeyOrPin)) { + if (!await notifier.authenticate(_currentController.text)) { setState(() { _currentIsWrong = true; }); @@ -126,9 +129,10 @@ class _ManageKeyDialogState extends ConsumerState { ManagementKeyType.tdes; final hexLength = _keyType.keyLength * 2; final protected = widget.pivState.protectedKey; + final currentKeyOrPin = _currentController.text; final currentLenOk = protected - ? _currentKeyOrPin.length >= 4 - : _currentKeyOrPin.length == currentType.keyLength * 2; + ? currentKeyOrPin.length >= 4 + : currentKeyOrPin.length == currentType.keyLength * 2; final newLenOk = _keyController.text.length == hexLength; return ResponsiveDialog( @@ -153,6 +157,7 @@ class _ManageKeyDialogState extends ConsumerState { autofillHints: const [AutofillHints.password], key: keys.pinPukField, maxLength: 8, + controller: _currentController, decoration: InputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, @@ -166,7 +171,6 @@ class _ManageKeyDialogState extends ConsumerState { onChanged: (value) { setState(() { _currentIsWrong = false; - _currentKeyOrPin = value; }); }, ), @@ -175,16 +179,34 @@ class _ManageKeyDialogState extends ConsumerState { key: keys.managementKeyField, autofocus: !_defaultKeyUsed, autofillHints: const [AutofillHints.password], - initialValue: _defaultKeyUsed ? defaultManagementKey : null, + controller: _currentController, readOnly: _defaultKeyUsed, maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null, decoration: InputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_current_management_key, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Icons.key_outlined), errorText: _currentIsWrong ? l10n.l_wrong_key : null, errorMaxLines: 3, helperText: _defaultKeyUsed ? l10n.l_default_key_used : null, + suffixIcon: _hasMetadata + ? null + : IconButton( + icon: Icon(_defaultKeyUsed + ? Icons.auto_awesome + : Icons.auto_awesome_outlined), + tooltip: l10n.s_use_default, + onPressed: () { + setState(() { + _defaultKeyUsed = !_defaultKeyUsed; + if (_defaultKeyUsed) { + _currentController.text = defaultManagementKey; + } else { + _currentController.clear(); + } + }); + }, + ), ), inputFormatters: [ FilteringTextInputFormatter.allow( @@ -194,7 +216,6 @@ class _ManageKeyDialogState extends ConsumerState { onChanged: (value) { setState(() { _currentIsWrong = false; - _currentKeyOrPin = value; }); }, ), @@ -211,10 +232,11 @@ class _ManageKeyDialogState extends ConsumerState { decoration: InputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_new_management_key, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Icons.key_outlined), enabled: currentLenOk, suffixIcon: IconButton( icon: const Icon(Icons.refresh), + tooltip: l10n.s_generate_random, onPressed: currentLenOk ? () { final random = Random.secure(); diff --git a/lib/piv/views/manage_pin_puk_dialog.dart b/lib/piv/views/manage_pin_puk_dialog.dart index b45736baa..7cb679507 100644 --- a/lib/piv/views/manage_pin_puk_dialog.dart +++ b/lib/piv/views/manage_pin_puk_dialog.dart @@ -114,7 +114,7 @@ class _ManagePinPukDialogState extends ConsumerState { : l10n.s_current_puk, prefixIcon: const Icon(Icons.password_outlined), errorText: _currentIsWrong - ? (widget.target == ManageTarget.puk + ? (widget.target == ManageTarget.pin ? l10n.l_wrong_pin_attempts_remaining( _attemptsRemaining) : l10n.l_wrong_puk_attempts_remaining( diff --git a/lib/piv/views/overwrite_confirm_dialog.dart b/lib/piv/views/overwrite_confirm_dialog.dart new file mode 100644 index 000000000..50ade8ab7 --- /dev/null +++ b/lib/piv/views/overwrite_confirm_dialog.dart @@ -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 '../../app/message.dart'; +import '../../widgets/responsive_dialog.dart'; +import '../models.dart'; + +class _OverwriteConfirmDialog extends StatelessWidget { + final SlotId slot; + final bool certificate; + final bool? privateKey; + + const _OverwriteConfirmDialog({ + required this.certificate, + required this.privateKey, + required this.slot, + }); + @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(slot.getDisplayName(l10n))), + const SizedBox(height: 12), + if (certificate) Text(l10n.l_bullet(l10n.l_overwrite_cert)), + if (privateKey == true) Text(l10n.l_bullet(l10n.l_overwrite_key)), + if (privateKey == null) + Text(l10n.l_bullet(l10n.l_overwrite_key_maybe)), + ], + ), + ), + ); + } +} + +Future confirmOverwrite( + BuildContext context, + PivSlot pivSlot, { + required bool writeKey, + required bool writeCert, +}) async { + final overwritesCert = writeCert && pivSlot.certInfo != null; + final overwritesKey = writeKey ? pivSlot.hasKey : false; + if (overwritesCert || overwritesKey != false) { + return await showBlurDialog( + context: context, + builder: (context) => _OverwriteConfirmDialog( + slot: pivSlot.slot, + certificate: overwritesCert, + privateKey: overwritesKey, + )) ?? + false; + } + return true; +} diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index 4a4f60846..7f849e5f4 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -33,22 +33,30 @@ class PinDialog extends ConsumerStatefulWidget { } class _PinDialogState extends ConsumerState { - String _pin = ''; + final _pinController = TextEditingController(); bool _pinIsWrong = false; int _attemptsRemaining = -1; + bool _isObscure = true; + + @override + void dispose() { + _pinController.dispose(); + super.dispose(); + } Future _submit() async { final navigator = Navigator.of(context); try { final status = await ref .read(pivStateProvider(widget.devicePath).notifier) - .verifyPin(_pin); + .verifyPin(_pinController.text); status.when( success: () { navigator.pop(true); }, failure: (attemptsRemaining) { setState(() { + _pinController.clear(); _attemptsRemaining = attemptsRemaining; _pinIsWrong = true; }); @@ -67,7 +75,7 @@ class _PinDialogState extends ConsumerState { actions: [ TextButton( key: keys.unlockButton, - onPressed: _pin.length >= 4 ? _submit : null, + onPressed: _pinController.text.length >= 4 ? _submit : null, child: Text(l10n.s_unlock), ), ], @@ -79,23 +87,35 @@ class _PinDialogState extends ConsumerState { Text(l10n.p_pin_required_desc), TextField( autofocus: true, - obscureText: true, + obscureText: _isObscure, maxLength: 8, autofillHints: const [AutofillHints.password], key: keys.managementKeyField, + controller: _pinController, decoration: InputDecoration( - 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), + 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, + suffixIcon: IconButton( + icon: Icon( + _isObscure ? Icons.visibility : Icons.visibility_off, + color: IconTheme.of(context).color, + ), + onPressed: () { + setState(() { + _isObscure = !_isObscure; + }); + }, + ), + ), textInputAction: TextInputAction.next, onChanged: (value) { setState(() { _pinIsWrong = false; - _pin = value; }); }, onSubmitted: (_) => _submit(), diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index e54b879bd..3727f8121 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -105,7 +105,8 @@ class _CertificateListItem extends StatelessWidget { ), title: slot.getDisplayName(l10n), subtitle: certInfo != null - ? certInfo.subject + // Simplify subtitle by stripping "CN=", etc. + ? certInfo.subject.replaceAll(RegExp(r'[A-Z]+='), ' ').trimLeft() : pivSlot.hasKey == true ? l10n.l_key_no_certificate : l10n.l_no_certificate, diff --git a/lib/piv/views/slot_dialog.dart b/lib/piv/views/slot_dialog.dart index 92f289b59..31f0f460b 100644 --- a/lib/piv/views/slot_dialog.dart +++ b/lib/piv/views/slot_dialog.dart @@ -17,16 +17,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; -import '../../app/message.dart'; import '../../app/state.dart'; import '../../app/views/fs_dialog.dart'; import '../../app/views/action_list.dart'; -import '../../widgets/tooltip_if_truncated.dart'; import '../models.dart'; import '../state.dart'; import 'actions.dart'; +import 'cert_info_view.dart'; class SlotDialog extends ConsumerWidget { final SlotId pivSlot; @@ -48,8 +46,6 @@ class SlotDialog extends ConsumerWidget { final subtitleStyle = textTheme.bodyMedium!.copyWith( color: textTheme.bodySmall!.color, ); - final clipboard = ref.watch(clipboardProvider); - final withContext = ref.read(withContextProvider); final pivState = ref.watch(pivStateProvider(node.path)).valueOrNull; final slotData = ref.watch(pivSlotsProvider(node.path).select((value) => @@ -61,32 +57,6 @@ class SlotDialog extends ConsumerWidget { return const FsDialog(child: CircularProgressIndicator()); } - TableRow detailRow(String title, String value) { - return TableRow( - children: [ - Text( - l10n.s_definition(title), - textAlign: TextAlign.right, - ), - const SizedBox(width: 8.0), - GestureDetector( - onDoubleTap: () async { - await clipboard.setText(value); - if (!clipboard.platformGivesFeedback()) { - await withContext((context) async { - showMessage(context, l10n.p_target_copied_clipboard(title)); - }); - } - }, - child: TooltipIfTruncated( - text: value, - style: subtitleStyle, - ), - ), - ], - ); - } - final certInfo = slotData.certInfo; return registerPivActions( node.path, @@ -111,27 +81,7 @@ class SlotDialog extends ConsumerWidget { if (certInfo != null) ...[ Padding( padding: const EdgeInsets.all(16), - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - columnWidths: const {2: FlexColumnWidth()}, - children: [ - detailRow(l10n.s_subject, certInfo.subject), - detailRow(l10n.s_issuer, certInfo.issuer), - detailRow(l10n.s_serial, certInfo.serial), - detailRow(l10n.s_certificate_fingerprint, - certInfo.fingerprint), - detailRow( - l10n.s_valid_from, - DateFormat.yMMMEd().format( - DateTime.parse(certInfo.notValidBefore)), - ), - detailRow( - l10n.s_valid_to, - DateFormat.yMMMEd().format( - DateTime.parse(certInfo.notValidAfter)), - ), - ], - ), + child: CertInfoTable(certInfo), ), ] else ...[ Padding( diff --git a/lib/widgets/responsive_dialog.dart b/lib/widgets/responsive_dialog.dart index 871423ced..511457da1 100755 --- a/lib/widgets/responsive_dialog.dart +++ b/lib/widgets/responsive_dialog.dart @@ -78,7 +78,7 @@ class _ResponsiveDialogState extends State { scrollable: true, contentPadding: const EdgeInsets.symmetric(vertical: 8), content: SizedBox( - width: 380, + width: 550, child: Container(key: _childKey, child: widget.child), ), actions: [ @@ -107,7 +107,7 @@ class _ResponsiveDialogState extends State { _hasLostFocus = true; } }, - child: constraints.maxWidth < 540 + child: constraints.maxWidth < 400 ? _buildFullscreen(context) : _buildDialog(context), ); diff --git a/lib/widgets/tooltip_if_truncated.dart b/lib/widgets/tooltip_if_truncated.dart index 3694143e2..2eaaf932c 100644 --- a/lib/widgets/tooltip_if_truncated.dart +++ b/lib/widgets/tooltip_if_truncated.dart @@ -19,8 +19,9 @@ import 'package:flutter/material.dart'; class TooltipIfTruncated extends StatelessWidget { final String text; final TextStyle style; + final String? tooltip; const TooltipIfTruncated( - {super.key, required this.text, required this.style}); + {super.key, required this.text, required this.style, this.tooltip}); @override Widget build(BuildContext context) { @@ -41,7 +42,7 @@ class TooltipIfTruncated extends StatelessWidget { return textPainter.didExceedMaxLines ? Tooltip( margin: const EdgeInsets.all(16), - message: text, + message: tooltip ?? text, child: textWidget, ) : textWidget;