--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py Fri Jul 19 18:06:48 2024 +0200 @@ -0,0 +1,472 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to manage FIDO2 security keys. +""" + +from PyQt6.QtCore import Qt, QTimer, pyqtSlot +from PyQt6.QtWidgets import QDialog, QTreeWidgetItem + +from eric7.EricGui import EricPixmapCache +from eric7.EricGui.EricOverrideCursor import EricOverrideCursor +from eric7.EricWidgets import EricMessageBox + +from .Fido2Management import Fido2DeviceError, Fido2Management, Fido2PinError +from .Fido2PinDialog import Fido2PinDialog, Fido2PinDialogMode +from .Ui_Fido2ManagementDialog import Ui_Fido2ManagementDialog + + +class Fido2ManagementDialog(QDialog, Ui_Fido2ManagementDialog): + """ + Class implementing a dialog to manage FIDO2 security keys. + """ + + CredentialIdRole = Qt.ItemDataRole.UserRole + UserIdRole = Qt.ItemDataRole.UserRole + 1 + + RelyingPartyColumn = 0 + CredentialIdColumn = 1 + DisplayNameColumn = 2 + UserNameColumn = 3 + + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + self.reloadButton.setIcon(EricPixmapCache.getIcon("reload")) + self.lockButton.setIcon(EricPixmapCache.getIcon("locked")) + + 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) + + ############################################################################ + ## methods related to device handling + ############################################################################ + + @pyqtSlot() + def __populateDeviceSelector(self): + """ + Private slot to populate the device selector combo box. + """ + self.__manager.disconnectFromDevice() + self.securityKeysComboBox.clear() + self.reloadButton.setEnabled(False) + + securityKeys = self.__manager.getDevices() + + if len(securityKeys) != 1: + self.securityKeysComboBox.addItem("") + for securityKey in securityKeys: + self.securityKeysComboBox.addItem( + self.tr("{0} ({1})").format( + securityKey.product_name, securityKey.descriptor.path + ), + securityKey, + ) + + self.reloadButton.setEnabled(True) + + if len(securityKeys) == 0: + EricMessageBox.information( + self, + self.tr("FIDO2 Security Key Management"), + self.tr( + """No security key could be detected. Attach a key and press""" + """ the "Reload" button.""" + ), + ) + + @pyqtSlot(int) + def on_securityKeysComboBox_currentIndexChanged(self, index): + """ + Private slot handling the selection of security key. + + @param index index of the selected security key + @type int + """ + self.lockButton.setChecked(False) + self.__manager.disconnectFromDevice() + + securityKey = self.securityKeysComboBox.itemData(index) + + 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.loadPasskeysButton.setEnabled(securityKey is not None) + + if securityKey is not None: + self.__manager.connectToDevice(securityKey) + hasPin = self.__manager.hasPin() + forcedPinChange = self.__manager.forcedPinChange() + if hasPin is True: + self.pinButton.setText(self.tr("Change PIN")) + elif hasPin is False: + self.pinButton.setText(self.tr("Set PIN")) + else: + self.pinButton.setEnabled(False) + if forcedPinChange or hasPin is False: + self.lockButton.setEnabled(False) + self.loadPasskeysButton.setEnabled(False) + msg = ( + self.tr("A PIN change is required.") + if forcedPinChange + else self.tr("You must set a PIN first.") + ) + EricMessageBox.information( + self, + self.tr("FIDO2 Security Key Management"), + msg, + ) + + self.passkeysList.clear() + self.on_passkeysList_itemSelectionChanged() + + @pyqtSlot(bool) + def on_lockButton_toggled(self, checked): + """ + Private slot to handle the toggling of the device locked status. + + @param checked state of the lock/unlock button + @type bool + """ + if checked: + # unlock the selected security key + pin = self.__getRequiredPin(self.tr("Unlock Security Key")) + if pin: + ok, msg = self.__manager.verifyPin(pin=pin) + if ok: + self.lockButton.setIcon(EricPixmapCache.getIcon("unlocked")) + self.__manager.unlockDevice(pin) + else: + EricMessageBox.critical( + self, + self.tr("Unlock Security Key"), + msg, + ) + self.lockButton.setChecked(False) + else: + self.lockButton.setChecked(False) + else: + # lock the selected security key + self.lockButton.setIcon(EricPixmapCache.getIcon("locked")) + self.__manager.lockDevice() + + @pyqtSlot() + def on_showInfoButton_clicked(self): + """ + Slot documentation goes here. + """ + # TODO: not implemented yet + pass + + ############################################################################ + ## methods related to PIN handling + ############################################################################ + + def __checkPinStatus(self, feature): + """ + Private method to check the PIN status of the connected security key. + + @param feature name of the feature requesting the PIN (defaults to None) + @type str (optional) + @return flag indicating a positive status + @rtype bool + """ + feature = self.tr("This feature") if feature is None else f"'{feature}'" + + hasPin = self.__manager.hasPin() + retries, powerCycle = self.__manager.getPinRetries() + + if hasPin is None: + msg = self.tr("{0} is not supported by the selected security key.").format( + feature + ) + elif not hasPin: + msg = self.tr("{0} requires having a PIN. Set a PIN first.").format(feature) + elif self.__manager.forcedPinChange(): + msg = self.tr("The security key is locked. Change the PIN first.") + elif powerCycle: + msg = self.tr( + "The security key is locked because the wrong PIN was entered " + "too many times. To unlock it, remove and reinsert it." + ) + elif retries == 0: + msg = self.tr( + "The security key is locked because the wrong PIN was entered too" + " many times. You will need to reset the security key." + ) + else: + msg = "" + + if msg: + EricMessageBox.critical( + self, + self.tr("FIDO2 Security Key Management"), + msg, + ) + return False + else: + return True + + def __getRequiredPin(self, feature=None): + """ + Private method to check, if a pin has been set for the selected device, and + ask the user to enter it. + + @param feature name of the feature requesting the PIN (defaults to None) + @type str (optional) + @return PIN of the selected security key or None in case of an issue + @rtype str or None + """ + if not self.__checkPinStatus(feature=feature): + return None + else: + if self.__manager.isDeviceLocked(): + retries = self.__manager.getPinRetries()[0] + title = self.tr("PIN required") if feature is None else feature + dlg = Fido2PinDialog( + mode=Fido2PinDialogMode.GET, + title=title, + message=self.tr( + "Enter the PIN to unlock the security key (%n attempt(s)" + " remaining.", + "", + retries, + ), + minLength=self.__manager.getMinimumPinLength(), + parent=self, + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + return dlg.getPins()[0] + else: + return None + else: + return "" + + @pyqtSlot() + def __setPin(self): + """ + Private slot to set a PIN for the selected security key. + """ + # TODO: not implemented yet + pass + + @pyqtSlot() + def __changePin(self): + """ + Private slot to set a PIN for the selected security key. + """ + # TODO: not implemented yet + pass + + @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: + self.__setPin() + + ############################################################################ + ## methods related to passkeys handling + ############################################################################ + + @pyqtSlot() + def __populatePasskeysList(self): + """ + Private slot to populate the list of store passkeys of the selected security + key. + """ + keyIndex = self.securityKeysComboBox.currentData() + if keyIndex is None: + return + + pin = self.__getRequiredPin(feature=self.tr("Credential Management")) + if pin is None: + return + + self.passkeysList.clear() + + try: + with EricOverrideCursor(): + passkeys, existingCount, remainingCount = self.__manager.getPasskeys( + pin=pin + ) + except (Fido2DeviceError, Fido2PinError) as err: + self.__handleError( + error=err, + title=self.tr("Load Passkeys"), + message=self.tr("The stored passkeys could not be loaded."), + ) + return + + self.existingCountLabel.setText(str(existingCount)) + self.remainingCountLabel.setText(str(remainingCount)) + + for relyingParty in passkeys: + rpItem = QTreeWidgetItem(self.passkeysList, [relyingParty]) + rpItem.setFirstColumnSpanned(True) + rpItem.setExpanded(True) + for passDict in passkeys[relyingParty]: + item = QTreeWidgetItem( + rpItem, + [ + "", + passDict["credentialId"]["id"].hex(), + passDict["displayName"], + passDict["userName"], + ], + ) + item.setData(0, self.CredentialIdRole, passDict["credentialId"]) + item.setData(0, self.UserIdRole, passDict["userId"]) + + self.passkeysList.sortItems(self.DisplayNameColumn, Qt.SortOrder.AscendingOrder) + self.passkeysList.sortItems( + self.RelyingPartyColumn, Qt.SortOrder.AscendingOrder + ) + + @pyqtSlot() + def on_loadPasskeysButton_clicked(self): + """ + Slot documentation goes here. + """ + self.__populatePasskeysList() + + @pyqtSlot() + def on_passkeysList_itemSelectionChanged(self): + """ + Slot documentation goes here. + """ + enableButtons = ( + len(self.passkeysList.selectedItems()) == 1 + and self.passkeysList.selectedItems()[0].parent() is not None + ) + self.editButton.setEnabled(enableButtons) + self.deleteButton.setEnabled(enableButtons) + + @pyqtSlot() + def on_editButton_clicked(self): + """ + Private slot to edit the selected passkey. + """ + from .Fido2PasskeyEditDialog import Fido2PasskeyEditDialog + + selectedItem = self.passkeysList.selectedItems()[0] + dlg = Fido2PasskeyEditDialog( + displayName=selectedItem.text(self.DisplayNameColumn), + userName=selectedItem.text(self.UserNameColumn), + relyingParty=selectedItem.parent().text(self.RelyingPartyColumn), + parent=self, + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + displayName, userName = dlg.getData() + if displayName != selectedItem.text( + self.DisplayNameColumn + ) or userName != selectedItem.text(self.UserNameColumn): + # only change on the security key, if there is really a change + pin = self.__getRequiredPin(feature=self.tr("Change User Info")) + try: + self.__manager.changePasskeyUserInfo( + pin=pin, + credentialId=selectedItem.data(0, self.CredentialIdRole), + userId=selectedItem.data(0, self.UserIdRole), + userName=userName, + displayName=displayName, + ) + except (Fido2DeviceError, Fido2PinError) as err: + self.__handleError( + error=err, + title=self.tr("Change User Info"), + message=self.tr("The user info could not be changed."), + ) + return + + selectedItem.setText(self.DisplayNameColumn, displayName) + selectedItem.setText(self.UserNameColumn, userName) + + @pyqtSlot() + def on_deleteButton_clicked(self): + """ + Private slot to delete the selected passkey. + """ + selectedItem = self.passkeysList.selectedItems()[0] + + ok = EricMessageBox.yesNo( + self, + self.tr("Delete Passkey"), + self.tr( + "<p>Shall the selected passkey really be deleted?</p>" + "<ul>" + "<li>Relying Party: {0}</li>" + "<li>Display Name: {1}</li>" + "<li>User Name: {2}</li>" + "</ul>" + ).format( + selectedItem.parent().text(self.RelyingPartyColumn), + selectedItem.text(self.DisplayNameColumn), + selectedItem.text(self.UserNameColumn), + ), + ) + if ok: + pin = self.__getRequiredPin(feature=self.tr("Delete Passkey")) + try: + self.__manager.deletePasskey( + pin=pin, + credentialId=selectedItem.data(0, self.CredentialIdRole), + ) + except (Fido2DeviceError, Fido2PinError) as err: + self.__handleError( + error=err, + title=self.tr("Delete Passkey"), + message=self.tr("The passkey could not be deleted."), + ) + return + + rpItem = selectedItem.parent() + index = rpItem.indexOfChild(selectedItem) + rpItem.takeChild(index) + del selectedItem + if rpItem.childCount() == 0: + index = self.passkeysList.indexOfTopLevelItem(rpItem) + self.passkeysList.takeTopLevelItem(index) + del rpItem + + ############################################################################ + ## utility methods + ############################################################################ + + def __handleError(self, error, title, message): + """ + Private method to handle an error reported by the manager. + + @param error reference to the exception object + @type Exception + @param title tirle of the message box + @type str + @param message message to be shown + @type str + """ + EricMessageBox.critical( + self, + title, + self.tr("<p>{0}</p><p>Reason: {1}</p>").format(message, str(error)), + ) + if isinstance(error, Fido2DeviceError): + self.__populateDeviceSelector()