Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 122 additions & 20 deletions electrum/dnssec.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@
import dns.name
import dns.asyncquery
import dns.dnssec
import dns.exception
import dns.message
import dns.asyncresolver
import dns.resolver
import dns.rcode
import dns.rdatatype
import dns.rdtypes.ANY.NS
import dns.rdtypes.ANY.CNAME
Expand All @@ -54,24 +57,102 @@
import dns.rdtypes.IN.AAAA

from .logging import get_logger
from typing import Tuple
from typing import Tuple, Optional, TYPE_CHECKING

if TYPE_CHECKING:
from .network import ProxySettings


_logger = get_logger(__name__)


# hard-coded trust anchors (root KSKs)
trust_anchors = [
TRUST_ANCHORS = [
# KSK-2017:
dns.rrset.from_text('.', 1 , 'IN', 'DNSKEY', '257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU='),
# KSK-2010:
dns.rrset.from_text('.', 15202, 'IN', 'DNSKEY', '257 3 8 AwEAAagAIKlVZrpC6Ia7gEzahOR+9W29euxhJhVVLOyQbSEW0O8gcCjF FVQUTf6v58fLjwBd0YI0EzrAcQqBGCzh/RStIoO8g0NfnfL2MTJRkxoX bfDaUeVPQuYEhg37NZWAJQ9VnMVDxP/VHL496M/QZxkjf5/Efucp2gaD X6RS6CXpoY68LsvPVjR0ZSwzz1apAzvN9dlzEheX7ICJBBtuA6G3LQpz W5hOA2hzCTMjJPJ8LbqF6dsV6DoBQzgul0sGIcGOYl7OyQdXfZ57relS Qageu+ipAdTTJ25AsRTAoub8ONGcLmqrAmRLKBP1dfwhYB4N7knNnulq QxA+Uk1ihz0='),
]


async def _check_query(ns, sub, _type, keys) -> dns.rrset.RRset:
# public fallback nameserver, used only if the system/DHCP resolver cannot be determined
# (e.g. no /etc/resolv.conf, some mobile setups). Google Public DNS.
FALLBACK_NAMESERVER = '8.8.8.8'


class DNSTransport:
"""Delegate that decides how a single DNS query message is sent and answered.

The DNSSEC logic downstream is transport-agnostic: it builds query messages and
hands them to a transport..
"""

async def send(self, q: dns.message.Message) -> dns.message.Message:
raise NotImplementedError


class LocalTransport(DNSTransport):
"""Plain DNS to the system (DHCP-provided) nameserver over UDP, falling back to
TCP on truncation (DNSSEC responses are large and routinely get truncated).
Used when no proxy is configured."""

def __init__(self, nameserver: Optional[str] = None):
self.nameserver = nameserver or _system_nameserver()

async def send(self, q: dns.message.Message) -> dns.message.Message:
response, _used_tcp = await dns.asyncquery.udp_with_fallback(q, self.nameserver, timeout=5)
return response


class DoHTransport(DNSTransport):
"""DNS-over-HTTPS, tunneled through the proxy via Network.async_send_http_on_proxy
(aiohttp + SOCKS, with rdns so the DoH hostname is resolved through the proxy).
Used when a proxy is configured: SOCKS cannot carry UDP, its RESOLVE command cannot
transport TXT records, nor the DNSSEC records we need for verification. We run real
DNS over an HTTPS stream through the proxy instead."""

def __init__(self, doh_endpoint: str):
self.doh_endpoint = doh_endpoint

async def send(self, q: dns.message.Message) -> dns.message.Message:
from .network import Network

async def on_finish(resp):
resp.raise_for_status()
return await resp.read()

raw = await Network.async_send_http_on_proxy(
'post', self.doh_endpoint,
body=q.to_wire(),
headers={
'content-type': 'application/dns-message',
'accept': 'application/dns-message',
},
on_finish=on_finish,
timeout=5,
)
return dns.message.from_wire(raw)


def _system_nameserver() -> str:
# the DHCP/OS-provided resolver(s), as dnspython reads them from the system config
try:
nameservers = dns.resolver.get_default_resolver().nameservers
except Exception as e:
_logger.info(f"could not determine system nameserver, using fallback: {e!r}")
nameservers = None
return nameservers[0] if nameservers else FALLBACK_NAMESERVER


def _make_transport(proxy: Optional['ProxySettings']) -> DNSTransport:
if proxy is not None and proxy.enabled and proxy.doh_endpoint:
return DoHTransport(proxy.doh_endpoint)
return LocalTransport()


async def _check_query(transport: DNSTransport, sub, _type, keys) -> dns.rrset.RRset:
q = dns.message.make_query(sub, _type, want_dnssec=True)
response = await dns.asyncquery.tcp(q, ns, timeout=5)
response = await transport.send(q)
assert response.rcode() == 0, 'No answer'
answer = response.answer
assert len(answer) != 0, ('No DNS record found', sub, _type)
Expand All @@ -88,13 +169,13 @@ async def _check_query(ns, sub, _type, keys) -> dns.rrset.RRset:
return rrset


async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset:
async def _get_and_validate(transport: DNSTransport, url, _type) -> dns.rrset.RRset:
# get trusted root key
root_rrset = None
for dnskey_rr in trust_anchors:
for dnskey_rr in TRUST_ANCHORS:
try:
# Check if there is a valid signature for the root dnskey
root_rrset = await _check_query(ns, '', dns.rdatatype.DNSKEY, {dns.name.root: dnskey_rr})
root_rrset = await _check_query(transport, '', dns.rdatatype.DNSKEY, {dns.name.root: dnskey_rr})
break
except dns.dnssec.ValidationFailure:
# It's OK as long as one key validates
Expand All @@ -109,16 +190,16 @@ async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset:
name = dns.name.from_text(sub)
# If server is authoritative, don't fetch DNSKEY
query = dns.message.make_query(sub, dns.rdatatype.NS)
response = await dns.asyncquery.udp(query, ns, 3)
response = await transport.send(query)
assert response.rcode() == dns.rcode.NOERROR, "query error"
rrset = response.authority[0] if len(response.authority) > 0 else response.answer[0]
rr = rrset[0]
if rr.rdtype == dns.rdatatype.SOA:
continue
# get DNSKEY (self-signed)
rrset = await _check_query(ns, sub, dns.rdatatype.DNSKEY, None)
rrset = await _check_query(transport, sub, dns.rdatatype.DNSKEY, None)
# get DS (signed by parent)
ds_rrset = await _check_query(ns, sub, dns.rdatatype.DS, keys)
ds_rrset = await _check_query(transport, sub, dns.rdatatype.DS, keys)
# verify that a signed DS validates DNSKEY
for ds in ds_rrset:
for dnskey in rrset:
Expand All @@ -134,27 +215,48 @@ async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset:
# set key for next iteration
keys = {name: rrset}
# get TXT record (signed by zone)
rrset = await _check_query(ns, url, _type, keys)
rrset = await _check_query(transport, url, _type, keys)
return rrset


async def query(url: str, rtype: dns.rdatatype.RdataType) -> Tuple[dns.rrset.RRset, bool]:
async def _resolve_unvalidated(transport: DNSTransport, url, rtype) -> dns.rrset.RRset:
"""Plain (non-DNSSEC) resolution, over the same transport so a proxied user does
not leak DNS via the system resolver."""
response = await transport.send(dns.message.make_query(url, rtype))
if response.rcode() != dns.rcode.NOERROR:
raise dns.exception.DNSException(f"query error: rcode={response.rcode()}")
for rrset in response.answer:
if rrset.rdtype == rtype:
return rrset
raise dns.exception.DNSException(f"no {dns.rdatatype.to_text(rtype)} answer for {url!r}")


async def query(
url: str,
rtype: dns.rdatatype.RdataType,
*,
proxy: Optional['ProxySettings'] = None,
) -> Tuple[dns.rrset.RRset, bool]:
"""Try to do DNS resolution, including DNSSEC.
'validated' shows whether the DNSSEC checks passed. DNS is completely INSECURE without DNSSEC,
so the caller must carefully consider whether the response can be used for anything if validated=False.

The transport is chosen from the proxy settings: without a proxy we query the system
(DHCP-provided) nameserver directly over UDP/TCP; with a proxy we run DNS-over-HTTPS
tunneled through it (SOCKS cannot carry UDP, and Tor's RESOLVE extension supports
neither TXT nor DNSSEC). The DoH endpoint is taken from ProxySettings.doh_endpoint.
"""
# FIXME this method is not using the network proxy. (although the proxy might not support UDP?)
# if the proxy is a Tor SOCKS proxy, we could use Tor's "RESOLVE" extension command.
# (see https://spec.torproject.org/socks-extensions.html)
# 8.8.8.8 is Google's public DNS server
nameservers = ['8.8.8.8']
ns = nameservers[0]
if proxy is None:
from .network import Network
network = Network.get_instance()
proxy = network.proxy if network else None
transport = _make_transport(proxy)
try:
out = await _get_and_validate(ns, url, rtype)
out = await _get_and_validate(transport, url, rtype)
validated = True
except Exception as e:
log_level = logging.WARNING if isinstance(e, ImportError) else logging.INFO
_logger.log(log_level, f"DNSSEC error: {repr(e)}")
out = await dns.asyncresolver.resolve(url, rtype)
out = await _resolve_unvalidated(transport, url, rtype)
validated = False
return out, validated
9 changes: 8 additions & 1 deletion electrum/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ def __init__(self):
self.port = ''
self.user = None
self.password = None
self.doh_endpoint = ''

def set_defaults(self):
self.__init__() # call __init__ for default values
Expand Down Expand Up @@ -223,7 +224,8 @@ def to_dict(self):
'host': self.host,
'port': self.port,
'user': self.user,
'password': self.password
'password': self.password,
'doh_endpoint': self.doh_endpoint,
}

