From e5e1e46b7b9e84f07628b6d805b23689b94243e1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 25 Sep 2023 15:35:47 +0200 Subject: [PATCH] qml: add message sign/verify --- .../gui/qml/components/AddressDetails.qml | 25 ++- electrum/gui/qml/components/Constants.qml | 3 + .../components/SignVerifyMessageDialog.qml | 211 ++++++++++++++++++ .../gui/qml/components/WalletMainView.qml | 19 ++ .../qml/components/controls/ElTextArea.qml | 67 ++++++ electrum/gui/qml/components/main.qml | 8 + electrum/gui/qml/qebitcoin.py | 5 + electrum/gui/qml/qedaemon.py | 17 ++ electrum/gui/qml/qewallet.py | 14 ++ 9 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 electrum/gui/qml/components/SignVerifyMessageDialog.qml create mode 100644 electrum/gui/qml/components/controls/ElTextArea.qml diff --git a/electrum/gui/qml/components/AddressDetails.qml b/electrum/gui/qml/components/AddressDetails.qml index e779a8e03..200c4e8ec 100644 --- a/electrum/gui/qml/components/AddressDetails.qml +++ b/electrum/gui/qml/components/AddressDetails.qml @@ -279,11 +279,28 @@ Pane { } } - FlatButton { + ButtonContainer { Layout.fillWidth: true - text: addressdetails.isFrozen ? qsTr('Unfreeze address') : qsTr('Freeze address') - onClicked: addressdetails.freeze(!addressdetails.isFrozen) - icon.source: '../../icons/seal.png' + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: addressdetails.isFrozen ? qsTr('Unfreeze address') : qsTr('Freeze address') + onClicked: addressdetails.freeze(!addressdetails.isFrozen) + icon.source: '../../icons/seal.png' + } + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + visible: Daemon.currentWallet.canSignMessage + text: qsTr('Sign/Verify') + icon.source: '../../icons/pen.png' + onClicked: { + var dialog = app.signVerifyMessageDialog.createObject(app, { + address: root.address + }) + dialog.open() + } + } } } diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index 84c935de6..a701a9f1d 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -30,6 +30,7 @@ Item { property color mutedForeground: 'gray' //Qt.lighter(Material.background, 2) property color darkerBackground: Qt.darker(Material.background, 1.20) property color lighterBackground: Qt.lighter(Material.background, 1.10) + property color darkerDialogBackground: Qt.darker(Material.dialogColor, 1.20) property color notificationBackground: Qt.lighter(Material.background, 1.5) property color colorCredit: "#ff80ff80" @@ -40,6 +41,8 @@ Item { property color colorError: '#ffff8080' property color colorProgress: '#ffffff80' property color colorDone: '#ff80ff80' + property color colorValidBackground: '#ff008000' + property color colorInvalidBackground: '#ff800000' property color colorLightningLocal: "#6060ff" property color colorLightningLocalReserve: "#0000a0" diff --git a/electrum/gui/qml/components/SignVerifyMessageDialog.qml b/electrum/gui/qml/components/SignVerifyMessageDialog.qml new file mode 100644 index 000000000..d4e82d8c0 --- /dev/null +++ b/electrum/gui/qml/components/SignVerifyMessageDialog.qml @@ -0,0 +1,211 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + + enum Check { + Unknown, + Valid, + Invalid + } + + property string address + + property bool _addressValid: false + property bool _addressMine: false + property int _verified: SignVerifyMessageDialog.Check.Unknown + + implicitHeight: parent.height + implicitWidth: parent.width + + title: qsTr('Sign/Verify Message') + iconSource: Qt.resolvedUrl('../../icons/pen.png') + + padding: 0 + + function validateAddress() { + // TODO: not all types of addresses are valid (e.g. p2wsh) + _addressValid = bitcoin.isAddress(addressField.text) + _addressMine = Daemon.currentWallet.isAddressMine(addressField.text) + } + + ColumnLayout { + width: parent.width + height: parent.height + spacing: constants.paddingLarge + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: constants.paddingLarge + Layout.rightMargin: constants.paddingLarge + + Label { + text: qsTr('Address') + color: Material.accentColor + } + + RowLayout { + Layout.fillWidth: true + TextField { + id: addressField + Layout.fillWidth: true + placeholderText: qsTr('Address') + font.family: FixedFont + onTextChanged: { + validateAddress() + _verified = SignVerifyMessageDialog.Check.Unknown + } + } + ToolButton { + icon.source: '../../icons/paste.png' + icon.color: 'transparent' + onClicked: { + addressField.text = AppController.clipboardToText() + } + } + } + + Label { + text: qsTr('Message') + color: Material.accentColor + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + ElTextArea { + id: plaintext + Layout.fillWidth: true + Layout.fillHeight: true + font.family: FixedFont + wrapMode: TextInput.Wrap + background: PaneInsetBackground { + baseColor: constants.darkerDialogBackground + } + onTextChanged: _verified = SignVerifyMessageDialog.Check.Unknown + } + ColumnLayout { + Layout.alignment: Qt.AlignTop + ToolButton { + icon.source: '../../icons/paste.png' + icon.color: 'transparent' + onClicked: { + plaintext.text = AppController.clipboardToText() + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Label { + text: qsTr('Signature') + color: Material.accentColor + } + Label { + Layout.alignment: Qt.AlignRight + visible: _verified != SignVerifyMessageDialog.Check.Unknown + text: _verified == SignVerifyMessageDialog.Check.Valid + ? qsTr('Valid!') + : qsTr('Invalid!') + color: _verified == SignVerifyMessageDialog.Check.Valid + ? constants.colorDone + : constants.colorError + } + } + RowLayout { + Layout.fillWidth: true + ElTextArea { + id: signature + Layout.fillWidth: true + Layout.maximumHeight: fontMetrics.lineSpacing * 4 + topPadding + bottomPadding + Layout.minimumHeight: fontMetrics.lineSpacing * 4 + topPadding + bottomPadding + font.family: FixedFont + wrapMode: TextInput.Wrap + background: PaneInsetBackground { + baseColor: _verified == SignVerifyMessageDialog.Check.Unknown + ? constants.darkerDialogBackground + : _verified == SignVerifyMessageDialog.Check.Valid + ? constants.colorValidBackground + : constants.colorInvalidBackground + } + onTextChanged: _verified = SignVerifyMessageDialog.Check.Unknown + } + ColumnLayout { + Layout.alignment: Qt.AlignTop + ToolButton { + icon.source: '../../icons/paste.png' + icon.color: 'transparent' + onClicked: { + signature.text = AppController.clipboardToText() + } + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: enabled ? 'transparent' : Material.iconDisabledColor + enabled: signature.text + onClicked: { + var dialog = app.genericShareDialog.createObject(app, { + title: qsTr('Message signature'), + text_qr: signature.text + }) + dialog.open() + } + } + } + } + } + + ButtonContainer { + Layout.fillWidth: true + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: qsTr('Sign') + visible: Daemon.currentWallet.canSignMessage + enabled: _addressMine + icon.source: '../../icons/seal.png' + onClicked: { + var sig = Daemon.currentWallet.signMessage(addressField.text, plaintext.text) + signature.text = sig + } + } + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + enabled: _addressValid && signature.text + text: qsTr('Verify') + icon.source: '../../icons/confirmed.png' + onClicked: { + var result = Daemon.verifyMessage(addressField.text, plaintext.text, signature.text) + _verified = result + ? SignVerifyMessageDialog.Check.Valid + : SignVerifyMessageDialog.Check.Invalid + } + } + } + + } + + Component.onCompleted: { + addressField.text = address + } + + Bitcoin { + id: bitcoin + } + + FontMetrics { + id: fontMetrics + font: signature.font + } + +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 88f5a1779..79a29d00d 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -126,6 +126,21 @@ Item { } } + MenuItem { + icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor + icon.source: '../../icons/pen.png' + action: Action { + text: Daemon.currentWallet.canSignMessage + ? qsTr('Sign/Verify Message') + : qsTr('Verify Message') + onTriggered: { + var dialog = app.signVerifyMessageDialog.createObject(app) + dialog.open() + menu.deselect() + } + } + } + MenuSeparator { } MenuItem { @@ -140,6 +155,10 @@ Item { function openPage(url) { stack.pushOnRoot(url) + deselect() + } + + function deselect() { currentIndex = -1 } } diff --git a/electrum/gui/qml/components/controls/ElTextArea.qml b/electrum/gui/qml/components/controls/ElTextArea.qml new file mode 100644 index 000000000..45268a50a --- /dev/null +++ b/electrum/gui/qml/components/controls/ElTextArea.qml @@ -0,0 +1,67 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +// this component adds (auto)scrolling to the bare TextArea, to make it +// workable if text overflows the available space. +// This unfortunately hides many signals and properties from the TextArea, +// so add signals propagation and property aliases when needed. +Flickable { + id: root + + property alias text: edit.text + property alias wrapMode: edit.wrapMode + property alias background: rootpane.background + property alias font: edit.font + + contentWidth: rootpane.width + contentHeight: rootpane.height + clip: true + + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + + function ensureVisible(r) { + r.x = r.x + rootpane.leftPadding + r.y = r.y + rootpane.topPadding + var w = width - rootpane.leftPadding - rootpane.rightPadding + var h = height - rootpane.topPadding - rootpane.bottomPadding + if (contentX >= r.x) + contentX = r.x + else if (contentX+w <= r.x+r.width) + contentX = r.x+r.width-w + if (contentY >= r.y) + contentY = r.y + else if (contentY+h <= r.y+r.height) + contentY = r.y+r.height-h + } + + Pane { + id: rootpane + width: root.width + height: Math.max(root.height, edit.height + topPadding + bottomPadding) + padding: constants.paddingXSmall + TextArea { + id: edit + width: parent.width + focus: true + wrapMode: TextEdit.Wrap + onCursorRectangleChanged: root.ensureVisible(cursorRectangle) + onTextChanged: root.textChanged() + background: Rectangle { + color: 'transparent' + } + } + MouseArea { + // remaining area clicks focus textarea + width: parent.width + anchors.top: edit.bottom + anchors.bottom: parent.bottom + onClicked: edit.forceActiveFocus() + } + } + +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 13ad78acc..8400c5353 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -387,6 +387,14 @@ ApplicationWindow id: _channelOpenProgressDialog } + property alias signVerifyMessageDialog: _signVerifyMessageDialog + Component { + id: _signVerifyMessageDialog + SignVerifyMessageDialog { + onClosed: destroy() + } + } + Component { id: swapDialog SwapDialog { diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index f158a1857..f63e752fd 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -12,6 +12,7 @@ from electrum.util import get_asyncio_loop from electrum.transaction import tx_from_any from electrum.mnemonic import Mnemonic, is_any_2fa_seed_type from electrum.old_mnemonic import wordlist as old_wordlist +from electrum.bitcoin import is_address class QEBitcoin(QObject): @@ -150,6 +151,10 @@ class QEBitcoin(QObject): except Exception: return False + @pyqtSlot(str, result=bool) + def isAddress(self, addr: str): + return is_address(addr) + @pyqtSlot(str, result=bool) def isAddressList(self, csv: str): return keystore.is_address_list(csv) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index a6cc25bf5..f1f015ce0 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -1,3 +1,4 @@ +import base64 import os import threading from typing import TYPE_CHECKING @@ -10,6 +11,8 @@ from electrum.logging import get_logger from electrum.util import WalletFileException, standardize_path from electrum.plugin import run_hook from electrum.lnchannel import ChannelState +from electrum.bitcoin import is_address +from electrum.ecc import verify_message_with_address from .auth import AuthMixin, auth_protect from .qefx import QEFX @@ -354,3 +357,17 @@ class QEDaemon(AuthMixin, QObject): @pyqtSlot() def startNetwork(self): self.daemon.start_network() + + @pyqtSlot(str, str, str, result=bool) + def verifyMessage(self, address, message, signature): + address = address.strip() + message = message.strip().encode('utf-8') + if not is_address(address): + return False + try: + # This can throw on invalid base64 + sig = base64.b64decode(str(signature.strip())) + verified = verify_message_with_address(address, sig, message) + except Exception as e: + verified = False + return verified diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 148a39303..72b0d5cbc 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,4 +1,5 @@ import asyncio +import base64 import queue import threading import time @@ -433,6 +434,10 @@ class QEWallet(AuthMixin, QObject, QtEventListener): return self.wallet.m == 1 return True + @pyqtProperty(bool, notify=dataChanged) + def canSignMessage(self): + return not isinstance(self.wallet, Multisig_Wallet) and not self.wallet.is_watching_only() + @pyqtProperty(QEAmount, notify=balanceChanged) def frozenBalance(self): c, u, x = self.wallet.get_frozen_balance() @@ -762,3 +767,12 @@ class QEWallet(AuthMixin, QObject, QtEventListener): 'f_lightning': int(f_lightning), 'total': sum([int(x) for x in list(balances)]) } + + @pyqtSlot(str, result=bool) + def isAddressMine(self, addr): + return self.wallet.is_mine(addr) + + @pyqtSlot(str, str, result=str) + def signMessage(self, address, message): + sig = self.wallet.sign_message(address, message, self.password) + return base64.b64encode(sig).decode('ascii')