Skip to content

lnpeer: harden _check_unfulfilled_htlc - #10899

Open
ecdsa wants to merge 3 commits into
masterfrom
harden_check_unfulfilled_htlc
Open

lnpeer: harden _check_unfulfilled_htlc#10899
ecdsa wants to merge 3 commits into
masterfrom
harden_check_unfulfilled_htlc

Conversation

@ecdsa

@ecdsa ecdsa commented Aug 26, 2026

Copy link
Copy Markdown
Member

see commits

@ecdsa
ecdsa force-pushed the harden_check_unfulfilled_htlc branch from eab727a to 8940aef Compare August 26, 2026 12:52
@ecdsa ecdsa changed the title lnpeer: harden _check_unfulfilled_htlc, if we see a trampoline forward request lnpeer: harden _check_unfulfilled_htlc Aug 26, 2026
@ecdsa
ecdsa force-pushed the harden_check_unfulfilled_htlc branch 2 times, most recently from d4efb6b to 7ccce15 Compare August 26, 2026 13:10
@ecdsa ecdsa added this to the 4.8.2 milestone Aug 26, 2026
@ecdsa
ecdsa force-pushed the harden_check_unfulfilled_htlc branch from 7ccce15 to d0fd39b Compare August 27, 2026 15:20
Comment thread electrum/lnpeer.py
@ecdsa
ecdsa force-pushed the harden_check_unfulfilled_htlc branch 2 times, most recently from c93a565 to f999bd5 Compare August 27, 2026 17:48
@ecdsa
ecdsa marked this pull request as ready for review August 27, 2026 18:01
Comment thread electrum/lnworker.py
Comment on lines 2959 to 2964
# Payment key creation:
# * for regular forwarded htlcs -> "scid.hex() + ':%d' % htlc_id" [htlc key]
# * for trampoline forwarding -> "payment hash + payment secret from outer onion"
# * for trampoline forwarding -> "fwd:" + (payment hash + payment secret from outer onion).hex()
# * for final non-trampoline htlcs (we are receiver) -> "payment hash + payment secret from onion"
# * for final trampoline htlcs (we are receiver) -> 2. step grouping:
# 1. grouping of htlcs by "payments hash + outer onion payment secret", a 'multi-trampoline mpp part'.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we should introduce full domain-separation, so each category would have its own namespace.
I understand it is simpler not to touch the non-forwarding categories as that would definitely require a wallet DB upgrade -- whereas for forwarding, we can skip DB upgrades atm.

At the very least, please choose a prefix for "trampoline forwarding" that makes it clear it is for trampoline forwarding. E.g. fwd_t.
We could also add a prefix to regular forwarding now, e.g. fwd_r.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I now realize that this namespace separation does not prevent the attack we discussed. it is certainly cleaner, but I have yet to find a scenario where it would actually prevent something.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, the bug has nothing to do with payment_keys colliding. It is a timing race. see #10899 (comment)

Comment thread electrum/lnworker.py Outdated
Comment thread tests/test_lnpeer.py Outdated
@ecdsa
ecdsa force-pushed the harden_check_unfulfilled_htlc branch from f999bd5 to 396799a Compare August 29, 2026 10:36
ecdsa added 3 commits August 29, 2026 12:39
Apply the comparison of BOLT4 L547-L553 to forwarding requests too, not
only if we are the final recipient: the difference between the two onions
is the fee/cltv budget we are given (see _maybe_forward_trampoline).
Also tolerate an inner payload with missing fields.

Collapse the mpp and non-mpp cases while here: they are the same
comparison, as a sender not using mpp must set total_msat equal to
amt_to_forward (BOLT4 L406).
We must fail a HTLC to forward if its payment_hash matches
one of our invoices. Instead of allowing the HTLC to enter
a MPP set, we fail it before, in _check_unfulfilled_htlc.

Also rewrite test_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created:
The new test uses send_trampoline_htlc_to_forward and
only two peers, so it belongs in the TestPeerDirect class.
The new test is logically equivalent and 4~5 times faster.
This prevents collisions with payment_keys to an invoice of ours.

Note that this is not defense-in-depth: this commit does NOT
prevent a node from releasing the preimage, if it receives a
tiny trampoline HTLC to forward for one of its own invoices.
Indeed, the namespaced bucket would still enter the SETTLING
state.

What prevents the attack is the previous commit, and only that.
@ecdsa
ecdsa force-pushed the harden_check_unfulfilled_htlc branch from 396799a to 4af12fd Compare August 29, 2026 10:39
SomberNight added a commit that referenced this pull request Aug 31, 2026

@SomberNight SomberNight left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I merged the first commit into master in ddd22c8.

Comment thread tests/test_lnpeer.py
Comment on lines +1304 to +1310
async def test_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(self):
"""Alice holds an invoice created by Bob, hence she knows RHASH and Bob's payment_secret.
She sends Bob a dust htlc whose outer onion is addressed to Bob (using the invoice's
payment_secret, and claiming a tiny total_msat), but whose inner trampoline onion asks Bob
to *forward* the payment.
Bob must fail such htlcs, and he must not release the preimage.
"""

@SomberNight SomberNight Aug 31, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused about this test.
The commit message says:

Also rewrite test_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created:
The new test uses send_trampoline_htlc_to_forward and
only two peers, so it belongs in the TestPeerDirect class.
The new test is logically equivalent and 4~5 times faster.

Without the new check in lnpeer.py, the new test fails but the old test passes.
With that, how could the two tests be logically equivalent?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First I thought that the difference between the two tests is that the old one has the attacker send an htlc with matching paymenthash but different payment_secret, whereas in the new test both paymenthash and payment_secret match.

but now I think the whole payment_secret stuff and the combined payment_key colliding is a complete red herring.

The meaningful difference between the old test and the new test is the timing!
Try with this diff for the old test, now it will fail as the preimage gets released:

diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py
index db86707637..e78afc4429 100644
--- a/tests/test_lnpeer.py
+++ b/tests/test_lnpeer.py
@@ -2123,6 +2123,14 @@ class TestPeerForwarding(TestPeer):
         # now graph is linear: A <-> B <-> D
         graph = self.prepare_chans_and_peers_in_graph(graph_def)
         peers = graph.peers.values()
+
+        w2 = graph.workers['bob']
+        orig_maybe_forward_htlc_set = w2.maybe_forward_htlc_set
+        async def maybe_forward_htlc_set(*args, **kwargs):
+            await asyncio.sleep(0.15)
+            return await orig_maybe_forward_htlc_set(*args, **kwargs)
+        w2.maybe_forward_htlc_set = maybe_forward_htlc_set
+
         async def pay():
             lnaddr1, pay_req1 = self.prepare_invoice(
                 graph.workers['bob'],

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants