--- a/src/eric7/WebBrowser/WebAuth/Fido2Management.py Sat Jul 20 11:14:51 2024 +0200 +++ b/src/eric7/WebBrowser/WebAuth/Fido2Management.py Mon Jul 22 10:15:41 2024 +0200 @@ -6,11 +6,13 @@ Module implementing a manager for FIDO2 security keys. """ +import time + 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 +from PyQt6.QtCore import QCoreApplication, QObject, QThread, pyqtSignal class Fido2PinError(Exception): @@ -40,6 +42,34 @@ deviceConnected = pyqtSignal() deviceDisconnected = pyqtSignal() + FidoVersion2Str = { + "FIDO_2_1": "CTAP 2.1 / FIDO2", + "FIDO_2_0": "CTAP 2.0 / FIDO2", + "FIDO_2_1_PRE": QCoreApplication.translate( + "Fido2Management", "CTAP2.1 Preview Features" + ), + "U2F_V2": "CTAP 1 / U2F", + } + + FidoExtension2Str = { + "credBlob": QCoreApplication.translate("Fido2Management", "Credential BLOB"), + "credProtect": QCoreApplication.translate( + "Fido2Management", "Credential Protection" + ), + "hmac-secret": QCoreApplication.translate("Fido2Management", "HMAC Secret"), + "largeBlobKey": QCoreApplication.translate("Fido2Management", "Large Blob Key"), + "minPinLength": QCoreApplication.translate( + "Fido2Management", "Minimum PIN Length" + ), + } + + FidoInfoCategories2Str = { + "pin": QCoreApplication.translate("Fido2Management", "PIN"), + "security_key": QCoreApplication.translate("Fido2Management", "Security Key"), + "options": QCoreApplication.translate("Fido2Management", "Options"), + "extensions": QCoreApplication.translate("Fido2Management", "Extensions"), + } + def __init__(self, parent=None): """ Constructor @@ -77,6 +107,13 @@ self.deviceDisconnected.emit() + def reconnectToDevice(self): + """ + Public method to reconnect the current security key. + """ + if self.__ctap2 is not None: + self.connectToDevice(self.__ctap2.device) + def unlockDevice(self, pin): """ Public method to unlock the device (i.e. store the PIN for later use). @@ -111,22 +148,382 @@ """ return list(CtapHidDevice.list_devices()) - def getKeyInfo(self): + def getSecurityKeyInfo(self): """ Public method to get information about the connected security key. @return dictionary containing the info data - @rtype dict[str, Any] + @rtype dict[str, list[tuple[str, str]]] """ - # TODO: not implemented yet - return {} + if self.__ctap2 is None: + return {} + + # each entry is a list of tuples containing the display name and the value + data = { + "pin": [], + "security_key": [], + "options": [], + "extensions": [], + } + + # PIN related data + if self.__ctap2.info.options["clientPin"]: + if self.__ctap2.info.force_pin_change: + msg = self.tr( + "PIN is disabled and must be changed before it can be used!" + ) + pinRetries, powerCycle = self.getPinRetries() + if pinRetries: + if powerCycle: + msg = self.tr( + "PIN is temporarily blocked. Remove and re-insert the" + " security keyto unblock it." + ) + else: + msg = self.tr("%n attempts remaining", "", pinRetries) + else: + msg = self.tr("PIN is blocked. The security key needs to be reset.") + else: + msg = self.tr("A PIN has not been set.") + data["pin"].append((self.tr("PIN"), msg)) + + alwaysUv = self.__ctap2.info.options.get("alwaysUv") + msg = ( + self.tr("not supported") + if alwaysUv is None + else self.tr("switched on") if alwaysUv else self.tr("switched off") + ) + data["pin"].append((self.tr("Always require User Verification"), msg)) + + remainingPasskeys = self.__ctap2.info.remaining_disc_creds + if remainingPasskeys is not None: + data["pin"].append( + (self.tr("Passkeys storage remaining"), str(remainingPasskeys)) + ) + + enterprise = self.__ctap2.info.options.get("ep") + if enterprise is not None: + data["pin"].append( + ( + self.tr("Enterprise Attestation"), + self.tr("enabled") if enterprise else self.tr("disabled"), + ) + ) + + # security key related data + data["security_key"].extend( + [ + (self.tr("Name"), self.__ctap2.device.product_name), + (self.tr("Path"), self.__ctap2.device.descriptor.path), + ( + self.tr("Version"), + ".".join(str(p) for p in self.__ctap2.device.device_version), + ), + (self.tr("Vendor ID"), f"0x{self.__ctap2.device.descriptor.vid:04x}"), + (self.tr("Product ID"), f"0x{self.__ctap2.device.descriptor.pid:04x}"), + ] + ) + serial = self.__ctap2.device.serial_number + if serial is not None: + data["security_key"].append((self.tr("Serial Number"), serial)) + data["security_key"].append( + ( + self.tr("Supported Versions"), + "\n".join( + self.FidoVersion2Str.get(v, v) for v in self.__ctap2.info.versions + ), + ) + ) + data["security_key"].append( + (self.tr("Supported Transports"), "\n".join(self.__ctap2.info.transports)) + ) + + # extensions data + if self.__ctap2.info.extensions: + for ext in self.FidoExtension2Str: + data["extensions"].append( + ( + self.FidoExtension2Str[ext], + ( + self.tr("supported") + if ext in self.__ctap2.info.extensions + else self.tr("not supported") + ), + ) + ) + + # options data + options = self.__ctap2.info.options + data["options"].append( + ( + self.tr("Is Platform Device"), + self.tr("yes") if options.get("plat", False) else self.tr("no"), + ) + ) + data["options"].append( + ( + self.tr("Resident Passkeys"), + ( + self.tr("supported") + if options.get("rk", False) + else self.tr("not supported") + ), + ) + ) + cp = options.get("clientPin") + data["options"].append( + ( + self.tr("Client PIN"), + ( + self.tr("not supported") + if cp is None + else ( + self.tr("supported, PIN set") + if cp is True + else self.tr("supported, PIN not set") + ) + ), + ) + ) + data["options"].append( + ( + self.tr("Detect User Presence"), + ( + self.tr("supported") + if options.get("up", True) + else self.tr("not supported") + ), + ) + ) + uv = options.get("uv") + data["options"].append( + ( + self.tr("User Verification"), + ( + self.tr("not supported") + if uv is None + else ( + self.tr("supported, configured") + if uv is True + else self.tr("supported, not configured") + ) + ), + ) + ) + data["options"].append( + ( + self.tr("Verify User with Client PIN"), + ( + self.tr("available") + if options.get("pinUvAuthToken", False) + else self.tr("not available") + ), + ) + ) + data["options"].append( + ( + self.tr("Make Credential / Get Assertion"), + ( + self.tr("available") + if options.get("noMcGaPermissionsWithClientPin", False) + else self.tr("not available") + ), + ) + ) + data["options"].append( + ( + self.tr("Large BLOBs"), + ( + self.tr("supported") + if options.get("largeBlobs", False) + else self.tr("not supported") + ), + ) + ) + ep = options.get("ep") + data["options"].append( + ( + self.tr("Enterprise Attestation"), + ( + self.tr("not supported") + if ep is None + else ( + self.tr("supported, enabled") + if ep is True + else self.tr("supported, disabled") + ) + ), + ) + ) + be = options.get("bioEnroll") + data["options"].append( + ( + self.tr("Fingerprint"), + ( + self.tr("not supported") + if be is None + else ( + self.tr("supported, registered") + if be is True + else self.tr("supported, not registered") + ) + ), + ) + ) + uvmp = options.get("userVerificationMgmtPreview") + data["options"].append( + ( + self.tr("CTAP2.1 Preview Fingerprint"), + ( + self.tr("not supported") + if uvmp is None + else ( + self.tr("supported, registered") + if uvmp is True + else self.tr("supported, not registered") + ) + ), + ) + ) + data["options"].append( + ( + self.tr("Verify User for Fingerprint Registration"), + ( + self.tr("supported") + if options.get("uvBioEnroll", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Security Key Configuration"), + ( + self.tr("supported") + if options.get("authnrCfg", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Verify User for Security Key Configuration"), + ( + self.tr("supported") + if options.get("uvAcfg", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Credential Management"), + ( + self.tr("supported") + if options.get("credMgmt", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("CTAP2.1 Preview Credential Management"), + ( + self.tr("supported") + if options.get("credentialMgmtPreview", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Set Minimum PIN Length"), + ( + self.tr("supported") + if options.get("setMinPINLength", False) + else self.tr("not supported") + ), + ) + ) + data["options"].append( + ( + self.tr("Make Non-Resident Passkey without User Verification"), + ( + self.tr("allowed") + if options.get("makeCredUvNotRqd", False) + else self.tr("not allowed") + ), + ) + ) + auv = options.get("alwaysUv") + data["options"].append( + ( + self.tr("Always Require User Verification"), + ( + self.tr("not supported") + if auv is None + else ( + self.tr("supported, enabled") + if auv is True + else self.tr("supported, disabled") + ) + ), + ) + ) + + return data def resetDevice(self): """ Public method to reset the connected security key. + + @return flag indicating success and a message + @rtype tuple of (bool, str) """ - # TODO: not implemented yet - pass + if self.__ctap2 is None: + return False, self.tr("No security key connected.") + + removed = False + startTime = time.monotonic() + while True: + QThread.msleep(500) + try: + securityKeys = self.getDevices() + except OSError: + securityKeys = [] + if not securityKeys: + removed = True + if removed and len(securityKeys) == 1: + ctap2 = Ctap2(securityKeys[0]) + break + if time.monotonic() - startTime >= 30: + return False, self.tr( + "Reset failed. The security key was not removed and re-inserted" + " within 30 seconds." + ) + + try: + ctap2.reset() + return True, "The security key has been reset." + except CtapError as err: + if err.code == CtapError.ERR.ACTION_TIMEOUT: + msg = self.tr( + "You need to touch your security key to confirm the reset." + ) + elif err.code in ( + CtapError.ERR.NOT_ALLOWED, + CtapError.ERR.PIN_AUTH_BLOCKED, + ): + msg = self.tr( + "Reset must be triggered within 5 seconds after the security" + "key is inserted." + ) + else: + msg = str(err) + + return False, self.tr("Reset failed. {0}").format(msg) + except Exception: + return False, self.tr("Reset failed.") ############################################################################ ## methods related to PIN handling @@ -175,25 +572,44 @@ need of a power cycle. @return tuple containing the number of retries left and a flag indicating a - power cycle is required + power cycle is required. A retry value of -1 indicates, that no PIN was + set yet. @rtype tuple of (int, bool) """ if self.__ctap2 is None or self.__clientPin is None: return (None, None) - return self.__clientPin.get_pin_retries() + try: + return self.__clientPin.get_pin_retries() + except CtapError as err: + if err.code == CtapError.ERR.PIN_NOT_SET: + # return -1 retries to indicate a missing PIN + return (-1, False) - def changePin(self, pin, newPin): + def changePin(self, oldPin, newPin): """ Public method to change the PIN of the connected security key. - @param pin current PIN + @param oldPin current PIN @type str @param newPin new PIN @type str + @return flag indicating success and a message + @rtype tuple of (bool, str) """ - # TODO: not implemented yet - pass + if self.__ctap2 is None or self.__clientPin is None: + return False, self.tr("No security key connected.") + + try: + self.__clientPin.change_pin(old_pin=oldPin, new_pin=newPin) + return True, self.tr("PIN was changed successfully.") + except CtapError as err: + return ( + False, + self.tr("<p>Failed to change the PIN.</p><p>Reason: {0}</p>").format( + self.__pinErrorMessage(err) + ), + ) def setPin(self, pin): """ @@ -201,9 +617,22 @@ @param pin PIN to be set @type str + @return flag indicating success and a message + @rtype tuple of (bool, str) """ - # TODO: not implemented yet - pass + if self.__ctap2 is None or self.__clientPin is None: + return False, self.tr("No security key connected.") + + try: + self.__clientPin.set_pin(pin=pin) + return True, self.tr("PIN was set successfully.") + except CtapError as err: + return ( + False, + self.tr("<p>Failed to set the PIN.</p><p>Reason: {0}</p>").format( + self.__pinErrorMessage(err) + ), + ) def verifyPin(self, pin): """ @@ -217,17 +646,17 @@ @rtype tuple of (bool, str) """ if self.__ctap2 is None or self.__clientPin is None: - return False + return False, self.tr("No security key connected.") try: self.__clientPin.get_pin_token( pin, ClientPin.PERMISSION.GET_ASSERTION, "eric-ide.python-projects.org" ) - return True, self.tr("PIN verified") + return True, self.tr("PIN was verified.") except CtapError as err: return ( False, - self.tr("<p>PIN verification failed.</p><p>Reason: {0}").format( + self.tr("<p>PIN verification failed.</p><p>Reason: {0}</p>").format( self.__pinErrorMessage(err) ), ) @@ -248,6 +677,8 @@ msg = self.tr("PIN is blocked.") elif errorCode == CtapError.ERR.PIN_NOT_SET: msg = self.tr("No PIN set.") + elif errorCode == CtapError.ERR.PIN_POLICY_VIOLATION: + msg = self.tr("New PIN doesn't meet complexity requirements.") else: msg = str(err) return msg