From 22ab977b9e45c109e94f34877ea4434f0a7773cd Mon Sep 17 00:00:00 2001 From: 21M4TW <21m4tw@proton.me> Date: Sat, 24 Jan 2026 22:55:43 -0500 Subject: [PATCH] Changelog-Added: Support for non-final onion messages hops to self --- common/onion_message_parse.c | 145 +++++++++++++++++++---------------- tests/test_pay.py | 17 ++++ 2 files changed, 94 insertions(+), 68 deletions(-) diff --git a/common/onion_message_parse.c b/common/onion_message_parse.c index 8668c85d9d09..777a2d51442d 100644 --- a/common/onion_message_parse.c +++ b/common/onion_message_parse.c @@ -99,6 +99,7 @@ const char *onion_message_parse(const tal_t *ctx, struct secret ss, onion_ss; const u8 *cursor; size_t max, maxlen; + struct pubkey next_path_key = *path_key; /* We unwrap the onion now. */ op = parse_onionpacket(tmpctx, @@ -110,79 +111,87 @@ const char *onion_message_parse(const tal_t *ctx, onion_wire_name(badreason)); } - ephemeral = op->ephemeralkey; - if (!unblind_onion(path_key, ecdh, &ephemeral, &ss)) { - return tal_fmt(ctx, "onion_message_parse: can't unblind onionpacket"); - } - - /* Now get onion shared secret and parse it. */ - ecdh(&ephemeral, &onion_ss); - rs = process_onionpacket(tmpctx, op, &onion_ss, NULL, 0); - if (!rs) { - return tal_fmt(ctx, "onion_message_parse: can't process onionpacket ss=%s", - fmt_secret(tmpctx, &onion_ss)); - } + for(;;) { + ephemeral = op->ephemeralkey; + if (!unblind_onion(&next_path_key, ecdh, &ephemeral, &ss)) { + return tal_fmt(ctx, "onion_message_parse: can't unblind onionpacket"); + } - /* The raw payload is prepended with length in the modern onion. */ - cursor = rs->raw_payload; - max = tal_bytelen(rs->raw_payload); - maxlen = fromwire_bigsize(&cursor, &max); - if (!cursor) { - return tal_fmt(ctx, "onion_message_parse: Invalid hop payload %s", - tal_hex(tmpctx, rs->raw_payload)); - } - if (maxlen > max) { - return tal_fmt(ctx, "onion_message_parse: overlong hop payload %s", - tal_hex(tmpctx, rs->raw_payload)); - } + /* Now get onion shared secret and parse it. */ + ecdh(&ephemeral, &onion_ss); + rs = process_onionpacket(tmpctx, op, &onion_ss, NULL, 0); + if (!rs) { + return tal_fmt(ctx, "onion_message_parse: can't process onionpacket ss=%s", + fmt_secret(tmpctx, &onion_ss)); + } - om = fromwire_tlv_onionmsg_tlv(tmpctx, &cursor, &maxlen); - if (!om) { - return tal_fmt(ctx, "onion_message_parse: invalid onionmsg_tlv %s", - tal_hex(tmpctx, rs->raw_payload)); - } - if (rs->nextcase == ONION_END) { - *next_onion_msg = NULL; - *final_om = tal_steal(ctx, om); - /* Final enctlv is actually optional */ - if (!om->encrypted_recipient_data) { - *final_alias = *me; - *final_path_id = NULL; - } else if (!decrypt_final_onionmsg(ctx, &ss, - om->encrypted_recipient_data, me, - final_alias, - final_path_id)) { - return tal_fmt(ctx, - "onion_message_parse: failed to decrypt encrypted_recipient_data" - " %s", tal_hex(tmpctx, om->encrypted_recipient_data)); + /* The raw payload is prepended with length in the modern onion. */ + cursor = rs->raw_payload; + max = tal_bytelen(rs->raw_payload); + maxlen = fromwire_bigsize(&cursor, &max); + if (!cursor) { + return tal_fmt(ctx, "onion_message_parse: Invalid hop payload %s", + tal_hex(tmpctx, rs->raw_payload)); } - } else { - struct pubkey next_path_key; - - *final_om = NULL; - - /* BOLT #4: - * - if it is not the final node according to the onion encryption: - * - if the `onionmsg_tlv` contains other tlv fields than `encrypted_recipient_data`: - * - MUST ignore the message. - */ - if (tal_count(om->fields) != 1) { - return tal_fmt(ctx, "onion_message_parse: disallowed tlv field"); + if (maxlen > max) { + return tal_fmt(ctx, "onion_message_parse: overlong hop payload %s", + tal_hex(tmpctx, rs->raw_payload)); } - /* This fails as expected if no enctlv. */ - if (!decrypt_forwarding_onionmsg(path_key, &ss, om->encrypted_recipient_data, next_node, - &next_path_key)) { - return tal_fmt(ctx, - "onion_message_parse: invalid encrypted_recipient_data %s", - tal_hex(tmpctx, om->encrypted_recipient_data)); + om = fromwire_tlv_onionmsg_tlv(tmpctx, &cursor, &maxlen); + if (!om) { + return tal_fmt(ctx, "onion_message_parse: invalid onionmsg_tlv %s", + tal_hex(tmpctx, rs->raw_payload)); + } + if (rs->nextcase == ONION_END) { + *next_onion_msg = NULL; + *final_om = tal_steal(ctx, om); + /* Final enctlv is actually optional */ + if (!om->encrypted_recipient_data) { + *final_alias = *me; + *final_path_id = NULL; + } else if (!decrypt_final_onionmsg(ctx, &ss, + om->encrypted_recipient_data, me, + final_alias, + final_path_id)) { + return tal_fmt(ctx, + "onion_message_parse: failed to decrypt encrypted_recipient_data" + " %s", tal_hex(tmpctx, om->encrypted_recipient_data)); + } + } else { + + *final_om = NULL; + + /* BOLT #4: + * - if it is not the final node according to the onion encryption: + * - if the `onionmsg_tlv` contains other tlv fields than `encrypted_recipient_data`: + * - MUST ignore the message. + */ + if (tal_count(om->fields) != 1) { + return tal_fmt(ctx, "onion_message_parse: disallowed tlv field"); + } + + /* This fails as expected if no enctlv. */ + if (!decrypt_forwarding_onionmsg(&next_path_key, &ss, om->encrypted_recipient_data, next_node, + &next_path_key)) { + return tal_fmt(ctx, + "onion_message_parse: invalid encrypted_recipient_data %s", + tal_hex(tmpctx, om->encrypted_recipient_data)); + } + + if(next_node->is_pubkey && !pubkey_cmp(&next_node->pubkey, me)) { + op = rs->next; + continue; + + } else { + *next_onion_msg = towire_onion_message(ctx, + &next_path_key, + serialize_onionpacket(tmpctx, rs->next)); + } } - *next_onion_msg = towire_onion_message(ctx, - &next_path_key, - serialize_onionpacket(tmpctx, rs->next)); - } - /* Exactly one is set */ - assert(!*next_onion_msg + !*final_om == 1); - return NULL; + /* Exactly one is set */ + assert(!*next_onion_msg + !*final_om == 1); + return NULL; + } } diff --git a/tests/test_pay.py b/tests/test_pay.py index 54defd2a7d74..15fe1ca1b0ec 100644 --- a/tests/test_pay.py +++ b/tests/test_pay.py @@ -6125,6 +6125,23 @@ def test_offer_with_private_channels_multyhop2(node_factory): l1.rpc.pay(invoice) +def test_offer_with_private_channels_multyhop_with_dummy_hop(node_factory): + """We should be able to fetch an invoice through a private path with dummy hops and pay the invoice""" + l1, l2, l3, l4, l5 = node_factory.line_graph(5, fundchannel=False) + + node_factory.join_nodes([l1, l2], wait_for_announce=True) + node_factory.join_nodes([l2, l3], wait_for_announce=True) + node_factory.join_nodes([l3, l4], wait_for_announce=True) + node_factory.join_nodes([l3, l5], announce_channels=False) + wait_for(lambda: ['alias' in n for n in l4.rpc.listnodes()['nodes']] == [True, True, True, True]) + + offer = l5.rpc.call('offer', {'amount': '2msat', + 'description': 'test_offer_with_private_channels_multyhop2', + 'dev_paths': [[l3.info['id'], l5.info['id'], l5.info['id']]]})['bolt12'] + invoice = l1.rpc.fetchinvoice(offer=offer)["invoice"] + l1.rpc.pay(invoice) + + def diamond_network(node_factory): """Build a diamond, with a cheap route, that is exhausted. The first payment should try that route first, learn it's exhausted,