|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2010 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to show and edit all certificates. |
|
8 """ |
|
9 |
|
10 import contextlib |
|
11 import pathlib |
|
12 |
|
13 from PyQt6.QtCore import pyqtSlot, Qt, QByteArray |
|
14 from PyQt6.QtWidgets import QDialog, QTreeWidgetItem |
|
15 with contextlib.suppress(ImportError): |
|
16 from PyQt6.QtNetwork import QSslCertificate, QSslConfiguration, QSsl |
|
17 |
|
18 from EricWidgets import EricMessageBox, EricFileDialog |
|
19 |
|
20 from .Ui_EricSslCertificatesDialog import Ui_EricSslCertificatesDialog |
|
21 |
|
22 import Preferences |
|
23 import Utilities |
|
24 import UI.PixmapCache |
|
25 import Globals |
|
26 |
|
27 |
|
28 class EricSslCertificatesDialog(QDialog, Ui_EricSslCertificatesDialog): |
|
29 """ |
|
30 Class implementing a dialog to show and edit all certificates. |
|
31 """ |
|
32 CertRole = Qt.ItemDataRole.UserRole + 1 |
|
33 |
|
34 def __init__(self, parent=None): |
|
35 """ |
|
36 Constructor |
|
37 |
|
38 @param parent reference to the parent widget (QWidget) |
|
39 """ |
|
40 super().__init__(parent) |
|
41 self.setupUi(self) |
|
42 |
|
43 self.serversViewButton.setIcon( |
|
44 UI.PixmapCache.getIcon("certificates")) |
|
45 self.serversDeleteButton.setIcon( |
|
46 UI.PixmapCache.getIcon("certificateDelete")) |
|
47 self.serversExportButton.setIcon( |
|
48 UI.PixmapCache.getIcon("certificateExport")) |
|
49 self.serversImportButton.setIcon( |
|
50 UI.PixmapCache.getIcon("certificateImport")) |
|
51 |
|
52 self.caViewButton.setIcon( |
|
53 UI.PixmapCache.getIcon("certificates")) |
|
54 self.caDeleteButton.setIcon( |
|
55 UI.PixmapCache.getIcon("certificateDelete")) |
|
56 self.caExportButton.setIcon( |
|
57 UI.PixmapCache.getIcon("certificateExport")) |
|
58 self.caImportButton.setIcon( |
|
59 UI.PixmapCache.getIcon("certificateImport")) |
|
60 |
|
61 self.__populateServerCertificatesTree() |
|
62 self.__populateCaCertificatesTree() |
|
63 |
|
64 def __populateServerCertificatesTree(self): |
|
65 """ |
|
66 Private slot to populate the server certificates tree. |
|
67 """ |
|
68 certificateDict = Globals.toDict( |
|
69 Preferences.getSettings().value("Ssl/CaCertificatesDict")) |
|
70 for server in certificateDict: |
|
71 for cert in QSslCertificate.fromData(certificateDict[server]): |
|
72 self.__createServerCertificateEntry(server, cert) |
|
73 |
|
74 self.serversCertificatesTree.expandAll() |
|
75 for i in range(self.serversCertificatesTree.columnCount()): |
|
76 self.serversCertificatesTree.resizeColumnToContents(i) |
|
77 |
|
78 def __createServerCertificateEntry(self, server, cert): |
|
79 """ |
|
80 Private method to create a server certificate entry. |
|
81 |
|
82 @param server server name of the certificate (string) |
|
83 @param cert certificate to insert (QSslCertificate) |
|
84 """ |
|
85 # step 1: extract the info to be shown |
|
86 organisation = Utilities.decodeString( |
|
87 ", ".join(cert.subjectInfo( |
|
88 QSslCertificate.SubjectInfo.Organization))) |
|
89 commonName = Utilities.decodeString( |
|
90 ", ".join(cert.subjectInfo( |
|
91 QSslCertificate.SubjectInfo.CommonName))) |
|
92 if organisation is None or organisation == "": |
|
93 organisation = self.tr("(Unknown)") |
|
94 if commonName is None or commonName == "": |
|
95 commonName = self.tr("(Unknown common name)") |
|
96 expiryDate = cert.expiryDate().toString("yyyy-MM-dd") |
|
97 |
|
98 # step 2: create the entry |
|
99 items = self.serversCertificatesTree.findItems( |
|
100 organisation, |
|
101 Qt.MatchFlag.MatchFixedString | Qt.MatchFlag.MatchCaseSensitive) |
|
102 if len(items) == 0: |
|
103 parent = QTreeWidgetItem( |
|
104 self.serversCertificatesTree, [organisation]) |
|
105 parent.setFirstColumnSpanned(True) |
|
106 else: |
|
107 parent = items[0] |
|
108 |
|
109 itm = QTreeWidgetItem(parent, [commonName, server, expiryDate]) |
|
110 itm.setData(0, self.CertRole, cert.toPem()) |
|
111 |
|
112 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) |
|
113 def on_serversCertificatesTree_currentItemChanged(self, current, previous): |
|
114 """ |
|
115 Private slot handling a change of the current item in the |
|
116 server certificates list. |
|
117 |
|
118 @param current new current item (QTreeWidgetItem) |
|
119 @param previous previous current item (QTreeWidgetItem) |
|
120 """ |
|
121 enable = current is not None and current.parent() is not None |
|
122 self.serversViewButton.setEnabled(enable) |
|
123 self.serversDeleteButton.setEnabled(enable) |
|
124 self.serversExportButton.setEnabled(enable) |
|
125 |
|
126 @pyqtSlot() |
|
127 def on_serversViewButton_clicked(self): |
|
128 """ |
|
129 Private slot to show data of the selected server certificate. |
|
130 """ |
|
131 with contextlib.suppress(ImportError): |
|
132 from EricNetwork.EricSslCertificatesInfoDialog import ( |
|
133 EricSslCertificatesInfoDialog |
|
134 ) |
|
135 cert = QSslCertificate.fromData( |
|
136 self.serversCertificatesTree.currentItem().data( |
|
137 0, self.CertRole)) |
|
138 dlg = EricSslCertificatesInfoDialog(cert, self) |
|
139 dlg.exec() |
|
140 |
|
141 @pyqtSlot() |
|
142 def on_serversDeleteButton_clicked(self): |
|
143 """ |
|
144 Private slot to delete the selected server certificate. |
|
145 """ |
|
146 itm = self.serversCertificatesTree.currentItem() |
|
147 res = EricMessageBox.yesNo( |
|
148 self, |
|
149 self.tr("Delete Server Certificate"), |
|
150 self.tr("""<p>Shall the server certificate really be""" |
|
151 """ deleted?</p><p>{0}</p>""" |
|
152 """<p>If the server certificate is deleted, the""" |
|
153 """ normal security checks will be reinstantiated""" |
|
154 """ and the server has to present a valid""" |
|
155 """ certificate.</p>""") |
|
156 .format(itm.text(0))) |
|
157 if res: |
|
158 server = itm.text(1) |
|
159 cert = self.serversCertificatesTree.currentItem().data( |
|
160 0, self.CertRole) |
|
161 |
|
162 # delete the selected entry and its parent entry, |
|
163 # if it was the only one |
|
164 parent = itm.parent() |
|
165 parent.takeChild(parent.indexOfChild(itm)) |
|
166 if parent.childCount() == 0: |
|
167 self.serversCertificatesTree.takeTopLevelItem( |
|
168 self.serversCertificatesTree.indexOfTopLevelItem(parent)) |
|
169 |
|
170 # delete the certificate from the user certificate store |
|
171 certificateDict = Globals.toDict( |
|
172 Preferences.getSettings().value("Ssl/CaCertificatesDict")) |
|
173 if server in certificateDict: |
|
174 certs = [c.toPem() for c in |
|
175 QSslCertificate.fromData(certificateDict[server])] |
|
176 if cert in certs: |
|
177 certs.remove(cert) |
|
178 if certs: |
|
179 pems = QByteArray() |
|
180 for cert in certs: |
|
181 pems.append(cert + b'\n') |
|
182 certificateDict[server] = pems |
|
183 else: |
|
184 del certificateDict[server] |
|
185 Preferences.getSettings().setValue( |
|
186 "Ssl/CaCertificatesDict", |
|
187 certificateDict) |
|
188 |
|
189 # delete the certificate from the default certificates |
|
190 self.__updateDefaultConfiguration() |
|
191 |
|
192 @pyqtSlot() |
|
193 def on_serversImportButton_clicked(self): |
|
194 """ |
|
195 Private slot to import server certificates. |
|
196 """ |
|
197 certs = self.__importCertificate() |
|
198 if certs: |
|
199 server = "*" |
|
200 certificateDict = Globals.toDict( |
|
201 Preferences.getSettings().value("Ssl/CaCertificatesDict")) |
|
202 if server in certificateDict: |
|
203 sCerts = QSslCertificate.fromData(certificateDict[server]) |
|
204 else: |
|
205 sCerts = [] |
|
206 |
|
207 pems = QByteArray() |
|
208 for cert in certs: |
|
209 if cert in sCerts: |
|
210 commonStr = ", ".join( |
|
211 cert.subjectInfo( |
|
212 QSslCertificate.SubjectInfo.CommonName)) |
|
213 EricMessageBox.warning( |
|
214 self, |
|
215 self.tr("Import Certificate"), |
|
216 self.tr( |
|
217 """<p>The certificate <b>{0}</b> already exists.""" |
|
218 """ Skipping.</p>""") |
|
219 .format(Utilities.decodeString(commonStr))) |
|
220 else: |
|
221 pems.append(cert.toPem() + b'\n') |
|
222 if server not in certificateDict: |
|
223 certificateDict[server] = QByteArray() |
|
224 certificateDict[server].append(pems) |
|
225 Preferences.getSettings().setValue( |
|
226 "Ssl/CaCertificatesDict", |
|
227 certificateDict) |
|
228 |
|
229 self.serversCertificatesTree.clear() |
|
230 self.__populateServerCertificatesTree() |
|
231 |
|
232 self.__updateDefaultConfiguration() |
|
233 |
|
234 @pyqtSlot() |
|
235 def on_serversExportButton_clicked(self): |
|
236 """ |
|
237 Private slot to export the selected server certificate. |
|
238 """ |
|
239 cert = self.serversCertificatesTree.currentItem().data( |
|
240 0, self.CertRole) |
|
241 fname = ( |
|
242 self.serversCertificatesTree.currentItem().text(0).replace(" ", "") |
|
243 .replace("\t", "") |
|
244 ) |
|
245 self.__exportCertificate(fname, cert) |
|
246 |
|
247 def __updateDefaultConfiguration(self): |
|
248 """ |
|
249 Private method to update the default SSL configuration. |
|
250 """ |
|
251 caList = self.__getSystemCaCertificates() |
|
252 certificateDict = Globals.toDict( |
|
253 Preferences.getSettings().value("Ssl/CaCertificatesDict")) |
|
254 for server in certificateDict: |
|
255 for cert in QSslCertificate.fromData(certificateDict[server]): |
|
256 if cert not in caList: |
|
257 caList.append(cert) |
|
258 sslCfg = QSslConfiguration.defaultConfiguration() |
|
259 sslCfg.setCaCertificates(caList) |
|
260 QSslConfiguration.setDefaultConfiguration(sslCfg) |
|
261 |
|
262 def __getSystemCaCertificates(self): |
|
263 """ |
|
264 Private method to get the list of system certificates. |
|
265 |
|
266 @return list of system certificates (list of QSslCertificate) |
|
267 """ |
|
268 caList = QSslCertificate.fromData(Globals.toByteArray( |
|
269 Preferences.getSettings().value("Help/SystemCertificates"))) |
|
270 if not caList: |
|
271 caList = QSslConfiguration.systemCaCertificates() |
|
272 return caList |
|
273 |
|
274 def __populateCaCertificatesTree(self): |
|
275 """ |
|
276 Private slot to populate the CA certificates tree. |
|
277 """ |
|
278 for cert in self.__getSystemCaCertificates(): |
|
279 self.__createCaCertificateEntry(cert) |
|
280 |
|
281 self.caCertificatesTree.expandAll() |
|
282 for i in range(self.caCertificatesTree.columnCount()): |
|
283 self.caCertificatesTree.resizeColumnToContents(i) |
|
284 self.caCertificatesTree.sortItems(0, Qt.SortOrder.AscendingOrder) |
|
285 |
|
286 def __createCaCertificateEntry(self, cert): |
|
287 """ |
|
288 Private method to create a CA certificate entry. |
|
289 |
|
290 @param cert certificate to insert (QSslCertificate) |
|
291 """ |
|
292 # step 1: extract the info to be shown |
|
293 organisation = Utilities.decodeString( |
|
294 ", ".join(cert.subjectInfo( |
|
295 QSslCertificate.SubjectInfo.Organization))) |
|
296 commonName = Utilities.decodeString( |
|
297 ", ".join(cert.subjectInfo( |
|
298 QSslCertificate.SubjectInfo.CommonName))) |
|
299 if organisation is None or organisation == "": |
|
300 organisation = self.tr("(Unknown)") |
|
301 if commonName is None or commonName == "": |
|
302 commonName = self.tr("(Unknown common name)") |
|
303 expiryDate = cert.expiryDate().toString("yyyy-MM-dd") |
|
304 |
|
305 # step 2: create the entry |
|
306 items = self.caCertificatesTree.findItems( |
|
307 organisation, |
|
308 Qt.MatchFlag.MatchFixedString | Qt.MatchFlag.MatchCaseSensitive) |
|
309 if len(items) == 0: |
|
310 parent = QTreeWidgetItem(self.caCertificatesTree, [organisation]) |
|
311 parent.setFirstColumnSpanned(True) |
|
312 else: |
|
313 parent = items[0] |
|
314 |
|
315 itm = QTreeWidgetItem(parent, [commonName, expiryDate]) |
|
316 itm.setData(0, self.CertRole, cert.toPem()) |
|
317 |
|
318 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) |
|
319 def on_caCertificatesTree_currentItemChanged(self, current, previous): |
|
320 """ |
|
321 Private slot handling a change of the current item |
|
322 in the CA certificates list. |
|
323 |
|
324 @param current new current item (QTreeWidgetItem) |
|
325 @param previous previous current item (QTreeWidgetItem) |
|
326 """ |
|
327 enable = current is not None and current.parent() is not None |
|
328 self.caViewButton.setEnabled(enable) |
|
329 self.caDeleteButton.setEnabled(enable) |
|
330 self.caExportButton.setEnabled(enable) |
|
331 |
|
332 @pyqtSlot() |
|
333 def on_caViewButton_clicked(self): |
|
334 """ |
|
335 Private slot to show data of the selected CA certificate. |
|
336 """ |
|
337 with contextlib.suppress(ImportError): |
|
338 from EricNetwork.EricSslCertificatesInfoDialog import ( |
|
339 EricSslCertificatesInfoDialog |
|
340 ) |
|
341 cert = QSslCertificate.fromData( |
|
342 self.caCertificatesTree.currentItem().data(0, self.CertRole)) |
|
343 dlg = EricSslCertificatesInfoDialog(cert, self) |
|
344 dlg.exec() |
|
345 |
|
346 @pyqtSlot() |
|
347 def on_caDeleteButton_clicked(self): |
|
348 """ |
|
349 Private slot to delete the selected CA certificate. |
|
350 """ |
|
351 itm = self.caCertificatesTree.currentItem() |
|
352 res = EricMessageBox.yesNo( |
|
353 self, |
|
354 self.tr("Delete CA Certificate"), |
|
355 self.tr( |
|
356 """<p>Shall the CA certificate really be deleted?</p>""" |
|
357 """<p>{0}</p>""" |
|
358 """<p>If the CA certificate is deleted, the browser""" |
|
359 """ will not trust any certificate issued by this CA.</p>""") |
|
360 .format(itm.text(0))) |
|
361 if res: |
|
362 cert = self.caCertificatesTree.currentItem().data(0, self.CertRole) |
|
363 |
|
364 # delete the selected entry and its parent entry, |
|
365 # if it was the only one |
|
366 parent = itm.parent() |
|
367 parent.takeChild(parent.indexOfChild(itm)) |
|
368 if parent.childCount() == 0: |
|
369 self.caCertificatesTree.takeTopLevelItem( |
|
370 self.caCertificatesTree.indexOfTopLevelItem(parent)) |
|
371 |
|
372 # delete the certificate from the CA certificate store |
|
373 caCerts = self.__getSystemCaCertificates() |
|
374 if cert in caCerts: |
|
375 caCerts.remove(cert) |
|
376 pems = QByteArray() |
|
377 for cert in caCerts: |
|
378 pems.append(cert.toPem() + '\n') |
|
379 Preferences.getSettings().setValue( |
|
380 "Help/SystemCertificates", pems) |
|
381 |
|
382 # delete the certificate from the default certificates |
|
383 self.__updateDefaultConfiguration() |
|
384 |
|
385 @pyqtSlot() |
|
386 def on_caImportButton_clicked(self): |
|
387 """ |
|
388 Private slot to import server certificates. |
|
389 """ |
|
390 certs = self.__importCertificate() |
|
391 if certs: |
|
392 caCerts = self.__getSystemCaCertificates() |
|
393 for cert in certs: |
|
394 if cert in caCerts: |
|
395 commonStr = ", ".join( |
|
396 cert.subjectInfo( |
|
397 QSslCertificate.SubjectInfo.CommonName)) |
|
398 EricMessageBox.warning( |
|
399 self, |
|
400 self.tr("Import Certificate"), |
|
401 self.tr( |
|
402 """<p>The certificate <b>{0}</b> already exists.""" |
|
403 """ Skipping.</p>""") |
|
404 .format(Utilities.decodeString(commonStr))) |
|
405 else: |
|
406 caCerts.append(cert) |
|
407 |
|
408 pems = QByteArray() |
|
409 for cert in caCerts: |
|
410 pems.append(cert.toPem() + '\n') |
|
411 Preferences.getSettings().setValue( |
|
412 "Help/SystemCertificates", pems) |
|
413 |
|
414 self.caCertificatesTree.clear() |
|
415 self.__populateCaCertificatesTree() |
|
416 |
|
417 self.__updateDefaultConfiguration() |
|
418 |
|
419 @pyqtSlot() |
|
420 def on_caExportButton_clicked(self): |
|
421 """ |
|
422 Private slot to export the selected CA certificate. |
|
423 """ |
|
424 cert = self.caCertificatesTree.currentItem().data(0, self.CertRole) |
|
425 fname = ( |
|
426 self.caCertificatesTree.currentItem().text(0).replace(" ", "") |
|
427 .replace("\t", "") |
|
428 ) |
|
429 self.__exportCertificate(fname, cert) |
|
430 |
|
431 def __exportCertificate(self, name, cert): |
|
432 """ |
|
433 Private slot to export a certificate. |
|
434 |
|
435 @param name default file name without extension |
|
436 @type str |
|
437 @param cert certificate to be exported encoded as PEM |
|
438 @type QByteArray |
|
439 """ |
|
440 if cert is not None: |
|
441 fname, selectedFilter = EricFileDialog.getSaveFileNameAndFilter( |
|
442 self, |
|
443 self.tr("Export Certificate"), |
|
444 name, |
|
445 self.tr("Certificate File (PEM) (*.pem);;" |
|
446 "Certificate File (DER) (*.der)"), |
|
447 None, |
|
448 EricFileDialog.DontConfirmOverwrite) |
|
449 |
|
450 if fname: |
|
451 fpath = pathlib.Path(fname) |
|
452 if not fpath.suffix: |
|
453 ex = selectedFilter.split("(*")[1].split(")")[0] |
|
454 if ex: |
|
455 fpath = fpath.with_suffix(ex) |
|
456 if fpath.exists(): |
|
457 res = EricMessageBox.yesNo( |
|
458 self, |
|
459 self.tr("Export Certificate"), |
|
460 self.tr("<p>The file <b>{0}</b> already exists." |
|
461 " Overwrite it?</p>").format(fname), |
|
462 icon=EricMessageBox.Warning) |
|
463 if not res: |
|
464 return |
|
465 |
|
466 if fpath.suffix == ".pem": |
|
467 crt = bytes(cert) |
|
468 else: |
|
469 crt = bytes( |
|
470 QSslCertificate.fromData( |
|
471 crt, QSsl.EncodingFormat.Pem)[0].toDer() |
|
472 ) |
|
473 try: |
|
474 with fpath.open("wb") as f: |
|
475 f.write(crt) |
|
476 except OSError as err: |
|
477 EricMessageBox.critical( |
|
478 self, |
|
479 self.tr("Export Certificate"), |
|
480 self.tr( |
|
481 """<p>The certificate could not be written""" |
|
482 """ to file <b>{0}</b></p><p>Error: {1}</p>""") |
|
483 .format(fpath, str(err))) |
|
484 |
|
485 def __importCertificate(self): |
|
486 """ |
|
487 Private method to read a certificate. |
|
488 |
|
489 @return certificates read |
|
490 @rtype list of QSslCertificate |
|
491 """ |
|
492 fname = EricFileDialog.getOpenFileName( |
|
493 self, |
|
494 self.tr("Import Certificate"), |
|
495 "", |
|
496 self.tr("Certificate Files (*.pem *.crt *.der *.cer *.ca);;" |
|
497 "All Files (*)")) |
|
498 |
|
499 if fname: |
|
500 try: |
|
501 with pathlib.Path(fname).open("rb") as f: |
|
502 crt = QByteArray(f.read()) |
|
503 cert = QSslCertificate.fromData( |
|
504 crt, QSsl.EncodingFormat.Pem) |
|
505 if not cert: |
|
506 cert = QSslCertificate.fromData( |
|
507 crt, QSsl.EncodingFormat.Der) |
|
508 |
|
509 return cert |
|
510 except OSError as err: |
|
511 EricMessageBox.critical( |
|
512 self, |
|
513 self.tr("Import Certificate"), |
|
514 self.tr( |
|
515 """<p>The certificate could not be read from file""" |
|
516 """ <b>{0}</b></p><p>Error: {1}</p>""") |
|
517 .format(fname, str(err))) |
|
518 |
|
519 return [] |