Skip to content

Commit 877018a

Browse files
committed
htlcswitch+lnwire: add attributable failure tests
Add test coverage for the new attributable failures feature: - lnwire/attr_data_test.go: round-trip, TLV type, empty/nil cases - hop/error_encryptor_test.go: encode/decode, backward compat, hold time, reextract, introduction/relaying encrypter round-trips - htlcswitch/attributable_failure_test.go: end-to-end encrypt/decrypt with attribution data, backward compat without attr data, hold times
1 parent 045de99 commit 877018a

3 files changed

Lines changed: 533 additions & 0 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package htlcswitch
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
"time"
7+
8+
"github.com/btcsuite/btcd/btcec/v2"
9+
sphinx "github.com/lightningnetwork/lightning-onion"
10+
"github.com/lightningnetwork/lnd/htlcswitch/hop"
11+
"github.com/lightningnetwork/lnd/lnwire"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// deriveSharedSecrets derives the shared secrets for each hop along the payment
16+
// path using the session key, mirroring the logic of sphinx's internal
17+
// generateSharedSecrets.
18+
func deriveSharedSecrets(t *testing.T, paymentPath []*btcec.PublicKey,
19+
sessionKey *btcec.PrivateKey) []sphinx.Hash256 {
20+
21+
t.Helper()
22+
23+
numHops := len(paymentPath)
24+
secrets := make([]sphinx.Hash256, numHops)
25+
26+
ephemECDH := &sphinx.PrivKeyECDH{PrivKey: sessionKey}
27+
28+
// First hop.
29+
ss, err := ephemECDH.ECDH(paymentPath[0])
30+
require.NoError(t, err)
31+
secrets[0] = ss
32+
33+
// Subsequent hops: derive the next ephemeral private key using the
34+
// blinding factor.
35+
for i := 1; i < numHops; i++ {
36+
nextPriv, err := sphinx.NextEphemeralPriv(
37+
ephemECDH, paymentPath[i-1],
38+
)
39+
require.NoError(t, err)
40+
41+
ephemECDH = &sphinx.PrivKeyECDH{PrivKey: nextPriv}
42+
43+
ss, err = ephemECDH.ECDH(paymentPath[i])
44+
require.NoError(t, err)
45+
secrets[i] = ss
46+
}
47+
48+
return secrets
49+
}
50+
51+
// TestAttributableFailureEndToEnd exercises the full encrypt → intermediate
52+
// encrypt → decrypt flow with attribution data and validates that HoldTimes
53+
// are correctly populated.
54+
func TestAttributableFailureEndToEnd(t *testing.T) {
55+
t.Parallel()
56+
57+
const numHops = 4
58+
59+
// Generate random node keys for the payment path.
60+
paymentPath := make([]*btcec.PublicKey, numHops)
61+
for i := 0; i < numHops; i++ {
62+
privKey, err := btcec.NewPrivateKey()
63+
require.NoError(t, err)
64+
paymentPath[i] = privKey.PubKey()
65+
}
66+
67+
// Use a deterministic session key.
68+
sessionKey, _ := btcec.PrivKeyFromBytes(
69+
bytes.Repeat([]byte{0x42}, 32),
70+
)
71+
72+
// Derive per-hop shared secrets.
73+
sharedSecrets := deriveSharedSecrets(t, paymentPath, sessionKey)
74+
75+
// The failing node is hop index 2 (third node, 0-indexed).
76+
failingHopIdx := 2
77+
78+
// Create a failure message at the failing hop.
79+
failureMsg := lnwire.NewFailIncorrectDetails(1000, 100)
80+
81+
// Create the error encrypter at the failing hop, with a creation time
82+
// slightly in the past to get a non-zero hold time.
83+
failEncrypter := hop.NewSphinxErrorEncrypter(
84+
paymentPath[failingHopIdx],
85+
sharedSecrets[failingHopIdx],
86+
)
87+
failEncrypter.CreatedAt = time.Now().Add(-200 * time.Millisecond)
88+
89+
// Encrypt at the origin of the failure.
90+
reason, attrData, err := failEncrypter.EncryptFirstHop(failureMsg)
91+
require.NoError(t, err)
92+
require.NotEmpty(t, reason)
93+
require.NotEmpty(t, attrData, "attribution data should be populated")
94+
95+
// Wrap the attribution data in ExtraOpaqueData for transmission.
96+
extraData, err := lnwire.AttrDataToExtraData(attrData)
97+
require.NoError(t, err)
98+
99+
// Intermediate encrypt at each hop back to the sender.
100+
for i := failingHopIdx - 1; i >= 0; i-- {
101+
intermediateEnc := hop.NewSphinxErrorEncrypter(
102+
paymentPath[i],
103+
sharedSecrets[i],
104+
)
105+
// Set a slightly older creation time to simulate hold time.
106+
intermediateEnc.CreatedAt = time.Now().Add(
107+
-100 * time.Millisecond,
108+
)
109+
110+
// Extract attr data from the extra data (as it would come from
111+
// the wire message).
112+
attrData, err = lnwire.ExtraDataToAttrData(extraData)
113+
require.NoError(t, err)
114+
115+
reason, attrData, err = intermediateEnc.IntermediateEncrypt(
116+
reason, attrData,
117+
)
118+
require.NoError(t, err)
119+
120+
extraData, err = lnwire.AttrDataToExtraData(attrData)
121+
require.NoError(t, err)
122+
}
123+
124+
// Now decrypt at the sender using the SphinxErrorDecrypter.
125+
circuit := &sphinx.Circuit{
126+
SessionKey: sessionKey,
127+
PaymentPath: paymentPath,
128+
}
129+
decrypter := NewSphinxErrorDecrypter(circuit)
130+
131+
attrData, err = lnwire.ExtraDataToAttrData(extraData)
132+
require.NoError(t, err)
133+
134+
fwdErr, err := decrypter.DecryptError(reason, attrData)
135+
require.NoError(t, err)
136+
137+
// Verify the failure source is identified correctly.
138+
// SenderIdx is 1-indexed (0 = self), so failing hop index 2 means
139+
// SenderIdx = 3.
140+
require.Equal(t, failingHopIdx+1, fwdErr.FailureSourceIdx,
141+
"failure source index mismatch")
142+
143+
// Verify we got the right failure message back.
144+
msg := fwdErr.WireMessage()
145+
incorrectDetails, ok := msg.(*lnwire.FailIncorrectDetails)
146+
require.True(t, ok, "expected FailIncorrectDetails, got %T",
147+
fwdErr.WireMessage())
148+
require.EqualValues(t, 1000, incorrectDetails.Amount())
149+
require.EqualValues(t, 100, incorrectDetails.Height())
150+
151+
// Verify that HoldTimes are populated. We should have hold times for
152+
// hops 1 through failingHopIdx (the failing node plus intermediates).
153+
require.NotEmpty(t, fwdErr.HoldTimes,
154+
"expected non-empty hold times")
155+
}
156+
157+
// TestAttributableFailureWithoutAttrData tests that decryption works without
158+
// attribution data (backward compatibility with non-attributable errors).
159+
func TestAttributableFailureWithoutAttrData(t *testing.T) {
160+
t.Parallel()
161+
162+
const numHops = 3
163+
164+
paymentPath := make([]*btcec.PublicKey, numHops)
165+
for i := 0; i < numHops; i++ {
166+
privKey, err := btcec.NewPrivateKey()
167+
require.NoError(t, err)
168+
paymentPath[i] = privKey.PubKey()
169+
}
170+
171+
sessionKey, _ := btcec.PrivKeyFromBytes(
172+
bytes.Repeat([]byte{0x33}, 32),
173+
)
174+
175+
sharedSecrets := deriveSharedSecrets(t, paymentPath, sessionKey)
176+
177+
// Failing hop is the last node.
178+
failingHopIdx := numHops - 1
179+
180+
failureMsg := lnwire.NewFailIncorrectDetails(500, 50)
181+
182+
failEncrypter := hop.NewSphinxErrorEncrypter(
183+
paymentPath[failingHopIdx],
184+
sharedSecrets[failingHopIdx],
185+
)
186+
187+
reason, _, err := failEncrypter.EncryptFirstHop(failureMsg)
188+
require.NoError(t, err)
189+
190+
// Intermediate hops encrypt WITHOUT using attribution data (passing
191+
// nil), simulating nodes that don't support attributable failures.
192+
for i := failingHopIdx - 1; i >= 0; i-- {
193+
intermediateEnc := hop.NewSphinxErrorEncrypter(
194+
paymentPath[i],
195+
sharedSecrets[i],
196+
)
197+
198+
reason, _, err = intermediateEnc.IntermediateEncrypt(
199+
reason, nil,
200+
)
201+
require.NoError(t, err)
202+
}
203+
204+
// Decrypt at the sender without attribution data.
205+
circuit := &sphinx.Circuit{
206+
SessionKey: sessionKey,
207+
PaymentPath: paymentPath,
208+
}
209+
decrypter := NewSphinxErrorDecrypter(circuit)
210+
211+
fwdErr, err := decrypter.DecryptError(reason, nil)
212+
require.NoError(t, err)
213+
214+
// The failure source should still be correctly identified via the
215+
// legacy HMAC-based mechanism.
216+
require.Equal(t, failingHopIdx+1, fwdErr.FailureSourceIdx)
217+
218+
msg := fwdErr.WireMessage()
219+
incorrectDetails, ok := msg.(*lnwire.FailIncorrectDetails)
220+
require.True(t, ok)
221+
require.EqualValues(t, 500, incorrectDetails.Amount())
222+
}
223+
224+
// TestNewForwardingErrorHoldTimes verifies that NewForwardingError correctly
225+
// stores and exposes HoldTimes.
226+
func TestNewForwardingErrorHoldTimes(t *testing.T) {
227+
t.Parallel()
228+
229+
holdTimes := []uint32{10, 20, 30, 40}
230+
failure := lnwire.NewFailIncorrectDetails(100, 10)
231+
232+
fwdErr := NewForwardingError(failure, 3, holdTimes)
233+
234+
require.Equal(t, 3, fwdErr.FailureSourceIdx)
235+
require.Equal(t, holdTimes, fwdErr.HoldTimes)
236+
require.NotNil(t, fwdErr.WireMessage())
237+
238+
// With nil hold times.
239+
fwdErr2 := NewForwardingError(failure, 1, nil)
240+
require.Nil(t, fwdErr2.HoldTimes)
241+
}

0 commit comments

Comments
 (0)