diff -r 1f651b204780 -r 30c45bd597e6 src/eric7/WebBrowser/WebAuth/Fido2Management.py --- /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)