From 7e0f72a5869cc8d6b8a1002d88049cf70639d43e Mon Sep 17 00:00:00 2001 From: Fan DANG Date: Tue, 7 May 2024 23:06:48 +0800 Subject: [PATCH] add PIV --- lib/controller/applets/piv.dart | 67 ++++++ lib/generated/intl/messages_en.dart | 16 +- lib/generated/intl/messages_zh_Hans.dart | 11 +- lib/generated/l10n.dart | 40 ++++ lib/l10n/intl_en.arb | 6 +- lib/l10n/intl_zh_Hans.arb | 6 +- lib/routes.dart | 2 + lib/views/applets/piv.dart | 272 +++++++++++++++++++++++ lib/views/layout/left_bar.dart | 12 +- 9 files changed, 416 insertions(+), 16 deletions(-) create mode 100644 lib/controller/applets/piv.dart create mode 100644 lib/views/applets/piv.dart diff --git a/lib/controller/applets/piv.dart b/lib/controller/applets/piv.dart new file mode 100644 index 0000000..03326a3 --- /dev/null +++ b/lib/controller/applets/piv.dart @@ -0,0 +1,67 @@ +import 'package:canokey_console/controller/base_controller.dart'; +import 'package:canokey_console/generated/l10n.dart'; +import 'package:canokey_console/helper/theme/admin_theme.dart'; +import 'package:canokey_console/helper/utils/prompts.dart'; +import 'package:canokey_console/helper/utils/smartcard.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:logging/logging.dart'; + +final log = Logger('Console:PIV:Controller'); + +class PivController extends Controller { + bool polled = true; + + @override + void onClose() { + try { + ScaffoldMessenger.of(Get.context!).hideCurrentSnackBar(); + ScaffoldMessenger.of(Get.context!).hideCurrentMaterialBanner(); + // ignore: empty_catches + } catch (e) {} + } + + Future refreshData(String pin) async { + SmartCard.process(() async { + SmartCard.assertOK(await SmartCard.transceive('00A4040005F000000000')); + }); + } + + changePin(String oldPin, String newPin) { + SmartCard.process(() async { + SmartCard.assertOK(await SmartCard.transceive('00A4040005A000000308')); + String oldPinHex = _padPin(oldPin); + String newPinHex = _padPin(newPin); + String resp = await SmartCard.transceive('0024008010$oldPinHex$newPinHex'); + if (SmartCard.isOK(resp)) { + Navigator.pop(Get.context!); + Prompts.showPrompt(S.of(Get.context!).successfullyChanged, ContentThemeColor.success); + } else { + Prompts.promptPinFailureResult(resp); + } + }); + } + + changePUK(String oldPin, String newPin) { + SmartCard.process(() async { + SmartCard.assertOK(await SmartCard.transceive('00A4040005A000000308')); + String oldPinHex = _padPin(oldPin); + String newPinHex = _padPin(newPin); + String resp = await SmartCard.transceive('0024008110$oldPinHex$newPinHex'); + if (SmartCard.isOK(resp)) { + Navigator.pop(Get.context!); + Prompts.showPrompt(S.of(Get.context!).successfullyChanged, ContentThemeColor.success); + } else { + Prompts.promptPinFailureResult(resp); + } + }); + } + + String _padPin(String pin) { + String pinHex = pin.codeUnits.map((e) => e.toRadixString(16)).join(); + if (pinHex.length < 16) { + pinHex = pinHex.padRight(16, 'F'); + } + return pinHex; + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 3f070a9..db55cc3 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -33,10 +33,13 @@ class MessageLookup extends MessageLookupByLibrary { static String m4(retries) => "Incorrect PIN. ${retries} retries left."; - static String m5(applet) => + static String m5(min, max) => + "New PUK should be at least ${min} characters long. The maximum length is ${max}."; + + static String m6(applet) => "This operation will RESET all data of ${applet}!"; - static String m6(name) => + static String m7(name) => "This action will delete the account ${name} from your CanoKey. Make sure you have other ways to log in."; final messages = _notInlinedMessages(_notInlinedMessages); @@ -186,6 +189,11 @@ class MessageLookup extends MessageLookupByLibrary { "The provided PIN is too short or too long."), "pinRetries": m4, "pivChangePUK": MessageLookupByLibrary.simpleMessage("Change PUK"), + "pivChangePUKPrompt": m5, + "pivNewPUK": MessageLookupByLibrary.simpleMessage("New PUK"), + "pivOldPUK": MessageLookupByLibrary.simpleMessage("Old PUK"), + "pivPinManagement": + MessageLookupByLibrary.simpleMessage("PIN Management"), "pollCanceled": MessageLookupByLibrary.simpleMessage("No CanoKey is selected."), "pollCanoKey": MessageLookupByLibrary.simpleMessage( @@ -223,7 +231,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Reset CanoKey"), "settingsResetAllPrompt": MessageLookupByLibrary.simpleMessage( "All data is about to be erased. When you confirm, the CanoKey will blink repeatedly. Touch while it is blinking until success."), - "settingsResetApplet": m5, + "settingsResetApplet": m6, "settingsResetConditionNotSatisfying": MessageLookupByLibrary.simpleMessage("PIN has not been locked yet"), "settingsResetNDEF": MessageLookupByLibrary.simpleMessage("Reset NDEF"), @@ -250,7 +258,7 @@ class MessageLookup extends MessageLookupByLibrary { "warning": MessageLookupByLibrary.simpleMessage("Warning"), "webauthnClientPinNotSupported": MessageLookupByLibrary.simpleMessage( "This key does not support WebAuthn PIN."), - "webauthnDelete": m6, + "webauthnDelete": m7, "webauthnInputPinPrompt": MessageLookupByLibrary.simpleMessage( "Please input your WebAuthn PIN."), "webauthnInputPinTitle": diff --git a/lib/generated/intl/messages_zh_Hans.dart b/lib/generated/intl/messages_zh_Hans.dart index 06ac27a..41fd2a0 100644 --- a/lib/generated/intl/messages_zh_Hans.dart +++ b/lib/generated/intl/messages_zh_Hans.dart @@ -30,9 +30,9 @@ class MessageLookup extends MessageLookupByLibrary { static String m4(retries) => "PIN 输入错误,剩余重试次数:${retries}"; - static String m5(applet) => "该操作将抹除 ${applet} 的全部数据!"; + static String m6(applet) => "该操作将抹除 ${applet} 的全部数据!"; - static String m6(name) => "您正在删除${name},删除该项目后无法恢复!请确认您有其他方式登录该服务。"; + static String m7(name) => "您正在删除${name},删除该项目后无法恢复!请确认您有其他方式登录该服务。"; final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { @@ -156,6 +156,9 @@ class MessageLookup extends MessageLookupByLibrary { "pinLength": MessageLookupByLibrary.simpleMessage("输入的 PIN 长度错误"), "pinRetries": m4, "pivChangePUK": MessageLookupByLibrary.simpleMessage("修改 PUK"), + "pivNewPUK": MessageLookupByLibrary.simpleMessage("新 PUK"), + "pivOldPUK": MessageLookupByLibrary.simpleMessage("旧 PUK"), + "pivPinManagement": MessageLookupByLibrary.simpleMessage("管理 PIN"), "pollCanceled": MessageLookupByLibrary.simpleMessage("您没有选择任何 CanoKey"), "pollCanoKey": MessageLookupByLibrary.simpleMessage("请点击右上角刷新按钮读取 CanoKey"), @@ -185,7 +188,7 @@ class MessageLookup extends MessageLookupByLibrary { "settingsResetAll": MessageLookupByLibrary.simpleMessage("重置 CanoKey"), "settingsResetAllPrompt": MessageLookupByLibrary.simpleMessage( "即将抹除全部数据。当您确认后,CanoKey 将会反复闪烁,请在闪烁时触摸,直到提示成功。"), - "settingsResetApplet": m5, + "settingsResetApplet": m6, "settingsResetConditionNotSatisfying": MessageLookupByLibrary.simpleMessage("PIN 尚未锁定"), "settingsResetNDEF": MessageLookupByLibrary.simpleMessage("重置 NDEF"), @@ -209,7 +212,7 @@ class MessageLookup extends MessageLookupByLibrary { "warning": MessageLookupByLibrary.simpleMessage("警告"), "webauthnClientPinNotSupported": MessageLookupByLibrary.simpleMessage("该密钥不支持 WebAuthn PIN。"), - "webauthnDelete": m6, + "webauthnDelete": m7, "webauthnInputPinPrompt": MessageLookupByLibrary.simpleMessage("请输入您的 WebAuthn PIN。"), "webauthnInputPinTitle": diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index e1860c9..a3fdb27 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -1560,6 +1560,16 @@ class S { ); } + /// `PIN Management` + String get pivPinManagement { + return Intl.message( + 'PIN Management', + name: 'pivPinManagement', + desc: '', + args: [], + ); + } + /// `Change PUK` String get pivChangePUK { return Intl.message( @@ -1569,6 +1579,36 @@ class S { args: [], ); } + + /// `Old PUK` + String get pivOldPUK { + return Intl.message( + 'Old PUK', + name: 'pivOldPUK', + desc: '', + args: [], + ); + } + + /// `New PUK` + String get pivNewPUK { + return Intl.message( + 'New PUK', + name: 'pivNewPUK', + desc: '', + args: [], + ); + } + + /// `New PUK should be at least {min} characters long. The maximum length is {max}.` + String pivChangePUKPrompt(Object min, Object max) { + return Intl.message( + 'New PUK should be at least $min characters long. The maximum length is $max.', + name: 'pivChangePUKPrompt', + desc: '', + args: [min, max], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 92fd328..9543467 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -150,5 +150,9 @@ "webauthnDelete": "This action will delete the account {name} from your CanoKey. Make sure you have other ways to log in.", "webauthnPinAuthBlocked": "PIN authentication is blocked. Please reinsert you CanoKey to retry.", "webauthnPinBlocked": "PIN authentication is blocked. Please reset WebAuthn.", - "pivChangePUK": "Change PUK" + "pivPinManagement": "PIN Management", + "pivChangePUK": "Change PUK", + "pivOldPUK": "Old PUK", + "pivNewPUK": "New PUK", + "pivChangePUKPrompt": "New PUK should be at least {min} characters long. The maximum length is {max}." } \ No newline at end of file diff --git a/lib/l10n/intl_zh_Hans.arb b/lib/l10n/intl_zh_Hans.arb index 3ac48c5..e1ca10b 100644 --- a/lib/l10n/intl_zh_Hans.arb +++ b/lib/l10n/intl_zh_Hans.arb @@ -150,5 +150,9 @@ "webauthnDelete": "您正在删除{name},删除该项目后无法恢复!请确认您有其他方式登录该服务。", "webauthnPinAuthBlocked": "PIN 被锁定,请重新插拔 CanoKey。", "webauthnPinBlocked": "PIN 被锁定,请重置 WebAuthn。", - "pivChangePUK": "修改 PUK" + "pivPinManagement": "管理 PIN", + "pivChangePUK": "修改 PUK", + "pivOldPUK": "旧 PUK", + "pivNewPUK": "新 PUK", + "pivChangePinPrompt": "新 PUK 的长度应当为 {min} - {max} 个字符。" } \ No newline at end of file diff --git a/lib/routes.dart b/lib/routes.dart index d441c9a..34e1968 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,5 +1,6 @@ import 'package:canokey_console/views/applets/oath.dart'; import 'package:canokey_console/views/applets/pass.dart'; +import 'package:canokey_console/views/applets/piv.dart'; import 'package:canokey_console/views/applets/webauthn.dart'; import 'package:canokey_console/views/settings.dart'; import 'package:canokey_console/views/starter_screen.dart'; @@ -13,6 +14,7 @@ getPageRoute() { GetPage(name: '/applets/webauthn', page: () => const WebAuthnPage()), GetPage(name: '/applets/oath', page: () => const OathPage()), GetPage(name: '/applets/pass', page: () => const PassPage()), + GetPage(name: '/applets/piv', page: () => const PivPage()), GetPage(name: '/settings', page: () => const SettingsPage()), ]; diff --git a/lib/views/applets/piv.dart b/lib/views/applets/piv.dart new file mode 100644 index 0000000..3aeaced --- /dev/null +++ b/lib/views/applets/piv.dart @@ -0,0 +1,272 @@ +import 'package:canokey_console/controller/applets/piv.dart'; +import 'package:canokey_console/generated/l10n.dart'; +import 'package:canokey_console/helper/theme/admin_theme.dart'; +import 'package:canokey_console/helper/theme/app_style.dart'; +import 'package:canokey_console/helper/theme/app_theme.dart'; +import 'package:canokey_console/helper/utils/prompts.dart'; +import 'package:canokey_console/helper/utils/shadow.dart'; +import 'package:canokey_console/helper/utils/smartcard.dart'; +import 'package:canokey_console/helper/utils/ui_mixins.dart'; +import 'package:canokey_console/helper/widgets/customized_button.dart'; +import 'package:canokey_console/helper/widgets/customized_card.dart'; +import 'package:canokey_console/helper/widgets/customized_text.dart'; +import 'package:canokey_console/helper/widgets/field_validator.dart'; +import 'package:canokey_console/helper/widgets/form_validator.dart'; +import 'package:canokey_console/helper/widgets/responsive.dart'; +import 'package:canokey_console/helper/widgets/spacing.dart'; +import 'package:canokey_console/helper/widgets/validators.dart'; +import 'package:canokey_console/views/layout/layout.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:logging/logging.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +final log = Logger('Console:PIV:View'); + +class PivPage extends StatefulWidget { + const PivPage({Key? key}) : super(key: key); + + @override + State createState() => _PivPageState(); +} + +class _PivPageState extends State with SingleTickerProviderStateMixin, UIMixin { + late PivController controller; + + @override + void initState() { + super.initState(); + controller = Get.put(PivController()); + } + + @override + Widget build(BuildContext context) { + return Layout( + title: 'PIV', + topActions: InkWell( + onTap: () { + if (controller.polled) { + } else { + Prompts.showInputPinDialog( + title: S.of(context).settingsInputPin, + label: "PIN", + prompt: S.of(context).passInputPinPrompt, + ).then((value) { + controller.refreshData(value); + }).onError((error, stackTrace) => null); // User canceled + } + }, + child: Icon(LucideIcons.refreshCw, size: 20, color: topBarTheme.onBackground), + ), + child: GetBuilder( + init: controller, + builder: (_) { + if (!controller.polled) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Spacing.height(MediaQuery.of(context).size.height / 2 - 120), + Center( + child: Padding( + padding: Spacing.horizontal(36), + child: CustomizedText.bodyMedium(S.of(context).pollCanoKey, fontSize: 24), + )), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: Spacing.x(flexSpacing), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Spacing.height(20), + CustomizedCard( + clipBehavior: Clip.antiAliasWithSaveLayer, + shadow: Shadow(elevation: 0.5, position: ShadowPosition.bottom), + paddingAll: 0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: contentTheme.primary.withOpacity(0.08), + padding: Spacing.xy(16, 12), + child: Row( + children: [ + Icon(LucideIcons.keyboard, color: contentTheme.primary, size: 16), + Spacing.width(12), + CustomizedText.titleMedium(S.of(context).pivPinManagement, fontWeight: 600, color: contentTheme.primary) + ], + ), + ), + Padding( + padding: Spacing.xy(flexSpacing, 16), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + CustomizedButton( + onPressed: () { + showChangePinDialog( + title: S.of(context).changePin, + oldValueLabel: S.of(context).oldPin, + newValueLabel: S.of(context).newPin, + prompt: S.of(context).changePinPrompt(6, 8), + validators: [LengthValidator(min: 6, max: 8)], + handler: controller.changePin, + ); + }, + elevation: 0, + padding: Spacing.xy(20, 16), + backgroundColor: contentTheme.primary, + borderRadiusAll: AppStyle.buttonRadius.medium, + child: CustomizedText.bodySmall(S.of(context).changePin, color: contentTheme.onPrimary), + ), + CustomizedButton( + onPressed: () { + showChangePinDialog( + title: S.of(context).pivChangePUK, + oldValueLabel: S.of(context).pivOldPUK, + newValueLabel: S.of(context).pivNewPUK, + prompt: S.of(context).pivChangePUKPrompt(6, 8), + validators: [LengthValidator(min: 6, max: 8)], + handler: controller.changePUK, + ); + }, + elevation: 0, + padding: Spacing.xy(20, 16), + backgroundColor: contentTheme.primary, + borderRadiusAll: AppStyle.buttonRadius.medium, + child: CustomizedText.bodySmall(S.of(context).pivChangePUK, color: contentTheme.onPrimary), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + + showChangePinDialog({ + required String title, + required String oldValueLabel, + required String newValueLabel, + required String prompt, + List validators = const [], + required Function(String, String) handler, + }) { + RxBool showOldPin = false.obs; + RxBool showNewPin = false.obs; + FormValidator validator = FormValidator(); + validator.addField('oldPin', required: true, controller: TextEditingController(), validators: validators); + validator.addField('newPin', required: true, controller: TextEditingController(), validators: validators); + + Get.dialog( + Dialog( + child: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: Spacing.all(16), + child: CustomizedText.labelLarge(title), + ), + Divider(height: 0, thickness: 1), + Padding( + padding: Spacing.all(16), + child: Form( + key: validator.formKey, + child: Column( + children: [ + CustomizedText.bodyMedium(prompt), + Spacing.height(16), + Obx(() => TextFormField( + autofocus: true, + onTap: () => SmartCard.eject(), + obscureText: !showOldPin.value, + controller: validator.getController('oldPin'), + validator: validator.getValidator('oldPin'), + decoration: InputDecoration( + labelText: oldValueLabel, + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(4)), + borderSide: BorderSide(width: 1, strokeAlign: 0, color: AppTheme.theme.colorScheme.onBackground.withAlpha(80)), + ), + floatingLabelBehavior: FloatingLabelBehavior.auto, + suffixIcon: IconButton( + onPressed: () => showOldPin.value = !showOldPin.value, + icon: Icon(showOldPin.value ? Icons.visibility_off_outlined : Icons.visibility_outlined), + ), + ), + )), + Spacing.height(16), + Obx(() => TextFormField( + onTap: () => SmartCard.eject(), + obscureText: !showNewPin.value, + controller: validator.getController('newPin'), + validator: validator.getValidator('newPin'), + decoration: InputDecoration( + labelText: newValueLabel, + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(4)), + borderSide: BorderSide(width: 1, strokeAlign: 0, color: AppTheme.theme.colorScheme.onBackground.withAlpha(80)), + ), + floatingLabelBehavior: FloatingLabelBehavior.auto, + suffixIcon: IconButton( + onPressed: () => showNewPin.value = !showNewPin.value, + icon: Icon(showNewPin.value ? Icons.visibility_off_outlined : Icons.visibility_outlined), + ), + ), + )), + ], + ))), + Divider(height: 0, thickness: 1), + Padding( + padding: Spacing.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + CustomizedButton.rounded( + onPressed: () { + Navigator.pop(Get.context!); + }, + elevation: 0, + padding: Spacing.xy(20, 16), + backgroundColor: ContentThemeColor.secondary.color, + child: CustomizedText.labelMedium(S.of(Get.context!).cancel, color: ContentThemeColor.secondary.onColor), + ), + Spacing.width(16), + CustomizedButton.rounded( + onPressed: () { + if (validator.validateForm()) { + handler(validator.getController('oldPin')!.text, validator.getController('newPin')!.text); + } + }, + elevation: 0, + padding: Spacing.xy(20, 16), + backgroundColor: ContentThemeColor.primary.color, + child: CustomizedText.labelMedium(S.of(Get.context!).confirm, color: ContentThemeColor.primary.onColor), + ), + ], + ), + ), + ], + ), + ), + )); + } +} diff --git a/lib/views/layout/left_bar.dart b/lib/views/layout/left_bar.dart index 1c28c11..c6683e2 100644 --- a/lib/views/layout/left_bar.dart +++ b/lib/views/layout/left_bar.dart @@ -114,12 +114,12 @@ class _LeftBarState extends State with SingleTickerProviderStateMixin, isCondensed: isCondensed, route: '/applets/pass', ), - // NavigationItem( - // iconData: LucideIcons.creditCard, - // title: "PIV", - // isCondensed: isCondensed, - // route: '/applets/piv', - // ), + NavigationItem( + iconData: LucideIcons.creditCard, + title: "PIV", + isCondensed: isCondensed, + route: '/applets/piv', + ), // NavigationItem( // iconData: LucideIcons.lock, // title: "OpenPGP",