src/eric7/WebBrowser/WebAuth/Fido2Management.py

branch
eric7
changeset 10854
30c45bd597e6
child 10856
b19cefceca15
equal deleted inserted replaced
10853:1f651b204780 10854:30c45bd597e6
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)

eric ide

mercurial