Continued implementing the FIDO2 security key management interface. eric7

Mon, 22 Jul 2024 15:24:27 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Mon, 22 Jul 2024 15:24:27 +0200
branch
eric7
changeset 10857
abcb288e7e17
parent 10856
b19cefceca15
child 10858
8a03d5f6146c

Continued implementing the FIDO2 security key management interface.

src/eric7/WebBrowser/WebAuth/Fido2Management.py file | annotate | diff | comparison | revisions
src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py file | annotate | diff | comparison | revisions
--- a/src/eric7/WebBrowser/WebAuth/Fido2Management.py	Mon Jul 22 10:15:41 2024 +0200
+++ b/src/eric7/WebBrowser/WebAuth/Fido2Management.py	Mon Jul 22 15:24:27 2024 +0200
@@ -9,7 +9,7 @@
 import time
 
 from fido2.ctap import CtapError
-from fido2.ctap2 import ClientPin, CredentialManagement, Ctap2
+from fido2.ctap2 import ClientPin, Config, CredentialManagement, Ctap2
 from fido2.hid import CtapHidDevice
 from fido2.webauthn import PublicKeyCredentialUserEntity
 from PyQt6.QtCore import QCoreApplication, QObject, QThread, pyqtSignal
@@ -168,10 +168,13 @@
 
         # PIN related data
         if self.__ctap2.info.options["clientPin"]:
