wallet: sign_message: consolidate checks from UIs
What changed, and why it matters
This commit moves input-validation checks for signing, verifying, encrypting, and decrypting messages out of the user-interface code and into the shared wallet logic. It also adds type checks so that non-text inputs are rejected earlier. The change is a defensive hardening/refactoring patch: it reduces the chance that different Electrum interfaces (command line, desktop Qt, mobile QML) handle errors inconsistently or skip checks, and it makes the code easier to maintain safely. There is no direct evidence in the commit of an active vulnerability being fixed, but consolidating security checks is a security-relevant improvement.
Treat as a hardening commit. Reviewers should confirm that all call sites of sign_message, verify_message, encrypt_message, and decrypt_message now catch UserFacingException and present it appropriately to users, and that the new str type checks do not break legitimate callers that previously passed bytes or other types. No urgent patch deployment is indicated by the diff alone, but the change should be included in normal release testing.
Security signals we found
Input validation moved from UI controllers into core wallet logic
Type checks added at the CLI command entry points for message-crypto operations
UserFacingException used consistently for address, wallet, script-type, and key-format errors
verify_message changed from silent False on invalid address to explicit exception
QML error handling added for sign/verify failures to prevent unhandled exceptions
assertions added in core methods requiring str inputs
Evidence from the diff
The patch centralizes validation inside Abstract_Wallet.sign_message, verify_message, encrypt_message, and decrypt_message. Previously, the Qt GUI performed several sign_message checks (valid address, wallet not watching-only, address belongs to wallet, supported script type) inline before calling wallet.sign_message. Those checks are now enforced in wallet.py. The QML GUI is updated to emit and display signMessageError/verifyMessageError signals instead of letting exceptions propagate. The CLI commands layer adds str type checks for address, message, signature, pubkey, and encrypted. verify_message now raises UserFacingException for invalid addresses instead of returning False. encrypt_message and decrypt_message tighten their signatures from Any|str to str and add assertions. The commit is a consolidation/hardening change rather than a fix for a specific reported bug.
Changed components
electrum/wallet.pyelectrum/commands.pyelectrum/gui/qt/main_window.pyelectrum/gui/qml/qewallet.pyelectrum/gui/qml/qedaemon.pyelectrum/gui/qml/components/SignVerifyMessageDialog.qmlInspect captured patch +89 / −39
diff --git a/electrum/commands.py b/electrum/commands.py
index ec8556c..660a2c7 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -914,6 +914,10 @@ class Commands(Logger):
arg:str:address:Bitcoin address
arg:str:message:Clear text message. Use quotes if it contains spaces.
"""
+ if not isinstance(address, str):
+ raise UserFacingException(f"address must be a str instead of {type(address)}")
+ if not isinstance(message, str):
+ raise UserFacingException(f"message must be a str instead of {type(message)}")
sig = wallet.sign_message(address, message, password)
return base64.b64encode(sig).decode('ascii')
@@ -925,6 +929,12 @@ class Commands(Logger):
arg:str:message:Clear text message. Use quotes if it contains spaces.
arg:str:signature:The signature, base64-encoded.
"""
+ if not isinstance(address, str):
+ raise UserFacingException(f"address must be a str instead of {type(address)}")
+ if not isinstance(signature, str):
+ raise UserFacingException(f"signature must be a str instead of {type(signature)}")
+ if not isinstance(message, str):
+ raise UserFacingException(f"message must be a str instead of {type(message)}")
return Abstract_Wallet.verify_message(address=address, signature=signature, message=message)
def _get_fee_policy(self, fee: str, feerate: str):
@@ -1226,6 +1236,10 @@ class Commands(Logger):
arg:str:pubkey:Public key
arg:str:message:Clear text message. Use quotes if it contains spaces.
"""
+ if not isinstance(pubkey, str):
+ raise UserFacingException(f"pubkey must be a str instead of {type(pubkey)}")
+ if not isinstance(message, str):
+ raise UserFacingException(f"message must be a str instead of {type(message)}")
return Abstract_Wallet.encrypt_message(pubkey=pubkey, message=message)
@command('wp')
@@ -1235,6 +1249,10 @@ class Commands(Logger):
arg:str:encrypted:Encrypted message
arg:str:pubkey:Public key of one of your wallet addresses
"""
+ if not isinstance(pubkey, str):
+ raise UserFacingException(f"pubkey must be a str instead of {type(pubkey)}")
+ if not isinstance(encrypted, str):
+ raise UserFacingException(f"encrypted must be a str instead of {type(encrypted)}")
decrypted = wallet.decrypt_message(pubkey=pubkey, message=encrypted, password=password)
return decrypted.decode('utf-8')
diff --git a/electrum/gui/qml/components/SignVerifyMessageDialog.qml b/electrum/gui/qml/components/SignVerifyMessageDialog.qml
index a37ce2c..589a31c 100644
--- a/electrum/gui/qml/components/SignVerifyMessageDialog.qml
+++ b/electrum/gui/qml/components/SignVerifyMessageDialog.qml
@@ -215,6 +215,26 @@ ElDialog {
function onMessageSigned(sig) {
signature.text = sig
}
+ function onSignMessageError(error) {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Error'),
+ iconSource: Qt.resolvedUrl('../../icons/warning.png'),
+ text: error
+ })
+ dialog.open()
+ }
+ }
+
+ Connections {
+ target: Daemon
+ function onVerifyMessageError(error) {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Error'),
+ iconSource: Qt.resolvedUrl('../../icons/warning.png'),
+ text: error
+ })
+ dialog.open()
+ }
}
Component.onCompleted: {
diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py
index 2d1983d..749ee31 100644
--- a/electrum/gui/qml/qedaemon.py
+++ b/electrum/gui/qml/qedaemon.py
@@ -7,7 +7,9 @@ from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.i18n import _
from electrum.logging import get_logger
-from electrum.util import WalletFileException, standardize_path, InvalidPassword, send_exception_to_crash_reporter
+from electrum.util import (
+ WalletFileException, standardize_path, InvalidPassword, send_exception_to_crash_reporter, UserFacingException,
+)
from electrum.plugin import run_hook
from electrum.lnchannel import ChannelState
from electrum.storage import StorageReadWriteError, WalletStorage
@@ -156,6 +158,7 @@ class QEDaemon(AuthMixin, QObject):
walletOpenError = pyqtSignal([str], arguments=["error"])
walletDeleteError = pyqtSignal([str, str], arguments=['code', 'message'])
walletRenameError = pyqtSignal([str], arguments=['message'])
+ verifyMessageError = pyqtSignal([str], arguments=['error'])
def __init__(self, daemon: 'Daemon', plugins: 'Plugins', parent=None):
super().__init__(parent)
@@ -506,7 +509,11 @@ class QEDaemon(AuthMixin, QObject):
address = address.strip()
message = message.strip()
signature = signature.strip()
- return Abstract_Wallet.verify_message(address=address, signature=signature, message=message)
+ try:
+ return Abstract_Wallet.verify_message(address=address, signature=signature, message=message)
+ except UserFacingException as e:
+ self.verifyMessageError.emit(str(e))
+ return False
@pyqtSlot(str, result=int)
def passwordStrength(self, password):
diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py
index 3302f6f..f525636 100644
--- a/electrum/gui/qml/qewallet.py
+++ b/electrum/gui/qml/qewallet.py
@@ -14,7 +14,8 @@ from electrum.logging import get_logger
from electrum.network import TxBroadcastError, BestEffortRequestFailed
from electrum.transaction import PartialTransaction, Transaction
from electrum.util import (
- InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates
+ InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates,
+ UserFacingException,
)
from electrum.lnutil import MIN_FUNDING_SAT
from electrum.plugin import run_hook
@@ -81,6 +82,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener):
peersUpdated = pyqtSignal()
seedRetrieved = pyqtSignal()
messageSigned = pyqtSignal([str], arguments=['signature'])
+ signMessageError = pyqtSignal([str], arguments=['error'])
_network_signal = pyqtSignal(str, object)
@@ -849,7 +851,11 @@ class QEWallet(AuthMixin, QObject, QtEventListener):
# strip, as in qt gui and in qml verifyMessage (see #4327)
address = address.strip()
message = message.strip()
- sig = self.wallet.sign_message(address, message, self.password)
+ try:
+ sig = self.wallet.sign_message(address, message, self.password)
+ except UserFacingException as e:
+ self.signMessageError.emit(str(e))
+ return
result = base64.b64encode(sig).decode('ascii')
self.messageSigned.emit(result)
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
index 32dfa38..306da47 100644
--- a/electrum/gui/qt/main_window.py
+++ b/electrum/gui/qt/main_window.py
@@ -2123,12 +2123,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
d.setLayout(vbox)
d.exec()
- msg_sign = _("Signing with an address actually means signing with the corresponding "
- "private key, and verifying with the corresponding public key. The "
- "address you have entered does not have a unique public key, so these "
- "operations cannot be performed.") + '\n\n' + \
- _('The operation is undefined. Not just in Electrum, but in general.')
-
@protected
def do_sign(
self,
@@ -2140,20 +2134,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
) -> None:
address = address_e.text().strip()
message = message_e.toPlainText().strip()
- if not bitcoin.is_address(address):
- self.show_message(_('Invalid Bitcoin address.'))
- return
- if self.wallet.is_watching_only():
- self.show_message(_('This is a watching-only wallet.'))
- return
- if not self.wallet.is_mine(address):
- self.show_message(_('Address not in wallet.'))
- return
- txin_type = self.wallet.get_txin_type(address)
- if txin_type not in ['p2pkh', 'p2wpkh', 'p2wpkh-p2sh']:
- self.show_message(_('Cannot sign messages with this type of address:') + \
- ' ' + txin_type + '\n\n' + self.msg_sign)
- return
task = partial(self.wallet.sign_message, address, message, password)
def show_signed_message(sig):
@@ -2174,9 +2154,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
) -> None:
address = address_e.text().strip()
message = message_e.toPlainText().strip()
- if not bitcoin.is_address(address):
- self.show_message(_('Invalid Bitcoin address.'))
- return
verified = self.wallet.verify_message(
address=address, signature=str(signature_e.toPlainText()), message=message)
if verified:
diff --git a/electrum/wallet.py b/electrum/wallet.py
index f37a23b..eb3c628 100644
--- a/electrum/wallet.py
+++ b/electrum/wallet.py
@@ -3235,15 +3235,38 @@ class Abstract_Wallet(ABC, Logger, EventListener):
pass
def sign_message(self, address: str, message: str, password) -> bytes:
+ """Caller must handle UserFacingException."""
+ assert isinstance(address, str), f"address must be str. got {type(address)}"
+ assert isinstance(message, str), f"message must be str. got {type(message)}"
+ if not bitcoin.is_address(address):
+ raise UserFacingException(_("Invalid Bitcoin address."))
+ if self.is_watching_only():
+ raise UserFacingException(_("This is a watching-only wallet."))
+ if not self.is_mine(address):
+ raise UserFacingException(_("Address not in wallet."))
+ txin_type = self.get_txin_type(address)
+ assert txin_type != "address" # logic error, as this implies watching-only
+ if txin_type not in ['p2pkh', 'p2wpkh', 'p2wpkh-p2sh']:
+ raise UserFacingException(
+ _("Cannot sign messages with this type of address:") +
+ " " + txin_type + "\n\n"
+ + _("Signing with an address actually means signing with the corresponding "
+ "private key, and verifying with the corresponding public key. The "
+ "address you have entered does not have a unique public key, so these "
+ "operations cannot be performed.") + "\n\n"
+ + _("The operation is undefined. Not just in Electrum, but in general.")
+ )
index = self.get_address_index(address)
- script_type = self.get_txin_type(address)
- assert script_type != "address"
- return self.keystore.sign_message(index, message, password, script_type=script_type)
+ return self.keystore.sign_message(index, message, password, script_type=txin_type)
@classmethod
def verify_message(cls, *, address: str, signature: str, message: str) -> bool:
+ """Caller must handle UserFacingException."""
+ assert isinstance(address, str), f"address must be str. got {type(address)}"
+ assert isinstance(signature, str), f"signature must be str. got {type(signature)}"
+ assert isinstance(message, str), f"message must be str. got {type(message)}"
if not is_address(address):
- return False
+ raise UserFacingException(_("Invalid Bitcoin address."))
try:
sig = base64.b64decode(signature, validate=True)
except ValueError:
@@ -3253,14 +3276,14 @@ class Abstract_Wallet(ABC, Logger, EventListener):
message = util.to_bytes(message)
return bitcoin.verify_usermessage_with_address(address, sig, message)
- def decrypt_message(self, *, pubkey: Any | str, message: str, password) -> bytes:
+ def decrypt_message(self, *, pubkey: str, message: str, password) -> bytes:
"""Caller must handle UserFacingException."""
+ assert isinstance(pubkey, str), f"pubkey must be str. got {type(pubkey)}"
+ assert isinstance(message, str), f"message must be str. got {type(message)}"
if self.is_watching_only():
raise UserFacingException(_("This is a watching-only wallet."))
if isinstance(self, Multisig_Wallet): # FIXME does not work with multisig wallets. (see #5856)
raise UserFacingException(_("Decrypting messages is currently not implemented for multisig wallets."))
- if not isinstance(message, str):
- raise UserFacingException(f"message must be a str instead of {type(message)}")
if not is_hex_str(pubkey):
raise UserFacingException(f"pubkey must be a hex string instead of {type(pubkey)}")
if isinstance(self, Imported_Wallet):
@@ -3272,12 +3295,11 @@ class Abstract_Wallet(ABC, Logger, EventListener):
return self.keystore.decrypt_message(addr_index, message, password)
@classmethod
- def encrypt_message(cls, *, pubkey: Any | str, message: Any | str) -> str:
+ def encrypt_message(cls, *, pubkey: str, message: str) -> str:
"""Caller must handle UserFacingException."""
- try:
- message = util.to_bytes(message)
- except TypeError:
- raise UserFacingException(f"message must be a str instead of {type(message)}") from None
+ assert isinstance(pubkey, str), f"pubkey must be str. got {type(pubkey)}"
+ assert isinstance(message, str), f"message must be str. got {type(message)}"
+ message = util.to_bytes(message)
if not is_hex_str(pubkey):
raise UserFacingException(f"pubkey must be a hex string instead of {type(pubkey)}")
pubkey_bytes = bytes.fromhex(pubkey)
Why this scored 39/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.