diff --git a/routing/blinding.go b/routing/blinding.go index 0c27e8743..70e1a22cb 100644 --- a/routing/blinding.go +++ b/routing/blinding.go @@ -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/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" + "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) +} diff --git a/routing/blinding_test.go b/routing/blinding_test.go index 8f83f7fd8..0a8846adb 100644 --- a/routing/blinding_test.go +++ b/routing/blinding_test.go @@ -2,11 +2,11 @@ package routing import ( "bytes" + "reflect" "testing" "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" - "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" @@ -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) +} diff --git a/routing/pathfind.go b/routing/pathfind.go index 80f4b1e68..5b5bbe9f2 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -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 diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index c463b8135..2adeca134 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -3284,9 +3284,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]