|
1 # -*- coding: utf-8 -*- |
|
2 # Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> |
|
3 # |
|
4 |
|
5 """ |
|
6 Module implementing a manager for FIDO2 security keys. |
|
7 """ |
|
8 |
|
9 from fido2.ctap import CtapError |
|
10 from fido2.ctap2 import ClientPin, CredentialManagement, Ctap2 |
|
11 from fido2.hid import CtapHidDevice |
|
12 from fido2.webauthn import PublicKeyCredentialUserEntity |
|
13 from PyQt6.QtCore import QObject, pyqtSignal |
|
14 |
|
15 |
|
16 class Fido2PinError(Exception): |
|
17 """ |
|
18 Class signaling an issue with the PIN. |
|
19 """ |
|
20 |
|
21 pass |
|
22 |
|
23 |
|
24 class Fido2DeviceError(Exception): |
|
25 """ |
|
26 Class signaling an issue with the device. |
|
27 """ |
|
28 |
|
29 pass |
|
30 |
|
31 |
|
32 class Fido2Management(QObject): |
|
33 """ |
|
34 Class implementing a manager for FIDO2 security keys. |
|
35 |
|
36 @signal deviceConnected() emitted to indicate a connect to the security key |
|
37 @signal deviceDisconnected() emitted to indicate a disconnect from the security key |
|
38 """ |
|
39 |
|
40 deviceConnected = pyqtSignal() |
|
41 deviceDisconnected = pyqtSignal() |
|
42 |
|
43 def __init__(self, parent=None): |
|
44 """ |
|
45 Constructor |
|
46 |
|
47 @param parent reference to the parent object (defaults to None) |
|
48 @type QObject (optional) |
|
49 """ |
|
50 super().__init__(parent) |
|
51 |
|
52 self.disconnectFromDevice() |
|
53 |
|
54 def connectToDevice(self, device): |
|
55 """ |
|
56 Public method to connect to a given security key. |
|
57 |
|
58 @param device reference to the security key device class |
|
59 @type CtapHidDevice |
|
60 """ |
|
61 if self.__ctap2 is not None: |
|
62 self.disconnectFromDevice() |
|
63 |
|
64 self.__ctap2 = Ctap2(device) |
|
65 self.__clientPin = ClientPin(self.__ctap2) |
|
66 self.__pin = None |
|
67 |
|
68 self.deviceConnected.emit() |
|
69 |
|
70 def disconnectFromDevice(self): |
|
71 """ |
|
72 Public method to disconnect from the current device. |
|
73 """ |
|
74 self.__ctap2 = None |
|
75 self.__clientPin = None |
|
76 self.__pin = None |
|
77 |
|
78 self.deviceDisconnected.emit() |
|
79 |
|
80 def unlockDevice(self, pin): |
|
81 """ |
|
82 Public method to unlock the device (i.e. store the PIN for later use). |
|
83 |
|
84 @param pin PIN to be stored |
|
85 @type str |
|
86 """ |
|
87 self.__pin = pin |
|
88 |
|
89 def lockDevice(self): |
|
90 """ |
|
91 Public method to lock the device (i.e. delete the stored PIN). |
|
92 """ |
|
93 self.__pin = None |
|
94 |
|
95 def isDeviceLocked(self): |
|
96 """ |
|
97 Public method to check, if the device is in locked state (i.e. the stored PIN |
|
98 is None). |
|
99 |
|
100 @return flag indicating the locked state |
|
101 @rtype bool |
|
102 """ |
|
103 return self.__pin is None |
|
104 |
|
105 def getDevices(self): |
|
106 """ |
|
107 Public method to get a list of connected security keys. |
|
108 |
|
109 @return list of connected security keys |
|
110 @rtype list of CtapHidDevice |
|
111 """ |
|
112 return list(CtapHidDevice.list_devices()) |
|
113 |
|
114 def getKeyInfo(self): |
|
115 """ |
|
116 Public method to get information about the connected security key. |
|
117 |
|
118 @return dictionary containing the info data |
|
119 @rtype dict[str, Any] |
|
120 """ |
|
121 # TODO: not implemented yet |
|
122 return {} |
|
123 |
|
124 def resetDevice(self): |
|
125 """ |
|
126 Public method to reset the connected security key. |
|
127 """ |
|
128 # TODO: not implemented yet |
|
129 pass |
|
130 |
|
131 ############################################################################ |
|
132 ## methods related to PIN handling |
|
133 ############################################################################ |
|
134 |
|
135 def getMinimumPinLength(self): |
|
136 """ |
|
137 Public method to get the minimum PIN length defined by the security key. |
|
138 |
|
139 @return minimum length for the PIN |
|
140 @rtype int |
|
141 """ |
|
142 if self.__ctap2 is None: |
|
143 return None |
|
144 else: |
|
145 return self.__ctap2.info.min_pin_length |
|
146 |
|
147 def hasPin(self): |
|
148 """ |
|
149 Public method to check, if the connected security key has a PIN set. |
|
150 |
|
151 @return flag indicating that a PIN has been set or None in case no device |
|
152 was connected yet or it does not support PIN |
|
153 @rtype bool or None |
|
154 """ |
|
155 if self.__ctap2 is None: |
|
156 return None |
|
157 |
|
158 return self.__ctap2.info.options.get("clientPin") |
|
159 |
|
160 def forcedPinChange(self): |
|
161 """ |
|
162 Public method to check for a forced PIN change. |
|
163 |
|
164 @return flag indicating a forced PIN change is required |
|
165 @rtype bool |
|
166 """ |
|
167 if self.__ctap2 is None: |
|
168 return False |
|
169 |
|
170 return self.__ctap2.info.force_pin_change |
|
171 |
|
172 def getPinRetries(self): |
|
173 """ |
|
174 Public method to get the number of PIN retries left and an indication for the |
|
175 need of a power cycle. |
|
176 |
|
177 @return tuple containing the number of retries left and a flag indicating a |
|
178 power cycle is required |
|
179 @rtype tuple of (int, bool) |
|
180 """ |
|
181 if self.__ctap2 is None or self.__clientPin is None: |
|
182 return (None, None) |
|
183 |
|
184 return self.__clientPin.get_pin_retries() |
|
185 |
|
186 def changePin(self, pin, newPin): |
|
187 """ |
|
188 Public method to change the PIN of the connected security key. |
|
189 |
|
190 @param pin current PIN |
|
191 @type str |
|
192 @param newPin new PIN |
|
193 @type str |
|
194 """ |
|
195 # TODO: not implemented yet |
|
196 pass |
|
197 |
|
198 def setPin(self, pin): |
|
199 """ |
|
200 Public method to set a PIN for the connected security key. |
|
201 |
|
202 @param pin PIN to be set |
|
203 @type str |
|
204 """ |
|
205 # TODO: not implemented yet |
|
206 pass |
|
207 |
|
208 def verifyPin(self, pin): |
|
209 """ |
|
210 Public method to verify a given PIN. |
|
211 |
|
212 A successful verification of the PIN will reset the "retries" counter. |
|
213 |
|
214 @param pin PIN to be verified |
|
215 @type str |
|
216 @return flag indicating successful verification and a verification message |
|
217 @rtype tuple of (bool, str) |
|
218 """ |
|
219 if self.__ctap2 is None or self.__clientPin is None: |
|
220 return False |
|
221 |
|
222 try: |
|
223 self.__clientPin.get_pin_token( |
|
224 pin, ClientPin.PERMISSION.GET_ASSERTION, "eric-ide.python-projects.org" |
|
225 ) |
|
226 return True, self.tr("PIN verified") |
|
227 except CtapError as err: |
|
228 return ( |
|
229 False, |
|
230 self.tr("<p>PIN verification failed.</p><p>Reason: {0}").format( |
|
231 self.__pinErrorMessage(err) |
|
232 ), |
|
233 ) |
|
234 |
|
235 def __pinErrorMessage(self, err): |
|
236 """ |
|
237 Private method to get a message for a PIN error. |
|
238 |
|
239 @param err reference to the exception object |
|
240 @type CtapError |
|
241 @return message for the given PIN error |
|
242 @rtype str |
|
243 """ |
|
244 errorCode = err.code |
|
245 if errorCode == CtapError.ERR.PIN_INVALID: |
|
246 msg = self.tr("Invalid PIN") |
|
247 elif errorCode == CtapError.ERR.PIN_BLOCKED: |
|
248 msg = self.tr("PIN is blocked.") |
|
249 elif errorCode == CtapError.ERR.PIN_NOT_SET: |
|
250 msg = self.tr("No PIN set.") |
|
251 else: |
|
252 msg = str(err) |
|
253 return msg |
|
254 |
|
255 ############################################################################ |
|
256 ## methods related to passkey (credential) handling |
|
257 ############################################################################ |
|
258 |
|
259 def getPasskeys(self, pin): |
|
260 """ |
|
261 Public method to get all stored passkeys. |
|
262 |
|
263 @param pin PIN to unlock the connected security key |
|
264 @type str |
|
265 @return tuple containing a dictionary containing the stored passkeys grouped |
|
266 by Relying Party ID, the count of used credential slots and the count |
|
267 of available credential slots |
|
268 @rtype tuple of [dict[str, list[dict[str, Any]]], int, int] |
|
269 """ |
|
270 credentials = {} |
|
271 |
|
272 credentialManager = self.__initializeCredentialManager(pin) |
|
273 data = credentialManager.get_metadata() |
|
274 if data.get(CredentialManagement.RESULT.EXISTING_CRED_COUNT) > 0: |
|
275 for relyingParty in credentialManager.enumerate_rps(): |
|
276 relyingPartyId = relyingParty[CredentialManagement.RESULT.RP]["id"] |
|
277 credentials[relyingPartyId] = [] |
|
278 for credential in credentialManager.enumerate_creds( |
|
279 relyingParty[CredentialManagement.RESULT.RP_ID_HASH] |
|
280 ): |
|
281 credentials[relyingPartyId].append( |
|
282 { |
|
283 "credentialId": credential[ |
|
284 CredentialManagement.RESULT.CREDENTIAL_ID |
|
285 ], |
|
286 "userId": credential[CredentialManagement.RESULT.USER][ |
|
287 "id" |
|
288 ], |
|
289 "userName": credential[ |
|
290 CredentialManagement.RESULT.USER |
|
291 ].get("name", ""), |
|
292 "displayName": credential[ |
|
293 CredentialManagement.RESULT.USER |
|
294 ].get("displayName", ""), |
|
295 } |
|
296 ) |
|
297 |
|
298 return ( |
|
299 credentials, |
|
300 data.get(CredentialManagement.RESULT.EXISTING_CRED_COUNT), |
|
301 data.get(CredentialManagement.RESULT.MAX_REMAINING_COUNT), |
|
302 ) |
|
303 |
|
304 def deletePasskey(self, pin, credentialId): |
|
305 """ |
|
306 Public method to delete the passkey of the given ID. |
|
307 |
|
308 @param pin PIN to unlock the connected security key |
|
309 @type str |
|
310 @param credentialId ID of the passkey to be deleted |
|
311 @type fido2.webauthn.PublicKeyCredentialDescriptor |
|
312 """ |
|
313 credentialManager = self.__initializeCredentialManager(pin) |
|
314 credentialManager.delete_cred(cred_id=credentialId) |
|
315 |
|
316 def changePasskeyUserInfo(self, pin, credentialId, userId, userName, displayName): |
|
317 """ |
|
318 Public method to change the user info of a stored passkey. |
|
319 |
|
320 @param pin PIN to unlock the connected security key |
|
321 @type str |
|
322 @param credentialId ID of the passkey to change |
|
323 @type fido2.webauthn.PublicKeyCredentialDescriptor |
|
324 @param userId ID of the user |
|
325 @type bytes |
|
326 @param userName user name to set |
|
327 @type str |
|
328 @param displayName display name to set |
|
329 @type str |
|
330 """ |
|
331 userInfo = PublicKeyCredentialUserEntity( |
|
332 name=userName, id=userId, display_name=displayName |
|
333 ) |
|
334 credentialManager = self.__initializeCredentialManager(pin) |
|
335 credentialManager.update_user_info(cred_id=credentialId, user_info=userInfo) |
|
336 |
|
337 def __initializeCredentialManager(self, pin): |
|
338 """ |
|
339 Private method to initialize a credential manager object. |
|
340 |
|
341 @param pin PIN to unlock the connected security key |
|
342 @type str |
|
343 @return reference to the credential manager object |
|
344 @rtype CredentialManagement |
|
345 @exception Fido2DeviceError raised to indicate an issue with the selected |
|
346 security key |
|
347 @exception Fido2PinError raised to indicate an issue with the PIN |
|
348 """ |
|
349 if self.__clientPin is None: |
|
350 self.__clientPin = ClientPin(self.__ctap2) |
|
351 |
|
352 if pin == "": |
|
353 pin = self.__pin |
|
354 if pin is None: |
|
355 # Error |
|
356 raise Fido2PinError( |
|
357 self.tr( |
|
358 "The selected security key is not unlocked or no PIN was entered." |
|
359 ) |
|
360 ) |
|
361 |
|
362 try: |
|
363 pinToken = self.__clientPin.get_pin_token( |
|
364 pin, ClientPin.PERMISSION.CREDENTIAL_MGMT |
|
365 ) |
|
366 except CtapError as err: |
|
367 raise Fido2PinError( |
|
368 self.tr("PIN error: {0}").format(self.__pinErrorMessage(err)) |
|
369 ) |
|
370 except OSError: |
|
371 raise Fido2DeviceError( |
|
372 self.tr("Connected security key unplugged. Reinsert and try again.") |
|
373 ) |
|
374 |
|
375 return CredentialManagement(self.__ctap2, self.__clientPin.protocol, pinToken) |