|
1 # -*- coding: utf-8 -*- |
|
2 # Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> |
|
3 # |
|
4 |
|
5 """ |
|
6 Module implementing a dialog to manage FIDO2 security keys. |
|
7 """ |
|
8 |
|
9 from PyQt6.QtCore import Qt, QTimer, pyqtSlot |
|
10 from PyQt6.QtWidgets import QDialog, QTreeWidgetItem |
|
11 |
|
12 from eric7.EricGui import EricPixmapCache |
|
13 from eric7.EricGui.EricOverrideCursor import EricOverrideCursor |
|
14 from eric7.EricWidgets import EricMessageBox |
|
15 |
|
16 from .Fido2Management import Fido2DeviceError, Fido2Management, Fido2PinError |
|
17 from .Fido2PinDialog import Fido2PinDialog, Fido2PinDialogMode |
|
18 from .Ui_Fido2ManagementDialog import Ui_Fido2ManagementDialog |
|
19 |
|
20 |
|
21 class Fido2ManagementDialog(QDialog, Ui_Fido2ManagementDialog): |
|
22 """ |
|
23 Class implementing a dialog to manage FIDO2 security keys. |
|
24 """ |
|
25 |
|
26 CredentialIdRole = Qt.ItemDataRole.UserRole |
|
27 UserIdRole = Qt.ItemDataRole.UserRole + 1 |
|
28 |
|
29 RelyingPartyColumn = 0 |
|
30 CredentialIdColumn = 1 |
|
31 DisplayNameColumn = 2 |
|
32 UserNameColumn = 3 |
|
33 |
|
34 def __init__(self, parent=None): |
|
35 """ |
|
36 Constructor |
|
37 |
|
38 @param parent reference to the parent widget (defaults to None) |
|
39 @type QWidget (optional) |
|
40 """ |
|
41 super().__init__(parent) |
|
42 self.setupUi(self) |
|
43 |
|
44 self.reloadButton.setIcon(EricPixmapCache.getIcon("reload")) |
|
45 self.lockButton.setIcon(EricPixmapCache.getIcon("locked")) |
|
46 |
|
47 self.reloadButton.clicked.connect(self.__populateDeviceSelector) |
|
48 |
|
49 self.__manager = Fido2Management(parent=self) |
|
50 ##self.__manager.deviceConnected.connect(self.__deviceConnected) |
|
51 ##self.__manager.deviceDisconnected.connect(self.__deviceDisconnected) |
|
52 |
|
53 QTimer.singleShot(0, self.__populateDeviceSelector) |
|
54 |
|
55 ############################################################################ |
|
56 ## methods related to device handling |
|
57 ############################################################################ |
|
58 |
|
59 @pyqtSlot() |
|
60 def __populateDeviceSelector(self): |
|
61 """ |
|
62 Private slot to populate the device selector combo box. |
|
63 """ |
|
64 self.__manager.disconnectFromDevice() |
|
65 self.securityKeysComboBox.clear() |
|
66 self.reloadButton.setEnabled(False) |
|
67 |
|
68 securityKeys = self.__manager.getDevices() |
|
69 |
|
70 if len(securityKeys) != 1: |
|
71 self.securityKeysComboBox.addItem("") |
|
72 for securityKey in securityKeys: |
|
73 self.securityKeysComboBox.addItem( |
|
74 self.tr("{0} ({1})").format( |
|
75 securityKey.product_name, securityKey.descriptor.path |
|
76 ), |
|
77 securityKey, |
|
78 ) |
|
79 |
|
80 self.reloadButton.setEnabled(True) |
|
81 |
|
82 if len(securityKeys) == 0: |
|
83 EricMessageBox.information( |
|
84 self, |
|
85 self.tr("FIDO2 Security Key Management"), |
|
86 self.tr( |
|
87 """No security key could be detected. Attach a key and press""" |
|
88 """ the "Reload" button.""" |
|
89 ), |
|
90 ) |
|
91 |
|
92 @pyqtSlot(int) |
|
93 def on_securityKeysComboBox_currentIndexChanged(self, index): |
|
94 """ |
|
95 Private slot handling the selection of security key. |
|
96 |
|
97 @param index index of the selected security key |
|
98 @type int |
|
99 """ |
|
100 self.lockButton.setChecked(False) |
|
101 self.__manager.disconnectFromDevice() |
|
102 |
|
103 securityKey = self.securityKeysComboBox.itemData(index) |
|
104 |
|
105 self.lockButton.setEnabled(securityKey is not None) |
|
106 self.pinButton.setEnabled(securityKey is not None) |
|
107 self.showInfoButton.setEnabled(securityKey is not None) |
|
108 self.resetButton.setEnabled(securityKey is not None) |
|
109 self.loadPasskeysButton.setEnabled(securityKey is not None) |
|
110 |
|
111 if securityKey is not None: |
|
112 self.__manager.connectToDevice(securityKey) |
|
113 hasPin = self.__manager.hasPin() |
|
114 forcedPinChange = self.__manager.forcedPinChange() |
|
115 if hasPin is True: |
|
116 self.pinButton.setText(self.tr("Change PIN")) |
|
117 elif hasPin is False: |
|
118 self.pinButton.setText(self.tr("Set PIN")) |
|
119 else: |
|
120 self.pinButton.setEnabled(False) |
|
121 if forcedPinChange or hasPin is False: |
|
122 self.lockButton.setEnabled(False) |
|
123 self.loadPasskeysButton.setEnabled(False) |
|
124 msg = ( |
|
125 self.tr("A PIN change is required.") |
|
126 if forcedPinChange |
|
127 else self.tr("You must set a PIN first.") |
|
128 ) |
|
129 EricMessageBox.information( |
|
130 self, |
|
131 self.tr("FIDO2 Security Key Management"), |
|
132 msg, |
|
133 ) |
|
134 |
|
135 self.passkeysList.clear() |
|
136 self.on_passkeysList_itemSelectionChanged() |
|
137 |
|
138 @pyqtSlot(bool) |
|
139 def on_lockButton_toggled(self, checked): |
|
140 """ |
|
141 Private slot to handle the toggling of the device locked status. |
|
142 |
|
143 @param checked state of the lock/unlock button |
|
144 @type bool |
|
145 """ |
|
146 if checked: |
|
147 # unlock the selected security key |
|
148 pin = self.__getRequiredPin(self.tr("Unlock Security Key")) |
|
149 if pin: |
|
150 ok, msg = self.__manager.verifyPin(pin=pin) |
|
151 if ok: |
|
152 self.lockButton.setIcon(EricPixmapCache.getIcon("unlocked")) |
|
153 self.__manager.unlockDevice(pin) |
|
154 else: |
|
155 EricMessageBox.critical( |
|
156 self, |
|
157 self.tr("Unlock Security Key"), |
|
158 msg, |
|
159 ) |
|
160 self.lockButton.setChecked(False) |
|
161 else: |
|
162 self.lockButton.setChecked(False) |
|
163 else: |
|
164 # lock the selected security key |
|
165 self.lockButton.setIcon(EricPixmapCache.getIcon("locked")) |
|
166 self.__manager.lockDevice() |
|
167 |
|
168 @pyqtSlot() |
|
169 def on_showInfoButton_clicked(self): |
|
170 """ |
|
171 Slot documentation goes here. |
|
172 """ |
|
173 # TODO: not implemented yet |
|
174 pass |
|
175 |
|
176 ############################################################################ |
|
177 ## methods related to PIN handling |
|
178 ############################################################################ |
|
179 |
|
180 def __checkPinStatus(self, feature): |
|
181 """ |
|
182 Private method to check the PIN status of the connected security key. |
|
183 |
|
184 @param feature name of the feature requesting the PIN (defaults to None) |
|
185 @type str (optional) |
|
186 @return flag indicating a positive status |
|
187 @rtype bool |
|
188 """ |
|
189 feature = self.tr("This feature") if feature is None else f"'{feature}'" |
|
190 |
|
191 hasPin = self.__manager.hasPin() |
|
192 retries, powerCycle = self.__manager.getPinRetries() |
|
193 |
|
194 if hasPin is None: |
|
195 msg = self.tr("{0} is not supported by the selected security key.").format( |
|
196 feature |
|
197 ) |
|
198 elif not hasPin: |
|
199 msg = self.tr("{0} requires having a PIN. Set a PIN first.").format(feature) |
|
200 elif self.__manager.forcedPinChange(): |
|
201 msg = self.tr("The security key is locked. Change the PIN first.") |
|
202 elif powerCycle: |
|
203 msg = self.tr( |
|
204 "The security key is locked because the wrong PIN was entered " |
|
205 "too many times. To unlock it, remove and reinsert it." |
|
206 ) |
|
207 elif retries == 0: |
|
208 msg = self.tr( |
|
209 "The security key is locked because the wrong PIN was entered too" |
|
210 " many times. You will need to reset the security key." |
|
211 ) |
|
212 else: |
|
213 msg = "" |
|
214 |
|
215 if msg: |
|
216 EricMessageBox.critical( |
|
217 self, |
|
218 self.tr("FIDO2 Security Key Management"), |
|
219 msg, |
|
220 ) |
|
221 return False |
|
222 else: |
|
223 return True |
|
224 |
|
225 def __getRequiredPin(self, feature=None): |
|
226 """ |
|
227 Private method to check, if a pin has been set for the selected device, and |
|
228 ask the user to enter it. |
|
229 |
|
230 @param feature name of the feature requesting the PIN (defaults to None) |
|
231 @type str (optional) |
|
232 @return PIN of the selected security key or None in case of an issue |
|
233 @rtype str or None |
|
234 """ |
|
235 if not self.__checkPinStatus(feature=feature): |
|
236 return None |
|
237 else: |
|
238 if self.__manager.isDeviceLocked(): |
|
239 retries = self.__manager.getPinRetries()[0] |
|
240 title = self.tr("PIN required") if feature is None else feature |
|
241 dlg = Fido2PinDialog( |
|
242 mode=Fido2PinDialogMode.GET, |
|
243 title=title, |
|
244 message=self.tr( |
|
245 "Enter the PIN to unlock the security key (%n attempt(s)" |
|
246 " remaining.", |
|
247 "", |
|
248 retries, |
|
249 ), |
|
250 minLength=self.__manager.getMinimumPinLength(), |
|
251 parent=self, |
|
252 ) |
|
253 if dlg.exec() == QDialog.DialogCode.Accepted: |
|
254 return dlg.getPins()[0] |
|
255 else: |
|
256 return None |
|
257 else: |
|
258 return "" |
|
259 |
|
260 @pyqtSlot() |
|
261 def __setPin(self): |
|
262 """ |
|
263 Private slot to set a PIN for the selected security key. |
|
264 """ |
|
265 # TODO: not implemented yet |
|
266 pass |
|
267 |
|
268 @pyqtSlot() |
|
269 def __changePin(self): |
|
270 """ |
|
271 Private slot to set a PIN for the selected security key. |
|
272 """ |
|
273 # TODO: not implemented yet |
|
274 pass |
|
275 |
|
276 @pyqtSlot() |
|
277 def on_pinButton_clicked(self): |
|
278 """ |
|
279 Private slot to set or change the PIN for the selected security key. |
|
280 """ |
|
281 # TODO: not implemented yet |
|
282 if self.__manager.hasPin(): |
|
283 self.__changePin() |
|
284 else: |
|
285 self.__setPin() |
|
286 |
|
287 ############################################################################ |
|
288 ## methods related to passkeys handling |
|
289 ############################################################################ |
|
290 |
|
291 @pyqtSlot() |
|
292 def __populatePasskeysList(self): |
|
293 """ |
|
294 Private slot to populate the list of store passkeys of the selected security |
|
295 key. |
|
296 """ |
|
297 keyIndex = self.securityKeysComboBox.currentData() |
|
298 if keyIndex is None: |
|
299 return |
|
300 |
|
301 pin = self.__getRequiredPin(feature=self.tr("Credential Management")) |
|
302 if pin is None: |
|
303 return |
|
304 |
|
305 self.passkeysList.clear() |
|
306 |
|
307 try: |
|
308 with EricOverrideCursor(): |
|
309 passkeys, existingCount, remainingCount = self.__manager.getPasskeys( |
|
310 pin=pin |
|
311 ) |
|
312 except (Fido2DeviceError, Fido2PinError) as err: |
|
313 self.__handleError( |
|
314 error=err, |
|
315 title=self.tr("Load Passkeys"), |
|
316 message=self.tr("The stored passkeys could not be loaded."), |
|
317 ) |
|
318 return |
|
319 |
|
320 self.existingCountLabel.setText(str(existingCount)) |
|
321 self.remainingCountLabel.setText(str(remainingCount)) |
|
322 |
|
323 for relyingParty in passkeys: |
|
324 rpItem = QTreeWidgetItem(self.passkeysList, [relyingParty]) |
|
325 rpItem.setFirstColumnSpanned(True) |
|
326 rpItem.setExpanded(True) |
|
327 for passDict in passkeys[relyingParty]: |
|
328 item = QTreeWidgetItem( |
|
329 rpItem, |
|
330 [ |
|
331 "", |
|
332 passDict["credentialId"]["id"].hex(), |
|
333 passDict["displayName"], |
|
334 passDict["userName"], |
|
335 ], |
|
336 ) |
|
337 item.setData(0, self.CredentialIdRole, passDict["credentialId"]) |
|
338 item.setData(0, self.UserIdRole, passDict["userId"]) |
|
339 |
|
340 self.passkeysList.sortItems(self.DisplayNameColumn, Qt.SortOrder.AscendingOrder) |
|
341 self.passkeysList.sortItems( |
|
342 self.RelyingPartyColumn, Qt.SortOrder.AscendingOrder |
|
343 ) |
|
344 |
|
345 @pyqtSlot() |
|
346 def on_loadPasskeysButton_clicked(self): |
|
347 """ |
|
348 Slot documentation goes here. |
|
349 """ |
|
350 self.__populatePasskeysList() |
|
351 |
|
352 @pyqtSlot() |
|
353 def on_passkeysList_itemSelectionChanged(self): |
|
354 """ |
|
355 Slot documentation goes here. |
|
356 """ |
|
357 enableButtons = ( |
|
358 len(self.passkeysList.selectedItems()) == 1 |
|
359 and self.passkeysList.selectedItems()[0].parent() is not None |
|
360 ) |
|
361 self.editButton.setEnabled(enableButtons) |
|
362 self.deleteButton.setEnabled(enableButtons) |
|
363 |
|
364 @pyqtSlot() |
|
365 def on_editButton_clicked(self): |
|
366 """ |
|
367 Private slot to edit the selected passkey. |
|
368 """ |
|
369 from .Fido2PasskeyEditDialog import Fido2PasskeyEditDialog |
|
370 |
|
371 selectedItem = self.passkeysList.selectedItems()[0] |
|
372 dlg = Fido2PasskeyEditDialog( |
|
373 displayName=selectedItem.text(self.DisplayNameColumn), |
|
374 userName=selectedItem.text(self.UserNameColumn), |
|
375 relyingParty=selectedItem.parent().text(self.RelyingPartyColumn), |
|
376 parent=self, |
|
377 ) |
|
378 if dlg.exec() == QDialog.DialogCode.Accepted: |
|
379 displayName, userName = dlg.getData() |
|
380 if displayName != selectedItem.text( |
|
381 self.DisplayNameColumn |
|
382 ) or userName != selectedItem.text(self.UserNameColumn): |
|
383 # only change on the security key, if there is really a change |
|
384 pin = self.__getRequiredPin(feature=self.tr("Change User Info")) |
|
385 try: |
|
386 self.__manager.changePasskeyUserInfo( |
|
387 pin=pin, |
|
388 credentialId=selectedItem.data(0, self.CredentialIdRole), |
|
389 userId=selectedItem.data(0, self.UserIdRole), |
|
390 userName=userName, |
|
391 displayName=displayName, |
|
392 ) |
|
393 except (Fido2DeviceError, Fido2PinError) as err: |
|
394 self.__handleError( |
|
395 error=err, |
|
396 title=self.tr("Change User Info"), |
|
397 message=self.tr("The user info could not be changed."), |
|
398 ) |
|
399 return |
|
400 |
|
401 selectedItem.setText(self.DisplayNameColumn, displayName) |
|
402 selectedItem.setText(self.UserNameColumn, userName) |
|
403 |
|
404 @pyqtSlot() |
|
405 def on_deleteButton_clicked(self): |
|
406 """ |
|
407 Private slot to delete the selected passkey. |
|
408 """ |
|
409 selectedItem = self.passkeysList.selectedItems()[0] |
|
410 |
|
411 ok = EricMessageBox.yesNo( |
|
412 self, |
|
413 self.tr("Delete Passkey"), |
|
414 self.tr( |
|
415 "<p>Shall the selected passkey really be deleted?</p>" |
|
416 "<ul>" |
|
417 "<li>Relying Party: {0}</li>" |
|
418 "<li>Display Name: {1}</li>" |
|
419 "<li>User Name: {2}</li>" |
|
420 "</ul>" |
|
421 ).format( |
|
422 selectedItem.parent().text(self.RelyingPartyColumn), |
|
423 selectedItem.text(self.DisplayNameColumn), |
|
424 selectedItem.text(self.UserNameColumn), |
|
425 ), |
|
426 ) |
|
427 if ok: |
|
428 pin = self.__getRequiredPin(feature=self.tr("Delete Passkey")) |
|
429 try: |
|
430 self.__manager.deletePasskey( |
|
431 pin=pin, |
|
432 credentialId=selectedItem.data(0, self.CredentialIdRole), |
|
433 ) |
|
434 except (Fido2DeviceError, Fido2PinError) as err: |
|
435 self.__handleError( |
|
436 error=err, |
|
437 title=self.tr("Delete Passkey"), |
|
438 message=self.tr("The passkey could not be deleted."), |
|
439 ) |
|
440 return |
|
441 |
|
442 rpItem = selectedItem.parent() |
|
443 index = rpItem.indexOfChild(selectedItem) |
|
444 rpItem.takeChild(index) |
|
445 del selectedItem |
|
446 if rpItem.childCount() == 0: |
|
447 index = self.passkeysList.indexOfTopLevelItem(rpItem) |
|
448 self.passkeysList.takeTopLevelItem(index) |
|
449 del rpItem |
|
450 |
|
451 ############################################################################ |
|
452 ## utility methods |
|
453 ############################################################################ |
|
454 |
|
455 def __handleError(self, error, title, message): |
|
456 """ |
|
457 Private method to handle an error reported by the manager. |
|
458 |
|
459 @param error reference to the exception object |
|
460 @type Exception |
|
461 @param title tirle of the message box |
|
462 @type str |
|
463 @param message message to be shown |
|
464 @type str |
|
465 """ |
|
466 EricMessageBox.critical( |
|
467 self, |
|
468 title, |
|
469 self.tr("<p>{0}</p><p>Reason: {1}</p>").format(message, str(error)), |
|
470 ) |
|
471 if isinstance(error, Fido2DeviceError): |
|
472 self.__populateDeviceSelector() |