From 98c6fd7d2dcef75162228e45bd397619cdb49b92 Mon Sep 17 00:00:00 2001 From: Radu Carpa Date: Thu, 2 Nov 2023 18:11:04 +0100 Subject: [PATCH] [Core] Implement SSL peers support This feature is interesting when multiple deluge instances are managed by the same administrator who uses it to transfer private data across a non-secure network. A separate port has to be allocated for incoming SSL connections from peers. Libtorrent already supports this. It's enough to add the suffix 's' when configuring libtorrent's listen_interfaces. Implement a way to activate listening on an SSL port via the configuration. To actually allow SSL connection between peers, one has to also configure a x509 certificate, private_key and diffie-hellman for each affected torrent. This is achieved by calling libtorrent's handle->set_ssl_certificate. Add a new exported method to perform this goal. By default, this method will persist certificates on disk. Allowing them to be re-loaded automatically on restart. Cleanup the certificates of a torrent when it is removed. --- deluge/common.py | 20 +-- deluge/conftest.py | 68 +++++---- deluge/core/core.py | 67 +++++++++ deluge/core/preferencesmanager.py | 23 +++ deluge/core/torrent.py | 50 +++++++ deluge/core/torrentmanager.py | 51 +++++++ deluge/metafile.py | 5 + deluge/tests/test_ssl_torrents.py | 231 ++++++++++++++++++++++++++++++ requirements-tests.txt | 1 + 9 files changed, 483 insertions(+), 33 deletions(-) create mode 100644 deluge/tests/test_ssl_torrents.py diff --git a/deluge/common.py b/deluge/common.py index 256f1f7831..bdbc041811 100644 --- a/deluge/common.py +++ b/deluge/common.py @@ -1231,12 +1231,9 @@ def __lt__(self, other): 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: @@ -1246,22 +1243,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), ] ) @@ -1269,6 +1270,7 @@ def create_localclient_account(append=False): ) _file.flush() os.fsync(_file.fileno()) + return username, password def get_localhost_auth(): diff --git a/deluge/conftest.py b/deluge/conftest.py index 19a0cff57b..8c3bf0df52 100644 --- a/deluge/conftest.py +++ b/deluge/conftest.py @@ -88,7 +88,44 @@ async def client(request, config_dir, monkeypatch, listen_port): @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, logfile=None, custom_script='', config_dir=''): + 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 + daemon.listen_port = listen_port + created_daemons.append(daemon) + return daemon + except CannotListenError as ex: + exception_error = ex + listen_port += 1 + except (KeyboardInterrupt, SystemExit): + raise + else: + break + else: + raise exception_error + + yield _make_daemon + + for d in created_daemons: + await d.kill() + + +@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 +134,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() diff --git a/deluge/core/core.py b/deluge/core/core.py index e2130f595a..e4fc1a6a01 100644 --- a/deluge/core/core.py +++ b/deluge/core/core.py @@ -13,6 +13,7 @@ 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 @@ -674,6 +675,57 @@ def connect_peer(self, torrent_id: str, ip: str, port: int): if not self.torrentmanager[torrent_id].connect_peer(ip, port): 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) @@ -821,6 +873,17 @@ def get_listen_port(self) -> int: """Returns the active listen port""" 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 @@ -999,6 +1062,7 @@ def create_torrent( 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) @@ -1017,6 +1081,7 @@ def create_torrent( trackers=trackers, add_to_session=add_to_session, torrent_format=torrent_format, + ca_cert=ca_cert, ) def _create_torrent_thread( @@ -1032,6 +1097,7 @@ def _create_torrent_thread( trackers, add_to_session, torrent_format, + ca_cert, ): from deluge import metafile @@ -1045,6 +1111,7 @@ def _create_torrent_thread( 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 7e5c207a1f..25981cab31 100644 --- a/deluge/core/preferencesmanager.py +++ b/deluge/core/preferencesmanager.py @@ -48,6 +48,11 @@ '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, @@ -224,6 +229,24 @@ def __set_listen_on(self): 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 57ec26f37a..ccc7b07b28 100644 --- a/deluge/core/torrent.py +++ b/deluge/core/torrent.py @@ -1276,6 +1276,56 @@ def connect_peer(self, peer_ip, peer_port): 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 c43a7a262d..c0c9fe897d 100644 --- a/deluge/core/torrentmanager.py +++ b/deluge/core/torrentmanager.py @@ -14,6 +14,7 @@ import pickle import time from base64 import b64encode +from pathlib import Path from tempfile import gettempdir from typing import Dict, List, NamedTuple, Tuple @@ -209,6 +210,7 @@ def __init__(self): 'torrent_finished', 'torrent_paused', 'torrent_checked', + 'torrent_need_cert', 'torrent_resumed', 'tracker_reply', 'tracker_announce', @@ -766,6 +768,11 @@ def remove(self, torrent_id, remove_data=False, save_state=True): 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): @@ -1339,6 +1346,50 @@ def on_alert_torrent_checked(self, alert): 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/metafile.py b/deluge/metafile.py index 81a371ff39..3d5174b9c4 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/tests/test_ssl_torrents.py b/deluge/tests/test_ssl_torrents.py new file mode 100644 index 0000000000..ebddad0cf3 --- /dev/null +++ b/deluge/tests/test_ssl_torrents.py @@ -0,0 +1,231 @@ +# +# 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 = await daemon_factory(58900, config_dir=config_dir) + client = Client() + await client.connect(port=daemon.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: + all_finished = True + for torrent_id in torrent_ids.values(): + status = await leecher.core.get_torrent_status( + torrent_id=torrent_id, keys=[] + ) + all_finished = all_finished and status['is_finished'] + + 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/requirements-tests.txt b/requirements-tests.txt index a570108adb..ff78a83b2e 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -11,3 +11,4 @@ pep8-naming mccabe pylint asyncmock; python_version <= '3.7' +cryptography