Mon, 22 Jul 2024 10:15:41 +0200
Continued implementing the FIDO2 security key management interface.
--- a/eric7.epj Sat Jul 20 11:14:51 2024 +0200 +++ b/eric7.epj Mon Jul 22 10:15:41 2024 +0200 @@ -783,6 +783,7 @@ "src/eric7/WebBrowser/VirusTotal/VirusTotalDomainReportDialog.ui", "src/eric7/WebBrowser/VirusTotal/VirusTotalIpReportDialog.ui", "src/eric7/WebBrowser/VirusTotal/VirusTotalWhoisDialog.ui", + "src/eric7/WebBrowser/WebAuth/Fido2InfoDialog.ui", "src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.ui", "src/eric7/WebBrowser/WebAuth/Fido2PasskeyEditDialog.ui", "src/eric7/WebBrowser/WebAuth/Fido2PinDialog.ui", @@ -2452,6 +2453,7 @@ "src/eric7/WebBrowser/VirusTotal/VirusTotalIpReportDialog.py", "src/eric7/WebBrowser/VirusTotal/VirusTotalWhoisDialog.py", "src/eric7/WebBrowser/VirusTotal/__init__.py", + "src/eric7/WebBrowser/WebAuth/Fido2InfoDialog.py", "src/eric7/WebBrowser/WebAuth/Fido2Management.py", "src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py", "src/eric7/WebBrowser/WebAuth/Fido2PasskeyEditDialog.py",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/WebBrowser/WebAuth/Fido2InfoDialog.py Mon Jul 22 10:15:41 2024 +0200 @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog showing information about the selected security key. +""" + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QDialog, QTreeWidgetItem + +from .Ui_Fido2InfoDialog import Ui_Fido2InfoDialog + + +class Fido2InfoDialog(QDialog, Ui_Fido2InfoDialog): + """ + Class implementing a dialog showing information about the selected security key. + """ + + def __init__(self, header, manager, parent=None): + """ + Constructor + + @param header header string + @type str + @param manager reference to the FIDO2 manager object + @type Fido2Management + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + self.headerLabel.setText(f"<b>{header}</b>") + + data = manager.getSecurityKeyInfo() + if not data: + itm = QTreeWidgetItem( + self.infoWidget, [self.tr("No information available.")] + ) + itm.setFirstColumnSpanned(True) + return + + for key in data: + if data[key]: + topItem = QTreeWidgetItem( + self.infoWidget, [manager.FidoInfoCategories2Str.get(key, key)] + ) + topItem.setFirstColumnSpanned(True) + topItem.setExpanded(True) + for entry in data[key]: + QTreeWidgetItem(topItem, list(entry)) + + self.infoWidget.sortItems(1, Qt.SortOrder.AscendingOrder) + self.infoWidget.sortItems(0, Qt.SortOrder.AscendingOrder) + self.infoWidget.resizeColumnToContents(0) + self.infoWidget.resizeColumnToContents(1)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/WebBrowser/WebAuth/Fido2InfoDialog.ui Mon Jul 22 10:15:41 2024 +0200 @@ -0,0 +1,100 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Fido2InfoDialog</class> + <widget class="QDialog" name="Fido2InfoDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>600</width> + <height>700</height> + </rect> + </property> + <property name="windowTitle"> + <string>Security Key Information</string> + </property> + <property name="sizeGripEnabled"> + <bool>true</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QLabel" name="headerLabel"> + <property name="text"> + <string notr="true">Header</string> + </property> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QTreeWidget" name="infoWidget"> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="columnCount"> + <number>2</number> + </property> + <attribute name="headerVisible"> + <bool>false</bool> + </attribute> + <column> + <property name="text"> + <string notr="true">1</string> + </property> + </column> + <column> + <property name="text"> + <string notr="true">2</string> + </property> + </column> + </widget> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Close</set> + </property> + </widget> + </item> + </layout> + </widget> + <resources/> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>accepted()</signal> + <receiver>Fido2InfoDialog</receiver> + <slot>accept()</slot> + <hints> + <hint type="sourcelabel"> + <x>248</x> + <y>254</y> + </hint> + <hint type="destinationlabel"> + <x>157</x> + <y>274</y> + </hint> + </hints> + </connection> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>Fido2InfoDialog</receiver> + <slot>reject()</slot> + <hints> + <hint type="sourcelabel"> + <x>316</x> + <y>260</y> + </hint> + <hint type="destinationlabel"> + <x>286</x> + <y>274</y> + </hint> + </hints> + </connection> + </connections> +</ui>
--- a/src/eric7/WebBrowser/WebAuth/Fido2Management.py Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Fido2Management.py Mon Jul 22 10:15:41 2024 +0200 @@ -6,11 +6,13 @@ Module implementing a manager for FIDO2 security keys. """ +import time + from fido2.ctap import CtapError from fido2.ctap2 import ClientPin, CredentialManagement, Ctap2 from fido2.hid import CtapHidDevice from fido2.webauthn import PublicKeyCredentialUserEntity -from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QCoreApplication, QObject, QThread, pyqtSignal class Fido2PinError(Exception): @@ -40,6 +42,34 @@ deviceConnected = pyqtSignal() deviceDisconnected = pyqtSignal() + FidoVersion2Str = { + "FIDO_2_1": "CTAP 2.1 / FIDO2", + "FIDO_2_0": "CTAP 2.0 / FIDO2", + "FIDO_2_1_PRE": QCoreApplication.translate( + "Fido2Management", "CTAP2.1 Preview Features" + ), + "U2F_V2": "CTAP 1 / U2F", + } + + FidoExtension2Str = { + "credBlob": QCoreApplication.translate("Fido2Management", "Credential BLOB"), + "credProtect": QCoreApplication.translate( + "Fido2Management", "Credential Protection" + ), + "hmac-secret": QCoreApplication.translate("Fido2Management", "HMAC Secret"), + "largeBlobKey": QCoreApplication.translate("Fido2Management", "Large Blob Key"), + "minPinLength": QCoreApplication.translate( + "Fido2Management", "Minimum PIN Length" + ), + } + + FidoInfoCategories2Str = { + "pin": QCoreApplication.translate("Fido2Management", "PIN"), + "security_key": QCoreApplication.translate("Fido2Management", "Security Key"), + "options": QCoreApplication.translate("Fido2Management", "Options"), + "extensions": QCoreApplication.translate("Fido2Management", "Extensions"), + } + def __init__(self, parent=None): """ Constructor @@ -77,6 +107,13 @@ self.deviceDisconnected.emit() + def reconnectToDevice(self): + """ + Public method to reconnect the current security key. + """ + if self.__ctap2 is not None: + self.connectToDevice(self.__ctap2.device) + def unlockDevice(self, pin): """ Public method to unlock the device (i.e. store the PIN for later use). @@ -111,22 +148,382 @@ """ return list(CtapHidDevice.list_devices()) - def getKeyInfo(self): + def getSecurityKeyInfo(self): """ Public method to get information about the connected security key. @return dictionary containing the info data - @rtype dict[str, Any] + @rtype dict[str, list[tuple[str, str]]] """ - # TODO: not implemented yet - return {} + if self.__ctap2 is None: + return {} + + # each entry is a list of tuples containing the display name and the value + data = { + "pin": [], + "security_key": [], + "options": [], + "extensions": [], + } + + # PIN related data + if self.__ctap2.info.options["clientPin"]: + if self.__ctap2.info.force_pin_change: + msg = self.tr( + "PIN is disabled and must be changed before it can be used!" + ) + pinRetries, powerCycle = self.getPinRetries() + if pinRetries: + if powerCycle: + msg = self.tr( + "PIN is temporarily blocked. Remove and re-insert the" + " security keyto unblock it." + ) + else: + msg = self.tr("%n attempts remaining", "", pinRetries) + else: + msg = self.tr("PIN is blocked. The security key needs to be reset.") + else: + msg = self.tr("A PIN has not been set.") + data["pin"].append((self.tr("PIN"), msg)) + + alwaysUv = self.__ctap2.info.options.get("alwaysUv") + msg = ( + self.tr("not supported") + if alwaysUv is None + else self.tr("switched on") if alwaysUv else self.tr("switched off") + ) + data["pin"].append((self.tr("Always require User Verification"), msg)) + + remainingPasskeys = self.__ctap2.info.remaining_disc_creds + if remainingPasskeys is not None: + data["pin"].append( + (self.tr("Passkeys storage remaining"), str(remainingPasskeys)) + ) + + enterprise = self.__ctap2.info.options.get("ep") + if enterprise is not None: + data["pin"].append( + ( + self.tr("Enterprise Attestation"), + self.tr("enabled") if enterprise else self.tr("disabled"), + ) + ) + + # security key related data + data["security_key"].extend( + [ + (self.tr("Name"), self.__ctap2.device.product_name), + (self.tr("Path"), self.__ctap2.device.descriptor.path), + ( + self.tr("Version"), + ".".join(str(p) for p in self.__ctap2.device.device_version), + ), + (self.tr("Vendor ID"), f"0x{self.__ctap2.device.descriptor.vid:04x}"), + (self.tr("Product ID"), f"0x{self.__ctap2.device.descriptor.pid:04x}"), + ] + ) + serial = self.__ctap2.device.serial_number + if serial is not None: + data["security_key"].append((self.tr("Serial Number"), serial)) + data["security_key"].append( + ( + self.tr("Supported Versions"), + "\n".join( + self.FidoVersion2Str.get(v, v) for v in self.__ctap2.info.versions + ), + ) + ) + data["security_key"].append( + (self.tr("Supported Transports"), "\n".join(self.__ctap2.info.transports)) + ) + + # extensions data + if self.__ctap2.info.extensions: + for ext in self.FidoExtension2Str: + data["extensions"].append( + ( + self.FidoExtension2Str[ext], + ( + self.tr("supported") + if ext in self.__ctap2.info.extensions + else self.tr("not supported") + ), + ) + ) + + # options data + options = self.__ctap2.info.options + data["options"].append( + ( + self.tr("Is Platform Device"), + self.tr("yes") if options.get("plat", False) else self.tr("no"), + ) + ) + data["options"].append( + ( + self.tr("Resident Passkeys"), + ( + self.tr("supported") + if options.get("rk", False) + else self.tr("not supported") + ), + ) + ) + cp = options.get("clientPin") + data["options"].append( + ( + self.tr("Client PIN"), + ( + self.tr("not supported") + if cp is None + else ( + self.tr("supported, PIN set") + if cp is True + else self.tr("supported, PIN not set") + ) + ), + ) + ) + data["options"].append( + ( + self.tr("Detect User Presence"), + ( + self.tr("supported") + if options.get("up", True) + else self.tr("not supported") + ), + ) + ) + uv = options.get("uv") + data["options"].append( + ( + self.tr("User Verification"), + ( + self.tr("not supported") + if uv is None + else ( + self.tr("supported, configured") + if uv is True + else self.tr("supported, not configured") + ) + ), + ) + ) + data["options"].append( + ( + self.tr("Verify User with Client PIN"), + ( + self.tr("available") + if options.get("pinUvAuthToken", False) + else self.tr("not available") + ), + ) + ) + data["options"].append( + ( + self.tr("Make Credential / Get Assertion"), + ( + self.tr("available") + if options.get("noMcGaPermissionsWithClientPin", False) + else self.tr("not available") + ), + ) + ) + data["options"].append( + ( + self.tr("Large BLOBs"), + ( + self.tr("supported") + if options.get("largeBlobs", False) + else self.tr("not supported") + ), + ) + ) + ep = options.get("ep") + data["options"].append( + ( + self.tr("Enterprise Attestation"), + ( + self.tr("not supported") + if ep is None + else ( + self.tr("supported, enabled") + if ep is True + else self.tr("supported, disabled") + ) + ), + ) + ) + be = options.get("bioEnroll") + data["options"].append( + ( + self.tr("Fingerprint"), + ( + self.tr("not supported") + if be is None + else ( + self.tr("supported, registered") + if be is True + else self.tr("supported, not registered") + ) + ), + ) + ) + uvmp = options.get("userVerificationMgmtPreview") + data["options"].append( + ( + self.tr("CTAP2.1 Preview Fingerprint"), + ( + self.tr("not supported") + if uvmp is None + else ( + self.tr("supported, registered") + if uvmp is True + else self.tr("supported, not registered") + ) + ), + ) + ) + data["options"].append( + ( + self.tr("Verify User for Fingerprint Registration"), + ( + self.tr("supported") + if options.get("uvBioEnroll", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Security Key Configuration"), + ( + self.tr("supported") + if options.get("authnrCfg", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Verify User for Security Key Configuration"), + ( + self.tr("supported") + if options.get("uvAcfg", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Credential Management"), + ( + self.tr("supported") + if options.get("credMgmt", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("CTAP2.1 Preview Credential Management"), + ( + self.tr("supported") + if options.get("credentialMgmtPreview", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Set Minimum PIN Length"), + ( + self.tr("supported") + if options.get("setMinPINLength", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Make Non-Resident Passkey without User Verification"), + ( + self.tr("allowed") + if options.get("makeCredUvNotRqd", False) + else self.tr("not allowed") + ), + ) + ) + auv = options.get("alwaysUv") + data["options"].append( + ( + self.tr("Always Require User Verification"), + ( + self.tr("not supported") + if auv is None + else ( + self.tr("supported, enabled") + if auv is True + else self.tr("supported, disabled") + ) + ), + ) + ) + + return data def resetDevice(self): """ Public method to reset the connected security key. + + @return flag indicating success and a message + @rtype tuple of (bool, str) """ - # TODO: not implemented yet - pass + if self.__ctap2 is None: + return False, self.tr("No security key connected.") + + removed = False + startTime = time.monotonic() + while True: + QThread.msleep(500) + try: + securityKeys = self.getDevices() + except OSError: + securityKeys = [] + if not securityKeys: + removed = True + if removed and len(securityKeys) == 1: + ctap2 = Ctap2(securityKeys[0]) + break + if time.monotonic() - startTime >= 30: + return False, self.tr( + "Reset failed. The security key was not removed and re-inserted" + " within 30 seconds." + ) + + try: + ctap2.reset() + return True, "The security key has been reset." + except CtapError as err: + if err.code == CtapError.ERR.ACTION_TIMEOUT: + msg = self.tr( + "You need to touch your security key to confirm the reset." + ) + elif err.code in ( + CtapError.ERR.NOT_ALLOWED, + CtapError.ERR.PIN_AUTH_BLOCKED, + ): + msg = self.tr( + "Reset must be triggered within 5 seconds after the security" + "key is inserted." + ) + else: + msg = str(err) + + return False, self.tr("Reset failed. {0}").format(msg) + except Exception: + return False, self.tr("Reset failed.") ############################################################################ ## methods related to PIN handling @@ -175,25 +572,44 @@ need of a power cycle. @return tuple containing the number of retries left and a flag indicating a - power cycle is required + power cycle is required. A retry value of -1 indicates, that no PIN was + set yet. @rtype tuple of (int, bool) """ if self.__ctap2 is None or self.__clientPin is None: return (None, None) - return self.__clientPin.get_pin_retries() + try: + return self.__clientPin.get_pin_retries() + except CtapError as err: + if err.code == CtapError.ERR.PIN_NOT_SET: + # return -1 retries to indicate a missing PIN + return (-1, False) - def changePin(self, pin, newPin): + def changePin(self, oldPin, newPin): """ Public method to change the PIN of the connected security key. - @param pin current PIN + @param oldPin current PIN @type str @param newPin new PIN @type str + @return flag indicating success and a message + @rtype tuple of (bool, str) """ - # TODO: not implemented yet - pass + if self.__ctap2 is None or self.__clientPin is None: + return False, self.tr("No security key connected.") + + try: + self.__clientPin.change_pin(old_pin=oldPin, new_pin=newPin) + return True, self.tr("PIN was changed successfully.") + except CtapError as err: + return ( + False, + self.tr("<p>Failed to change the PIN.</p><p>Reason: {0}</p>").format( + self.__pinErrorMessage(err) + ), + ) def setPin(self, pin): """ @@ -201,9 +617,22 @@ @param pin PIN to be set @type str + @return flag indicating success and a message + @rtype tuple of (bool, str) """ - # TODO: not implemented yet - pass + if self.__ctap2 is None or self.__clientPin is None: + return False, self.tr("No security key connected.") + + try: + self.__clientPin.set_pin(pin=pin) + return True, self.tr("PIN was set successfully.") + except CtapError as err: + return ( + False, + self.tr("<p>Failed to set the PIN.</p><p>Reason: {0}</p>").format( + self.__pinErrorMessage(err) + ), + ) def verifyPin(self, pin): """ @@ -217,17 +646,17 @@ @rtype tuple of (bool, str) """ if self.__ctap2 is None or self.__clientPin is None: - return False + return False, self.tr("No security key connected.") try: self.__clientPin.get_pin_token( pin, ClientPin.PERMISSION.GET_ASSERTION, "eric-ide.python-projects.org" ) - return True, self.tr("PIN verified") + return True, self.tr("PIN was verified.") except CtapError as err: return ( False, - self.tr("<p>PIN verification failed.</p><p>Reason: {0}").format( + self.tr("<p>PIN verification failed.</p><p>Reason: {0}</p>").format( self.__pinErrorMessage(err) ), ) @@ -248,6 +677,8 @@ msg = self.tr("PIN is blocked.") elif errorCode == CtapError.ERR.PIN_NOT_SET: msg = self.tr("No PIN set.") + elif errorCode == CtapError.ERR.PIN_POLICY_VIOLATION: + msg = self.tr("New PIN doesn't meet complexity requirements.") else: msg = str(err) return msg
--- a/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py Mon Jul 22 10:15:41 2024 +0200 @@ -7,7 +7,13 @@ """ from PyQt6.QtCore import Qt, QTimer, pyqtSlot -from PyQt6.QtWidgets import QDialog, QTreeWidgetItem +from PyQt6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QMenu, + QToolButton, + QTreeWidgetItem, +) from eric7.EricGui import EricPixmapCache from eric7.EricGui.EricOverrideCursor import EricOverrideCursor @@ -31,10 +37,13 @@ DisplayNameColumn = 2 UserNameColumn = 3 - def __init__(self, parent=None): + def __init__(self, standalone=False, parent=None): """ Constructor + @param standalone flag indicating the standalone management application + (defaults to False) + @type bool (optional) @param parent reference to the parent widget (defaults to None) @type QWidget (optional) """ @@ -44,14 +53,56 @@ self.reloadButton.setIcon(EricPixmapCache.getIcon("reload")) self.lockButton.setIcon(EricPixmapCache.getIcon("locked")) + self.menuButton.setObjectName("fido2_supermenu_button") + self.menuButton.setIcon(EricPixmapCache.getIcon("superMenu")) + self.menuButton.setToolTip(self.tr("Security Key Management Menu")) + self.menuButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.menuButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.menuButton.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.menuButton.setAutoRaise(True) + self.menuButton.setShowMenuInside(True) + + self.__initManagementMenu() + + if standalone: + self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setText( + self.tr("Quit") + ) + self.reloadButton.clicked.connect(self.__populateDeviceSelector) self.__manager = Fido2Management(parent=self) - ##self.__manager.deviceConnected.connect(self.__deviceConnected) - ##self.__manager.deviceDisconnected.connect(self.__deviceDisconnected) QTimer.singleShot(0, self.__populateDeviceSelector) + def __initManagementMenu(self): + """ + Private method to initialize the security key management menu with + actions not needed so much. + """ + self.__mgmtMenu = QMenu() + self.__mgmtMenu.addAction(self.tr("Show Info"), self.__showSecurityKeyInfo) + self.__mgmtMenu.addSeparator() + self.__mgmtMenu.addAction( + self.tr("Reset Security Key"), self.__resetSecurityKey + ) + # TODO: potentially add these 'config' actions + # - Force PIN Change + # - Set Minimum PIN Length + # - Toggle 'Always Require UV' + + self.__mgmtMenu.aboutToShow.connect(self.__aboutToShowManagementMenu) + + self.menuButton.setMenu(self.__mgmtMenu) + + @pyqtSlot() + def __aboutToShowManagementMenu(self): + """ + Private slot to prepare the security key management menu before it is shown. + """ + # TODO: not implemented yet + pass + ############################################################################ ## methods related to device handling ############################################################################ @@ -104,8 +155,7 @@ self.lockButton.setEnabled(securityKey is not None) self.pinButton.setEnabled(securityKey is not None) - self.showInfoButton.setEnabled(securityKey is not None) - self.resetButton.setEnabled(securityKey is not None) + self.menuButton.setEnabled(securityKey is not None) self.loadPasskeysButton.setEnabled(securityKey is not None) if securityKey is not None: @@ -166,12 +216,61 @@ self.__manager.lockDevice() @pyqtSlot() - def on_showInfoButton_clicked(self): + def __showSecurityKeyInfo(self): + """ + Private slot to show some info about the selected security key. + """ + from .Fido2InfoDialog import Fido2InfoDialog + + securityKey = self.securityKeysComboBox.currentData() + dlg = Fido2InfoDialog( + header=securityKey.product_name, manager=self.__manager, parent=self + ) + dlg.exec() + + @pyqtSlot() + def __resetSecurityKey(self): + """ + Private slot to reset the selected security key. """ - Slot documentation goes here. - """ - # TODO: not implemented yet - pass + title = self.tr("Reset Security Key") + + yes = EricMessageBox.yesNo( + parent=self, + title=title, + text=self.tr( + "<p>Shall the selected security key really be reset?</p><p><b>WARNING" + ":</b> This will delete all passkeys and restore factory settings.</p>" + ), + ) + if yes: + if len(self.__manager.getDevices()) != 1: + EricMessageBox.critical( + self, + title=title, + text=self.tr( + "Only one security key can be connected to perform a reset." + " Remove all other security keys and try again." + ), + ) + return + + EricMessageBox.information( + self, + title=title, + text=self.tr( + "Confirm this dialog then remove and re-insert the security key." + " Confirm the reset by touching it." + ), + ) + + ok, msg = self.__manager.resetDevice() + if ok: + EricMessageBox.information(self, title, msg) + else: + EricMessageBox.warning(self, title, msg) + + self.__populateDeviceSelector() ############################################################################ ## methods related to PIN handling @@ -241,13 +340,9 @@ dlg = Fido2PinDialog( mode=Fido2PinDialogMode.GET, title=title, - message=self.tr( - "Enter the PIN to unlock the security key (%n attempt(s)" - " remaining.", - "", - retries, - ), + message=self.tr("Enter the PIN to unlock the security key."), minLength=self.__manager.getMinimumPinLength(), + retries=retries, parent=self, ) if dlg.exec() == QDialog.DialogCode.Accepted: @@ -262,23 +357,60 @@ """ Private slot to set a PIN for the selected security key. """ - # TODO: not implemented yet - pass + retries = self.__manager.getPinRetries()[0] + title = self.tr("Set PIN") + + dlg = Fido2PinDialog( + mode=Fido2PinDialogMode.SET, + title=title, + message=self.tr("Enter the PIN for the security key."), + minLength=self.__manager.getMinimumPinLength(), + retries=retries, + parent=self, + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + newPin = dlg.getPins()[1] + ok, msg = self.__manager.setPin(newPin) + if ok: + self.lockButton.setEnabled(True) + self.lockButton.setChecked(False) + self.pinButton.setText(self.tr("Change PIN")) + self.loadPasskeysButton.setEnabled(True) + self.__manager.reconnectToDevice() + EricMessageBox.information(self, title, msg) + else: + EricMessageBox.warning(self, title, msg) @pyqtSlot() def __changePin(self): """ - Private slot to set a PIN for the selected security key. + Private slot to change the PIN of the selected security key. """ - # TODO: not implemented yet - pass + retries = self.__manager.getPinRetries()[0] + title = self.tr("Change PIN") + + dlg = Fido2PinDialog( + mode=Fido2PinDialogMode.CHANGE, + title=title, + message=self.tr("Enter the current and new PINs."), + minLength=self.__manager.getMinimumPinLength(), + retries=retries, + parent=self, + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + oldPin, newPin = dlg.getPins() + ok, msg = self.__manager.changePin(oldPin, newPin) + if ok: + self.lockButton.setChecked(False) + EricMessageBox.information(self, title, msg) + else: + EricMessageBox.warning(self, title, msg) @pyqtSlot() def on_pinButton_clicked(self): """ Private slot to set or change the PIN for the selected security key. """ - # TODO: not implemented yet if self.__manager.hasPin(): self.__changePin() else: @@ -345,14 +477,14 @@ @pyqtSlot() def on_loadPasskeysButton_clicked(self): """ - Slot documentation goes here. + Private slot to (re-)populate the passkeys list. """ self.__populatePasskeysList() @pyqtSlot() def on_passkeysList_itemSelectionChanged(self): """ - Slot documentation goes here. + Private slot handling the selection of a passkey. """ enableButtons = ( len(self.passkeysList.selectedItems()) == 1
--- a/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.ui Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.ui Mon Jul 22 10:15:41 2024 +0200 @@ -60,29 +60,9 @@ </widget> </item> <item> - <widget class="QPushButton" name="showInfoButton"> - <property name="toolTip"> - <string>Press to show a dialog with technical data of the selected security key.</string> - </property> - <property name="text"> - <string>Show Info</string> - </property> - </widget> - </item> - <item> - <widget class="Line" name="line_2"> - <property name="orientation"> - <enum>Qt::Vertical</enum> - </property> - </widget> - </item> - <item> - <widget class="QPushButton" name="resetButton"> - <property name="toolTip"> - <string>Press to reset the selected security key.</string> - </property> - <property name="text"> - <string>Reset Key</string> + <widget class="EricToolButton" name="menuButton"> + <property name="popupMode"> + <enum>QToolButton::InstantPopup</enum> </property> </widget> </item> @@ -292,16 +272,23 @@ </item> </layout> </widget> + <customwidgets> + <customwidget> + <class>EricToolButton</class> + <extends>QToolButton</extends> + <header>eric7/EricWidgets/EricToolButton.h</header> + </customwidget> + </customwidgets> <tabstops> <tabstop>securityKeysComboBox</tabstop> <tabstop>lockButton</tabstop> <tabstop>pinButton</tabstop> - <tabstop>showInfoButton</tabstop> <tabstop>loadPasskeysButton</tabstop> <tabstop>passkeysList</tabstop> <tabstop>editButton</tabstop> <tabstop>deleteButton</tabstop> <tabstop>reloadButton</tabstop> + <tabstop>menuButton</tabstop> </tabstops> <resources/> <connections>
--- a/src/eric7/WebBrowser/WebAuth/Fido2PinDialog.py Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Fido2PinDialog.py Mon Jul 22 10:15:41 2024 +0200 @@ -31,7 +31,7 @@ Class implementing a dialog to enter the current and potentially new PIN. """ - def __init__(self, mode, title, message, minLength, parent=None): + def __init__(self, mode, title, message, minLength, retries, parent=None): """ Constructor @@ -43,6 +43,8 @@ @type str @param minLength minimum PIN length @type int + @param retries number of attempts remaining before the security key get locked + @type int @param parent reference to the parent widget (defaults to None) @type QWidget (optional) """ @@ -63,6 +65,11 @@ self.descriptionLabel.setText(message) else: self.descriptionLabel.setVisible(False) + if self.__mode == Fido2PinDialogMode.SET: + self.remainingWidget.setVisible(False) + else: + self.remainingWidget.setVisible(True) + self.remainingLabel.setText(str(retries)) self.pinErrorLabel.setVisible(False) if mode == Fido2PinDialogMode.GET: @@ -91,15 +98,10 @@ """ messages = [] - if ( - self.__mode in (Fido2PinDialogMode.GET, Fido2PinDialogMode.CHANGE) - and not self.pinEdit.text() - ): - messages.append(self.tr("PIN must not be empty.")) if self.__mode in (Fido2PinDialogMode.SET, Fido2PinDialogMode.CHANGE): if len(self.newPinEdit.text()) < self.__minLength: messages.append( - self.tr("New PIN is TOO short (minimum length: {0}).").format( + self.tr("New PIN is too short (minimum length: {0}).").format( self.__minLength ) ) @@ -133,7 +135,7 @@ if len(errorMessages) == 1: msg = errorMessages[0] else: - msg = "<ul><li>{0}</li></ul>".format("</li><li>".join(errorMessages)) + msg = "- {0}".format("\n- ".join(errorMessages)) self.pinErrorLabel.setText(msg) self.pinErrorLabel.setVisible(True) @@ -169,9 +171,9 @@ self.newPinButton.setIcon(EricPixmapCache.getIcon("showPassword")) self.newPinEdit.setEchoMode(QLineEdit.EchoMode.Password) - self.confirmnewPinLabel.setVisible(not checked) - self.confirmPinnewEdit.setVisible(not checked) - self.on_newPinEdit_textEdited(self.newPinEdit.text()) + self.confirmNewPinLabel.setVisible(not checked) + self.confirmNewPinEdit.setVisible(not checked) + self.__checkPins() def getPins(self): """ @@ -184,7 +186,7 @@ return self.pinEdit.text(), None elif self.__mode == Fido2PinDialogMode.SET: return None, self.newPinEdit.text() - elif self.__mode == Fido2PinDialogMode.GET: + elif self.__mode == Fido2PinDialogMode.CHANGE: return self.pinEdit.text(), self.newPinEdit.text() else: return None, None
--- a/src/eric7/WebBrowser/WebAuth/Fido2PinDialog.ui Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Fido2PinDialog.ui Mon Jul 22 10:15:41 2024 +0200 @@ -6,8 +6,8 @@ <rect> <x>0</x> <y>0</y> - <width>614</width> - <height>251</height> + <width>400</width> + <height>280</height> </rect> </property> <property name="windowTitle"> @@ -29,9 +29,47 @@ <property name="text"> <string notr="true">Description</string> </property> - <property name="wordWrap"> - <bool>true</bool> - </property> + </widget> + </item> + <item> + <widget class="QWidget" name="remainingWidget" native="true"> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Attempts remaining:</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="remainingLabel"/> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>251</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> </widget> </item> <item> @@ -143,6 +181,13 @@ </item> </layout> </widget> + <tabstops> + <tabstop>pinEdit</tabstop> + <tabstop>newPinEdit</tabstop> + <tabstop>confirmNewPinEdit</tabstop> + <tabstop>pinButton</tabstop> + <tabstop>newPinButton</tabstop> + </tabstops> <resources/> <connections> <connection>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/WebBrowser/WebAuth/Ui_Fido2InfoDialog.py Mon Jul 22 10:15:41 2024 +0200 @@ -0,0 +1,45 @@ +# Form implementation generated from reading ui file 'src/eric7/WebBrowser/WebAuth/Fido2InfoDialog.ui' +# +# Created by: PyQt6 UI code generator 6.7.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_Fido2InfoDialog(object): + def setupUi(self, Fido2InfoDialog): + Fido2InfoDialog.setObjectName("Fido2InfoDialog") + Fido2InfoDialog.resize(600, 700) + Fido2InfoDialog.setSizeGripEnabled(True) + self.verticalLayout = QtWidgets.QVBoxLayout(Fido2InfoDialog) + self.verticalLayout.setObjectName("verticalLayout") + self.headerLabel = QtWidgets.QLabel(parent=Fido2InfoDialog) + self.headerLabel.setText("Header") + self.headerLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.headerLabel.setObjectName("headerLabel") + self.verticalLayout.addWidget(self.headerLabel) + self.infoWidget = QtWidgets.QTreeWidget(parent=Fido2InfoDialog) + self.infoWidget.setAlternatingRowColors(True) + self.infoWidget.setColumnCount(2) + self.infoWidget.setObjectName("infoWidget") + self.infoWidget.headerItem().setText(0, "1") + self.infoWidget.headerItem().setText(1, "2") + self.infoWidget.header().setVisible(False) + self.verticalLayout.addWidget(self.infoWidget) + self.buttonBox = QtWidgets.QDialogButtonBox(parent=Fido2InfoDialog) + self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Close) + self.buttonBox.setObjectName("buttonBox") + self.verticalLayout.addWidget(self.buttonBox) + + self.retranslateUi(Fido2InfoDialog) + self.buttonBox.accepted.connect(Fido2InfoDialog.accept) # type: ignore + self.buttonBox.rejected.connect(Fido2InfoDialog.reject) # type: ignore + QtCore.QMetaObject.connectSlotsByName(Fido2InfoDialog) + + def retranslateUi(self, Fido2InfoDialog): + _translate = QtCore.QCoreApplication.translate + Fido2InfoDialog.setWindowTitle(_translate("Fido2InfoDialog", "Security Key Information"))
--- a/src/eric7/WebBrowser/WebAuth/Ui_Fido2ManagementDialog.py Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Ui_Fido2ManagementDialog.py Mon Jul 22 10:15:41 2024 +0200 @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.ui' # -# Created by: PyQt6 UI code generator 6.7.0 +# Created by: PyQt6 UI code generator 6.7.1 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -36,17 +36,10 @@ self.pinButton = QtWidgets.QPushButton(parent=Fido2ManagementDialog) self.pinButton.setObjectName("pinButton") self.horizontalLayout.addWidget(self.pinButton) - self.showInfoButton = QtWidgets.QPushButton(parent=Fido2ManagementDialog) - self.showInfoButton.setObjectName("showInfoButton") - self.horizontalLayout.addWidget(self.showInfoButton) - self.line_2 = QtWidgets.QFrame(parent=Fido2ManagementDialog) - self.line_2.setFrameShape(QtWidgets.QFrame.Shape.VLine) - self.line_2.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line_2.setObjectName("line_2") - self.horizontalLayout.addWidget(self.line_2) - self.resetButton = QtWidgets.QPushButton(parent=Fido2ManagementDialog) - self.resetButton.setObjectName("resetButton") - self.horizontalLayout.addWidget(self.resetButton) + self.menuButton = EricToolButton(parent=Fido2ManagementDialog) + self.menuButton.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) + self.menuButton.setObjectName("menuButton") + self.horizontalLayout.addWidget(self.menuButton) self.verticalLayout_2.addLayout(self.horizontalLayout) self.groupBox = QtWidgets.QGroupBox(parent=Fido2ManagementDialog) self.groupBox.setObjectName("groupBox") @@ -118,12 +111,12 @@ QtCore.QMetaObject.connectSlotsByName(Fido2ManagementDialog) Fido2ManagementDialog.setTabOrder(self.securityKeysComboBox, self.lockButton) Fido2ManagementDialog.setTabOrder(self.lockButton, self.pinButton) - Fido2ManagementDialog.setTabOrder(self.pinButton, self.showInfoButton) - Fido2ManagementDialog.setTabOrder(self.showInfoButton, self.loadPasskeysButton) + Fido2ManagementDialog.setTabOrder(self.pinButton, self.loadPasskeysButton) Fido2ManagementDialog.setTabOrder(self.loadPasskeysButton, self.passkeysList) Fido2ManagementDialog.setTabOrder(self.passkeysList, self.editButton) Fido2ManagementDialog.setTabOrder(self.editButton, self.deleteButton) Fido2ManagementDialog.setTabOrder(self.deleteButton, self.reloadButton) + Fido2ManagementDialog.setTabOrder(self.reloadButton, self.menuButton) def retranslateUi(self, Fido2ManagementDialog): _translate = QtCore.QCoreApplication.translate @@ -133,10 +126,6 @@ self.lockButton.setToolTip(_translate("Fido2ManagementDialog", "Press to unlock the security key, release to lock it again.")) self.pinButton.setToolTip(_translate("Fido2ManagementDialog", "Press to set or change the PIN of the selected security key.")) self.pinButton.setText(_translate("Fido2ManagementDialog", "Set PIN")) - self.showInfoButton.setToolTip(_translate("Fido2ManagementDialog", "Press to show a dialog with technical data of the selected security key.")) - self.showInfoButton.setText(_translate("Fido2ManagementDialog", "Show Info")) - self.resetButton.setToolTip(_translate("Fido2ManagementDialog", "Press to reset the selected security key.")) - self.resetButton.setText(_translate("Fido2ManagementDialog", "Reset Key")) self.groupBox.setTitle(_translate("Fido2ManagementDialog", "Passkeys")) self.loadPasskeysButton.setToolTip(_translate("Fido2ManagementDialog", "Press ro load the passkeys of the selected security key.")) self.loadPasskeysButton.setText(_translate("Fido2ManagementDialog", "Load Passkeys")) @@ -151,3 +140,4 @@ self.deleteButton.setText(_translate("Fido2ManagementDialog", "Delete")) self.label.setText(_translate("Fido2ManagementDialog", "Existing Passkeys:")) self.label_2.setText(_translate("Fido2ManagementDialog", "Max. Remaining Passkeys:")) +from eric7.EricWidgets.EricToolButton import EricToolButton
--- a/src/eric7/WebBrowser/WebAuth/Ui_Fido2PinDialog.py Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Ui_Fido2PinDialog.py Mon Jul 22 10:15:41 2024 +0200 @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'src/eric7/WebBrowser/WebAuth/Fido2PinDialog.ui' # -# Created by: PyQt6 UI code generator 6.7.0 +# Created by: PyQt6 UI code generator 6.7.1 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -12,7 +12,7 @@ class Ui_Fido2PinDialog(object): def setupUi(self, Fido2PinDialog): Fido2PinDialog.setObjectName("Fido2PinDialog") - Fido2PinDialog.resize(614, 251) + Fido2PinDialog.resize(400, 280) Fido2PinDialog.setSizeGripEnabled(True) self.verticalLayout = QtWidgets.QVBoxLayout(Fido2PinDialog) self.verticalLayout.setObjectName("verticalLayout") @@ -22,9 +22,22 @@ self.verticalLayout.addWidget(self.headerLabel) self.descriptionLabel = QtWidgets.QLabel(parent=Fido2PinDialog) self.descriptionLabel.setText("Description") - self.descriptionLabel.setWordWrap(True) self.descriptionLabel.setObjectName("descriptionLabel") self.verticalLayout.addWidget(self.descriptionLabel) + self.remainingWidget = QtWidgets.QWidget(parent=Fido2PinDialog) + self.remainingWidget.setObjectName("remainingWidget") + self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.remainingWidget) + self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.label = QtWidgets.QLabel(parent=self.remainingWidget) + self.label.setObjectName("label") + self.horizontalLayout_2.addWidget(self.label) + self.remainingLabel = QtWidgets.QLabel(parent=self.remainingWidget) + self.remainingLabel.setObjectName("remainingLabel") + self.horizontalLayout_2.addWidget(self.remainingLabel) + spacerItem = QtWidgets.QSpacerItem(251, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_2.addItem(spacerItem) + self.verticalLayout.addWidget(self.remainingWidget) self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout.setObjectName("horizontalLayout") self.pinLabel = QtWidgets.QLabel(parent=Fido2PinDialog) @@ -78,10 +91,15 @@ self.buttonBox.accepted.connect(Fido2PinDialog.accept) # type: ignore self.buttonBox.rejected.connect(Fido2PinDialog.reject) # type: ignore QtCore.QMetaObject.connectSlotsByName(Fido2PinDialog) + Fido2PinDialog.setTabOrder(self.pinEdit, self.newPinEdit) + Fido2PinDialog.setTabOrder(self.newPinEdit, self.confirmNewPinEdit) + Fido2PinDialog.setTabOrder(self.confirmNewPinEdit, self.pinButton) + Fido2PinDialog.setTabOrder(self.pinButton, self.newPinButton) def retranslateUi(self, Fido2PinDialog): _translate = QtCore.QCoreApplication.translate Fido2PinDialog.setWindowTitle(_translate("Fido2PinDialog", "PIN Entry")) + self.label.setText(_translate("Fido2PinDialog", "Attempts remaining:")) self.pinLabel.setText(_translate("Fido2PinDialog", "PIN:")) self.pinEdit.setToolTip(_translate("Fido2PinDialog", "Enter the PIN")) self.pinButton.setToolTip(_translate("Fido2PinDialog", "Press to show or hide the PIN."))
--- a/src/eric7/eric7_fido2.py Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/eric7_fido2.py Mon Jul 22 10:15:41 2024 +0200 @@ -108,7 +108,7 @@ """ from eric7.WebBrowser.WebAuth.Fido2ManagementDialog import Fido2ManagementDialog - return Fido2ManagementDialog() + return Fido2ManagementDialog(standalone=True) def main():