diff options
Diffstat (limited to 'deluge')
| -rw-r--r-- | deluge/common.py | 23 | ||||
| -rw-r--r-- | deluge/conftest.py | 93 | ||||
| -rw-r--r-- | deluge/core/authmanager.py | 70 | ||||
| -rw-r--r-- | deluge/core/core.py | 67 | ||||
| -rw-r--r-- | deluge/core/preferencesmanager.py | 23 | ||||
| -rw-r--r-- | deluge/core/torrent.py | 50 | ||||
| -rw-r--r-- | deluge/core/torrentmanager.py | 55 | ||||
| -rw-r--r-- | deluge/error.py | 7 | ||||
| -rw-r--r-- | deluge/metafile.py | 5 | ||||
| -rw-r--r-- | deluge/security.py | 127 | ||||
| -rw-r--r-- | deluge/tests/common.py | 36 | ||||
| -rw-r--r-- | deluge/tests/daemon_base.py | 7 | ||||
| -rw-r--r-- | deluge/tests/test_authmanager.py | 106 | ||||
| -rw-r--r-- | deluge/tests/test_core.py | 1 | ||||
| -rw-r--r-- | deluge/tests/test_json_api.py | 2 | ||||
| -rw-r--r-- | deluge/tests/test_ssl_torrents.py | 227 | ||||
| -rw-r--r-- | deluge/tests/test_torrent.py | 1 | ||||
| -rw-r--r-- | deluge/tests/test_tracker_icons.py | 1 | ||||
| -rw-r--r-- | deluge/ui/gtk3/connectionmanager.py | 7 | ||||
| -rw-r--r-- | deluge/ui/gtk3/dialogs.py | 31 | ||||
| -rw-r--r-- | deluge/ui/gtk3/mainwindow.py | 12 | ||||
| -rw-r--r-- | deluge/ui/gtk3/preferences.py | 8 |
22 files changed, 856 insertions, 103 deletions
diff --git a/deluge/common.py b/deluge/common.py index 7b76d24..7f94b54 100644 --- a/deluge/common.py +++ b/deluge/common.py @@ -1232,12 +1232,9 @@ AUTH_LEVEL_ADMIN = 10 AUTH_LEVEL_DEFAULT = AUTH_LEVEL_NORMAL -def create_auth_file(): +def create_auth_file(auth_file): import stat - import deluge.configmanager - - auth_file = deluge.configmanager.get_config_dir('auth') # Check for auth file and create if necessary if not os.path.exists(auth_file): with open(auth_file, 'w', encoding='utf8') as _file: @@ -1247,22 +1244,26 @@ def create_auth_file(): os.chmod(auth_file, stat.S_IREAD | stat.S_IWRITE) -def create_localclient_account(append=False): +def create_localclient_account(append=False, auth_file=None): import random from hashlib import sha1 as sha import deluge.configmanager - auth_file = deluge.configmanager.get_config_dir('auth') + if not auth_file: + auth_file = deluge.configmanager.get_config_dir('auth') + if not os.path.exists(auth_file): - create_auth_file() + create_auth_file(auth_file) + username = 'localclient' + password = sha(str(random.random()).encode('utf8')).hexdigest() with open(auth_file, 'a' if append else 'w', encoding='utf8') as _file: _file.write( ':'.join( [ - 'localclient', - sha(str(random.random()).encode('utf8')).hexdigest(), + username, + password, str(AUTH_LEVEL_ADMIN), ] ) @@ -1270,6 +1271,7 @@ def create_localclient_account(append=False): ) _file.flush() os.fsync(_file.fileno()) + return username, password def get_localhost_auth(): @@ -1306,6 +1308,9 @@ def get_localhost_auth(): if username == 'localclient': return (username, password) + log.warning('Could not find localclient account in auth file.') + return None, None + def set_env_variable(name, value): """ diff --git a/deluge/conftest.py b/deluge/conftest.py index 19a0cff..bd660c7 100644 --- a/deluge/conftest.py +++ b/deluge/conftest.py @@ -6,6 +6,7 @@ import asyncio import tempfile import warnings +from pathlib import Path from unittest.mock import Mock, patch import pytest @@ -67,15 +68,15 @@ def config_dir(tmp_path): yield config_dir -@pytest_twisted.async_yield_fixture() +@pytest_twisted.async_yield_fixture async def client(request, config_dir, monkeypatch, listen_port): # monkeypatch.setattr( # _client, 'connect', functools.partial(_client.connect, port=listen_port) # ) - try: - username, password = get_localhost_auth() - except Exception: - username, password = '', '' + username, password = get_localhost_auth() + if not (username and password): + raise ValueError('No localhost username or password found') + await _client.connect( 'localhost', port=listen_port, @@ -84,11 +85,56 @@ async def client(request, config_dir, monkeypatch, listen_port): ) yield _client if _client.connected(): - await _client.disconnect() + result = _client.disconnect() + if asyncio.iscoroutine(result): + await result @pytest_twisted.async_yield_fixture -async def daemon(request, config_dir, tmp_path): +async def daemon_factory(): + created_daemons = [] + + async def _make_daemon( + listen_port=DEFAULT_LISTEN_PORT, + logfile=None, + custom_script='', + config_dir=Path(), + ): + exception_error = RuntimeError('Failed to start daemon') + for _ in range(10): + try: + d, daemon_proc = common.start_core( + listen_port=listen_port, + logfile=logfile, + timeout=5, + timeout_msg='Timeout!', + custom_script=custom_script, + print_stdout=True, + print_stderr=True, + config_dir=config_dir, + ) + await d + created_daemons.append(daemon_proc) + return daemon_proc + except CannotListenError as ex: + exception_error = ex + listen_port += 1 + except (KeyboardInterrupt, SystemExit): + raise + else: + raise exception_error + + yield _make_daemon + + for daemon in created_daemons: + try: + await daemon.kill() + except ProcessTerminated: + pass + + +@pytest_twisted.async_yield_fixture +async def daemon(request, config_dir, tmp_path, daemon_factory): listen_port = DEFAULT_LISTEN_PORT logfile = tmp_path / 'daemon.log' @@ -97,29 +143,12 @@ async def daemon(request, config_dir, tmp_path): else: custom_script = '' - for dummy in range(10): - try: - d, daemon = common.start_core( - listen_port=listen_port, - logfile=logfile, - timeout=5, - timeout_msg='Timeout!', - custom_script=custom_script, - print_stdout=True, - print_stderr=True, - config_directory=config_dir, - ) - await d - except CannotListenError as ex: - exception_error = ex - listen_port += 1 - except (KeyboardInterrupt, SystemExit): - raise - else: - break - else: - raise exception_error - daemon.listen_port = listen_port + daemon = await daemon_factory( + listen_port=listen_port, + logfile=logfile, + custom_script=custom_script, + config_dir=config_dir, + ) yield daemon try: await daemon.kill() @@ -144,7 +173,7 @@ def common_fixture(config_dir, request, monkeypatch, listen_port): request.cls.fail = fail -@pytest_twisted.async_yield_fixture(scope='function') +@pytest_twisted.async_yield_fixture async def component(): """Verify component registry is clean, and clean up after test.""" if len(_component._ComponentRegistry.components) != 0: @@ -161,7 +190,7 @@ async def component(): _component._ComponentRegistry.dependents.clear() -@pytest_twisted.async_yield_fixture(scope='function') +@pytest_twisted.async_yield_fixture async def base_fixture(common_fixture, component, request): """This fixture is autoused on all tests that subclass BaseTestCase""" self = request.instance diff --git a/deluge/core/authmanager.py b/deluge/core/authmanager.py index 3ff8a3a..291a206 100644 --- a/deluge/core/authmanager.py +++ b/deluge/core/authmanager.py @@ -21,7 +21,13 @@ from deluge.common import ( AUTH_LEVEL_READONLY, create_localclient_account, ) -from deluge.error import AuthenticationRequired, AuthManagerError, BadLoginError +from deluge.error import ( + AuthenticationRequired, + AuthManagerError, + BadLoginError, + InvalidHashError, +) +from deluge.security import check_password_hash, generate_password_hash log = logging.getLogger(__name__) @@ -86,15 +92,15 @@ class AuthManager(component.Component): log.info('Auth file changed, reloading it!') self.__load_auth_file() - def authorize(self, username, password): + def authorize(self, username: str, password: str) -> int: """Authorizes users based on username and password. Args: - username (str): Username - password (str): Password + username: Username + password: Password Returns: - int: The auth level for this user. + The auth level for this user. Raises: AuthenticationRequired: If additional details are required to authenticate. @@ -112,14 +118,33 @@ class AuthManager(component.Component): if username not in self.__auth: raise BadLoginError('Username does not exist', username) - if self.__auth[username].password == password: - # Return the users auth level - return self.__auth[username].authlevel - elif not password and self.__auth[username].password: + stored_password = self.__auth[username].password + if not password and stored_password: raise AuthenticationRequired('Password is required', username) - else: + + if not self._verify_password(username, password, stored_password): raise BadLoginError('Password does not match', username) + return self.__auth[username].authlevel + + @staticmethod + def _verify_password(username: str, password: str, stored_password: str) -> bool: + """Verifies a user's password either as hash or plaintext.""" + if username == 'localclient': + # Plaintext validation to maintain localclient autologin compatibility + return stored_password == password + + try: + return check_password_hash(stored_password, password) + except InvalidHashError as ex: + log.warning( + 'Invalid hash method in password for user %s: %s' + ' Falling back to plaintext validation.', + username, + ex.method, + ) + return stored_password == password + def has_account(self, username): return username in self.__auth @@ -129,13 +154,15 @@ class AuthManager(component.Component): return [account.data() for account in self.__auth.values()] def create_account(self, username, password, authlevel): + password_hash = generate_password_hash(password) + if username in self.__auth: raise AuthManagerError('Username in use.', username) if authlevel not in AUTH_LEVELS_MAPPING: - raise AuthManagerError('Invalid auth level: %s' % authlevel) + raise AuthManagerError('Invalid auth level: %s' % authlevel, username) try: self.__auth[username] = Account( - username, password, AUTH_LEVELS_MAPPING[authlevel] + username, password_hash, AUTH_LEVELS_MAPPING[authlevel] ) self.write_auth_file() return True @@ -144,13 +171,18 @@ class AuthManager(component.Component): raise ex def update_account(self, username, password, authlevel): + # If the username is 'localclient', we don't hash the password + # to keep compatability with the current localclient autologin. + password_hash = None + if username != 'localclient': + password_hash = generate_password_hash(password) if username not in self.__auth: raise AuthManagerError('Username not known', username) if authlevel not in AUTH_LEVELS_MAPPING: - raise AuthManagerError('Invalid auth level: %s' % authlevel) + raise AuthManagerError('Invalid auth level: %s' % authlevel, username) try: self.__auth[username].username = username - self.__auth[username].password = password + self.__auth[username].password = password_hash or password self.__auth[username].authlevel = AUTH_LEVELS_MAPPING[authlevel] self.write_auth_file() return True @@ -213,13 +245,14 @@ class AuthManager(component.Component): create_localclient_account() return self.__load_auth_file() - auth_file_modification_time = os.stat(auth_file).st_mtime + auth_file_modification_time = os.stat(auth_file).st_mtime_ns if self.__auth_modification_time is None: self.__auth_modification_time = auth_file_modification_time elif self.__auth_modification_time == auth_file_modification_time: - # File didn't change, no need for re-parsing's + log.debug('Auth file unchanged, skipping re-parsing.') return + file_data = [] for _filepath in (auth_file, auth_file_bak): log.info('Opening %s for load: %s', filename, _filepath) try: @@ -227,7 +260,6 @@ class AuthManager(component.Component): file_data = _file.readlines() except OSError as ex: log.warning('Unable to load %s: %s', _filepath, ex) - file_data = [] else: log.info('Successfully loaded %s: %s', filename, _filepath) break @@ -265,13 +297,13 @@ class AuthManager(component.Component): authlevel = int(authlevel) except ValueError: try: - authlevel = AUTH_LEVELS_MAPPING[authlevel] + authlevel = AUTH_LEVELS_MAPPING[str(authlevel)] except KeyError: log.error( 'Your auth file is malformed: %r is not a valid auth level', authlevel, ) - continue + continue self.__auth[username] = Account(username, password, authlevel) diff --git a/deluge/core/core.py b/deluge/core/core.py index e621a45..66392b6 100644 --- a/deluge/core/core.py +++ b/deluge/core/core.py @@ -13,6 +13,7 @@ import os import shutil import tempfile from base64 import b64decode, b64encode +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union from urllib.request import URLError, urlopen @@ -676,6 +677,57 @@ class Core(component.Component): log.warning('Error adding peer %s:%s to %s', ip, port, torrent_id) @export + def set_ssl_torrent_cert( + self, + torrent_id: str, + certificate: str, + private_key: str, + dh_params: str, + save_to_disk: bool = True, + ): + """ + Set the SSL certificates used to connect to SSL peers of the given torrent. + """ + log.debug('adding ssl certificate to %s', torrent_id) + if save_to_disk: + ( + crt_file, + key_file, + dh_params_file, + ) = self.torrentmanager.ssl_file_paths_for_torrent(torrent_id) + + cert_dir = Path(self.config['ssl_torrents_certs']) + if not cert_dir.exists(): + cert_dir.mkdir(exist_ok=True) + + for file, content in ( + (crt_file, certificate), + (key_file, private_key), + (dh_params_file, dh_params), + ): + try: + with open(file, 'w') as f: + f.write(content) + except OSError as err: + log.warning('Error writing file %f to disk: %s', file, err) + return + + if not self.torrentmanager[torrent_id].set_ssl_certificate( + str(crt_file), str(key_file), str(dh_params_file) + ): + log.warning('Error adding certificate to %s', torrent_id) + else: + try: + if not self.torrentmanager[torrent_id].set_ssl_certificate_buffer( + certificate, private_key, dh_params + ): + log.warning('Error adding certificate to %s', torrent_id) + except AttributeError: + log.warning( + 'libtorrent version >=2.0.10 required to set ssl torrent cert without writing to disk' + ) + + @export def move_storage(self, torrent_ids: List[str], dest: str): log.debug('Moving storage %s to %s', torrent_ids, dest) for torrent_id in torrent_ids: @@ -823,6 +875,17 @@ class Core(component.Component): return self.session.listen_port() @export + def get_ssl_listen_port(self) -> int: + """Returns the active SSL listen port""" + try: + return self.session.ssl_listen_port() + except AttributeError: + log.warning( + 'libtorrent version >=2.0.10 required to get active SSL listen port' + ) + return -1 + + @export def get_proxy(self) -> Dict[str, Any]: """Returns the proxy settings @@ -1000,6 +1063,7 @@ class Core(component.Component): trackers=None, add_to_session=False, torrent_format=metafile.TorrentFormat.V1, + ca_cert=None, ): if isinstance(torrent_format, str): torrent_format = metafile.TorrentFormat(torrent_format) @@ -1018,6 +1082,7 @@ class Core(component.Component): trackers=trackers, add_to_session=add_to_session, torrent_format=torrent_format, + ca_cert=ca_cert, ) def _create_torrent_thread( @@ -1033,6 +1098,7 @@ class Core(component.Component): trackers, add_to_session, torrent_format, + ca_cert, ): from deluge import metafile @@ -1046,6 +1112,7 @@ class Core(component.Component): created_by=created_by, trackers=trackers, torrent_format=torrent_format, + ca_cert=ca_cert, ) write_file = False diff --git a/deluge/core/preferencesmanager.py b/deluge/core/preferencesmanager.py index 4dbf4d1..0699384 100644 --- a/deluge/core/preferencesmanager.py +++ b/deluge/core/preferencesmanager.py @@ -48,6 +48,11 @@ DEFAULT_PREFS = { 'listen_random_port': None, 'listen_use_sys_port': False, 'listen_reuse_port': True, + 'ssl_torrents': False, + 'ssl_listen_ports': [6892, 6896], + 'ssl_torrents_certs': os.path.join( + deluge.configmanager.get_config_dir(), 'ssl_torrents_certs' + ), 'outgoing_ports': [0, 0], 'random_outgoing_ports': True, 'copy_torrent_file': False, @@ -227,6 +232,24 @@ class PreferencesManager(component.Component): f'{interface}:{port}' for port in range(listen_ports[0], listen_ports[1] + 1) ] + + if self.config['ssl_torrents']: + if self.config['random_port']: + ssl_listen_ports = [self.config['listen_random_port'] + 1] * 2 + else: + ssl_listen_ports = self.config['ssl_listen_ports'] + interfaces.extend( + [ + f'{interface}:{port}s' + for port in range(ssl_listen_ports[0], ssl_listen_ports[1] + 1) + ] + ) + log.debug( + 'SSL listen Interface: %s, Ports: %s', + interface, + listen_ports, + ) + self.core.apply_session_settings( { 'listen_system_port_fallback': self.config['listen_use_sys_port'], diff --git a/deluge/core/torrent.py b/deluge/core/torrent.py index dbcf8f1..c7c7349 100644 --- a/deluge/core/torrent.py +++ b/deluge/core/torrent.py @@ -1275,6 +1275,56 @@ class Torrent: return False return True + def set_ssl_certificate( + self, + certificate_path: str, + private_key_path: str, + dh_params_path: str, + password: str = '', + ): + """add a peer to the torrent + + Args: + certificate_path(str) : Path to the PEM-encoded x509 certificate + private_key_path(str) : Path to the PEM-encoded private key + dh_params_path(str) : Path to the PEM-encoded Diffie-Hellman parameter + password(str) : (Optional) password used to decrypt the private key + + Returns: + bool: True is successful, otherwise False + """ + try: + self.handle.set_ssl_certificate( + certificate_path, private_key_path, dh_params_path, password + ) + except RuntimeError as ex: + log.error('Unable to set ssl certificate from file: %s', ex) + return False + return True + + def set_ssl_certificate_buffer( + self, + certificate: str, + private_key: str, + dh_params: str, + ): + """add a peer to the torrent + + Args: + certificate(str) : PEM-encoded content of the x509 certificate + private_key(str) : PEM-encoded content of the private key + dh_params(str) : PEM-encoded content of the Diffie-Hellman parameters + + Returns: + bool: True is successful, otherwise False + """ + try: + self.handle.set_ssl_certificate_buffer(certificate, private_key, dh_params) + except RuntimeError as ex: + log.error('Unable to set ssl certificate from buffer: %s', ex) + return False + return True + def move_storage(self, dest): """Move a torrent's storage location diff --git a/deluge/core/torrentmanager.py b/deluge/core/torrentmanager.py index 1233553..a659298 100644 --- a/deluge/core/torrentmanager.py +++ b/deluge/core/torrentmanager.py @@ -15,6 +15,7 @@ import os import pickle import time from base64 import b64encode +from pathlib import Path from tempfile import gettempdir from typing import Dict, List, NamedTuple, Tuple @@ -210,6 +211,7 @@ class TorrentManager(component.Component): 'torrent_finished', 'torrent_paused', 'torrent_checked', + 'torrent_need_cert', 'torrent_resumed', 'tracker_reply', 'tracker_announce', @@ -338,8 +340,8 @@ class TorrentManager(component.Component): if log.isEnabledFor(logging.DEBUG): log.debug('Attempting to extract torrent_info from %s', filepath) try: - torrent_info = lt.torrent_info(filepath) - except RuntimeError as ex: + torrent_info = lt.torrent_info(Path(filepath).read_bytes()) + except (RuntimeError, OSError) as ex: log.warning('Unable to open torrent file %s: %s', filepath, ex) else: return torrent_info @@ -767,6 +769,11 @@ class TorrentManager(component.Component): torrent_name, component.get('RPCServer').get_session_user(), ) + + for file in self.ssl_file_paths_for_torrent(torrent_id): + if file.is_file(): + file.unlink() + return True def fixup_state(self, state): @@ -1340,6 +1347,50 @@ class TorrentManager(component.Component): torrent.update_state() + def ssl_file_paths_for_torrent(self, torrent_id): + certs_dir = Path(self.config['ssl_torrents_certs']) + + crt_file = certs_dir / f'{torrent_id}.crt.pem' + key_file = certs_dir / f'{torrent_id}.key.pem' + dh_params_file = certs_dir / f'{torrent_id}.dh.pem' + + return crt_file, key_file, dh_params_file + + def on_alert_torrent_need_cert(self, alert): + """Alert handler for libtorrent torrent_need_cert_alert""" + + if not self.config['ssl_torrents']: + return + + torrent_id = str(alert.handle.info_hash()) + + certs_dir = Path(self.config['ssl_torrents_certs']) + crt_file, key_file, dh_params_file = self.ssl_file_paths_for_torrent(torrent_id) + if not crt_file.is_file() or not key_file.is_file(): + crt_file = certs_dir / 'default.crt.pem' + key_file = certs_dir / 'default.key.pem' + if not dh_params_file.is_file(): + dh_params_file = certs_dir / 'default.dh.pem' + if not (crt_file.is_file() and key_file.is_file() and dh_params_file.is_file()): + log.error('Unable to load certs for SSL Torrent %s', torrent_id) + return + + try: + # Cannot use the handle via self.torrents. + # torrent_need_cert_alert is raised before add_torrent_alert + alert.handle.set_ssl_certificate( + str(crt_file), str(key_file), str(dh_params_file) + ) + except RuntimeError as err: + log.error( + 'Unable to set ssl certificate for %s from files %s:%s:%s: %s', + torrent_id, + crt_file, + key_file, + dh_params_file, + err, + ) + def on_alert_tracker_reply(self, alert): """Alert handler for libtorrent tracker_reply_alert""" try: diff --git a/deluge/error.py b/deluge/error.py index d542dc2..03253b6 100644 --- a/deluge/error.py +++ b/deluge/error.py @@ -94,3 +94,10 @@ class AuthManagerError(_UsernameBasedPasstroughError): class LibtorrentImportError(ImportError): pass + + +class InvalidHashError(_ClientSideRecreateError): + def __init__(self, message, method): + super().__init__(message) + self.method = method + self.message = message diff --git a/deluge/metafile.py b/deluge/metafile.py index 81a371f..3d5174b 100644 --- a/deluge/metafile.py +++ b/deluge/metafile.py @@ -98,6 +98,7 @@ def make_meta_file_content( created_by=None, trackers=None, torrent_format=TorrentFormat.V1, + ca_cert=None, ): data = {'creation date': int(gmtime())} if url: @@ -121,6 +122,7 @@ def make_meta_file_content( content_type, private, torrent_format, + ca_cert, ) # check_info(info) @@ -294,6 +296,7 @@ def makeinfo( content_type=None, private=False, torrent_format=TorrentFormat.V1, + ca_cert=None, ): # HEREDAVE. If path is directory, how do we assign content type? @@ -443,6 +446,8 @@ def makeinfo( b'file tree': file_tree, } ) + if ca_cert: + info[b'ssl-cert'] = ca_cert return info, piece_layers if torrent_format.includes_v2() else None diff --git a/deluge/security.py b/deluge/security.py new file mode 100644 index 0000000..85638c4 --- /dev/null +++ b/deluge/security.py @@ -0,0 +1,127 @@ +# This file is adapted from Werkzeug (https://github.com/pallets/werkzeug) +# and is licensed under the BSD 3-Clause License: + +# Copyright 2007 Pallets + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: + +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. + +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import annotations + +import hashlib +import hmac +import secrets + +from deluge.error import InvalidHashError + +SALT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + + +def gen_salt(length: int) -> str: + """Generate a random string of SALT_CHARS with specified ``length``.""" + if length <= 0: + raise ValueError('Salt length must be at least 1.') + + return ''.join(secrets.choice(SALT_CHARS) for _ in range(length)) + + +def _hash_internal(method: str, salt: str, password: str) -> tuple[str, str]: + method, *args = method.split('$') + salt_bytes = salt.encode() + password_bytes = password.encode() + + if method == 'scrypt': + if not args: + n = 2**15 + r = 8 + p = 1 + else: + try: + n, r, p = map(int, args) + except ValueError: + raise ValueError("'scrypt' takes 3 arguments.") from None + + maxmem = 132 * n * r * p # ideally 128, but some extra seems needed + return ( + hashlib.scrypt( + password_bytes, salt=salt_bytes, n=n, r=r, p=p, maxmem=maxmem + ).hex(), + f'scrypt${n}${r}${p}', + ) + + else: + raise ValueError(f"Invalid hash method '{method}'.") + + +def generate_password_hash( + password: str, method: str = 'scrypt', salt_length: int = 16 +) -> str: + """Securely hash a password for storage. A password can be compared to a stored hash using check_password_hash. + + Args: + password (str): The plaintext password to hash. + method (str): The key derivation function and parameters. Defaults to 'scrypt'. + salt_length (int): The length of the salt to generate. Defaults to 16. + + Returns: + str: The hashed password in the format '$method$salt$hash'. + + """ + salt = gen_salt(salt_length) + h, actual_method = _hash_internal(method, salt, password) + return f'${actual_method}${salt}${h}' + + +def check_password_hash(pwhash: str, password: str) -> bool: + """Securely check that the given stored password hash, previously generated using + generate_password_hash, matches the given password. + + Methods may be deprecated and removed if they are no longer considered secure. To + migrate old hashes, you may generate a new hash when checking an old hash, or you + may contact users with a link to reset their password. + + Args: + pwhash (str): The hashed password in the format '$method$salt$hash + password (str): The plaintext password to check against the hash. + + Raises: + ValueError: If the hash format is invalid or the method is not recognized. + + Returns: + bool: True if the password matches the hash, False otherwise. + """ + method = None + try: + method, salt, stored_hash = pwhash.lstrip('$').rsplit('$', 2) + computed_hash = _hash_internal(method, salt, password)[0] + except ValueError as ve: + raise InvalidHashError( + 'Invalid password hash format. Expected format: "$method$salt$hash".', + method=method if method else pwhash, + ) from ve + + return hmac.compare_digest(computed_hash, stored_hash) diff --git a/deluge/tests/common.py b/deluge/tests/common.py index b594156..e902c89 100644 --- a/deluge/tests/common.py +++ b/deluge/tests/common.py @@ -9,6 +9,7 @@ import os import sys import traceback +from pathlib import Path import pytest from twisted.internet import defer, protocol, reactor @@ -96,6 +97,7 @@ class ProcessOutputHandler(protocol.ProcessProtocol): logfile=None, print_stdout=True, print_stderr=True, + **kwargs, ): """Executes a script and handle the process' output to stdout and stderr. @@ -119,6 +121,7 @@ class ProcessOutputHandler(protocol.ProcessProtocol): self.quit_d = None self.killed = False self.watchdogs = [] + self.options = kwargs def connectionMade(self): # NOQA: N802 self.transport.write(self.script) @@ -158,14 +161,14 @@ class ProcessOutputHandler(protocol.ProcessProtocol): if not w.called and not w.cancelled: w.cancel() - def processEnded(self, status): # NOQA: N802 + def processEnded(self, reason): # NOQA: N802 self.transport.loseConnection() if self.quit_d is None: return - if status.value.exitCode == 0: + if reason.value.exitCode == 0: self.quit_d.callback(True) else: - self.quit_d.errback(status) + self.quit_d.errback(reason) def check_callbacks(self, data, cb_type='stdout'): ret = False @@ -220,7 +223,7 @@ def start_core( print_stdout=True, print_stderr=True, extra_callbacks=None, - config_directory='', + config_dir: Path = Path(), ): """Start the deluge core as a daemon. @@ -261,7 +264,7 @@ except Exception: import traceback sys.stderr.write('Exception raised:\\n %%s' %% traceback.format_exc()) """ % { - 'dir': config_directory.as_posix(), + 'dir': config_dir.as_posix(), 'port': listen_port, 'script': custom_script, } @@ -270,6 +273,7 @@ except Exception: default_core_cb = {'deferred': Deferred(), 'types': 'stdout'} if timeout: default_core_cb['timeout'] = timeout + default_core_cb['timeout_msg'] = timeout_msg if timeout_msg else 'Timeout!' # Specify the triggers for daemon log output default_core_cb['triggers'] = [ @@ -299,6 +303,9 @@ except Exception: @defer.inlineCallbacks def shutdown_daemon(): username, password = get_localhost_auth() + if not (username and password): + raise ValueError('No localhost username or password found') + client = Client() yield client.connect( 'localhost', listen_port, username=username, password=password @@ -306,13 +313,26 @@ except Exception: yield client.daemon.shutdown() process_protocol = start_process( - daemon_script, shutdown_daemon, callbacks, logfile, print_stdout, print_stderr + daemon_script, + shutdown_daemon, + callbacks, + logfile, + print_stdout, + print_stderr, + listen_port=listen_port, + config_dir=config_dir, ) return default_core_cb['deferred'], process_protocol def start_process( - script, shutdown_func, callbacks, logfile=None, print_stdout=True, print_stderr=True + script, + shutdown_func, + callbacks, + logfile=None, + print_stdout=True, + print_stderr=True, + **kwargs, ): """ Starts an external python process which executes the given script. @@ -324,6 +344,7 @@ def start_process( logfile (str, optional): Logfile name to write the output from the process. print_stderr (bool): If the output from the process' stderr should be printed to stdout. print_stdout (bool): If the output from the process' stdout should be printed to stdout. + **kwargs: Additional options that will be stored in the instance's options attribute. Returns: ProcessOutputHandler: The handler for the process's output. @@ -347,6 +368,7 @@ def start_process( logfile, print_stdout, print_stderr, + **kwargs, ) # Add timeouts to deferreds diff --git a/deluge/tests/daemon_base.py b/deluge/tests/daemon_base.py index 707570f..7eb6421 100644 --- a/deluge/tests/daemon_base.py +++ b/deluge/tests/daemon_base.py @@ -25,7 +25,7 @@ class DaemonBase: if hasattr(args[0], 'getTraceback'): print('terminate_core: Errback Exception: %s' % args[0].getTraceback()) - if not self.core.killed: + if self.core and not self.core.killed: d = self.core.kill() return d @@ -43,7 +43,8 @@ class DaemonBase: ): logfile = f'daemon_{self.id()}.log' if logfile == '' else logfile - for dummy in range(port_range): + exception_error = RuntimeError('Failed to start daemon') + for _ in range(port_range): try: d, self.core = common.start_core( listen_port=self.listen_port, @@ -54,7 +55,7 @@ class DaemonBase: print_stdout=print_stdout, print_stderr=print_stderr, extra_callbacks=extra_callbacks, - config_directory=self.config_dir, + config_dir=self.config_dir, ) yield d except CannotListenError as ex: diff --git a/deluge/tests/test_authmanager.py b/deluge/tests/test_authmanager.py index aa86fdb..7f087e2 100644 --- a/deluge/tests/test_authmanager.py +++ b/deluge/tests/test_authmanager.py @@ -4,20 +4,118 @@ # See LICENSE for more details. # +import os + +import pytest + import deluge.component as component -from deluge.common import get_localhost_auth +from deluge.common import ( + AUTH_LEVEL_ADMIN, + AUTH_LEVEL_NORMAL, + get_localhost_auth, +) +from deluge.configmanager import get_config_dir from deluge.conftest import BaseTestCase -from deluge.core.authmanager import AUTH_LEVEL_ADMIN, AuthManager +from deluge.core.authmanager import AuthManager +from deluge.core.rpcserver import RPCServer +from deluge.error import AuthenticationRequired, BadLoginError + + +@pytest.fixture +def add_user_to_authfile(): + """Add user directly to the auth file.""" + + def _add_user(username, password, level): + auth_file = get_config_dir('auth') + with open(auth_file, 'a') as file: + file.write(f'{username}:{password}:{level}\n') + + # Force mtime to workaround Windows not updating for check by __load_auth_file. + stat = os.stat(auth_file) + os.utime(auth_file, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1000)) + + return _add_user class TestAuthManager(BaseTestCase): def set_up(self): self.auth = AuthManager() + self.rpcserver = RPCServer(listen=False) self.auth.start() def tear_down(self): # We must ensure that the components in component registry are removed return component.shutdown() - def test_authorize(self): - assert self.auth.authorize(*get_localhost_auth()) == AUTH_LEVEL_ADMIN + def test_authorize_localhost(self): + username, password = get_localhost_auth() + assert username == 'localclient' + assert password + assert self.auth.authorize(username, password) == AUTH_LEVEL_ADMIN + + @pytest.mark.parametrize('username', ['', None]) + def test_authorize_no_username_raises(self, username): + with pytest.raises(AuthenticationRequired): + self.auth.authorize(username, 'password') + + def test_authorize_no_password_raises(self): + with pytest.raises(AuthenticationRequired): + self.auth.authorize('localclient', '') + + def test_authorize_incorrect_username_raises(self): + with pytest.raises(BadLoginError): + self.auth.authorize('notuser', 'password') + + def test_authorize_wrong_password_raises(self): + username, password = get_localhost_auth() + assert username == 'localclient' + assert password + with pytest.raises(BadLoginError): + self.auth.authorize(username, password + 'x') + + def test_create_account(self): + self.auth.create_account('test', 'testpass', 'ADMIN') + assert self.auth.has_account('test') + assert self.auth.authorize('test', 'testpass') == AUTH_LEVEL_ADMIN + + def test_remove_account(self): + self.auth.create_account('test', 'testpass', 'ADMIN') + assert self.auth.has_account('test') + + self.auth.remove_account('test') + assert not self.auth.has_account('test') + + def test_update_account(self): + self.auth.create_account('test', 'testpass', 'ADMIN') + assert self.auth.has_account('test') + + self.auth.update_account('test', 'testpass', 'NORMAL') + assert self.auth.authorize('test', 'testpass') == AUTH_LEVEL_NORMAL + + self.auth.update_account('test', 'newpass', 'ADMIN') + assert self.auth.authorize('test', 'newpass') == AUTH_LEVEL_ADMIN + + @pytest.mark.parametrize('password', ['testpass', '$testpa$$', 'test:pass']) + def test_password_hashed(self, password): + """Test account password is hashed with scrypt method.""" + self.auth.create_account('test', password, 'ADMIN') + + with open(get_config_dir('auth')) as file: + user, result_password, _ = file.readlines()[1].strip().split(':') + + assert user == 'test' + assert result_password.startswith('$scrypt$') + assert password not in result_password + + @pytest.mark.parametrize('password', ['testpass1', '$testpa$$']) + def test_password_plaintext(self, password, add_user_to_authfile): + """Test account plaintext password is still accepted.""" + add_user_to_authfile('test', password, AUTH_LEVEL_ADMIN) + + assert self.auth.authorize('test', password) == AUTH_LEVEL_ADMIN + + def test_load_auth_level_str(self, add_user_to_authfile): + """Test load auth config with auth level as string.""" + add_user_to_authfile('test', 'testpass', 'NORMAL') + + assert self.auth.authorize('test', 'testpass') == AUTH_LEVEL_NORMAL diff --git a/deluge/tests/test_core.py b/deluge/tests/test_core.py index 28b5902..7533028 100644 --- a/deluge/tests/test_core.py +++ b/deluge/tests/test_core.py @@ -426,6 +426,7 @@ class TestCore(BaseTestCase): assert space >= 0 assert self.core.get_free_space('/someinvalidpath') == -1 + @pytest.mark.flaky(reruns=3, reruns_delay=2) @pytest.mark.slow def test_test_listen_port(self): d = self.core.test_listen_port() diff --git a/deluge/tests/test_json_api.py b/deluge/tests/test_json_api.py index d8e382f..ab15c7f 100644 --- a/deluge/tests/test_json_api.py +++ b/deluge/tests/test_json_api.py @@ -217,7 +217,7 @@ class TestJSONRequestFailed(WebServerMockBase): print_stderr=False, timeout=5, extra_callbacks=[extra_callback], - config_directory=config_dir, + config_dir=config_dir, ) extra_callback['deferred'].addCallback(on_test_raise, daemon) diff --git a/deluge/tests/test_ssl_torrents.py b/deluge/tests/test_ssl_torrents.py new file mode 100644 index 0000000..7e55eeb --- /dev/null +++ b/deluge/tests/test_ssl_torrents.py @@ -0,0 +1,227 @@ +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# +import datetime +import time + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +from deluge.common import create_localclient_account +from deluge.config import Config +from deluge.conftest import BaseTestCase +from deluge.tests.common import get_test_data_file +from deluge.ui.client import Client + + +def generate_x509_cert(common_name, san_list=None): + private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ] + ) + ) + .not_valid_before(datetime.datetime.utcnow() - datetime.timedelta(days=1)) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=90)) + .serial_number(x509.random_serial_number()) + .public_key(private_key.public_key()) + ) + + if san_list: + san_objects = [ + x509.DNSName(str(san).strip()) for san in san_list if str(san).strip() + ] + builder = builder.add_extension( + x509.SubjectAlternativeName(san_objects), critical=False + ) + + return private_key, builder + + +def x509_ca(): + common_name = 'Test CA' + private_key, builder = generate_x509_cert( + common_name=common_name, + ) + builder = builder.add_extension( + x509.BasicConstraints(ca=True, path_length=1), + critical=True, + ).issuer_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ] + ) + ) + certificate = builder.sign( + private_key=private_key, + algorithm=hashes.SHA256(), + backend=default_backend(), + ) + return certificate, private_key + + +def x509_peer_certificate_pem(torrent_name, ca_cert, ca_key): + private_key, builder = generate_x509_cert( + common_name='doesnt_matter', + san_list=[torrent_name], + ) + builder = builder.issuer_name(ca_cert.issuer) + certificate = builder.sign( + private_key=ca_key, algorithm=hashes.SHA256(), backend=default_backend() + ) + + certificate_pem = certificate.public_bytes( + encoding=serialization.Encoding.PEM + ).decode() + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + return certificate_pem, private_key_pem + + +DH_PARAMS_PEM = """ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEA+oeNEEXOCzrdmDwkKb31I+WaGIeRlx9jvF4sold3Mrw8tQ8rqyfc +GNfjEUhqSnyROQ9Wf8BvQJ94Fcw3oV9Os3APZtHOwTag3PzSe2ImCHTWL+LbQD/m +bl2zDJ2xD6j1ZmyGes8DZC8RyBEMSS/aoWFKWKzlba5WXTzC8n/2MBReoOm2eMhF +wUG21UW/MQQ+i1sHrC0d0zPdvnqXAa7tnO70j/kLhxv8446fsbXJo4G/iIAR1RSD +UbMIXHrloW/G5BviauWNxIwvfTYTlzfzwhhCDieLI/GwuAF388BKG4KQ181qrTFO +iTniEzsEklfNUEZ59lwiDmJF1qmmH017PwIBAg== +-----END DH PARAMETERS----- +""" + +CA_CERT, CA_KEY = x509_ca() + + +async def _create_daemon_and_client(daemon_factory, config_dir): + certificate_location = config_dir / 'ssl_torrents_certs' + certificate_location.mkdir() + + # Write default SSL certificates + crt_pem, key_pem = x509_peer_certificate_pem( + torrent_name='*', + ca_cert=CA_CERT, + ca_key=CA_KEY, + ) + with open(certificate_location / 'default.crt.pem', 'w') as file: + file.write(crt_pem) + with open(certificate_location / 'default.key.pem', 'w') as file: + file.write(key_pem) + with open(certificate_location / 'default.dh.pem', 'w') as file: + file.write(DH_PARAMS_PEM) + + # Open SSL port and set the certificate location in Deluge configuration + config = Config('core.conf', config_dir=config_dir) + config.set_item('ssl_torrents', True) + config.save() + + # Pre-create the authentication credentials + username, password = create_localclient_account(auth_file=config_dir / 'auth') + + # Run the daemon and connect a client to it + daemon_proc = await daemon_factory(config_dir=config_dir) + client = Client() + await client.connect( + port=daemon_proc.options['listen_port'], username=username, password=password + ) + + return client + + +class TestSslTorrents(BaseTestCase): + async def test_ssl_torrents(self, daemon_factory, tmp_path_factory): + seeder = await _create_daemon_and_client( + daemon_factory=daemon_factory, config_dir=tmp_path_factory.mktemp('seeder') + ) + leecher_config_dir = tmp_path_factory.mktemp('leecher') + leecher = await _create_daemon_and_client( + daemon_factory=daemon_factory, config_dir=leecher_config_dir + ) + destination_dir = tmp_path_factory.mktemp('destination') + + # Create two SSL torrents and add them to the seeder and the leecher + torrent_ids = {} + for test_file in ('deluge.png', 'seo.svg'): + filename, filedump = await seeder.core.create_torrent( + path=get_test_data_file(test_file), + tracker='localhost', + piece_length=2**14, + private=True, + add_to_session=True, + ca_cert=CA_CERT.public_bytes(encoding=serialization.Encoding.PEM), + target=str(destination_dir / f'{test_file}.torrent'), + ) + + torrent_id = await leecher.core.add_torrent_file( + filename=filename, + filedump=filedump, + options={'download_location': str(destination_dir)}, + ) + + torrent_ids[test_file] = torrent_id + + # Add an explicit certificate for one of the two torrents. + # The second torrent will use the default certificate for transfers. + torrent_name = 'deluge.png' + for client in seeder, leecher: + crt_pem, key_pem = x509_peer_certificate_pem( + torrent_name=torrent_name, + ca_cert=CA_CERT, + ca_key=CA_KEY, + ) + await client.core.set_ssl_torrent_cert( + torrent_ids[torrent_name], crt_pem, key_pem, DH_PARAMS_PEM + ) + + # Connect the two peers directly, without tracker + seeder_port = await seeder.core.get_ssl_listen_port() + if seeder_port < 0: + seeder_conf = await seeder.core.get_config() + seeder_port = seeder_conf['listen_random_port'] + 1 + for torrent_id in torrent_ids.values(): + await leecher.core.connect_peer(torrent_id, '127.0.0.1', seeder_port) + + # Wait for transfers to be executed + max_wait_seconds = 10 + all_finished = False + while max_wait_seconds > 0: + status_dict = await leecher.core.get_torrents_status( + {'id': list(torrent_ids.values())}, ['is_finished'] + ) + all_finished = all(status['is_finished'] for status in status_dict.values()) + if all_finished: + break + + time.sleep(1) + max_wait_seconds -= 1 + + assert all_finished + + # Ensure that certificates are removed on torrent removal + certificate_location = leecher_config_dir / 'ssl_torrents_certs' + torrent_id = torrent_ids[torrent_name] + ssl_files = ( + certificate_location / f'{torrent_id}.crt.pem', + certificate_location / f'{torrent_id}.key.pem', + certificate_location / f'{torrent_id}.dh.pem', + ) + for file in ssl_files: + assert file.is_file() + await leecher.core.remove_torrent(torrent_id, remove_data=False) + for file in ssl_files: + assert not file.is_file() diff --git a/deluge/tests/test_torrent.py b/deluge/tests/test_torrent.py index 5a298ef..680d347 100644 --- a/deluge/tests/test_torrent.py +++ b/deluge/tests/test_torrent.py @@ -128,6 +128,7 @@ class TestTorrent(BaseTestCase): result = all(p in piece_prio for p in [3, 2, 0, 5, 6, 7]) assert result + @pytest.mark.flaky(reruns=3, reruns_delay=2) def test_set_prioritize_first_last_pieces(self): piece_indexes = [ 0, diff --git a/deluge/tests/test_tracker_icons.py b/deluge/tests/test_tracker_icons.py index 57cc138..550ff33 100644 --- a/deluge/tests/test_tracker_icons.py +++ b/deluge/tests/test_tracker_icons.py @@ -27,6 +27,7 @@ class TestTrackerIcons(BaseTestCase): def tear_down(self): return component.shutdown() + @pytest.mark.flaky(reruns=3, reruns_delay=2) async def test_get_deluge_png(self, mock_mkstemp): # Deluge has a png favicon link icon = TrackerIcon(common.get_test_data_file('deluge.png')) diff --git a/deluge/ui/gtk3/connectionmanager.py b/deluge/ui/gtk3/connectionmanager.py index b53dd8e..29dbc5d 100644 --- a/deluge/ui/gtk3/connectionmanager.py +++ b/deluge/ui/gtk3/connectionmanager.py @@ -314,9 +314,10 @@ class ConnectionManager(component.Component): log.debug('PasswordRequired exception') dialog = AuthenticationDialog(reason.value.message, reason.value.username) - def dialog_finished(response_id): - if response_id == Gtk.ResponseType.OK: - self._connect(host_id, dialog.get_username(), dialog.get_password()) + def dialog_finished(user_password): + if not user_password: + return + self._connect(host_id, *user_password) return dialog.run().addCallback(dialog_finished) diff --git a/deluge/ui/gtk3/dialogs.py b/deluge/ui/gtk3/dialogs.py index db337d3..81b4db9 100644 --- a/deluge/ui/gtk3/dialogs.py +++ b/deluge/ui/gtk3/dialogs.py @@ -222,7 +222,7 @@ class AuthenticationDialog(BaseDialog): self.password_label.set_padding(5, 5) self.password_entry = Gtk.Entry() self.password_entry.set_visibility(False) - self.password_entry.connect('activate', self.on_password_activate) + self.password_entry.connect('activate', self._on_password_activate) table.attach(self.password_label, 0, 1, 1, 2) table.attach(self.password_entry, 1, 2, 1, 2) @@ -236,15 +236,15 @@ class AuthenticationDialog(BaseDialog): self.set_focus(self.username_entry) self.show_all() - def get_username(self): - return self.username_entry.get_text() - - def get_password(self): - return self.password_entry.get_text() - - def on_password_activate(self, widget): + def _on_password_activate(self, widget): self.response(Gtk.ResponseType.OK) + def _on_response(self, widget, response): + result = None + if response == Gtk.ResponseType.OK: + result = (self.username_entry.get_text(), self.password_entry.get_text()) + super()._on_response(widget, result) + class AccountDialog(BaseDialog): def __init__( @@ -277,7 +277,7 @@ class AccountDialog(BaseDialog): parent, ) - self.account = None + self.account = Account('', '', 'DEFAULT') table = Gtk.Table(2, 3, False) username_label = Gtk.Label() @@ -441,7 +441,7 @@ class PasswordDialog(BaseDialog): self.password_label.set_padding(5, 5) self.password_entry = Gtk.Entry() self.password_entry.set_visibility(False) - self.password_entry.connect('activate', self.on_password_activate) + self.password_entry.connect('activate', self._on_password_activate) table.attach(self.password_label, 0, 1, 1, 2) table.attach(self.password_entry, 1, 2, 1, 2) @@ -450,12 +450,15 @@ class PasswordDialog(BaseDialog): self.show_all() - def get_password(self): - return self.password_entry.get_text() - - def on_password_activate(self, widget): + def _on_password_activate(self, widget): self.response(Gtk.ResponseType.OK) + def _on_response(self, widget, response): + password = None + if response == Gtk.ResponseType.OK: + password = self.password_entry.get_text() + super()._on_response(widget, password) + class CopyMagnetDialog(BaseDialog): """ diff --git a/deluge/ui/gtk3/mainwindow.py b/deluge/ui/gtk3/mainwindow.py index 6c871d2..b01bd99 100644 --- a/deluge/ui/gtk3/mainwindow.py +++ b/deluge/ui/gtk3/mainwindow.py @@ -198,11 +198,11 @@ class MainWindow(component.Component): if self.config['lock_tray'] and not self.visible(): dialog = PasswordDialog(_('Enter your password to show Deluge...')) - def on_dialog_response(response_id): - if response_id == Gtk.ResponseType.OK: + def on_dialog_response(password): + if password is not None: if ( self.config['tray_password'] - == sha(decode_bytes(dialog.get_password()).encode()).hexdigest() + == sha(decode_bytes(password).encode()).hexdigest() ): restore() @@ -257,11 +257,11 @@ class MainWindow(component.Component): if self.config['lock_tray'] and not self.visible(): dialog = PasswordDialog(_('Enter your password to Quit Deluge...')) - def on_dialog_response(response_id): - if response_id == Gtk.ResponseType.OK: + def on_dialog_response(password): + if password: if ( self.config['tray_password'] - == sha(decode_bytes(dialog.get_password()).encode()).hexdigest() + == sha(decode_bytes(password).encode()).hexdigest() ): quit_gtkui() diff --git a/deluge/ui/gtk3/preferences.py b/deluge/ui/gtk3/preferences.py index 59b2226..d913010 100644 --- a/deluge/ui/gtk3/preferences.py +++ b/deluge/ui/gtk3/preferences.py @@ -1409,8 +1409,8 @@ class Preferences(component.Component): def dialog_finished(response_id): def update_ok(rc): - model.set_value(itr, ACCOUNTS_PASSWORD, dialog.get_username()) - model.set_value(itr, ACCOUNTS_LEVEL, dialog.get_authlevel()) + model.set_value(itr, ACCOUNTS_PASSWORD, dialog.account.username) + model.set_value(itr, ACCOUNTS_LEVEL, dialog.account.authlevel) def update_fail(failure): ErrorDialog( @@ -1422,7 +1422,9 @@ class Preferences(component.Component): if response_id == Gtk.ResponseType.OK: client.core.update_account( - dialog.get_username(), dialog.get_password(), dialog.get_authlevel() + dialog.account.username, + dialog.account.password, + dialog.account.authlevel, ).addCallback(update_ok).addErrback(update_fail) dialog.run().addCallback(dialog_finished) |
