Merge branch 'main' into adamve/android_fido

This commit is contained in:
Adam Velebil 2024-03-13 16:01:12 +01:00
commit 0310919607
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
46 changed files with 526 additions and 313 deletions

View File

@ -56,9 +56,9 @@ Future<Widget> initialize() async {
return ProviderScope(
overrides: [
supportedAppsProvider.overrideWith(
supportedSectionsProvider.overrideWith(
(ref) {
return [Application.home, Application.accounts, Application.passkeys];
return [Section.home, Section.accounts, Section.passkeys];
},
),
prefProvider.overrideWithValue(await SharedPreferences.getInstance()),
@ -72,10 +72,10 @@ Future<Widget> initialize() async {
oathStateProvider.overrideWithProvider(androidOathStateProvider.call),
credentialListProvider
.overrideWithProvider(androidCredentialListProvider.call),
currentAppProvider.overrideWith((ref) {
final notifier = AndroidSubPageNotifier(
currentSectionProvider.overrideWith((ref) {
final notifier = AndroidSectionNotifier(
ref,
ref.watch(supportedAppsProvider),
ref.watch(supportedSectionsProvider),
ref.watch(prefProvider),
);
ref.listen<AsyncValue<YubiKeyData>>(currentDeviceDataProvider,

View File

@ -91,25 +91,25 @@ final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) {
});
class _AndroidAppContextHandler {
Future<void> switchAppContext(Application subPage) async {
await _contextChannel.invokeMethod('setContext', {'index': subPage.index});
Future<void> switchAppContext(Section section) async {
await _contextChannel.invokeMethod('setContext', {'index': section.index});
}
}
final androidAppContextHandler =
Provider<_AndroidAppContextHandler>((ref) => _AndroidAppContextHandler());
class AndroidSubPageNotifier extends CurrentAppNotifier {
final StateNotifierProviderRef<CurrentAppNotifier, Application> _ref;
class AndroidSectionNotifier extends CurrentSectionNotifier {
final StateNotifierProviderRef<CurrentSectionNotifier, Section> _ref;
AndroidSubPageNotifier(this._ref, super.supportedApps, super.prefs) {
AndroidSectionNotifier(this._ref, super._supportedSections, super.prefs) {
_ref.read(androidAppContextHandler).switchAppContext(state);
}
@override
void setCurrentApp(Application app) {
super.setCurrentApp(app);
_ref.read(androidAppContextHandler).switchAppContext(app);
void setCurrentSection(Section section) {
super.setCurrentSection(section);
_ref.read(androidAppContextHandler).switchAppContext(section);
}
@override

View File

@ -16,11 +16,12 @@
import '../core/state.dart';
final home = root.feature('home');
final oath = root.feature('oath');
final fido = root.feature('fido');
final piv = root.feature('piv');
final otp = root.feature('otp');
final management = root.feature('management');
final home = root.feature('home');
final fingerprints = fido.feature('fingerprints');

View File

@ -31,43 +31,32 @@ const _listEquality = ListEquality();
enum Availability { enabled, disabled, unsupported }
enum Application {
enum Section {
home(),
accounts([Capability.oath]),
webauthn([Capability.u2f]),
securityKey([Capability.u2f]),
fingerprints([Capability.fido2]),
passkeys([Capability.fido2]),
certificates([Capability.piv]),
slots([Capability.otp]),
management();
slots([Capability.otp]);
final List<Capability> capabilities;
const Application([this.capabilities = const []]);
const Section([this.capabilities = const []]);
String getDisplayName(AppLocalizations l10n) => switch (this) {
Application.home => l10n.s_home,
Application.accounts => l10n.s_accounts,
Application.webauthn => l10n.s_webauthn,
Application.fingerprints => l10n.s_fingerprints,
Application.passkeys => l10n.s_passkeys,
Application.certificates => l10n.s_certificates,
Application.slots => l10n.s_slots,
_ => name.substring(0, 1).toUpperCase() + name.substring(1),
Section.home => l10n.s_home,
Section.accounts => l10n.s_accounts,
Section.securityKey => l10n.s_security_key,
Section.fingerprints => l10n.s_fingerprints,
Section.passkeys => l10n.s_passkeys,
Section.certificates => l10n.s_certificates,
Section.slots => l10n.s_slots,
};
Availability getAvailability(YubiKeyData data) {
if (this == Application.management) {
final version = data.info.version;
final available = (version.major > 4 || // YK5 and up
(version.major == 4 && version.minor >= 1) || // YK4.1 and up
version.major == 3); // NEO
// Management can't be disabled
return available ? Availability.enabled : Availability.unsupported;
}
// TODO: Require credman for passkeys?
if (this == Application.fingerprints) {
if (this == Section.fingerprints) {
if (!const {FormFactor.usbABio, FormFactor.usbCBio}
.contains(data.info.formFactor)) {
return Availability.unsupported;
@ -79,8 +68,8 @@ enum Application {
final int enabled =
data.info.config.enabledCapabilities[data.node.transport] ?? 0;
// Don't show WebAuthn if we have FIDO2
if (this == Application.webauthn &&
// Don't show securityKey if we have FIDO2
if (this == Section.securityKey &&
Capability.fido2.value & supported != 0) {
return Availability.unsupported;
}

View File

@ -38,23 +38,24 @@ const officialLocales = [
Locale('en', ''),
];
extension on Application {
extension on Section {
Feature get _feature => switch (this) {
Application.home => features.home,
Application.accounts => features.oath,
Application.webauthn => features.fido,
Application.passkeys => features.fido,
Application.fingerprints => features.fingerprints,
Application.slots => features.otp,
Application.certificates => features.piv,
Application.management => features.management,
Section.home => features.home,
Section.accounts => features.oath,
Section.securityKey => features.fido,
Section.passkeys => features.fido,
Section.fingerprints => features.fingerprints,
Section.slots => features.otp,
Section.certificates => features.piv,
};
}
final supportedAppsProvider = Provider<List<Application>>(
final supportedSectionsProvider = Provider<List<Section>>(
(ref) {
final hasFeature = ref.watch(featureProvider);
return Application.values.where((app) => hasFeature(app._feature)).toList();
return Section.values
.where((section) => hasFeature(section._feature))
.toList();
},
);
@ -201,62 +202,62 @@ abstract class CurrentDeviceNotifier extends Notifier<DeviceNode?> {
setCurrentDevice(DeviceNode? device);
}
final currentAppProvider =
StateNotifierProvider<CurrentAppNotifier, Application>((ref) {
final notifier = CurrentAppNotifier(
ref.watch(supportedAppsProvider), ref.watch(prefProvider));
final currentSectionProvider =
StateNotifierProvider<CurrentSectionNotifier, Section>((ref) {
final notifier = CurrentSectionNotifier(
ref.watch(supportedSectionsProvider), ref.watch(prefProvider));
ref.listen<AsyncValue<YubiKeyData>>(currentDeviceDataProvider, (_, data) {
notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data)));
}, fireImmediately: true);
return notifier;
});
class CurrentAppNotifier extends StateNotifier<Application> {
final List<Application> _supportedApps;
static const String _key = 'APP_STATE_LAST_APP';
class CurrentSectionNotifier extends StateNotifier<Section> {
final List<Section> _supportedSections;
static const String _key = 'APP_STATE_LAST_SECTION';
final SharedPreferences _prefs;
CurrentAppNotifier(this._supportedApps, this._prefs)
: super(_fromName(_prefs.getString(_key), _supportedApps));
CurrentSectionNotifier(this._supportedSections, this._prefs)
: super(_fromName(_prefs.getString(_key), _supportedSections));
void setCurrentApp(Application app) {
state = app;
_prefs.setString(_key, app.name);
void setCurrentSection(Section section) {
state = section;
_prefs.setString(_key, section.name);
}
void notifyDeviceChanged(YubiKeyData? data) {
if (data == null) {
state = _supportedApps.first;
state = _supportedSections.first;
return;
}
String? lastAppName = _prefs.getString(_key);
if (lastAppName != null && lastAppName != state.name) {
// Try switching to saved app
state = Application.values.firstWhere((app) => app.name == lastAppName);
state = Section.values.firstWhere((app) => app.name == lastAppName);
}
if (state == Application.passkeys &&
if (state == Section.passkeys &&
state.getAvailability(data) != Availability.enabled) {
state = Application.webauthn;
state = Section.securityKey;
}
if (state == Application.webauthn &&
if (state == Section.securityKey &&
state.getAvailability(data) != Availability.enabled) {
state = Application.passkeys;
state = Section.passkeys;
}
if (state.getAvailability(data) != Availability.unsupported) {
// Keep current app
return;
}
state = _supportedApps.firstWhere(
state = _supportedSections.firstWhere(
(app) => app.getAvailability(data) == Availability.enabled,
orElse: () => _supportedApps.first,
orElse: () => _supportedSections.first,
);
}
static Application _fromName(String? name, List<Application> supportedApps) =>
supportedApps.firstWhere((element) => element.name == name,
orElse: () => supportedApps.first);
static Section _fromName(String? name, List<Section> supportedSections) =>
supportedSections.firstWhere((element) => element.name == name,
orElse: () => supportedSections.first);
}
abstract class QrScanner {

View File

@ -64,9 +64,9 @@ class AppFailurePage extends ConsumerWidget {
case 'fido':
if (Platform.isWindows &&
!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
final currentApp = ref.read(currentAppProvider);
title = currentApp.getDisplayName(l10n);
capabilities = currentApp.capabilities;
final currentSection = ref.read(currentSectionProvider);
title = currentSection.getDisplayName(l10n);
capabilities = currentSection.capabilities;
header = l10n.l_admin_privileges_required;
message = l10n.p_webauthn_elevated_permissions_required;
centered = false;

View File

@ -39,9 +39,9 @@ class DeviceErrorScreen extends ConsumerWidget {
if (pid.usbInterfaces == UsbInterface.fido.value) {
if (Platform.isWindows &&
!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
final currentApp = ref.read(currentAppProvider);
final currentSection = ref.read(currentSectionProvider);
return HomeMessagePage(
capabilities: currentApp.capabilities,
capabilities: currentSection.capabilities,
header: l10n.l_admin_privileges_required,
message: l10n.p_elevated_permissions_required,
actionsBuilder: (context, expanded) => [

View File

@ -118,8 +118,8 @@ class MainPage extends ConsumerWidget {
} else {
return ref.watch(currentDeviceDataProvider).when(
data: (data) {
final app = ref.watch(currentAppProvider);
final capabilities = app.capabilities;
final section = ref.watch(currentSectionProvider);
final capabilities = section.capabilities;
if (data.info.supportedCapabilities.isEmpty &&
data.name == 'Unrecognized device') {
return HomeMessagePage(
@ -131,19 +131,20 @@ class MainPage extends ConsumerWidget {
),
header: l10n.s_yk_not_recognized,
);
} else if (app.getAvailability(data) ==
} else if (section.getAvailability(data) ==
Availability.unsupported) {
return MessagePage(
title: app.getDisplayName(l10n),
title: section.getDisplayName(l10n),
capabilities: capabilities,
header: l10n.s_app_not_supported,
message: l10n.l_app_not_supported_on_yk(capabilities
.map((c) => c.getDisplayName(l10n))
.join(',')),
);
} else if (app.getAvailability(data) != Availability.enabled) {
} else if (section.getAvailability(data) !=
Availability.enabled) {
return MessagePage(
title: app.getDisplayName(l10n),
title: section.getDisplayName(l10n),
capabilities: capabilities,
header: l10n.s_app_disabled,
message: l10n.l_app_disabled_desc(capabilities
@ -166,18 +167,14 @@ class MainPage extends ConsumerWidget {
);
}
return switch (app) {
Application.home => HomeScreen(data),
Application.accounts => OathScreen(data.node.path),
Application.webauthn => const WebAuthnScreen(),
Application.passkeys => PasskeysScreen(data),
Application.fingerprints => FingerprintsScreen(data),
Application.certificates => PivScreen(data.node.path),
Application.slots => OtpScreen(data.node.path),
_ => MessagePage(
header: l10n.s_app_not_supported,
message: l10n.l_app_not_supported_desc,
),
return switch (section) {
Section.home => HomeScreen(data),
Section.accounts => OathScreen(data.node.path),
Section.securityKey => const WebAuthnScreen(),
Section.passkeys => PasskeysScreen(data),
Section.fingerprints => FingerprintsScreen(data),
Section.certificates => PivScreen(data.node.path),
Section.slots => OtpScreen(data.node.path),
};
},
loading: () => DeviceErrorScreen(deviceNode),

View File

@ -85,27 +85,25 @@ class NavigationItem extends StatelessWidget {
}
}
extension on Application {
extension on Section {
IconData get _icon => switch (this) {
Application.accounts => Symbols.supervisor_account,
Application.webauthn => Symbols.security_key,
Application.passkeys => Symbols.passkey,
Application.fingerprints => Symbols.fingerprint,
Application.slots => Symbols.touch_app,
Application.certificates => Symbols.approval,
Application.management => Symbols.construction,
Application.home => Symbols.home
Section.home => Symbols.home,
Section.accounts => Symbols.supervisor_account,
Section.securityKey => Symbols.security_key,
Section.passkeys => Symbols.passkey,
Section.fingerprints => Symbols.fingerprint,
Section.slots => Symbols.touch_app,
Section.certificates => Symbols.badge,
};
Key get _key => switch (this) {
Application.accounts => oathAppDrawer,
Application.webauthn => u2fAppDrawer,
Application.passkeys => fidoPasskeysAppDrawer,
Application.fingerprints => fidoFingerprintsAppDrawer,
Application.slots => otpAppDrawer,
Application.certificates => pivAppDrawer,
Application.management => managementAppDrawer,
Application.home => homeDrawer,
Section.home => homeDrawer,
Section.accounts => oathAppDrawer,
Section.securityKey => u2fAppDrawer,
Section.passkeys => fidoPasskeysAppDrawer,
Section.fingerprints => fidoFingerprintsAppDrawer,
Section.slots => otpAppDrawer,
Section.certificates => pivAppDrawer,
};
}
@ -118,17 +116,16 @@ class NavigationContent extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final supportedApps = ref.watch(supportedAppsProvider);
final supportedSections = ref.watch(supportedSectionsProvider);
final data = ref.watch(currentDeviceDataProvider).valueOrNull;
final availableApps = data != null
? supportedApps
.where(
(app) => app.getAvailability(data) != Availability.unsupported)
final availableSections = data != null
? supportedSections
.where((section) =>
section.getAvailability(data) != Availability.unsupported)
.toList()
: [Application.home];
availableApps.remove(Application.management);
final currentApp = ref.watch(currentAppProvider);
: [Section.home];
final currentSection = ref.watch(currentSectionProvider);
return Padding(
padding: const EdgeInsets.all(8.0),
@ -144,21 +141,21 @@ class NavigationContent extends ConsumerWidget {
child: Column(
children: [
// Normal YubiKey Applications
...availableApps.map((app) => NavigationItem(
...availableSections.map((app) => NavigationItem(
key: app._key,
title: app.getDisplayName(l10n),
leading:
Icon(app._icon, fill: app == currentApp ? 1.0 : 0.0),
leading: Icon(app._icon,
fill: app == currentSection ? 1.0 : 0.0),
collapsed: !extended,
selected: app == currentApp,
onTap: data == null && currentApp == Application.home ||
selected: app == currentSection,
onTap: data == null && currentSection == Section.home ||
data != null &&
app.getAvailability(data) ==
Availability.enabled
? () {
ref
.read(currentAppProvider.notifier)
.setCurrentApp(app);
.read(currentSectionProvider.notifier)
.setCurrentSection(app);
if (shouldPop) {
Navigator.of(context).pop();
}

View File

@ -48,7 +48,7 @@ extension on Capability {
IconData get _icon => switch (this) {
Capability.oath => Symbols.supervisor_account,
Capability.fido2 => Symbols.passkey,
Capability.piv => Symbols.approval,
Capability.piv => Symbols.badge,
_ => throw UnsupportedError('Icon not defined'),
};
}
@ -256,10 +256,10 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
if (isAndroid) {
// switch current app context
ref
.read(currentAppProvider.notifier)
.setCurrentApp(switch (_application) {
Capability.oath => Application.accounts,
Capability.fido2 => Application.passkeys,
.read(currentSectionProvider.notifier)
.setCurrentSection(switch (_application) {
Capability.oath => Section.accounts,
Capability.fido2 => Section.passkeys,
_ => throw UnimplementedError(
'Reset for $_application is not implemented')
});

View File

@ -32,14 +32,18 @@ import '../state.dart';
final _log = Logger('desktop.fido.state');
final _pinProvider = StateProvider.autoDispose.family<String?, DevicePath>(
(ref, _) => null,
final _pinProvider = StateProvider.family<String?, DevicePath>(
(ref, _) {
// Clear PIN if current device is changed
ref.watch(currentDeviceProvider);
return null;
},
);
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(ref, devicePath) {
// Make sure the pinProvider is held for the duration of the session.
// Refresh state when PIN is changed
ref.watch(_pinProvider(devicePath));
return RpcNodeSession(
ref.watch(rpcProvider).requireValue, devicePath, ['fido', 'ctap2']);

View File

@ -256,7 +256,7 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
onFieldSubmitted: (_) {
_submit();
},
),
).init(),
)
]
],

View File

@ -112,8 +112,8 @@ class _FidoLockedPage extends ConsumerWidget {
label: Text(l10n.s_setup_fingerprints),
onPressed: () async {
ref
.read(currentAppProvider.notifier)
.setCurrentApp(Application.fingerprints);
.read(currentSectionProvider.notifier)
.setCurrentSection(Section.fingerprints);
},
avatar: const Icon(Symbols.fingerprint),
),
@ -248,8 +248,8 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
label: Text(l10n.s_setup_fingerprints),
onPressed: () async {
ref
.read(currentAppProvider.notifier)
.setCurrentApp(Application.fingerprints);
.read(currentSectionProvider.notifier)
.setCurrentSection(Section.fingerprints);
},
avatar: const Icon(Symbols.fingerprint),
)

View File

@ -46,7 +46,8 @@ class FidoPinDialog extends ConsumerStatefulWidget {
}
class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
String _currentPin = '';
final _currentPinController = TextEditingController();
final _currentPinFocus = FocusNode();
String _newPin = '';
String _confirmPin = '';
String? _currentPinError;
@ -56,15 +57,28 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
bool _isBlocked = false;
@override
void dispose() {
_currentPinController.dispose();
_currentPinFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final hasPin = widget.state.hasPin;
final isValid = _newPin.isNotEmpty &&
_newPin == _confirmPin &&
(!hasPin || _currentPin.isNotEmpty);
final minPinLength = widget.state.minPinLength;
final currentMinPinLen = !hasPin
? 0
// N.B. current PIN may be shorter than minimum if set before the minimum was increased
: (widget.state.forcePinChange ? 4 : widget.state.minPinLength);
final currentPinLenOk =
_currentPinController.text.length >= currentMinPinLen;
final newPinLenOk = _newPin.length >= minPinLength;
final isValid = currentPinLenOk && newPinLenOk && _newPin == _confirmPin;
return ResponsiveDialog(
title: Text(hasPin ? l10n.s_change_pin : l10n.s_set_pin),
@ -84,11 +98,13 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
Text(l10n.p_enter_current_pin_or_reset_no_puk),
AppTextFormField(
key: currentPin,
initialValue: _currentPin,
controller: _currentPinController,
focusNode: _currentPinFocus,
autofocus: true,
obscureText: _isObscureCurrent,
autofillHints: const [AutofillHints.password],
decoration: AppInputDecoration(
enabled: !_isBlocked,
border: const OutlineInputBorder(),
labelText: l10n.s_current_pin,
errorText: _currentIsWrong ? _currentPinError : null,
@ -110,10 +126,9 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentPin = value;
});
},
),
).init(),
],
Text(l10n.p_enter_new_fido2_pin(minPinLength)),
// TODO: Set max characters based on UTF-8 bytes
@ -126,7 +141,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_new_pin,
enabled: !hasPin || _currentPin.isNotEmpty,
enabled: !_isBlocked && currentPinLenOk,
errorText: _newIsWrong ? _newPinError : null,
errorMaxLines: 3,
prefixIcon: const Icon(Symbols.pin),
@ -148,7 +163,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
_newPin = value;
});
},
),
).init(),
AppTextFormField(
key: confirmPin,
initialValue: _confirmPin,
@ -170,8 +185,12 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
tooltip:
_isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin,
),
enabled:
(!hasPin || _currentPin.isNotEmpty) && _newPin.isNotEmpty,
enabled: !_isBlocked && currentPinLenOk && newPinLenOk,
errorText: _newPin.length == _confirmPin.length &&
_newPin != _confirmPin
? l10n.l_pin_mismatch
: null,
helperText: '', // Prevents resizing when errorText shown
),
onChanged: (value) {
setState(() {
@ -183,7 +202,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
@ -197,15 +216,9 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
void _submit() async {
final l10n = AppLocalizations.of(context)!;
final minPinLength = widget.state.minPinLength;
final oldPin = _currentPin.isNotEmpty ? _currentPin : null;
if (_newPin.length < minPinLength) {
setState(() {
_newPinError = l10n.l_new_pin_len(minPinLength);
_newIsWrong = true;
});
return;
}
final oldPin = _currentPinController.text.isNotEmpty
? _currentPinController.text
: null;
try {
final result = await ref
.read(fidoStateProvider(widget.devicePath).notifier)
@ -215,9 +228,13 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
showMessage(context, l10n.s_pin_set);
}, failed: (retries, authBlocked) {
setState(() {
_currentPinController.selection = TextSelection(
baseOffset: 0, extentOffset: _currentPinController.text.length);
_currentPinFocus.requestFocus();
if (authBlocked) {
_currentPinError = l10n.l_pin_soft_locked;
_currentIsWrong = true;
_isBlocked = true;
} else {
_currentPinError = l10n.l_wrong_pin_attempts_remaining(retries);
_currentIsWrong = true;

View File

@ -38,11 +38,19 @@ class PinEntryForm extends ConsumerStatefulWidget {
class _PinEntryFormState extends ConsumerState<PinEntryForm> {
final _pinController = TextEditingController();
final _pinFocus = FocusNode();
bool _blocked = false;
int? _retries;
bool _pinIsWrong = false;
bool _isObscure = true;
@override
void dispose() {
_pinController.dispose();
_pinFocus.dispose();
super.dispose();
}
void _submit() async {
setState(() {
_pinIsWrong = false;
@ -53,8 +61,10 @@ class _PinEntryFormState extends ConsumerState<PinEntryForm> {
.read(fidoStateProvider(widget._deviceNode.path).notifier)
.unlock(_pinController.text);
result.whenOrNull(failed: (retries, authBlocked) {
_pinController.selection = TextSelection(
baseOffset: 0, extentOffset: _pinController.text.length);
_pinFocus.requestFocus();
setState(() {
_pinController.clear();
_pinIsWrong = true;
_retries = retries;
_blocked = authBlocked;
@ -97,6 +107,8 @@ class _PinEntryFormState extends ConsumerState<PinEntryForm> {
obscureText: _isObscure,
autofillHints: const [AutofillHints.password],
controller: _pinController,
focusNode: _pinFocus,
enabled: !_blocked && (_retries ?? 1) > 0,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
@ -121,7 +133,7 @@ class _PinEntryFormState extends ConsumerState<PinEntryForm> {
});
}, // Update state on change
onSubmitted: (_) => _submit(),
),
).init(),
),
ListTile(
leading: noFingerprints
@ -141,8 +153,12 @@ class _PinEntryFormState extends ConsumerState<PinEntryForm> {
key: unlockFido2WithPin,
icon: const Icon(Symbols.lock_open),
label: Text(l10n.s_unlock),
onPressed:
_pinController.text.isNotEmpty && !_blocked ? _submit : null,
onPressed: !_pinIsWrong &&
_pinController.text.length >=
widget._state.minPinLength &&
!_blocked
? _submit
: null,
),
),
],

View File

@ -112,7 +112,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameFingerprintDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -27,7 +27,7 @@ class WebAuthnScreen extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return MessagePage(
title: l10n.s_webauthn,
title: l10n.s_security_key,
capabilities: const [Capability.u2f],
header: l10n.l_ready_to_use,
message: l10n.l_register_sk_on_websites,

View File

@ -24,9 +24,9 @@ import '../../app/features.dart' as features;
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../app/views/action_list.dart';
import '../../app/views/reset_dialog.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../management/views/management_screen.dart';
@ -34,11 +34,13 @@ Widget homeBuildActions(
BuildContext context, YubiKeyData? deviceData, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
final managementAvailability =
deviceData == null || !hasFeature(features.management)
? Availability.unsupported
: Application.management.getAvailability(deviceData);
final managementAvailability = hasFeature(features.management) &&
switch (deviceData?.info.version) {
Version version => (version.major > 4 || // YK5 and up
(version.major == 4 && version.minor >= 1) || // YK4.1 and up
version.major == 3), // NEO,
null => false,
};
return Column(
children: [
@ -46,7 +48,7 @@ Widget homeBuildActions(
ActionListSection(
l10n.s_device,
children: [
if (managementAvailability == Availability.enabled)
if (managementAvailability)
ActionListItem(
feature: features.management,
icon: const Icon(Symbols.construction),
@ -57,12 +59,11 @@ Widget homeBuildActions(
subtitle: deviceData.info.version.major > 4
? l10n.l_toggle_applications_desc
: l10n.l_toggle_interfaces_desc,
onTap: (context) async {
await ref.read(withContextProvider)(
(context) => showBlurDialog(
context: context,
builder: (context) => ManagementScreen(deviceData),
),
onTap: (context) {
Navigator.of(context).popUntil((route) => route.isFirst);
showBlurDialog(
context: context,
builder: (context) => ManagementScreen(deviceData),
);
},
),
@ -77,12 +78,11 @@ Widget homeBuildActions(
title: l10n.s_factory_reset,
subtitle: l10n.l_factory_reset_desc,
actionStyle: ActionStyle.primary,
onTap: (context) async {
await ref.read(withContextProvider)(
(context) => showBlurDialog(
context: context,
builder: (context) => ResetDialog(deviceData),
),
onTap: (context) {
Navigator.of(context).popUntil((route) => route.isFirst);
showBlurDialog(
context: context,
builder: (context) => ResetDialog(deviceData),
);
},
)
@ -94,7 +94,8 @@ Widget homeBuildActions(
title: l10n.s_settings,
subtitle: l10n.l_settings_desc,
actionStyle: ActionStyle.primary,
onTap: (_) {
onTap: (context) {
Navigator.of(context).popUntil((route) => route.isFirst);
Actions.maybeInvoke(context, const SettingsIntent());
},
),
@ -103,7 +104,8 @@ Widget homeBuildActions(
title: l10n.s_help_and_about,
subtitle: l10n.l_help_and_about_desc,
actionStyle: ActionStyle.primary,
onTap: (_) {
onTap: (context) {
Navigator.of(context).popUntil((route) => route.isFirst);
Actions.maybeInvoke(context, const AboutIntent());
},
)

View File

@ -87,7 +87,7 @@ class _ManageLabelDialogState extends ConsumerState<ManageLabelDialog> {
onFieldSubmitted: (_) {
_submit();
},
)
).init()
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -66,7 +66,7 @@
"s_settings": "Einstellungen",
"l_settings_desc": null,
"s_certificates": null,
"s_webauthn": "WebAuthn",
"s_security_key": null,
"s_slots": null,
"s_help_and_about": "Hilfe und Über",
"l_help_and_about_desc": null,
@ -233,6 +233,8 @@
"s_confirm_pin": "PIN bestätigen",
"s_confirm_puk": null,
"s_unblock_pin": null,
"l_pin_mismatch": null,
"l_puk_mismatch": null,
"l_new_pin_len": "Neue PIN muss mindestens {length} Zeichen lang sein",
"@l_new_pin_len": {
"placeholders": {
@ -309,6 +311,7 @@
"s_new_password": "Neues Passwort",
"s_current_password": "Aktuelles Passwort",
"s_confirm_password": "Passwort bestätigen",
"l_password_mismatch": null,
"s_wrong_password": "Falsches Passwort",
"s_remove_password": "Passwort entfernen",
"s_password_removed": "Passwort entfernt",

View File

@ -66,7 +66,7 @@
"s_settings": "Settings",
"l_settings_desc": "Change application preferences",
"s_certificates": "Certificates",
"s_webauthn": "WebAuthn",
"s_security_key": "Security Key",
"s_slots": "Slots",
"s_help_and_about": "Help and about",
"l_help_and_about_desc": "Troubleshoot and support",
@ -233,6 +233,8 @@
"s_confirm_pin": "Confirm PIN",
"s_confirm_puk": "Confirm PUK",
"s_unblock_pin": "Unblock PIN",
"l_pin_mismatch": "PINs do not match",
"l_puk_mismatch": "PUKs do not match",
"l_new_pin_len": "New PIN must be at least {length} characters",
"@l_new_pin_len": {
"placeholders": {
@ -309,6 +311,7 @@
"s_new_password": "New password",
"s_current_password": "Current password",
"s_confirm_password": "Confirm password",
"l_password_mismatch": "Passwords do not match",
"s_wrong_password": "Wrong password",
"s_remove_password": "Remove password",
"s_password_removed": "Password removed",

View File

@ -66,7 +66,7 @@
"s_settings": "Paramètres",
"l_settings_desc": null,
"s_certificates": "Certificats",
"s_webauthn": "WebAuthn",
"s_security_key": null,
"s_slots": null,
"s_help_and_about": "Aide et à propos",
"l_help_and_about_desc": null,
@ -233,6 +233,8 @@
"s_confirm_pin": "Confirmez le PIN",
"s_confirm_puk": "Confirmez le PUK",
"s_unblock_pin": "Débloquer le PIN",
"l_pin_mismatch": null,
"l_puk_mismatch": null,
"l_new_pin_len": "Le nouveau PIN doit avoir au moins {length} caractères",
"@l_new_pin_len": {
"placeholders": {
@ -309,6 +311,7 @@
"s_new_password": "Nouveau mot de passe",
"s_current_password": "Mot de passe actuel",
"s_confirm_password": "Confirmez le mot de passe",
"l_password_mismatch": null,
"s_wrong_password": "Mauvais mot de passe",
"s_remove_password": "Supprimer le mot de passe",
"s_password_removed": "Mot de passe supprimé",

View File

@ -66,7 +66,7 @@
"s_settings": "設定",
"l_settings_desc": null,
"s_certificates": "証明書",
"s_webauthn": "WebAuthn",
"s_security_key": null,
"s_slots": null,
"s_help_and_about": "ヘルプと概要",
"l_help_and_about_desc": null,
@ -233,6 +233,8 @@
"s_confirm_pin": "PINの確認",
"s_confirm_puk": "PUKの確認",
"s_unblock_pin": "ブロックを解除",
"l_pin_mismatch": null,
"l_puk_mismatch": null,
"l_new_pin_len": "新しいPINは少なくとも{length}文字である必要があります",
"@l_new_pin_len": {
"placeholders": {
@ -309,6 +311,7 @@
"s_new_password": "新しいパスワード",
"s_current_password": "現在のパスワード",
"s_confirm_password": "パスワードを確認",
"l_password_mismatch": null,
"s_wrong_password": "間違ったパスワード",
"s_remove_password": "パスワードの削除",
"s_password_removed": "パスワードが削除されました",

View File

@ -66,7 +66,7 @@
"s_settings": "Ustawienia",
"l_settings_desc": null,
"s_certificates": "Certyfikaty",
"s_webauthn": "WebAuthn",
"s_security_key": null,
"s_slots": "Sloty",
"s_help_and_about": "Pomoc i informacje",
"l_help_and_about_desc": null,
@ -233,6 +233,8 @@
"s_confirm_pin": "Potwierdź PIN",
"s_confirm_puk": "Potwierdź PUK",
"s_unblock_pin": "Odblokuj PIN",
"l_pin_mismatch": null,
"l_puk_mismatch": null,
"l_new_pin_len": "Nowy PIN musi mieć co najmniej {length} znaków",
"@l_new_pin_len": {
"placeholders": {
@ -309,6 +311,7 @@
"s_new_password": "Nowe hasło",
"s_current_password": "Aktualne hasło",
"s_confirm_password": "Potwierdź hasło",
"l_password_mismatch": null,
"s_wrong_password": "Błędne hasło",
"s_remove_password": "Usuń hasło",
"s_password_removed": "Hasło zostało usunięte",

View File

@ -390,7 +390,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
onSubmitted: (_) {
if (isValid) submit();
},
),
).init(),
AppTextField(
key: keys.nameField,
controller: _accountController,
@ -418,7 +418,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
onSubmitted: (_) {
if (isValid) submit();
},
),
).init(),
AppTextField(
key: keys.secretField,
controller: _secretController,
@ -460,7 +460,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
onSubmitted: (_) {
if (isValid) submit();
},
),
).init(),
const SizedBox(height: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,

View File

@ -41,7 +41,8 @@ class ManagePasswordDialog extends ConsumerStatefulWidget {
}
class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
String _currentPassword = '';
final _currentPasswordController = TextEditingController();
final _currentPasswordFocus = FocusNode();
String _newPassword = '';
String _confirmPassword = '';
bool _currentIsWrong = false;
@ -49,12 +50,19 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
bool _isObscureNew = true;
bool _isObscureConfirm = true;
@override
void dispose() {
_currentPasswordController.dispose();
_currentPasswordFocus.dispose();
super.dispose();
}
_submit() async {
FocusUtils.unfocus(context);
final result = await ref
.read(oathStateProvider(widget.path).notifier)
.setPassword(_currentPassword, _newPassword);
.setPassword(_currentPasswordController.text, _newPassword);
if (result) {
if (mounted) {
await ref.read(withContextProvider)((context) async {
@ -63,6 +71,9 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
});
}
} else {
_currentPasswordController.selection = TextSelection(
baseOffset: 0, extentOffset: _currentPasswordController.text.length);
_currentPasswordFocus.requestFocus();
setState(() {
_currentIsWrong = true;
});
@ -72,9 +83,10 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isValid = _newPassword.isNotEmpty &&
final isValid = !_currentIsWrong &&
_newPassword.isNotEmpty &&
_newPassword == _confirmPassword &&
(!widget.state.hasKey || _currentPassword.isNotEmpty);
(!widget.state.hasKey || _currentPasswordController.text.isNotEmpty);
return ResponsiveDialog(
title: Text(
@ -98,6 +110,8 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
obscureText: _isObscureCurrent,
autofillHints: const [AutofillHints.password],
key: keys.currentPasswordField,
controller: _currentPasswordController,
focusNode: _currentPasswordFocus,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_current_password,
@ -121,21 +135,21 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
onChanged: (value) {
setState(() {
_currentIsWrong = false;
_currentPassword = value;
});
},
),
).init(),
Wrap(
spacing: 4.0,
runSpacing: 8.0,
children: [
OutlinedButton(
key: keys.removePasswordButton,
onPressed: _currentPassword.isNotEmpty
onPressed: _currentPasswordController.text.isNotEmpty &&
!_currentIsWrong
? () async {
final result = await ref
.read(oathStateProvider(widget.path).notifier)
.unsetPassword(_currentPassword);
.unsetPassword(_currentPasswordController.text);
if (result) {
if (mounted) {
await ref.read(withContextProvider)(
@ -145,6 +159,12 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
});
}
} else {
_currentPasswordController.selection =
TextSelection(
baseOffset: 0,
extentOffset: _currentPasswordController
.text.length);
_currentPasswordFocus.requestFocus();
setState(() {
_currentIsWrong = true;
});
@ -193,7 +213,8 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
tooltip: _isObscureNew
? l10n.s_show_password
: l10n.s_hide_password),
enabled: !widget.state.hasKey || _currentPassword.isNotEmpty,
enabled: !widget.state.hasKey ||
_currentPasswordController.text.isNotEmpty,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
@ -206,7 +227,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
_submit();
}
},
),
).init(),
AppTextField(
key: keys.confirmPasswordField,
obscureText: _isObscureConfirm,
@ -227,9 +248,14 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
tooltip: _isObscureConfirm
? l10n.s_show_password
: l10n.s_hide_password),
enabled:
(!widget.state.hasKey || _currentPassword.isNotEmpty) &&
_newPassword.isNotEmpty,
enabled: (!widget.state.hasKey ||
_currentPasswordController.text.isNotEmpty) &&
_newPassword.isNotEmpty,
errorText: _newPassword.length == _confirmPassword.length &&
_newPassword != _confirmPassword
? l10n.l_password_mismatch
: null,
helperText: '', // Prevents resizing when errorText shown
),
textInputAction: TextInputAction.done,
onChanged: (value) {
@ -242,7 +268,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -443,7 +443,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
Focus.of(context)
.focusInDirection(TraversalDirection.down);
},
),
).init(),
);
}),
),

View File

@ -193,7 +193,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
_issuer = value.trim();
});
},
),
).init(),
AppTextFormField(
initialValue: _name,
maxLength: nameRemaining,
@ -222,7 +222,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -38,6 +38,7 @@ class UnlockForm extends ConsumerStatefulWidget {
class _UnlockFormState extends ConsumerState<UnlockForm> {
final _passwordController = TextEditingController();
final _passwordFocus = FocusNode();
bool _remember = false;
bool _passwordIsWrong = false;
bool _isObscure = true;
@ -51,9 +52,11 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
.unlock(_passwordController.text, remember: _remember);
if (!mounted) return;
if (!success) {
_passwordController.selection = TextSelection(
baseOffset: 0, extentOffset: _passwordController.text.length);
_passwordFocus.requestFocus();
setState(() {
_passwordIsWrong = true;
_passwordController.clear();
});
} else if (_remember && !remembered) {
showMessage(context, AppLocalizations.of(context)!.l_remember_pw_failed);
@ -79,6 +82,7 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
child: AppTextField(
key: keys.passwordField,
controller: _passwordController,
focusNode: _passwordFocus,
autofocus: true,
obscureText: _isObscure,
autofillHints: const [AutofillHints.password],
@ -106,7 +110,7 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
_passwordIsWrong = false;
}), // Update state on change
onSubmitted: (_) => _submit(),
),
).init(),
),
const SizedBox(height: 3.0),
Column(
@ -143,7 +147,8 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
key: keys.unlockButton,
label: Text(l10n.s_unlock),
icon: const Icon(Symbols.lock_open),
onPressed: _passwordController.text.isNotEmpty
onPressed: _passwordController.text.isNotEmpty &&
!_passwordIsWrong
? _submit
: null,
),

View File

@ -166,7 +166,7 @@ class _ConfigureChalrespDialogState
_validateSecret = false;
});
},
),
).init(),
FilterChip(
label: Text(l10n.s_require_touch),
selected: _requireTouch,

View File

@ -127,6 +127,7 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
key: keys.secretField,
controller: _secretController,
obscureText: _isObscure,
autofocus: true,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
@ -158,7 +159,7 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
_validateSecret = false;
});
},
),
).init(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,

View File

@ -181,7 +181,7 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
_validatePassword = false;
});
},
),
).init(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,

View File

@ -237,7 +237,7 @@ class _ConfigureYubiOtpDialogState
_validatePublicIdFormat = false;
});
},
),
).init(),
AppTextField(
key: keys.privateIdField,
controller: _privateIdController,
@ -274,7 +274,7 @@ class _ConfigureYubiOtpDialogState
_validatePrivateIdFormat = false;
});
},
),
).init(),
AppTextField(
key: keys.secretField,
controller: _secretController,
@ -311,7 +311,7 @@ class _ConfigureYubiOtpDialogState
_validateSecretFormat = false;
});
},
),
).init(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,

View File

@ -35,8 +35,7 @@ abstract class PivStateNotifier extends ApplicationStateNotifier<PivState> {
bool storeKey = false,
});
Future<PinVerificationStatus> verifyPin(
String pin); //TODO: Maybe return authenticated?
Future<PinVerificationStatus> verifyPin(String pin);
Future<PinVerificationStatus> changePin(String pin, String newPin);
Future<PinVerificationStatus> changePuk(String puk, String newPuk);
Future<PinVerificationStatus> unblockPin(String puk, String newPin);

View File

@ -52,24 +52,26 @@ class ExportIntent extends Intent {
const ExportIntent(this.slot);
}
Future<bool> _authenticate(
BuildContext context, DevicePath devicePath, PivState pivState) async {
return await showBlurDialog(
context: context,
builder: (context) => pivState.protectedKey
? PinDialog(devicePath)
: AuthenticationDialog(
devicePath,
pivState,
),
) ??
false;
}
Future<bool> _authIfNeeded(
BuildContext context, DevicePath devicePath, PivState pivState) async {
Future<bool> _authIfNeeded(BuildContext context, WidgetRef ref,
DevicePath devicePath, PivState pivState) async {
if (pivState.needsAuth) {
return await _authenticate(context, devicePath, pivState);
if (pivState.protectedKey &&
pivState.metadata?.pinMetadata.defaultValue == true) {
final status = await ref
.read(pivStateProvider(devicePath).notifier)
.verifyPin(defaultPin);
return status.when(success: () => true, failure: (_) => false);
}
return await showBlurDialog(
context: context,
builder: (context) => pivState.protectedKey
? PinDialog(devicePath)
: AuthenticationDialog(
devicePath,
pivState,
),
) ??
false;
}
return true;
}
@ -96,21 +98,32 @@ class PivActions extends ConsumerWidget {
if (hasFeature(features.slotsGenerate))
GenerateIntent:
CallbackAction<GenerateIntent>(onInvoke: (intent) async {
if (!pivState.protectedKey &&
!await withContext((context) =>
_authIfNeeded(context, devicePath, pivState))) {
//Verify management key and maybe PIN
if (!await withContext((context) =>
_authIfNeeded(context, ref, devicePath, pivState))) {
return false;
}
// Verify PIN, unless already done above
// TODO: Avoid asking for PIN if not needed?
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(devicePath))) ??
false;
if (!pivState.protectedKey) {
bool verified;
if (pivState.metadata?.pinMetadata.defaultValue == true) {
final status = await ref
.read(pivStateProvider(devicePath).notifier)
.verifyPin(defaultPin);
verified =
status.when(success: () => true, failure: (_) => false);
} else {
verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(devicePath))) ??
false;
}
if (!verified) {
return false;
if (!verified) {
return false;
}
}
return await withContext((context) async {
@ -158,8 +171,8 @@ class PivActions extends ConsumerWidget {
}),
if (hasFeature(features.slotsImport))
ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async {
if (!await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
if (!await withContext((context) =>
_authIfNeeded(context, ref, devicePath, pivState))) {
return false;
}
@ -243,8 +256,8 @@ class PivActions extends ConsumerWidget {
if (hasFeature(features.slotsDelete))
DeleteIntent<PivSlot>:
CallbackAction<DeleteIntent<PivSlot>>(onInvoke: (intent) async {
if (!await withContext(
(context) => _authIfNeeded(context, devicePath, pivState))) {
if (!await withContext((context) =>
_authIfNeeded(context, ref, devicePath, pivState))) {
return false;
}

View File

@ -44,10 +44,12 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
bool _keyIsWrong = false;
bool _keyFormatInvalid = false;
final _keyController = TextEditingController();
final _keyFocus = FocusNode();
@override
void dispose() {
_keyController.dispose();
_keyFocus.dispose();
super.dispose();
}
@ -65,7 +67,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
actions: [
TextButton(
key: keys.unlockButton,
onPressed: _keyController.text.length == keyLen
onPressed: !_keyIsWrong && _keyController.text.length == keyLen
? () async {
if (keyFormatInvalid) {
setState(() {
@ -81,6 +83,10 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
if (status) {
navigator.pop(true);
} else {
_keyController.selection = TextSelection(
baseOffset: 0,
extentOffset: _keyController.text.length);
_keyFocus.requestFocus();
setState(() {
_keyIsWrong = true;
});
@ -88,6 +94,10 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
} on CancellationException catch (_) {
navigator.pop(false);
} catch (_) {
_keyController.selection = TextSelection(
baseOffset: 0,
extentOffset: _keyController.text.length);
_keyFocus.requestFocus();
// TODO: More error cases
setState(() {
_keyIsWrong = true;
@ -109,6 +119,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
autofocus: true,
autofillHints: const [AutofillHints.password],
controller: _keyController,
focusNode: _keyFocus,
readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? keyLen : null,
decoration: AppInputDecoration(
@ -149,7 +160,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
_keyFormatInvalid = false;
});
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -174,7 +174,7 @@ class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
_subject = value;
});
},
),
).init(),
Text(
l10n.rfc4514_examples,
style: subtitleStyle,

View File

@ -162,7 +162,7 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
});
},
onSubmitted: (_) => _examine(),
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -60,7 +60,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath,
ActionListItem(
key: keys.managePinAction,
feature: features.actionsPin,
title: l10n.s_pin,
title: l10n.s_change_pin,
subtitle: pinBlocked
? (pukAttempts != 0
? l10n.l_piv_pin_blocked
@ -88,7 +88,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath,
ActionListItem(
key: keys.managePukAction,
feature: features.actionsPuk,
title: l10n.s_puk,
title: l10n.s_change_puk,
subtitle: pukAttempts != null
? (pukAttempts == 0
? l10n.l_piv_pin_puk_blocked

View File

@ -30,6 +30,7 @@ import '../../widgets/app_text_field.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
@ -48,6 +49,7 @@ class ManageKeyDialog extends ConsumerStatefulWidget {
class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
late bool _hasMetadata;
late bool _defaultKeyUsed;
late bool _defaultPinUsed;
late bool _usesStoredKey;
late bool _storeKey;
bool _currentIsWrong = false;
@ -56,6 +58,7 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
int _attemptsRemaining = -1;
late ManagementKeyType _keyType;
final _currentController = TextEditingController();
final _currentFocus = FocusNode();
final _keyController = TextEditingController();
bool _isObscure = true;
@ -68,9 +71,13 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
defaultManagementKeyType;
_defaultKeyUsed =
widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false;
_defaultPinUsed =
widget.pivState.metadata?.pinMetadata.defaultValue ?? false;
_usesStoredKey = widget.pivState.protectedKey;
if (!_usesStoredKey && _defaultKeyUsed) {
_currentController.text = defaultManagementKey;
} else if (_usesStoredKey && _defaultPinUsed) {
_currentController.text = defaultPin;
}
_storeKey = _usesStoredKey;
}
@ -79,16 +86,18 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
void dispose() {
_keyController.dispose();
_currentController.dispose();
_currentFocus.dispose();
super.dispose();
}
_submit() async {
final currentInvalidFormat = Format.hex.isValid(_currentController.text);
final newInvalidFormat = Format.hex.isValid(_keyController.text);
if (!currentInvalidFormat || !newInvalidFormat) {
final currentValidFormat =
_usesStoredKey || Format.hex.isValid(_currentController.text);
final newValidFormat = Format.hex.isValid(_keyController.text);
if (!currentValidFormat || !newValidFormat) {
setState(() {
_currentInvalidFormat = !currentInvalidFormat;
_newInvalidFormat = !newInvalidFormat;
_currentInvalidFormat = !currentValidFormat;
_newInvalidFormat = !newValidFormat;
});
return;
}
@ -98,6 +107,9 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
final status = (await notifier.verifyPin(_currentController.text)).when(
success: () => true,
failure: (attemptsRemaining) {
_currentController.selection = TextSelection(
baseOffset: 0, extentOffset: _currentController.text.length);
_currentFocus.requestFocus();
setState(() {
_attemptsRemaining = attemptsRemaining;
_currentIsWrong = true;
@ -110,6 +122,9 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
}
} else {
if (!await notifier.authenticate(_currentController.text)) {
_currentController.selection = TextSelection(
baseOffset: 0, extentOffset: _currentController.text.length);
_currentFocus.requestFocus();
setState(() {
_currentIsWrong = true;
});
@ -118,15 +133,19 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
}
if (_storeKey && !_usesStoredKey) {
final withContext = ref.read(withContextProvider);
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(widget.path))) ??
false;
if (_defaultPinUsed) {
await notifier.verifyPin(defaultPin);
} else {
final withContext = ref.read(withContextProvider);
final verified = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => PinDialog(widget.path))) ??
false;
if (!verified) {
return;
if (!verified) {
return;
}
}
}
@ -147,9 +166,8 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
widget.pivState.metadata?.managementKeyMetadata.keyType ??
defaultManagementKeyType;
final hexLength = _keyType.keyLength * 2;
final protected = widget.pivState.protectedKey;
final currentKeyOrPin = _currentController.text;
final currentLenOk = protected
final currentLenOk = _usesStoredKey
? currentKeyOrPin.length >= 4
: currentKeyOrPin.length == currentType.keyLength * 2;
final newLenOk = _keyController.text.length == hexLength;
@ -158,7 +176,8 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
title: Text(l10n.l_change_management_key),
actions: [
TextButton(
onPressed: currentLenOk && newLenOk ? _submit : null,
onPressed:
!_currentIsWrong && currentLenOk && newLenOk ? _submit : null,
key: keys.saveButton,
child: Text(l10n.s_save),
)
@ -169,23 +188,25 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_change_management_key_desc),
if (protected)
if (_usesStoredKey)
AppTextField(
autofocus: true,
obscureText: _isObscure,
autofillHints: const [AutofillHints.password],
key: keys.pinPukField,
maxLength: 8,
inputFormatters: [limitBytesLength(8)],
buildCounter: buildByteCounterFor(_currentController.text),
controller: _currentController,
focusNode: _currentFocus,
readOnly: _defaultPinUsed,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
helperText: _defaultPinUsed ? l10n.l_default_pin_used : null,
errorText: _currentIsWrong
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: _currentInvalidFormat
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
: null,
errorMaxLines: 3,
prefixIcon: const Icon(Symbols.pin),
suffixIcon: IconButton(
@ -206,13 +227,14 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
_currentInvalidFormat = false;
});
},
),
if (!protected)
).init(),
if (!_usesStoredKey)
AppTextFormField(
key: keys.managementKeyField,
autofocus: !_defaultKeyUsed,
autofillHints: const [AutofillHints.password],
controller: _currentController,
focusNode: _currentFocus,
readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null,
decoration: AppInputDecoration(
@ -251,7 +273,7 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
_currentIsWrong = false;
});
},
),
).init(),
AppTextField(
key: keys.newPinPukField,
autofocus: _defaultKeyUsed,
@ -299,7 +321,7 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
_submit();
}
},
),
).init(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,

View File

@ -24,6 +24,7 @@ import '../../app/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
@ -44,20 +45,25 @@ class ManagePinPukDialog extends ConsumerStatefulWidget {
class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
final _currentPinController = TextEditingController();
final _currentPinFocus = FocusNode();
String _newPin = '';
String _confirmPin = '';
bool _pinIsBlocked = false;
bool _currentIsWrong = false;
int _attemptsRemaining = -1;
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
late bool _defaultPinUsed;
late bool _defaultPukUsed;
late final bool _defaultPinUsed;
late final bool _defaultPukUsed;
late final int _minPinLen;
@override
void initState() {
super.initState();
// Old YubiKeys allowed a 4 digit PIN
_minPinLen = widget.pivState.version.isAtLeast(4, 3, 1) ? 6 : 4;
_defaultPinUsed =
widget.pivState.metadata?.pinMetadata.defaultValue ?? false;
_defaultPukUsed =
@ -73,6 +79,7 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
@override
void dispose() {
_currentPinController.dispose();
_currentPinFocus.dispose();
super.dispose();
}
@ -98,11 +105,16 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
_ => l10n.s_pin_set,
});
}, failure: (attemptsRemaining) {
_currentPinController.selection = TextSelection(
baseOffset: 0, extentOffset: _currentPinController.text.length);
_currentPinFocus.requestFocus();
setState(() {
_attemptsRemaining = attemptsRemaining;
_currentIsWrong = true;
if (_attemptsRemaining == 0) {
_pinIsBlocked = true;
}
});
_currentPinController.clear();
});
}
@ -110,8 +122,12 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final currentPin = _currentPinController.text;
final isValid =
_newPin.isNotEmpty && _newPin == _confirmPin && currentPin.isNotEmpty;
final currentPinLen = byteLength(currentPin);
final newPinLen = byteLength(_newPin);
final isValid = !_currentIsWrong &&
_newPin.isNotEmpty &&
_newPin == _confirmPin &&
currentPin.isNotEmpty;
final titleText = switch (widget.target) {
ManageTarget.pin => l10n.s_change_pin,
@ -138,7 +154,6 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//TODO fix string
Text(widget.target == ManageTarget.pin
? l10n.p_enter_current_pin_or_reset
: l10n.p_enter_current_puk_or_reset),
@ -146,10 +161,14 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
autofocus: !(showDefaultPinUsed || showDefaultPukUsed),
obscureText: _isObscureCurrent,
maxLength: 8,
inputFormatters: [limitBytesLength(8)],
buildCounter: buildByteCounterFor(currentPin),
autofillHints: const [AutofillHints.password],
key: keys.pinPukField,
readOnly: showDefaultPinUsed || showDefaultPukUsed,
controller: _currentPinController,
focusNode: _currentPinFocus,
enabled: !_pinIsBlocked,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
helperText: showDefaultPinUsed
@ -160,13 +179,17 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
labelText: widget.target == ManageTarget.pin
? l10n.s_current_pin
: l10n.s_current_puk,
errorText: _currentIsWrong
errorText: _pinIsBlocked
? (widget.target == ManageTarget.pin
? l10n
.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: l10n
.l_wrong_puk_attempts_remaining(_attemptsRemaining))
: null,
? l10n.l_piv_pin_blocked
: l10n.l_piv_pin_puk_blocked)
: (_currentIsWrong
? (widget.target == ManageTarget.pin
? l10n.l_wrong_pin_attempts_remaining(
_attemptsRemaining)
: l10n.l_wrong_puk_attempts_remaining(
_attemptsRemaining))
: null),
errorMaxLines: 3,
prefixIcon: const Icon(Symbols.password),
suffixIcon: IconButton(
@ -189,7 +212,7 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
_currentIsWrong = false;
});
},
),
).init(),
Text(l10n.p_enter_new_piv_pin_puk(
widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)),
AppTextField(
@ -197,6 +220,8 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
autofocus: showDefaultPinUsed || showDefaultPukUsed,
obscureText: _isObscureNew,
maxLength: 8,
inputFormatters: [limitBytesLength(8)],
buildCounter: buildByteCounterFor(_newPin),
autofillHints: const [AutofillHints.newPassword],
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
@ -217,8 +242,7 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
? (_isObscureNew ? l10n.s_show_pin : l10n.s_hide_pin)
: (_isObscureNew ? l10n.s_show_puk : l10n.s_hide_puk),
),
// Old YubiKeys allowed a 4 digit PIN
enabled: currentPin.length >= 4,
enabled: currentPinLen >= _minPinLen,
),
textInputAction: TextInputAction.next,
onChanged: (value) {
@ -231,11 +255,13 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
_submit();
}
},
),
).init(),
AppTextField(
key: keys.confirmPinPukField,
obscureText: _isObscureConfirm,
maxLength: 8,
inputFormatters: [limitBytesLength(8)],
buildCounter: buildByteCounterFor(_confirmPin),
autofillHints: const [AutofillHints.newPassword],
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
@ -256,7 +282,14 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
? (_isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin)
: (_isObscureConfirm ? l10n.s_show_puk : l10n.s_hide_puk),
),
enabled: currentPin.length >= 4 && _newPin.length >= 6,
enabled: currentPinLen >= _minPinLen && newPinLen >= 6,
errorText:
newPinLen == _confirmPin.length && _newPin != _confirmPin
? (widget.target == ManageTarget.pin
? l10n.l_pin_mismatch
: l10n.l_puk_mismatch)
: null,
helperText: '', // Prevents resizing when errorText shown
),
textInputAction: TextInputAction.done,
onChanged: (value) {
@ -269,7 +302,7 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -24,6 +24,7 @@ import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart';
import '../keys.dart' as keys;
import '../state.dart';
@ -37,6 +38,7 @@ class PinDialog extends ConsumerStatefulWidget {
class _PinDialogState extends ConsumerState<PinDialog> {
final _pinController = TextEditingController();
final _pinFocus = FocusNode();
bool _pinIsWrong = false;
int _attemptsRemaining = -1;
bool _isObscure = true;
@ -44,6 +46,7 @@ class _PinDialogState extends ConsumerState<PinDialog> {
@override
void dispose() {
_pinController.dispose();
_pinFocus.dispose();
super.dispose();
}
@ -58,8 +61,10 @@ class _PinDialogState extends ConsumerState<PinDialog> {
navigator.pop(true);
},
failure: (attemptsRemaining) {
_pinController.selection = TextSelection(
baseOffset: 0, extentOffset: _pinController.text.length);
_pinFocus.requestFocus();
setState(() {
_pinController.clear();
_attemptsRemaining = attemptsRemaining;
_pinIsWrong = true;
});
@ -73,12 +78,15 @@ class _PinDialogState extends ConsumerState<PinDialog> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final version = ref.watch(pivStateProvider(widget.devicePath)).valueOrNull;
final minPinLen = version?.version.isAtLeast(4, 3, 1) == true ? 6 : 4;
final currentPinLen = byteLength(_pinController.text);
return ResponsiveDialog(
title: Text(l10n.s_pin_required),
actions: [
TextButton(
key: keys.unlockButton,
onPressed: _pinController.text.length >= 4 ? _submit : null,
onPressed: currentPinLen >= minPinLen ? _submit : null,
child: Text(l10n.s_unlock),
),
],
@ -92,9 +100,12 @@ class _PinDialogState extends ConsumerState<PinDialog> {
autofocus: true,
obscureText: _isObscure,
maxLength: 8,
inputFormatters: [limitBytesLength(8)],
buildCounter: buildByteCounterFor(_pinController.text),
autofillHints: const [AutofillHints.password],
key: keys.managementKeyField,
controller: _pinController,
focusNode: _pinFocus,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_pin,
@ -121,7 +132,7 @@ class _PinDialogState extends ConsumerState<PinDialog> {
});
},
onSubmitted: (_) => _submit(),
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -226,7 +226,7 @@ class _CertificateListItem extends ConsumerWidget {
leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary,
child: const Icon(Symbols.approval),
child: const Icon(Symbols.badge),
),
title: slot.getDisplayName(l10n),
subtitle: certInfo != null

View File

@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'app_input_decoration.dart';
@ -86,4 +85,13 @@ class AppTextField extends TextField {
super.spellCheckConfiguration,
super.magnifierConfiguration,
}) : super(decoration: decoration);
Widget init() => Builder(
builder: (context) => DefaultSelectionStyle(
selectionColor: decoration?.errorText != null
? Theme.of(context).colorScheme.error
: null,
child: this,
),
);
}

View File

@ -20,6 +20,7 @@ import 'app_input_decoration.dart';
/// TextFormField without autocorrect and suggestions
class AppTextFormField extends TextFormField {
final AppInputDecoration? decoration;
AppTextFormField({
// default settings to turn off autocorrect
super.autocorrect = false,
@ -30,7 +31,7 @@ class AppTextFormField extends TextFormField {
super.controller,
super.initialValue,
super.focusNode,
AppInputDecoration? decoration,
this.decoration,
super.textCapitalization,
super.textInputAction,
super.style,
@ -90,4 +91,13 @@ class AppTextFormField extends TextFormField {
super.scribbleEnabled,
super.canRequestFocus,
}) : super(decoration: decoration);
Widget init() => Builder(
builder: (context) => DefaultSelectionStyle(
selectionColor: decoration?.errorText != null
? Theme.of(context).colorScheme.error
: null,
child: this,
),
);
}

View File

@ -14,6 +14,7 @@
* limitations under the License.
*/
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
@ -30,6 +31,8 @@ class _AppLinter extends PluginBase {
discouraged: 'TextFormField',
recommended: 'AppTextFormField',
),
const CallInitAfterCreation(className: 'AppTextField'),
const CallInitAfterCreation(className: 'AppTextFormField'),
];
}
@ -59,3 +62,35 @@ class UseRecommendedWidget extends DartLintRule {
});
}
}
class CallInitAfterCreation extends DartLintRule {
final String className;
const CallInitAfterCreation({required this.className})
: super(
code: const LintCode(
name: 'call_init_after_creation',
problemMessage: 'Call init() after creation',
));
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
context.registry.addInstanceCreationExpression((node) {
if (node.constructorName.toString() == className) {
final dot = node.endToken.next;
final next = dot?.next;
if (dot?.type == TokenType.PERIOD) {
if (next?.type == TokenType.IDENTIFIER &&
next?.toString() == 'init') {
return;
}
}
reporter.reportErrorForNode(code, node.constructorName);
}
});
}
}