src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py

Fri, 19 Jul 2024 18:06:48 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Fri, 19 Jul 2024 18:06:48 +0200
branch
eric7
changeset 10854
30c45bd597e6
child 10856
b19cefceca15
permissions
-rw-r--r--

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()

eric ide

mercurial