This commit is contained in:
Dain Nilsson 2023-08-24 21:01:47 +02:00
commit ad645a4b43
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
21 changed files with 622 additions and 254 deletions

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from yubikit.core import InvalidPinError
from functools import partial from functools import partial
import logging import logging
@ -123,6 +124,8 @@ class RpcNode:
except ChildResetException as e: except ChildResetException as e:
self._close_child() self._close_child()
raise StateResetException(e.message, traversed) raise StateResetException(e.message, traversed)
except InvalidPinError:
raise # Prevent catching this as a ValueError below
except ValueError as e: except ValueError as e:
raise InvalidParametersException(e) raise InvalidParametersException(e)
raise NoSuchActionException(action) raise NoSuchActionException(action)

View File

@ -21,13 +21,12 @@ from .base import (
TimeoutException, TimeoutException,
AuthRequiredException, AuthRequiredException,
) )
from yubikit.core import NotSupportedError, BadResponseError from yubikit.core import NotSupportedError, BadResponseError, InvalidPinError
from yubikit.core.smartcard import ApduError, SW from yubikit.core.smartcard import ApduError, SW
from yubikit.piv import ( from yubikit.piv import (
PivSession, PivSession,
OBJECT_ID, OBJECT_ID,
MANAGEMENT_KEY_TYPE, MANAGEMENT_KEY_TYPE,
InvalidPinError,
SLOT, SLOT,
require_version, require_version,
KEY_TYPE, KEY_TYPE,
@ -43,6 +42,7 @@ from ykman.piv import (
generate_self_signed_certificate, generate_self_signed_certificate,
generate_csr, generate_csr,
generate_chuid, generate_chuid,
parse_rfc4514_string,
) )
from ykman.util import ( from ykman.util import (
parse_certificates, parse_certificates,
@ -234,6 +234,34 @@ class PivNode(RpcNode):
def slots(self): def slots(self):
return SlotsNode(self.session) 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): def _slot_for(name):
return SLOT(int(name, base=16)) return SLOT(int(name, base=16))
@ -255,6 +283,29 @@ def _parse_file(data, password=None):
return private_key, certs 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): class SlotsNode(RpcNode):
def __init__(self, session): def __init__(self, session):
super().__init__() super().__init__()
@ -290,16 +341,7 @@ class SlotsNode(RpcNode):
slot=int(slot), slot=int(slot),
name=slot.name, name=slot.name,
has_key=metadata is not None if self._has_metadata else None, has_key=metadata is not None if self._has_metadata else None,
cert_info=dict( cert_info=_get_cert_info(cert),
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,
) )
for slot, (metadata, cert) in self._slots.items() for slot, (metadata, cert) in self._slots.items()
} }
@ -311,22 +353,6 @@ class SlotsNode(RpcNode):
return SlotNode(self.session, slot, metadata, certificate, self.refresh) return SlotNode(self.session, slot, metadata, certificate, self.refresh)
return super().create_child(name) 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): class SlotNode(RpcNode):
def __init__(self, session, slot, metadata, certificate, refresh): def __init__(self, session, slot, metadata, certificate, refresh):
@ -382,12 +408,8 @@ class SlotNode(RpcNode):
except (ApduError, BadResponseError): except (ApduError, BadResponseError):
pass pass
if certs: certificate = _choose_cert(certs)
if len(certs) > 1: if certificate:
leafs = get_leaf_certificates(certs)
certificate = leafs[0]
else:
certificate = certs[0]
self.session.put_certificate(self.slot, certificate) self.session.put_certificate(self.slot, certificate)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid()) self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
self.certificate = certificate self.certificate = certificate
@ -414,7 +436,9 @@ class SlotNode(RpcNode):
pin_policy = PIN_POLICY(params.pop("pin_policy", PIN_POLICY.DEFAULT)) pin_policy = PIN_POLICY(params.pop("pin_policy", PIN_POLICY.DEFAULT))
touch_policy = TOUCH_POLICY(params.pop("touch_policy", TOUCH_POLICY.DEFAULT)) touch_policy = TOUCH_POLICY(params.pop("touch_policy", TOUCH_POLICY.DEFAULT))
subject = params.pop("subject") 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( public_key = self.session.generate_key(
self.slot, key_type, pin_policy, touch_policy self.slot, key_type, pin_policy, touch_policy
) )

View File

