aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/cd.yml1
-rw-r--r--.github/workflows/ci.yml34
-rw-r--r--.github/workflows/docs.yml13
-rw-r--r--CHANGELOG.md26
-rw-r--r--DEPENDS.md2
-rw-r--r--deluge/common.py23
-rw-r--r--deluge/conftest.py93
-rw-r--r--deluge/core/authmanager.py70
-rw-r--r--deluge/core/core.py67
-rw-r--r--deluge/core/preferencesmanager.py23
-rw-r--r--deluge/core/torrent.py50
-rw-r--r--deluge/core/torrentmanager.py55
-rw-r--r--deluge/error.py7
-rw-r--r--deluge/metafile.py5
-rw-r--r--deluge/security.py127
-rw-r--r--deluge/tests/common.py36
-rw-r--r--deluge/tests/daemon_base.py7
-rw-r--r--deluge/tests/test_authmanager.py106
-rw-r--r--deluge/tests/test_core.py1
-rw-r--r--deluge/tests/test_json_api.py2
-rw-r--r--deluge/tests/test_ssl_torrents.py227
-rw-r--r--deluge/tests/test_torrent.py1
-rw-r--r--deluge/tests/test_tracker_icons.py1
-rw-r--r--deluge/ui/gtk3/connectionmanager.py7
-rw-r--r--deluge/ui/gtk3/dialogs.py31
-rw-r--r--deluge/ui/gtk3/mainwindow.py12
-rw-r--r--deluge/ui/gtk3/preferences.py8
-rw-r--r--requirements-tests.txt3
-rw-r--r--requirements.txt1
-rwxr-xr-xsetup.py7
30 files changed, 917 insertions, 129 deletions
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 959b654..297d809 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -20,6 +20,7 @@ on:
jobs:
windows_package:
runs-on: windows-2022
+ timeout-minutes: 30
if: (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'package'))
strategy:
matrix:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d648ba1..2d17d64 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,12 +10,17 @@ on:
core-dump:
description: "Set to 1 to enable retrieving core dump from crashes"
default: "0"
+
+env:
+ UV_SYSTEM_PYTHON: 1
+
jobs:
test-linux:
runs-on: ubuntu-22.04
+ timeout-minutes: 10
strategy:
matrix:
- python-version: ["3.7", "3.10"]
+ python-version: ["3.9", "3.13"]
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
@@ -27,8 +32,11 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- cache: "pip"
- cache-dependency-path: "requirements*.txt"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v6
+ with:
+ enable-cache: true
- name: Sets env var for security
if: (github.event_name == 'pull_request' && contains(github.event.pull_request.body, 'security_test')) || (github.event_name == 'push' && contains(github.event.head_commit.message, 'security_test'))
@@ -36,9 +44,8 @@ jobs:
- name: Install dependencies
run: |
- pip install --upgrade pip wheel setuptools
- pip install -r requirements-ci.txt
- pip install -e .
+ uv pip install -r requirements-ci.txt
+ uv pip install -e .
- name: Install security dependencies
if: contains(env.SECURITY_TESTS, 'True')
@@ -72,9 +79,10 @@ jobs:
test-windows:
runs-on: windows-2022
+ timeout-minutes: 10
strategy:
matrix:
- python-version: ["3.7", "3.10"]
+ python-version: ["3.9", "3.12"]
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
@@ -86,14 +94,16 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- cache: "pip"
- cache-dependency-path: "requirements*.txt"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v6
+ with:
+ enable-cache: true
- name: Install dependencies
run: |
- pip install --upgrade pip wheel setuptools
- pip install -r requirements-ci.txt
- pip install -e .
+ uv pip install -r requirements-ci.txt
+ uv pip install -e .
- name: Test with pytest
run: |
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index a23e264..a5f55eb 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -25,14 +25,15 @@ jobs:
cache: "pip"
cache-dependency-path: "requirements*.txt"
+ - name: Install uv
+ uses: astral-sh/setup-uv@v6
+ with:
+ enable-cache: true
+
- name: Install dependencies
- run: |
- pip install --upgrade pip wheel
- pip install tox
- sudo apt-get install enchant-2
+ run: sudo apt-get install enchant-2
- name: Build docs with tox
env:
TOX_ENV: docs
- run: |
- tox -e $TOX_ENV
+ run: uvx tox -e $TOX_ENV
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf5fe14..bc4061b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,31 @@
# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Intended Effort Versioning (EffVer)](https://jacobtomlinson.dev/effver/).
+
+## [Unreleased]
+
+### Removed
+
+- Dropped support for Python 3.8 or older. (Requires Python >= 3.9)
+
+### Core
+
+#### Added
+
+- SSL torrents support for secure peer-to-peer connections. See [libtorrent docs](https://libtorrent.org/manual-ref.html#ssl-torrents) for further implementation details.
+
+#### Changed
+
+- Passwords are now stored encrypted with scrypt. A fallback mechanism will still validate existing plaintext passwords in auth files. (#2442)
+
+### GTK UI
+
+#### Fixed
+
+- Fix passwords being ignored in certain dialogs such as Tray Password and Connection Manager.
+
## 2.2.0 (2025-04-28)
### Breaking changes
diff --git a/DEPENDS.md b/DEPENDS.md
index 67736d6..9ed2179 100644
--- a/DEPENDS.md
+++ b/DEPENDS.md
@@ -7,7 +7,7 @@ All modules will require the [common](#common) section dependencies.
## Prerequisite
-- [Python] _>= 3.6_
+- [Python] _>= 3.9_
## Build
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)
diff --git a/requirements-tests.txt b/requirements-tests.txt
index 346a275..9a717fc 100644
--- a/requirements-tests.txt
+++ b/requirements-tests.txt
@@ -2,7 +2,8 @@ libtorrent
pytest
pytest-twisted
pytest-cov
+pytest-rerunfailures
mock
pre-commit
pylint
-asyncmock; python_version <= '3.7'
+cryptography
diff --git a/requirements.txt b/requirements.txt
index 4aee26d..0010ad3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,6 @@ rencode
pyopenssl
pyxdg
pillow
-pillow<=9; python_version=="3.7"
mako
setuptools
chardet
diff --git a/setup.py b/setup.py
index 6161d60..41ebea4 100755
--- a/setup.py
+++ b/setup.py
@@ -587,17 +587,14 @@ setup(
'Environment :: X11 Applications :: GTK',
'Framework :: Twisted',
'Intended Audience :: End Users/Desktop',
- (
- 'License :: OSI Approved :: '
- 'GNU General Public License v3 or later (GPLv3+)'
- ),
+ ('License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)'),
'Programming Language :: Python',
'Operating System :: MacOS :: MacOS X',
'Operating System :: Microsoft :: Windows',
'Operating System :: POSIX',
'Topic :: Internet',
],
- python_requires='>=3.6',
+ python_requires='>=3.9',
license='GPLv3+',
cmdclass=cmdclass,
setup_requires=setup_requires,