-
Notifications
You must be signed in to change notification settings - Fork 3.5k
lnpeer: harden _check_unfulfilled_htlc #10899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,6 +109,63 @@ def register(cls) -> 'SenderHtlcResolvedTracker': | |
| # ElectrumTestCase.tearDown() will clean up after us using callback_mgr.clear_all_callbacks() | ||
| return tracker | ||
|
|
||
| def send_trampoline_htlc_to_forward( | ||
| *, | ||
| p1: Peer, | ||
| w1: MockLNWallet, | ||
| w2: MockLNWallet, | ||
| chan: Channel, | ||
| payment_hash: bytes, | ||
| outer_payment_secret: bytes, | ||
| htlc_amount_msat: int, | ||
| amt_to_forward: int, | ||
| inner_cltv_delta: int = 144, | ||
| outgoing_node_id: bytes = None, | ||
| ) -> UpdateAddHtlc: | ||
| """Sends an htlc from w1 to w2 whose outer onion is a final hop to w2, claiming | ||
| total_msat == htlc_amount_msat, and whose inner trampoline onion asks w2 to *forward* | ||
| amt_to_forward to outgoing_node_id, to be delivered at (cltv_abs - inner_cltv_delta). | ||
| A negative inner_cltv_delta asks w2 to lock up funds for longer than it is given. | ||
| note: nothing constrains the outer onion's total_msat here. w2 is not the final | ||
| recipient, so he has no invoice to validate such an htlc against. | ||
| """ | ||
| cltv_abs = w1.network.get_local_height() + 500 | ||
| next_node_id = outgoing_node_id or privkey_to_pubkey(os.urandom(32)) | ||
| trampoline_hops_data = [ | ||
| OnionHopsDataSingle(payload={ | ||
| "amt_to_forward": {"amt_to_forward": amt_to_forward}, | ||
| "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs - inner_cltv_delta}, | ||
| "outgoing_node_id": {"outgoing_node_id": next_node_id}, | ||
| }), | ||
| OnionHopsDataSingle(payload={ | ||
| "amt_to_forward": {"amt_to_forward": amt_to_forward}, | ||
| "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs - inner_cltv_delta}, | ||
| "payment_data": {"payment_secret": os.urandom(32), "total_msat": amt_to_forward}, | ||
| }), | ||
| ] | ||
| trampoline_onion = electrum.lnonion.new_onion_packet( | ||
| [w2.node_keypair.pubkey, next_node_id], | ||
| os.urandom(32), | ||
| trampoline_hops_data, | ||
| associated_data=payment_hash, | ||
| trampoline=True, | ||
| ) | ||
| hops_data = [OnionHopsDataSingle(payload={ | ||
| "amt_to_forward": {"amt_to_forward": htlc_amount_msat}, | ||
| "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs}, | ||
| "payment_data": {"payment_secret": outer_payment_secret, "total_msat": htlc_amount_msat}, | ||
| "trampoline_onion_packet": {"trampoline_onion_packet": trampoline_onion.to_bytes()}, | ||
| })] | ||
| onion = electrum.lnonion.new_onion_packet( | ||
| [w2.node_keypair.pubkey], os.urandom(32), hops_data, associated_data=payment_hash) | ||
| return p1.send_htlc( | ||
| chan=chan, | ||
| payment_hash=payment_hash, | ||
| amount_msat=htlc_amount_msat, | ||
| cltv_abs=cltv_abs, | ||
| onion=onion, | ||
| ) | ||
|
|
||
|
|
||
| class TestPeer(ElectrumTestCase): | ||
| TESTNET = True | ||
|
|
@@ -1157,6 +1214,166 @@ async def f(): | |
| with self.assertRaises(SuccessfulTest): | ||
| await f() | ||
|
|
||
| async def test_trampoline_forward_request_must_not_exceed_the_budget_it_is_given(self): | ||
| # this test is about forwarding. It is in TestPeerDirect because it only requires 2 peers. | ||
| """An htlc whose inner trampoline onion asks us to relay is not validated against any | ||
| invoice of ours: what we are paid is what the outer onion says, and what we are asked to | ||
| deliver is what the inner onion says. The difference between the two is the fee/cltv budget | ||
| we are given, so the outer onion has to cover the inner one. Otherwise a single dust htlc | ||
| claiming a tiny total_msat completes a relay set, and the negative budget that follows | ||
| reaches the asserts in pay_to_node. | ||
| """ | ||
| graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) | ||
| p1, p2 = graph.peers.values() | ||
| w1, w2 = graph.workers.values() | ||
| alice_channel = graph.channels[('alice', 'bob')][0] | ||
| w2.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True | ||
| w2.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = True | ||
|
|
||
| payment_keys_used = [] | ||
| orig_update_or_create_mpp = w2.update_or_create_mpp_with_received_htlc | ||
| def update_or_create_mpp(*, payment_key, **kwargs): | ||
| payment_keys_used.append(payment_key) | ||
| return orig_update_or_create_mpp(payment_key=payment_key, **kwargs) | ||
| w2.update_or_create_mpp_with_received_htlc = update_or_create_mpp | ||
|
|
||
| async def attack(): | ||
| await util.wait_for2(p1.initialized, 1) | ||
| await util.wait_for2(p2.initialized, 1) | ||
|
|
||
| with self.subTest(msg="relay request claiming a total_msat below amt_to_forward"): | ||
| # the htlc claims to forward of 100_000_000 msat while paying 1000 msat | ||
| send_trampoline_htlc_to_forward( | ||
| p1=p1, w1=w1, w2=w2, chan=alice_channel, | ||
| payment_hash=os.urandom(32), | ||
| outer_payment_secret=os.urandom(32), | ||
| htlc_amount_msat=1000, | ||
| amt_to_forward=100_000_000, | ||
| ) | ||
| await wait_for_htlcs_failed(num_htlcs=1) | ||
| self.assertEqual([], payment_keys_used, "dust htlc entered a relay htlc set") | ||
|
|
||
| with self.subTest(msg="relay request demanding an outgoing cltv beyond the incoming one"): | ||
| # Bob is asked to relay to a node he has a channel with, so that | ||
| # _maybe_forward_trampoline takes its direct-channel path, which skips the budget | ||
| # checks. The resulting negative cltv budget would reach an assert in pay_to_node, | ||
| # i.e. it would get reported as a bug of ours. | ||
| send_trampoline_htlc_to_forward( | ||
| p1=p1, w1=w1, w2=w2, chan=alice_channel, | ||
| payment_hash=os.urandom(32), | ||
| outer_payment_secret=os.urandom(32), | ||
| htlc_amount_msat=1000, | ||
| amt_to_forward=1000, | ||
| inner_cltv_delta=-144, | ||
| outgoing_node_id=w1.node_keypair.pubkey, | ||
| ) | ||
| await wait_for_htlcs_failed(num_htlcs=2) | ||
| self.assertEqual([], payment_keys_used, "htlc entered a relay htlc set") | ||
| self.assertEqual([], crash_reports, "attacker-chosen cltv reached an assert") | ||
|
|
||
| raise SuccessfulTest() | ||
|
|
||
| async def f(): | ||
| async with OldTaskGroup() as group: | ||
| await group.spawn(p1._message_loop()) | ||
| await group.spawn(p1.htlc_switch()) | ||
| await group.spawn(p2._message_loop()) | ||
| await group.spawn(p2.htlc_switch()) | ||
| await asyncio.sleep(0.01) | ||
| await group.spawn(attack()) | ||
|
|
||
| crash_reports = [] | ||
| def send_exception_to_crash_reporter(e): | ||
| crash_reports.append(str(e)) | ||
|
|
||
| htlc_tracker = SenderHtlcResolvedTracker.register() | ||
|
|
||
| async def wait_for_htlcs_failed(*, num_htlcs: int): | ||
| async with util.async_timeout(5): | ||
| while htlc_tracker.num_failed < num_htlcs: | ||
| await htlc_tracker.resolved_event.wait() | ||
| # give Bob some extra time to (wrongly) act on the htlc | ||
| await asyncio.sleep(0.3) | ||
|
|
||
| with mock.patch.object(util, "send_exception_to_crash_reporter", | ||
| side_effect=send_exception_to_crash_reporter): | ||
| with self.assertRaises(SuccessfulTest): | ||
| await f() | ||
|
|
||
|
|
||
| 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. | ||
| """ | ||
|
Comment on lines
+1304
to
+1310
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am confused about this test.
Without the new check in lnpeer.py, the new test fails but the old test passes.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 but now I think the whole The meaningful difference between the old test and the new test is the timing! 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'],
|
||
| graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) | ||
| p1, p2 = graph.peers.values() | ||
| w1, w2 = graph.workers.values() | ||
| alice_channel = graph.channels[('alice', 'bob')][0] | ||
| w2.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True | ||
| w2.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS = True | ||
|
|
||
| # The forwarding callback is spawned as a task *after* the htlc set has been moved to | ||
| # SETTLING, and a SETTLING set is settled as soon as we have a preimage for its RHASH. | ||
| # Delay the callback, modelling another peer's htlc_switch iteration (the htlc sets are | ||
| # shared between peers) getting to the SETTLING set before the callback records its failure. | ||
| 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 | ||
|
|
||
| payment_keys_used = [] | ||
| orig_update_or_create_mpp = w2.update_or_create_mpp_with_received_htlc | ||
| def update_or_create_mpp(*, payment_key, **kwargs): | ||
| payment_keys_used.append(payment_key) | ||
| return orig_update_or_create_mpp(payment_key=payment_key, **kwargs) | ||
| w2.update_or_create_mpp_with_received_htlc = update_or_create_mpp | ||
|
|
||
| htlc_tracker = SenderHtlcResolvedTracker.register() | ||
| async def wait_for_htlc_resolved(): | ||
| async with util.async_timeout(5): | ||
| while htlc_tracker.num_success + htlc_tracker.num_failed < 1: | ||
| await htlc_tracker.resolved_event.wait() | ||
|
|
||
| async def attack(): | ||
| await util.wait_for2(p1.initialized, 1) | ||
| await util.wait_for2(p2.initialized, 1) | ||
| lnaddr, _pay_req = self.prepare_invoice(w2, amount_msat=100_000_000) | ||
| payment_hash = lnaddr.paymenthash | ||
| self.assertIsNotNone(w2.get_preimage(payment_hash)) | ||
| invoice_payment_key = (payment_hash + lnaddr.payment_secret).hex() | ||
| # note: the amounts of the two onions are consistent with each other, so that the htlc | ||
| # is only stopped by the checks this test is about. | ||
| send_trampoline_htlc_to_forward( | ||
| p1=p1, w1=w1, w2=w2, chan=alice_channel, | ||
| payment_hash=payment_hash, | ||
| outer_payment_secret=lnaddr.payment_secret, | ||
| htlc_amount_msat=1000, | ||
| amt_to_forward=1000, | ||
| ) | ||
| await wait_for_htlc_resolved() | ||
| self.assertEqual(0, htlc_tracker.num_success, "Bob settled a relay request htlc") | ||
| self.assertIsNone(w1.get_preimage(payment_hash), "Bob released the preimage") | ||
| self.assertEqual(PR_UNPAID, w2.get_payment_status(payment_hash, direction=RECEIVED)) | ||
| self.assertNotIn(invoice_payment_key, payment_keys_used, "htlc was bucketed together with our invoice") | ||
| self.assertEqual([], payment_keys_used) | ||
| raise SuccessfulTest() | ||
|
|
||
| async def f(): | ||
| async with OldTaskGroup() as group: | ||
| await group.spawn(p1._message_loop()) | ||
| await group.spawn(p1.htlc_switch()) | ||
| await group.spawn(p2._message_loop()) | ||
| await group.spawn(p2.htlc_switch()) | ||
| await asyncio.sleep(0.01) | ||
| await group.spawn(attack()) | ||
|
|
||
| with self.assertRaises(SuccessfulTest): | ||
| await f() | ||
|
|
||
| async def test_mpp_cleanup_after_expiry(self): | ||
| """ | ||
| 1. Alice sends two HTLCs to Bob, not reaching total_msat, and eventually they MPP_TIMEOUT | ||
|
|
@@ -1968,57 +2185,6 @@ async def f(): | |
| with self.assertRaises(PaymentDone): | ||
| await f() | ||
|
|
||
| async def test_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(self): | ||
| # This test checks that the following attack does not work: | ||
| # - Bob creates payment request with HASH1, for 1 BTC; and gives the payreq to Alice | ||
| # - Alice sends htlc A->B->D, for 100k sat, with HASH1 | ||
| # - Bob must not release the preimage of HASH1 | ||
| graph_def = self.GRAPH_DEFINITIONS['square_graph'] | ||
| graph_def.pop('carol') | ||
| graph_def['alice']['channels'].pop('carol') | ||
| # now graph is linear: A <-> B <-> D | ||
| graph = self.prepare_chans_and_peers_in_graph(graph_def) | ||
| peers = graph.peers.values() | ||
| async def pay(): | ||
| lnaddr1, pay_req1 = self.prepare_invoice( | ||
| graph.workers['bob'], | ||
| amount_msat=100_000_000_000, | ||
| ) | ||
| lnaddr2, pay_req2 = self.prepare_invoice( | ||
| graph.workers['dave'], | ||
| amount_msat=100_000_000, | ||
| payment_hash=lnaddr1.paymenthash, # Dave is cooperating with Alice, and he reuses Bob's hash | ||
| include_routing_hints=True, | ||
| ) | ||
| with self.subTest(msg="try to make Bob forward in legacy (non-trampoline) mode"): | ||
| result, log = await graph.workers['alice'].pay_invoice(pay_req2, attempts=1) | ||
| self.assertFalse(result) | ||
| self.assertEqual(OnionFailureCode.TEMPORARY_NODE_FAILURE, log[0].failure_msg.code) | ||
| self.assertEqual(None, graph.workers['alice'].get_preimage(lnaddr1.paymenthash)) | ||
| with self.subTest(msg="try to make Bob forward in trampoline mode"): | ||
| # declare Bob as trampoline forwarding node | ||
| electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = { | ||
| graph.workers['bob'].name: LNPeerAddr(host="127.0.0.1", port=9735, pubkey=graph.workers['bob'].node_keypair.pubkey), | ||
| } | ||
| await self._activate_trampoline(graph.workers['alice']) | ||
| result, log = await graph.workers['alice'].pay_invoice(pay_req2, attempts=5) | ||
| self.assertFalse(result) | ||
| self.assertEqual(OnionFailureCode.TEMPORARY_NODE_FAILURE, log[0].failure_msg.code) | ||
| self.assertEqual(None, graph.workers['alice'].get_preimage(lnaddr1.paymenthash)) | ||
| raise SuccessfulTest() | ||
|
|
||
| async def f(): | ||
| async with OldTaskGroup() as group: | ||
| for peer in peers: | ||
| await group.spawn(peer._message_loop()) | ||
| await group.spawn(peer.htlc_switch()) | ||
| for peer in peers: | ||
| await peer.initialized | ||
| await group.spawn(pay()) | ||
|
|
||
| with self.assertRaises(SuccessfulTest): | ||
| await f() | ||
|
|
||
| async def test_payment_with_temp_channel_failure_and_liquidity_hints(self): | ||
| # prepare channels such that a temporary channel failure happens at c->d | ||
| graph_definition = self.GRAPH_DEFINITIONS['square_graph'] | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.