-            if self.__ctap2.info.force_pin_change:
-                msg = self.tr(
+            msg1 = (
+                self.tr(
                     "PIN is disabled and must be changed before it can be used!"
                 )
+                if self.__ctap2.info.force_pin_change
+                else ""
+            )
             pinRetries, powerCycle = self.getPinRetries()
             if pinRetries:
                 if powerCycle:
@@ -183,10 +186,16 @@
                     msg = self.tr("%n attempts remaining", "", pinRetries)
             else:
                 msg = self.tr("PIN is blocked. The security key needs to be reset.")
+            if msg1:
+                msg += "\n" + msg1
         else:
             msg = self.tr("A PIN has not been set.")
         data["pin"].append((self.tr("PIN"), msg))
 
+        data["pin"].append(
+            (self.tr("Minimum PIN length"), str(self.__ctap2.info.min_pin_length))
+        )
+
         alwaysUv = self.__ctap2.info.options.get("alwaysUv")
         msg = (
             self.tr("not supported")
@@ -554,7 +563,7 @@
 
         return self.__ctap2.info.options.get("clientPin")
 
-    def forcedPinChange(self):
+    def pinChangeRequired(self):
         """
         Public method to check for a forced PIN change.
 
@@ -602,6 +611,7 @@
 
         try:
             self.__clientPin.change_pin(old_pin=oldPin, new_pin=newPin)
+            self.reconnectToDevice()
             return True, self.tr("PIN was changed successfully.")
         except CtapError as err:
             return (
@@ -625,6 +635,7 @@
 
         try:
             self.__clientPin.set_pin(pin=pin)
+            self.reconnectToDevice()
             return True, self.tr("PIN was set successfully.")
         except CtapError as err:
             return (
@@ -804,3 +815,162 @@
             )
 
         return CredentialManagement(self.__ctap2, self.__clientPin.protocol, pinToken)
+
+    ############################################################################
+    ## methods related to configuration handling
+    ############################################################################
+
+    def __initConfig(self, pin):
+        """
+        Private method to initialize a configuration object.
+
+        @param pin PIN to unlock the connected security key
+        @type str
+        @return reference to the configuration object
+        @rtype Config
+        @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."
+                )
+            )
+
+        if not Config.is_supported(self.__ctap2.info):
+            raise Fido2DeviceError(
+                self.tr("The selected security key does not support configuration.")
+            )
+
+        try:
+            pinToken = self.__clientPin.get_pin_token(
+                pin, ClientPin.PERMISSION.AUTHENTICATOR_CFG
+            )
+        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 Config(self.__ctap2, self.__clientPin.protocol, pinToken)
+
+    def forcePinChangeSupported(self):
+        """
+        Public method to check, if the 'forcePinChange' function is supported by the
+        selected security key.
+        
+        @return flag indicating support
+        @rtype bool
+        """
+        if (
+            self.__ctap2 is None
+            or self.__ctap2.info is None
+            or not self.__ctap2.info.options.get("setMinPINLength")
+        ):
+            return False
+        else:
+            return True
+
+    def forcePinChange(self, pin):
+        """
+        Public method to force the PIN to be changed to a new value before use.
+
+        @param pin PIN to unlock the connected security key
+        @type str
+        """
+        config = self.__initConfig(pin)
+        config.set_min_pin_length(force_change_pin=True)
+        self.reconnectToDevice()
+
+    def canSetMinimumPinLength(self):
+        """
+        Public method to check, if the 'setMinPINLength' function is available.
+
+        @return flag indicating availability
+        @rtype bool
+        """
+        if (
+            self.__ctap2 is None
+            or self.__ctap2.info is None
+            or not self.__ctap2.info.options.get("setMinPINLength")
+            or (
+                self.__ctap2.info.options.get("alwaysUv")
+                and not self.__ctap2.info.options.get("clientPin")
+            )
+        ):
+            return False
+        else:
+            return True
+
+    def setMinimumPinLength(self, pin, minLength):
+        """
+        Public method to set the minimum PIN length.
+
+        @param pin PIN to unlock the connected security key
+        @type str
+        @param minLength minimum PIN length
+        @type int
+        """
+        if minLength < 4 or minLength > 63:
+            raise Fido2PinError(
+                self.tr("The minimum PIN length must be between 4 and 63.")
+            )
+        if minLength < self.__ctap2.info.min_pin_length:
+            raise Fido2PinError(
+                self.tr("The minimum PIN length must be at least {0}.").format(
+                    self.__ctap2.info.min_pin_length
+                )
+            )
+
+        config = self.__initConfig(pin)
+        config.set_min_pin_length(min_pin_length=minLength)
+        self.reconnectToDevice()
+
+    def canToggleAlwaysUv(self):
+        """
+        Public method to check, if the 'toggleAlwaysUv' function is available.
+
+        @return flag indicating availability
+        @rtype bool
+        """
+        if (
+            self.__ctap2 is None
+            or self.__ctap2.info is None
+            or "alwaysUv" not in self.__ctap2.info.options
+        ):
+            return False
+        else:
+            return True
+
+    def getAlwaysUv(self):
+        """
+        Public method to get the value of the 'alwaysUv' flag of the current security
+        key.
+        """
+        if self.__ctap2 is None:
+            return False
+
+        info = self.__ctap2.get_info()
+        return info is not None and info.options.get("alwaysUv", False)
+
+    def toggleAlwaysUv(self, pin):
+        """
+        Public method to toggle the 'alwaysUv' flag of the selected security key.
+
+        @param pin PIN to unlock the connected security key
+        @type str
+        """
+        config = self.__initConfig(pin)
+        config.toggle_always_uv()
+        self.reconnectToDevice()
--- a/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py	Mon Jul 22 10:15:41 2024 +0200
+++ b/src/eric7/WebBrowser/WebAuth/Fido2ManagementDialog.py	Mon Jul 22 15:24:27 2024 +0200
@@ -10,6 +10,7 @@
 from PyQt6.QtWidgets import (
     QDialog,
     QDialogButtonBox,
+    QInputDialog,
     QMenu,
     QToolButton,
     QTreeWidgetItem,
@@ -72,6 +73,8 @@
         self.reloadButton.clicked.connect(self.__populateDeviceSelector)
 
         self.__manager = Fido2Management(parent=self)
+        self.__manager.deviceConnected.connect(self.__deviceConnected)
+        self.__manager.deviceDisconnected.connect(self.__deviceDisconnected)
 
         QTimer.singleShot(0, self.__populateDeviceSelector)
 
@@ -86,10 +89,16 @@
         self.__mgmtMenu.addAction(
             self.tr("Reset Security Key"), self.__resetSecurityKey
         )
-        # TODO: potentially add these 'config' actions
-        #       - Force PIN Change
-        #       - Set Minimum PIN Length
-        #       - Toggle 'Always Require UV'
+        self.__mgmtMenu.addSeparator()
+        self.__forcePinChangeAct = self.__mgmtMenu.addAction(
+            self.tr("Force PIN Change"), self.__forcePinChange
+        )
+        self.__minPinLengthAct = self.__mgmtMenu.addAction(
+            self.tr("Set Minimum PIN Length"), self.__setMinimumPinLength
+        )
+        self.__toggleAlwaysUvAct = self.__mgmtMenu.addAction(
+            self.tr("Toggle 'Always Require User Verification'"), self.__toggleAlwaysUv
+        )
 
         self.__mgmtMenu.aboutToShow.connect(self.__aboutToShowManagementMenu)
 
@@ -100,8 +109,18 @@
         """
         Private slot to prepare the security key management menu before it is shown.
         """
-        # TODO: not implemented yet
-        pass
+        self.__forcePinChangeAct.setEnabled(
+            self.__manager.forcePinChangeSupported()
+            and not self.__manager.pinChangeRequired()
+        )
+        self.__minPinLengthAct.setEnabled(
+            self.__manager.canSetMinimumPinLength()
+            and not self.__manager.pinChangeRequired()
+        )
+        self.__toggleAlwaysUvAct.setEnabled(
+            self.__manager.canToggleAlwaysUv()
+            and not self.__manager.pinChangeRequired()
+        )
 
     ############################################################################
     ## methods related to device handling
@@ -114,7 +133,6 @@
         """
         self.__manager.disconnectFromDevice()
         self.securityKeysComboBox.clear()
-        self.reloadButton.setEnabled(False)
 
         securityKeys = self.__manager.getDevices()
 
@@ -128,8 +146,6 @@
                 securityKey,
             )
 
-        self.reloadButton.setEnabled(True)
-
         if len(securityKeys) == 0:
             EricMessageBox.information(
                 self,
@@ -148,39 +164,60 @@
         @param index index of the selected security key
         @type int
         """
-        self.lockButton.setChecked(False)
         self.__manager.disconnectFromDevice()
 
         securityKey = self.securityKeysComboBox.itemData(index)
-
-        self.lockButton.setEnabled(securityKey is not None)
-        self.pinButton.setEnabled(securityKey is not None)
-        self.menuButton.setEnabled(securityKey is not None)
-        self.loadPasskeysButton.setEnabled(securityKey is not None)
-
         if securityKey is not None:
             self.__manager.connectToDevice(securityKey)
-            hasPin = self.__manager.hasPin()
-            forcedPinChange = self.__manager.forcedPinChange()
-            if hasPin is True:
-                self.pinButton.setText(self.tr("Change PIN"))
-            elif hasPin is False:
-                self.pinButton.setText(self.tr("Set PIN"))
-            else:
-                self.pinButton.setEnabled(False)
-            if forcedPinChange or hasPin is False:
-                self.lockButton.setEnabled(False)
-                self.loadPasskeysButton.setEnabled(False)
-                msg = (
-                    self.tr("A PIN change is required.")
-                    if forcedPinChange
-                    else self.tr("You must set a PIN first.")
-                )
-                EricMessageBox.information(
-                    self,
-                    self.tr("FIDO2 Security Key Management"),
-                    msg,
-                )
+
+    @pyqtSlot()
+    def __deviceConnected(self):
+        """
+        Private slot handling the device connected signal.
+        """
+        self.lockButton.setEnabled(True)
+        self.pinButton.setEnabled(True)
+        self.menuButton.setEnabled(True)
+        self.loadPasskeysButton.setEnabled(True)
+
+        hasPin = self.__manager.hasPin()
+        forcedPinChange = self.__manager.pinChangeRequired()
+        if hasPin is True:
+            self.pinButton.setText(self.tr("Change PIN"))
+        elif hasPin is False:
+            self.pinButton.setText(self.tr("Set PIN"))
+        else:
+            self.pinButton.setEnabled(False)
+        if forcedPinChange or hasPin is False:
+            self.lockButton.setEnabled(False)
+            self.loadPasskeysButton.setEnabled(False)
+            msg = (
+                self.tr("A PIN change is required.")
+                if forcedPinChange
+                else self.tr("You must set a PIN first.")
+            )
+            EricMessageBox.information(
+                self,
+                self.tr("FIDO2 Security Key Management"),
+                msg,
+            )
+
+        self.passkeysList.clear()
+        self.on_passkeysList_itemSelectionChanged()
+
+    @pyqtSlot()
+    def __deviceDisconnected(self):
+        """
+        Private slot handling the device disconnected signal.
+        """
+        self.lockButton.setChecked(False)
+        self.passkeysList.clear()
+        self.on_passkeysList_itemSelectionChanged()
+
+        self.lockButton.setEnabled(False)
+        self.pinButton.setEnabled(False)
+        self.menuButton.setEnabled(False)
+        self.loadPasskeysButton.setEnabled(False)
 
         self.passkeysList.clear()
         self.on_passkeysList_itemSelectionChanged()
@@ -296,7 +333,7 @@
             )
         elif not hasPin:
             msg = self.tr("{0} requires having a PIN. Set a PIN first.").format(feature)