@classmethod
Expand All @@ -233,6 +235,7 @@ def from_config(cls, config: 'SimpleConfig') -> 'ProxySettings':
config.NETWORK_PROXY, config.NETWORK_PROXY_USER, config.NETWORK_PROXY_PASSWORD
)
proxy.enabled = config.NETWORK_PROXY_ENABLED
proxy.doh_endpoint = config.NETWORK_PROXY_DOH_ENDPOINT
return proxy

@classmethod
Expand All @@ -244,6 +247,7 @@ def from_dict(cls, d: dict) -> 'ProxySettings':
proxy.port = d.get('port', proxy.port)
proxy.user = d.get('user', proxy.user)
proxy.password = d.get('password', proxy.password)
proxy.doh_endpoint = d.get('doh_endpoint', proxy.doh_endpoint)
return proxy

@classmethod
Expand All @@ -265,6 +269,9 @@ async def detect_task(finished: Callable[[str | None, int | None], None]):
cls.probe_fut = asyncio.run_coroutine_threadsafe(detect_task(on_finished), util.get_asyncio_loop())

def __eq__(self, other):
# note: doh_endpoint is intentionally excluded; this equality gates network
# restarts (see Network._set_proxy / set_parameters), and the DoH endpoint
# only affects DNSSEC lookups, not the network transport/interfaces.
return self.enabled == other.enabled \
and self.mode == other.mode \
and self.host == other.host \
Expand Down
2 changes: 0 additions & 2 deletions electrum/payment_identifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,6 @@ async def _do_resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None
openalias_task = asyncio.create_task(self.resolve_openalias(openalias_key))

