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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user