Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,7 @@ replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-d
// well).
go 1.25.5

// Temporary replace until dependent PR is merged in lightning-onion.
replace github.com/lightningnetwork/lightning-onion => github.com/joostjager/lightning-onion v0.0.0-20260312135706-2dd58e7b9794

retract v0.0.2
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bB
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/joostjager/lightning-onion v0.0.0-20260312135706-2dd58e7b9794 h1:xKnZDFhNa3yoJWF2XrSKzSx76qXd79FF46vA8Jym16s=
github.com/joostjager/lightning-onion v0.0.0-20260312135706-2dd58e7b9794/go.mod h1:nP85zMHG7c0si/eHBbSQpuDCtnIXfSvFrK3tW6YWzmU=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/jrick/logrotate v1.1.2 h1:6ePk462NCX7TfKtNp5JJ7MbA2YIslkpfgP03TlTYMN0=
Expand Down Expand Up @@ -370,8 +372,6 @@ github.com/lightninglabs/neutrino/cache v1.1.3 h1:rgnabC41W+XaPuBTQrdeFjFCCAVKh1
github.com/lightninglabs/neutrino/cache v1.1.3/go.mod h1:qxkJb+pUxR5p84jl5uIGFCR4dGdFkhNUwMSxw3EUWls=
github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display h1:Y2WiPkBS/00EiEg0qp0FhehxnQfk3vv8U6Xt3nN+rTY=
github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
github.com/lightningnetwork/lightning-onion v1.3.0 h1:FqILgHjD6euc/Muo1VOzZ4+XDPuFnw6EYROBq0rR/5c=
github.com/lightningnetwork/lightning-onion v1.3.0/go.mod h1:nP85zMHG7c0si/eHBbSQpuDCtnIXfSvFrK3tW6YWzmU=
github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI=
github.com/lightningnetwork/lnd/cert v1.2.2/go.mod h1:jQmFn/Ez4zhDgq2hnYSw8r35bqGVxViXhX6Cd7HXM6U=
github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0=
Expand Down
241 changes: 241 additions & 0 deletions htlcswitch/attributable_failure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package htlcswitch

