routing: Use NUMS point for blinded paths

To be able to do MPP payment to multiple blinded routes we need
to add a constant dummy hop as a final hop to every blined path.
This is used when sending or querying a blinded path, to let the
pathfinder be able to send MPP payments over different blinded
routes. For this dummy final hop we use a NUMS key so that we
are sure no other node can use this blinded pubkey either in a
normal or blinded route.
Moreover this helps us handling the mission control data for
blinded paths correctly because we always consider the blinded
pubkey pairs which are registered with mission control when
a payment to a blinded path fails.
This commit is contained in:
ziggie 2024-12-09 22:08:43 +01:00 committed by Oliver Gugger
parent 0b90d3c3c2
commit eb93eb7ee9
No known key found for this signature in database
GPG Key ID: 8E4256593F177720
4 changed files with 259 additions and 60 deletions

View File

@ -1,17 +1,27 @@
package routing
import (
"bytes"
"errors"
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/channeldb/models"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
// BlindedPathNUMSHex is the hex encoded version of the blinded path target
// NUMs key (in compressed format) which has no known private key.
// This was generated using the following script:
// https://github.com/lightninglabs/lightning-node-connect/tree/master/
// mailbox/numsgen, with the seed phrase "Lightning Blinded Path".
const BlindedPathNUMSHex = "02667a98ef82ecb522f803b17a74f14508a48b25258f9831" +
"dd6e95f5e299dfd54e"
var (
// ErrNoBlindedPath is returned when the blinded path in a blinded
// payment is missing.
@ -25,6 +35,14 @@ var (
// ErrHTLCRestrictions is returned when a blinded path has invalid
// HTLC maximum and minimum values.
ErrHTLCRestrictions = errors.New("invalid htlc minimum and maximum")
// BlindedPathNUMSKey is a NUMS key (nothing up my sleeves number) that
// has no known private key.
BlindedPathNUMSKey = input.MustParsePubKey(BlindedPathNUMSHex)
// CompressedBlindedPathNUMSKey is the compressed version of the
// BlindedPathNUMSKey.
CompressedBlindedPathNUMSKey = BlindedPathNUMSKey.SerializeCompressed()
)
// BlindedPaymentPathSet groups the data we need to handle sending to a set of
@ -70,7 +88,9 @@ type BlindedPaymentPathSet struct {
}
// NewBlindedPaymentPathSet constructs a new BlindedPaymentPathSet from a set of
// BlindedPayments.
// BlindedPayments. For blinded paths which have more than one single hop a
// dummy hop via a NUMS key is appeneded to allow for MPP path finding via
// multiple blinded paths.
func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet,
error) {
@ -103,36 +123,53 @@ func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet,
}
}
// Derive an ephemeral target priv key that will be injected into each
// blinded path final hop.
targetPriv, err := btcec.NewPrivateKey()
if err != nil {
return nil, err
// Deep copy the paths to avoid mutating the original paths.
pathSet := make([]*BlindedPayment, len(paths))
for i, path := range paths {
pathSet[i] = path.deepCopy()
}
targetPub := targetPriv.PubKey()
var (
pathSet = paths
finalCLTVDelta uint16
)
// If any provided blinded path only has a single hop (ie, the
// destination node is also the introduction node), then we discard all
// other paths since we know the real pub key of the destination node.
// We also then set the final CLTV delta to the path's delta since
// there are no other edge hints that will account for it. For a single
// hop path, there is also no need for the pseudo target pub key
// replacement, so our target pub key in this case just remains the
// real introduction node ID.
for _, path := range paths {
if len(path.BlindedPath.BlindedHops) != 1 {
continue
// For blinded paths we use the NUMS key as a target if the blinded
// path has more hops than just the introduction node.
targetPub := &BlindedPathNUMSKey
var finalCLTVDelta uint16
// In case the paths do NOT include a single hop route we append a
// dummy hop via a NUMS key to allow for MPP path finding via multiple
// blinded paths. A unified target is needed to use all blinded paths
// during the payment lifecycle. A dummy hop is solely added for the
// path finding process and is removed after the path is found. This
// ensures that we still populate the mission control with the correct
// data and also respect these mc entries when looking for a path.
for _, path := range pathSet {
pathLength := len(path.BlindedPath.BlindedHops)
// If any provided blinded path only has a single hop (ie, the
// destination node is also the introduction node), then we
// discard all other paths since we know the real pub key of the
// destination node. We also then set the final CLTV delta to
// the path's delta since there are no other edge hints that
// will account for it.
if pathLength == 1 {
pathSet = []*BlindedPayment{path}
finalCLTVDelta = path.CltvExpiryDelta
targetPub = path.BlindedPath.IntroductionPoint
break
}
pathSet = []*BlindedPayment{path}
finalCLTVDelta = path.CltvExpiryDelta
targetPub = path.BlindedPath.IntroductionPoint
break
lastHop := path.BlindedPath.BlindedHops[pathLength-1]
path.BlindedPath.BlindedHops = append(
path.BlindedPath.BlindedHops,
&sphinx.BlindedHopInfo{
BlindedNodePub: &BlindedPathNUMSKey,
// We add the last hop's cipher text so that
// the payload size of the final hop is equal
// to the real last hop.
CipherText: lastHop.CipherText,
},
)
}
return &BlindedPaymentPathSet{
@ -222,7 +259,7 @@ func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) {
hints := make(RouteHints)
for _, path := range s.paths {
pathHints, err := path.toRouteHints(fn.Some(s.targetPubKey))
pathHints, err := path.toRouteHints()
if err != nil {
return nil, err
}
@ -239,6 +276,12 @@ func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) {
return hints, nil
}
// IsBlindedRouteNUMSTargetKey returns true if the given public key is the
// NUMS key used as a target for blinded path final hops.
func IsBlindedRouteNUMSTargetKey(pk []byte) bool {
return bytes.Equal(pk, CompressedBlindedPathNUMSKey)
}
// BlindedPayment provides the path and payment parameters required to send a
// payment along a blinded path.
type BlindedPayment struct {
@ -291,6 +334,22 @@ func (b *BlindedPayment) Validate() error {
b.HtlcMaximum, b.HtlcMinimum)
}
for _, hop := range b.BlindedPath.BlindedHops {
// The first hop of the blinded path does not necessarily have
// blinded node pub key because it is the introduction point.
if hop.BlindedNodePub == nil {
continue
}
if IsBlindedRouteNUMSTargetKey(
hop.BlindedNodePub.SerializeCompressed(),
) {
return fmt.Errorf("blinded path cannot include NUMS "+
"key: %s", BlindedPathNUMSHex)
}
}
return nil
}
@ -301,11 +360,8 @@ func (b *BlindedPayment) Validate() error {
// effectively the final_cltv_delta for the receiving introduction node). In
// the case of multiple blinded hops, CLTV delta is fully accounted for in the
// hints (both for intermediate hops and the final_cltv_delta for the receiving
// node). The pseudoTarget, if provided, will be used to override the pub key
// of the destination node in the path.
func (b *BlindedPayment) toRouteHints(
pseudoTarget fn.Option[*btcec.PublicKey]) (RouteHints, error) {
// node).
func (b *BlindedPayment) toRouteHints() (RouteHints, error) {
// If we just have a single hop in our blinded route, it just contains
// an introduction node (this is a valid path according to the spec).
// Since we have the un-blinded node ID for the introduction node, we
@ -393,16 +449,77 @@ func (b *BlindedPayment) toRouteHints(
hints[fromNode] = []AdditionalEdge{lastEdge}
}
pseudoTarget.WhenSome(func(key *btcec.PublicKey) {
// For the very last hop on the path, switch out the ToNodePub
// for the pseudo target pub key.
lastEdge.policy.ToNodePubKey = func() route.Vertex {
return route.NewVertex(key)
}
// Then override the final hint with this updated edge.
hints[fromNode] = []AdditionalEdge{lastEdge}
})
return hints, nil
}
// deepCopy returns a deep copy of the BlindedPayment.
func (b *BlindedPayment) deepCopy() *BlindedPayment {
if b == nil {
return nil
}
cpyPayment := &BlindedPayment{
BaseFee: b.BaseFee,
ProportionalFeeRate: b.ProportionalFeeRate,
CltvExpiryDelta: b.CltvExpiryDelta,
HtlcMinimum: b.HtlcMinimum,
HtlcMaximum: b.HtlcMaximum,
}
// Deep copy the BlindedPath if it exists
if b.BlindedPath != nil {
cpyPayment.BlindedPath = &sphinx.BlindedPath{
BlindedHops: make([]*sphinx.BlindedHopInfo,
len(b.BlindedPath.BlindedHops)),
}
if b.BlindedPath.IntroductionPoint != nil {
cpyPayment.BlindedPath.IntroductionPoint =
copyPublicKey(b.BlindedPath.IntroductionPoint)
}
if b.BlindedPath.BlindingPoint != nil {
cpyPayment.BlindedPath.BlindingPoint =
copyPublicKey(b.BlindedPath.BlindingPoint)
}
// Copy each blinded hop info.
for i, hop := range b.BlindedPath.BlindedHops {
if hop == nil {
continue
}
cpyHop := &sphinx.BlindedHopInfo{
CipherText: hop.CipherText,
}
if hop.BlindedNodePub != nil {
cpyHop.BlindedNodePub =
copyPublicKey(hop.BlindedNodePub)
}
cpyHop.CipherText = make([]byte, len(hop.CipherText))
copy(cpyHop.CipherText, hop.CipherText)
cpyPayment.BlindedPath.BlindedHops[i] = cpyHop
}
}
// Deep copy the Features if they exist
if b.Features != nil {
cpyPayment.Features = b.Features.Clone()
}
return cpyPayment
}
// copyPublicKey makes a deep copy of a public key.
//
// TODO(ziggie): Remove this function if this is available in the btcec library.
func copyPublicKey(pk *btcec.PublicKey) *btcec.PublicKey {
var result secp256k1.JacobianPoint
pk.AsJacobian(&result)
result.ToAffine()
return btcec.NewPublicKey(&result.X, &result.Y)
}

View File

@ -2,12 +2,12 @@ package routing
import (
"bytes"
"reflect"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/channeldb/models"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
@ -129,7 +129,7 @@ func TestBlindedPaymentToHints(t *testing.T) {
HtlcMaximum: htlcMax,
Features: features,
}
hints, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]())
hints, err := blindedPayment.toRouteHints()
require.NoError(t, err)
require.Nil(t, hints)
@ -184,7 +184,7 @@ func TestBlindedPaymentToHints(t *testing.T) {
},
}
actual, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]())
actual, err := blindedPayment.toRouteHints()
require.NoError(t, err)
require.Equal(t, len(expected), len(actual))
@ -218,3 +218,63 @@ func TestBlindedPaymentToHints(t *testing.T) {
require.Equal(t, expectedHint[0], actualHint[0])
}
}
// TestBlindedPaymentDeepCopy tests the deep copy method of the BLindedPayment
// struct.
//
// TODO(ziggie): Make this a property test instead.
func TestBlindedPaymentDeepCopy(t *testing.T) {
_, pkBlind1 := btcec.PrivKeyFromBytes([]byte{1})
_, blindingPoint := btcec.PrivKeyFromBytes([]byte{2})
_, pkBlind2 := btcec.PrivKeyFromBytes([]byte{3})
// Create a test BlindedPayment with non-nil fields
original := &BlindedPayment{
BaseFee: 1000,
ProportionalFeeRate: 2000,
CltvExpiryDelta: 144,
HtlcMinimum: 1000,
HtlcMaximum: 1000000,
Features: lnwire.NewFeatureVector(nil, nil),
BlindedPath: &sphinx.BlindedPath{
IntroductionPoint: pkBlind1,
BlindingPoint: blindingPoint,
BlindedHops: []*sphinx.BlindedHopInfo{
{
BlindedNodePub: pkBlind2,
CipherText: []byte("test cipher"),
},
},
},
}
// Make a deep copy
cpyPayment := original.deepCopy()
// Test 1: Verify the copy is not the same pointer
if cpyPayment == original {
t.Fatal("deepCopy returned same pointer")
}
// Verify all fields are equal
if !reflect.DeepEqual(original, cpyPayment) {
t.Fatal("copy is not equal to original")
}
// Modify the copy and verify it doesn't affect the original
cpyPayment.BaseFee = 2000
cpyPayment.BlindedPath.BlindedHops[0].CipherText = []byte("modified")
require.NotEqual(t, original.BaseFee, cpyPayment.BaseFee)
require.NotEqual(
t,
original.BlindedPath.BlindedHops[0].CipherText,
cpyPayment.BlindedPath.BlindedHops[0].CipherText,
)
// Verify nil handling.
var nilPayment *BlindedPayment
nilCopy := nilPayment.deepCopy()
require.Nil(t, nilCopy)
}

