Fri, 19 Jul 2024 18:06:48 +0200
Started implementing a dialog to manage FIDO2 security keys.
# -*- 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()