From ca1046bce2c8d57c03c7803fab0e14b7c119b2c6 Mon Sep 17 00:00:00 2001 From: Luke Childs Date: Fri, 24 Apr 2020 21:11:40 +0700 Subject: [PATCH] Add --serverfingerprint option (#6094) * Add --fingerprint option * Simplify conditional checks * Improve warning wording * Throw error instead of logging and returning * --fingerprint => --serverfingerprint * Only run fingerprint checks against main server * Throw error if --serverfingerprint is set for a non SSL main server * Fix linting errors * Don't check certificate fingerprint in a seperate connection * Disallow CA signed certs when a fingerprint is provided * Show clear error and then exit for Qt GUI users * Remove leading newlines from error dialog * Always check is_main_server() when getting fingerprint * Document how to generate SSL cert fingerprint --- electrum/commands.py | 2 ++ electrum/gui/qt/main_window.py | 16 +++++++++++++++- electrum/interface.py | 26 +++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 6d8a780f3..03cb5847e 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1268,6 +1268,8 @@ argparse._SubParsersAction.__call__ = subparser_call def add_network_options(parser): + parser.add_argument("-f", "--serverfingerprint", dest="serverfingerprint", default=None, help="only allow connecting to servers with a matching SSL certificate SHA256 fingerprint." + " " + + "To calculate this yourself: '$ openssl x509 -noout -fingerprint -sha256 -inform pem -in mycertfile.crt'. Enter as 64 hex chars.") parser.add_argument("-1", "--oneserver", action="store_true", dest="oneserver", default=None, help="connect to one server only") parser.add_argument("-s", "--server", dest="server", default=None, help="set server host:port:protocol, where protocol is either t (tcp) or s (ssl)") parser.add_argument("-p", "--proxy", dest="proxy", default=None, help="set proxy [type:]host[:port] (or 'none' to disable proxy), where type is socks4,socks5 or http") diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 437ee5be3..60472e2a4 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -183,6 +183,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.checking_accounts = False self.qr_window = None self.pluginsdialog = None + self.showing_cert_mismatch_error = False self.tl_windows = [] Logger.__init__(self) @@ -267,7 +268,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): 'banner', 'verified', 'fee', 'fee_histogram', 'on_quotes', 'on_history', 'channel', 'channels_updated', 'payment_failed', 'payment_succeeded', - 'invoice_status', 'request_status', 'ln_gossip_sync_progress'] + 'invoice_status', 'request_status', 'ln_gossip_sync_progress', + 'cert_mismatch'] # To avoid leaking references to "self" that prevent the # window from being GC-ed when closed, callbacks should be # methods of this class only, and specifically not be @@ -442,6 +444,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.history_model.on_fee_histogram() elif event == 'ln_gossip_sync_progress': self.update_lightning_icon() + elif event == 'cert_mismatch': + self.show_cert_mismatch_error() else: self.logger.info(f"unexpected network event: {event} {args}") @@ -3119,3 +3123,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): "to see it, you need to broadcast it.")) win.msg_box(QPixmap(icon_path("offline_tx.png")), None, _('Success'), msg) return True + + def show_cert_mismatch_error(self): + if self.showing_cert_mismatch_error: + return + self.showing_cert_mismatch_error = True + self.show_critical(title=_("Certificate mismatch"), + msg=_("The SSL certificate provided by the main server did not match the fingerprint passed in with the --serverfingerprint option.") + "\n\n" + + _("Electrum will now exit.")) + self.showing_cert_mismatch_error = False + self.close() diff --git a/electrum/interface.py b/electrum/interface.py index 5600524e5..15e6706e6 100644 --- a/electrum/interface.py +++ b/electrum/interface.py @@ -34,6 +34,7 @@ from collections import defaultdict from ipaddress import IPv4Network, IPv6Network, ip_address, IPv6Address import itertools import logging +import hashlib import aiorpcx from aiorpcx import TaskGroup @@ -190,6 +191,8 @@ class RequestCorrupted(GracefulDisconnect): pass class ErrorParsingSSLCert(Exception): pass class ErrorGettingSSLCertFromServer(Exception): pass +class ErrorSSLCertFingerprintMismatch(Exception): pass +class InvalidOptionCombination(Exception): pass class ConnectError(NetworkException): pass @@ -350,6 +353,8 @@ class Interface(Logger): async def _try_saving_ssl_cert_for_first_time(self, ca_ssl_context): ca_signed = await self.is_server_ca_signed(ca_ssl_context) if ca_signed: + if self.get_expected_fingerprint(): + raise InvalidOptionCombination("cannot use --serverfingerprint with CA signed servers") with open(self.cert_path, 'w') as f: # empty file means this is CA signed, not self-signed f.write('') @@ -362,6 +367,8 @@ class Interface(Logger): with open(self.cert_path, 'r') as f: contents = f.read() if contents == '': # CA signed + if self.get_expected_fingerprint(): + raise InvalidOptionCombination("cannot use --serverfingerprint with CA signed servers") return True # pinned self-signed cert try: @@ -376,11 +383,12 @@ class Interface(Logger): raise ErrorParsingSSLCert(e) from e try: x.check_date() - return True except x509.CertificateError as e: self.logger.info(f"certificate has expired: {e}") os.unlink(self.cert_path) # delete pinned cert only in this case return False + self.verify_certificate_fingerprint(bytearray(b)) + return True async def _get_ssl_context(self): if self.protocol != 's': @@ -468,6 +476,7 @@ class Interface(Logger): dercert = await self.get_certificate() if dercert: self.logger.info("succeeded in getting cert") + self.verify_certificate_fingerprint(dercert) with open(self.cert_path, 'w') as f: cert = ssl.DER_cert_to_PEM_cert(dercert) # workaround android bug @@ -492,6 +501,21 @@ class Interface(Logger): ssl_object = asyncio_transport.get_extra_info("ssl_object") # type: ssl.SSLObject return ssl_object.getpeercert(binary_form=True) + def get_expected_fingerprint(self): + if self.is_main_server(): + return self.network.config.get("serverfingerprint") + + def verify_certificate_fingerprint(self, certificate): + expected_fingerprint = self.get_expected_fingerprint() + if not expected_fingerprint: + return + fingerprint = hashlib.sha256(certificate).hexdigest() + fingerprints_match = fingerprint.lower() == expected_fingerprint.lower() + if not fingerprints_match: + util.trigger_callback('cert_mismatch') + raise ErrorSSLCertFingerprintMismatch('Refusing to connect to server due to cert fingerprint mismatch') + self.logger.info("cert fingerprint verification passed") + async def get_block_header(self, height, assert_mode): self.logger.info(f'requesting block header {height} in mode {assert_mode}') # use lower timeout as we usually have network.bhi_lock here