mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-13 12:33:20 +02:00
Ln/OnionError: shared onion-failure parser; XMoveFunds adopts the hardened version
XMoveFunds/Main.cpp carried a duplicate of FundsMover/Attempter.cpp's
parse_chan_update that had drifted behind the PR320 deep-review
hardening: it lacked the fee_proportional_millionths > 1e6 reject, its
TLV bounds check (tpos + tlen > cu_size) could wrap on an
attacker-supplied bigsize length, and it accepted inbound-fee TLVs of
any length >= 8 instead of exactly 8.
Extract the parser into a new shared module, Ln/OnionError, carrying
the hardened semantics:
- failcode_name(): BOLT 04 failcode -> grep-friendly name
(previously Track-A-only).
- ChanUpdate: the parsed channel_update policy subset askrene
consumes, plus the bLIP-18 inbound-fee TLV fields, and an
operator== that deliberately compares only the askrene-visible
policy (the repeat-update detection use case).
- parse_chan_update(): extract the embedded BOLT 07 channel_update
from a sendpay 204 raw_message, with the prop clamp doubling as
an overflow guarantee for callers' ceil(amt * prop / 1e6) math.
XMoveFunds switches to the shared module via using-declarations, so
call sites are unchanged; its three 204 log lines now print the
failcode name next to the hex. Also correct a stale comment above
failure_summary() claiming the channel_update-refresh branch is
skipped -- it has existed since the FEE_INSUFFICIENT dissection work;
only the in-call retry is (deliberately) absent.
FundsMover/Attempter.cpp intentionally keeps its own copy: Track A is
being treated as done, and switching it to Ln/OnionError is deferred
to the next time Track A is touched for its own reasons.
New unit test tests/ln/test_onionerror covers the onion wire format
for all carrying failcodes, both type-prefix forms, the prop clamp
boundary (1e6 accepted, 1e6+1 rejected), signed inbound-fee values,
exact-8 TLV length enforcement, and the bigsize-wrap attack that the
old Track B bounds check would have mishandled. 86/86 tests pass.
This commit is contained in:
parent
4c72671349
commit
fe220493df
5 changed files with 505 additions and 173 deletions
|
|
@ -30,6 +30,7 @@
|
|||
#include"Ln/Amount.hpp"
|
||||
#include"Ln/CommandId.hpp"
|
||||
#include"Ln/NodeId.hpp"
|
||||
#include"Ln/OnionError.hpp"
|
||||
#include"Ln/Preimage.hpp"
|
||||
#include"Ln/Scid.hpp"
|
||||
#include"S/Bus.hpp"
|
||||
|
|
@ -58,167 +59,13 @@ namespace {
|
|||
* (e.g. Dowser, MoveFundsCommand). */
|
||||
constexpr int RPC_INVALID_PARAMS = -32602;
|
||||
|
||||
/* Parsed channel_update fields fed back into askrene via
|
||||
* AskreneLayer::update_channel after a sendpay 204 with an
|
||||
* onion-error failcode that carries a channel_update payload.
|
||||
* Mirrors the subset of BOLT 07 channel_update fields askrene-
|
||||
* update-channel accepts.
|
||||
*
|
||||
* Duplicated from FundsMover/Attempter.cpp for now -- both
|
||||
* sites parse the same wire format with the same field set.
|
||||
* Pulling the parser into a shared module (Util/, Ln/, or a
|
||||
* new Boss/Mod/ChanUpdate) is a separate cleanup tracked
|
||||
* apart from xrebalance work. */
|
||||
struct ChanUpdate {
|
||||
bool enabled;
|
||||
std::uint16_t cltv_expiry_delta;
|
||||
std::uint64_t htlc_minimum_msat;
|
||||
std::uint32_t fee_base_msat;
|
||||
std::uint32_t fee_proportional_millionths;
|
||||
std::uint64_t htlc_maximum_msat;
|
||||
/* bLIP-18 inbound fees (TLV 55555), signed. has_inbound_fee
|
||||
* is false when the channel_update carries no such TLV. */
|
||||
bool has_inbound_fee = false;
|
||||
std::int32_t inbound_fee_base_msat = 0;
|
||||
std::int32_t inbound_fee_proportional_millionths = 0;
|
||||
};
|
||||
|
||||
/* Read a big-endian unsigned integer of 1..8 bytes from `data`
|
||||
* starting at `offset`. Caller ensures the read is in-bounds.
|
||||
*/
|
||||
std::uint64_t read_be( std::uint8_t const* data
|
||||
, std::size_t offset
|
||||
, std::size_t nbytes
|
||||
) {
|
||||
auto v = std::uint64_t(0);
|
||||
for (auto i = std::size_t(0); i < nbytes; ++i)
|
||||
v = (v << 8) | std::uint64_t(data[offset + i]);
|
||||
return v;
|
||||
}
|
||||
|
||||
/* Read a BOLT 01 BigSize at `pos` in `data` (size `size`), advancing
|
||||
* `pos` past it. Returns false if truncated. */
|
||||
bool read_bigsize( std::uint8_t const* data
|
||||
, std::size_t size
|
||||
, std::size_t& pos
|
||||
, std::uint64_t& out
|
||||
) {
|
||||
if (pos >= size)
|
||||
return false;
|
||||
auto first = data[pos];
|
||||
auto nbytes = std::size_t( first < 0xfd ? 0
|
||||
: first == 0xfd ? 2
|
||||
: first == 0xfe ? 4
|
||||
: 8 );
|
||||
if (nbytes == 0) {
|
||||
out = first;
|
||||
pos += 1;
|
||||
return true;
|
||||
}
|
||||
if (pos + 1 + nbytes > size)
|
||||
return false;
|
||||
out = read_be(data, pos + 1, nbytes);
|
||||
pos += 1 + nbytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse a BOLT 04 onion failure payload (the `raw_message` hex
|
||||
* from sendpay_failure data) and extract the embedded BOLT 07
|
||||
* channel_update fields. Returns true on success and writes the
|
||||
* parsed values into `out`; returns false if the hex is malformed,
|
||||
* the failcode does not carry a channel_update, or the payload is
|
||||
* truncated.
|
||||
*
|
||||
* Wire layout of the onion failure for the relevant failcodes:
|
||||
*
|
||||
* 2 failcode
|
||||
* X variable per-failcode header:
|
||||
* 0x1007 / 0x100e: 0 bytes
|
||||
* 0x100b / 0x100c (amount): 8 bytes htlc_msat
|
||||
* 0x100d (cltv): 4 bytes cltv_expiry
|
||||
* 2 channel_update length (big-endian)
|
||||
* N channel_update bytes
|
||||
*
|
||||
* channel_update wire layout (BOLT 07), 128 bytes after the
|
||||
* optional 2-byte 0x0102 type prefix. We only need the policy
|
||||
* fields (offset 109 onwards in the body), so we skip past
|
||||
* signature (64), chain_hash (32), short_channel_id (8),
|
||||
* timestamp (4), and message_flags (1). The 2-byte type prefix
|
||||
* is present in CLN-issued channel_updates and absent in
|
||||
* LND-pre-v0.18 ones; detect by sniffing the first two bytes.
|
||||
*/
|
||||
bool parse_chan_update( std::string const& raw_message_hex
|
||||
, ChanUpdate& out
|
||||
) {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
try {
|
||||
bytes = Util::Str::hexread(raw_message_hex);
|
||||
} catch (std::exception const&) {
|
||||
return false;
|
||||
}
|
||||
if (bytes.size() < 4)
|
||||
return false;
|
||||
|
||||
auto failcode = std::uint16_t((bytes[0] << 8) | bytes[1]);
|
||||
auto header = std::size_t(0);
|
||||
switch (failcode) {
|
||||
case 0x1007: case 0x100e: header = 0; break;
|
||||
case 0x100b: case 0x100c: header = 8; break;
|
||||
case 0x100d: header = 4; break;
|
||||
default: return false;
|
||||
}
|
||||
auto pos = std::size_t(2) + header;
|
||||
if (bytes.size() < pos + 2)
|
||||
return false;
|
||||
auto cu_len = std::size_t((bytes[pos] << 8) | bytes[pos + 1]);
|
||||
pos += 2;
|
||||
if (cu_len == 0 || bytes.size() < pos + cu_len)
|
||||
return false;
|
||||
|
||||
auto cu = bytes.data() + pos;
|
||||
auto cu_size = cu_len;
|
||||
/* Skip the optional 2-byte type prefix 0x0102 if present. */
|
||||
if (cu_size >= 2 && cu[0] == 0x01 && cu[1] == 0x02) {
|
||||
cu += 2;
|
||||
cu_size -= 2;
|
||||
}
|
||||
if (cu_size < 136)
|
||||
return false;
|
||||
|
||||
auto channel_flags = cu[109];
|
||||
out.enabled = !(channel_flags & 0x02);
|
||||
out.cltv_expiry_delta = std::uint16_t(read_be(cu, 110, 2));
|
||||
out.htlc_minimum_msat = read_be(cu, 112, 8);
|
||||
out.fee_base_msat = std::uint32_t(read_be(cu, 120, 4));
|
||||
out.fee_proportional_millionths = std::uint32_t(read_be(cu, 124, 4));
|
||||
out.htlc_maximum_msat = read_be(cu, 128, 8);
|
||||
|
||||
/* Scan the trailing TLV stream for bLIP-18 inbound fees
|
||||
* (type 55555): value is [i32 base][i32 prop], both signed. */
|
||||
out.has_inbound_fee = false;
|
||||
out.inbound_fee_base_msat = 0;
|
||||
out.inbound_fee_proportional_millionths = 0;
|
||||
auto tpos = std::size_t(136);
|
||||
while (tpos < cu_size) {
|
||||
auto ttype = std::uint64_t(0);
|
||||
auto tlen = std::uint64_t(0);
|
||||
if (!read_bigsize(cu, cu_size, tpos, ttype))
|
||||
break;
|
||||
if (!read_bigsize(cu, cu_size, tpos, tlen))
|
||||
break;
|
||||
if (tpos + tlen > cu_size)
|
||||
break;
|
||||
if (ttype == 55555 && tlen >= 8) {
|
||||
out.has_inbound_fee = true;
|
||||
out.inbound_fee_base_msat =
|
||||
std::int32_t(std::uint32_t(read_be(cu, tpos, 4)));
|
||||
out.inbound_fee_proportional_millionths =
|
||||
std::int32_t(std::uint32_t(read_be(cu, tpos + 4, 4)));
|
||||
}
|
||||
tpos += tlen;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/* Onion-error interpretation (ChanUpdate, parse_chan_update,
|
||||
* failcode_name) lives in Ln/OnionError. FundsMover/Attempter.cpp
|
||||
* still carries its own copy of the parser; switching it to the
|
||||
* shared module is deferred until Track A is next touched. */
|
||||
using Ln::OnionError::ChanUpdate;
|
||||
using Ln::OnionError::parse_chan_update;
|
||||
using Ln::OnionError::failcode_name;
|
||||
|
||||
/* Decode either a single scid string or an array of scid strings
|
||||
* from a JSON value into a vector. Throws on type/format error
|
||||
|
|
@ -1130,13 +977,12 @@ private:
|
|||
* lookup fails -- a strictly safer signal than no
|
||||
* feedback at all.
|
||||
*
|
||||
* Mirrors the simpler half of FundsMover/Attempter.cpp's
|
||||
* 204 handling; we deliberately skip the
|
||||
* channel_update-refresh branch (parse_chan_update +
|
||||
* update_channel with policy fields) for now -- the manual
|
||||
* xmovefunds primitive does not yet retry, so the inform-
|
||||
* constrained path alone is sufficient to make the NEXT
|
||||
* manual invocation pick a different route. */
|
||||
* Mirrors FundsMover/Attempter.cpp's 204 handling, including
|
||||
* the channel_update-refresh branch (parse_chan_update +
|
||||
* Msg::AskreneChannelUpdate with policy fields) -- but unlike
|
||||
* the Attempter there is no retry within one invocation; the
|
||||
* refresh lands in the AskreneUpdates store and benefits the
|
||||
* NEXT invocation via its projected private layer. */
|
||||
/* Concise one-line failure summary for the response's errors[]
|
||||
* (and thus the XRebalancer "reason:" log line, so a single
|
||||
* grep XRebalancer shows how close the part got). For a 204 it
|
||||
|
|
@ -1171,12 +1017,14 @@ private:
|
|||
(eidx < route_hops)
|
||||
? (route_hops - eidx)
|
||||
: std::size_t(0);
|
||||
auto fc = std::uint16_t(double(
|
||||
data["failcode"]));
|
||||
auto os = std::ostringstream();
|
||||
os << "204 from_target=" << from_target
|
||||
<< " failcode=0x" << std::hex
|
||||
<< std::uint16_t(double(
|
||||
data["failcode"]))
|
||||
<< std::dec << " node="
|
||||
<< " failcode=0x" << std::hex << fc
|
||||
<< std::dec
|
||||
<< "(" << failcode_name(fc) << ")"
|
||||
<< " node="
|
||||
<< std::string(data["erring_node"]);
|
||||
return os.str();
|
||||
}
|
||||
|
|
@ -1248,7 +1096,9 @@ private:
|
|||
: std::size_t(0);
|
||||
auto sum = std::ostringstream();
|
||||
sum << "XMoveFunds: 204 failcode=0x" << std::hex << fail
|
||||
<< std::dec << " erring=" << echan_str << "/" << edir
|
||||
<< std::dec
|
||||
<< "(" << failcode_name(fail) << ")"
|
||||
<< " erring=" << echan_str << "/" << edir
|
||||
<< " node=" << std::string(enode)
|
||||
<< " from_target=" << from_target
|
||||
<< " alloc_fee="
|
||||
|
|
@ -1287,6 +1137,7 @@ private:
|
|||
auto os = std::ostringstream();
|
||||
os << "XMoveFunds: 204 picture failcode=0x"
|
||||
<< std::hex << fail << std::dec
|
||||
<< "(" << failcode_name(fail) << ")"
|
||||
<< " erring_index=" << eidx
|
||||
<< " erring_channel=" << echan_str
|
||||
<< "/" << edir
|
||||
|
|
|
|||
162
Ln/OnionError.cpp
Normal file
162
Ln/OnionError.cpp
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
#include"Ln/OnionError.hpp"
|
||||
#include"Util/Str.hpp"
|
||||
#include<vector>
|
||||
|
||||
namespace {
|
||||
|
||||
/* Read a big-endian unsigned integer of 1..8 bytes from `data`
|
||||
* starting at `offset`. Caller ensures the read is in-bounds.
|
||||
*/
|
||||
std::uint64_t read_be( std::uint8_t const* data
|
||||
, std::size_t offset
|
||||
, std::size_t nbytes
|
||||
) {
|
||||
auto v = std::uint64_t(0);
|
||||
for (auto i = std::size_t(0); i < nbytes; ++i)
|
||||
v = (v << 8) | std::uint64_t(data[offset + i]);
|
||||
return v;
|
||||
}
|
||||
|
||||
/* Read a BOLT 01 BigSize at `pos` in `data` (size `size`), advancing
|
||||
* `pos` past it. Returns false if truncated. */
|
||||
bool read_bigsize( std::uint8_t const* data
|
||||
, std::size_t size
|
||||
, std::size_t& pos
|
||||
, std::uint64_t& out
|
||||
) {
|
||||
if (pos >= size)
|
||||
return false;
|
||||
auto first = data[pos];
|
||||
auto nbytes = std::size_t( first < 0xfd ? 0
|
||||
: first == 0xfd ? 2
|
||||
: first == 0xfe ? 4
|
||||
: 8 );
|
||||
if (nbytes == 0) {
|
||||
out = first;
|
||||
pos += 1;
|
||||
return true;
|
||||
}
|
||||
if (pos + 1 + nbytes > size)
|
||||
return false;
|
||||
out = read_be(data, pos + 1, nbytes);
|
||||
pos += 1 + nbytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
namespace Ln { namespace OnionError {
|
||||
|
||||
char const* failcode_name(std::uint16_t f) {
|
||||
switch (f) {
|
||||
case 0x1007: return "TEMPORARY_CHANNEL_FAILURE";
|
||||
case 0x100b: return "AMOUNT_BELOW_MINIMUM";
|
||||
case 0x100c: return "FEE_INSUFFICIENT";
|
||||
case 0x100d: return "INCORRECT_CLTV_EXPIRY";
|
||||
case 0x100e: return "EXPIRY_TOO_SOON";
|
||||
case 0x1014: return "CHANNEL_DISABLED";
|
||||
case 0x2002: return "TEMPORARY_NODE_FAILURE";
|
||||
case 0x4008: return "PERMANENT_CHANNEL_FAILURE";
|
||||
case 0x400a: return "UNKNOWN_NEXT_PEER";
|
||||
case 0x4010: return "REQUIRED_CHANNEL_FEATURE_MISSING";
|
||||
case 0x6002: return "PERMANENT_NODE_FAILURE";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
bool parse_chan_update( std::string const& raw_message_hex
|
||||
, ChanUpdate& out
|
||||
) {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
try {
|
||||
bytes = Util::Str::hexread(raw_message_hex);
|
||||
} catch (std::exception const&) {
|
||||
return false;
|
||||
}
|
||||
if (bytes.size() < 4)
|
||||
return false;
|
||||
|
||||
auto failcode = std::uint16_t((bytes[0] << 8) | bytes[1]);
|
||||
auto header = std::size_t(0);
|
||||
switch (failcode) {
|
||||
case 0x1007: case 0x100e: header = 0; break;
|
||||
case 0x100b: case 0x100c: header = 8; break;
|
||||
case 0x100d: header = 4; break;
|
||||
default: return false;
|
||||
}
|
||||
auto pos = std::size_t(2) + header;
|
||||
if (bytes.size() < pos + 2)
|
||||
return false;
|
||||
auto cu_len = std::size_t((bytes[pos] << 8) | bytes[pos + 1]);
|
||||
pos += 2;
|
||||
if (cu_len == 0 || bytes.size() < pos + cu_len)
|
||||
return false;
|
||||
|
||||
auto cu = bytes.data() + pos;
|
||||
auto cu_size = cu_len;
|
||||
/* Skip the optional 2-byte type prefix 0x0102 if present. */
|
||||
if (cu_size >= 2 && cu[0] == 0x01 && cu[1] == 0x02) {
|
||||
cu += 2;
|
||||
cu_size -= 2;
|
||||
}
|
||||
/* Fixed-layout body from offset 0 of the post-prefix
|
||||
* channel_update: 64 sig + 32 chain_hash + 8
|
||||
* short_channel_id + 4 timestamp + 1 message_flags + 1
|
||||
* channel_flags + 2 cltv_expiry_delta + 8 htlc_minimum_msat
|
||||
* + 4 fee_base_msat + 4 fee_proportional_millionths + 8
|
||||
* htlc_maximum_msat = 136.
|
||||
*/
|
||||
if (cu_size < 136)
|
||||
return false;
|
||||
|
||||
auto channel_flags = cu[109];
|
||||
out.enabled = !(channel_flags & 0x02);
|
||||
out.cltv_expiry_delta = std::uint16_t(read_be(cu, 110, 2));
|
||||
out.htlc_minimum_msat = read_be(cu, 112, 8);
|
||||
out.fee_base_msat = std::uint32_t(read_be(cu, 120, 4));
|
||||
out.fee_proportional_millionths = std::uint32_t(read_be(cu, 124, 4));
|
||||
out.htlc_maximum_msat = read_be(cu, 128, 8);
|
||||
|
||||
/* Reject absurd signed policies rather than propagating them.
|
||||
* A proportional fee above 100% is never a policy we would
|
||||
* pay, and bounding it here is the overflow guarantee the
|
||||
* header doc promises callers. Failing the parse routes the
|
||||
* failure into the callers' max_msat=0 hard-exclusion
|
||||
* fallback -- the right response to a forwarder signing
|
||||
* garbage.
|
||||
*/
|
||||
if (out.fee_proportional_millionths > 1000000)
|
||||
return false;
|
||||
|
||||
/* Scan the trailing TLV stream for bLIP-18 inbound fees
|
||||
* (type 55555): value is [i32 base][i32 prop], both signed. */
|
||||
out.has_inbound_fee = false;
|
||||
out.inbound_fee_base_msat = 0;
|
||||
out.inbound_fee_proportional_millionths = 0;
|
||||
auto tpos = std::size_t(136);
|
||||
while (tpos < cu_size) {
|
||||
auto ttype = std::uint64_t(0);
|
||||
auto tlen = std::uint64_t(0);
|
||||
if (!read_bigsize(cu, cu_size, tpos, ttype))
|
||||
break;
|
||||
if (!read_bigsize(cu, cu_size, tpos, tlen))
|
||||
break;
|
||||
/* Overflow-safe: tpos <= cu_size (guaranteed by
|
||||
* read_bigsize) so cu_size - tpos cannot underflow,
|
||||
* whereas tpos + tlen can wrap for an attacker-supplied
|
||||
* tlen and slip past a `> cu_size` check. */
|
||||
if (tlen > std::uint64_t(cu_size - tpos))
|
||||
break;
|
||||
if (ttype == 55555 && tlen == 8) {
|
||||
out.has_inbound_fee = true;
|
||||
out.inbound_fee_base_msat =
|
||||
std::int32_t(std::uint32_t(read_be(cu, tpos, 4)));
|
||||
out.inbound_fee_proportional_millionths =
|
||||
std::int32_t(std::uint32_t(read_be(cu, tpos + 4, 4)));
|
||||
}
|
||||
tpos += tlen;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}}
|
||||
92
Ln/OnionError.hpp
Normal file
92
Ln/OnionError.hpp
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#ifndef LN_ONIONERROR_HPP
|
||||
#define LN_ONIONERROR_HPP
|
||||
|
||||
#include<cstdint>
|
||||
#include<string>
|
||||
|
||||
namespace Ln { namespace OnionError {
|
||||
|
||||
/** char const* Ln::OnionError::failcode_name(failcode)
|
||||
*
|
||||
* @brief BOLT 04 onion failure code -> short human-readable
|
||||
* name. Used in sendpay-204 logs so the failcode can be
|
||||
* grepped by name (FEE_INSUFFICIENT etc.) rather than only
|
||||
* the hex. Returns "UNKNOWN" for codes not in the table.
|
||||
*/
|
||||
char const* failcode_name(std::uint16_t failcode);
|
||||
|
||||
/** struct Ln::OnionError::ChanUpdate
|
||||
*
|
||||
* @brief Parsed BOLT 07 channel_update policy fields, as
|
||||
* extracted from the payload a BOLT 04 onion failure embeds.
|
||||
* Mirrors the subset of channel_update fields that
|
||||
* askrene-update-channel accepts, plus the bLIP-18 inbound-fee
|
||||
* TLV.
|
||||
*/
|
||||
struct ChanUpdate {
|
||||
bool enabled;
|
||||
std::uint16_t cltv_expiry_delta;
|
||||
std::uint64_t htlc_minimum_msat;
|
||||
std::uint32_t fee_base_msat;
|
||||
std::uint32_t fee_proportional_millionths;
|
||||
std::uint64_t htlc_maximum_msat;
|
||||
/* bLIP-18 inbound fees (TLV 55555), signed. has_inbound_fee
|
||||
* is false when the channel_update carries no such TLV. */
|
||||
bool has_inbound_fee = false;
|
||||
std::int32_t inbound_fee_base_msat = 0;
|
||||
std::int32_t inbound_fee_proportional_millionths = 0;
|
||||
|
||||
/* Compares only the fields askrene consumes, deliberately
|
||||
* excluding the inbound-fee TLV: the use case is detecting a
|
||||
* forwarder that returns the identical signed policy we
|
||||
* already applied (enforcement diverging from gossip), and
|
||||
* askrene never prices inbound fees, so a TLV-only change
|
||||
* would not alter any route we build. */
|
||||
bool operator==(ChanUpdate const& o) const {
|
||||
return enabled == o.enabled
|
||||
&& cltv_expiry_delta == o.cltv_expiry_delta
|
||||
&& htlc_minimum_msat == o.htlc_minimum_msat
|
||||
&& fee_base_msat == o.fee_base_msat
|
||||
&& fee_proportional_millionths == o.fee_proportional_millionths
|
||||
&& htlc_maximum_msat == o.htlc_maximum_msat;
|
||||
}
|
||||
};
|
||||
|
||||
/** bool Ln::OnionError::parse_chan_update(raw_message_hex, out)
|
||||
*
|
||||
* @brief Parse a BOLT 04 onion failure payload (the
|
||||
* `raw_message` hex from sendpay_failure data) and extract the
|
||||
* embedded BOLT 07 channel_update fields. Returns true on
|
||||
* success and writes the parsed values into `out`; returns
|
||||
* false if the hex is malformed, the failcode does not carry a
|
||||
* channel_update, the payload is truncated, or the update
|
||||
* carries an absurd policy (proportional fee above 100%).
|
||||
*
|
||||
* The proportional-fee bound doubles as an overflow guarantee
|
||||
* for callers: with fee_proportional_millionths <= 1e6, a
|
||||
* ceil(amount * prop / 1e6) computed in uint64 cannot wrap for
|
||||
* any Lightning-plausible amount, whereas a forwarder-signed
|
||||
* 0xFFFFFFFF would.
|
||||
*
|
||||
* Wire layout of the onion failure for the relevant failcodes:
|
||||
*
|
||||
* 2 failcode
|
||||
* X variable per-failcode header:
|
||||
* 0x1007 / 0x100e: 0 bytes
|
||||
* 0x100b / 0x100c (amount): 8 bytes htlc_msat
|
||||
* 0x100d (cltv): 4 bytes cltv_expiry
|
||||
* 2 channel_update length (big-endian)
|
||||
* N channel_update bytes
|
||||
*
|
||||
* The channel_update's 2-byte type prefix 0x0102 is present in
|
||||
* CLN-issued channel_updates and absent in LND-pre-v0.18 ones;
|
||||
* both forms are accepted (detected by sniffing the first two
|
||||
* bytes).
|
||||
*/
|
||||
bool parse_chan_update( std::string const& raw_message_hex
|
||||
, ChanUpdate& out
|
||||
);
|
||||
|
||||
}}
|
||||
|
||||
#endif /* !defined(LN_ONIONERROR_HPP) */
|
||||
|
|
@ -485,6 +485,8 @@ libclboss_la_SOURCES = \
|
|||
Ln/HtlcAccepted.hpp \
|
||||
Ln/NodeId.cpp \
|
||||
Ln/NodeId.hpp \
|
||||
Ln/OnionError.cpp \
|
||||
Ln/OnionError.hpp \
|
||||
Ln/Preimage.cpp \
|
||||
Ln/Preimage.hpp \
|
||||
Ln/Scid.cpp \
|
||||
|
|
@ -690,6 +692,7 @@ TESTS = \
|
|||
tests/ln/test_commandid \
|
||||
tests/ln/test_htlcaccepted \
|
||||
tests/ln/test_nodeid \
|
||||
tests/ln/test_onionerror \
|
||||
tests/ln/test_scid \
|
||||
tests/net/test_ipaddr \
|
||||
tests/net/test_ipaddroronion \
|
||||
|
|
|
|||
224
tests/ln/test_onionerror.cpp
Normal file
224
tests/ln/test_onionerror.cpp
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
#undef NDEBUG
|
||||
#include"Ln/OnionError.hpp"
|
||||
#include"Util/Str.hpp"
|
||||
#include<assert.h>
|
||||
#include<cstdint>
|
||||
#include<string>
|
||||
#include<vector>
|
||||
|
||||
namespace {
|
||||
|
||||
/* Append a big-endian integer of `nbytes` to `b`. */
|
||||
void push_be( std::vector<std::uint8_t>& b
|
||||
, std::uint64_t v
|
||||
, std::size_t nbytes
|
||||
) {
|
||||
for (auto i = std::size_t(0); i < nbytes; ++i)
|
||||
b.push_back(std::uint8_t((v >> (8 * (nbytes - 1 - i))) & 0xff));
|
||||
}
|
||||
|
||||
/* Build a 136-byte BOLT 07 channel_update body (no type prefix):
|
||||
* 64 sig + 32 chain_hash + 8 scid + 4 timestamp + 1 message_flags
|
||||
* + 1 channel_flags + 2 cltv + 8 htlc_min + 4 base + 4 prop
|
||||
* + 8 htlc_max, sig..timestamp left zeroed. */
|
||||
std::vector<std::uint8_t> make_cu_body( bool disabled
|
||||
, std::uint16_t cltv
|
||||
, std::uint64_t htlc_min
|
||||
, std::uint32_t base
|
||||
, std::uint32_t prop
|
||||
, std::uint64_t htlc_max
|
||||
) {
|
||||
auto b = std::vector<std::uint8_t>(110, 0);
|
||||
b[109] = disabled ? 0x02 : 0x00;
|
||||
push_be(b, cltv, 2);
|
||||
push_be(b, htlc_min, 8);
|
||||
push_be(b, base, 4);
|
||||
push_be(b, prop, 4);
|
||||
push_be(b, htlc_max, 8);
|
||||
assert(b.size() == 136);
|
||||
return b;
|
||||
}
|
||||
|
||||
/* Wrap a channel_update in a BOLT 04 onion failure payload:
|
||||
* failcode + `header` zero bytes + 2-byte length + optional
|
||||
* 0x0102 type prefix + the update. */
|
||||
std::string make_onion_hex( std::uint16_t failcode
|
||||
, std::size_t header
|
||||
, std::vector<std::uint8_t> const& cu
|
||||
, bool type_prefix
|
||||
) {
|
||||
auto m = std::vector<std::uint8_t>();
|
||||
push_be(m, failcode, 2);
|
||||
m.insert(m.end(), header, 0);
|
||||
auto full = std::vector<std::uint8_t>();
|
||||
if (type_prefix) {
|
||||
full.push_back(0x01);
|
||||
full.push_back(0x02);
|
||||
}
|
||||
full.insert(full.end(), cu.begin(), cu.end());
|
||||
push_be(m, full.size(), 2);
|
||||
m.insert(m.end(), full.begin(), full.end());
|
||||
return Util::Str::hexdump(m.data(), m.size());
|
||||
}
|
||||
|
||||
/* Append a bLIP-18 inbound-fee TLV (type 55555) with the given
|
||||
* length byte and [i32 base][i32 prop] value (value always 8
|
||||
* bytes; a `len` other than 8 makes the TLV malformed-for-us
|
||||
* on purpose). */
|
||||
void push_inbound_tlv( std::vector<std::uint8_t>& cu
|
||||
, std::uint8_t len
|
||||
, std::int32_t base
|
||||
, std::int32_t prop
|
||||
) {
|
||||
/* type 55555 = 0xd903 needs the 0xfd bigsize form. */
|
||||
cu.push_back(0xfd);
|
||||
push_be(cu, 55555, 2);
|
||||
cu.push_back(len);
|
||||
push_be(cu, std::uint32_t(base), 4);
|
||||
push_be(cu, std::uint32_t(prop), 4);
|
||||
for (auto i = std::size_t(8); i < std::size_t(len); ++i)
|
||||
cu.push_back(0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
int main() {
|
||||
using Ln::OnionError::ChanUpdate;
|
||||
using Ln::OnionError::parse_chan_update;
|
||||
using Ln::OnionError::failcode_name;
|
||||
|
||||
/* Happy path: 0x100c (8-byte header), CLN-style 0x0102 type
|
||||
* prefix, no TLVs. */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(false, 144, 1000, 1234, 567,
|
||||
1000000000);
|
||||
auto cu = ChanUpdate();
|
||||
assert(parse_chan_update(
|
||||
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
|
||||
assert(cu.enabled);
|
||||
assert(cu.cltv_expiry_delta == 144);
|
||||
assert(cu.htlc_minimum_msat == 1000);
|
||||
assert(cu.fee_base_msat == 1234);
|
||||
assert(cu.fee_proportional_millionths == 567);
|
||||
assert(cu.htlc_maximum_msat == 1000000000);
|
||||
assert(!cu.has_inbound_fee);
|
||||
}
|
||||
|
||||
/* Same update without the type prefix (LND-pre-v0.18 style),
|
||||
* and via a 0x100d failure (4-byte header). */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(true, 40, 1, 0, 100, 21000000);
|
||||
auto cu = ChanUpdate();
|
||||
assert(parse_chan_update(
|
||||
make_onion_hex(0x100d, 4, cu_bytes, false), cu));
|
||||
assert(!cu.enabled);
|
||||
assert(cu.cltv_expiry_delta == 40);
|
||||
assert(cu.fee_proportional_millionths == 100);
|
||||
}
|
||||
|
||||
/* Proportional fee above 100% is rejected: a forwarder-signed
|
||||
* absurd policy must fail the parse (and thus route callers
|
||||
* into their hard-exclusion fallback), not propagate. */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(false, 144, 0, 0, 1000001, 1);
|
||||
auto cu = ChanUpdate();
|
||||
assert(!parse_chan_update(
|
||||
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
|
||||
/* Exactly 100% is still accepted. */
|
||||
cu_bytes = make_cu_body(false, 144, 0, 0, 1000000, 1);
|
||||
assert(parse_chan_update(
|
||||
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
|
||||
}
|
||||
|
||||
/* bLIP-18 inbound-fee TLV, including negative (discount)
|
||||
* base: values are signed i32. */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
|
||||
push_inbound_tlv(cu_bytes, 8, -1000, 250);
|
||||
auto cu = ChanUpdate();
|
||||
assert(parse_chan_update(
|
||||
make_onion_hex(0x100b, 8, cu_bytes, true), cu));
|
||||
assert(cu.has_inbound_fee);
|
||||
assert(cu.inbound_fee_base_msat == -1000);
|
||||
assert(cu.inbound_fee_proportional_millionths == 250);
|
||||
}
|
||||
|
||||
/* TLV length must be exactly 8: a 9-byte 55555 TLV is not
|
||||
* treated as an inbound fee (but does not fail the parse). */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
|
||||
push_inbound_tlv(cu_bytes, 9, 1000, 250);
|
||||
auto cu = ChanUpdate();
|
||||
assert(parse_chan_update(
|
||||
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
|
||||
assert(!cu.has_inbound_fee);
|
||||
}
|
||||
|
||||
/* Malicious TLV length that would wrap a naive
|
||||
* `tpos + tlen > cu_size` bounds check: bigsize 0xff with
|
||||
* 0xFFFFFFFFFFFFFFFF. The parse must neither crash nor
|
||||
* accept the TLV. */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
|
||||
cu_bytes.push_back(0xfd);
|
||||
push_be(cu_bytes, 55555, 2);
|
||||
cu_bytes.push_back(0xff);
|
||||
push_be(cu_bytes, 0xFFFFFFFFFFFFFFFFull, 8);
|
||||
/* 8 in-bounds bytes a wrapped check would misread. */
|
||||
push_be(cu_bytes, 0x1122334455667788ull, 8);
|
||||
auto cu = ChanUpdate();
|
||||
assert(parse_chan_update(
|
||||
make_onion_hex(0x100c, 8, cu_bytes, true), cu));
|
||||
assert(!cu.has_inbound_fee);
|
||||
}
|
||||
|
||||
/* Failcodes that carry no channel_update are rejected. */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
|
||||
auto cu = ChanUpdate();
|
||||
assert(!parse_chan_update(
|
||||
make_onion_hex(0x2002, 0, cu_bytes, true), cu));
|
||||
}
|
||||
|
||||
/* Truncated payloads and garbage hex are rejected. */
|
||||
{
|
||||
auto cu_bytes = make_cu_body(false, 144, 0, 0, 0, 1);
|
||||
auto hex = make_onion_hex(0x100c, 8, cu_bytes, true);
|
||||
auto cu = ChanUpdate();
|
||||
assert(!parse_chan_update(hex.substr(0, hex.size() - 40), cu));
|
||||
assert(!parse_chan_update("zznothexzz", cu));
|
||||
assert(!parse_chan_update("", cu));
|
||||
/* Zero-length channel_update. */
|
||||
assert(!parse_chan_update("100c00000000000000000000", cu));
|
||||
}
|
||||
|
||||
/* operator== compares the askrene-visible policy only; a
|
||||
* difference confined to the inbound-fee TLV still compares
|
||||
* equal (repeat-update detection semantics). */
|
||||
{
|
||||
auto mk = [](std::uint32_t prop) {
|
||||
auto cu = ChanUpdate();
|
||||
cu.enabled = true;
|
||||
cu.cltv_expiry_delta = 144;
|
||||
cu.htlc_minimum_msat = 1000;
|
||||
cu.fee_base_msat = 0;
|
||||
cu.fee_proportional_millionths = prop;
|
||||
cu.htlc_maximum_msat = 1;
|
||||
return cu;
|
||||
};
|
||||
auto a = mk(100);
|
||||
auto b = mk(100);
|
||||
assert(a == b);
|
||||
b.has_inbound_fee = true;
|
||||
b.inbound_fee_base_msat = 5000;
|
||||
assert(a == b);
|
||||
auto c = mk(101);
|
||||
assert(!(a == c));
|
||||
}
|
||||
|
||||
assert(std::string(failcode_name(0x100c)) == "FEE_INSUFFICIENT");
|
||||
assert(std::string(failcode_name(0x100d)) == "INCORRECT_CLTV_EXPIRY");
|
||||
assert(std::string(failcode_name(0xdead)) == "UNKNOWN");
|
||||
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue