src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py

Mon, 22 Jul 2024 10:15:41 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Mon, 22 Jul 2024 10:15:41 +0200
branch
eric7
changeset 10856
b19cefceca15
parent 10854
30c45bd597e6
child 10857
abcb288e7e17
permissions
-rw-r--r--

Continued implementing the FIDO2 security key management interface.

# -*- 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,
    QDialogButtonBox,
    QMenu,
    QToolButton,
    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, 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)
        """
        super().__init__(parent)
        self.setupUi(self)

        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)

        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
    ############################################################################

    @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.menuButton.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 __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.
        """
        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
    ############################################################################

    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."),
                    minLength=self.__manager.getMinimumPinLength(),
                    retries=retries,
                    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.
        """
        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 change the PIN of the selected security key.
        """
        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.
        """
        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):
        """
        Private slot to (re-)populate the passkeys list.
        """
        self.__populatePasskeysList()

    @pyqtSlot()
    def on_passkeysList_itemSelectionChanged(self):
        """
        Private slot handling the selection of a passkey.
        """
        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