lnd/routing/blinding.go

538 lines
17 KiB
Go
Raw Normal View History

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/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.
ErrNoBlindedPath = errors.New("blinded path required")
// ErrInsufficientBlindedHops is returned when a blinded path does
// not have enough blinded hops.
ErrInsufficientBlindedHops = errors.New("blinded path requires " +
"at least one hop")
// 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
// blinded paths provided by the recipient of a payment.
//
// NOTE: for now this only holds a single BlindedPayment. By the end of the PR
// series, it will handle multiple paths.
type BlindedPaymentPathSet struct {
// paths is the set of blinded payment paths for a single payment.
// NOTE: For now this will always only have a single entry. By the end
// of this PR, it can hold multiple.
paths []*BlindedPayment
// targetPubKey is the ephemeral node pub key that we will inject into
// each path as the last hop. This is only for the sake of path finding.
// Once the path has been found, the original destination pub key is
// used again. In the edge case where there is only a single hop in the
// path (the introduction node is the destination node), then this will
// just be the introduction node's real public key.
targetPubKey *btcec.PublicKey
// features is the set of relay features available for the payment.
// This is extracted from the set of blinded payment paths. At the
// moment we require that all paths for the same payment have the
// same feature set.
features *lnwire.FeatureVector
// finalCLTV is the final hop's expiry delta of _any_ path in the set.
// For any multi-hop path, the final CLTV delta should be seen as zero
// since the final hop's final CLTV delta is accounted for in the
// accumulated path policy values. The only edge case is for when the
// final hop in the path is also the introduction node in which case
// that path's FinalCLTV must be the non-zero min CLTV of the final hop
// so that it is accounted for in path finding. For this reason, if
// we have any single path in the set with only one hop, then we throw
// away all the other paths. This should be fine to do since if there is
// a path where the intro node is also the destination node, then there
// isn't any need to try any other longer blinded path. In other words,
// if this value is non-zero, then there is only one path in this
// blinded path set and that path only has a single hop: the
// introduction node.
finalCLTV uint16
}
// NewBlindedPaymentPathSet constructs a new BlindedPaymentPathSet from a set of
// 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) {
if len(paths) == 0 {
return nil, ErrNoBlindedPath
}
// For now, we assert that all the paths have the same set of features.
features := paths[0].Features
noFeatures := features == nil || features.IsEmpty()
for i := 1; i < len(paths); i++ {
noFeats := paths[i].Features == nil ||
paths[i].Features.IsEmpty()
if noFeatures && !noFeats {
return nil, fmt.Errorf("all blinded paths must have " +
"the same set of features")
}
if noFeatures {
continue
}
if !features.RawFeatureVector.Equals(
paths[i].Features.RawFeatureVector,
) {
return nil, fmt.Errorf("all blinded paths must have " +
"the same set of features")
}
}
// Deep copy the paths to avoid mutating the original paths.
pathSet := make([]*BlindedPayment, len(paths))
for i, path := range paths {
pathSet[i] = path.deepCopy()
}
// 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
}
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{
paths: pathSet,
targetPubKey: targetPub,
features: features,
finalCLTV: finalCLTVDelta,
}, nil
}
// TargetPubKey returns the public key to be used as the destination node's
// public key during pathfinding.
func (s *BlindedPaymentPathSet) TargetPubKey() *btcec.PublicKey {
return s.targetPubKey
}
// Features returns the set of relay features available for the payment.
func (s *BlindedPaymentPathSet) Features() *lnwire.FeatureVector {
return s.features
}
// IntroNodeOnlyPath can be called if it is expected that the path set only
// contains a single payment path which itself only has one hop. It errors if
// this is not the case.
func (s *BlindedPaymentPathSet) IntroNodeOnlyPath() (*BlindedPayment, error) {
if len(s.paths) != 1 {
return nil, fmt.Errorf("expected only a single path in the "+
"blinded payment set, got %d", len(s.paths))
}
if len(s.paths[0].BlindedPath.BlindedHops) > 1 {
return nil, fmt.Errorf("an intro node only path cannot have " +
"more than one hop")
}
return s.paths[0], nil
}
// IsIntroNode returns true if the given vertex is an introduction node for one
// of the paths in the blinded payment path set.
func (s *BlindedPaymentPathSet) IsIntroNode(source route.Vertex) bool {
for _, path := range s.paths {
introVertex := route.NewVertex(
path.BlindedPath.IntroductionPoint,
)
if source == introVertex {
return true
}
}
return false
}
// FinalCLTVDelta is the minimum CLTV delta to use for the final hop on the
// route. In most cases this will return zero since the value is accounted for
// in the path's accumulated CLTVExpiryDelta. Only in the edge case of the path
// set only including a single path which only includes an introduction node
// will this return a non-zero value.
func (s *BlindedPaymentPathSet) FinalCLTVDelta() uint16 {
return s.finalCLTV
}
// LargestLastHopPayloadPath returns the BlindedPayment in the set that has the
// largest last-hop payload. This is to be used for onion size estimation in
// path finding.
func (s *BlindedPaymentPathSet) LargestLastHopPayloadPath() (*BlindedPayment,
error) {
var (
largestPath *BlindedPayment
currentMax int
)
if len(s.paths) == 0 {
return nil, fmt.Errorf("no blinded paths in the set")
}
// We set the largest path to make sure we always return a path even
// if the cipher text is empty.
largestPath = s.paths[0]
for _, path := range s.paths {
numHops := len(path.BlindedPath.BlindedHops)
lastHop := path.BlindedPath.BlindedHops[numHops-1]
if len(lastHop.CipherText) > currentMax {
largestPath = path
currentMax = len(lastHop.CipherText)
}
}
return largestPath, nil
}
// ToRouteHints converts the blinded path payment set into a RouteHints map so
// that the blinded payment paths can be treated like route hints throughout the
// code base.
func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) {
hints := make(RouteHints)
for _, path := range s.paths {
pathHints, err := path.toRouteHints()
if err != nil {
return nil, err
}
for from, edges := range pathHints {
hints[from] = append(hints[from], edges...)
}
}
if len(hints) == 0 {
return nil, nil
}
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 {
// BlindedPath contains the unblinded introduction point and blinded
// hops for the blinded section of the payment.
BlindedPath *sphinx.BlindedPath
// BaseFee is the total base fee to be paid for payments made over the
// blinded path.
BaseFee uint32
// ProportionalFeeRate is the aggregated proportional fee rate for
// payments made over the blinded path.
ProportionalFeeRate uint32
// CltvExpiryDelta is the total expiry delta for the blinded path. This
// field includes the CLTV for the blinded hops *and* the final cltv
// delta for the receiver.
CltvExpiryDelta uint16
// HtlcMinimum is the highest HLTC minimum supported along the blinded
// path (while some hops may have lower values, we're effectively
// bounded by the highest minimum).
HtlcMinimum uint64
// HtlcMaximum is the lowest HTLC maximum supported along the blinded
// path (while some hops may have higher values, we're effectively
// bounded by the lowest maximum).
HtlcMaximum uint64
// Features is the set of relay features available for the payment.
Features *lnwire.FeatureVector
}
// Validate performs validation on a blinded payment.
func (b *BlindedPayment) Validate() error {
if b.BlindedPath == nil {
return ErrNoBlindedPath
}
// The sphinx library inserts the introduction node as the first hop,
// so we expect at least one hop.
if len(b.BlindedPath.BlindedHops) < 1 {
return fmt.Errorf("%w got: %v", ErrInsufficientBlindedHops,
len(b.BlindedPath.BlindedHops))
}
if b.HtlcMaximum < b.HtlcMinimum {
return fmt.Errorf("%w: %v < %v", ErrHTLCRestrictions,
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
}
// toRouteHints produces a set of chained route hints that represent a blinded
// path. In the case of a single hop blinded route (which is paying directly
// to the introduction point), no hints will be returned. In this case callers
// *must* account for the blinded route's CLTV delta elsewhere (as this is
// 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).
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
// don't need to add any route hints.
if len(b.BlindedPath.BlindedHops) == 1 {
return nil, nil
}
hintCount := len(b.BlindedPath.BlindedHops) - 1
hints := make(
RouteHints, hintCount,
)
// Start at the unblinded introduction node, because our pathfinding
// will be able to locate this point in the graph.
fromNode := route.NewVertex(b.BlindedPath.IntroductionPoint)
features := lnwire.EmptyFeatureVector()
if b.Features != nil {
features = b.Features.Clone()
}
// Use the total aggregate relay parameters for the entire blinded
// route as the policy for the hint from our introduction node. This
// will ensure that pathfinding provides sufficient fees/delay for the
// blinded portion to the introduction node.
firstBlindedHop := b.BlindedPath.BlindedHops[1].BlindedNodePub
edgePolicy := &models.CachedEdgePolicy{
TimeLockDelta: b.CltvExpiryDelta,
MinHTLC: lnwire.MilliSatoshi(b.HtlcMinimum),
MaxHTLC: lnwire.MilliSatoshi(b.HtlcMaximum),
FeeBaseMSat: lnwire.MilliSatoshi(b.BaseFee),
FeeProportionalMillionths: lnwire.MilliSatoshi(
b.ProportionalFeeRate,
),
ToNodePubKey: func() route.Vertex {
return route.NewVertex(
// The first node in this slice is
// the introduction node, so we start
// at index 1 to get the first blinded
// relaying node.
firstBlindedHop,
)
},
ToNodeFeatures: features,
}
lastEdge, err := NewBlindedEdge(edgePolicy, b, 0)
if err != nil {
return nil, err
}
hints[fromNode] = []AdditionalEdge{lastEdge}
// Start at an offset of 1 because the first node in our blinded hops
// is the introduction node and terminate at the second-last node
// because we're dealing with hops as pairs.
for i := 1; i < hintCount; i++ {
// Set our origin node to the current
fromNode = route.NewVertex(
b.BlindedPath.BlindedHops[i].BlindedNodePub,
)
// Create a hint which has no fee or cltv delta. We
// specifically want zero values here because our relay
// parameters are expressed in encrypted blobs rather than the
// route itself for blinded routes.
nextHopIdx := i + 1
nextNode := route.NewVertex(
b.BlindedPath.BlindedHops[nextHopIdx].BlindedNodePub,
)
edgePolicy := &models.CachedEdgePolicy{
ToNodePubKey: func() route.Vertex {
return nextNode
},
ToNodeFeatures: features,
}
lastEdge, err = NewBlindedEdge(edgePolicy, b, i)
if err != nil {
return nil, err
}
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)
}