import (
"bytes"
"testing"
"time"

"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)

// deriveSharedSecrets derives the shared secrets for each hop along the payment
// path using the session key, mirroring the logic of sphinx's internal
// generateSharedSecrets.
func deriveSharedSecrets(t *testing.T, paymentPath []*btcec.PublicKey,
sessionKey *btcec.PrivateKey) []sphinx.Hash256 {

t.Helper()

numHops := len(paymentPath)
secrets := make([]sphinx.Hash256, numHops)

ephemECDH := &sphinx.PrivKeyECDH{PrivKey: sessionKey}

// First hop.
ss, err := ephemECDH.ECDH(paymentPath[0])
require.NoError(t, err)
secrets[0] = ss

// Subsequent hops: derive the next ephemeral private key using the
// blinding factor.
for i := 1; i < numHops; i++ {
nextPriv, err := sphinx.NextEphemeralPriv(
ephemECDH, paymentPath[i-1],
)
require.NoError(t, err)

ephemECDH = &sphinx.PrivKeyECDH{PrivKey: nextPriv}

ss, err = ephemECDH.ECDH(paymentPath[i])
require.NoError(t, err)
secrets[i] = ss
}

return secrets
}

// TestAttributableFailureEndToEnd exercises the full encrypt → intermediate
// encrypt → decrypt flow with attribution data and validates that HoldTimes
// are correctly populated.
func TestAttributableFailureEndToEnd(t *testing.T) {
t.Parallel()

const numHops = 4

// Generate random node keys for the payment path.
paymentPath := make([]*btcec.PublicKey, numHops)
for i := 0; i < numHops; i++ {
privKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
paymentPath[i] = privKey.PubKey()
}

// Use a deterministic session key.
sessionKey, _ := btcec.PrivKeyFromBytes(
bytes.Repeat([]byte{0x42}, 32),
)

// Derive per-hop shared secrets.
sharedSecrets := deriveSharedSecrets(t, paymentPath, sessionKey)

// The failing node is hop index 2 (third node, 0-indexed).
failingHopIdx := 2

// Create a failure message at the failing hop.
failureMsg := lnwire.NewFailIncorrectDetails(1000, 100)

// Create the error encrypter at the failing hop, with a creation time
// slightly in the past to get a non-zero hold time.
failEncrypter := hop.NewSphinxErrorEncrypter(
paymentPath[failingHopIdx],
sharedSecrets[failingHopIdx],
)
failEncrypter.CreatedAt = time.Now().Add(-200 * time.Millisecond)

// Encrypt at the origin of the failure.
reason, attrData, err := failEncrypter.EncryptFirstHop(failureMsg)
require.NoError(t, err)
require.NotEmpty(t, reason)
require.NotEmpty(t, attrData, "attribution data should be populated")

// Wrap the attribution data in ExtraOpaqueData for transmission.
extraData, err := lnwire.AttrDataToExtraData(attrData)
require.NoError(t, err)

// Intermediate encrypt at each hop back to the sender.
for i := failingHopIdx - 1; i >= 0; i-- {
intermediateEnc := hop.NewSphinxErrorEncrypter(
paymentPath[i],
sharedSecrets[i],
)
// Set a slightly older creation time to simulate hold time.
intermediateEnc.CreatedAt = time.Now().Add(
-100 * time.Millisecond,
)

// Extract attr data from the extra data (as it would come from
// the wire message).
attrData, err = lnwire.ExtraDataToAttrData(extraData)
require.NoError(t, err)

reason, attrData, err = intermediateEnc.IntermediateEncrypt(
reason, attrData,
)
require.NoError(t, err)

extraData, err = lnwire.AttrDataToExtraData(attrData)
require.NoError(t, err)
}

// Now decrypt at the sender using the SphinxErrorDecrypter.
circuit := &sphinx.Circuit{
SessionKey: sessionKey,
PaymentPath: paymentPath,
}
decrypter := NewSphinxErrorDecrypter(circuit)

attrData, err = lnwire.ExtraDataToAttrData(extraData)
require.NoError(t, err)

fwdErr, err := decrypter.DecryptError(reason, attrData)
require.NoError(t, err)

// Verify the failure source is identified correctly.
// SenderIdx is 1-indexed (0 = self), so failing hop index 2 means
// SenderIdx = 3.
require.Equal(t, failingHopIdx+1, fwdErr.FailureSourceIdx,
"failure source index mismatch")

// Verify we got the right failure message back.
msg := fwdErr.WireMessage()
incorrectDetails, ok := msg.(*lnwire.FailIncorrectDetails)
require.True(t, ok, "expected FailIncorrectDetails, got %T",
fwdErr.WireMessage())
require.EqualValues(t, 1000, incorrectDetails.Amount())
require.EqualValues(t, 100, incorrectDetails.Height())

// Verify that HoldTimes are populated. We should have hold times for
// hops 1 through failingHopIdx (the failing node plus intermediates).
require.NotEmpty(t, fwdErr.HoldTimes,
"expected non-empty hold times")
}

// TestAttributableFailureWithoutAttrData tests that decryption works without
// attribution data (backward compatibility with non-attributable errors).
func TestAttributableFailureWithoutAttrData(t *testing.T) {
t.Parallel()

const numHops = 3

paymentPath := make([]*btcec.PublicKey, numHops)
for i := 0; i < numHops; i++ {
privKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
paymentPath[i] = privKey.PubKey()
}

sessionKey, _ := btcec.PrivKeyFromBytes(
bytes.Repeat([]byte{0x33}, 32),
)

sharedSecrets := deriveSharedSecrets(t, paymentPath, sessionKey)

// Failing hop is the last node.
failingHopIdx := numHops - 1

failureMsg := lnwire.NewFailIncorrectDetails(500, 50)

failEncrypter := hop.NewSphinxErrorEncrypter(
paymentPath[failingHopIdx],
sharedSecrets[failingHopIdx],
)

reason, _, err := failEncrypter.EncryptFirstHop(failureMsg)
require.NoError(t, err)

// Intermediate hops encrypt WITHOUT using attribution data (passing
// nil), simulating nodes that don't support attributable failures.
for i := failingHopIdx - 1; i >= 0; i-- {
intermediateEnc := hop.NewSphinxErrorEncrypter(
paymentPath[i],
sharedSecrets[i],
)

reason, _, err = intermediateEnc.IntermediateEncrypt(
reason, nil,
)
require.NoError(t, err)
}

// Decrypt at the sender without attribution data.
circuit := &sphinx.Circuit{
SessionKey: sessionKey,
PaymentPath: paymentPath,
}
decrypter := NewSphinxErrorDecrypter(circuit)

fwdErr, err := decrypter.DecryptError(reason, nil)
require.NoError(t, err)

// The failure source should still be correctly identified via the
// legacy HMAC-based mechanism.
require.Equal(t, failingHopIdx+1, fwdErr.FailureSourceIdx)

msg := fwdErr.WireMessage()
incorrectDetails, ok := msg.(*lnwire.FailIncorrectDetails)
require.True(t, ok)
require.EqualValues(t, 500, incorrectDetails.Amount())
}

// TestNewForwardingErrorHoldTimes verifies that NewForwardingError correctly
// stores and exposes HoldTimes.
func TestNewForwardingErrorHoldTimes(t *testing.T) {
t.Parallel()

holdTimes := []uint32{10, 20, 30, 40}
failure := lnwire.NewFailIncorrectDetails(100, 10)

fwdErr := NewForwardingError(failure, 3, holdTimes)

require.Equal(t, 3, fwdErr.FailureSourceIdx)
require.Equal(t, holdTimes, fwdErr.HoldTimes)
require.NotNil(t, fwdErr.WireMessage())

// With nil hold times.
fwdErr2 := NewForwardingError(failure, 1, nil)
require.Nil(t, fwdErr2.HoldTimes)
}
7 changes: 4 additions & 3 deletions htlcswitch/circuit.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,17 +199,18 @@ func (c *PaymentCircuit) Decode(r io.Reader) error {

case hop.EncrypterTypeSphinx:
// Sphinx encrypter was used as this is a forwarded HTLC.
c.ErrorEncrypter = hop.NewSphinxErrorEncrypter()
c.ErrorEncrypter = hop.NewSphinxErrorEncrypterUninitialized()

case hop.EncrypterTypeMock:
// Test encrypter.
c.ErrorEncrypter = NewMockObfuscator()

case hop.EncrypterTypeIntroduction:
c.ErrorEncrypter = hop.NewIntroductionErrorEncrypter()
c.ErrorEncrypter =
hop.NewIntroductionErrorEncrypterUninitialized()

case hop.EncrypterTypeRelaying:
c.ErrorEncrypter = hop.NewRelayingErrorEncrypter()
c.ErrorEncrypter = hop.NewRelayingErrorEncrypterUninitialized()

default:
return UnknownEncrypterType(encrypterType)
Expand Down
10 changes: 4 additions & 6 deletions htlcswitch/circuit_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,9 @@ type CircuitMapConfig struct {
FetchClosedChannels func(
pendingOnly bool) ([]*channeldb.ChannelCloseSummary, error)

// ExtractErrorEncrypter derives the shared secret used to encrypt
// errors from the obfuscator's ephemeral public key.
ExtractErrorEncrypter hop.ErrorEncrypterExtracter
// ExtractSharedSecret derives the shared secret used to encrypt errors
// from the obfuscator's ephemeral public key.
ExtractSharedSecret hop.SharedSecretGenerator

// CheckResolutionMsg checks whether a given resolution message exists
// for the passed CircuitKey.
Expand Down Expand Up @@ -632,9 +632,7 @@ func (cm *circuitMap) decodeCircuit(v []byte) (*PaymentCircuit, error) {

// Otherwise, we need to reextract the encrypter, so that the shared
// secret is rederived from what was decoded.
err := circuit.ErrorEncrypter.Reextract(
cm.cfg.ExtractErrorEncrypter,
)
err := circuit.ErrorEncrypter.Reextract(cm.cfg.ExtractSharedSecret)
if err != nil {
return nil, err
}
Expand Down
31 changes: 16 additions & 15 deletions htlcswitch/circuit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,17 @@ func initTestExtracter() {
onionProcessor := newOnionProcessor(nil)
defer onionProcessor.Stop()

obfuscator, _ := onionProcessor.ExtractErrorEncrypter(
sharedSecret, failCode := onionProcessor.ExtractSharedSecret(
testEphemeralKey,
)

sphinxExtracter, ok := obfuscator.(*hop.SphinxErrorEncrypter)
if !ok {
panic("did not extract sphinx error encrypter")
if failCode != lnwire.CodeNone {
panic("did not extract shared secret")
}

testExtracter = sphinxExtracter
testExtracter = hop.NewSphinxErrorEncrypter(
testEphemeralKey, sharedSecret,
)

// We also set this error extracter on startup, otherwise it will be nil
// at compile-time.
Expand Down Expand Up @@ -106,10 +107,10 @@ func newCircuitMap(t *testing.T, resMsg bool) (*htlcswitch.CircuitMapConfig,

db := makeCircuitDB(t, "")
circuitMapCfg := &htlcswitch.CircuitMapConfig{
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractErrorEncrypter: onionProcessor.ExtractErrorEncrypter,
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractSharedSecret: onionProcessor.ExtractSharedSecret,
}

if resMsg {
Expand Down Expand Up @@ -216,7 +217,7 @@ func TestHalfCircuitSerialization(t *testing.T) {
// encrypters, this will be a NOP.
if circuit2.ErrorEncrypter != nil {
err := circuit2.ErrorEncrypter.Reextract(
onionProcessor.ExtractErrorEncrypter,
onionProcessor.ExtractSharedSecret,
)
if err != nil {
t.Fatalf("unable to reextract sphinx error "+
Expand Down Expand Up @@ -643,11 +644,11 @@ func restartCircuitMap(t *testing.T, cfg *htlcswitch.CircuitMapConfig) (
// Reinitialize circuit map with same db path.
db := makeCircuitDB(t, dbPath)
cfg2 := &htlcswitch.CircuitMapConfig{
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractErrorEncrypter: cfg.ExtractErrorEncrypter,
CheckResolutionMsg: cfg.CheckResolutionMsg,
DB: db,
FetchAllOpenChannels: db.ChannelStateDB().FetchAllOpenChannels,
FetchClosedChannels: db.ChannelStateDB().FetchClosedChannels,
ExtractSharedSecret: cfg.ExtractSharedSecret,
CheckResolutionMsg: cfg.CheckResolutionMsg,
}
cm2, err := htlcswitch.NewCircuitMap(cfg2)
require.NoError(t, err, "unable to recreate persistent circuit map")
Expand Down
Loading
Loading