diff --git a/src/sakia/data/processors/identities.py b/src/sakia/data/processors/identities.py index 776bafe1c630ec97e289768029978cee8487ceda..5bd34187e9501e1b75d3e18cc3b4146b253ea1dd 100644 --- a/src/sakia/data/processors/identities.py +++ b/src/sakia/data/processors/identities.py @@ -95,16 +95,6 @@ class IdentitiesProcessor: self._logger.debug(str(e)) return identities - def get_written(self, currency, pubkey): - """ - Get identities from a given certification document - :param str currency: the currency in which to look for written identities - :param str pubkey: the pubkey of the identity - - :rtype: sakia.data.entities.Identity - """ - return self._identities_repo.get_all(currency=currency, pubkey=pubkey, ms_written_on=0) - def get_identity(self, currency, pubkey, uid=""): """ Return the identity corresponding to a given pubkey, uid and currency @@ -115,20 +105,16 @@ class IdentitiesProcessor: :rtype: sakia.data.entities.Identity """ - written = self.get_written(currency=currency, pubkey=pubkey) - if not written: - identities = self._identities_repo.get_all(currency=currency, pubkey=pubkey) - if identities: - recent = identities[0] - for i in identities: - if i.blockstamp > recent.blockstamp: - if uid and i.uid == uid: - recent = i - elif not uid: - recent = i - return recent - else: - return written[0] + identities = self._identities_repo.get_all(currency=currency, pubkey=pubkey) + if identities: + recent = identities[0] + for i in identities: + if i.blockstamp > recent.blockstamp: + if uid and i.uid == uid: + recent = i + elif not uid: + recent = i + return recent def insert_or_update_identity(self, identity): """ diff --git a/src/sakia/gui/dialogs/connection_cfg/model.py b/src/sakia/gui/dialogs/connection_cfg/model.py index ef1001a8a5d1c9aaa762c3cffca260900a891518..318269e3208836a1ff982e0419d65c266f541682 100644 --- a/src/sakia/gui/dialogs/connection_cfg/model.py +++ b/src/sakia/gui/dialogs/connection_cfg/model.py @@ -1,6 +1,6 @@ import aiohttp from PyQt5.QtCore import QObject -from duniterpy.documents import BlockUID, BMAEndpoint +from duniterpy.documents import BlockUID, BMAEndpoint, SecuredBMAEndpoint from duniterpy.api import bma, errors from duniterpy.key import SigningKey from sakia.data.entities import Connection, Identity, Node @@ -169,7 +169,8 @@ Current database is storing {1} network.""".format(node_connector.node.currency, async def execute_requests(parser, search): tries = 0 nonlocal registered - for endpoint in [e for e in self.node_connector.node.endpoints if isinstance(e, BMAEndpoint)]: + for endpoint in [e for e in self.node_connector.node.endpoints + if isinstance(e, BMAEndpoint) or isinstance(e, SecuredBMAEndpoint)]: if not registered[0] and not registered[2]: try: data = await self.node_connector.safe_request(endpoint, bma.wot.lookup, diff --git a/src/sakia/gui/dialogs/revocation/controller.py b/src/sakia/gui/dialogs/revocation/controller.py index 116ecd2d1932de27f063d4a087b078ee6b4174a4..23d5c3de4047505a58d0f3be174c755558ad080a 100644 --- a/src/sakia/gui/dialogs/revocation/controller.py +++ b/src/sakia/gui/dialogs/revocation/controller.py @@ -12,22 +12,29 @@ class RevocationController(QObject): The revocation view """ - def __init__(self, parent, view, model): + def __init__(self, view, model): """ Constructor of the revocation component - :param sakia.gui.revocation.view.revocationView: the view - :param sakia.gui.revocation.model.revocationModel model: the model + :param sakia.gui.dialogs.revocation.view.revocationView: the view + :param sakia.gui.dialogs.revocation.model.revocationModel model: the model """ - super().__init__(parent) + super().__init__() + self.view = view + self.model = model - self.handle_next_step(init=True) self.view.button_next.clicked.connect(lambda checked: self.handle_next_step(False)) + self.view.button_load.clicked.connect(self.load_from_file) + self.view.spinbox_port.valueChanged.connect(self.refresh_revocation_info) + self.view.edit_address.textChanged.connect(self.refresh_revocation_info) + self.view.radio_address.toggled.connect(self.refresh_revocation_info) + self.view.radio_currency.toggled.connect(self.refresh_revocation_info) + self._steps = ( { 'page': self.view.page_load_file, - 'init': self.init_dialog, - 'next': self.revocation_selected + 'init': lambda: None, + 'next': lambda: None }, { 'page': self.view.page_destination, @@ -38,41 +45,31 @@ class RevocationController(QObject): self._current_step = 0 @classmethod - def create(cls, parent, app, account): + def create(cls, parent, app, connection): """ Instanciate a revocation component - :param sakia.gui.component.controller.ComponentController parent: - :param sakia.core.Application app: + :param sakia.app.Application app: :return: a new revocation controller :rtype: revocationController """ view = RevocationView(parent.view) - model = RevocationModel(None, app, account) - revocation = cls(parent, view, model) - model.setParent(revocation) + model = RevocationModel(app, connection) + revocation = cls(view, model) return revocation @classmethod - def open_dialog(cls, parent, app, account): + def open_dialog(cls, parent, app, connection): """ Certify and identity :param sakia.gui.component.controller.ComponentController parent: the parent - :param sakia.core.Application app: the application - :param sakia.core.Account account: the account certifying the identity + :param sakia.app.Application app: the application + :param sakia.data.entities.Connection connection: the connection certifying the identity :return: """ - dialog = cls.create(parent, app, account=account) - dialog.refresh() + dialog = cls.create(parent, app, connection=connection) + dialog.handle_next_step(init=True) return dialog.exec() - @property - def view(self) -> RevocationView: - return self._view - - @property - def model(self) -> RevocationModel: - return self._model - def handle_next_step(self, init=False): if self._current_step < len(self._steps) - 1: if not init: @@ -86,18 +83,20 @@ class RevocationController(QObject): selected_file = self.view.select_revocation_file() try: self.model.load_revocation(selected_file) + self.view.show_revoked_selfcert(self.model.revoked_identity) + self.view.button_next.setEnabled(True) except FileNotFoundError: pass except MalformedDocumentError: self.view.malformed_file_error() self.button_next.setEnabled(False) - def revocation_selected(self): - pass + def refresh_revocation_info(self): + self.view.refresh_revocation_label(self.model.revoked_identity) def init_publication_page(self): - communities_names = self.model.communities_names() - self.view.set_communities_names(communities_names) + communities_names = self.model.currencies_names() + self.view.set_currencies_names(communities_names) def publish(self): self.view.button_next.setEnabled(False) @@ -108,13 +107,14 @@ class RevocationController(QObject): @asyncify async def accept(self): - if self.view.radio_community.isChecked(): - index = self.view.combo_community.currentIndex() - result, error = await self.model.send_to_community(index) + if self.view.radio_currency.isChecked(): + index = self.view.combo_currency.currentIndex() + result, error = await self.model.broadcast_to_network(index) else: server = self.view.edit_address.text() port = self.view.spinbox_port.value() - result, error = await self.model.send_to_node(server, port) + secured = self.view.checkbox_secured.isChecked() + result, error = await self.model.send_to_node(server, port, secured) if result: self.view.accept() @@ -123,10 +123,10 @@ class RevocationController(QObject): def async_exec(self): future = asyncio.Future() - self.widget.finished.connect(lambda r: future.set_result(r)) - self.widget.open() + self.view.finished.connect(lambda r: future.set_result(r)) + self.view.open() self.refresh() return future def exec(self): - self.widget.exec() + self.view.exec() diff --git a/src/sakia/gui/dialogs/revocation/model.py b/src/sakia/gui/dialogs/revocation/model.py index 3d7af2f2d8e775d33c0a3e5c82e2577561de3b7c..519c63220506eecc85ee8f476f5fee82bc71cbe1 100644 --- a/src/sakia/gui/dialogs/revocation/model.py +++ b/src/sakia/gui/dialogs/revocation/model.py @@ -1,5 +1,11 @@ -from duniterpy.documents.certification import Revocation +from duniterpy.documents import Revocation, BMAEndpoint, SecuredBMAEndpoint from duniterpy.api import bma, errors +from sakia.data.connectors import NodeConnector +from asyncio import TimeoutError +from socket import gaierror +import jsonschema +from aiohttp.errors import ClientError, DisconnectedError +from aiohttp.errors import ClientResponseError from PyQt5.QtCore import QObject import aiohttp @@ -9,13 +15,13 @@ class RevocationModel(QObject): The model of HomeScreen component """ - def __init__(self, parent, app, account): - super().__init__(parent) + def __init__(self, app, connection): + super().__init__() self.app = app - self.account = account + self.connection = connection self.revocation_document = None - self.revoked_selfcert = None + self.revoked_identity = None def load_revocation(self, path): """ @@ -25,42 +31,34 @@ class RevocationModel(QObject): with open(path, 'r') as file: file_content = file.read() self.revocation_document = Revocation.from_signed_raw(file_content) - self.revoked_selfcert = Revocation.extract_self_cert(file_content) + self.revoked_identity = Revocation.extract_self_cert(file_content) - def communities_names(self): - return [c.name for c in self.account.communities] + def currencies_names(self): + return [c for c in self.app.db.connections_repo.get_currencies()] - async def send_to_community(self, index): - community = self.account.communities[index] - responses = await community.bma_access.broadcast(bma.wot.Revoke, {}, - { - 'revocation': self.revocation_document.signed_raw( - self.revoked_selfcert) - }) + async def broadcast_to_network(self, index): + currency = self.currencies_names()[index] + return await self.app.documents_service.broadcast_revocation(currency, self.revoked_identity, + self.revocation_document) - result = False, "" - for r in responses: - if r.status == 200: - result = True, (await r.json()) - elif not result[0]: - result = False, (await r.text()) - else: - await r.release() - - return result - - async def send_to_node(self, server, port): - session = aiohttp.ClientSession() - try: - node = await Node.from_address(None, server, port, proxy=self.app.parameters.proxy(), session=session) - conn_handler = node.endpoint.conn_handler() - await bma.wot.Revoke(conn_handler).post(session, - revocation=self.revocation_document.signed_raw( - self.revoked_selfcert)) - except (ValueError, errors.DuniterError, - aiohttp.errors.ClientError, aiohttp.errors.DisconnectedError, - aiohttp.errors.TimeoutError) as e: - return False, str(e) - finally: - session.close() + async def send_to_node(self, server, port, secured): + signed_raw = self.revocation_document.signed_raw(self.revoked_identity) + node_connector = await NodeConnector.from_address(None, secured, server, port, self.app.parameters) + for endpoint in [e for e in node_connector.node.endpoints + if isinstance(e, BMAEndpoint) or isinstance(e, SecuredBMAEndpoint)]: + try: + self._logger.debug("Broadcasting : \n" + signed_raw) + conn_handler = endpoint.conn_handler(node_connector.session, proxy=self.app.parameters.proxy()) + result = await bma.wot.revoke(conn_handler, signed_raw) + if result.status == 200: + return True, "" + else: + return False, bma.api.parse_error(await result.text())["message"] + except errors.DuniterError as e: + return False, e.message + except (jsonschema.ValidationError, ClientError, gaierror, + TimeoutError, ConnectionRefusedError, DisconnectedError, ValueError) as e: + return False, str(e) + finally: + node_connector.session.close() return True, "" diff --git a/src/sakia/gui/dialogs/revocation/revocation.ui b/src/sakia/gui/dialogs/revocation/revocation.ui index 21641ab85afbae8571c4904f07b91166987cbf16..7688bd3f38c2b87a16a3a63b9b88f124f37ecfbc 100644 --- a/src/sakia/gui/dialogs/revocation/revocation.ui +++ b/src/sakia/gui/dialogs/revocation/revocation.ui @@ -6,8 +6,8 @@ <rect> <x>0</x> <y>0</y> - <width>400</width> - <height>250</height> + <width>452</width> + <height>358</height> </rect> </property> <property name="windowTitle"> @@ -103,14 +103,14 @@ QGroupBox::title { <item> <layout class="QHBoxLayout" name="horizontalLayout_2"> <item> - <widget class="QRadioButton" name="radio_community"> + <widget class="QRadioButton" name="radio_currency"> <property name="text"> <string>To a co&mmunity</string> </property> </widget> </item> <item> - <widget class="QComboBox" name="combo_community"/> + <widget class="QComboBox" name="combo_currency"/> </item> </layout> </item> @@ -139,6 +139,13 @@ QGroupBox::title { </property> </widget> </item> + <item> + <widget class="QCheckBox" name="checkbox_secured"> + <property name="text"> + <string>SSL/TLS</string> + </property> + </widget> + </item> </layout> </item> <item> diff --git a/src/sakia/gui/dialogs/revocation/view.py b/src/sakia/gui/dialogs/revocation/view.py index 25a37640fdd0d1cd208fd994f99ee9c85b78e022..4483a3d8c07c6b7770e2ba9a240b6803a38930c0 100644 --- a/src/sakia/gui/dialogs/revocation/view.py +++ b/src/sakia/gui/dialogs/revocation/view.py @@ -24,24 +24,19 @@ class RevocationView(QDialog, Ui_RevocationDialog): self.setupUi(self) self.button_next.setEnabled(False) - self.button_load.clicked.connect(self.load_from_file) self.radio_address.toggled.connect(lambda c: self.publication_mode_changed(RevocationView.PublicationMode.ADDRESS)) - self.radio_community.toggled.connect(lambda c: self.publication_mode_changed(RevocationView.PublicationMode.COMMUNITY)) - self.edit_address.textChanged.connect(self.refresh) - self.spinbox_port.valueChanged.connect(self.refresh) - self.combo_community.currentIndexChanged.connect(self.refresh) + self.radio_currency.toggled.connect(lambda c: self.publication_mode_changed(RevocationView.PublicationMode.COMMUNITY)) def publication_mode_changed(self, radio): self.edit_address.setEnabled(radio == RevocationView.PublicationMode.ADDRESS) self.spinbox_port.setEnabled(radio == RevocationView.PublicationMode.ADDRESS) - self.combo_community.setEnabled(radio == RevocationView.PublicationMode.COMMUNITY) - self.refresh_revocation_label() + self.combo_currency.setEnabled(radio == RevocationView.PublicationMode.COMMUNITY) def refresh_target(self): - if self.radio_community.isChecked(): + if self.radio_currency.isChecked(): target = self.tr( - "All nodes of community {name}".format(name=self.combo_community.currentText())) + "All nodes of currency {name}".format(name=self.combo_currency.currentText())) elif self.radio_address.isChecked(): target = self.tr("Address {address}:{port}".format(address=self.edit_address.text(), port=self.spinbox_port.value())) @@ -52,12 +47,40 @@ class RevocationView(QDialog, Ui_RevocationDialog): <div>{target}</div> """.format(target=target)) + def refresh_revocation_label(self, revoked_identity): + if revoked_identity: + text = self.tr(""" +<div>Identity revoked : {uid} (public key : {pubkey}...)</div> +<div>Identity signed on block : {timestamp}</div> + """.format(uid=revoked_identity.uid, + pubkey=revoked_identity.pubkey[:12], + timestamp=revoked_identity.timestamp)) + + self.label_revocation_content.setText(text) + + if self.radio_currency.isChecked(): + target = self.tr("All nodes of currency {name}".format(name=self.combo_currency.currentText())) + elif self.radio_address.isChecked(): + target = self.tr("Address {address}:{port}".format(address=self.edit_address.text(), + port=self.spinbox_port.value())) + else: + target = "" + self.label_revocation_info.setText(""" +<h4>Revocation document</h4> +<div>{text}</div> +<h4>Publication address</h4> +<div>{target}</div> +""".format(text=text, + target=target)) + else: + self.label_revocation_content.setText("") + def select_revocation_file(self): """ Get a revocation file using a file dialog :rtype: str """ - selected_files = QFileDialog.getOpenFileName(self.widget, + selected_files = QFileDialog.getOpenFileName(self, self.tr("Load a revocation file"), "", self.tr("All text files (*.txt)")) @@ -82,17 +105,17 @@ class RevocationView(QDialog, Ui_RevocationDialog): timestamp=selfcert.timestamp)) self.label_revocation_content.setText(text) - def set_communities_names(self, names): - self.combo_community.clear() + def set_currencies_names(self, names): + self.combo_currency.clear() for name in names: - self.combo_community.addItem(name) - self.radio_community.setChecked(True) + self.combo_currency.addItem(name) + self.radio_currency.setChecked(True) def ask_for_confirmation(self): - answer = QMessageBox.warning(self.widget, self.tr("Revocation"), + answer = QMessageBox.warning(self, self.tr("Revocation"), self.tr("""<h4>The publication of this document will remove your identity from the network.</h4> <li> - <li> <b>This identity won't be able to join the targeted community anymore.</b> </li> + <li> <b>This identity won't be able to join the targeted currency anymore.</b> </li> <li> <b>This identity won't be able to generate Universal Dividends anymore.</b> </li> <li> <b>This identity won't be able to certify individuals anymore.</b> </li> </li> @@ -102,6 +125,6 @@ class RevocationView(QDialog, Ui_RevocationDialog): @asyncify async def accept(self): - await QAsyncMessageBox.information(self.widget, self.tr("Revocation broadcast"), + await QAsyncMessageBox.information(self, self.tr("Revocation broadcast"), self.tr("The document was successfully broadcasted.")) super().accept() diff --git a/src/sakia/gui/main_window/toolbar/controller.py b/src/sakia/gui/main_window/toolbar/controller.py index 96e9d45f80a174a5b3aef718e1e41a6b9b1349ed..8c3fc1966867de4e07f6cc59ae5387bc04704633 100644 --- a/src/sakia/gui/main_window/toolbar/controller.py +++ b/src/sakia/gui/main_window/toolbar/controller.py @@ -34,6 +34,7 @@ class ToolbarController(QObject): self.view.action_add_connection.triggered.connect(self.open_add_connection_dialog) self.view.action_parameters.triggered.connect(self.open_settings_dialog) self.view.action_about.triggered.connect(self.open_about_dialog) + self.view.action_revoke_uid.triggered.connect(self.open_revocation_dialog) @classmethod def create(cls, app, navigation): @@ -81,7 +82,7 @@ class ToolbarController(QObject): self.model.navigation_model.current_connection()) def open_revocation_dialog(self): - RevocationController.open_dialog(self.app, self.model.navigation_model.current_connection()) + RevocationController.open_dialog(self, self.model.app, self.model.navigation_model.current_connection()) def open_transfer_money_dialog(self): TransferController.open_dialog(self, self.model.app, self.model.navigation_model.current_connection()) diff --git a/src/sakia/gui/navigation/controller.py b/src/sakia/gui/navigation/controller.py index f3c2e9c2f5997ed38bb04d2767859c60ad077e8c..a178023debc13269056f15aa2df994a333f8b2de 100644 --- a/src/sakia/gui/navigation/controller.py +++ b/src/sakia/gui/navigation/controller.py @@ -9,11 +9,11 @@ from .informations.controller import InformationsController from .graphs.wot.controller import WotController from sakia.data.entities import Connection from PyQt5.QtCore import pyqtSignal, QObject, Qt -from PyQt5.QtWidgets import QMenu, QAction, QMessageBox, QDialog +from PyQt5.QtWidgets import QMenu, QAction, QMessageBox, QDialog, QFileDialog from PyQt5.QtGui import QCursor from sakia.decorators import asyncify from sakia.gui.password_asker import PasswordAskerDialog -from sakia.gui.widgets.dialogs import QAsyncFileDialog, QAsyncMessageBox +from sakia.gui.widgets.dialogs import QAsyncMessageBox from sakia.gui.widgets import toast @@ -179,8 +179,8 @@ The process to join back the community later will have to be done again.""") raw_document = self.model.generate_revokation(connection, password) # Testable way of using a QFileDialog - selected_files = QAsyncFileDialog.get_save_filename(self, self.tr("Save a revokation document"), - "", self.tr("All text files (*.txt)")) + selected_files = QFileDialog.getSaveFileName(self.view, self.tr("Save a revokation document"), + "", self.tr("All text files (*.txt)")) if selected_files: path = selected_files[0] if not path.endswith('.txt'): @@ -191,7 +191,6 @@ The process to join back the community later will have to be done again.""") dialog = QMessageBox(QMessageBox.Information, self.tr("Revokation file"), self.tr("""<div>Your revokation document has been saved.</div> <div><b>Please keep it in a safe place.</b></div> -The publication of this document will remove your identity from the network.</p>"""), QMessageBox.Ok, - self) +The publication of this document will remove your identity from the network.</p>"""), QMessageBox.Ok) dialog.setTextFormat(Qt.RichText) dialog.exec() diff --git a/src/sakia/services/documents.py b/src/sakia/services/documents.py index 27bb0274f6d8c616a6aaeddfde1337aaf572a989..a68d58a26b8a9ae2c60b444ecfeae9c5b8a38b60 100644 --- a/src/sakia/services/documents.py +++ b/src/sakia/services/documents.py @@ -1,4 +1,4 @@ -import asyncio +import jsonschema import attr import logging @@ -91,6 +91,27 @@ class DocumentsService: return result, identity + async def broadcast_revocation(self, currency, identity_document, revocation_document): + signed_raw = revocation_document.signed_raw(identity_document) + self._logger.debug("Broadcasting : \n" + signed_raw) + responses = await self._bma_connector.broadcast(currency, bma.wot.revoke, req_args={ + 'revocation': signed_raw + }) + + result = False, "" + for r in responses: + if r.status == 200: + result = True, (await r.json()) + elif not result[0]: + try: + result = False, bma.api.parse_error(await r.text())["message"] + except jsonschema.ValidationError as e: + result = False, str(e) + else: + await r.release() + + return result + async def send_membership(self, connection, password, mstype): """ Send a membership document to a target community. @@ -214,9 +235,9 @@ class DocumentsService: :param sakia.data.entities.Connection connection: The connection of the identity :param str password: The account SigningKey password """ - document = Revocation(2, connection.currency, connection.pubkey, "") - identity = self._identities_processor.get_written(connection.currency, connection.pubkey) - self_cert = identity[0].document() + document = Revocation(10, connection.currency, connection.pubkey, "") + identity = self._identities_processor.get_identity(connection.currency, connection.pubkey, connection.uid) + self_cert = identity.document() key = SigningKey(connection.salt, password, connection.scrypt_params)