src/eric7/WebBrowser/WebAuth/Fido2Management.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 manager for FIDO2 security keys.
"""

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


class Fido2PinError(Exception):
    """
    Class signaling an issue with the PIN.
    """

    pass


class Fido2DeviceError(Exception):
    """
    Class signaling an issue with the device.
    """

    pass


class Fido2Management(QObject):
    """
    Class implementing a manager for FIDO2 security keys.

    @signal deviceConnected() emitted to indicate a connect to the security key
    @signal deviceDisconnected() emitted to indicate a disconnect from the security key
    """

    deviceConnected = pyqtSignal()
    deviceDisconnected = pyqtSignal()

    def __init__(self, parent=None):
        """
        Constructor

        @param parent reference to the parent object (defaults to None)
        @type QObject (optional)
        """
        super().__init__(parent)

        self.disconnectFromDevice()

    def connectToDevice(self, device):
        """
        Public method to connect to a given security key.

        @param device reference to the security key device class
        @type CtapHidDevice
        """
        if self.__ctap2 is not None:
            self.disconnectFromDevice()

        self.__ctap2 = Ctap2(device)
        self.__clientPin = ClientPin(self.__ctap2)
        self.__pin = None

        self.deviceConnected.emit()

    def disconnectFromDevice(self):
        """
        Public method to disconnect from the current device.
        """
        self.__ctap2 = None
        self.__clientPin = None
        self.__pin = None

        self.deviceDisconnected.emit()

    def unlockDevice(self, pin):
        """
        Public method to unlock the device (i.e. store the PIN for later use).

        @param pin PIN to be stored
        @type str
        """
        self.__pin = pin

    def lockDevice(self):
        """
        Public method to lock the device (i.e. delete the stored PIN).
        """
        self.__pin = None

    def isDeviceLocked(self):
        """
        Public method to check, if the device is in locked state (i.e. the stored PIN
        is None).

        @return flag indicating the locked state
        @rtype bool
        """
        return self.__pin is None

    def getDevices(self):
        """
        Public method to get a list of connected security keys.

        @return list of connected security keys
        @rtype list of CtapHidDevice
        """
        return list(CtapHidDevice.list_devices())

    def getKeyInfo(self):
        """
        Public method to get information about the connected security key.

        @return dictionary containing the info data
        @rtype dict[str, Any]
        """
        # TODO: not implemented yet
        return {}

    def resetDevice(self):
        """
        Public method to reset the connected security key.
        """
        # TODO: not implemented yet
        pass

    ############################################################################
    ## methods related to PIN handling
    ############################################################################

    def getMinimumPinLength(self):
        """
        Public method to get the minimum PIN length defined by the security key.

        @return minimum length for the PIN
        @rtype int
        """
        if self.__ctap2 is None:
            return None
        else:
            return self.__ctap2.info.min_pin_length

    def hasPin(self):
        """
        Public method to check, if the connected security key has a PIN set.

        @return flag indicating that a PIN has been set or None in case no device
            was connected yet or it does not support PIN
        @rtype bool or None
        """
        if self.__ctap2 is None:
            return None

        return self.__ctap2.info.options.get("clientPin")

    def forcedPinChange(self):
        """
        Public method to check for a forced PIN change.

        @return flag indicating a forced PIN change is required
        @rtype bool
        """
        if self.__ctap2 is None:
            return False

        return self.__ctap2.info.force_pin_change

    def getPinRetries(self):
        """
        Public method to get the number of PIN retries left and an indication for the
        need of a power cycle.

        @return tuple containing the number of retries left and a flag indicating a
            power cycle is required
        @rtype tuple of (int, bool)
        """
        if self.__ctap2 is None or self.__clientPin is None:
            return (None, None)

        return self.__clientPin.get_pin_retries()

    def changePin(self, pin, newPin):
        """
        Public method to change the PIN of the connected security key.

        @param pin current PIN
        @type str
        @param newPin new PIN
        @type str
        """
        # TODO: not implemented yet
        pass

    def setPin(self, pin):
        """
        Public method to set a PIN for the connected security key.

        @param pin PIN to be set
        @type str
        """
        # TODO: not implemented yet
        pass

    def verifyPin(self, pin):
        """
        Public method to verify a given PIN.

        A successful verification of the PIN will reset the "retries" counter.

        @param pin PIN to be verified
        @type str
        @return flag indicating successful verification and a verification message
        @rtype tuple of (bool, str)
        """
        if self.__ctap2 is None or self.__clientPin is None:
            return False

        try:
            self.__clientPin.get_pin_token(
                pin, ClientPin.PERMISSION.GET_ASSERTION, "eric-ide.python-projects.org"
            )
            return True, self.tr("PIN verified")
        except CtapError as err:
            return (
                False,
                self.tr("<p>PIN verification failed.</p><p>Reason: {0}").format(
                    self.__pinErrorMessage(err)
                ),
            )

    def __pinErrorMessage(self, err):
        """
        Private method to get a message for a PIN error.

        @param err reference to the exception object
        @type CtapError
        @return message for the given PIN error
        @rtype str
        """
        errorCode = err.code
        if errorCode == CtapError.ERR.PIN_INVALID:
            msg = self.tr("Invalid PIN")
        elif errorCode == CtapError.ERR.PIN_BLOCKED:
            msg = self.tr("PIN is blocked.")
        elif errorCode == CtapError.ERR.PIN_NOT_SET:
            msg = self.tr("No PIN set.")
        else:
            msg = str(err)
        return msg

    ############################################################################
    ## methods related to passkey (credential) handling
    ############################################################################

    def getPasskeys(self, pin):
        """
        Public method to get all stored passkeys.

        @param pin PIN to unlock the connected security key
        @type str
        @return tuple containing a dictionary containing the stored passkeys grouped
            by Relying Party ID, the count of used credential slots and the count
            of available credential slots
        @rtype tuple of [dict[str, list[dict[str, Any]]], int, int]
        """
        credentials = {}

        credentialManager = self.__initializeCredentialManager(pin)
        data = credentialManager.get_metadata()
        if data.get(CredentialManagement.RESULT.EXISTING_CRED_COUNT) > 0:
            for relyingParty in credentialManager.enumerate_rps():
                relyingPartyId = relyingParty[CredentialManagement.RESULT.RP]["id"]
                credentials[relyingPartyId] = []
                for credential in credentialManager.enumerate_creds(
                    relyingParty[CredentialManagement.RESULT.RP_ID_HASH]
                ):
                    credentials[relyingPartyId].append(
                        {
                            "credentialId": credential[
                                CredentialManagement.RESULT.CREDENTIAL_ID
                            ],
                            "userId": credential[CredentialManagement.RESULT.USER][
                                "id"
                            ],
                            "userName": credential[
                                CredentialManagement.RESULT.USER
                            ].get("name", ""),
                            "displayName": credential[
                                CredentialManagement.RESULT.USER
                            ].get("displayName", ""),
                        }
                    )

        return (
            credentials,
            data.get(CredentialManagement.RESULT.EXISTING_CRED_COUNT),
            data.get(CredentialManagement.RESULT.MAX_REMAINING_COUNT),
        )

    def deletePasskey(self, pin, credentialId):
        """
        Public method to delete the passkey of the given ID.

        @param pin PIN to unlock the connected security key
        @type str
        @param credentialId ID of the passkey to be deleted
        @type fido2.webauthn.PublicKeyCredentialDescriptor
        """
        credentialManager = self.__initializeCredentialManager(pin)
        credentialManager.delete_cred(cred_id=credentialId)

    def changePasskeyUserInfo(self, pin, credentialId, userId, userName, displayName):
        """
        Public method to change the user info of a stored passkey.

        @param pin PIN to unlock the connected security key
        @type str
        @param credentialId ID of the passkey to change
        @type fido2.webauthn.PublicKeyCredentialDescriptor
        @param userId ID of the user
        @type bytes
        @param userName user name to set
        @type str
        @param displayName display name to set
        @type str
        """
        userInfo = PublicKeyCredentialUserEntity(
            name=userName, id=userId, display_name=displayName
        )
        credentialManager = self.__initializeCredentialManager(pin)
        credentialManager.update_user_info(cred_id=credentialId, user_info=userInfo)

    def __initializeCredentialManager(self, pin):
        """
        Private method to initialize a credential manager object.

        @param pin PIN to unlock the connected security key
        @type str
        @return reference to the credential manager object
        @rtype CredentialManagement
        @exception Fido2DeviceError raised to indicate an issue with the selected
            security key
        @exception Fido2PinError raised to indicate an issue with the PIN
        """
        if self.__clientPin is None:
            self.__clientPin = ClientPin(self.__ctap2)

        if pin == "":
            pin = self.__pin
        if pin is None:
            # Error
            raise Fido2PinError(
                self.tr(
                    "The selected security key is not unlocked or no PIN was entered."
                )
            )

        try:
            pinToken = self.__clientPin.get_pin_token(
                pin, ClientPin.PERMISSION.CREDENTIAL_MGMT
            )
        except CtapError as err:
            raise Fido2PinError(
                self.tr("PIN error: {0}").format(self.__pinErrorMessage(err))
            )
        except OSError:
            raise Fido2DeviceError(
                self.tr("Connected security key unplugged. Reinsert and try again.")
            )

        return CredentialManagement(self.__ctap2, self.__clientPin.protocol, pinToken)

eric ide

mercurial