From 1f626f3ad817bbe2f563611e872cf62d7cca5cbf Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 16 Jan 2025 16:46:07 +0100 Subject: [PATCH] add gossip address field serialization, parsing and tests add space add gossip address field serialization, parsing and tests fix linter consolidate tests, fix intendation refactor test in loops add gossip address field serialization, parsing and tests --- electrum/channel_db.py | 56 ++++++++++++++++++++++++++++++++++++++---- electrum/lnpeer.py | 7 +++++- tests/test_lnmsg.py | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) 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)