View File

@ -158,6 +158,32 @@ func newRoute(sourceVertex route.Vertex,
)
pathLength := len(pathEdges)
// When paying to a blinded route we might have appended a dummy hop at
// the end to make MPP payments possible via all paths of the blinded
// route set. We always append a dummy hop when the internal pathfiner
// looks for a route to a blinded path which is at least one hop long
// (excluding the introduction point). We add this dummy hop so that
// we search for a universal target but also respect potential mc
// entries which might already be present for a particular blinded path.
// However when constructing the Sphinx packet we need to remove this
// dummy hop again which we do here.
//
// NOTE: The path length is always at least 1 because there must be one
// edge from the source to the destination. However we check for > 0
// just for robustness here.
if blindedPathSet != nil && pathLength > 0 {
finalBlindedPubKey := pathEdges[pathLength-1].policy.
ToNodePubKey()
if IsBlindedRouteNUMSTargetKey(finalBlindedPubKey[:]) {
// If the last hop is the NUMS key for blinded paths, we
// remove the dummy hop from the route.
pathEdges = pathEdges[:pathLength-1]
pathLength--
}
}
for i := pathLength - 1; i >= 0; i-- {
// Now we'll start to calculate the items within the per-hop
// payload for the hop this edge is leading to.
@ -319,10 +345,6 @@ func newRoute(sourceVertex route.Vertex,
dataIndex = 0
blindedPath = blindedPayment.BlindedPath
numHops = len(blindedPath.BlindedHops)
realFinal = blindedPath.BlindedHops[numHops-1].
BlindedNodePub
introVertex = route.NewVertex(
blindedPath.IntroductionPoint,
)
@ -350,11 +372,6 @@ func newRoute(sourceVertex route.Vertex,
if i != len(hops)-1 {
hop.AmtToForward = 0
hop.OutgoingTimeLock = 0
} else {
// For the final hop, we swap out the pub key
// bytes to the original destination node pub
// key for that payment path.
hop.PubKeyBytes = route.NewVertex(realFinal)
}
dataIndex++
@ -901,6 +918,13 @@ func findPath(g *graphParams, r *RestrictParams, cfg *PathFindingConfig,
// included. If we are coming from the source hop, the payload
// size is zero, because the original htlc isn't in the onion
// blob.
//
// NOTE: For blinded paths with the NUMS key as the last hop,
// the payload size accounts for this dummy hop which is of
// the same size as the real last hop. So we account for a
// bigger size than the route is however we accept this
// little inaccuracy here because we are over estimating by
// 1 hop.
var payloadSize uint64
if fromVertex != source {
// In case the unifiedEdge does not have a payload size

View File

@ -3287,9 +3287,7 @@ func TestBlindedRouteConstruction(t *testing.T) {
// that make up the graph we'll give to route construction. The hints
// map is keyed by source node, so we can retrieve our blinded edges
// accordingly.
blindedEdges, err := blindedPayment.toRouteHints(
fn.None[*btcec.PublicKey](),
)
blindedEdges, err := blindedPayment.toRouteHints()
require.NoError(t, err)
carolDaveEdge := blindedEdges[carolVertex][0]