diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 027800a..f93bb91 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,6 +1,8 @@ PODS: - ccid (0.1.1): - Flutter + - cryptography_flutter (0.2.0): + - Flutter - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.8): @@ -37,6 +39,8 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter + - file_saver (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_nfc_kit (2.0.0): - Flutter @@ -112,8 +116,10 @@ PODS: DEPENDENCIES: - ccid (from `.symlinks/plugins/ccid/ios`) + - cryptography_flutter (from `.symlinks/plugins/cryptography_flutter/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) + - file_saver (from `.symlinks/plugins/file_saver/ios`) - Flutter (from `Flutter`) - flutter_nfc_kit (from `.symlinks/plugins/flutter_nfc_kit/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) @@ -145,10 +151,14 @@ SPEC REPOS: EXTERNAL SOURCES: ccid: :path: ".symlinks/plugins/ccid/ios" + cryptography_flutter: + :path: ".symlinks/plugins/cryptography_flutter/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" + file_saver: + :path: ".symlinks/plugins/file_saver/ios" Flutter: :path: Flutter flutter_nfc_kit: @@ -166,10 +176,12 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: ccid: f196fb7dc141fa1ee3497ee6e5486a483f1ef9d1 + cryptography_flutter: 381bdacc984abcfbe3ca45ef7c76566ff061614c device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: a7836546cfdfe014171694f643a7d575bc8ace7f DKPhotoGallery: acbd8a3bab19cf6e5fe64a853fc07bfbd247a8f6 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_nfc_kit: 965c98c3fa68f5609f1cc89abb968fe1b8ffdbaa flutter_webrtc: 9bc044b0b5bcaabd0fb7d52c90421fb540f8c35e diff --git a/lib/controller/applets/piv.dart b/lib/controller/applets/piv.dart index 1a742ca..f8cadff 100644 --- a/lib/controller/applets/piv.dart +++ b/lib/controller/applets/piv.dart @@ -1,7 +1,6 @@ import 'dart:async'; -import 'dart:typed_data'; -import 'package:asn1lib/asn1lib.dart'; +import 'package:basic_utils/basic_utils.dart'; import 'package:canokey_console/controller/base_controller.dart'; import 'package:canokey_console/generated/l10n.dart'; import 'package:canokey_console/helper/theme/admin_theme.dart'; @@ -13,7 +12,7 @@ import 'package:dart_des/dart_des.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:logging/logging.dart'; -import 'package:x509/x509.dart'; +import 'package:pem/pem.dart'; final log = Logger('Console:PIV:Controller'); @@ -44,13 +43,8 @@ class PivController extends Controller { if (_certDO.containsKey(slot)) { resp = await _transceive('00CB3FFF055C035FC1${hex.encode([_certDO[slot]!])}00'); if (SmartCard.isOK(resp)) { - var bytes = hex.decode(resp.substring(16, resp.length - 4)); - var p = ASN1Parser(bytes as Uint8List); - var o = p.nextObject(); - if (o is! ASN1Sequence) { - throw FormatException('Expected SEQUENCE, got ${o.runtimeType}'); - } - var cert = X509Certificate.fromAsn1(o); + final bytes = hex.decode(resp.substring(16, resp.length - 4)); + final cert = X509Utils.x509CertificateFromPem(PemCodec(PemLabel.certificate).encode(bytes)); slotInfo.cert = cert; slotInfo.certBytes = bytes; } diff --git a/lib/models/piv.dart b/lib/models/piv.dart index 686f35d..5e1b862 100644 --- a/lib/models/piv.dart +++ b/lib/models/piv.dart @@ -1,5 +1,5 @@ +import 'package:basic_utils/basic_utils.dart'; import 'package:canokey_console/helper/tlv.dart'; -import 'package:x509/src/x509_base.dart'; enum AlgorithmType { pin(0xFF), @@ -137,7 +137,7 @@ class SlotInfo { final int retriesCount; final int remainingCount; List? certBytes; - X509Certificate? cert; + X509CertificateData? cert; SlotInfo(this.number, this.algorithm, this.pinPolicy, this.touchPolicy, this.origin, this.public, this.defaultValue, this.retriesCount, this.remainingCount); diff --git a/lib/views/applets/oath.dart b/lib/views/applets/oath.dart index 698ecee..68c6186 100644 --- a/lib/views/applets/oath.dart +++ b/lib/views/applets/oath.dart @@ -8,10 +8,10 @@ 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_text.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_container.dart'; +import 'package:canokey_console/helper/widgets/customized_text.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'; diff --git a/lib/views/applets/piv.dart b/lib/views/applets/piv.dart index 2d4b686..a367eb3 100644 --- a/lib/views/applets/piv.dart +++ b/lib/views/applets/piv.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; +import 'package:basic_utils/basic_utils.dart'; import 'package:canokey_console/controller/applets/piv.dart'; import 'package:canokey_console/generated/l10n.dart'; import 'package:canokey_console/helper/extensions/date_time_extension.dart'; @@ -24,6 +25,7 @@ import 'package:canokey_console/helper/widgets/validators.dart'; import 'package:canokey_console/models/piv.dart'; import 'package:canokey_console/views/layout/layout.dart'; import 'package:convert/convert.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -477,7 +479,7 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, InkWell(child: CustomizedText.bodySmall('${S.of(context).pivAlgorithm}: ${slot.algorithm.name.toUpperCase()}')), InkWell( child: CustomizedText.bodySmall( - '${S.of(context).pivCertificate}: ${slot.cert?.tbsCertificate.subject?.toString() ?? S.of(context).pivEmpty}')), + '${S.of(context).pivCertificate}: ${_displayDN(slot.cert!.tbsCertificate!.subject) ?? S.of(context).pivEmpty}')), ] else ...[ CustomizedText.bodySmall(S.of(context).pivEmpty), ], @@ -493,7 +495,7 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, _showSlotDetailDialog(String title, String slotNumber, SlotInfo? slot) { Get.dialog(Dialog( child: SizedBox( - width: 430, + width: slot == null ? 400 : max(430, MediaQuery.of(context).size.width * 0.6), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -510,31 +512,38 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, child: Column( children: [ TextFormField( - initialValue: slot.cert!.tbsCertificate.subject!.toString(), + initialValue: _displayDN(slot.cert!.tbsCertificate!.subject), readOnly: true, decoration: InputDecoration(labelText: 'Subject', border: outlineInputBorder, floatingLabelBehavior: FloatingLabelBehavior.auto), ), Spacing.height(16), TextFormField( - initialValue: slot.cert!.tbsCertificate.issuer!.toString(), + initialValue: _displayDN(slot.cert!.tbsCertificate!.issuer), readOnly: true, decoration: InputDecoration(labelText: 'Issuer', border: outlineInputBorder, floatingLabelBehavior: FloatingLabelBehavior.auto), ), Spacing.height(16), TextFormField( - initialValue: slot.cert!.tbsCertificate.serialNumber!.toRadixString(16), + initialValue: slot.cert!.tbsCertificate!.serialNumber.toRadixString(16), readOnly: true, decoration: InputDecoration(labelText: 'Serial', border: outlineInputBorder, floatingLabelBehavior: FloatingLabelBehavior.auto), ), Spacing.height(16), TextFormField( - initialValue: slot.cert!.tbsCertificate.validity!.notBefore.toIsoDateString(), + initialValue: slot.cert!.sha256Thumbprint, + readOnly: true, + decoration: + InputDecoration(labelText: 'Fingerprint (SHA256)', border: outlineInputBorder, floatingLabelBehavior: FloatingLabelBehavior.auto), + ), + Spacing.height(16), + TextFormField( + initialValue: slot.cert!.tbsCertificate!.validity.notBefore.toIsoDateString(), readOnly: true, decoration: InputDecoration(labelText: 'Valid from', border: outlineInputBorder, floatingLabelBehavior: FloatingLabelBehavior.auto), ), Spacing.height(16), TextFormField( - initialValue: slot.cert!.tbsCertificate.validity!.notAfter.toIsoDateString(), + initialValue: slot.cert!.tbsCertificate!.validity.notAfter.toIsoDateString(), readOnly: true, decoration: InputDecoration(labelText: 'Valid to', border: outlineInputBorder, floatingLabelBehavior: FloatingLabelBehavior.auto), ), @@ -550,7 +559,7 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, mainAxisAlignment: MainAxisAlignment.center, children: [ CustomizedButton.rounded( - onPressed: () => Navigator.pop(context), + onPressed: () {}, elevation: 0, padding: Spacing.xy(20, 16), backgroundColor: contentTheme.primary, @@ -558,7 +567,7 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, ), Spacing.width(12), CustomizedButton.rounded( - onPressed: () {}, + onPressed: _showImportDialog, elevation: 0, padding: Spacing.xy(20, 16), backgroundColor: contentTheme.primary, @@ -567,47 +576,7 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, if (slot != null) ...[ Spacing.width(12), CustomizedButton.rounded( - onPressed: () { - // A dialog with two buttons: DER and PEM - Get.dialog(Dialog( - child: SizedBox( - width: 300, - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding(padding: Spacing.all(16), child: CustomizedText.labelLarge(S.of(context).pivExportCertificate)), - Divider(height: 0, thickness: 1), - Padding( - padding: Spacing.all(16), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - CustomizedButton.rounded( - onPressed: () async { - // Export DER - await FileSaver.instance.saveFile(name: 'certificate.der', bytes: slot.certBytes! as Uint8List); - if (mounted) { - Navigator.pop(context); - } - }, - elevation: 0, - padding: Spacing.xy(20, 16), - backgroundColor: contentTheme.primary, - child: CustomizedText.labelMedium('DER', color: contentTheme.onPrimary), - ), - Spacing.width(12), - CustomizedButton.rounded( - onPressed: () async { - String pem = PemCodec(PemLabel.certificate).encode(slot.certBytes!); - await FileSaver.instance.saveFile(name: 'certificate.pem', bytes: utf8.encode(pem)); - if (mounted) { - Navigator.pop(context); - } - }, - elevation: 0, - padding: Spacing.xy(20, 16), - backgroundColor: contentTheme.primary, - child: CustomizedText.labelMedium('PEM', color: contentTheme.onPrimary), - ) - ])) - ])))); - }, + onPressed: () => _showExportDialog(slot), elevation: 0, padding: Spacing.xy(20, 16), backgroundColor: contentTheme.primary, @@ -631,6 +600,180 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, )); } + _showExportDialog(SlotInfo slot) { + Get.dialog(Dialog( + child: SizedBox( + width: 300, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: Spacing.all(16), child: CustomizedText.labelLarge(S.of(context).pivExportCertificate)), + Divider(height: 0, thickness: 1), + Padding( + padding: Spacing.all(16), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + CustomizedButton.rounded( + onPressed: () async { + // Export DER + await FileSaver.instance.saveFile(name: 'certificate.der', bytes: slot.certBytes! as Uint8List); + Get.back(); + }, + elevation: 0, + padding: Spacing.xy(20, 16), + backgroundColor: contentTheme.primary, + child: CustomizedText.labelMedium('DER', color: contentTheme.onPrimary), + ), + Spacing.width(12), + CustomizedButton.rounded( + onPressed: () async { + String pem = PemCodec(PemLabel.certificate).encode(slot.certBytes!); + await FileSaver.instance.saveFile(name: 'certificate.pem', bytes: utf8.encode(pem)); + Get.back(); + }, + elevation: 0, + padding: Spacing.xy(20, 16), + backgroundColor: contentTheme.primary, + child: CustomizedText.labelMedium('PEM', color: contentTheme.onPrimary), + ) + ])) + ])))); + } + + _showImportDialog() { + Rx step = 0.obs; + Rx hasCert = false.obs; + Rx hasKey = false.obs; + Rx selected = false.obs; + String? pinPolicy; + + void nextStep() { + if (step < 2) { + setState(() => step.value++); + } else { + // TODO: Generate certificate + Get.back(); + } + } + + Get.dialog(Dialog( + child: Obx( + () => SizedBox( + width: 400, + child: Stepper( + currentStep: step.value, + onStepContinue: nextStep, + onStepCancel: () => Get.back(), + controlsBuilder: (BuildContext context, ControlsDetails details) { + return Row( + children: [ + if (details.stepIndex > 0) ...{ + CustomizedButton.rounded( + onPressed: details.onStepContinue, + elevation: 0, + backgroundColor: ContentThemeColor.primary.color, + child: CustomizedText.labelMedium('Next', color: ContentThemeColor.primary.onColor), + ), + Spacing.width(12), + }, + CustomizedButton.rounded( + onPressed: details.onStepCancel, + elevation: 0, + backgroundColor: ContentThemeColor.secondary.color, + child: CustomizedText.labelMedium('Cancel', color: ContentThemeColor.secondary.onColor), + ), + ], + ); + }, + steps: [ + Step( + title: Text('Select Your Certificate'), + content: InkWell( + onTap: () async { + final result = await FilePicker.platform.pickFiles(); + final file = result?.files.firstOrNull; + if (file != null) { + selected.value = true; + final pem = utf8.decode(file.bytes!); + pem.split('-----BEGIN ').forEach((element) { + if (element.isNotEmpty) { + final item = '-----BEGIN $element'; + if (item.startsWith(CryptoUtils.BEGIN_EC_PRIVATE_KEY)) { + final ecPrivateKey = CryptoUtils.ecPrivateKeyFromPem(item); + hasKey.value = true; + } else if (item.startsWith(CryptoUtils.BEGIN_RSA_PRIVATE_KEY)) { + final rsaPrivateKey = CryptoUtils.rsaPrivateKeyFromPem(item); + hasKey.value = true; + } else if (item.startsWith(X509Utils.BEGIN_CERT)) { + final cert = X509Utils.x509CertificateFromPem(item); + hasCert.value = true; + } + } + }); + if (hasKey.value && hasCert.value) { + nextStep(); + } + } + }, + child: CustomizedContainer.bordered( + child: Center( + heightFactor: 1.2, + child: Padding( + padding: Spacing.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(LucideIcons.uploadCloud, size: 24), + CustomizedContainer( + width: 340, + alignment: Alignment.center, + paddingAll: 0, + child: CustomizedText.titleMedium( + "Click to select a certificate", + fontWeight: 600, + muted: true, + fontSize: 18, + textAlign: TextAlign.center, + ), + ), + if (selected.value && !hasCert.value && !hasKey.value) + CustomizedContainer( + alignment: Alignment.center, + child: CustomizedText.titleMedium( + "(Make sure the file contains a plaintext key and a certificate)", + muted: true, + fontWeight: 500, + fontSize: 12, + textAlign: TextAlign.center, + color: contentTheme.danger, + ), + ), + ], + ), + ), + ), + ), + ), + ), + Step( + title: Text('PIN and Touch Policy'), + content: Column( + children: [ + DropdownButtonFormField( + value: pinPolicy, + items: ['Default', 'Never', 'Once', 'Always'].map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(), + onChanged: (value) => setState(() => pinPolicy = value), + decoration: InputDecoration(labelText: 'PIN Policy'), + dropdownColor: contentTheme.background, + ), + ], + ), + ) + ], + ), + ), + ), + )); + } + String _pinPolicy(PinPolicy policy) { switch (policy) { case PinPolicy.never: @@ -663,4 +806,12 @@ class _PivPageState extends State with SingleTickerProviderStateMixin, } return S.of(context).pivOriginImported; } + + String? _displayDN(Map? data) { + if (data == null) { + return null; + } + final dnMap = Map.fromEntries(X509Utils.DN.entries.map((e) => MapEntry(e.value, e.key))); + return data.keys.map((e) => '${dnMap[e]}=${data[e]}').join(', '); + } } diff --git a/pubspec.yaml b/pubspec.yaml index ebb5bda..4d8e8b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,10 +43,9 @@ dependencies: ccid: ^0.1.1 device_info_plus: ^10.1.0 dart_des: ^1.0.2 - x509: ^0.2.4+2 - asn1lib: ^1.5.2 pem: ^2.0.5 file_saver: ^0.2.12 + basic_utils: ^5.7.0 dev_dependencies: flutter_test: