|
|
|
|
@ -23,6 +23,7 @@
|
|
|
|
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
|
|
|
# SOFTWARE. |
|
|
|
|
|
|
|
|
|
import io |
|
|
|
|
import hashlib |
|
|
|
|
from typing import Sequence, List, Tuple, NamedTuple, TYPE_CHECKING |
|
|
|
|
from enum import IntEnum, IntFlag |
|
|
|
|
@ -31,15 +32,16 @@ from . import ecc
|
|
|
|
|
from .crypto import sha256, hmac_oneshot, chacha20_encrypt |
|
|
|
|
from .util import bh2u, profiler, xor_bytes, bfh |
|
|
|
|
from .lnutil import (get_ecdh, PaymentFailure, NUM_MAX_HOPS_IN_PAYMENT_PATH, |
|
|
|
|
NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID) |
|
|
|
|
NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, OnionFailureCodeMetaFlag) |
|
|
|
|
from .lnmsg import OnionWireSerializer, read_bigsize_int, write_bigsize_int |
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING: |
|
|
|
|
from .lnrouter import LNPaymentRoute |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HOPS_DATA_SIZE = 1300 # also sometimes called routingInfoSize in bolt-04 |
|
|
|
|
PER_HOP_FULL_SIZE = 65 # HOPS_DATA_SIZE / 20 |
|
|
|
|
NUM_STREAM_BYTES = HOPS_DATA_SIZE + PER_HOP_FULL_SIZE |
|
|
|
|
LEGACY_PER_HOP_FULL_SIZE = 65 |
|
|
|
|
NUM_STREAM_BYTES = 2 * HOPS_DATA_SIZE |
|
|
|
|
PER_HOP_HMAC_SIZE = 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -48,64 +50,124 @@ class InvalidOnionMac(Exception): pass
|
|
|
|
|
class InvalidOnionPubkey(Exception): pass |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OnionPerHop: |
|
|
|
|
class LegacyHopDataPayload: |
|
|
|
|
|
|
|
|
|
def __init__(self, short_channel_id: bytes, amt_to_forward: bytes, outgoing_cltv_value: bytes): |
|
|
|
|
def __init__(self, *, short_channel_id: bytes, amt_to_forward: int, outgoing_cltv_value: int): |
|
|
|
|
self.short_channel_id = ShortChannelID(short_channel_id) |
|
|
|
|
self.amt_to_forward = amt_to_forward |
|
|
|
|
self.outgoing_cltv_value = outgoing_cltv_value |
|
|
|
|
|
|
|
|
|
def to_bytes(self) -> bytes: |
|
|
|
|
ret = self.short_channel_id |
|
|
|
|
ret += self.amt_to_forward |
|
|
|
|
ret += self.outgoing_cltv_value |
|
|
|
|
ret += int.to_bytes(self.amt_to_forward, length=8, byteorder="big", signed=False) |
|
|
|
|
ret += int.to_bytes(self.outgoing_cltv_value, length=4, byteorder="big", signed=False) |
|
|
|
|
ret += bytes(12) # padding |
|
|
|
|
if len(ret) != 32: |
|
|
|
|
raise Exception('unexpected length {}'.format(len(ret))) |
|
|
|
|
return ret |
|
|
|
|
|
|
|
|
|
def to_tlv_dict(self) -> dict: |
|
|
|
|
d = { |
|
|
|
|
"amt_to_forward": {"amt_to_forward": self.amt_to_forward}, |
|
|
|
|
"outgoing_cltv_value": {"outgoing_cltv_value": self.outgoing_cltv_value}, |
|
|
|
|
"short_channel_id": {"short_channel_id": self.short_channel_id}, |
|
|
|
|
} |
|
|
|
|
return d |
|
|
|
|
|
|
|
|
|
@classmethod |
|
|
|
|
def from_bytes(cls, b: bytes): |
|
|
|
|
def from_bytes(cls, b: bytes) -> 'LegacyHopDataPayload': |
|
|
|
|
if len(b) != 32: |
|
|
|
|
raise Exception('unexpected length {}'.format(len(b))) |
|
|
|
|
return OnionPerHop( |
|
|
|
|
return LegacyHopDataPayload( |
|
|
|
|
short_channel_id=b[:8], |
|
|
|
|
amt_to_forward=b[8:16], |
|
|
|
|
outgoing_cltv_value=b[16:20] |
|
|
|
|
amt_to_forward=int.from_bytes(b[8:16], byteorder="big", signed=False), |
|
|
|
|
outgoing_cltv_value=int.from_bytes(b[16:20], byteorder="big", signed=False), |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
@classmethod |
|
|
|
|
def from_tlv_dict(cls, d: dict) -> 'LegacyHopDataPayload': |
|
|
|
|
return LegacyHopDataPayload( |
|
|
|
|
short_channel_id=d["short_channel_id"]["short_channel_id"], |
|
|
|
|
amt_to_forward=d["amt_to_forward"]["amt_to_forward"], |
|
|
|
|
outgoing_cltv_value=d["outgoing_cltv_value"]["outgoing_cltv_value"], |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OnionHopsDataSingle: # called HopData in lnd |
|
|
|
|
|
|
|
|
|
def __init__(self, per_hop: OnionPerHop = None): |
|
|
|
|
self.realm = 0 |
|
|
|
|
self.per_hop = per_hop |
|
|
|
|
def __init__(self, *, is_tlv_payload: bool, payload: dict = None): |
|
|
|
|
self.is_tlv_payload = is_tlv_payload |
|
|
|
|
if payload is None: |
|
|
|
|
payload = {} |
|
|
|
|
self.payload = payload |
|
|
|
|
self.hmac = None |
|
|
|
|
self._raw_bytes_payload = None # used in unit tests |
|
|
|
|
|
|
|
|
|
def to_bytes(self) -> bytes: |
|
|
|
|
ret = bytes([self.realm]) |
|
|
|
|
ret += self.per_hop.to_bytes() |
|
|
|
|
ret += self.hmac if self.hmac is not None else bytes(PER_HOP_HMAC_SIZE) |
|
|
|
|
if len(ret) != PER_HOP_FULL_SIZE: |
|
|
|
|
raise Exception('unexpected length {}'.format(len(ret))) |
|
|
|
|
return ret |
|
|
|
|
hmac_ = self.hmac if self.hmac is not None else bytes(PER_HOP_HMAC_SIZE) |
|
|
|
|
if self._raw_bytes_payload is not None: |
|
|
|
|
ret = write_bigsize_int(len(self._raw_bytes_payload)) |
|
|
|
|
ret += self._raw_bytes_payload |
|
|
|
|
ret += hmac_ |
|
|
|
|
return ret |
|
|
|
|
if not self.is_tlv_payload: |
|
|
|
|
ret = b"\x00" # realm==0 |
|
|
|
|
legacy_payload = LegacyHopDataPayload.from_tlv_dict(self.payload) |
|
|
|
|
ret += legacy_payload.to_bytes() |
|
|
|
|
ret += hmac_ |
|
|
|
|
if len(ret) != LEGACY_PER_HOP_FULL_SIZE: |
|
|
|
|
raise Exception('unexpected length {}'.format(len(ret))) |
|
|
|
|
return ret |
|
|
|
|
else: # tlv |
|
|
|
|
payload_fd = io.BytesIO() |
|
|
|
|
OnionWireSerializer.write_tlv_stream(fd=payload_fd, |
|
|
|
|
tlv_stream_name="tlv_payload", |
|
|
|
|
**self.payload) |
|
|
|
|
payload_bytes = payload_fd.getvalue() |
|
|
|
|
with io.BytesIO() as fd: |
|
|
|
|
fd.write(write_bigsize_int(len(payload_bytes))) |
|
|
|
|
fd.write(payload_bytes) |
|
|
|
|
fd.write(hmac_) |
|
|
|
|
return fd.getvalue() |
|
|
|
|
|
|
|
|
|
@classmethod |
|
|
|
|
def from_bytes(cls, b: bytes): |
|
|
|
|
if len(b) != PER_HOP_FULL_SIZE: |
|
|
|
|
raise Exception('unexpected length {}'.format(len(b))) |
|
|
|
|
ret = OnionHopsDataSingle() |
|
|
|
|
ret.realm = b[0] |
|
|
|
|
if ret.realm != 0: |
|
|
|
|
raise Exception('only realm 0 is supported') |
|
|
|
|
ret.per_hop = OnionPerHop.from_bytes(b[1:33]) |
|
|
|
|
ret.hmac = b[33:] |
|
|
|
|
return ret |
|
|
|
|
def from_fd(cls, fd: io.BytesIO) -> 'OnionHopsDataSingle': |
|
|
|
|
first_byte = fd.read(1) |
|
|
|
|
if len(first_byte) == 0: |
|
|
|
|
raise Exception(f"unexpected EOF") |
|
|
|
|
fd.seek(-1, io.SEEK_CUR) # undo read |
|
|
|
|
if first_byte == b'\x00': |
|
|
|
|
# legacy hop data format |
|
|
|
|
b = fd.read(LEGACY_PER_HOP_FULL_SIZE) |
|
|
|
|
if len(b) != LEGACY_PER_HOP_FULL_SIZE: |
|
|
|
|
raise Exception(f'unexpected length {len(b)}') |
|
|
|
|
ret = OnionHopsDataSingle(is_tlv_payload=False) |
|
|
|
|
legacy_payload = LegacyHopDataPayload.from_bytes(b[1:33]) |
|
|
|
|
ret.payload = legacy_payload.to_tlv_dict() |
|
|
|
|
ret.hmac = b[33:] |
|
|
|
|
return ret |
|
|
|
|
elif first_byte == b'\x01': |
|
|
|
|
# reserved for future use |
|
|
|
|
raise Exception("unsupported hop payload: length==1") |
|
|
|
|
else: |
|
|
|
|
hop_payload_length = read_bigsize_int(fd) |
|
|
|
|
hop_payload = fd.read(hop_payload_length) |
|
|
|
|
if hop_payload_length != len(hop_payload): |
|
|
|
|
raise Exception(f"unexpected EOF") |
|
|
|
|
ret = OnionHopsDataSingle(is_tlv_payload=True) |
|
|
|
|
ret.payload = OnionWireSerializer.read_tlv_stream(fd=io.BytesIO(hop_payload), |
|
|
|
|
tlv_stream_name="tlv_payload") |
|
|
|
|
ret.hmac = fd.read(PER_HOP_HMAC_SIZE) |
|
|
|
|
assert len(ret.hmac) == PER_HOP_HMAC_SIZE |
|
|
|
|
return ret |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OnionPacket: |
|
|
|
|
|
|
|
|
|
def __init__(self, public_key: bytes, hops_data: bytes, hmac: bytes): |
|
|
|
|
assert len(public_key) == 33 |
|
|
|
|
assert len(hops_data) == HOPS_DATA_SIZE |
|
|
|
|
assert len(hmac) == PER_HOP_HMAC_SIZE |
|
|
|
|
self.version = 0 |
|
|
|
|
self.public_key = public_key |
|
|
|
|
self.hops_data = hops_data # also called RoutingInfo in bolt-04 |
|
|
|
|
@ -163,13 +225,14 @@ def get_shared_secrets_along_route(payment_path_pubkeys: Sequence[bytes],
|
|
|
|
|
def new_onion_packet(payment_path_pubkeys: Sequence[bytes], session_key: bytes, |
|
|
|
|
hops_data: Sequence[OnionHopsDataSingle], associated_data: bytes) -> OnionPacket: |
|
|
|
|
num_hops = len(payment_path_pubkeys) |
|
|
|
|
assert num_hops == len(hops_data) |
|
|
|
|
hop_shared_secrets = get_shared_secrets_along_route(payment_path_pubkeys, session_key) |
|
|
|
|
|
|
|
|
|
filler = generate_filler(b'rho', num_hops, PER_HOP_FULL_SIZE, hop_shared_secrets) |
|
|
|
|
filler = _generate_filler(b'rho', hops_data, hop_shared_secrets) |
|
|
|
|
next_hmac = bytes(PER_HOP_HMAC_SIZE) |
|
|
|
|
|
|
|
|
|
# Our starting packet needs to be filled out with random bytes, we |
|
|
|
|
# generate some determinstically using the session private key. |
|
|
|
|
# generate some deterministically using the session private key. |
|
|
|
|
pad_key = get_bolt04_onion_key(b'pad', session_key) |
|
|
|
|
mix_header = generate_cipher_stream(pad_key, HOPS_DATA_SIZE) |
|
|
|
|
|
|
|
|
|
@ -178,9 +241,10 @@ def new_onion_packet(payment_path_pubkeys: Sequence[bytes], session_key: bytes,
|
|
|
|
|
rho_key = get_bolt04_onion_key(b'rho', hop_shared_secrets[i]) |
|
|
|
|
mu_key = get_bolt04_onion_key(b'mu', hop_shared_secrets[i]) |
|
|
|
|
hops_data[i].hmac = next_hmac |
|
|
|
|
stream_bytes = generate_cipher_stream(rho_key, NUM_STREAM_BYTES) |
|
|
|
|
mix_header = mix_header[:-PER_HOP_FULL_SIZE] |
|
|
|
|
mix_header = hops_data[i].to_bytes() + mix_header |
|
|
|
|
stream_bytes = generate_cipher_stream(rho_key, HOPS_DATA_SIZE) |
|
|
|
|
hop_data_bytes = hops_data[i].to_bytes() |
|
|
|
|
mix_header = mix_header[:-len(hop_data_bytes)] |
|
|
|
|
mix_header = hop_data_bytes + mix_header |
|
|
|
|
mix_header = xor_bytes(mix_header, stream_bytes) |
|
|
|
|
if i == num_hops - 1 and len(filler) != 0: |
|
|
|
|
mix_header = mix_header[:-len(filler)] + filler |
|
|
|
|
@ -203,32 +267,53 @@ def calc_hops_data_for_payment(route: 'LNPaymentRoute', amount_msat: int, final_
|
|
|
|
|
|
|
|
|
|
amt = amount_msat |
|
|
|
|
cltv = final_cltv |
|
|
|
|
hops_data = [OnionHopsDataSingle(OnionPerHop(b"\x00" * 8, |
|
|
|
|
amt.to_bytes(8, "big"), |
|
|
|
|
cltv.to_bytes(4, "big")))] |
|
|
|
|
hop_payload = { |
|
|
|
|
"amt_to_forward": {"amt_to_forward": amt}, |
|
|
|
|
"outgoing_cltv_value": {"outgoing_cltv_value": cltv}, |
|
|
|
|
"short_channel_id": {"short_channel_id": b"\x00" * 8}, # TODO omit if tlv |
|
|
|
|
} |
|
|
|
|
hops_data = [OnionHopsDataSingle(is_tlv_payload=False, # TODO |
|
|
|
|
payload=hop_payload)] |
|
|
|
|
for route_edge in reversed(route[1:]): |
|
|
|
|
hops_data += [OnionHopsDataSingle(OnionPerHop(route_edge.short_channel_id, |
|
|
|
|
amt.to_bytes(8, "big"), |
|
|
|
|
cltv.to_bytes(4, "big")))] |
|
|
|
|
hop_payload = { |
|
|
|
|
"amt_to_forward": {"amt_to_forward": amt}, |
|
|
|
|
"outgoing_cltv_value": {"outgoing_cltv_value": cltv}, |
|
|
|
|
"short_channel_id": {"short_channel_id": route_edge.short_channel_id}, |
|
|
|
|
} |
|
|
|
|
hops_data += [OnionHopsDataSingle(is_tlv_payload=False, # TODO |
|
|
|
|
payload=hop_payload)] |
|
|
|
|
amt += route_edge.fee_for_edge(amt) |
|
|
|
|
cltv += route_edge.cltv_expiry_delta |
|
|
|
|
hops_data.reverse() |
|
|
|
|
return hops_data, amt, cltv |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_filler(key_type: bytes, num_hops: int, hop_size: int, |
|
|
|
|
shared_secrets: Sequence[bytes]) -> bytes: |
|
|
|
|
filler_size = (NUM_MAX_HOPS_IN_PAYMENT_PATH + 1) * hop_size |
|
|
|
|
def _generate_filler(key_type: bytes, hops_data: Sequence[OnionHopsDataSingle], |
|
|
|
|
shared_secrets: Sequence[bytes]) -> bytes: |
|
|
|
|
num_hops = len(hops_data) |
|
|
|
|
|
|
|
|
|
# generate filler that matches all but the last hop (no HMAC for last hop) |
|
|
|
|
filler_size = 0 |
|
|
|
|
for hop_data in hops_data[:-1]: |
|
|
|
|
filler_size += len(hop_data.to_bytes()) |
|
|
|
|
filler = bytearray(filler_size) |
|
|
|
|
|
|
|
|
|
for i in range(0, num_hops-1): # -1, as last hop does not obfuscate |
|
|
|
|
filler = filler[hop_size:] |
|
|
|
|
filler += bytearray(hop_size) |
|
|
|
|
# Sum up how many frames were used by prior hops. |
|
|
|
|
filler_start = HOPS_DATA_SIZE |
|
|
|
|
for hop_data in hops_data[:i]: |
|
|
|
|
filler_start -= len(hop_data.to_bytes()) |
|
|
|
|
# The filler is the part dangling off of the end of the |
|
|
|
|
# routingInfo, so offset it from there, and use the current |
|
|
|
|
# hop's frame count as its size. |
|
|
|
|
filler_end = HOPS_DATA_SIZE + len(hops_data[i].to_bytes()) |
|
|
|
|
|
|
|
|
|
stream_key = get_bolt04_onion_key(key_type, shared_secrets[i]) |
|
|
|
|
stream_bytes = generate_cipher_stream(stream_key, filler_size) |
|
|
|
|
filler = xor_bytes(filler, stream_bytes) |
|
|
|
|
stream_bytes = generate_cipher_stream(stream_key, NUM_STREAM_BYTES) |
|
|
|
|
filler = xor_bytes(filler, stream_bytes[filler_start:filler_end]) |
|
|
|
|
filler += bytes(filler_size - len(filler)) # right pad with zeroes |
|
|
|
|
|
|
|
|
|
return filler[(NUM_MAX_HOPS_IN_PAYMENT_PATH-num_hops+2)*hop_size:] |
|
|
|
|
return filler |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_cipher_stream(stream_key: bytes, num_bytes: int) -> bytes: |
|
|
|
|
@ -260,8 +345,9 @@ def process_onion_packet(onion_packet: OnionPacket, associated_data: bytes,
|
|
|
|
|
# peel an onion layer off |
|
|
|
|
rho_key = get_bolt04_onion_key(b'rho', shared_secret) |
|
|
|
|
stream_bytes = generate_cipher_stream(rho_key, NUM_STREAM_BYTES) |
|
|
|
|
padded_header = onion_packet.hops_data + bytes(PER_HOP_FULL_SIZE) |
|
|
|
|
padded_header = onion_packet.hops_data + bytes(HOPS_DATA_SIZE) |
|
|
|
|
next_hops_data = xor_bytes(padded_header, stream_bytes) |
|
|
|
|
next_hops_data_fd = io.BytesIO(next_hops_data) |
|
|
|
|
|
|
|
|
|
# calc next ephemeral key |
|
|
|
|
blinding_factor = sha256(onion_packet.public_key + shared_secret) |
|
|
|
|
@ -269,10 +355,10 @@ def process_onion_packet(onion_packet: OnionPacket, associated_data: bytes,
|
|
|
|
|
next_public_key_int = ecc.ECPubkey(onion_packet.public_key) * blinding_factor_int |
|
|
|
|
next_public_key = next_public_key_int.get_public_key_bytes() |
|
|
|
|
|
|
|
|
|
hop_data = OnionHopsDataSingle.from_bytes(next_hops_data[:PER_HOP_FULL_SIZE]) |
|
|
|
|
hop_data = OnionHopsDataSingle.from_fd(next_hops_data_fd) |
|
|
|
|
next_onion_packet = OnionPacket( |
|
|
|
|
public_key=next_public_key, |
|
|
|
|
hops_data=next_hops_data[PER_HOP_FULL_SIZE:], |
|
|
|
|
hops_data=next_hops_data_fd.read(HOPS_DATA_SIZE), |
|
|
|
|
hmac=hop_data.hmac |
|
|
|
|
) |
|
|
|
|
if hop_data.hmac == bytes(PER_HOP_HMAC_SIZE): |
|
|
|
|
@ -365,12 +451,7 @@ def get_failure_msg_from_onion_error(decrypted_error_packet: bytes) -> OnionRout
|
|
|
|
|
return OnionRoutingFailureMessage(failure_code, failure_data) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OnionFailureCodeMetaFlag(IntFlag): |
|
|
|
|
BADONION = 0x8000 |
|
|
|
|
PERM = 0x4000 |
|
|
|
|
NODE = 0x2000 |
|
|
|
|
UPDATE = 0x1000 |
|
|
|
|
|
|
|
|
|
# TODO maybe we should rm this and just use OnionWireSerializer and onion_wire.csv |
|
|
|
|
BADONION = OnionFailureCodeMetaFlag.BADONION |
|
|
|
|
PERM = OnionFailureCodeMetaFlag.PERM |
|
|
|
|
NODE = OnionFailureCodeMetaFlag.NODE |
|
|
|
|
|