Mon, 22 Jul 2024 15:24:27 +0200
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 ############################################################################