-        elif self.__manager.forcedPinChange():
+        elif self.__manager.pinChangeRequired():
             msg = self.tr("The security key is locked. Change the PIN first.")
         elif powerCycle:
             msg = self.tr(
@@ -372,11 +409,6 @@
             newPin = dlg.getPins()[1]
             ok, msg = self.__manager.setPin(newPin)
             if ok:
-                self.lockButton.setEnabled(True)
-                self.lockButton.setChecked(False)
-                self.pinButton.setText(self.tr("Change PIN"))
-                self.loadPasskeysButton.setEnabled(True)
-                self.__manager.reconnectToDevice()
                 EricMessageBox.information(self, title, msg)
             else:
                 EricMessageBox.warning(self, title, msg)
@@ -401,7 +433,6 @@
             oldPin, newPin = dlg.getPins()
             ok, msg = self.__manager.changePin(oldPin, newPin)
             if ok:
-                self.lockButton.setChecked(False)
                 EricMessageBox.information(self, title, msg)
             else:
                 EricMessageBox.warning(self, title, msg)
@@ -581,6 +612,89 @@
                 del rpItem
 
     ############################################################################
+    ## methods related to device configuration
+    ############################################################################
+
+    @pyqtSlot()
+    def __forcePinChange(self):
+        """
+        Private slot to force a PIN change before the next use.
+        """
+        pin = self.__getRequiredPin(feature=self.tr("Force PIN Change"))
+        try:
+            self.__manager.forcePinChange(pin=pin)
+        except (Fido2DeviceError, Fido2PinError) as err:
+            self.__handleError(
+                error=err,
+                title=self.tr("Force PIN Change"),
+                message=self.tr("The 'Force PIN Change' flag could not be set."),
+            )
+
+    @pyqtSlot()
+    def __setMinimumPinLength(self):
+        """
+        Private slot to set the minimum PIN length.
+        """
+        currMinLength = self.__manager.getMinimumPinLength()
+
+        minPinLength, ok = QInputDialog.getInt(
+            self,
+            self.tr("Set Minimum PIN Length"),
+            self.tr("Enter the minimum PIN length (between {0} and 63):").format(
+                currMinLength
+            ),
+            0,
+            currMinLength,
+            63,
+            1,
+        )
+        if ok and minPinLength != currMinLength:
+            pin = self.__getRequiredPin(feature=self.tr("Set Minimum PIN Length"))
+            try:
+                self.__manager.setMinimumPinLength(pin=pin, minLength=minPinLength)
+                EricMessageBox.information(
+                    self,
+                    self.tr("Set Minimum PIN Length"),
+                    self.tr("The minimum PIN length was set to be {0}.").format(
+                        minPinLength
+                    ),
+                )
+            except (Fido2DeviceError, Fido2PinError) as err:
+                self.__handleError(
+                    error=err,
+                    title=self.tr("Set Minimum PIN Length"),
+                    message=self.tr("The minimum PIN length could not be set."),
+                )
+
+    @pyqtSlot()
+    def __toggleAlwaysUv(self):
+        """
+        Private slot to toggle the state of the 'Always Require User Verification'
+        flag.
+        """
+        pin = self.__getRequiredPin(
+            feature=self.tr("Toggle 'Always Require User Verification'")
+        )
+        try:
+            self.__manager.toggleAlwaysUv(pin=pin)
+            EricMessageBox.information(
+                self,
+                self.tr("Always Require User Verification"),
+                self.tr("Always Require User Verification is now enabled.")
+                if self.__manager.getAlwaysUv()
+                else self.tr("Always Require User Verification is now disabled."),
+            )
+            
+        except (Fido2DeviceError, Fido2PinError) as err:
+            self.__handleError(
+                error=err,
+                title=self.tr("Toggle 'Always Require User Verification'"),
+                message=self.tr(
+                    "The 'Always Require User Verification' flag could not be toggled."
+                ),
+            )
+
+    ############################################################################
     ## utility methods
     ############################################################################
 

eric ide

mercurial