src/eric7/WebBrowser/WebAuth/Fido2Management.py

branch
eric7
changeset 10854
30c45bd597e6
child 10856
b19cefceca15
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/WebBrowser/WebAuth/Fido2Management.py	Fri Jul 19 18:06:48 2024 +0200
@@ -0,0 +1,375 @@
+# -*- 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