# prefers lnurl over openalias if both are available
# FIXME resolving openalias disregards the network proxy and leaks our IP to
# the DNS server. (see dnssec.query)
lnurl = lightning_address_to_url(self.emaillike) if self.emaillike else None
if lnurl is not None and (lnurl_result := await try_resolve_lnurlpay(lnurl)):
openalias_task.cancel()
Expand Down
6 changes: 6 additions & 0 deletions electrum/simple_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,12 @@ def __setattr__(self, name, value):
NETWORK_PROXY_USER = ConfigVar('proxy_user', default=None, type_=str)
NETWORK_PROXY_PASSWORD = ConfigVar('proxy_password', default=None, type_=str)
NETWORK_PROXY_ENABLED = ConfigVar('enable_proxy', default=lambda config: config.NETWORK_PROXY not in [None, "none"], type_=bool)
NETWORK_PROXY_DOH_ENDPOINT = ConfigVar(
'proxy_doh_endpoint', default="https://cloudflare-dns.com/dns-query", type_=str,
short_desc=lambda: _('DNS-over-HTTPS server'),
long_desc=lambda: (
_('DNS-over-HTTPS resolver used for DNSSEC lookups (e.g. OpenAlias) when a proxy is configured.')),
)
NETWORK_SERVER = ConfigVar('server', default=None, type_=str)
NETWORK_NOONION = ConfigVar('noonion', default=False, type_=bool)
NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool)
Expand Down