1
0

Merge pull request #9765 from f321x/plugin_pubk_user_prompt

plugins: add functionality to allow setting plugin pubkey from gui
This commit is contained in:
ThomasV
2025-05-18 12:08:12 +02:00
committed by GitHub
2 changed files with 321 additions and 38 deletions

View File

@@ -3,7 +3,8 @@ from functools import partial
import shutil
import os
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QWidget, QScrollArea, QFormLayout, QFileDialog, QMenu, QApplication
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QWidget, QScrollArea, \
QFormLayout, QFileDialog, QMenu, QApplication, QMessageBox
from PyQt6.QtCore import QTimer
from electrum.i18n import _
@@ -15,7 +16,7 @@ from .util import (WindowModalDialog, Buttons, CloseButton, WWLabel, insert_spac
if TYPE_CHECKING:
from . import ElectrumGui
from electrum_cc import ECPrivkey
from electrum_ecc import ECPrivkey
from electrum.simple_config import SimpleConfig
from electrum.plugin import Plugins
@@ -174,6 +175,7 @@ class PluginsDialog(WindowModalDialog, MessageBoxMixin):
scroll_w.setLayout(self.grid)
vbox.addWidget(scroll)
add_button = QPushButton(_('Add'))
add_button.setMinimumWidth(40) # looks better on windows, no difference on linux
menu = QMenu()
for name, item in self.plugins.internal_plugin_metadata.items():
fullname = item['fullname']
@@ -194,7 +196,7 @@ class PluginsDialog(WindowModalDialog, MessageBoxMixin):
pubkey, salt = self.plugins.get_pubkey_bytes()
if not pubkey:
self.init_plugins_password()
return
return None
# ask for url and password, same window
pw = self.password_dialog(
msg=' '.join([
@@ -208,18 +210,39 @@ class PluginsDialog(WindowModalDialog, MessageBoxMixin):
])
)
if not pw:
return
return None
privkey = self.plugins.derive_privkey(pw, salt)
if pubkey != privkey.get_public_key_bytes():
keyfile_path, keyfile_help = self.plugins.get_keyfile_path()
self.show_error(
''.join([
_('Incorrect password.'), '\n\n',
_('Your plugin authorization password is required to install plugins.'), ' ',
_('If you need to reset it, remove the following file:'), '\n\n',
keyfile_path
]))
return
keyfile_path, _keyfile_help = self.plugins.get_keyfile_path(None)
while True:
exit_dialog = True
auto_reset_btn = QPushButton(_('Try Auto-Reset'))
def on_try_auto_reset_clicked():
nonlocal exit_dialog
if not self.plugins.try_auto_key_reset():
self.show_error(_("Auto-Reset not possible. Delete the file manually."))
exit_dialog = False
else:
self.show_message(_("Auto-Reset successful. You can now setup a new password."))
auto_reset_btn.clicked.connect(on_try_auto_reset_clicked)
buttons = [
QMessageBox.StandardButton.Ok,
(auto_reset_btn, QMessageBox.ButtonRole.ActionRole, 0),
]
if self.show_error(
''.join([
_('Incorrect password.'), '\n\n',
_('Your plugin authorization password is required to install plugins.'), ' ',
_('If you need to reset it, remove the following file:'), '\n\n',
keyfile_path
]),
buttons=buttons
) or exit_dialog:
break
return None
return privkey
def init_plugins_password(self):
@@ -233,7 +256,7 @@ class PluginsDialog(WindowModalDialog, MessageBoxMixin):
if not pw:
return
key_hex = self.plugins.create_new_key(pw)
keyfile_path, keyfile_help = self.plugins.get_keyfile_path()
keyfile_path, keyfile_help = self.plugins.get_keyfile_path(key_hex)
msg = '\n\n'.join([
_('Your plugins key is:'), key_hex,
_('This key has been copied to your clipboard. Please save it in:'),
@@ -243,10 +266,30 @@ class PluginsDialog(WindowModalDialog, MessageBoxMixin):
])
clipboard = QApplication.clipboard()
clipboard.setText(key_hex)
self.show_message(msg)
while True:
exit_dialog = True
# the button has to be recreated inside the loop, as qt destroys it when the dialog is closed
auto_setup_btn = QPushButton(_('Try Auto-Setup'))
def on_auto_setup_clicked():
nonlocal exit_dialog
if not self.plugins.try_auto_key_setup(key_hex):
self.show_error(_("Auto-Setup not possible. Try the manual setup."))
exit_dialog = False
else:
self.show_message(_("Auto-Setup successful. You can now install plugins."))
auto_setup_btn.clicked.connect(on_auto_setup_clicked)
# on windows, the auto-setup button is shown right of the ok button,
# apparently due to OS conventions
buttons = [
(auto_setup_btn, QMessageBox.ButtonRole.ActionRole, 0),
QMessageBox.StandardButton.Ok,
]
if self.show_message(msg, buttons=buttons) or exit_dialog:
break
def download_plugin_dialog(self):
import os
from .util import line_dialog
from electrum.util import UserCancelled
pubkey, salt = self.plugins.get_pubkey_bytes()
@@ -263,18 +306,10 @@ class PluginsDialog(WindowModalDialog, MessageBoxMixin):
except UserCancelled:
return
except Exception as e:
self._logger.exception("")
self.show_error(f"{e}")
return
try:
success = self.add_external_plugin(path)
except Exception as e:
self.show_error(f"{e}")
success = False
if not success:
try:
os.unlink(path)
except FileNotFoundError:
self._logger.debug("", exc_info=True)
self._try_add_external_plugin_from_path(path)
def add_plugin_dialog(self):
pubkey, salt = self.plugins.get_pubkey_bytes()
@@ -290,9 +325,13 @@ class PluginsDialog(WindowModalDialog, MessageBoxMixin):
self.show_warning(_('Plugin already installed.'))
return
shutil.copyfile(filename, path)
self._try_add_external_plugin_from_path(path)
def _try_add_external_plugin_from_path(self, path: str):
try:
success = self.add_external_plugin(path)
except Exception as e:
self._logger.exception("")
self.show_error(f"{e}")
success = False
if not success:

View File

@@ -72,8 +72,9 @@ class Plugins(DaemonThread):
LOGGING_SHORTCUT = 'p'
pkgpath = os.path.dirname(plugins.__file__)
keyfile_linux = '/etc/electrum/plugins_key'
keyfile_windows = 'C:\\HKEY_LOCAL_MACHINE\\SOFTWARE\\Electrum\\PluginsKey'
# TODO: use XDG Base Directory Specification instead of hardcoding /etc
keyfile_posix = '/etc/electrum/plugins_key'
keyfile_windows = r'HKEY_LOCAL_MACHINE\SOFTWARE\Electrum\PluginsKey'
@profiler
def __init__(self, config: SimpleConfig, gui_name: str = None, cmd_only: bool = False):
@@ -187,18 +188,256 @@ class Plugins(DaemonThread):
def _has_root_permissions(self, path):
return os.stat(path).st_uid == 0 and not os.access(path, os.W_OK)
def get_keyfile_path(self) -> Tuple[str, str]:
def get_keyfile_path(self, key_hex: Optional[str]) -> Tuple[str, str]:
if sys.platform in ['windows', 'win32']:
keyfile_path = self.keyfile_windows
keyfile_help = _('This file can be edited with Regdit')
elif 'ANDROID_DATA' in os.environ:
raise Exception('platform not supported')
else:
# treat unknown platforms as linux-like
keyfile_path = self.keyfile_linux
keyfile_help = _('The file must have root permissions')
# treat unknown platforms and macOS as linux-like
keyfile_path = self.keyfile_posix
keyfile_help = "" if not key_hex else "".join([
_('The file must have root permissions'),
".\n\n",
_("To set it you can also use the Auto-Setup or run "
"the following terminal command"),
":\n\n",
f"sudo sh -c \"{self._posix_plugin_key_creation_command(key_hex)}\"",
])
return keyfile_path, keyfile_help
def try_auto_key_setup(self, pubkey_hex: str) -> bool:
"""Can be called from the GUI to store the plugin pubkey as root/admin user"""
try:
if sys.platform in ['windows', 'win32']:
self._write_key_to_regedit_windows(pubkey_hex)
elif 'ANDROID_DATA' in os.environ:
raise Exception('platform not supported')
elif sys.platform.startswith('darwin'): # macOS
self._write_key_to_root_file_macos(pubkey_hex)
else:
self._write_key_to_root_file_linux(pubkey_hex)
except Exception:
self.logger.exception(f"auto-key setup for {pubkey_hex} failed")
return False
return True
def try_auto_key_reset(self) -> bool:
try:
if sys.platform in ['windows', 'win32']:
self._delete_plugin_key_from_windows_registry()
elif 'ANDROID_DATA' in os.environ:
raise Exception('platform not supported')
elif sys.platform.startswith('darwin'): # macOS
self._delete_macos_plugin_keyfile()
else:
self._delete_linux_plugin_keyfile()
except Exception:
self.logger.exception(f'auto-reset of plugin key failed')
return False
return True
def _posix_plugin_key_creation_command(self, pubkey_hex: str) -> str:
"""creates the dir (dir_path), writes the key in file, and sets permissions to 644"""
dir_path: str = os.path.dirname(self.keyfile_posix)
sh_command = (
f"mkdir -p {dir_path} " # create the /etc/electrum dir
f"&& printf '%s' '{pubkey_hex}' > {self.keyfile_posix} " # write the key to the file
f"&& chmod 644 {self.keyfile_posix} " # set read permissions for the file
f"&& chmod 755 {dir_path}" # set read permissions for the dir
)
return sh_command
@staticmethod
def _get_macos_osascript_command(commands: List[str]) -> List[str]:
"""
Inspired by
https://github.com/barneygale/elevate/blob/01263b690288f022bf6fa702711ac96816bc0e74/elevate/posix.py
Wraps the given commands in a macOS osascript command to prompt for root permissions.
"""
from shlex import quote
def quote_shell(args):
return " ".join(quote(arg) for arg in args)
def quote_applescript(string):
charmap = {
"\n": "\\n",
"\r": "\\r",
"\t": "\\t",
"\"": "\\\"",
"\\": "\\\\",
}
return '"%s"' % "".join(charmap.get(char, char) for char in string)
commands = [
"osascript",
"-e",
"do shell script %s "
"with administrator privileges "
"without altering line endings"
% quote_applescript(quote_shell(commands))
]
return commands
@staticmethod
def _run_win_regedit_as_admin(reg_exe_command: str) -> None:
"""
Runs reg.exe reg_exe_command and requests admin privileges through UAC prompt.
"""
# has to use ShellExecuteEx as ShellExecuteW (the simpler api) doesn't allow to wait
# for the result of the process (returns no process handle)
from ctypes import byref, sizeof, windll, Structure, c_ulong
from ctypes.wintypes import HANDLE, DWORD, HWND, HINSTANCE, HKEY, LPCWSTR
# https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-shellexecuteinfoa
class SHELLEXECUTEINFO(Structure):
_fields_ = [
('cbSize', DWORD),
('fMask', c_ulong),
('hwnd', HWND),
('lpVerb', LPCWSTR),
('lpFile', LPCWSTR),
('lpParameters', LPCWSTR),
('lpDirectory', LPCWSTR),
('nShow', c_ulong),
('hInstApp', HINSTANCE),
('lpIDList', c_ulong),
('lpClass', LPCWSTR),
('hkeyClass', HKEY),
('dwHotKey', DWORD),
('hIcon', HANDLE),
('hProcess', HANDLE)
]
info = SHELLEXECUTEINFO()
info.cbSize = sizeof(SHELLEXECUTEINFO)
info.fMask = 0x00000040 # SEE_MASK_NOCLOSEPROCESS (so we can check the result of the process)
info.hwnd = None
info.lpVerb = 'runas' # run as administrator
info.lpFile = 'reg.exe' # the executable to run
info.lpParameters = reg_exe_command # the registry edit command
info.lpDirectory = None
info.nShow = 1
# Execute and wait
if not windll.shell32.ShellExecuteExW(byref(info)):
error = windll.kernel32.GetLastError()
raise Exception(f'Error executing registry command: {error}')
# block until the process is done or 5 sec timeout
windll.kernel32.WaitForSingleObject(info.hProcess, 0x1338)
# Close handle
windll.kernel32.CloseHandle(info.hProcess)
@staticmethod
def _execute_commands_in_subprocess(commands: List[str]) -> None:
"""
Executes the given commands in a subprocess and asserts that it was successful.
"""
import subprocess
process = subprocess.Popen(
commands,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
stdout, stderr = process.communicate()
if process.returncode != 0:
raise Exception(f'error executing command ({process.returncode}): {stderr}')
def _write_key_to_root_file_linux(self, key_hex: str) -> None:
"""
Spawns a pkexec subprocess to write the key to a file with root permissions.
This will open an OS dialog asking for the root password. Can only succeed if
the system has polkit installed.
"""
assert os.path.exists("/etc"), "System does not have /etc directory"
sh_command: str = self._posix_plugin_key_creation_command(key_hex)
commands = ['pkexec', 'sh', '-c', sh_command]
self._execute_commands_in_subprocess(commands)
# check if the key was written correctly
with open(self.keyfile_posix, 'r') as f:
assert f.read() == key_hex, f'file content mismatch: {f.read()} != {key_hex}'
self.logger.debug(f'file saved successfully to {self.keyfile_posix}')
def _delete_linux_plugin_keyfile(self) -> None:
"""
Deletes the root owned key file at self.keyfile_posix.
"""
if not os.path.exists(self.keyfile_posix):
self.logger.debug(f'file {self.keyfile_posix} does not exist')
return
if not self._has_root_permissions(self.keyfile_posix):
os.unlink(self.keyfile_posix)
return
# use pkexec to delete the file as root user
commands = ['pkexec', 'rm', self.keyfile_posix]
self._execute_commands_in_subprocess(commands)
assert not os.path.exists(self.keyfile_posix), f'file {self.keyfile_posix} still exists'
def _write_key_to_root_file_macos(self, key_hex: str) -> None:
assert os.path.exists("/etc"), "System does not have /etc directory"
sh_command: str = self._posix_plugin_key_creation_command(key_hex)
macos_commands = self._get_macos_osascript_command(["sh", "-c", sh_command])
self._execute_commands_in_subprocess(macos_commands)
with open(self.keyfile_posix, 'r') as f:
assert f.read() == key_hex, f'file content mismatch: {f.read()} != {key_hex}'
self.logger.debug(f'file saved successfully to {self.keyfile_posix}')
def _delete_macos_plugin_keyfile(self) -> None:
if not os.path.exists(self.keyfile_posix):
self.logger.debug(f'file {self.keyfile_posix} does not exist')
return
if not self._has_root_permissions(self.keyfile_posix):
os.unlink(self.keyfile_posix)
return
# use osascript to delete the file as root user
macos_commands = self._get_macos_osascript_command(["rm", self.keyfile_posix])
self._execute_commands_in_subprocess(macos_commands)
assert not os.path.exists(self.keyfile_posix), f'file {self.keyfile_posix} still exists'
def _write_key_to_regedit_windows(self, key_hex: str) -> None:
"""
Writes the key to the Windows registry with windows UAC prompt.
"""
from winreg import ConnectRegistry, OpenKey, QueryValue, HKEY_LOCAL_MACHINE
value_type = 'REG_SZ'
command = f'add "{self.keyfile_windows}" /ve /t {value_type} /d "{key_hex}" /f'
self._run_win_regedit_as_admin(command)
# check if the key was written correctly
with ConnectRegistry(None, HKEY_LOCAL_MACHINE) as hkey:
with OpenKey(hkey, r'SOFTWARE\Electrum') as key:
assert key_hex == QueryValue(key, 'PluginsKey'), "incorrect registry key value"
self.logger.debug(f'key saved successfully to {self.keyfile_windows}')
def _delete_plugin_key_from_windows_registry(self) -> None:
"""
Deletes the PluginsKey dir in the Windows registry.
"""
from winreg import ConnectRegistry, OpenKey, HKEY_LOCAL_MACHINE
command = f'delete "{self.keyfile_windows}" /f'
self._run_win_regedit_as_admin(command)
try:
# do a sanity check to see if the key has been deleted
with ConnectRegistry(None, HKEY_LOCAL_MACHINE) as hkey:
with OpenKey(hkey, r'SOFTWARE\Electrum\PluginsKey'):
raise Exception(f'Key {self.keyfile_windows} still exists, deletion failed')
except FileNotFoundError:
pass
def create_new_key(self, password:str) -> str:
salt = os.urandom(32)
privkey = self.derive_privkey(password, salt)
@@ -224,14 +463,18 @@ class Plugins(DaemonThread):
return None, None
else:
# treat unknown platforms as linux-like
if not os.path.exists(self.keyfile_linux):
if not os.path.exists(self.keyfile_posix):
return None, None
if not self._has_root_permissions(self.keyfile_linux):
if not self._has_root_permissions(self.keyfile_posix):
return
with open(self.keyfile_linux) as f:
with open(self.keyfile_posix) as f:
key_hex = f.read()
key = bytes.fromhex(key_hex)
version = key[0]
try:
key = bytes.fromhex(key_hex)
version = key[0]
except Exception:
self.logger.exception(f'{key_hex=} invalid')
return None, None
if version != PLUGIN_PASSWORD_VERSION:
self.logger.info(f'unknown plugin password version: {version}')
return None, None
@@ -409,7 +652,8 @@ class Plugins(DaemonThread):
self.authorize_plugin(name, path, privkey)
def uninstall(self, name: str):
self.config.set_key(f'plugins.{name}', None)
if self.config.get(f'plugins.{name}'):
self.config.set_key(f'plugins.{name}', None)
if name in self.external_plugin_metadata:
zipfile = self.zip_plugin_path(name)
os.unlink(zipfile)