@ -380,9 +380,7 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
@override @override
Future<PivExamineResult> examine(String data, {String? password}) async { Future<PivExamineResult> examine(String data, {String? password}) async {
final result = await _session.command('examine_file', target: [ final result = await _session.command('examine_file', params: {
'slots',
], params: {
'data': data, 'data': data,
'password': password, 'password': password,
}); });
@ -394,6 +392,14 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
} }
} }
@override
Future<bool> validateRfc4514(String value) async {
final result = await _session.command('validate_rfc4514', params: {
'data': value,
});
return result['status'];
}
@override @override
Future<PivImportResult> import(SlotId slot, String data, Future<PivImportResult> import(SlotId slot, String data,
{String? password, {String? password,

View File

@ -30,10 +30,12 @@
"s_unlock": "Unlock", "s_unlock": "Unlock",
"s_calculate": "Calculate", "s_calculate": "Calculate",
"s_import": "Import", "s_import": "Import",
"s_overwrite": "Overwrite",
"s_label": "Label", "s_label": "Label",
"s_name": "Name", "s_name": "Name",
"s_usb": "USB", "s_usb": "USB",
"s_nfc": "NFC", "s_nfc": "NFC",
"s_options": "Options",
"s_show_window": "Show window", "s_show_window": "Show window",
"s_hide_window": "Hide window", "s_hide_window": "Hide window",
"q_rename_target": "Rename {label}?", "q_rename_target": "Rename {label}?",
@ -48,14 +50,9 @@
"item": {} "item": {}
} }
}, },
"s_definition": "{item}:",
"@s_definition" : {
"placeholders": {
"item": {}
}
},
"s_about": "About", "s_about": "About",
"s_algorithm": "Algorithm",
"s_appearance": "Appearance", "s_appearance": "Appearance",
"s_authenticator": "Authenticator", "s_authenticator": "Authenticator",
"s_actions": "Actions", "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.", "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_management_key_changed": "Management key changed",
"l_default_key_used": "Default management key used", "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", "l_warning_default_key": "Warning: Default key used",
"s_protect_key": "Protect with PIN", "s_protect_key": "Protect with PIN",
"l_pin_protected_key": "PIN can be used instead", "l_pin_protected_key": "PIN can be used instead",
@ -457,12 +456,26 @@
}, },
"l_certificate_deleted": "Certificate deleted", "l_certificate_deleted": "Certificate deleted",
"p_password_protected_file": "The selected file is password protected. Enter the password to proceed.", "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" : { "@p_import_items_desc" : {
"placeholders": { "placeholders": {
"slot": {} "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": {}, "@_piv_slots": {},
"s_slot_display_name": "{name} ({hexid})", "s_slot_display_name": "{name} ({hexid})",

View File

@ -266,8 +266,8 @@ class PivSlot with _$PivSlot {
class PivExamineResult with _$PivExamineResult { class PivExamineResult with _$PivExamineResult {
factory PivExamineResult.result({ factory PivExamineResult.result({
required bool password, required bool password,
required bool privateKey, required KeyType? keyType,
required int certificates, required CertInfo? certInfo,
}) = _ExamineResult; }) = _ExamineResult;
factory PivExamineResult.invalidPassword() = _InvalidPassword; factory PivExamineResult.invalidPassword() = _InvalidPassword;

View File

@ -1860,20 +1860,23 @@ PivExamineResult _$PivExamineResultFromJson(Map<String, dynamic> json) {
mixin _$PivExamineResult { mixin _$PivExamineResult {
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function(bool password, bool privateKey, int certificates) required TResult Function(
bool password, KeyType? keyType, CertInfo? certInfo)
result, result,
required TResult Function() invalidPassword, required TResult Function() invalidPassword,
}) => }) =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult? Function(bool password, bool privateKey, int certificates)? result, TResult? Function(bool password, KeyType? keyType, CertInfo? certInfo)?
result,
TResult? Function()? invalidPassword, TResult? Function()? invalidPassword,
}) => }) =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function(bool password, bool privateKey, int certificates)? result, TResult Function(bool password, KeyType? keyType, CertInfo? certInfo)?
result,
TResult Function()? invalidPassword, TResult Function()? invalidPassword,
required TResult orElse(), required TResult orElse(),
}) => }) =>
@ -1924,7 +1927,9 @@ abstract class _$$_ExamineResultCopyWith<$Res> {
_$_ExamineResult value, $Res Function(_$_ExamineResult) then) = _$_ExamineResult value, $Res Function(_$_ExamineResult) then) =
__$$_ExamineResultCopyWithImpl<$Res>; __$$_ExamineResultCopyWithImpl<$Res>;
@useResult @useResult
$Res call({bool password, bool privateKey, int certificates}); $Res call({bool password, KeyType? keyType, CertInfo? certInfo});
$CertInfoCopyWith<$Res>? get certInfo;
} }
/// @nodoc /// @nodoc
@ -1939,24 +1944,36 @@ class __$$_ExamineResultCopyWithImpl<$Res>
@override @override
$Res call({ $Res call({
Object? password = null, Object? password = null,
Object? privateKey = null, Object? keyType = freezed,
Object? certificates = null, Object? certInfo = freezed,
}) { }) {
return _then(_$_ExamineResult( return _then(_$_ExamineResult(
password: null == password password: null == password
? _value.password ? _value.password
: password // ignore: cast_nullable_to_non_nullable : password // ignore: cast_nullable_to_non_nullable
as bool, as bool,
privateKey: null == privateKey keyType: freezed == keyType
? _value.privateKey ? _value.keyType
: privateKey // ignore: cast_nullable_to_non_nullable : keyType // ignore: cast_nullable_to_non_nullable
as bool, as KeyType?,
certificates: null == certificates certInfo: freezed == certInfo
? _value.certificates ? _value.certInfo
: certificates // ignore: cast_nullable_to_non_nullable : certInfo // ignore: cast_nullable_to_non_nullable
as int, 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 /// @nodoc
@ -1964,8 +1981,8 @@ class __$$_ExamineResultCopyWithImpl<$Res>
class _$_ExamineResult implements _ExamineResult { class _$_ExamineResult implements _ExamineResult {
_$_ExamineResult( _$_ExamineResult(
{required this.password, {required this.password,
required this.privateKey, required this.keyType,
required this.certificates, required this.certInfo,
final String? $type}) final String? $type})
: $type = $type ?? 'result'; : $type = $type ?? 'result';
@ -1975,16 +1992,16 @@ class _$_ExamineResult implements _ExamineResult {
@override @override
final bool password; final bool password;
@override @override
final bool privateKey; final KeyType? keyType;
@override @override
final int certificates; final CertInfo? certInfo;
@JsonKey(name: 'runtimeType') @JsonKey(name: 'runtimeType')
final String $type; final String $type;
@override @override
String toString() { String toString() {
return 'PivExamineResult.result(password: $password, privateKey: $privateKey, certificates: $certificates)'; return 'PivExamineResult.result(password: $password, keyType: $keyType, certInfo: $certInfo)';
} }
@override @override
@ -1994,16 +2011,14 @@ class _$_ExamineResult implements _ExamineResult {
other is _$_ExamineResult && other is _$_ExamineResult &&
(identical(other.password, password) || (identical(other.password, password) ||
other.password == password) && other.password == password) &&
(identical(other.privateKey, privateKey) || (identical(other.keyType, keyType) || other.keyType == keyType) &&
other.privateKey == privateKey) && (identical(other.certInfo, certInfo) ||
(identical(other.certificates, certificates) || other.certInfo == certInfo));
other.certificates == certificates));
} }
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
int get hashCode => int get hashCode => Object.hash(runtimeType, password, keyType, certInfo);
Object.hash(runtimeType, password, privateKey, certificates);
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
@ -2014,31 +2029,34 @@ class _$_ExamineResult implements _ExamineResult {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function(bool password, bool privateKey, int certificates) required TResult Function(
bool password, KeyType? keyType, CertInfo? certInfo)
result, result,
required TResult Function() invalidPassword, required TResult Function() invalidPassword,
}) { }) {
return result(password, privateKey, certificates); return result(password, keyType, certInfo);
} }
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult? Function(bool password, bool privateKey, int certificates)? result, TResult? Function(bool password, KeyType? keyType, CertInfo? certInfo)?
result,
TResult? Function()? invalidPassword, TResult? Function()? invalidPassword,
}) { }) {
return result?.call(password, privateKey, certificates); return result?.call(password, keyType, certInfo);
} }
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function(bool password, bool privateKey, int certificates)? result, TResult Function(bool password, KeyType? keyType, CertInfo? certInfo)?
result,
TResult Function()? invalidPassword, TResult Function()? invalidPassword,
required TResult orElse(), required TResult orElse(),
}) { }) {
if (result != null) { if (result != null) {
return result(password, privateKey, certificates); return result(password, keyType, certInfo);
} }
return orElse(); return orElse();
} }
@ -2085,15 +2103,15 @@ class _$_ExamineResult implements _ExamineResult {
abstract class _ExamineResult implements PivExamineResult { abstract class _ExamineResult implements PivExamineResult {
factory _ExamineResult( factory _ExamineResult(
{required final bool password, {required final bool password,
required final bool privateKey, required final KeyType? keyType,
required final int certificates}) = _$_ExamineResult; required final CertInfo? certInfo}) = _$_ExamineResult;
factory _ExamineResult.fromJson(Map<String, dynamic> json) = factory _ExamineResult.fromJson(Map<String, dynamic> json) =
_$_ExamineResult.fromJson; _$_ExamineResult.fromJson;
bool get password; bool get password;
bool get privateKey; KeyType? get keyType;
int get certificates; CertInfo? get certInfo;
@JsonKey(ignore: true) @JsonKey(ignore: true)
_$$_ExamineResultCopyWith<_$_ExamineResult> get copyWith => _$$_ExamineResultCopyWith<_$_ExamineResult> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@ -2145,7 +2163,8 @@ class _$_InvalidPassword implements _InvalidPassword {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult when<TResult extends Object?>({ TResult when<TResult extends Object?>({
required TResult Function(bool password, bool privateKey, int certificates) required TResult Function(
bool password, KeyType? keyType, CertInfo? certInfo)
result, result,
required TResult Function() invalidPassword, required TResult Function() invalidPassword,
}) { }) {
@ -2155,7 +2174,8 @@ class _$_InvalidPassword implements _InvalidPassword {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({ TResult? whenOrNull<TResult extends Object?>({
TResult? Function(bool password, bool privateKey, int certificates)? result, TResult? Function(bool password, KeyType? keyType, CertInfo? certInfo)?
result,
TResult? Function()? invalidPassword, TResult? Function()? invalidPassword,
}) { }) {
return invalidPassword?.call(); return invalidPassword?.call();
@ -2164,7 +2184,8 @@ class _$_InvalidPassword implements _InvalidPassword {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({ TResult maybeWhen<TResult extends Object?>({
TResult Function(bool password, bool privateKey, int certificates)? result, TResult Function(bool password, KeyType? keyType, CertInfo? certInfo)?
result,
TResult Function()? invalidPassword, TResult Function()? invalidPassword,
required TResult orElse(), required TResult orElse(),
}) { }) {

View File

@ -168,16 +168,18 @@ const _$SlotIdEnumMap = {
_$_ExamineResult _$$_ExamineResultFromJson(Map<String, dynamic> json) => _$_ExamineResult _$$_ExamineResultFromJson(Map<String, dynamic> json) =>
_$_ExamineResult( _$_ExamineResult(
password: json['password'] as bool, password: json['password'] as bool,
privateKey: json['private_key'] as bool, keyType: $enumDecodeNullable(_$KeyTypeEnumMap, json['key_type']),
certificates: json['certificates'] as int, certInfo: json['cert_info'] == null
? null
: CertInfo.fromJson(json['cert_info'] as Map<String, dynamic>),
$type: json['runtimeType'] as String?, $type: json['runtimeType'] as String?,
); );
Map<String, dynamic> _$$_ExamineResultToJson(_$_ExamineResult instance) => Map<String, dynamic> _$$_ExamineResultToJson(_$_ExamineResult instance) =>
<String, dynamic>{ <String, dynamic>{
'password': instance.password, 'password': instance.password,
'private_key': instance.privateKey, 'key_type': _$KeyTypeEnumMap[instance.keyType],
'certificates': instance.certificates, 'cert_info': instance.certInfo,
'runtimeType': instance.$type, 'runtimeType': instance.$type,
}; };

View File

@ -50,6 +50,7 @@ final pivSlotsProvider = AsyncNotifierProvider.autoDispose
abstract class PivSlotsNotifier abstract class PivSlotsNotifier
extends AutoDisposeFamilyAsyncNotifier<List<PivSlot>, DevicePath> { extends AutoDisposeFamilyAsyncNotifier<List<PivSlot>, DevicePath> {
Future<PivExamineResult> examine(String data, {String? password}); Future<PivExamineResult> examine(String data, {String? password});
Future<bool> validateRfc4514(String value);
Future<(SlotMetadata?, String?)> read(SlotId slot); Future<(SlotMetadata?, String?)> read(SlotId slot);
Future<PivGenerateResult> generate( Future<PivGenerateResult> generate(
SlotId slot, SlotId slot,

View File

@ -47,23 +47,23 @@ class ExportIntent extends Intent {
} }
Future<bool> _authenticate( Future<bool> _authenticate(
WidgetRef ref, DevicePath devicePath, PivState pivState) async { BuildContext context, DevicePath devicePath, PivState pivState) async {
final withContext = ref.read(withContextProvider); return await showBlurDialog(
return await withContext((context) async =>
await showBlurDialog(
context: context, context: context,
builder: (context) => AuthenticationDialog( builder: (context) => pivState.protectedKey
devicePath, ? PinDialog(devicePath)
pivState, : AuthenticationDialog(
), devicePath,
pivState,
),
) ?? ) ??
false); false;
} }
Future<bool> _authIfNeeded( Future<bool> _authIfNeeded(
WidgetRef ref, DevicePath devicePath, PivState pivState) async { BuildContext context, DevicePath devicePath, PivState pivState) async {
if (pivState.needsAuth) { if (pivState.needsAuth) {
return await _authenticate(ref, devicePath, pivState); return await _authenticate(context, devicePath, pivState);
} }
return true; return true;
} }
@ -80,13 +80,13 @@ Widget registerPivActions(
actions: { actions: {
GenerateIntent: GenerateIntent:
CallbackAction<GenerateIntent>(onInvoke: (intent) async { CallbackAction<GenerateIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
if (!pivState.protectedKey && if (!pivState.protectedKey &&
!await _authIfNeeded(ref, devicePath, pivState)) { !await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
return false; return false;
} }
final withContext = ref.read(withContextProvider);
// TODO: Avoid asking for PIN if not needed? // TODO: Avoid asking for PIN if not needed?
final verified = await withContext((context) async => final verified = await withContext((context) async =>
await showBlurDialog( await showBlurDialog(
@ -130,12 +130,13 @@ Widget registerPivActions(
}); });
}), }),
ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async { ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async {
if (!await _authIfNeeded(ref, devicePath, pivState)) { final withContext = ref.read(withContextProvider);
if (!await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
return false; return false;
} }
final withContext = ref.read(withContextProvider);
final picked = await withContext( final picked = await withContext(
(context) async { (context) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@ -198,10 +199,12 @@ Widget registerPivActions(
return true; return true;
}), }),
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async { DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
if (!await _authIfNeeded(ref, devicePath, pivState)) { final withContext = ref.read(withContextProvider);
if (!await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
return false; return false;
} }
final withContext = ref.read(withContextProvider);
final bool? deleted = await withContext((context) async => final bool? deleted = await withContext((context) async =>
await showBlurDialog( await showBlurDialog(
context: context, context: context,

View File

@ -37,12 +37,20 @@ class AuthenticationDialog extends ConsumerStatefulWidget {
} }
class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> { class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
String _managementKey = ''; bool _defaultKeyUsed = false;
bool _keyIsWrong = false; bool _keyIsWrong = false;
final _keyController = TextEditingController();
@override
void dispose() {
_keyController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasMetadata = widget.pivState.metadata != null;
final keyLen = (widget.pivState.metadata?.managementKeyMetadata.keyType ?? final keyLen = (widget.pivState.metadata?.managementKeyMetadata.keyType ??
ManagementKeyType.tdes) ManagementKeyType.tdes)
.keyLength * .keyLength *
@ -52,13 +60,13 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
actions: [ actions: [
TextButton( TextButton(
key: keys.unlockButton, key: keys.unlockButton,
onPressed: _managementKey.length == keyLen onPressed: _keyController.text.length == keyLen
? () async { ? () async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
try { try {
final status = await ref final status = await ref
.read(pivStateProvider(widget.devicePath).notifier) .read(pivStateProvider(widget.devicePath).notifier)
.authenticate(_managementKey); .authenticate(_keyController.text);
if (status) { if (status) {
navigator.pop(true); navigator.pop(true);
} else { } else {
@ -88,24 +96,44 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
TextField( TextField(
key: keys.managementKeyField, key: keys.managementKeyField,
autofocus: true, autofocus: true,
maxLength: keyLen,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
controller: _keyController,
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow( FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false)) RegExp('[a-f0-9]', caseSensitive: false))
], ],
readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? keyLen : null,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_management_key, labelText: l10n.s_management_key,
prefixIcon: const Icon(Icons.key_outlined), prefixIcon: const Icon(Icons.key_outlined),
errorText: _keyIsWrong ? l10n.l_wrong_key : null, errorText: _keyIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3, 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, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_keyIsWrong = false; _keyIsWrong = false;
_managementKey = value;
}); });
}, },
), ),

View File

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

View File

@ -27,6 +27,7 @@ import '../../widgets/responsive_dialog.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import 'overwrite_confirm_dialog.dart';
class GenerateKeyDialog extends ConsumerStatefulWidget { class GenerateKeyDialog extends ConsumerStatefulWidget {
final DevicePath devicePath; final DevicePath devicePath;
@ -42,6 +43,7 @@ class GenerateKeyDialog extends ConsumerStatefulWidget {
class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> { class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
String _subject = ''; String _subject = '';
bool _invalidSubject = true;
GenerateType _generateType = defaultGenerateType; GenerateType _generateType = defaultGenerateType;
KeyType _keyType = defaultKeyType; KeyType _keyType = defaultKeyType;
late DateTime _validFrom; late DateTime _validFrom;
@ -64,6 +66,11 @@ class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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( return ResponsiveDialog(
allowCancel: !_generating, allowCancel: !_generating,
@ -71,36 +78,56 @@ class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
actions: [ actions: [
TextButton( TextButton(
key: keys.saveButton, key: keys.saveButton,
onPressed: _generating || _subject.isEmpty onPressed: _generating || _invalidSubject
? null ? null
: () async { : () async {
if (!await confirmOverwrite(
context,
widget.pivSlot,
writeKey: true,
writeCert: _generateType == GenerateType.certificate,
)) {
return;
}
setState(() { setState(() {
_generating = true; _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; final PivGenerateResult result;
try { try {
close = showMessage( close = await withContext<void Function()>(
context, (context) async => showMessage(
l10n.l_generating_private_key, context,
duration: const Duration(seconds: 30), 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 { } finally {
close?.call(); close?.call();
} }
@ -123,43 +150,45 @@ class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
l10n.p_generate_desc(widget.pivSlot.slot.getDisplayName(l10n))),
Text(
l10n.s_subject,
style: textTheme.bodyLarge,
),
Text(l10n.p_subject_desc),
TextField( TextField(
autofocus: true, autofocus: true,
key: keys.subjectField, key: keys.subjectField,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_subject, labelText: l10n.s_subject,
), errorText: _subject.isNotEmpty && _invalidSubject
? l10n.l_rfc4514_invalid
: null),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
enabled: !_generating, enabled: !_generating,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
if (value.isEmpty) { _invalidSubject = value.isEmpty;
_subject = ''; _subject = value;
} else {
_subject = value.contains('=') ? value : 'CN=$value';
}
}); });
}, },
), ),
Text(
l10n.rfc4514_examples,
style: subtitleStyle,
),
Text(
l10n.s_options,
style: textTheme.bodyLarge,
),
Text(l10n.p_cert_options_desc),
Wrap( Wrap(
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0, spacing: 4.0,
runSpacing: 8.0, runSpacing: 8.0,
children: [ children: [
ChoiceFilterChip<GenerateType>(
items: GenerateType.values,
value: _generateType,
selected: _generateType != defaultGenerateType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: _generating
? null
: (value) {
setState(() {
_generateType = value;
});
},
),
ChoiceFilterChip<KeyType>( ChoiceFilterChip<KeyType>(
items: KeyType.values, items: KeyType.values,
value: _keyType, value: _keyType,
@ -173,6 +202,19 @@ class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
}); });
}, },
), ),
ChoiceFilterChip<GenerateType>(
items: GenerateType.values,
value: _generateType,
selected: _generateType != defaultGenerateType,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
onChanged: _generating
? null
: (value) {
setState(() {
_generateType = value;
});
},
),
if (_generateType == GenerateType.certificate) if (_generateType == GenerateType.certificate)
FilterChip( FilterChip(
label: Text(dateFormatter.format(_validTo)), label: Text(dateFormatter.format(_validTo)),

View File

@ -25,6 +25,8 @@ import '../../widgets/responsive_dialog.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import 'cert_info_view.dart';
import 'overwrite_confirm_dialog.dart';
class ImportFileDialog extends ConsumerStatefulWidget { class ImportFileDialog extends ConsumerStatefulWidget {
final DevicePath devicePath; final DevicePath devicePath;
@ -77,6 +79,11 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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; final state = _state;
if (state == null) { if (state == null) {
return ResponsiveDialog( return ResponsiveDialog(
@ -141,26 +148,39 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
), ),
), ),
), ),
result: (_, privateKey, certificates) => ResponsiveDialog( result: (_, keyType, certInfo) => ResponsiveDialog(
title: Text(l10n.l_import_file), title: Text(l10n.l_import_file),
actions: [ actions: [
TextButton( TextButton(
key: keys.unlockButton, key: keys.unlockButton,
onPressed: () async { onPressed: (keyType == null && certInfo == null)
final navigator = Navigator.of(context); ? null
try { : () async {
await ref final navigator = Navigator.of(context);
.read(pivSlotsProvider(widget.devicePath).notifier)
.import(widget.pivSlot.slot, _data, if (!await confirmOverwrite(
password: _password.isNotEmpty ? _password : null); context,
navigator.pop(true); widget.pivSlot,
} catch (_) { writeKey: keyType != null,
// TODO: More error cases writeCert: certInfo != null,
setState(() { )) {
_passwordIsWrong = true; 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), child: Text(l10n.s_import),
), ),
], ],
@ -171,8 +191,37 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
children: [ children: [
Text(l10n.p_import_items_desc( Text(l10n.p_import_items_desc(
widget.pivSlot.slot.getDisplayName(l10n))), widget.pivSlot.slot.getDisplayName(l10n))),
if (privateKey) Text(l10n.l_bullet(l10n.s_private_key)), if (keyType != null) ...[
if (certificates > 0) Text(l10n.l_bullet(l10n.s_certificate)), 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( .map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -42,24 +42,26 @@ class ManageKeyDialog extends ConsumerStatefulWidget {
} }
class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> { class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
late bool _hasMetadata;
late bool _defaultKeyUsed; late bool _defaultKeyUsed;
late bool _usesStoredKey; late bool _usesStoredKey;
late bool _storeKey; late bool _storeKey;
String _currentKeyOrPin = '';
bool _currentIsWrong = false; bool _currentIsWrong = false;
int _attemptsRemaining = -1; int _attemptsRemaining = -1;
ManagementKeyType _keyType = ManagementKeyType.tdes; ManagementKeyType _keyType = ManagementKeyType.tdes;
final _currentController = TextEditingController();
final _keyController = TextEditingController(); final _keyController = TextEditingController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_hasMetadata = widget.pivState.metadata != null;
_defaultKeyUsed = _defaultKeyUsed =
widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false; widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false;
_usesStoredKey = widget.pivState.protectedKey; _usesStoredKey = widget.pivState.protectedKey;
if (!_usesStoredKey && _defaultKeyUsed) { if (!_usesStoredKey && _defaultKeyUsed) {
_currentKeyOrPin = defaultManagementKey; _currentController.text = defaultManagementKey;
} }
_storeKey = _usesStoredKey; _storeKey = _usesStoredKey;
} }
@ -67,13 +69,14 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
@override @override
void dispose() { void dispose() {
_keyController.dispose(); _keyController.dispose();
_currentController.dispose();
super.dispose(); super.dispose();
} }
_submit() async { _submit() async {
final notifier = ref.read(pivStateProvider(widget.path).notifier); final notifier = ref.read(pivStateProvider(widget.path).notifier);
if (_usesStoredKey) { if (_usesStoredKey) {
final status = (await notifier.verifyPin(_currentKeyOrPin)).when( final status = (await notifier.verifyPin(_currentController.text)).when(
success: () => true, success: () => true,
failure: (attemptsRemaining) { failure: (attemptsRemaining) {
setState(() { setState(() {
@ -87,7 +90,7 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
return; return;
} }
} else { } else {
if (!await notifier.authenticate(_currentKeyOrPin)) { if (!await notifier.authenticate(_currentController.text)) {
setState(() { setState(() {
_currentIsWrong = true; _currentIsWrong = true;
}); });
@ -126,9 +129,10 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
ManagementKeyType.tdes; ManagementKeyType.tdes;
final hexLength = _keyType.keyLength * 2; final hexLength = _keyType.keyLength * 2;
final protected = widget.pivState.protectedKey; final protected = widget.pivState.protectedKey;
final currentKeyOrPin = _currentController.text;
final currentLenOk = protected final currentLenOk = protected
? _currentKeyOrPin.length >= 4 ? currentKeyOrPin.length >= 4
: _currentKeyOrPin.length == currentType.keyLength * 2; : currentKeyOrPin.length == currentType.keyLength * 2;
final newLenOk = _keyController.text.length == hexLength; final newLenOk = _keyController.text.length == hexLength;
return ResponsiveDialog( return ResponsiveDialog(
@ -153,6 +157,7 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
key: keys.pinPukField, key: keys.pinPukField,
maxLength: 8, maxLength: 8,
controller: _currentController,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_pin, labelText: l10n.s_pin,
@ -166,7 +171,6 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_currentIsWrong = false; _currentIsWrong = false;
_currentKeyOrPin = value;
}); });
}, },
), ),
@ -175,16 +179,34 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
key: keys.managementKeyField, key: keys.managementKeyField,
autofocus: !_defaultKeyUsed, autofocus: !_defaultKeyUsed,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
initialValue: _defaultKeyUsed ? defaultManagementKey : null, controller: _currentController,
readOnly: _defaultKeyUsed, readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null, maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_current_management_key, 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, errorText: _currentIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3, errorMaxLines: 3,
helperText: _defaultKeyUsed ? l10n.l_default_key_used : null, 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: <TextInputFormatter>[ inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow( FilteringTextInputFormatter.allow(
@ -194,7 +216,6 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_currentIsWrong = false; _currentIsWrong = false;
_currentKeyOrPin = value;
}); });
}, },
), ),
@ -211,10 +232,11 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_new_management_key, labelText: l10n.s_new_management_key,
prefixIcon: const Icon(Icons.password_outlined), prefixIcon: const Icon(Icons.key_outlined),
enabled: currentLenOk, enabled: currentLenOk,
suffixIcon: IconButton( suffixIcon: IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
tooltip: l10n.s_generate_random,
onPressed: currentLenOk onPressed: currentLenOk
? () { ? () {
final random = Random.secure(); final random = Random.secure();

View File

@ -114,7 +114,7 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
: l10n.s_current_puk, : l10n.s_current_puk,
prefixIcon: const Icon(Icons.password_outlined), prefixIcon: const Icon(Icons.password_outlined),
errorText: _currentIsWrong errorText: _currentIsWrong
? (widget.target == ManageTarget.puk ? (widget.target == ManageTarget.pin
? l10n.l_wrong_pin_attempts_remaining( ? l10n.l_wrong_pin_attempts_remaining(
_attemptsRemaining) _attemptsRemaining)
: l10n.l_wrong_puk_attempts_remaining( : l10n.l_wrong_puk_attempts_remaining(

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 '../../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<bool> 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;
}

View File

@ -33,22 +33,30 @@ class PinDialog extends ConsumerStatefulWidget {
} }
class _PinDialogState extends ConsumerState<PinDialog> { class _PinDialogState extends ConsumerState<PinDialog> {
String _pin = ''; final _pinController = TextEditingController();
bool _pinIsWrong = false; bool _pinIsWrong = false;
int _attemptsRemaining = -1; int _attemptsRemaining = -1;
bool _isObscure = true;
@override
void dispose() {
_pinController.dispose();
super.dispose();
}
Future<void> _submit() async { Future<void> _submit() async {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
try { try {
final status = await ref final status = await ref
.read(pivStateProvider(widget.devicePath).notifier) .read(pivStateProvider(widget.devicePath).notifier)
.verifyPin(_pin); .verifyPin(_pinController.text);
status.when( status.when(
success: () { success: () {
navigator.pop(true); navigator.pop(true);
}, },
failure: (attemptsRemaining) { failure: (attemptsRemaining) {
setState(() { setState(() {
_pinController.clear();
_attemptsRemaining = attemptsRemaining; _attemptsRemaining = attemptsRemaining;
_pinIsWrong = true; _pinIsWrong = true;
}); });
@ -67,7 +75,7 @@ class _PinDialogState extends ConsumerState<PinDialog> {
actions: [ actions: [
TextButton( TextButton(
key: keys.unlockButton, key: keys.unlockButton,
onPressed: _pin.length >= 4 ? _submit : null, onPressed: _pinController.text.length >= 4 ? _submit : null,
child: Text(l10n.s_unlock), child: Text(l10n.s_unlock),
), ),
], ],
@ -79,23 +87,35 @@ class _PinDialogState extends ConsumerState<PinDialog> {
Text(l10n.p_pin_required_desc), Text(l10n.p_pin_required_desc),
TextField( TextField(
autofocus: true, autofocus: true,
obscureText: true, obscureText: _isObscure,
maxLength: 8, maxLength: 8,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
key: keys.managementKeyField, key: keys.managementKeyField,
controller: _pinController,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_pin, labelText: l10n.s_pin,
prefixIcon: const Icon(Icons.pin_outlined), prefixIcon: const Icon(Icons.pin_outlined),
errorText: _pinIsWrong errorText: _pinIsWrong
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: null, : null,
errorMaxLines: 3), errorMaxLines: 3,
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color,
),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
),
),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_pinIsWrong = false; _pinIsWrong = false;
_pin = value;
}); });
}, },
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),

View File

@ -105,7 +105,8 @@ class _CertificateListItem extends StatelessWidget {
), ),
title: slot.getDisplayName(l10n), title: slot.getDisplayName(l10n),
subtitle: certInfo != null subtitle: certInfo != null
? certInfo.subject // Simplify subtitle by stripping "CN=", etc.
? certInfo.subject.replaceAll(RegExp(r'[A-Z]+='), ' ').trimLeft()
: pivSlot.hasKey == true : pivSlot.hasKey == true
? l10n.l_key_no_certificate ? l10n.l_key_no_certificate
: l10n.l_no_certificate, : l10n.l_no_certificate,

View File

@ -17,16 +17,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../app/message.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/views/fs_dialog.dart'; import '../../app/views/fs_dialog.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../../widgets/tooltip_if_truncated.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'actions.dart'; import 'actions.dart';
import 'cert_info_view.dart';
class SlotDialog extends ConsumerWidget { class SlotDialog extends ConsumerWidget {
final SlotId pivSlot; final SlotId pivSlot;
@ -48,8 +46,6 @@ class SlotDialog extends ConsumerWidget {
final subtitleStyle = textTheme.bodyMedium!.copyWith( final subtitleStyle = textTheme.bodyMedium!.copyWith(
color: textTheme.bodySmall!.color, color: textTheme.bodySmall!.color,
); );
final clipboard = ref.watch(clipboardProvider);
final withContext = ref.read(withContextProvider);
final pivState = ref.watch(pivStateProvider(node.path)).valueOrNull; final pivState = ref.watch(pivStateProvider(node.path)).valueOrNull;
final slotData = ref.watch(pivSlotsProvider(node.path).select((value) => final slotData = ref.watch(pivSlotsProvider(node.path).select((value) =>
@ -61,32 +57,6 @@ class SlotDialog extends ConsumerWidget {
return const FsDialog(child: CircularProgressIndicator()); 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; final certInfo = slotData.certInfo;
return registerPivActions( return registerPivActions(
node.path, node.path,
@ -111,27 +81,7 @@ class SlotDialog extends ConsumerWidget {
if (certInfo != null) ...[ if (certInfo != null) ...[
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Table( child: CertInfoTable(certInfo),
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)),
),
],
),
), ),
] else ...[ ] else ...[
Padding( Padding(

View File

@ -78,7 +78,7 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
scrollable: true, scrollable: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8), contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: SizedBox( content: SizedBox(
width: 380, width: 550,
child: Container(key: _childKey, child: widget.child), child: Container(key: _childKey, child: widget.child),
), ),
actions: [ actions: [
@ -107,7 +107,7 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
_hasLostFocus = true; _hasLostFocus = true;
} }
}, },
child: constraints.maxWidth < 540 child: constraints.maxWidth < 400
? _buildFullscreen(context) ? _buildFullscreen(context)
: _buildDialog(context), : _buildDialog(context),
); );

View File

@ -19,8 +19,9 @@ import 'package:flutter/material.dart';
class TooltipIfTruncated extends StatelessWidget { class TooltipIfTruncated extends StatelessWidget {
final String text; final String text;
final TextStyle style; final TextStyle style;
final String? tooltip;
const TooltipIfTruncated( const TooltipIfTruncated(
{super.key, required this.text, required this.style}); {super.key, required this.text, required this.style, this.tooltip});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -41,7 +42,7 @@ class TooltipIfTruncated extends StatelessWidget {
return textPainter.didExceedMaxLines return textPainter.didExceedMaxLines
? Tooltip( ? Tooltip(
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
message: text, message: tooltip ?? text,
child: textWidget, child: textWidget,
) )
: textWidget; : textWidget;