Merge pull request #10546 from f321x/bolt12_preparation_1
bolt12: preparation 1
This commit is contained in:
@@ -193,6 +193,17 @@ def get_blinded_node_id(node_id: bytes, shared_secret: bytes):
|
||||
return blinded_node_id.get_public_key_bytes()
|
||||
|
||||
|
||||
def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes:
|
||||
shared_secret = get_ecdh(privkey, blinding)
|
||||
b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret)
|
||||
b_hmac_int = int.from_bytes(b_hmac, byteorder="big")
|
||||
|
||||
our_privkey_int = int.from_bytes(privkey, byteorder="big")
|
||||
our_privkey_int = our_privkey_int * b_hmac_int % ecc.CURVE_ORDER
|
||||
our_privkey = our_privkey_int.to_bytes(32, byteorder="big")
|
||||
return our_privkey
|
||||
|
||||
|
||||
def new_onion_packet(
|
||||
payment_path_pubkeys: Sequence[bytes],
|
||||
session_key: bytes,
|
||||
|
||||
+226
-120
@@ -31,18 +31,19 @@ import dataclasses
|
||||
from random import random
|
||||
from types import MappingProxyType
|
||||
|
||||
from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, List
|
||||
from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union
|
||||
|
||||
import electrum_ecc as ecc
|
||||
|
||||
from electrum.channel_db import get_mychannel_policy
|
||||
from electrum.lnrouter import PathEdge
|
||||
from electrum.logging import get_logger, Logger
|
||||
from electrum.crypto import sha256, get_ecdh
|
||||
from electrum.lnmsg import OnionWireSerializer
|
||||
from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet,
|
||||
from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, blinding_privkey,
|
||||
OnionHopsDataSingle, decrypt_onionmsg_data_tlv, encrypt_onionmsg_data_tlv,
|
||||
get_shared_secrets_along_route, new_onion_packet, encrypt_hops_recipient_data)
|
||||
from electrum.lnutil import LnFeatures
|
||||
from electrum.lnutil import LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED
|
||||
from electrum.util import OldTaskGroup, log_exceptions
|
||||
|
||||
|
||||
@@ -55,12 +56,14 @@ if TYPE_CHECKING:
|
||||
from electrum.network import Network
|
||||
from electrum.lnrouter import NodeInfo
|
||||
from electrum.lntransport import LNPeerAddr
|
||||
from electrum.lnchannel import Channel
|
||||
from asyncio import Task
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
REQUEST_REPLY_PATHS_MAX = 3
|
||||
PAYMENT_PATHS_MAX = 3
|
||||
|
||||
|
||||
class NoRouteFound(Exception):
|
||||
@@ -75,7 +78,8 @@ def create_blinded_path(
|
||||
final_recipient_data: dict,
|
||||
*,
|
||||
hop_extras: Optional[Sequence[dict]] = None,
|
||||
dummy_hops: Optional[int] = 0
|
||||
dummy_hops: Optional[int] = 0,
|
||||
channels: Optional[Sequence['Channel']] = None,
|
||||
) -> dict:
|
||||
# dummy hops could be inserted anywhere in the path, but for compatibility just add them at the end
|
||||
# because blinded paths are usually constructed towards ourselves, and we know we can handle dummy hops.
|
||||
@@ -93,10 +97,19 @@ def create_blinded_path(
|
||||
is_non_final_node = i < len(path) - 1
|
||||
|
||||
if is_non_final_node:
|
||||
recipient_data = {
|
||||
# TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length
|
||||
'next_node_id': {'node_id': path[i+1]}
|
||||
}
|
||||
# spec: alt: short_channel_id instead of next_node_id
|
||||
if channels: # use short_channel_id for payments
|
||||
scid = channels[i].get_remote_scid_alias() or channels[i].short_channel_id
|
||||
recipient_data = {
|
||||
# TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length
|
||||
'short_channel_id': {'short_channel_id': scid}
|
||||
}
|
||||
else:
|
||||
recipient_data = {
|
||||
# TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length
|
||||
'next_node_id': {'node_id': path[i+1]}
|
||||
}
|
||||
|
||||
if hop_extras and i < len(hop_extras): # extra hop data for debugging for now
|
||||
recipient_data.update(hop_extras[i])
|
||||
else:
|
||||
@@ -122,16 +135,14 @@ def create_blinded_path(
|
||||
return blinded_path
|
||||
|
||||
|
||||
def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes:
|
||||
shared_secret = get_ecdh(privkey, blinding)
|
||||
b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret)
|
||||
b_hmac_int = int.from_bytes(b_hmac, byteorder="big")
|
||||
|
||||
our_privkey_int = int.from_bytes(privkey, byteorder="big")
|
||||
our_privkey_int = our_privkey_int * b_hmac_int % ecc.CURVE_ORDER
|
||||
our_privkey = our_privkey_int.to_bytes(32, byteorder="big")
|
||||
|
||||
return our_privkey
|
||||
def encode_blinded_path(blinded_path: dict):
|
||||
with io.BytesIO() as blinded_path_fd:
|
||||
OnionWireSerializer.write_field(
|
||||
fd=blinded_path_fd,
|
||||
field_type='blinded_path',
|
||||
count=1,
|
||||
value=blinded_path)
|
||||
return blinded_path_fd.getvalue()
|
||||
|
||||
|
||||
def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bool:
|
||||
@@ -159,7 +170,11 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque
|
||||
invoice_amount_msat=10000, # TODO: do this without amount constraints
|
||||
node_filter=lambda x, y: True if x == lnwallet.node_keypair.pubkey else is_onion_message_node(x, y),
|
||||
my_sending_channels=my_sending_channels
|
||||
): return path
|
||||
):
|
||||
# first edge must be to our peer
|
||||
peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node)
|
||||
assert peer, 'first hop not a peer'
|
||||
return path
|
||||
|
||||
# alt: dest is existing peer?
|
||||
if lnwallet.lnpeermgr.get_peer_by_pubkey(node_id):
|
||||
@@ -173,6 +188,74 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque
|
||||
raise NoRouteFound('no path found')
|
||||
|
||||
|
||||
def create_route_to_introduction_point(
|
||||
lnwallet: 'LNWallet',
|
||||
blinded_path: dict,
|
||||
introduction_point: bytes,
|
||||
session_key: bytes
|
||||
):
|
||||
hops_data = []
|
||||
blinded_node_ids = []
|
||||
|
||||
peer = lnwallet.lnpeermgr.get_peer_by_pubkey(introduction_point)
|
||||
# if blinded path introduction point is our direct peer, no need to route-find
|
||||
if peer:
|
||||
# start of blinded path is our peer
|
||||
path_key = blinded_path['first_path_key']
|
||||
return peer, path_key, hops_data, blinded_node_ids
|
||||
|
||||
path = create_onion_message_route_to(lnwallet, introduction_point)
|
||||
|
||||
# last edge is to introduction point and start of blinded path. remove from route
|
||||
assert path[-1].end_node == introduction_point, 'last hop in route must be introduction point'
|
||||
assert len(path) > 1, "if we are directly connected to the IP, why didn't we return the peer above?"
|
||||
|
||||
peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node)
|
||||
assert peer, "first hop is not a peer"
|
||||
|
||||
# rm last hop (ip-1 -> ip) as it is added explicitly from the blinded path we got (final_hop_pre_ip)
|
||||
path = path[:-1]
|
||||
|
||||
# we construct a route to the introduction point
|
||||
payment_path_pubkeys = [edge.end_node for edge in path]
|
||||
hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(
|
||||
payment_path_pubkeys,
|
||||
session_key)
|
||||
|
||||
# exclude first hop (us to first node on path): we don't need to a layer for ourselves
|
||||
for edge in path[1:]:
|
||||
hop = OnionHopsDataSingle(
|
||||
tlv_stream_name='onionmsg_tlv',
|
||||
blind_fields={'next_node_id': {'node_id': edge.end_node}},
|
||||
)
|
||||
hops_data.append(hop)
|
||||
|
||||
# final hop pre-ip, add next_path_key_override
|
||||
final_hop_pre_ip = OnionHopsDataSingle(
|
||||
tlv_stream_name='onionmsg_tlv',
|
||||
blind_fields={
|
||||
'next_node_id': {'node_id': introduction_point},
|
||||
'next_path_key_override': {'path_key': blinded_path['first_path_key']},
|
||||
},
|
||||
)
|
||||
hops_data.append(final_hop_pre_ip)
|
||||
|
||||
# encrypt encrypted_data_tlv here
|
||||
for i, hop in enumerate(hops_data):
|
||||
encrypted_recipient_data = encrypt_onionmsg_data_tlv(
|
||||
shared_secret=hop_shared_secrets[i],
|
||||
**hop.blind_fields)
|
||||
payload = dict(hop.payload)
|
||||
payload['encrypted_recipient_data'] = {
|
||||
'encrypted_recipient_data': encrypted_recipient_data
|
||||
}
|
||||
hops_data[i] = dataclasses.replace(hop, payload=payload)
|
||||
|
||||
path_key = ecc.ECPrivkey(session_key).get_public_key_bytes()
|
||||
|
||||
return peer, path_key, hops_data, blinded_node_ids
|
||||
|
||||
|
||||
def send_onion_message_to(
|
||||
lnwallet: 'LNWallet',
|
||||
node_id_or_blinded_path: bytes,
|
||||
@@ -202,10 +285,10 @@ def send_onion_message_to(
|
||||
# https://github.com/lightning/bolts/blob/master/04-onion-routing.md
|
||||
# "MUST set first_node_id to N0"
|
||||
|
||||
hops_data = []
|
||||
blinded_node_ids = []
|
||||
|
||||
if lnwallet.node_keypair.pubkey == introduction_point:
|
||||
hops_data = []
|
||||
blinded_node_ids = []
|
||||
|
||||
# blinded path introduction point is me
|
||||
our_blinding = blinded_path['first_path_key']
|
||||
our_payload = blinded_path['path'][0]
|
||||
@@ -237,65 +320,13 @@ def send_onion_message_to(
|
||||
|
||||
else:
|
||||
# we need a route to introduction point
|
||||
r = create_route_to_introduction_point(lnwallet, blinded_path, introduction_point, session_key)
|
||||
peer, path_key, hops_data, blinded_node_ids = r
|
||||
|
||||
remaining_blinded_path = blinded_path['path']
|
||||
if not isinstance(remaining_blinded_path, list): # doesn't return list when num items == 1
|
||||
remaining_blinded_path = [remaining_blinded_path]
|
||||
|
||||
peer = lnwallet.lnpeermgr.get_peer_by_pubkey(introduction_point)
|
||||
# if blinded path introduction point is our direct peer, no need to route-find
|
||||
if peer:
|
||||
# start of blinded path is our peer
|
||||
path_key = blinded_path['first_path_key']
|
||||
else:
|
||||
path = create_onion_message_route_to(lnwallet, introduction_point)
|
||||
|
||||
# first edge must be to our peer
|
||||
peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node)
|
||||
assert peer, 'first hop not a peer'
|
||||
|
||||
# last edge is to introduction point and start of blinded path. remove from route
|
||||
assert path[-1].end_node == introduction_point, 'last hop in route must be introduction point'
|
||||
|
||||
path = path[:-1]
|
||||
|
||||
if len(path) == 0:
|
||||
path_key = blinded_path['first_path_key']
|
||||
else:
|
||||
payment_path_pubkeys = [edge.end_node for edge in path]
|
||||
hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(
|
||||
payment_path_pubkeys,
|
||||
session_key)
|
||||
|
||||
hops_data = [
|
||||
OnionHopsDataSingle(
|
||||
tlv_stream_name='onionmsg_tlv',
|
||||
blind_fields={'next_node_id': {'node_id': x.end_node}},
|
||||
) for x in path[:-1]
|
||||
]
|
||||
|
||||
# final hop pre-ip, add next_path_key_override
|
||||
final_hop_pre_ip = OnionHopsDataSingle(
|
||||
tlv_stream_name='onionmsg_tlv',
|
||||
blind_fields={
|
||||
'next_node_id': {'node_id': introduction_point},
|
||||
'next_path_key_override': {'path_key': blinded_path['first_path_key']},
|
||||
},
|
||||
)
|
||||
hops_data.append(final_hop_pre_ip)
|
||||
|
||||
# encrypt encrypted_data_tlv here
|
||||
for i in range(len(hops_data)):
|
||||
encrypted_recipient_data = encrypt_onionmsg_data_tlv(
|
||||
shared_secret=hop_shared_secrets[i],
|
||||
**hops_data[i].blind_fields)
|
||||
payload = dict(hops_data[i].payload)
|
||||
payload['encrypted_recipient_data'] = {
|
||||
'encrypted_recipient_data': encrypted_recipient_data
|
||||
}
|
||||
hops_data[i] = dataclasses.replace(hops_data[i], payload=payload)
|
||||
|
||||
path_key = ecc.ECPrivkey(session_key).get_public_key_bytes()
|
||||
|
||||
# append (remaining) blinded path and payload
|
||||
blinded_path_blinded_ids = []
|
||||
for i, onionmsg_hop in enumerate(remaining_blinded_path):
|
||||
@@ -369,37 +400,107 @@ def get_blinded_reply_paths(
|
||||
path_id: bytes,
|
||||
*,
|
||||
max_paths: int = REQUEST_REPLY_PATHS_MAX,
|
||||
preferred_node_id: bytes = None
|
||||
) -> Sequence[dict]:
|
||||
"""construct a list of blinded reply_paths.
|
||||
"""construct a list of blinded reply-paths for onion message.
|
||||
"""
|
||||
mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path
|
||||
paths, payinfo = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, onion_message=True)
|
||||
return paths
|
||||
|
||||
|
||||
def get_blinded_paths_to_me(
|
||||
lnwallet: 'LNWallet',
|
||||
final_recipient_data: dict,
|
||||
*,
|
||||
max_paths: int = PAYMENT_PATHS_MAX,
|
||||
my_channels: Optional[Sequence['Channel']] = None,
|
||||
onion_message: bool = False
|
||||
) -> Tuple[Sequence[dict], Sequence[dict]]:
|
||||
"""construct a list of blinded paths.
|
||||
current logic:
|
||||
- uses current onion_message capable channel peers if exist
|
||||
- otherwise, uses current onion_message capable peers
|
||||
- prefers preferred_node_id if given
|
||||
- reply_path introduction points are direct peers only (TODO: longer reply paths)"""
|
||||
- uses channels peers if not onion_message
|
||||
- uses current onion_message capable channel peers if exist and if onion_message
|
||||
- otherwise, uses current onion_message capable peers if onion_message
|
||||
- reply_path introduction points are direct peers only (TODO: longer paths)"""
|
||||
# TODO: build longer paths and/or add dummy hops to increase privacy
|
||||
my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()]
|
||||
my_onionmsg_channels = [chan for chan in my_active_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and
|
||||
lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)]
|
||||
my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)]
|
||||
if not my_channels:
|
||||
my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()]
|
||||
my_channels = my_active_channels
|
||||
|
||||
if onion_message:
|
||||
my_channels = [chan for chan in my_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and
|
||||
lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)]
|
||||
|
||||
result = []
|
||||
payinfo = []
|
||||
mynodeid = lnwallet.node_keypair.pubkey
|
||||
mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path
|
||||
if len(my_onionmsg_channels):
|
||||
# randomize list, but prefer preferred_node_id
|
||||
rchans = sorted(my_onionmsg_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0)
|
||||
for chan in rchans[:max_paths]:
|
||||
blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], mydata)
|
||||
result.append(blinded_path)
|
||||
elif len(my_onionmsg_peers):
|
||||
# randomize list, but prefer preferred_node_id
|
||||
rpeers = sorted(my_onionmsg_peers, key=lambda x: random() if x.pubkey != preferred_node_id else 0)
|
||||
for peer in rpeers[:max_paths]:
|
||||
blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], mydata)
|
||||
result.append(blinded_path)
|
||||
local_height = lnwallet.network.get_local_height()
|
||||
|
||||
return result
|
||||
if len(my_channels):
|
||||
# randomize list
|
||||
rchans = sorted(my_channels, key=lambda x: random())
|
||||
for chan in rchans[:max_paths]:
|
||||
hop_extras = None
|
||||
if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer)
|
||||
# get policy
|
||||
cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan})
|
||||
|
||||
dest_max_cltv_expiry = local_height + MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED
|
||||
|
||||
# TODO: for longer paths (>2), reverse traverse and calculate max_cltv_expiry at each intermediate hop
|
||||
# and determine the cltv delta sums and fee sums of the hops for the payinfo struct.
|
||||
# current assumption is len(blinded_path) == 2 (us and peer)
|
||||
sum_cltv_expiry_delta = cp.cltv_delta
|
||||
sum_fee_base_msat = cp.fee_base_msat
|
||||
sum_fee_proportional_millionths = cp.fee_proportional_millionths
|
||||
# path htlc limits
|
||||
blinded_path_min_htlc_msat = cp.htlc_minimum_msat
|
||||
blinded_path_max_htlc_msat = cp.htlc_maximum_msat
|
||||
|
||||
hop_extras = [{
|
||||
# spec: MUST include encrypted_data_tlv.payment_relay for each non-final node.
|
||||
'payment_relay': {
|
||||
'cltv_expiry_delta': cp.cltv_delta,
|
||||
'fee_base_msat': cp.fee_base_msat,
|
||||
'fee_proportional_millionths': cp.fee_proportional_millionths,
|
||||
},
|
||||
# spec: MUST set encrypted_data_tlv.payment_constraints for each non-final node and MAY set it for the final node:
|
||||
#
|
||||
# max_cltv_expiry to the largest block height at which the route is allowed to be used, starting
|
||||
# from the final node's chosen max_cltv_expiry height at which the route should expire, adding
|
||||
# the final node's min_final_cltv_expiry_delta and then adding
|
||||
# encrypted_data_tlv.payment_relay.cltv_expiry_delta at each hop.
|
||||
#
|
||||
# htlc_minimum_msat to the largest minimum HTLC value the nodes will allow.
|
||||
'payment_constraints': {
|
||||
'max_cltv_expiry': dest_max_cltv_expiry + cp.cltv_delta,
|
||||
'htlc_minimum_msat': blinded_path_min_htlc_msat
|
||||
}
|
||||
}]
|
||||
payinfo.append({
|
||||
'fee_base_msat': sum_fee_base_msat,
|
||||
'fee_proportional_millionths': sum_fee_proportional_millionths,
|
||||
'cltv_expiry_delta': sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED,
|
||||
'htlc_minimum_msat': blinded_path_min_htlc_msat,
|
||||
'htlc_maximum_msat': blinded_path_max_htlc_msat,
|
||||
'flen': 0,
|
||||
'features': bytes(0)
|
||||
})
|
||||
blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], final_recipient_data,
|
||||
hop_extras=hop_extras, channels=[chan] if not onion_message else None)
|
||||
result.append(blinded_path)
|
||||
elif onion_message:
|
||||
# we can use peers even without channels for onion messages
|
||||
my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if
|
||||
peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)]
|
||||
if len(my_onionmsg_peers):
|
||||
# randomize list
|
||||
rpeers = sorted(my_onionmsg_peers, key=lambda x: random())
|
||||
for peer in rpeers[:max_paths]:
|
||||
blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data)
|
||||
result.append(blinded_path)
|
||||
|
||||
return result, payinfo
|
||||
|
||||
|
||||
class Timeout(Exception): pass
|
||||
@@ -415,8 +516,7 @@ class OnionMessageManager(Logger):
|
||||
- forwards are best-effort. They should not need retrying, but a queue is used to limit the pacing of forwarding,
|
||||
and limiting the number of outstanding forwards. Any onion message forwards arriving when the forward queue
|
||||
is full will be dropped.
|
||||
|
||||
TODO: iterate through routes for each request"""
|
||||
"""
|
||||
|
||||
SLEEP_DELAY = 1
|
||||
REQUEST_REPLY_TIMEOUT = 30
|
||||
@@ -425,10 +525,12 @@ class OnionMessageManager(Logger):
|
||||
FORWARD_RETRY_DELAY = 2
|
||||
FORWARD_MAX_QUEUE = 3
|
||||
|
||||
class Request(NamedTuple):
|
||||
future: asyncio.Future
|
||||
payload: dict
|
||||
node_id_or_blinded_path: bytes
|
||||
class Request:
|
||||
def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[bytes]]):
|
||||
self.future = asyncio.Future()
|
||||
self.payload = payload
|
||||
self.node_id_or_blinded_paths = node_id_or_blinded_paths
|
||||
self.current_index: int = 0
|
||||
|
||||
def __init__(self, lnwallet: 'LNWallet'):
|
||||
Logger.__init__(self)
|
||||
@@ -525,7 +627,7 @@ class OnionMessageManager(Logger):
|
||||
try:
|
||||
self._send_pending_message(key)
|
||||
except BaseException as e:
|
||||
self.logger.debug(f'error while sending {key=} {e!r}')
|
||||
self.logger.debug(f'error while sending {key=}: ', exc_info=True)
|
||||
req.future.set_exception(copy.copy(e))
|
||||
# NOTE: above, when passing the caught exception instance e directly it leads to GeneratorExit() in
|
||||
if isinstance(e, NoRouteFound) and e.peer_address:
|
||||
@@ -542,8 +644,8 @@ class OnionMessageManager(Logger):
|
||||
def submit_send(
|
||||
self, *,
|
||||
payload: dict,
|
||||
node_id_or_blinded_path: bytes,
|
||||
key: bytes = None) -> 'Task':
|
||||
node_id_or_blinded_paths: Union[bytes, Sequence[bytes]],
|
||||
key: Optional[bytes] = None) -> 'Task':
|
||||
"""Add onion message to queue for sending. Queued onion message payloads
|
||||
are supplied with a path_id and a reply_path to determine which request
|
||||
corresponds with arriving replies.
|
||||
@@ -555,13 +657,9 @@ class OnionMessageManager(Logger):
|
||||
key = os.urandom(8)
|
||||
assert type(key) is bytes and len(key) >= 8
|
||||
|
||||
self.logger.debug(f'submit_send {key=} {payload=} {node_id_or_blinded_path=}')
|
||||
self.logger.debug(f'submit_send {key=} {payload=} {node_id_or_blinded_paths=}')
|
||||
|
||||
req = OnionMessageManager.Request(
|
||||
future=asyncio.Future(),
|
||||
payload=payload,
|
||||
node_id_or_blinded_path=node_id_or_blinded_path
|
||||
)
|
||||
req = OnionMessageManager.Request(payload=payload, node_id_or_blinded_paths=node_id_or_blinded_paths)
|
||||
with self.pending_lock:
|
||||
if key in self.pending:
|
||||
raise Exception(f'{key=} already exists!')
|
||||
@@ -584,8 +682,15 @@ class OnionMessageManager(Logger):
|
||||
"""adds reply_path to payload"""
|
||||
req = self.pending.get(key)
|
||||
payload = req.payload
|
||||
node_id_or_blinded_path = req.node_id_or_blinded_path
|
||||
self.logger.debug(f'send_pending_message {key=} {payload=} {node_id_or_blinded_path=}')
|
||||
|
||||
# get next path (round robin)
|
||||
dests = req.node_id_or_blinded_paths
|
||||
if isinstance(req.node_id_or_blinded_paths, bytes):
|
||||
dests = [req.node_id_or_blinded_paths]
|
||||
dest = dests[req.current_index]
|
||||
req.current_index = (req.current_index + 1) % len(dests)
|
||||
|
||||
self.logger.debug(f'send_pending_message {key=} {payload=} {dest=}')
|
||||
|
||||
final_payload = copy.deepcopy(payload)
|
||||
|
||||
@@ -598,9 +703,10 @@ class OnionMessageManager(Logger):
|
||||
|
||||
final_payload['reply_path'] = {'path': reply_paths}
|
||||
|
||||
# TODO: we should try alternate paths when retrying, this is currently not done.
|
||||
# NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route)
|
||||
# when retrying, this is currently not done.
|
||||
# (send_onion_message_to decides path, without knowledge of prev attempts)
|
||||
send_onion_message_to(self.lnwallet, node_id_or_blinded_path, final_payload)
|
||||
send_onion_message_to(self.lnwallet, dest, final_payload)
|
||||
|
||||
def _path_id_from_payload_and_key(self, payload: dict, key: bytes) -> bytes:
|
||||
# TODO: use payload to determine prefix?
|
||||
|
||||
+11
-6
@@ -43,6 +43,9 @@ class DecodedBech32(NamedTuple):
|
||||
data: Optional[Sequence[int]] # 5-bit ints
|
||||
|
||||
|
||||
INVALID_BECH32 = DecodedBech32(None, None, None)
|
||||
|
||||
|
||||
def bech32_polymod(values):
|
||||
"""Internal function that computes the Bech32 checksum."""
|
||||
generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
@@ -85,26 +88,28 @@ def bech32_encode(encoding: Encoding, hrp: str, data: List[int]) -> str:
|
||||
return hrp + '1' + ''.join([CHARSET[d] for d in combined])
|
||||
|
||||
|
||||
def bech32_decode(bech: str, *, ignore_long_length=False) -> DecodedBech32:
|
||||
def bech32_decode(bech: str, *, ignore_long_length=False, with_checksum=True) -> DecodedBech32:
|
||||
"""Validate a Bech32/Bech32m string, and determine HRP and data."""
|
||||
bech_lower = bech.lower()
|
||||
if bech_lower != bech and bech.upper() != bech:
|
||||
return DecodedBech32(None, None, None)
|
||||
return INVALID_BECH32
|
||||
pos = bech.rfind('1')
|
||||
if pos < 1 or pos + 7 > len(bech) or (not ignore_long_length and len(bech) > 90):
|
||||
return DecodedBech32(None, None, None)
|
||||
return INVALID_BECH32
|
||||
# check that HRP only consists of sane ASCII chars
|
||||
if any(ord(x) < 33 or ord(x) > 126 for x in bech[:pos+1]):
|
||||
return DecodedBech32(None, None, None)
|
||||
return INVALID_BECH32
|
||||
bech = bech_lower
|
||||
hrp = bech[:pos]
|
||||
try:
|
||||
data = [CHARSET_INVERSE[x] for x in bech[pos + 1:]]
|
||||
except KeyError:
|
||||
return DecodedBech32(None, None, None)
|
||||
return INVALID_BECH32
|
||||
if not with_checksum:
|
||||
return DecodedBech32(encoding=None, hrp=hrp, data=data)
|
||||
encoding = bech32_verify_checksum(hrp, data)
|
||||
if encoding is None:
|
||||
return DecodedBech32(None, None, None)
|
||||
return INVALID_BECH32
|
||||
return DecodedBech32(encoding=encoding, hrp=hrp, data=data[:-6])
|
||||
|
||||
|
||||
|
||||
@@ -660,6 +660,13 @@ class Test_bitcoin(ElectrumTestCase):
|
||||
self.assertEqual(DecodedBech32(None, None, None),
|
||||
segwit_addr.bech32_decode('1p2gdwpf'))
|
||||
|
||||
# without checksum
|
||||
bolt12_str = 'lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg'
|
||||
self.assertEqual(DecodedBech32(None, None, None),
|
||||
segwit_addr.bech32_decode(bolt12_str, with_checksum=True, ignore_long_length=True))
|
||||
self.assertEqual(DecodedBech32(None, 'lno', [1, 0, 1, 16, 30, 16, 18, 0, 1, 8, 11, 4, 2, 27, 17, 0, 12, 21, 28, 6, 2, 27, 11, 16, 13, 17, 18, 18, 0, 25, 3, 5, 14, 13, 17, 23, 4, 26, 11, 16, 14, 17, 20, 22, 30, 27, 16, 18, 2, 9, 1, 4, 30, 19, 2, 20, 4, 0, 24, 19, 4, 8, 3, 9, 13, 25, 18, 7, 10, 28, 27, 20, 14, 9, 20, 22, 10, 28, 24, 22, 4, 4, 1, 14, 29, 17, 25, 4, 11, 21, 21, 23, 26, 11, 6, 11, 6, 0, 28, 0, 23, 30, 31, 2, 20, 13, 18, 8, 25, 21, 29, 9, 8, 9, 18, 19, 30, 22, 21, 3, 8, 3, 22, 28, 29, 8, 15, 18, 16, 13, 20, 6, 12, 6, 8]),
|
||||
segwit_addr.bech32_decode(bolt12_str, with_checksum=False, ignore_long_length=True))
|
||||
|
||||
|
||||
class Test_xprv_xpub(ElectrumTestCase):
|
||||
|
||||
|
||||
@@ -377,6 +377,7 @@ class SuccessfulTest(Exception): pass
|
||||
|
||||
|
||||
def inject_chan_into_gossipdb(*, channel_db: ChannelDB, graph: Graph, node1name: str, node2name: str) -> None:
|
||||
print(f"injecting channel {node1name} -> {node2name} into channel_db")
|
||||
chan_ann_raw = graph.channels[(node1name, node2name)].construct_channel_announcement_without_sigs()[0]
|
||||
chan_ann_dict = decode_msg(chan_ann_raw)[1]
|
||||
channel_db.add_channel_announcements(chan_ann_dict, trusted=True)
|
||||
|
||||
+144
-10
@@ -5,7 +5,8 @@ import time
|
||||
import dataclasses
|
||||
import logging
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import patch
|
||||
from aiorpcx import NetAddress
|
||||
|
||||
import electrum_ecc as ecc
|
||||
from electrum_ecc import ECPrivkey
|
||||
@@ -15,16 +16,19 @@ from electrum.lnmsg import decode_msg, OnionWireSerializer
|
||||
from electrum.lnonion import (
|
||||
OnionHopsDataSingle, OnionPacket, process_onion_packet, get_bolt04_onion_key, encrypt_onionmsg_data_tlv,
|
||||
get_shared_secrets_along_route, new_onion_packet, ONION_MESSAGE_LARGE_SIZE, HOPS_DATA_SIZE, InvalidPayloadSize,
|
||||
encrypt_hops_recipient_data)
|
||||
encrypt_hops_recipient_data, blinding_privkey, decrypt_onionmsg_data_tlv)
|
||||
from electrum.crypto import get_ecdh, privkey_to_pubkey
|
||||
from electrum.lnutil import LnFeatures, Keypair
|
||||
from electrum.lntransport import LNPeerAddr
|
||||
from electrum.lnutil import LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE
|
||||
from electrum.onion_message import (
|
||||
blinding_privkey, create_blinded_path,OnionMessageManager, NoRouteFound, Timeout
|
||||
create_blinded_path, OnionMessageManager, NoRouteFound, Timeout,
|
||||
create_route_to_introduction_point, get_blinded_paths_to_me
|
||||
)
|
||||
from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop
|
||||
from electrum.logging import console_stderr_handler
|
||||
|
||||
from . import ElectrumTestCase
|
||||
from .test_lnpeer import TestPeer, inject_chan_into_gossipdb
|
||||
|
||||
|
||||
TIME_STEP = 0.01 # run tests 100 x faster
|
||||
@@ -310,11 +314,12 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
self.carol = keypair(ECPrivkey(privkey_bytes=b'\x43'*32))
|
||||
self.dave = keypair(ECPrivkey(privkey_bytes=b'\x44'*32))
|
||||
self.eve = keypair(ECPrivkey(privkey_bytes=b'\x45'*32))
|
||||
self.fred = keypair(ECPrivkey(privkey_bytes=b'\x46'*32))
|
||||
|
||||
async def run_test1(self, t):
|
||||
t1 = t.submit_send(
|
||||
payload={'message': {'text': 'alice_timeout'.encode('utf-8')}},
|
||||
node_id_or_blinded_path=self.alice.pubkey)
|
||||
node_id_or_blinded_paths=self.alice.pubkey)
|
||||
|
||||
with self.assertRaises(Timeout):
|
||||
await t1
|
||||
@@ -322,7 +327,7 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
async def run_test2(self, t):
|
||||
t2 = t.submit_send(
|
||||
payload={'message': {'text': 'bob_slow_timeout'.encode('utf-8')}},
|
||||
node_id_or_blinded_path=self.bob.pubkey)
|
||||
node_id_or_blinded_paths=self.bob.pubkey)
|
||||
|
||||
with self.assertRaises(Timeout):
|
||||
await t2
|
||||
@@ -330,7 +335,7 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
async def run_test3(self, t, rkey):
|
||||
t3 = t.submit_send(
|
||||
payload={'message': {'text': 'carol_with_immediate_reply'.encode('utf-8')}},
|
||||
node_id_or_blinded_path=self.carol.pubkey,
|
||||
node_id_or_blinded_paths=self.carol.pubkey,
|
||||
key=rkey)
|
||||
|
||||
t3_result = await t3
|
||||
@@ -339,7 +344,7 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
async def run_test4(self, t, rkey):
|
||||
t4 = t.submit_send(
|
||||
payload={'message': {'text': 'dave_with_slow_reply'.encode('utf-8')}},
|
||||
node_id_or_blinded_path=self.dave.pubkey,
|
||||
node_id_or_blinded_paths=self.dave.pubkey,
|
||||
key=rkey)
|
||||
|
||||
t4_result = await t4
|
||||
@@ -348,15 +353,35 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
async def run_test5(self, t):
|
||||
t5 = t.submit_send(
|
||||
payload={'message': {'text': 'no_peer'.encode('utf-8')}},
|
||||
node_id_or_blinded_path=self.eve.pubkey)
|
||||
node_id_or_blinded_paths=self.eve.pubkey)
|
||||
|
||||
with self.assertRaises(NoRouteFound):
|
||||
# will not find route to eve, but has eve's address, but we are configured to not direct connect
|
||||
with self.assertRaises(NoRouteFound) as c:
|
||||
await t5
|
||||
self.assertEqual(c.exception.peer_address, LNPeerAddr('localhost', 1234, self.eve.pubkey))
|
||||
|
||||
async def run_test6(self, t, rkey):
|
||||
# bob will not reply, fred will
|
||||
t6 = t.submit_send(
|
||||
payload={'message': {'text': 'send_dest_roundrobin'.encode('utf-8')}},
|
||||
node_id_or_blinded_paths=[self.bob.pubkey, self.fred.pubkey],
|
||||
key=rkey
|
||||
)
|
||||
|
||||
t6_result = await t6
|
||||
self.assertEqual(t6_result, ({'path_id': {'data': b'electrum' + rkey}}, {}))
|
||||
|
||||
async def test_request_and_reply(self):
|
||||
n = MockNetwork()
|
||||
lnw = self.create_mock_lnwallet(name='test_request_and_reply', has_anchors=False)
|
||||
|
||||
# mock add_peer for direct connection fallback
|
||||
async def mock__add_peer(host, port, node_id):
|
||||
mock_peer = MockPeer(pubkey=node_id)
|
||||
# lnw.lnpeermgr._peers[node_id] = mock_peer
|
||||
return mock_peer
|
||||
lnw.lnpeermgr._add_peer = mock__add_peer
|
||||
|
||||
def slow(*args, **kwargs):
|
||||
time.sleep(2*TIME_STEP)
|
||||
|
||||
@@ -369,11 +394,14 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
|
||||
rkey1 = bfh('0102030405060708')
|
||||
rkey2 = bfh('0102030405060709')
|
||||
rkey3 = bfh('010203040506070a')
|
||||
|
||||
lnw.lnpeermgr._peers[self.alice.pubkey] = MockPeer(self.alice.pubkey)
|
||||
lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow)
|
||||
lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1))
|
||||
lnw.lnpeermgr._peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2))
|
||||
lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())}
|
||||
lnw.lnpeermgr._peers[self.fred.pubkey] = MockPeer(self.fred.pubkey, on_send_message=partial(withreply, rkey3))
|
||||
t = OnionMessageManager(lnw)
|
||||
t.start_network(network=n)
|
||||
|
||||
@@ -385,6 +413,7 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
await self.run_test3(t, rkey1)
|
||||
await self.run_test4(t, rkey2)
|
||||
await self.run_test5(t)
|
||||
await self.run_test6(t, rkey3)
|
||||
self.logger.debug('tests in parallel')
|
||||
async with OldTaskGroup() as group:
|
||||
await group.spawn(self.run_test1(t))
|
||||
@@ -392,6 +421,7 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
await group.spawn(self.run_test3(t, rkey1))
|
||||
await group.spawn(self.run_test4(t, rkey2))
|
||||
await group.spawn(self.run_test5(t))
|
||||
await group.spawn(self.run_test6(t, rkey3))
|
||||
finally:
|
||||
await asyncio.sleep(TIME_STEP)
|
||||
|
||||
@@ -461,3 +491,107 @@ class TestOnionMessageManager(ElectrumTestCase):
|
||||
self.logger.debug('stopping manager')
|
||||
await t.stop()
|
||||
await lnw.stop()
|
||||
|
||||
|
||||
class TestOnionMessageUtils(TestPeer):
|
||||
|
||||
async def test_get_blinded_paths_to_me_payment(self):
|
||||
# A <- B (alice generates blinded path from bob to herself)
|
||||
graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan'])
|
||||
alice, bob = graph.workers.values()
|
||||
|
||||
# store bobs channel_update in alice
|
||||
alice_chan = graph.channels[('alice', 'bob')]
|
||||
bob_chan = graph.channels[('bob', 'alice')]
|
||||
bob_update_raw = bob_chan.get_outgoing_gossip_channel_update()
|
||||
bob_update = decode_msg(bob_update_raw)[1]
|
||||
bob_update['raw'] = bob_update_raw
|
||||
alice_chan.set_remote_update(bob_update)
|
||||
|
||||
final_recipient_data = {'path_id': {'data': os.urandom(32)}}
|
||||
paths, payinfos = get_blinded_paths_to_me(alice, final_recipient_data, onion_message=False)
|
||||
|
||||
self.assertEqual(len(paths), 1)
|
||||
self.assertEqual(len(payinfos), 1)
|
||||
|
||||
self.assertEqual(payinfos[0], {
|
||||
'fee_base_msat': bob_chan.forwarding_fee_base_msat,
|
||||
'fee_proportional_millionths': bob_chan.forwarding_fee_proportional_millionths,
|
||||
'cltv_expiry_delta': bob_chan.forwarding_cltv_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED,
|
||||
'htlc_minimum_msat': bob_chan.config[REMOTE].htlc_minimum_msat,
|
||||
'htlc_maximum_msat': min(bob_chan.config[REMOTE].max_htlc_value_in_flight_msat, 1000 * bob_chan.constraints.capacity),
|
||||
'flen': 0,
|
||||
'features': bytes(0),
|
||||
})
|
||||
|
||||
blinded_path = paths[0]
|
||||
self.assertEqual(len(blinded_path['path']), 2)
|
||||
self.assertEqual(blinded_path['first_node_id'], bob.node_keypair.pubkey)
|
||||
self.assertEqual(len(blinded_path['first_path_key']), 33)
|
||||
self.assertEqual(blinded_path['num_hops'], len(blinded_path['path']).to_bytes(length=1, byteorder='big'))
|
||||
self.assertIn('blinded_node_id', blinded_path['path'][0])
|
||||
self.assertIn('encrypted_recipient_data', blinded_path['path'][0])
|
||||
|
||||
async def test_create_route_to_introduction_point(self):
|
||||
# A -- B -- C -- D -- E
|
||||
# Alice constructs route to Edward as introduction point to some blinded path
|
||||
line_graph = self.GRAPH_DEFINITIONS['line_graph']
|
||||
graph = self.prepare_chans_and_peers_in_graph(line_graph)
|
||||
alice, bob, carol, dave, edward = graph.workers.values()
|
||||
|
||||
session_key = os.urandom(32)
|
||||
introduction_point = edward.node_keypair.pubkey
|
||||
first_path_key = ecc.ECPrivkey.generate_random_key().get_public_key_bytes()
|
||||
blinded_path = {
|
||||
'first_path_key': first_path_key,
|
||||
}
|
||||
with self.assertRaises(NoRouteFound):
|
||||
create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key)
|
||||
|
||||
for name, definition in line_graph.items():
|
||||
for channel_partner in definition.get('channels', {}):
|
||||
inject_chan_into_gossipdb(
|
||||
channel_db=alice.channel_db,
|
||||
graph=graph,
|
||||
node1name=name,
|
||||
node2name=channel_partner,
|
||||
)
|
||||
|
||||
# patch is_onion_message_node so we don't have to inject node announcements
|
||||
with patch('electrum.onion_message.is_onion_message_node', return_value=True):
|
||||
r = create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key)
|
||||
peer, path_key, hops_data, blinded_node_ids = r
|
||||
# alice hands the onion over to bob
|
||||
self.assertEqual(peer.pubkey, bob.lnpeermgr.node_keypair.pubkey)
|
||||
|
||||
self.assertEqual(path_key, ecc.ECPrivkey(session_key).get_public_key_bytes())
|
||||
self.assertEqual(len(hops_data), 3)
|
||||
self.assertEqual(len(hops_data), len(blinded_node_ids))
|
||||
|
||||
# bob unwraps the first layer, sees the next peer, next peer should be carol
|
||||
self.assertEqual(hops_data[0].blind_fields['next_node_id']['node_id'], carol.lnpeermgr.node_keypair.pubkey)
|
||||
self.assertEqual(hops_data[1].blind_fields['next_node_id']['node_id'], dave.lnpeermgr.node_keypair.pubkey)
|
||||
self.assertEqual(hops_data[2].blind_fields['next_node_id']['node_id'], edward.lnpeermgr.node_keypair.pubkey)
|
||||
self.assertEqual(hops_data[2].blind_fields['next_path_key_override']['path_key'], first_path_key)
|
||||
|
||||
# verify that the recipient data is encrypted to the correct node
|
||||
hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(
|
||||
(bob.node_keypair.pubkey, carol.node_keypair.pubkey, dave.node_keypair.pubkey),
|
||||
session_key)
|
||||
for hop, ss in zip(hops_data, hop_shared_secrets):
|
||||
encrypted_recipient_data = hop.payload['encrypted_recipient_data']['encrypted_recipient_data']
|
||||
decrypt_onionmsg_data_tlv(
|
||||
shared_secret=ss,
|
||||
encrypted_recipient_data=encrypted_recipient_data,
|
||||
)
|
||||
|
||||
# now Bob is IP, Alice is directly connected to IP
|
||||
introduction_point = bob.node_keypair.pubkey
|
||||
r = create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key)
|
||||
peer, path_key, hops_data, blinded_node_ids = r
|
||||
self.assertEqual(path_key, first_path_key)
|
||||
self.assertEqual(len(hops_data), 0)
|
||||
self.assertEqual(len(blinded_node_ids), 0)
|
||||
alice_bob_peer = alice.lnpeermgr.get_peer_by_pubkey(bob.node_keypair.pubkey)
|
||||
self.assertIsNotNone(alice_bob_peer)
|
||||
self.assertEqual(peer, alice_bob_peer)
|
||||
|
||||
Reference in New Issue
Block a user