diff --git a/electrumx/lib/peer.py b/electrumx/lib/peer.py index bc1391dd5..d07a6538e 100644 --- a/electrumx/lib/peer.py +++ b/electrumx/lib/peer.py @@ -40,7 +40,8 @@ class Peer: 'source', 'ip_addr', 'last_good', 'last_try', 'try_count') FEATURES = ('pruning', 'server_version', 'protocol_min', 'protocol_max', - 'ssl_port', 'tcp_port') + 'ssl_port', 'tcp_port', + 'cert_md5', 'cert_sha1', 'cert_sha256', 'cert_blake2b') # This should be set by the application DEFAULT_PORTS = {} @@ -249,6 +250,22 @@ def tcp_port(self): '''Returns None if no TCP port, otherwise the port as an integer.''' return self._port('tcp_port') + @cachedproperty + def cert_md5(self): + return self._string('cert_md5') + + @cachedproperty + def cert_sha1(self): + return self._string('cert_sha1') + + @cachedproperty + def cert_sha256(self): + return self._string('cert_sha256') + + @cachedproperty + def cert_blake2b(self): + return self._string('cert_blake2b') + @cachedproperty def server_version(self): '''Returns the server version as a string if known, otherwise None.''' @@ -328,6 +345,9 @@ def from_real_name(cls, real_name, source): features['protocol_max'] = features['protocol_min'] = part[1:] elif part[0] == 'p': features['pruning'] = part[1:] + elif part[0] == 'x': + algorithm, digest = part[1:].split('=', 1) + ports['cert_' + algorithm] = digest features.update(ports) features['hosts'] = {host: ports} diff --git a/electrumx/server/peers.py b/electrumx/server/peers.py index dbd53dc36..70692956e 100644 --- a/electrumx/server/peers.py +++ b/electrumx/server/peers.py @@ -8,6 +8,7 @@ '''Peer management.''' import asyncio +import hashlib import random import socket import ssl @@ -282,6 +283,7 @@ async def _should_drop_peer(self, peer): kwargs['local_addr'] = (str(local_hosts.pop()), None) peer_text = f'[{peer}:{port} {kind}]' + try: async with connect_rs(peer.host, port, session_factory=PeerSession, **kwargs) as session: @@ -345,6 +347,24 @@ async def _verify_peer(self, session, peer): if self._is_blacklisted(peer): raise BadPeerError('blacklisted') + ssl_obj = session.transport._asyncio_transport.get_extra_info('ssl_object') + if ssl_obj is not None: + # try to verify fingerprint + der_cert = ssl_obj.getpeercert(True) + certfp_checked = False + for algorithm in ('sha1', 'sha256', 'blake2b'): + peerfp = getattr(peer, 'cert_' + algorithm) + if peerfp is not None: + netfp = getattr(hashlib, algorithm)(der_cert).hexdigest() + if netfp != peerfp: + raise BadPeerError(f'peer {algorithm} thumbprint differs from network', peerfp, netfp) + certfp_checked = True + if not certfp_checked: + self.logger.warn(f'{peer.host} has no cert fingerprint specified. Consider:') + self.logger.warn(f'"{peer.real_name()} xsha256={hashlib.sha256(der_cert).hexdigest()} xblake2b={hashlib.blake2b(der_cert).hexdigest()}"') + else: + self.logger.warn(f'connected to {peer.host} in the clear, no certificate to verify host identity') + # Bucket good recent peers; forbid many servers from similar IPs # FIXME there's a race here, when verifying multiple peers # that belong to the same bucket ~simultaneously