Userspace plugins:

- Allow plugins saved as zipfiles in user data dir
 - plugins are authorized with a user chosen password
 - pubkey derived from password is saved with admin permissions
This commit is contained in:
ThomasV
2025-04-04 14:23:52 +02:00
parent bd5de52768
commit 737417fb80
3 changed files with 403 additions and 135 deletions
+26
View File
@@ -236,6 +236,32 @@ class ChangePasswordDialogBase(WindowModalDialog):
raise NotImplementedError() raise NotImplementedError()
class NewPasswordDialog(WindowModalDialog):
def __init__(self, parent, msg):
self.msg = msg
WindowModalDialog.__init__(self, parent)
OK_button = OkButton(self)
self.playout = PasswordLayout(
msg=self.msg,
kind=PW_CHANGE,
OK_button=OK_button,
wallet=None)
self.setWindowTitle(self.playout.title())
vbox = QVBoxLayout(self)
vbox.addLayout(self.playout.layout())
vbox.addStretch(1)
vbox.addLayout(Buttons(CancelButton(self), OK_button))
def run(self):
try:
if not self.exec():
return None
return self.playout.new_password()
finally:
self.playout.clear_password_fields()
class ChangePasswordDialogForSW(ChangePasswordDialogBase): class ChangePasswordDialogForSW(ChangePasswordDialogBase):
def create_password_layout(self, wallet, is_encrypted, OK_button): def create_password_layout(self, wallet, is_encrypted, OK_button):
+198 -53
View File
@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from functools import partial from functools import partial
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QWidget, QScrollArea, QCheckBox, QFormLayout from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QWidget, QScrollArea, QFormLayout
from electrum.i18n import _ from electrum.i18n import _
from electrum.plugin import run_hook from electrum.plugin import run_hook
@@ -11,12 +11,13 @@ from .util import WindowModalDialog, Buttons, CloseButton, WWLabel, insert_space
if TYPE_CHECKING: if TYPE_CHECKING:
from .main_window import ElectrumWindow from .main_window import ElectrumWindow
from electrum_cc import ECPrivkey
class PluginDialog(WindowModalDialog): class PluginDialog(WindowModalDialog):
def __init__(self, name, metadata, cb: 'QCheckBox', window: 'ElectrumWindow', index:int): def __init__(self, name, metadata, status_button: Optional['PluginStatusButton'], window: 'ElectrumWindow'):
display_name = metadata.get('display_name', '') display_name = metadata.get('fullname', '')
author = metadata.get('author', '') author = metadata.get('author', '')
description = metadata.get('description', '') description = metadata.get('description', '')
requires = metadata.get('requires') requires = metadata.get('requires')
@@ -24,14 +25,13 @@ class PluginDialog(WindowModalDialog):
zip_hash = metadata.get('zip_hash_sha256', None) zip_hash = metadata.get('zip_hash_sha256', None)
WindowModalDialog.__init__(self, window, 'Plugin') WindowModalDialog.__init__(self, window, 'Plugin')
self.setMinimumSize(400,250) self.setMinimumSize(400, 250)
self.index = index
self.window = window self.window = window
self.metadata = metadata self.metadata = metadata
self.plugins = self.window.plugins self.plugins = self.window.plugins
self.name = name self.name = name
self.cb = cb self.status_button = status_button
p = self.plugins.get(name) # is installed p = self.plugins.get(name) # is enabled
vbox = QVBoxLayout(self) vbox = QVBoxLayout(self)
form = QFormLayout(None) form = QFormLayout(None)
form.addRow(QLabel(_('Name') + ':'), QLabel(display_name)) form.addRow(QLabel(_('Name') + ':'), QLabel(display_name))
@@ -44,24 +44,75 @@ class PluginDialog(WindowModalDialog):
msg = '\n'.join(map(lambda x: x[1], requires)) msg = '\n'.join(map(lambda x: x[1], requires))
form.addRow(QLabel(_('Requires') + ':'), WWLabel(msg)) form.addRow(QLabel(_('Requires') + ':'), WWLabel(msg))
vbox.addLayout(form) vbox.addLayout(form)
text = _('Disable') if p else _('Enable') toggle_button = QPushButton('')
toggle_button = QPushButton(text) if not self.plugins.is_installed(name):
toggle_button.clicked.connect(partial(self.do_toggle, toggle_button, name)) toggle_button.setText(_('Install...'))
toggle_button.clicked.connect(self.accept)
else:
text = (_('Disable') if p else _('Enable')) if self.plugins.is_authorized(name) else _('Authorize...')
toggle_button.setText(text)
toggle_button.clicked.connect(partial(self.do_toggle, toggle_button, name))
close_button = CloseButton(self) close_button = CloseButton(self)
close_button.setText(_('Cancel')) close_button.setText(_('Close'))
buttons = [toggle_button, close_button] buttons = [toggle_button, close_button]
# add settings widget
if p and p.requires_settings() and p.is_enabled():
widget = p.settings_widget(self)
buttons.insert(0, widget)
vbox.addLayout(Buttons(*buttons)) vbox.addLayout(Buttons(*buttons))
def do_toggle(self, button, name): def do_toggle(self, toggle_button, name):
button.setEnabled(False) toggle_button.setEnabled(False)
p = self.plugins.toggle(name) if not self.plugins.is_authorized(name):
self.cb.setChecked(bool(p)) privkey = self.window.get_plugins_privkey()
if not privkey:
return
filename = self.plugins.zip_plugin_path(name)
self.window.plugins.authorize_plugin(name, filename, privkey)
self.status_button.update()
self.close()
return
p = self.plugins.get(name)
if not p:
self.plugins.enable(name)
else:
self.plugins.disable(name)
self.status_button.update()
self.close() self.close()
self.window.enable_settings_widget(name, self.index)
# note: all enabled plugins will receive this hook: # note: all enabled plugins will receive this hook:
run_hook('init_qt', self.window.window.gui_object) run_hook('init_qt', self.window.window.gui_object)
class PluginStatusButton(QPushButton):
def __init__(self, window, name):
QPushButton.__init__(self, '')
self.window = window
self.plugins = window.plugins
self.name = name
self.clicked.connect(self.show_plugin_dialog)
self.update()
def show_plugin_dialog(self):
metadata = self.plugins.descriptions[self.name]
d = PluginDialog(self.name, metadata, self, self.window)
d.exec()
def update(self):
from .util import ColorScheme
p = self.plugins.get(self.name)
plugin_is_loaded = p is not None
enabled = (
not plugin_is_loaded and self.plugins.is_available(self.name, self.window.wallet)
or plugin_is_loaded and p.can_user_disable()
)
self.setEnabled(enabled)
text, color = (_('Unauthorized'), ColorScheme.RED) if not self.window.plugins.is_authorized(self.name)\
else ((_('Enabled'), ColorScheme.BLUE) if p is not None and p.is_enabled() else (_('Disabled'), ColorScheme.DEFAULT))
self.setStyleSheet(color.as_stylesheet())
self.setText(text)
class PluginsDialog(WindowModalDialog): class PluginsDialog(WindowModalDialog):
def __init__(self, window: 'ElectrumWindow'): def __init__(self, window: 'ElectrumWindow'):
@@ -70,63 +121,157 @@ class PluginsDialog(WindowModalDialog):
self.wallet = self.window.wallet self.wallet = self.window.wallet
self.config = window.config self.config = window.config
self.plugins = self.window.gui_object.plugins self.plugins = self.window.gui_object.plugins
self.settings_widgets = {}
vbox = QVBoxLayout(self) vbox = QVBoxLayout(self)
scroll = QScrollArea() scroll = QScrollArea()
scroll.setEnabled(True) scroll.setEnabled(True)
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
scroll.setMinimumSize(400,250) scroll.setMinimumSize(400, 250)
scroll_w = QWidget() scroll_w = QWidget()
scroll.setWidget(scroll_w) scroll.setWidget(scroll_w)
self.grid = QGridLayout() self.grid = QGridLayout()
self.grid.setColumnStretch(0,1) self.grid.setColumnStretch(0, 1)
scroll_w.setLayout(self.grid) scroll_w.setLayout(self.grid)
vbox.addWidget(scroll) vbox.addWidget(scroll)
vbox.addLayout(Buttons(CloseButton(self))) add_button = QPushButton(_('Add'))
add_button.clicked.connect(self.add_plugin_dialog)
#add_button.clicked.connect(self.download_plugin_dialog)
vbox.addLayout(Buttons(add_button, CloseButton(self)))
self.show_list() self.show_list()
def enable_settings_widget(self, name: str, i: int): def get_plugins_privkey(self) -> Optional['ECPrivkey']:
p = self.plugins.get(name) pubkey, salt = self.plugins.get_pubkey_bytes()
widget = self.settings_widgets.get(name) # type: Optional[QWidget] if not pubkey:
if widget and not p: self.init_plugins_password()
# plugin got disabled, rm widget return
self.grid.removeWidget(widget) # ask for url and password, same window
widget.setParent(None) pw = self.window.password_dialog(
self.settings_widgets.pop(name) msg=' '.join([
elif widget is None and p and p.requires_settings() and p.is_enabled(): _('<b>Warning</b>: Third-party plugins are not endorsed by Electrum!'),
# plugin got enabled, add widget '<br/><br/>',
widget = self.settings_widgets[name] = p.settings_widget(self) _('If you install a third-party plugin, you trust the software not to be malicious.'),
self.grid.addWidget(widget, i, 1) _('Electrum will not be responsible in case of theft, loss of funds or privacy that might result from third-party plugins.'),
_('You should at minimum check who the author of the plugin is, and be careful of imposters.'),
'<br/><br/>',
_('Please enter your plugin authorization password') + ':'
])
)
if not pw:
return
privkey = self.plugins.derive_privkey(pw, salt)
if pubkey != privkey.get_public_key_bytes():
keyfile_path, keyfile_help = self.plugins.get_keyfile_path()
self.window.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
return privkey
def init_plugins_password(self):
from .password_dialog import NewPasswordDialog
msg = ' '.join([
_('In order to install third-party plugins, you need to choose a plugin authorization password.'),
_('Its purpose is to prevent unauthorized users (or malware) from installing plugins.'),
])
d = NewPasswordDialog(self, msg=msg)
pw = d.run()
if not pw:
return
key_hex = self.plugins.create_new_key(pw)
keyfile_path, keyfile_help = self.plugins.get_keyfile_path()
msg = ''.join([
_('Your plugins key is:'), '\n\n', key_hex, '\n\n',
_('Please save this key in'), '\n\n' + keyfile_path, '\n\n', keyfile_help
])
self.window.do_copy(key_hex, title=_('Plugins key'))
self.window.show_message(msg)
def download_plugin_dialog(self):
import os
from .util import line_dialog
from electrum.util import UserCancelled
pubkey, salt = self.plugins.get_pubkey_bytes()
if not pubkey:
self.init_plugins_password()
return
url = line_dialog(self, 'url', _('Enter plugin URL'), _('Download'))
if not url:
return
coro = self.plugins.download_external_plugin(url)
try:
path = self.window.run_coroutine_dialog(coro, "Downloading plugin...")
except UserCancelled:
return
except Exception as e:
self.window.show_error(f"{e}")
return
try:
success = self.confirm_add_plugin(path)
except Exception as e:
self.window.show_error(f"{e}")
success = False
if not success:
os.unlink(path)
def add_plugin_dialog(self):
from PyQt6.QtWidgets import QFileDialog
import shutil, os
pubkey, salt = self.plugins.get_pubkey_bytes()
if not pubkey:
self.init_plugins_password()
return
filename, __ = QFileDialog.getOpenFileName(self, "Select your plugin zipfile", "", "*.zip")
if not filename:
return
plugins_dir = self.plugins.get_external_plugin_dir()
path = os.path.join(plugins_dir, os.path.basename(filename))
shutil.copyfile(filename, path)
try:
success = self.confirm_add_plugin(path)
except Exception as e:
self.window.show_error(f"{e}")
success = False
if not success:
os.unlink(path)
def confirm_add_plugin(self, path):
manifest = self.plugins.read_manifest(path)
name = manifest['name']
d = PluginDialog(name, manifest, None, self)
if not d.exec():
return False
# ask password once user has approved
privkey = self.get_plugins_privkey()
if not privkey:
return False
self.plugins.external_plugin_metadata[name] = manifest
self.plugins.authorize_plugin(name, path, privkey)
self.window.show_message(_('Plugin installed successfully.'))
self.show_list()
return True
def show_list(self): def show_list(self):
descriptions = self.plugins.descriptions descriptions = self.plugins.descriptions
descriptions = sorted(descriptions.items()) descriptions = sorted(descriptions.items())
grid = self.grid grid = self.grid
# clear existing items
for i in reversed(range(grid.count())):
grid.itemAt(i).widget().setParent(None)
# populate
i = 0 i = 0
for name, metadata in descriptions: for name, metadata in descriptions:
i += 1 i += 1
p = self.plugins.get(name)
if metadata.get('registers_keystore'): if metadata.get('registers_keystore'):
continue continue
display_name = metadata.get('display_name') display_name = metadata.get('fullname')
if not display_name: if not display_name:
continue continue
#try: label = QLabel(display_name)
cb = QCheckBox(display_name) grid.addWidget(label, i, 0)
plugin_is_loaded = p is not None status_button = PluginStatusButton(self, name)
cb_enabled = (not plugin_is_loaded and self.plugins.is_available(name, self.wallet) grid.addWidget(status_button, i, 1)
or plugin_is_loaded and p.can_user_disable()) # add stretch
cb.setEnabled(cb_enabled) grid.setRowStretch(i + 1, 1)
cb.setChecked(plugin_is_loaded and p.is_enabled())
grid.addWidget(cb, i, 0)
self.enable_settings_widget(name, i)
cb.clicked.connect(partial(self.show_plugin_dialog, name, cb, i))
#grid.setRowStretch(len(descriptions), 1)
def show_plugin_dialog(self, name, cb, i):
p = self.plugins.get(name)
metadata = self.plugins.descriptions[name]
cb.setChecked(p is not None and p.is_enabled())
d = PluginDialog(name, metadata, cb, self, i)
d.exec()
+179 -82
View File
@@ -29,24 +29,27 @@ import pkgutil
import importlib.util import importlib.util
import time import time
import threading import threading
import traceback
import sys import sys
import aiohttp import aiohttp
import zipfile as zipfile_lib import zipfile as zipfile_lib
from urllib.parse import urlparse
from typing import (NamedTuple, Any, Union, TYPE_CHECKING, Optional, Tuple, from typing import (NamedTuple, Any, Union, TYPE_CHECKING, Optional, Tuple,
Dict, Iterable, List, Sequence, Callable, TypeVar, Mapping) Dict, Iterable, List, Sequence, Callable, TypeVar, Mapping)
import concurrent import concurrent
import zipimport import zipimport
from concurrent import futures
from functools import wraps, partial from functools import wraps, partial
from itertools import chain from itertools import chain
from electrum_ecc import ECPrivkey, ECPubkey
from ._vendor.distutils.version import StrictVersion
from .version import ELECTRUM_VERSION
from .i18n import _ from .i18n import _
from .util import (profiler, DaemonThread, UserCancelled, ThreadJob, UserFacingException) from .util import (profiler, DaemonThread, UserCancelled, ThreadJob, UserFacingException)
from . import bip32 from . import bip32
from . import plugins from . import plugins
from .simple_config import ConfigVar, SimpleConfig from .simple_config import SimpleConfig
from .logging import get_logger, Logger from .logging import get_logger, Logger
from .crypto import sha256 from .crypto import sha256
@@ -60,17 +63,20 @@ _logger = get_logger(__name__)
plugin_loaders = {} plugin_loaders = {}
hook_names = set() hook_names = set()
hooks = {} hooks = {}
_root_permission_cache = {}
_exec_module_failure = {} # type: Dict[str, Exception] _exec_module_failure = {} # type: Dict[str, Exception]
PLUGIN_PASSWORD_VERSION = 1
class Plugins(DaemonThread): class Plugins(DaemonThread):
LOGGING_SHORTCUT = 'p' LOGGING_SHORTCUT = 'p'
pkgpath = os.path.dirname(plugins.__file__) pkgpath = os.path.dirname(plugins.__file__)
keyfile_linux = '/etc/electrum/plugins_key'
keyfile_windows = 'C:\\HKEY_LOCAL_MACHINE\\SOFTWARE\\Electrum\\PluginsKey'
@profiler @profiler
def __init__(self, config: SimpleConfig, gui_name = None, cmd_only: bool = False): def __init__(self, config: SimpleConfig, gui_name: str = None, cmd_only: bool = False):
self.config = config self.config = config
self.cmd_only = cmd_only # type: bool self.cmd_only = cmd_only # type: bool
self.internal_plugin_metadata = {} self.internal_plugin_metadata = {}
@@ -106,9 +112,6 @@ class Plugins(DaemonThread):
if loader.__class__.__qualname__ == "PyiFrozenImporter": if loader.__class__.__qualname__ == "PyiFrozenImporter":
continue continue
module_path = os.path.join(pkg_path, name) module_path = os.path.join(pkg_path, name)
if external and not self._has_recursive_root_permissions(module_path):
self.logger.info(f"Not loading plugin {module_path}: directory has user write permissions")
continue
if self.cmd_only and not self.config.get('enable_plugin_' + name) is True: if self.cmd_only and not self.config.get('enable_plugin_' + name) is True:
continue continue
try: try:
@@ -119,7 +122,6 @@ class Plugins(DaemonThread):
continue continue
if 'fullname' not in d: if 'fullname' not in d:
continue continue
d['display_name'] = d['fullname']
d['path'] = module_path d['path'] = module_path
if not self.cmd_only: if not self.cmd_only:
gui_good = self.gui_name in d.get('available_for', []) gui_good = self.gui_name in d.get('available_for', [])
@@ -164,10 +166,11 @@ class Plugins(DaemonThread):
internal_plugins_path = (self.pkgpath, False) internal_plugins_path = (self.pkgpath, False)
external_plugins_path = (self.get_external_plugin_dir(), True) external_plugins_path = (self.get_external_plugin_dir(), True)
for pkg_path, external in (internal_plugins_path, external_plugins_path): for pkg_path, external in (internal_plugins_path, external_plugins_path):
# external plugins enforce root permissions on the directory
if pkg_path and os.path.exists(pkg_path): if pkg_path and os.path.exists(pkg_path):
self.find_directory_plugins(pkg_path=pkg_path, external=external) if not external:
self.find_zip_plugins(pkg_path=pkg_path, external=external) self.find_directory_plugins(pkg_path=pkg_path, external=external)
else:
self.find_zip_plugins(pkg_path=pkg_path, external=external)
def load_plugins(self): def load_plugins(self):
for name, d in chain(self.internal_plugin_metadata.items(), self.external_plugin_metadata.items()): for name, d in chain(self.internal_plugin_metadata.items(), self.external_plugin_metadata.items()):
@@ -183,35 +186,96 @@ class Plugins(DaemonThread):
def _has_root_permissions(self, path): def _has_root_permissions(self, path):
return os.stat(path).st_uid == 0 and not os.access(path, os.W_OK) return os.stat(path).st_uid == 0 and not os.access(path, os.W_OK)
@profiler(min_threshold=0.5) def get_keyfile_path(self) -> Tuple[str, str]:
def _has_recursive_root_permissions(self, path): if sys.platform in ['windows', 'win32']:
"""Check if a directory and all its subdirectories have root permissions""" keyfile_path = self.keyfile_windows
if _root_permission_cache.get(path) is not None: keyfile_help = _('This file can be edited with Regdit')
return _root_permission_cache[path] elif 'ANDROID_DATA' in os.environ:
_root_permission_cache[path] = False raise Exception('platform not supported')
for root, dirs, files in os.walk(path): else:
if not self._has_root_permissions(root): # treat unknown platforms as linux-like
return False keyfile_path = self.keyfile_linux
for f in files: keyfile_help = _('The file must have root permissions')
if not self._has_root_permissions(os.path.join(root, f)): return keyfile_path, keyfile_help
return False
_root_permission_cache[path] = True
return True
def get_external_plugin_dir(self): def create_new_key(self, password:str) -> str:
if sys.platform not in ['linux', 'darwin'] and not sys.platform.startswith('freebsd'): salt = os.urandom(32)
return privkey = self.derive_privkey(password, salt)
pkg_path = '/opt/electrum_plugins' pubkey = privkey.get_public_key_bytes()
key = chr(PLUGIN_PASSWORD_VERSION) + salt + pubkey
return key.hex()
def get_pubkey_bytes(self) -> Tuple[Optional[bytes], bytes]:
"""
returns pubkey, salt
returns None, None if the pubkey has not been set
"""
if sys.platform in ['windows', 'win32']:
import winreg
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as hkey:
try:
with winreg.OpenKey(hkey, r"SOFTWARE\\Electrum") as key:
key_hex = winreg.QueryValue(key, "PluginsKey")
except Exception as e:
self.logger.info(f'winreg error: {e}')
return None, None
elif 'ANDROID_DATA' in os.environ:
return None, None
else:
# treat unknown platforms as linux-like
if not os.path.exists(self.keyfile_linux):
return None, None
if not self._has_root_permissions(self.keyfile_linux):
return
with open(self.keyfile_linux) as f:
key_hex = f.read()
key = bytes.fromhex(key_hex)
version = key[0]
if version != PLUGIN_PASSWORD_VERSION:
self.logger.info(f'unknown plugin password version: {version}')
return None, None
# all good
salt = key[1:1+32]
pubkey = key[1+32:]
return pubkey, salt
def get_external_plugin_dir(self) -> str:
pkg_path = os.path.join(self.config.electrum_path(), 'plugins')
if not os.path.exists(pkg_path): if not os.path.exists(pkg_path):
self.logger.info(f'directory {pkg_path} does not exist') os.mkdir(pkg_path)
return
if not self._has_root_permissions(pkg_path):
self.logger.info(f'not loading {pkg_path}: directory has user write permissions')
return
return pkg_path return pkg_path
def zip_plugin_path(self, name): async def download_external_plugin(self, url):
filename = self.get_metadata(name)['filename'] filename = os.path.basename(urlparse(url).path)
pkg_path = self.get_external_plugin_dir()
path = os.path.join(pkg_path, filename)
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
with open(path, 'wb') as fd:
async for chunk in resp.content.iter_chunked(10):
fd.write(chunk)
return path
def read_manifest(self, path) -> dict:
""" return json dict """
with zipfile_lib.ZipFile(path) as file:
for filename in file.namelist():
if filename.endswith('manifest.json'):
break
else:
raise Exception('could not find manifest.json in zip archive')
with file.open(filename, 'r') as f:
manifest = json.load(f)
manifest['path'] = path # external, path of the zipfile
manifest['dirname'] = os.path.dirname(filename) # internal
manifest['is_zip'] = True
manifest['zip_hash_sha256'] = get_file_hash256(path).hex()
return manifest
def zip_plugin_path(self, name) -> str:
path = self.get_metadata(name)['path']
filename = os.path.basename(path)
if name in self.internal_plugin_metadata: if name in self.internal_plugin_metadata:
pkg_path = self.pkgpath pkg_path = self.pkgpath
else: else:
@@ -226,46 +290,37 @@ class Plugins(DaemonThread):
path = os.path.join(pkg_path, filename) path = os.path.join(pkg_path, filename)
if not filename.endswith('.zip'): if not filename.endswith('.zip'):
continue continue
if external and not self._has_root_permissions(path):
self.logger.info(f'not loading {path}: file has user write permissions')
continue
try: try:
zipfile = zipimport.zipimporter(path) d = self.read_manifest(path)
except zipimport.ZipImportError: name = d['name']
self.logger.exception(f"unable to load zip plugin '{filename}'") except Exception:
self.logger.info(f"could not load manifest.json from zip plugin {filename}", exc_info=True)
continue continue
for name, b in pkgutil.iter_zipimport_modules(zipfile): if name in self.internal_plugin_metadata:
if b is False: raise Exception(f"duplicate plugins for name={name}")
if name in self.external_plugin_metadata:
raise Exception(f"duplicate plugins for name={name}")
if self.cmd_only and not self.config.get('enable_plugin_' + name):
continue
min_version = d.get('min_electrum_version')
if min_version and StrictVersion(min_version) > StrictVersion(ELECTRUM_VERSION):
self.logger.info(f"version mismatch for zip plugin {filename}", exc_info=True)
continue
max_version = d.get('max_electrum_version')
if max_version and StrictVersion(max_version) < StrictVersion(ELECTRUM_VERSION):
self.logger.info(f"version mismatch for zip plugin {filename}", exc_info=True)
continue
if not self.cmd_only:
gui_good = self.gui_name in d.get('available_for', [])
if not gui_good:
continue continue
if name in self.internal_plugin_metadata: if 'fullname' not in d:
raise Exception(f"duplicate plugins for name={name}")
if name in self.external_plugin_metadata:
raise Exception(f"duplicate plugins for name={name}")
if self.cmd_only and not self.config.get('enable_plugin_' + name):
continue continue
try: if external:
with zipfile_lib.ZipFile(path) as file: self.external_plugin_metadata[name] = d
manifest_path = os.path.join(name, 'manifest.json') else:
with file.open(manifest_path, 'r') as f: self.internal_plugin_metadata[name] = d
d = json.load(f)
except Exception:
self.logger.info(f"could not load manifest.json from zip plugin {filename}", exc_info=True)
continue
d['filename'] = filename
d['is_zip'] = True
d['path'] = path
if not self.cmd_only:
gui_good = self.gui_name in d.get('available_for', [])
if not gui_good:
continue
if 'fullname' not in d:
continue
d['display_name'] = d['fullname']
d['zip_hash_sha256'] = get_file_hash256(path)
if external:
self.external_plugin_metadata[name] = d
else:
self.internal_plugin_metadata[name] = d
def get(self, name): def get(self, name):
return self.plugins.get(name) return self.plugins.get(name)
@@ -285,20 +340,23 @@ class Plugins(DaemonThread):
def maybe_load_plugin_init_method(self, name: str) -> None: def maybe_load_plugin_init_method(self, name: str) -> None:
"""Loads the __init__.py module of the plugin if it is not already loaded.""" """Loads the __init__.py module of the plugin if it is not already loaded."""
is_external = name in self.external_plugin_metadata is_external = name in self.external_plugin_metadata
base_name = (f'electrum_external_plugins.' if is_external else 'electrum.plugins.') + name base_name = ('electrum_external_plugins.' if is_external else 'electrum.plugins.') + name
if base_name not in sys.modules: if base_name not in sys.modules:
metadata = self.get_metadata(name) metadata = self.get_metadata(name)
is_zip = metadata.get('is_zip', False) is_zip = metadata.get('is_zip', False)
# if the plugin was not enabled on startup the init module hasn't been loaded yet # if the plugin was not enabled on startup the init module hasn't been loaded yet
if not is_zip: if not is_zip:
if is_external: if is_external:
# this branch is deprecated: external plugins are always zip files
path = os.path.join(metadata['path'], '__init__.py') path = os.path.join(metadata['path'], '__init__.py')
init_spec = importlib.util.spec_from_file_location(base_name, path) init_spec = importlib.util.spec_from_file_location(base_name, path)
else: else:
init_spec = importlib.util.find_spec(base_name) init_spec = importlib.util.find_spec(base_name)
else: else:
zipfile = zipimport.zipimporter(metadata['path']) zipfile = zipimport.zipimporter(metadata['path'])
init_spec = zipfile.find_spec(name) dirname = metadata['dirname']
init_spec = zipfile.find_spec(dirname)
self.exec_module_from_spec(init_spec, base_name) self.exec_module_from_spec(init_spec, base_name)
if name == "trustedcoin": if name == "trustedcoin":
# removes trustedcoin after loading to not show it in the list of plugins # removes trustedcoin after loading to not show it in the list of plugins
@@ -307,11 +365,12 @@ class Plugins(DaemonThread):
def load_plugin_by_name(self, name: str) -> 'BasePlugin': def load_plugin_by_name(self, name: str) -> 'BasePlugin':
if name in self.plugins: if name in self.plugins:
return self.plugins[name] return self.plugins[name]
# if the plugin was not enabled on startup the init module hasn't been loaded yet # if the plugin was not enabled on startup the init module hasn't been loaded yet
self.maybe_load_plugin_init_method(name) self.maybe_load_plugin_init_method(name)
is_external = name in self.external_plugin_metadata is_external = name in self.external_plugin_metadata
if is_external and not self.is_authorized(name):
self.logger.info(f'plugin not authorized {name}')
return
if not is_external: if not is_external:
full_name = f'electrum.plugins.{name}.{self.gui_name}' full_name = f'electrum.plugins.{name}.{self.gui_name}'
else: else:
@@ -333,6 +392,41 @@ class Plugins(DaemonThread):
def close_plugin(self, plugin): def close_plugin(self, plugin):
self.remove_jobs(plugin.thread_jobs()) self.remove_jobs(plugin.thread_jobs())
def derive_privkey(self, pw: str, salt:bytes) -> ECPrivkey:
from hashlib import pbkdf2_hmac
secret = pbkdf2_hmac('sha256', pw.encode('utf-8'), salt, iterations=10**5)
return ECPrivkey(secret)
def is_installed(self, name) -> bool:
"""an external plugin may be installed but not authorized """
return name in self.internal_plugin_metadata or name in self.external_plugin_metadata
def is_authorized(self, name) -> bool:
if name in self.internal_plugin_metadata:
return True
if name not in self.external_plugin_metadata:
return False
pubkey_bytes, salt = self.get_pubkey_bytes()
if not pubkey_bytes:
return False
if not self.is_plugin_zip(name):
return False
filename = self.zip_plugin_path(name)
plugin_hash = get_file_hash256(filename)
sig = self.config.get('authorize_plugin_' + name)
if not sig:
return False
pubkey = ECPubkey(pubkey_bytes)
return pubkey.ecdsa_verify(bytes.fromhex(sig), plugin_hash)
def authorize_plugin(self, name: str, filename, privkey: ECPrivkey):
pubkey_bytes, salt = self.get_pubkey_bytes()
assert pubkey_bytes == privkey.get_public_key_bytes()
plugin_hash = get_file_hash256(filename)
sig = privkey.ecdsa_sign(plugin_hash)
value = sig.hex()
self.config.set_key('authorize_plugin_' + name, value, save=True)
def enable(self, name: str) -> 'BasePlugin': def enable(self, name: str) -> 'BasePlugin':
self.config.set_key('enable_plugin_' + name, True, save=True) self.config.set_key('enable_plugin_' + name, True, save=True)
p = self.get(name) p = self.get(name)
@@ -351,7 +445,7 @@ class Plugins(DaemonThread):
@classmethod @classmethod
def is_plugin_enabler_config_key(cls, key: str) -> bool: def is_plugin_enabler_config_key(cls, key: str) -> bool:
return key.startswith('enable_plugin_') return key.startswith('enable_plugin_') or key.startswith('authorize_plugin_')
def toggle(self, name: str) -> Optional['BasePlugin']: def toggle(self, name: str) -> Optional['BasePlugin']:
p = self.get(name) p = self.get(name)
@@ -435,10 +529,11 @@ class Plugins(DaemonThread):
self.on_stop() self.on_stop()
def get_file_hash256(path: str) -> str: def get_file_hash256(path: str) -> bytes:
'''Get the sha256 hash of a file in hex, similar to `sha256sum`.''' '''Get the sha256 hash of a file, similar to `sha256sum`.'''
with open(path, 'rb') as f: with open(path, 'rb') as f:
return sha256(f.read()).hex() return sha256(f.read())
def hook(func): def hook(func):
hook_names.add(func.__name__) hook_names.add(func.__name__)
@@ -523,8 +618,10 @@ class BasePlugin(Logger):
def read_file(self, filename: str) -> bytes: def read_file(self, filename: str) -> bytes:
if self.parent.is_plugin_zip(self.name): if self.parent.is_plugin_zip(self.name):
plugin_filename = self.parent.zip_plugin_path(self.name) plugin_filename = self.parent.zip_plugin_path(self.name)
metadata = self.parent.external_plugin_metadata[self.name]
dirname = metadata['dirname']
with zipfile_lib.ZipFile(plugin_filename) as myzip: with zipfile_lib.ZipFile(plugin_filename) as myzip:
with myzip.open(os.path.join(self.name, filename)) as myfile: with myzip.open(os.path.join(dirname, filename)) as myfile:
return myfile.read() return myfile.read()
else: else:
if self.name in self.parent.internal_plugin_metadata: if self.name in self.parent.internal_plugin_metadata: