diff --git a/res/ui/mainwindow.ui b/res/ui/mainwindow.ui index 9e68d093597d4b9d2bf952aa91058a41914127b2..d92357d930a1c8257d97ae0eaa38dbe2dfe9b4f5 100644 --- a/res/ui/mainwindow.ui +++ b/res/ui/mainwindow.ui @@ -50,6 +50,12 @@ <string>&Open</string> </property> </widget> + <widget class="QMenu" name="menuAdvanced"> + <property name="title"> + <string>Advanced</string> + </property> + <addaction name="action_revoke_identity"/> + </widget> <addaction name="menu_change_account"/> <addaction name="action_configure_parameters"/> <addaction name="action_add_account"/> @@ -59,6 +65,8 @@ <addaction name="separator"/> <addaction name="action_add_a_contact"/> <addaction name="menu_contacts_list"/> + <addaction name="separator"/> + <addaction name="menuAdvanced"/> </widget> <widget class="QMenu" name="menu_help"> <property name="title"> @@ -188,6 +196,11 @@ <string>&Manage local node</string> </property> </action> + <action name="action_revoke_identity"> + <property name="text"> + <string>Revoke an identity</string> + </property> + </action> </widget> <resources> <include location="../icons/icons.qrc"/> diff --git a/res/ui/revocation.ui b/res/ui/revocation.ui new file mode 100644 index 0000000000000000000000000000000000000000..d7edc4990e334b82dbe71405c249084bd05ad234 --- /dev/null +++ b/res/ui/revocation.ui @@ -0,0 +1,234 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>RevocationDialog</class> + <widget class="QDialog" name="RevocationDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>400</width> + <height>250</height> + </rect> + </property> + <property name="windowTitle"> + <string>Revoke an identity</string> + </property> + <property name="modal"> + <bool>true</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QStackedWidget" name="stackedWidget"> + <property name="styleSheet"> + <string notr="true">QGroupBox { + border: 1px solid gray; + border-radius: 9px; + margin-top: 0.5em; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 3px 0 3px; + font-weight: bold; +}</string> + </property> + <property name="currentIndex"> + <number>1</number> + </property> + <widget class="QWidget" name="page_load_file"> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QLabel" name="label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Maximum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string><h2>Select a revokation document</h1></string> + </property> + <property name="textFormat"> + <enum>Qt::RichText</enum> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="button_load"> + <property name="text"> + <string>Load from file</string> + </property> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox"> + <property name="styleSheet"> + <string notr="true"/> + </property> + <property name="title"> + <string>Revocation document</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <widget class="QLabel" name="label_revocation_content"> + <property name="text"> + <string/> + </property> + </widget> + </item> + </layout> + </widget> + </item> + </layout> + </widget> + <widget class="QWidget" name="page_destination"> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <item> + <widget class="QLabel" name="label_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Maximum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string><html><head/><body><p><span style=" font-size:x-large; font-weight:600;">Select publication destination</span></p></body></html></string> + </property> + <property name="textFormat"> + <enum>Qt::RichText</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QRadioButton" name="radio_community"> + <property name="text"> + <string>To a co&mmunity</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="combo_community"/> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <property name="sizeConstraint"> + <enum>QLayout::SetDefaultConstraint</enum> + </property> + <item> + <widget class="QRadioButton" name="radio_address"> + <property name="text"> + <string>&To an address</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="edit_address"/> + </item> + <item> + <widget class="QSpinBox" name="spinbox_port"> + <property name="maximum"> + <number>65535</number> + </property> + <property name="value"> + <number>8201</number> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QGroupBox" name="groupBox_2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="title"> + <string>Revocation information</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_5"> + <item> + <widget class="QLabel" name="label_revocation_info"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>1</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </item> + </layout> + </widget> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <property name="topMargin"> + <number>6</number> + </property> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="button_next"> + <property name="text"> + <string>Next</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> + <slots> + <slot>open_process_add_community()</slot> + <slot>key_changed(int)</slot> + <slot>action_remove_community()</slot> + <slot>open_process_edit_community(QModelIndex)</slot> + <slot>next()</slot> + <slot>previous()</slot> + <slot>open_import_key()</slot> + <slot>open_generate_account_key()</slot> + <slot>action_edit_account_key()</slot> + <slot>action_edit_account_parameters()</slot> + <slot>action_show_pubkey()</slot> + <slot>action_delete_account()</slot> + </slots> +</ui> diff --git a/src/sakia/core/account.py b/src/sakia/core/account.py index 8765d181cb568d17626cbfd2011b8d15a7fc4939..82b0096486043f93b18f409f4b7d08b6e6636d9f 100644 --- a/src/sakia/core/account.py +++ b/src/sakia/core/account.py @@ -4,7 +4,7 @@ Created on 1 févr. 2014 @author: inso """ -from duniterpy.documents import Membership, SelfCertification, Certification, Revokation, BlockUID, Block +from duniterpy.documents import Membership, SelfCertification, Certification, Revocation, BlockUID, Block from duniterpy.key import SigningKey from duniterpy.api import bma from duniterpy.api.bma import PROTOCOL_VERSION @@ -546,7 +546,7 @@ class Account(QObject): """ revoked = await self._identities_registry.future_find(self.pubkey, community) - revokation = Revokation(PROTOCOL_VERSION, community.currency, None) + revokation = Revocation(PROTOCOL_VERSION, community.currency, None) selfcert = await revoked.selfcert(community) key = SigningKey(self.salt, password) @@ -578,9 +578,9 @@ class Account(QObject): :param sakia.core.Community community: the community :param str password: the password :return: the revokation document - :rtype: duniterpy.documents.certification.Revokation + :rtype: duniterpy.documents.certification.Revocation """ - document = Revokation(PROTOCOL_VERSION, community.currency, self.pubkey, "") + document = Revocation(PROTOCOL_VERSION, community.currency, self.pubkey, "") identity = await self.identity(community) selfcert = await identity.selfcert(community) diff --git a/src/sakia/core/transfer.py b/src/sakia/core/transfer.py index 3234a34a43f88a99b66fb56f1020afb234046b40..b1cd3048ac8d02f013480656ed5c82a2e9285729 100644 --- a/src/sakia/core/transfer.py +++ b/src/sakia/core/transfer.py @@ -68,6 +68,9 @@ class Transfer(QObject): self._locally_created = locally_created self._metadata = metadata + # Dict containing states of a transfer : + # keys are a tuple containg (current_state, transition_parameters) + # values are tuples containing (transition_test, transition_success, new_state) self._table_states = { (TransferState.TO_SEND, (list, Block)): ( diff --git a/src/sakia/gui/mainwindow.py b/src/sakia/gui/mainwindow.py index c348d56b97550584ddc78249cf126741d2ffe01a..5d66b78e060f2d8ad39799d10ec09d94e7944b9a 100644 --- a/src/sakia/gui/mainwindow.py +++ b/src/sakia/gui/mainwindow.py @@ -21,6 +21,7 @@ from .community_view import CommunityWidget from .contact import ConfigureContactDialog from .import_account import ImportAccountDialog from .certification import CertificationDialog +from .revocation import RevocationDialog from .password_asker import PasswordAskerDialog from .preferences import PreferencesDialog from .process_cfg_community import ProcessConfigureCommunity @@ -110,6 +111,7 @@ class MainWindow(QObject): self.ui.actionCertification.triggered.connect(self.open_certification_dialog) self.ui.actionPreferences.triggered.connect(self.open_preferences_dialog) self.ui.actionAbout.triggered.connect(self.open_about_popup) + self.ui.action_revoke_identity.triggered.connect(self.open_revocation_dialog) self.ui.actionManage_local_node.triggered.connect(self.open_duniter_ui) self.ui.menu_duniter.setDisabled(True) @@ -263,6 +265,10 @@ class MainWindow(QObject): self.community_view.community, self.password_asker) + def open_revocation_dialog(self): + RevocationDialog.open_dialog(self.app, + self.account) + def open_add_contact_dialog(self): dialog = ConfigureContactDialog.new_contact(self.app, self.account, self.widget) dialog.exec_() diff --git a/src/sakia/gui/revocation.py b/src/sakia/gui/revocation.py new file mode 100644 index 0000000000000000000000000000000000000000..ddcca43af472054f4cfb28a41e738b207531976c --- /dev/null +++ b/src/sakia/gui/revocation.py @@ -0,0 +1,206 @@ +import asyncio +import aiohttp +import logging +from duniterpy.api import errors +from duniterpy.api import bma +from duniterpy.documents import MalformedDocumentError +from duniterpy.documents.certification import Revocation +from sakia.core.net import Node +from PyQt5.QtWidgets import QDialog, QFileDialog, QMessageBox +from PyQt5.QtCore import QObject + +from .widgets.dialogs import QAsyncMessageBox +from ..tools.decorators import asyncify +from ..gen_resources.revocation_uic import Ui_RevocationDialog + + +class RevocationDialog(QObject): + """ + A dialog to revoke an identity + """ + + def __init__(self, app, account, widget, ui): + """ + Constructor if a certification dialog + + :param sakia.core.Application app: + :param sakia.core.Account account: + :param PyQt5.QtWidgets widget: the widget of the dialog + :param sakia.gen_resources.revocation_uic.Ui_RevocationDialog ui: the view of the certification dialog + :return: + """ + super().__init__() + self.widget = widget + self.ui = ui + self.ui.setupUi(self.widget) + self.app = app + self.account = account + self.revocation_document = None + self.revoked_selfcert = None + self._steps = ( + { + 'page': self.ui.page_load_file, + 'init': self.init_dialog, + 'next': self.revocation_selected + }, + { + 'page': self.ui.page_destination, + 'init': self.init_publication_page, + 'next': self.publish + } + ) + self._current_step = 0 + self.handle_next_step(init=True) + self.ui.button_next.clicked.connect(lambda checked: self.handle_next_step(False)) + + def handle_next_step(self, init=False): + if self._current_step < len(self._steps) - 1: + if not init: + self.ui.button_next.clicked.disconnect(self._steps[self._current_step]['next']) + self._current_step += 1 + self._steps[self._current_step]['init']() + self.ui.stackedWidget.setCurrentWidget(self._steps[self._current_step]['page']) + self.ui.button_next.clicked.connect(self._steps[self._current_step]['next']) + + def init_dialog(self): + self.ui.button_next.setEnabled(False) + self.ui.button_load.clicked.connect(self.load_from_file) + + self.ui.radio_address.toggled.connect(lambda c: self.publication_mode_changed("address")) + self.ui.radio_community.toggled.connect(lambda c: self.publication_mode_changed("community")) + self.ui.edit_address.textChanged.connect(self.refresh) + self.ui.spinbox_port.valueChanged.connect(self.refresh) + self.ui.combo_community.currentIndexChanged.connect(self.refresh) + + def publication_mode_changed(self, radio): + self.ui.edit_address.setEnabled(radio == "address") + self.ui.spinbox_port.setEnabled(radio == "address") + self.ui.combo_community.setEnabled(radio == "community") + self.refresh() + + def load_from_file(self): + selected_files = QFileDialog.getOpenFileName(self.widget, + self.tr("Load a revocation file"), + "", + self.tr("All text files (*.txt)")) + selected_file = selected_files[0] + try: + with open(selected_file, '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.refresh() + self.ui.button_next.setEnabled(True) + except MalformedDocumentError: + QMessageBox.critical(self.widget, self.tr("Error loading document"), + self.tr("Loaded document is not a revocation document"), + QMessageBox.Ok) + self.ui.button_next.setEnabled(False) + + def revocation_selected(self): + pass + + def init_publication_page(self): + self.ui.combo_community.clear() + if self.account: + for community in self.account.communities: + self.ui.combo_community.addItem(community.currency) + self.ui.radio_community.setChecked(True) + else: + self.ui.radio_address.setChecked(True) + self.ui.radio_community.setEnabled(False) + + def publish(self): + self.ui.button_next.setEnabled(False) + answer = QMessageBox.warning(self.widget, 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 generate Universal Dividends anymore.</b> </li> + <li> <b>This identity won't be able to certify individuals anymore.</b> </li> +</li> +Please think twice before publishing this document. +"""), QMessageBox.Ok | QMessageBox.Cancel) + if answer == QMessageBox.Ok: + self.accept() + else: + self.ui.button_next.setEnabled(True) + + @asyncify + async def accept(self): + try: + session = aiohttp.ClientSession() + if self.ui.radio_community.isChecked(): + community = self.account.communities[self.ui.combo_community.currentIndex()] + await community.bma_access.broadcast(bma.wot.Revoke, {}, + { + 'revocation': self.revocation_document.signed_raw(self.revoked_selfcert) + }) + elif self.ui.radio_address.isChecked(): + server = self.ui.edit_address.text() + port = self.ui.spinbox_port.value() + node = await Node.from_address(None, server, port, 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 (MalformedDocumentError, ValueError, errors.DuniterError, + aiohttp.errors.ClientError, aiohttp.errors.DisconnectedError, + aiohttp.errors.TimeoutError) as e: + await QAsyncMessageBox.critical(self.widget, self.tr("Error broadcasting document"), + str(e)) + else: + await QAsyncMessageBox.information(self.widget, self.tr("Revocation broadcast"), + self.tr("The document was successfully broadcasted.")) + self.widget.accept() + finally: + session.close() + + @classmethod + def open_dialog(cls, app, account): + """ + Certify and identity + :param sakia.core.Application app: the application + :param sakia.core.Account account: the account certifying the identity + :return: + """ + dialog = cls(app, account, QDialog(), Ui_RevocationDialog()) + dialog.refresh() + return dialog.exec() + + def refresh(self): + if self.revoked_selfcert: + text = self.tr(""" +<div>Identity revoked : {uid} (public key : {pubkey}...)</div> +<div>Identity signed on block : {timestamp}</div> + """.format(uid=self.revoked_selfcert.uid, + pubkey=self.revoked_selfcert.pubkey[:12], + timestamp=self.revoked_selfcert.timestamp)) + + self.ui.label_revocation_content.setText(text) + + if self.ui.radio_community.isChecked(): + target = self.tr("All nodes of community {name}".format(name=self.ui.combo_community.currentText())) + elif self.ui.radio_address.isChecked(): + target = self.tr("Address {address}:{port}".format(address=self.ui.edit_address.text(), + port=self.ui.spinbox_port.value())) + else: + target = "" + self.ui.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.ui.label_revocation_content.setText("") + + def async_exec(self): + future = asyncio.Future() + self.widget.finished.connect(lambda r: future.set_result(r)) + self.widget.open() + self.refresh() + return future + + def exec(self): + self.widget.exec()