diff --git a/electrum/channel_db.py b/electrum/channel_db.py index 880815df5..c589e1323 100644 --- a/electrum/channel_db.py +++ b/electrum/channel_db.py @@ -23,6 +23,7 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import ipaddress import time import random import os @@ -41,7 +42,7 @@ from electrum_ecc import ECPubkey from .sql_db import SqlDB, sql from . import constants, util -from .util import profiler, get_headers_dir, is_ip_address, json_normalize, UserFacingException +from .util import profiler, get_headers_dir, is_ip_address, json_normalize, UserFacingException, is_private_netaddress from .logging import Logger from .lntransport import LNPeerAddr from .lnutil import (format_short_channel_id, ShortChannelID, @@ -192,6 +193,48 @@ class NodeInfo(NamedTuple): payload_dict = decode_msg(raw)[1] return NodeInfo.from_msg(payload_dict) + @staticmethod + def to_addresses_field(hostname: str, port: int) -> bytes: + """Encodes a hostname/port pair into a BOLT-7 'addresses' field.""" + if (NodeInfo.invalid_announcement_hostname(hostname) + or port is None or port <= 0 or port > 65535): + return b'' + port_bytes = port.to_bytes(2, 'big') + if is_ip_address(hostname): # ipv4 or ipv6 + ip_addr = ipaddress.ip_address(hostname) + if ip_addr.version == 4: + return b'\x01' + ip_addr.packed + port_bytes + elif ip_addr.version == 6: + return b'\x02' + ip_addr.packed + port_bytes + elif hostname.endswith('.onion'): # Tor onion v3 + onion_addr: bytes = base64.b32decode(hostname[:-6], casefold=True) + return b'\x04' + onion_addr + port_bytes + else: + try: + hostname_ascii: bytes = hostname.encode('ascii') + except UnicodeEncodeError: + # encoding single characters to punycode (according to spec) doesn't make sense + # as you can't differentiate them from regular ascii? encoding the whole string to punycode + # doesn't work either as the receiver would interpret it as regular ascii. + # hostname_ascii: bytes = hostname.encode('punycode') + return b'' + if len(hostname_ascii) + 3 > 258: # + 1 byte for length and 2 for port + return b'' # too long + return b'\x05' + len(hostname_ascii).to_bytes(1, "big") + hostname_ascii + port_bytes + + @staticmethod + def invalid_announcement_hostname(hostname: Optional[str]) -> bool: + """Returns True if hostname unsuited for publishing in a NodeAnnouncement.""" + if (hostname is None or hostname == "" + or is_private_netaddress(hostname) + or hostname.startswith("http://") # not catching 'http' due to onion addresses + or hostname.startswith("https://")): + return True + if hostname.endswith('.onion'): + if len(hostname) != 62: # not an onion v3 link (probably onion v2) + return True + return False + @staticmethod def parse_addresses_field(addresses_field): buf = addresses_field @@ -216,15 +259,18 @@ class NodeInfo(NamedTuple): if is_ip_address(ipv6_addr) and port != 0: addresses.append((ipv6_addr, port)) elif atype == 3: # onion v2 - host = base64.b32encode(read(10)) + b'.onion' - host = host.decode('ascii').lower() - port = int.from_bytes(read(2), 'big') - addresses.append((host, port)) + read(12) # we skip onion v2 as it is deprecated elif atype == 4: # onion v3 host = base64.b32encode(read(35)) + b'.onion' host = host.decode('ascii').lower() port = int.from_bytes(read(2), 'big') addresses.append((host, port)) + elif atype == 5: # dns hostname + len_hostname = int.from_bytes(read(1), 'big') + host = read(len_hostname).decode('ascii') + port = int.from_bytes(read(2), 'big') + if not NodeInfo.invalid_announcement_hostname(host) and port > 0: + addresses.append((host, port)) else: # unknown address type # we don't know how long it is -> have to escape diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 691659c33..fafeadf3a 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1455,6 +1455,7 @@ class Peer(Logger, EventListener): self.maybe_mark_open(chan) def send_node_announcement(self, alias:str): + from .channel_db import NodeInfo timestamp = int(time.time()) node_id = privkey_to_pubkey(self.privkey) features = self.features.for_node_announcement() @@ -1463,7 +1464,11 @@ class Peer(Logger, EventListener): rgb_color = bytes.fromhex('000000') alias = bytes(alias, 'utf8') alias += bytes(32 - len(alias)) - addresses = b'' + addr = self.lnworker.config.LIGHTNING_LISTEN + hostname, port = addr.split(':') + if port is None: # use default port if not specified + port = 9735 + addresses = NodeInfo.to_addresses_field(hostname, int(port)) raw_msg = encode_msg( "node_announcement", flen=flen, diff --git a/tests/test_lnmsg.py b/tests/test_lnmsg.py index d83e7fc0a..b7ca81798 100644 --- a/tests/test_lnmsg.py +++ b/tests/test_lnmsg.py @@ -8,6 +8,7 @@ from electrum.lnmsg import (read_bigsize_int, write_bigsize_int, FieldEncodingNo from electrum.lnonion import OnionRoutingFailure from electrum.util import bfh from electrum.lnutil import ShortChannelID, LnFeatures +from electrum.channel_db import NodeInfo from electrum import constants from . import ElectrumTestCase @@ -370,3 +371,45 @@ class TestLNMsg(ElectrumTestCase): OnionWireSerializer.decode_msg(orf2.to_bytes()) self.assertEqual(None, orf2.decode_data()) + def test_address_parsing_and_serialization(self): + """Tests the NodeInfo bolt7 node_announcement addresses field serialization and parsing""" + taf = NodeInfo.to_addresses_field + paf = NodeInfo.parse_addresses_field + + # -- INVALID INPUTS -- + invalid_inputs_parsing = ( + b'', # empty input + b'\x06\x00', # address type 6 (\x06) is not specified + ) + invalid_inputs_serialization = ( + ("::1", 9735), # local ipv6 + ("::", 9735), # local ipv6 + ("::1", 0), # local host, invalid port + ("::1", 65536), # local host, invalid port + ("127.0.0.1", 9735), # local ipv4 + ("localhost", 9735), # local host + ("domain.com", 0), # domain, invalid port + ("domain.com", 65536), # domain, invalid port + ("domain.com", -1), # domain, invalid port + ("expyuzz4wqqyqhjn.onion", 9735), # onion v2, not supported + ("", 9735), # empty address + ) + for invalid_input in invalid_inputs_parsing: + self.assertEqual(paf(invalid_input), []) + for host, port in invalid_inputs_serialization: + self.assertEqual(taf(host, port), b'') + + # -- VALID INPUTS -- + valid_inputs = ( + ("34.138.100.228", 9735), # ipv4 + ("2001:41d0:0001:b40d:0000:0000:0000:0001", 9735), # ipv6 + ("2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion", 9735), # onion v3 + ("ecb.europa.eu", 8624), # domain + ) + valid_inputs_with_defined_output = [ + [["2001:41d0:1:b40d::1", 9735], [("2001:41d0:0001:b40d:0000:0000:0000:0001", 9735)]] # ipv6 + ] + for host, port in valid_inputs: + self.assertEqual(paf(taf(host, port)), [(host, port)]) + for input_, output in valid_inputs_with_defined_output: + self.assertEqual(paf(taf(*input_)), output)