Fri, 19 Jul 2024 18:06:48 +0200
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)