src/eric7/WebBrowser/WebAuth/Fido2Management.py

branch
eric7
changeset 10856
b19cefceca15
parent 10854
30c45bd597e6
child 10857
abcb288e7e17
diff -r 9082eb8f6571 -r b19cefceca15 src/eric7/WebBrowser/WebAuth/Fido2Management.py
--- 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

eric ide

mercurial