From 7fd9c2a7f87fc467131ea7c9dc0cceafd812b6ec Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 2 Apr 2024 08:46:14 -0400 Subject: [PATCH 01/16] multi: use some record for payment descriptor blinding point --- htlcswitch/hop/iterator.go | 13 +++--- lnwallet/channel.go | 92 ++++++++++---------------------------- lnwallet/channel_test.go | 6 ++- lnwire/update_add_htlc.go | 13 ------ 4 files changed, 36 insertions(+), 88 deletions(-) diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 1829522f48..f2f728038f 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" ) // Iterator is an interface that abstracts away the routing information @@ -186,7 +187,7 @@ type DecodeHopIteratorRequest struct { RHash []byte IncomingCltv uint32 IncomingAmount lnwire.MilliSatoshi - BlindingPoint *btcec.PublicKey + BlindingPoint lnwire.BlindingPointRecord } // DecodeHopIteratorResponse encapsulates the outcome of a batched sphinx onion @@ -243,12 +244,14 @@ func (p *OnionProcessor) DecodeHopIterators(id []byte, } var opts []sphinx.ProcessOnionOpt - if req.BlindingPoint != nil { + req.BlindingPoint.WhenSome(func( + b tlv.RecordT[lnwire.BlindingPointTlvType, + *btcec.PublicKey]) { + opts = append(opts, sphinx.WithBlindingPoint( - req.BlindingPoint, + b.Val, )) - } - + }) err = tx.ProcessOnionPacket( seqNum, onionPkt, req.RHash, req.IncomingCltv, opts..., ) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 1b6e71ffd6..1af0e4b5ff 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -31,7 +31,6 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/shachain" - "github.com/lightningnetwork/lnd/tlv" ) var ( @@ -377,7 +376,7 @@ type PaymentDescriptor struct { // This value is set for nodes that are relaying payments inside of a // blinded route (ie, not the introduction node) from update_add_htlc's // TLVs. - BlindingPoint *btcec.PublicKey + BlindingPoint lnwire.BlindingPointRecord } // PayDescsFromRemoteLogUpdates converts a slice of LogUpdates received from the @@ -418,7 +417,7 @@ func PayDescsFromRemoteLogUpdates(chanID lnwire.ShortChannelID, height uint64, Height: height, Index: uint16(i), }, - BlindingPoint: wireMsg.BlingingPointOrNil(), + BlindingPoint: pd.BlindingPoint, } pd.OnionBlob = make([]byte, len(wireMsg.OnionBlob)) copy(pd.OnionBlob[:], wireMsg.OnionBlob[:]) @@ -742,16 +741,9 @@ func (c *commitment) toDiskCommit(ourCommit bool) *channeldb.ChannelCommitment { HtlcIndex: htlc.HtlcIndex, LogIndex: htlc.LogIndex, Incoming: false, + BlindingPoint: htlc.BlindingPoint, } copy(h.OnionBlob[:], htlc.OnionBlob) - if htlc.BlindingPoint != nil { - h.BlindingPoint = tlv.SomeRecordT( - //nolint:lll - tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( - htlc.BlindingPoint, - ), - ) - } if ourCommit && htlc.sig != nil { h.Signature = htlc.sig.Serialize() @@ -774,16 +766,9 @@ func (c *commitment) toDiskCommit(ourCommit bool) *channeldb.ChannelCommitment { HtlcIndex: htlc.HtlcIndex, LogIndex: htlc.LogIndex, Incoming: true, + BlindingPoint: htlc.BlindingPoint, } copy(h.OnionBlob[:], htlc.OnionBlob) - if htlc.BlindingPoint != nil { - h.BlindingPoint = tlv.SomeRecordT( - //nolint:lll - tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( - htlc.BlindingPoint, - ), - ) - } if ourCommit && htlc.sig != nil { h.Signature = htlc.sig.Serialize() } @@ -866,7 +851,7 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight, // With the scripts reconstructed (depending on if this is our commit // vs theirs or a pending commit for the remote party), we can now // re-create the original payment descriptor. - pd = PaymentDescriptor{ + return PaymentDescriptor{ RHash: htlc.RHash, Timeout: htlc.RefundTimeout, Amount: htlc.Amt, @@ -880,15 +865,8 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight, ourWitnessScript: ourWitnessScript, theirPkScript: theirP2WSH, theirWitnessScript: theirWitnessScript, - } - - htlc.BlindingPoint.WhenSome(func(b tlv.RecordT[ - lnwire.BlindingPointTlvType, *btcec.PublicKey]) { - - pd.BlindingPoint = b.Val - }) - - return pd, nil + BlindingPoint: htlc.BlindingPoint, + }, nil } // extractPayDescs will convert all HTLC's present within a disk commit state @@ -1577,7 +1555,7 @@ func (lc *LightningChannel) logUpdateToPayDesc(logUpdate *channeldb.LogUpdate, HtlcIndex: wireMsg.ID, LogIndex: logUpdate.LogIndex, addCommitHeightRemote: commitHeight, - BlindingPoint: wireMsg.BlingingPointOrNil(), + BlindingPoint: wireMsg.BlindingPoint, } pd.OnionBlob = make([]byte, len(wireMsg.OnionBlob)) copy(pd.OnionBlob[:], wireMsg.OnionBlob[:]) @@ -1775,7 +1753,7 @@ func (lc *LightningChannel) remoteLogUpdateToPayDesc(logUpdate *channeldb.LogUpd HtlcIndex: wireMsg.ID, LogIndex: logUpdate.LogIndex, addCommitHeightLocal: commitHeight, - BlindingPoint: wireMsg.BlingingPointOrNil(), + BlindingPoint: wireMsg.BlindingPoint, } pd.OnionBlob = make([]byte, len(wireMsg.OnionBlob)) copy(pd.OnionBlob, wireMsg.OnionBlob[:]) @@ -3631,21 +3609,14 @@ func (lc *LightningChannel) createCommitDiff( switch pd.EntryType { case Add: htlc := &lnwire.UpdateAddHTLC{ - ChanID: chanID, - ID: pd.HtlcIndex, - Amount: pd.Amount, - Expiry: pd.Timeout, - PaymentHash: pd.RHash, + ChanID: chanID, + ID: pd.HtlcIndex, + Amount: pd.Amount, + Expiry: pd.Timeout, + PaymentHash: pd.RHash, + BlindingPoint: pd.BlindingPoint, } copy(htlc.OnionBlob[:], pd.OnionBlob) - if pd.BlindingPoint != nil { - htlc.BlindingPoint = tlv.SomeRecordT( - //nolint:lll - tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( - pd.BlindingPoint, - ), - ) - } logUpdate.UpdateMsg = htlc // Gather any references for circuits opened by this Add @@ -3775,21 +3746,13 @@ func (lc *LightningChannel) getUnsignedAckedUpdates() []channeldb.LogUpdate { // four messages that it corresponds to. switch pd.EntryType { case Add: - var b lnwire.BlindingPointRecord - if pd.BlindingPoint != nil { - tlv.SomeRecordT( - //nolint:lll - tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](pd.BlindingPoint), - ) - } - htlc := &lnwire.UpdateAddHTLC{ ChanID: chanID, ID: pd.HtlcIndex, Amount: pd.Amount, Expiry: pd.Timeout, PaymentHash: pd.RHash, - BlindingPoint: b, + BlindingPoint: pd.BlindingPoint, } copy(htlc.OnionBlob[:], pd.OnionBlob) logUpdate.UpdateMsg = htlc @@ -5784,19 +5747,12 @@ func (lc *LightningChannel) ReceiveRevocation(revMsg *lnwire.RevokeAndAck) ( switch pd.EntryType { case Add: htlc := &lnwire.UpdateAddHTLC{ - ChanID: chanID, - ID: pd.HtlcIndex, - Amount: pd.Amount, - Expiry: pd.Timeout, - PaymentHash: pd.RHash, - } - if pd.BlindingPoint != nil { - htlc.BlindingPoint = tlv.SomeRecordT( - //nolint:lll - tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( - pd.BlindingPoint, - ), - ) + ChanID: chanID, + ID: pd.HtlcIndex, + Amount: pd.Amount, + Expiry: pd.Timeout, + PaymentHash: pd.RHash, + BlindingPoint: pd.BlindingPoint, } copy(htlc.OnionBlob[:], pd.OnionBlob) logUpdate.UpdateMsg = htlc @@ -6135,7 +6091,7 @@ func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC, HtlcIndex: lc.localUpdateLog.htlcCounter, OnionBlob: htlc.OnionBlob[:], OpenCircuitKey: openKey, - BlindingPoint: htlc.BlingingPointOrNil(), + BlindingPoint: htlc.BlindingPoint, } } @@ -6193,7 +6149,7 @@ func (lc *LightningChannel) ReceiveHTLC(htlc *lnwire.UpdateAddHTLC) (uint64, err LogIndex: lc.remoteUpdateLog.logIndex, HtlcIndex: lc.remoteUpdateLog.htlcCounter, OnionBlob: htlc.OnionBlob[:], - BlindingPoint: htlc.BlingingPointOrNil(), + BlindingPoint: htlc.BlindingPoint, } localACKedIndex := lc.remoteCommitChain.tail().ourMessageIndex diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index d224b45983..7ef5c118aa 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -11045,7 +11045,8 @@ func TestBlindingPointPersistence(t *testing.T) { // Assert that the blinding point is restored from disk. remoteCommit := aliceChannel.remoteCommitChain.tip() require.Len(t, remoteCommit.outgoingHTLCs, 1) - require.Equal(t, blinding, remoteCommit.outgoingHTLCs[0].BlindingPoint) + require.Equal(t, blinding, + remoteCommit.outgoingHTLCs[0].BlindingPoint.UnwrapOrFailV(t)) // Next, update bob's commitment and assert that we can still retrieve // his incoming blinding point after restart. @@ -11061,5 +11062,6 @@ func TestBlindingPointPersistence(t *testing.T) { // Assert that Bob is able to recover the blinding point from disk. bobCommit := bobChannel.localCommitChain.tip() require.Len(t, bobCommit.incomingHTLCs, 1) - require.Equal(t, blinding, bobCommit.incomingHTLCs[0].BlindingPoint) + require.Equal(t, blinding, + bobCommit.incomingHTLCs[0].BlindingPoint.UnwrapOrFailV(t)) } diff --git a/lnwire/update_add_htlc.go b/lnwire/update_add_htlc.go index 951dc7f54c..8a40710e82 100644 --- a/lnwire/update_add_htlc.go +++ b/lnwire/update_add_htlc.go @@ -78,19 +78,6 @@ type UpdateAddHTLC struct { ExtraData ExtraOpaqueData } -// BlingingPointOrNil returns the blinding point associated with the update, or -// nil. -func (c *UpdateAddHTLC) BlingingPointOrNil() *btcec.PublicKey { - var blindingPoint *btcec.PublicKey - c.BlindingPoint.WhenSome(func(b tlv.RecordT[BlindingPointTlvType, - *btcec.PublicKey]) { - - blindingPoint = b.Val - }) - - return blindingPoint -} - // NewUpdateAddHTLC returns a new empty UpdateAddHTLC message. func NewUpdateAddHTLC() *UpdateAddHTLC { return &UpdateAddHTLC{} From 019b8fa8aacd07c8b6944d787b3ced0a1784a268 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Wed, 14 Dec 2022 15:00:37 -0500 Subject: [PATCH 02/16] hop: add function for calculating forwarding amount Co-authored-by: Calvin Zachman <calvin.zachman@protonmail.com> --- htlcswitch/hop/iterator.go | 45 ++++++++++++++++++++++++++++ htlcswitch/hop/iterator_test.go | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index f2f728038f..095fd190d3 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -114,6 +114,51 @@ func (r *sphinxHopIterator) ExtractErrorEncrypter( return extracter(r.ogPacket.EphemeralKey) } +// calculateForwardingAmount calculates the amount to forward for a blinded +// hop based on the incoming amount and forwarding parameters. +// +// When forwarding a payment, the fee we take is calculated, not on the +// incoming amount, but rather on the amount we forward. We charge fees based +// on our own liquidity we are forwarding downstream. +// +// With route blinding, we are NOT given the amount to forward. This +// unintuitive looking formula comes from the fact that without the amount to +// forward, we cannot compute the fees taken directly. +// +// The amount to be forwarded can be computed as follows: +// +// amt_to_forward = incoming_amount - total_fees +// total_fees = base_fee + amt_to_forward*(fee_rate/1000000) +// +// Solving for amount_to_forward: +// amt_to_forward = incoming_amount - base_fee - (amount_to_forward * fee_rate)/1e6 +// amt_to_forward + (amount_to_forward * fee_rate) / 1e6 = incoming_amount - base_fee +// amt_to_forward * 1e6 + (amount_to_forward * fee_rate) = (incoming_amount - base_fee) * 1e6 +// amt_to_forward * (1e6 + fee_rate) = (incoming_amount - base_fee) * 1e6 +// amt_to_forward = ((incoming_amount - base_fee) * 1e6) / (1e6 + fee_rate) +// +// From there we use a ceiling formula for integer division so that we always +// round up, otherwise the sender may receive slightly less than intended: +// +// ceil(a/b) = (a + b - 1)/(b). +// +//nolint:lll,dupword +func calculateForwardingAmount(incomingAmount lnwire.MilliSatoshi, baseFee, + proportionalFee uint32) (lnwire.MilliSatoshi, error) { + + // Sanity check to prevent overflow. + if incomingAmount < lnwire.MilliSatoshi(baseFee) { + return 0, fmt.Errorf("incoming amount: %v < base fee: %v", + incomingAmount, baseFee) + } + numerator := (uint64(incomingAmount) - uint64(baseFee)) * 1e6 + denominator := 1e6 + uint64(proportionalFee) + + ceiling := (numerator + denominator - 1) / denominator + + return lnwire.MilliSatoshi(ceiling), nil +} + // OnionProcessor is responsible for keeping all sphinx dependent parts inside // and expose only decoding function. With such approach we give freedom for // subsystems which wants to decode sphinx path to not be dependable from diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index cb2a2816f9..74eb60a7a6 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -100,3 +100,56 @@ func TestSphinxHopIteratorForwardingInstructions(t *testing.T) { } } } + +// TestForwardingAmountCalc tests calculation of forwarding amounts from the +// hop's forwarding parameters. +func TestForwardingAmountCalc(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + incomingAmount lnwire.MilliSatoshi + baseFee uint32 + proportional uint32 + forwardAmount lnwire.MilliSatoshi + expectErr bool + }{ + { + name: "overflow", + incomingAmount: 10, + baseFee: 100, + expectErr: true, + }, + { + name: "trivial proportional", + incomingAmount: 100_000, + baseFee: 1000, + proportional: 10, + forwardAmount: 99000, + }, + { + name: "both fees charged", + incomingAmount: 10_002_020, + baseFee: 1000, + proportional: 1, + forwardAmount: 10_001_010, + }, + } + + for _, testCase := range tests { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + actual, err := calculateForwardingAmount( + testCase.incomingAmount, testCase.baseFee, + testCase.proportional, + ) + + require.Equal(t, testCase.expectErr, err != nil) + require.Equal(t, testCase.forwardAmount.ToSatoshis(), + actual.ToSatoshis()) + }) + } +} From 040fcb0f927af4d1df99096645d0a230be25d191 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 2 Apr 2024 09:04:27 -0400 Subject: [PATCH 03/16] multi: add option to disable route blinding, rejecting at link Add an option to disable route blinding, failing back any HTLC with a blinding point set when we haven't got the feature enabled. Note that this commit only handles the case where we're chosen as the relaying node (where the blinding point is in update_add_htlc), we'll add handling for the introduction node case once we get to handling of blinded payloads). --- htlcswitch/link.go | 18 ++++++++++++++++++ lncfg/protocol.go | 8 ++++++++ lncfg/protocol_integration.go | 8 ++++++++ peer/brontide.go | 6 ++++++ sample-lnd.conf | 3 +++ server.go | 1 + 6 files changed, 44 insertions(+) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 7e1dded1dd..3288584d59 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -273,6 +273,11 @@ type ChannelLinkConfig struct { // re-establish and should not allow anymore HTLC adds on the outgoing // direction of the link. PreviouslySentShutdown fn.Option[lnwire.Shutdown] + + // Adds the option to disable forwarding payments in blinded routes + // by failing back any blinding-related payloads as if they were + // invalid. + DisallowRouteBlinding bool } // channelLink is the service which drives a channel's commitment update @@ -1928,6 +1933,19 @@ func (l *channelLink) handleUpstreamMsg(msg lnwire.Message) { return } + // Disallow htlcs with blinding points set if we haven't + // enabled the feature. This saves us from having to process + // the onion at all, but will only catch blinded payments + // where we are a relaying node (as the blinding point will + // be in the payload when we're the introduction node). + if msg.BlindingPoint.IsSome() && l.cfg.DisallowRouteBlinding { + l.fail(LinkFailureError{code: ErrInvalidUpdate}, + "blinding point included when route blinding "+ + "is disabled") + + return + } + // We just received an add request from an upstream peer, so we // add it to our state machine, then add the HTLC to our // "settle" list in the event that we know the preimage. diff --git a/lncfg/protocol.go b/lncfg/protocol.go index f8ac08e86b..e98b4dcf88 100644 --- a/lncfg/protocol.go +++ b/lncfg/protocol.go @@ -54,6 +54,9 @@ type ProtocolOptions struct { // also mean that we won't respond with timestamps if requested by our // peers. NoTimestampQueryOption bool `long:"no-timestamp-query-option" description:"do not query syncing peers for announcement timestamps and do not respond with timestamps if requested"` + + // NoRouteBlindingOption disables forwarding of payments in blinded routes. + NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"` } // Wumbo returns true if lnd should permit the creation and acceptance of wumbo @@ -97,3 +100,8 @@ func (l *ProtocolOptions) NoAnySegwit() bool { func (l *ProtocolOptions) NoTimestampsQuery() bool { return l.NoTimestampQueryOption } + +// NoRouteBlinding returns true if forwarding of blinded payments is disabled. +func (l *ProtocolOptions) NoRouteBlinding() bool { + return l.NoRouteBlindingOption +} diff --git a/lncfg/protocol_integration.go b/lncfg/protocol_integration.go index ff74ba9e90..841f8e9eb6 100644 --- a/lncfg/protocol_integration.go +++ b/lncfg/protocol_integration.go @@ -57,6 +57,9 @@ type ProtocolOptions struct { // also mean that we won't respond with timestamps if requested by our // peers. NoTimestampQueryOption bool `long:"no-timestamp-query-option" description:"do not query syncing peers for announcement timestamps and do not respond with timestamps if requested"` + + // NoRouteBlindingOption disables forwarding of payments in blinded routes. + NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"` } // Wumbo returns true if lnd should permit the creation and acceptance of wumbo @@ -92,3 +95,8 @@ func (l *ProtocolOptions) ZeroConf() bool { func (l *ProtocolOptions) NoAnySegwit() bool { return l.NoOptionAnySegwit } + +// NoRouteBlinding returns true if forwarding of blinded payments is disabled. +func (l *ProtocolOptions) NoRouteBlinding() bool { + return l.NoRouteBlindingOption +} diff --git a/peer/brontide.go b/peer/brontide.go index 541c0f358a..187fbafe17 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -365,6 +365,11 @@ type Config struct { // this across multiple Peer struct instances. PongBuf []byte + // Adds the option to disable forwarding payments in blinded routes + // by failing back any blinding-related payloads as if they were + // invalid. + DisallowRouteBlinding bool + // Quit is the server's quit channel. If this is closed, we halt operation. Quit chan struct{} } @@ -1155,6 +1160,7 @@ func (p *Brontide) addLink(chanPoint *wire.OutPoint, HtlcNotifier: p.cfg.HtlcNotifier, GetAliases: p.cfg.GetAliases, PreviouslySentShutdown: shutdownMsg, + DisallowRouteBlinding: p.cfg.DisallowRouteBlinding, } // Before adding our new link, purge the switch of any pending or live diff --git a/sample-lnd.conf b/sample-lnd.conf index dd538b07a2..08a79bde5b 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -1282,6 +1282,9 @@ ; Set to enable support for the experimental taproot channel type. ; protocol.simple-taproot-chans=false +; Set to disable blinded route forwarding. +; protocol.no-route-blinding=false + [db] ; The selected database backend. The current default backend is "bolt". lnd diff --git a/server.go b/server.go index ea51242e4a..2c8b75af10 100644 --- a/server.go +++ b/server.go @@ -3872,6 +3872,7 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq, GetAliases: s.aliasMgr.GetAliases, RequestAlias: s.aliasMgr.RequestAlias, AddLocalAlias: s.aliasMgr.AddLocalAlias, + DisallowRouteBlinding: s.cfg.ProtocolOptions.NoRouteBlinding(), Quit: s.quit, } From 03f6c5cd0a8141f40de689eeeae5cb2d30e1c6b5 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Wed, 14 Dec 2022 15:02:01 -0500 Subject: [PATCH 04/16] htlcswitch: add blinding kit to handle encrypted data in blinded routes This commit introduces a blinding kits which abstracts over the operations required to decrypt, deserialize and reconstruct forwarding data from an encrypted blob of data included for nodes in blinded routes. --- htlcswitch/hop/iterator.go | 136 +++++++++++++++++++++++++++++++ htlcswitch/hop/iterator_test.go | 139 ++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 095fd190d3..7ddb9af776 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -2,6 +2,7 @@ package hop import ( "bytes" + "errors" "fmt" "io" "sync" @@ -9,9 +10,15 @@ import ( "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/tlv" ) +var ( + // ErrDecodeFailed is returned when we can't decode blinded data. + ErrDecodeFailed = errors.New("could not decode blinded data") +) + // Iterator is an interface that abstracts away the routing information // included in HTLC's which includes the entirety of the payment path of an // HTLC. This interface provides two basic method which carry out: how to @@ -114,6 +121,135 @@ func (r *sphinxHopIterator) ExtractErrorEncrypter( return extracter(r.ogPacket.EphemeralKey) } +// BlindingProcessor is an interface that provides the cryptographic operations +// required for processing blinded hops. +// +// This interface is extracted to allow more granular testing of blinded +// forwarding calculations. +type BlindingProcessor interface { + // DecryptBlindedHopData decrypts a blinded blob of data using the + // ephemeral key provided. + DecryptBlindedHopData(ephemPub *btcec.PublicKey, + encryptedData []byte) ([]byte, error) +} + +// BlindingKit contains the components required to extract forwarding +// information for hops in a blinded route. +type BlindingKit struct { + // Processor provides the low-level cryptographic operations to + // handle an encrypted blob of data in a blinded forward. + Processor BlindingProcessor + + // UpdateAddBlinding holds a blinding point that was passed to the + // node via update_add_htlc's TLVs. + UpdateAddBlinding lnwire.BlindingPointRecord + + // IncomingCltv is the expiry of the incoming HTLC. + IncomingCltv uint32 + + // IncomingAmount is the amount of the incoming HTLC. + IncomingAmount lnwire.MilliSatoshi +} + +// validateBlindingPoint validates that only one blinding point is present for +// the hop and returns the relevant one. +func (b *BlindingKit) validateBlindingPoint(payloadBlinding *btcec.PublicKey, + isFinalHop bool) (*btcec.PublicKey, error) { + + // Bolt 04: if encrypted_recipient_data is present: + // - if blinding_point (in update add) is set: + // - MUST error if current_blinding_point is set (in payload) + // - otherwise: + // - MUST return an error if current_blinding_point is not present + // (in payload) + payloadBlindingSet := payloadBlinding != nil + updateBlindingSet := b.UpdateAddBlinding.IsSome() + + switch { + case !(payloadBlindingSet || updateBlindingSet): + return nil, ErrInvalidPayload{ + Type: record.BlindingPointOnionType, + Violation: OmittedViolation, + FinalHop: isFinalHop, + } + + case payloadBlindingSet && updateBlindingSet: + return nil, ErrInvalidPayload{ + Type: record.BlindingPointOnionType, + Violation: IncludedViolation, + FinalHop: isFinalHop, + } + + case payloadBlindingSet: + return payloadBlinding, nil + + case updateBlindingSet: + pk, err := b.UpdateAddBlinding.UnwrapOrErr( + fmt.Errorf("expected update add blinding"), + ) + if err != nil { + return nil, err + } + + return pk.Val, nil + } + + return nil, fmt.Errorf("expected blinded point set") +} + +// DecryptAndValidateFwdInfo performs all operations required to decrypt and +// validate a blinded route. +func (b *BlindingKit) DecryptAndValidateFwdInfo(payload *Payload, + isFinalHop bool) (*ForwardingInfo, error) { + + // We expect this function to be called when we have encrypted data + // present, and a blinding key is set either in the payload or the + // update_add_htlc message. + blindingPoint, err := b.validateBlindingPoint( + payload.blindingPoint, isFinalHop, + ) + if err != nil { + return nil, err + } + + decrypted, err := b.Processor.DecryptBlindedHopData( + blindingPoint, payload.encryptedData, + ) + if err != nil { + return nil, fmt.Errorf("decrypt blinded "+ + "data: %w", err) + } + + buf := bytes.NewBuffer(decrypted) + routeData, err := record.DecodeBlindedRouteData(buf) + if err != nil { + return nil, fmt.Errorf("%w: %w", + ErrDecodeFailed, err) + } + + if err := ValidateBlindedRouteData( + routeData, b.IncomingAmount, b.IncomingCltv, + ); err != nil { + return nil, err + } + + fwdAmt, err := calculateForwardingAmount( + b.IncomingAmount, routeData.RelayInfo.Val.BaseFee, + routeData.RelayInfo.Val.FeeRate, + ) + if err != nil { + return nil, err + } + + return &ForwardingInfo{ + NextHop: routeData.ShortChannelID.Val, + AmountToForward: fwdAmt, + OutgoingCTLV: b.IncomingCltv - uint32( + routeData.RelayInfo.Val.CltvExpiryDelta, + ), + }, nil +} + // calculateForwardingAmount calculates the amount to forward for a blinded // hop based on the incoming amount and forwarding parameters. // diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index 74eb60a7a6..b8b8b4f723 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -3,8 +3,10 @@ package hop import ( "bytes" "encoding/binary" + "errors" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/davecgh/go-spew/spew" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/lnwire" @@ -153,3 +155,140 @@ func TestForwardingAmountCalc(t *testing.T) { }) } } + +// mockProcessor is a mocked blinding point processor that just returns the +// data that it is called with when "decrypting". +type mockProcessor struct { + decryptErr error +} + +// DecryptBlindedHopData mocks blob decryption, returning the same data that +// it was called with and an optionally configured error. +func (m *mockProcessor) DecryptBlindedHopData(_ *btcec.PublicKey, + data []byte) ([]byte, error) { + + return data, m.decryptErr +} + +// TestDecryptAndValidateFwdInfo tests deriving forwarding info using a +// blinding kit. This test does not cover assertions on the calculations of +// forwarding information, because this is covered in a test dedicated to those +// calculations. +func TestDecryptAndValidateFwdInfo(t *testing.T) { + t.Parallel() + + // Encode valid blinding data that we'll fake decrypting for our test. + maxCltv := 1000 + blindedData := record.NewBlindedRouteData( + lnwire.NewShortChanIDFromInt(1500), nil, + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + BaseFee: 100, + FeeRate: 0, + }, + &record.PaymentConstraints{ + MaxCltvExpiry: 1000, + HtlcMinimumMsat: lnwire.MilliSatoshi(1), + }, + nil, + ) + + validData, err := record.EncodeBlindedRouteData(blindedData) + require.NoError(t, err) + + // Mocked error. + errDecryptFailed := errors.New("could not decrypt") + + tests := []struct { + name string + data []byte + incomingCLTV uint32 + updateAddBlinding *btcec.PublicKey + payloadBlinding *btcec.PublicKey + processor *mockProcessor + expectedErr error + }{ + { + name: "no blinding point", + data: validData, + processor: &mockProcessor{}, + expectedErr: ErrInvalidPayload{ + Type: record.BlindingPointOnionType, + Violation: OmittedViolation, + }, + }, + { + name: "both blinding points", + data: validData, + updateAddBlinding: &btcec.PublicKey{}, + payloadBlinding: &btcec.PublicKey{}, + processor: &mockProcessor{}, + expectedErr: ErrInvalidPayload{ + Type: record.BlindingPointOnionType, + Violation: IncludedViolation, + }, + }, + { + name: "decryption failed", + data: validData, + updateAddBlinding: &btcec.PublicKey{}, + incomingCLTV: 500, + processor: &mockProcessor{ + decryptErr: errDecryptFailed, + }, + expectedErr: errDecryptFailed, + }, + { + name: "decode fails", + data: []byte{1, 2, 3}, + updateAddBlinding: &btcec.PublicKey{}, + incomingCLTV: 500, + processor: &mockProcessor{}, + expectedErr: ErrDecodeFailed, + }, + { + name: "validation fails", + data: validData, + updateAddBlinding: &btcec.PublicKey{}, + incomingCLTV: uint32(maxCltv) + 10, + processor: &mockProcessor{}, + expectedErr: ErrInvalidPayload{ + Type: record.LockTimeOnionType, + Violation: InsufficientViolation, + }, + }, + { + name: "valid", + updateAddBlinding: &btcec.PublicKey{}, + data: validData, + processor: &mockProcessor{}, + expectedErr: nil, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + // We don't actually use blinding keys due to our + // mocking so they can be nil. + kit := BlindingKit{ + Processor: testCase.processor, + IncomingAmount: 10000, + IncomingCltv: testCase.incomingCLTV, + } + + if testCase.updateAddBlinding != nil { + kit.UpdateAddBlinding = tlv.SomeRecordT( + //nolint:lll + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](testCase.updateAddBlinding), + ) + } + _, err := kit.DecryptAndValidateFwdInfo( + &Payload{ + encryptedData: testCase.data, + blindingPoint: testCase.payloadBlinding, + }, false, + ) + require.ErrorIs(t, err, testCase.expectedErr) + }) + } +} From ca6d414308c13100a2d30beeac1c636399cfecf3 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 2 Apr 2024 15:20:25 -0400 Subject: [PATCH 05/16] multi: validate contents in blinded data against payload --- htlcswitch/hop/iterator.go | 11 +++++- htlcswitch/hop/iterator_test.go | 1 + htlcswitch/hop/payload.go | 34 +++++++++++++++++ htlcswitch/hop/payload_test.go | 65 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 7ddb9af776..87691d1925 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -200,7 +200,8 @@ func (b *BlindingKit) validateBlindingPoint(payloadBlinding *btcec.PublicKey, // DecryptAndValidateFwdInfo performs all operations required to decrypt and // validate a blinded route. func (b *BlindingKit) DecryptAndValidateFwdInfo(payload *Payload, - isFinalHop bool) (*ForwardingInfo, error) { + isFinalHop bool, payloadParsed map[tlv.Type][]byte) ( + *ForwardingInfo, error) { // We expect this function to be called when we have encrypted data // present, and a blinding key is set either in the payload or the @@ -227,6 +228,14 @@ func (b *BlindingKit) DecryptAndValidateFwdInfo(payload *Payload, ErrDecodeFailed, err) } + // Validate the contents of the payload against the values we've + // just pulled out of the encrypted data blob. + err = ValidatePayloadWithBlinded(isFinalHop, payloadParsed) + if err != nil { + return nil, err + } + // Validate the data in the blinded route against our incoming htlc's + // information. if err := ValidateBlindedRouteData( routeData, b.IncomingAmount, b.IncomingCltv, ); err != nil { diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index b8b8b4f723..e39dbb1513 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -287,6 +287,7 @@ func TestDecryptAndValidateFwdInfo(t *testing.T) { encryptedData: testCase.data, blindingPoint: testCase.payloadBlinding, }, false, + make(map[tlv.Type][]byte), ) require.ErrorIs(t, err, testCase.expectedErr) }) diff --git a/htlcswitch/hop/payload.go b/htlcswitch/hop/payload.go index cbd8d08a57..99c36db6de 100644 --- a/htlcswitch/hop/payload.go +++ b/htlcswitch/hop/payload.go @@ -484,3 +484,37 @@ func ValidateBlindedRouteData(blindedData *record.BlindedRouteData, return nil } + +// ValidatePayloadWithBlinded validates a payload against the contents of +// its encrypted data blob. +func ValidatePayloadWithBlinded(isFinalHop bool, + payloadParsed map[tlv.Type][]byte) error { + + // Blinded routes restrict the presence of TLVs more strictly than + // regular routes, check that intermediate and final hops only have + // the TLVs the spec allows them to have. + allowedTLVs := map[tlv.Type]bool{ + record.EncryptedDataOnionType: true, + record.BlindingPointOnionType: true, + } + + if isFinalHop { + allowedTLVs[record.AmtOnionType] = true + allowedTLVs[record.LockTimeOnionType] = true + allowedTLVs[record.TotalAmtMsatBlindedType] = true + } + + for tlvType := range payloadParsed { + if _, ok := allowedTLVs[tlvType]; ok { + continue + } + + return ErrInvalidPayload{ + Type: tlvType, + Violation: IncludedViolation, + FinalHop: isFinalHop, + } + } + + return nil +} diff --git a/htlcswitch/hop/payload_test.go b/htlcswitch/hop/payload_test.go index 148b806f96..84e1e7d736 100644 --- a/htlcswitch/hop/payload_test.go +++ b/htlcswitch/hop/payload_test.go @@ -10,6 +10,7 @@ import ( "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -695,3 +696,67 @@ func TestValidateBlindedRouteData(t *testing.T) { }) } } + +// TestValidatePayloadWithBlinded tests validation of the contents of a +// payload when it's for a blinded payment. +func TestValidatePayloadWithBlinded(t *testing.T) { + t.Parallel() + + finalHopMap := map[tlv.Type][]byte{ + record.AmtOnionType: nil, + record.LockTimeOnionType: nil, + record.TotalAmtMsatBlindedType: nil, + } + + tests := []struct { + name string + isFinal bool + parsed map[tlv.Type][]byte + err bool + }{ + { + name: "final hop, valid", + isFinal: true, + parsed: finalHopMap, + }, + { + name: "intermediate hop, invalid", + isFinal: false, + parsed: finalHopMap, + err: true, + }, + { + name: "intermediate hop, invalid", + isFinal: false, + parsed: map[tlv.Type][]byte{ + record.EncryptedDataOnionType: nil, + record.BlindingPointOnionType: nil, + }, + }, + { + name: "unknown record, invalid", + isFinal: false, + parsed: map[tlv.Type][]byte{ + tlv.Type(99): nil, + }, + err: true, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + err := hop.ValidatePayloadWithBlinded( + testCase.isFinal, testCase.parsed, + ) + + // We can't determine our exact error because we + // iterate through a map (non-deterministic) in the + // function. + if testCase.err { + require.NotNil(t, err) + } else { + require.Nil(t, err) + } + }) + } +} From da76d05fa59b963807a422f3d023f34bc7e15168 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 2 Apr 2024 09:50:13 -0400 Subject: [PATCH 06/16] htlcswitch: add NextBlinding to ForwardingInfo and set in UpdateAddHtlc When we have a HTLC that is part of a blinded route, we need to include the next ephemeral blinding point in UpdateAddHtlc for the next hop. The way that we handle the addition of this key is the same for introduction nodes and relaying nodes within the route. --- htlcswitch/hop/forwarding_info.go | 5 +++++ htlcswitch/hop/iterator.go | 32 +++++++++++++++++++++++++++++++ htlcswitch/hop/iterator_test.go | 7 +++++++ htlcswitch/link.go | 14 ++++++++------ 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go index 3ec358a0ac..5a1463c485 100644 --- a/htlcswitch/hop/forwarding_info.go +++ b/htlcswitch/hop/forwarding_info.go @@ -22,4 +22,9 @@ type ForwardingInfo struct { // OutgoingCTLV is the specified value of the CTLV timelock to be used // in the outgoing HTLC. OutgoingCTLV uint32 + + // NextBlinding is an optional blinding point to be passed to the next + // node in UpdateAddHtlc. This field is set if the htlc is part of a + // blinded route. + NextBlinding lnwire.BlindingPointRecord } diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 87691d1925..68b08fe42b 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -131,6 +131,10 @@ type BlindingProcessor interface { // ephemeral key provided. DecryptBlindedHopData(ephemPub *btcec.PublicKey, encryptedData []byte) ([]byte, error) + + // NextEphemeral returns the next hop's ephemeral key, calculated + // from the current ephemeral key provided. + NextEphemeral(*btcec.PublicKey) (*btcec.PublicKey, error) } // BlindingKit contains the components required to extract forwarding @@ -250,12 +254,40 @@ func (b *BlindingKit) DecryptAndValidateFwdInfo(payload *Payload, return nil, err } + // If we have an override for the blinding point for the next node, + // we'll just use it without tweaking (the sender intended to switch + // out directly for this blinding point). Otherwise, we'll tweak our + // blinding point to get the next ephemeral key. + nextEph, err := routeData.NextBlindingOverride.UnwrapOrFuncErr( + func() (tlv.RecordT[tlv.TlvType8, + *btcec.PublicKey], error) { + + next, err := b.Processor.NextEphemeral(blindingPoint) + if err != nil { + // Return a zero record because we expect the + // error to be checked. + return routeData.NextBlindingOverride.Zero(), + err + } + + return tlv.NewPrimitiveRecord[tlv.TlvType8](next), nil + }, + ) + if err != nil { + return nil, err + } + return &ForwardingInfo{ NextHop: routeData.ShortChannelID.Val, AmountToForward: fwdAmt, OutgoingCTLV: b.IncomingCltv - uint32( routeData.RelayInfo.Val.CltvExpiryDelta, ), + // Remap from blinding override type to blinding point type. + NextBlinding: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( + nextEph.Val), + ), }, nil } diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index e39dbb1513..60919333b3 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -170,6 +170,13 @@ func (m *mockProcessor) DecryptBlindedHopData(_ *btcec.PublicKey, return data, m.decryptErr } +// NextEphemeral mocks getting our next ephemeral key. +func (m *mockProcessor) NextEphemeral(*btcec.PublicKey) (*btcec.PublicKey, + error) { + + return nil, nil +} + // TestDecryptAndValidateFwdInfo tests deriving forwarding info using a // blinding kit. This test does not cover assertions on the calculations of // forwarding information, because this is covered in a test dedicated to those diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 3288584d59..45b9a8729a 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -3348,9 +3348,10 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg, // Otherwise, it was already processed, we can // can collect it and continue. addMsg := &lnwire.UpdateAddHTLC{ - Expiry: fwdInfo.OutgoingCTLV, - Amount: fwdInfo.AmountToForward, - PaymentHash: pd.RHash, + Expiry: fwdInfo.OutgoingCTLV, + Amount: fwdInfo.AmountToForward, + PaymentHash: pd.RHash, + BlindingPoint: fwdInfo.NextBlinding, } // Finally, we'll encode the onion packet for @@ -3393,9 +3394,10 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg, // create the outgoing HTLC using the parameters as // specified in the forwarding info. addMsg := &lnwire.UpdateAddHTLC{ - Expiry: fwdInfo.OutgoingCTLV, - Amount: fwdInfo.AmountToForward, - PaymentHash: pd.RHash, + Expiry: fwdInfo.OutgoingCTLV, + Amount: fwdInfo.AmountToForward, + PaymentHash: pd.RHash, + BlindingPoint: fwdInfo.NextBlinding, } // Finally, we'll encode the onion packet for the From 1e6fae37f752be7caf73995f693871e4a3d1bd3f Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 2 Apr 2024 10:56:08 -0400 Subject: [PATCH 07/16] htlcswitch: add blinding point to sphinx iterator for decoding --- .../htlc_incoming_contest_resolver.go | 17 ++---- .../htlc_incoming_contest_resolver_test.go | 3 +- contractcourt/interfaces.go | 3 +- htlcswitch/hop/iterator.go | 57 +++++++++++++++---- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go index d43a50d906..9f08f0a7c6 100644 --- a/contractcourt/htlc_incoming_contest_resolver.go +++ b/contractcourt/htlc_incoming_contest_resolver.go @@ -7,7 +7,6 @@ import ( "fmt" "io" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/lightningnetwork/lnd/channeldb" @@ -18,7 +17,6 @@ import ( "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/queue" - "github.com/lightningnetwork/lnd/tlv" ) // htlcIncomingContestResolver is a ContractResolver that's able to resolve an @@ -522,18 +520,15 @@ func (h *htlcIncomingContestResolver) Supplement(htlc channeldb.HTLC) { func (h *htlcIncomingContestResolver) decodePayload() (*hop.Payload, []byte, error) { - var blindingPoint *btcec.PublicKey - h.htlc.BlindingPoint.WhenSome( - func(b tlv.RecordT[lnwire.BlindingPointTlvType, - *btcec.PublicKey]) { - - blindingPoint = b.Val - }, - ) + blindingInfo := hop.ReconstructBlindingInfo{ + IncomingAmt: h.htlc.Amt, + IncomingExpiry: h.htlc.RefundTimeout, + BlindingKey: h.htlc.BlindingPoint, + } onionReader := bytes.NewReader(h.htlc.OnionBlob[:]) iterator, err := h.OnionProcessor.ReconstructHopIterator( - onionReader, h.htlc.RHash[:], blindingPoint, + onionReader, h.htlc.RHash[:], blindingInfo, ) if err != nil { return nil, nil, err diff --git a/contractcourt/htlc_incoming_contest_resolver_test.go b/contractcourt/htlc_incoming_contest_resolver_test.go index d789858fb4..cc3f9c934f 100644 --- a/contractcourt/htlc_incoming_contest_resolver_test.go +++ b/contractcourt/htlc_incoming_contest_resolver_test.go @@ -6,7 +6,6 @@ import ( "io/ioutil" "testing" - "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" @@ -290,7 +289,7 @@ type mockOnionProcessor struct { } func (o *mockOnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte, - blindingPoint *btcec.PublicKey) (hop.Iterator, error) { + _ hop.ReconstructBlindingInfo) (hop.Iterator, error) { data, err := ioutil.ReadAll(r) if err != nil { diff --git a/contractcourt/interfaces.go b/contractcourt/interfaces.go index 146670a414..a48d2373eb 100644 --- a/contractcourt/interfaces.go +++ b/contractcourt/interfaces.go @@ -4,7 +4,6 @@ import ( "context" "io" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb/models" @@ -42,7 +41,7 @@ type OnionProcessor interface { // ReconstructHopIterator attempts to decode a valid sphinx packet from // the passed io.Reader instance. ReconstructHopIterator(r io.Reader, rHash []byte, - blindingKey *btcec.PublicKey) (hop.Iterator, error) + blindingInfo hop.ReconstructBlindingInfo) (hop.Iterator, error) } // UtxoSweeper defines the sweep functions that contract court requires. diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 68b08fe42b..35fe5e44e4 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -55,16 +55,24 @@ type sphinxHopIterator struct { // includes the information required to properly forward the packet to // the next hop. processedPacket *sphinx.ProcessedPacket + + // blindingKit contains the elements required to process hops that are + // part of a blinded route. + blindingKit BlindingKit } // makeSphinxHopIterator converts a processed packet returned from a sphinx -// router and converts it into an hop iterator for usage in the link. +// router and converts it into an hop iterator for usage in the link. A +// blinding kit is passed through for the link to obtain forwarding information +// for blinded routes. func makeSphinxHopIterator(ogPacket *sphinx.OnionPacket, - packet *sphinx.ProcessedPacket) *sphinxHopIterator { + packet *sphinx.ProcessedPacket, + blindingKit BlindingKit) *sphinxHopIterator { return &sphinxHopIterator{ ogPacket: ogPacket, processedPacket: packet, + blindingKit: blindingKit, } } @@ -370,11 +378,24 @@ func (p *OnionProcessor) Stop() error { return nil } -// ReconstructHopIterator attempts to decode a valid sphinx packet from the passed io.Reader -// instance using the rHash as the associated data when checking the relevant -// MACs during the decoding process. +// ReconstructBlindingInfo contains the information required to reconstruct a +// blinded onion. +type ReconstructBlindingInfo struct { + // BlindingKey is the blinding point set in UpdateAddHTLC. + BlindingKey lnwire.BlindingPointRecord + + // IncomingAmt is the amount for the incoming HTLC. + IncomingAmt lnwire.MilliSatoshi + + // IncomingExpiry is the expiry height of the incoming HTLC. + IncomingExpiry uint32 +} + +// ReconstructHopIterator attempts to decode a valid sphinx packet from the +// passed io.Reader instance using the rHash as the associated data when +// checking the relevant MACs during the decoding process. func (p *OnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte, - blindingPoint *btcec.PublicKey) (Iterator, error) { + blindingInfo ReconstructBlindingInfo) (Iterator, error) { onionPkt := &sphinx.OnionPacket{} if err := onionPkt.Decode(r); err != nil { @@ -382,9 +403,11 @@ func (p *OnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte, } var opts []sphinx.ProcessOnionOpt - if blindingPoint != nil { - opts = append(opts, sphinx.WithBlindingPoint(blindingPoint)) - } + blindingInfo.BlindingKey.WhenSome(func( + r tlv.RecordT[lnwire.BlindingPointTlvType, *btcec.PublicKey]) { + + opts = append(opts, sphinx.WithBlindingPoint(r.Val)) + }) // Attempt to process the Sphinx packet. We include the payment hash of // the HTLC as it's authenticated within the Sphinx packet itself as @@ -398,7 +421,12 @@ func (p *OnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte, return nil, err } - return makeSphinxHopIterator(onionPkt, sphinxPacket), nil + return makeSphinxHopIterator(onionPkt, sphinxPacket, BlindingKit{ + Processor: p.router, + UpdateAddBlinding: blindingInfo.BlindingKey, + IncomingAmount: blindingInfo.IncomingAmt, + IncomingCltv: blindingInfo.IncomingExpiry, + }), nil } // DecodeHopIteratorRequest encapsulates all date necessary to process an onion @@ -575,7 +603,14 @@ func (p *OnionProcessor) DecodeHopIterators(id []byte, // Finally, construct a hop iterator from our processed sphinx // packet, simultaneously caching the original onion packet. - resp.HopIterator = makeSphinxHopIterator(&onionPkts[i], &packets[i]) + resp.HopIterator = makeSphinxHopIterator( + &onionPkts[i], &packets[i], BlindingKit{ + Processor: p.router, + UpdateAddBlinding: reqs[i].BlindingPoint, + IncomingAmount: reqs[i].IncomingAmount, + IncomingCltv: reqs[i].IncomingCltv, + }, + ) } return resps, nil From 2029a06918f138064dedeff1019b9180e757a912 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 2 Apr 2024 10:59:57 -0400 Subject: [PATCH 08/16] multi: return parsed types from payload To separate blinded route parsing from payload parsing, we need to return the parsed types map so that we can properly validate blinded data payloads against what we saw in the onion. --- htlcswitch/hop/fuzz_test.go | 4 ++-- htlcswitch/hop/iterator.go | 4 +++- htlcswitch/hop/payload.go | 17 ++++++++++------- htlcswitch/hop/payload_test.go | 2 +- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/htlcswitch/hop/fuzz_test.go b/htlcswitch/hop/fuzz_test.go index 7d528a1c42..cbbe882601 100644 --- a/htlcswitch/hop/fuzz_test.go +++ b/htlcswitch/hop/fuzz_test.go @@ -117,7 +117,7 @@ func fuzzPayload(f *testing.F, finalPayload bool) { r := bytes.NewReader(data) - payload1, err := NewPayloadFromReader(r, finalPayload) + payload1, _, err := NewPayloadFromReader(r, finalPayload) if err != nil { return } @@ -146,7 +146,7 @@ func fuzzPayload(f *testing.F, finalPayload bool) { require.NoError(t, err) } - payload2, err := NewPayloadFromReader(&b, finalPayload) + payload2, _, err := NewPayloadFromReader(&b, finalPayload) require.NoError(t, err) require.Equal(t, payload1, payload2) diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 35fe5e44e4..e149547236 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -106,11 +106,13 @@ func (r *sphinxHopIterator) HopPayload() (*Payload, error) { // Otherwise, if this is the TLV payload, then we'll make a new stream // to decode only what we need to make routing decisions. case sphinx.PayloadTLV: - return NewPayloadFromReader( + payload, _, err := NewPayloadFromReader( bytes.NewReader(r.processedPacket.Payload.Payload), r.processedPacket.Action == sphinx.ExitNode, ) + return payload, err + default: return nil, fmt.Errorf("unknown sphinx payload type: %v", r.processedPacket.Payload.Type) diff --git a/htlcswitch/hop/payload.go b/htlcswitch/hop/payload.go index 99c36db6de..70fdb1403f 100644 --- a/htlcswitch/hop/payload.go +++ b/htlcswitch/hop/payload.go @@ -133,11 +133,14 @@ func NewLegacyPayload(f *sphinx.HopData) *Payload { } } -// NewPayloadFromReader builds a new Hop from the passed io.Reader. The reader +// NewPayloadFromReader builds a new Hop from the passed io.Reader and returns +// a map of all the types that were found in the payload. The reader // should correspond to the bytes encapsulated in a TLV onion payload. The // final hop bool signals that this payload was the final packet parsed by // sphinx. -func NewPayloadFromReader(r io.Reader, finalHop bool) (*Payload, error) { +func NewPayloadFromReader(r io.Reader, finalHop bool) (*Payload, + map[tlv.Type][]byte, error) { + var ( cid uint64 amt uint64 @@ -162,27 +165,27 @@ func NewPayloadFromReader(r io.Reader, finalHop bool) (*Payload, error) { record.NewTotalAmtMsatBlinded(&totalAmtMsat), ) if err != nil { - return nil, err + return nil, nil, err } // Since this data is provided by a potentially malicious peer, pass it // into the P2P decoding variant. parsedTypes, err := tlvStream.DecodeWithParsedTypesP2P(r) if err != nil { - return nil, err + return nil, nil, err } // Validate whether the sender properly included or omitted tlv records // in accordance with BOLT 04. err = ValidateParsedPayloadTypes(parsedTypes, finalHop) if err != nil { - return nil, err + return nil, nil, err } // Check for violation of the rules for mandatory fields. violatingType := getMinRequiredViolation(parsedTypes) if violatingType != nil { - return nil, ErrInvalidPayload{ + return nil, nil, ErrInvalidPayload{ Type: *violatingType, Violation: RequiredViolation, FinalHop: finalHop, @@ -229,7 +232,7 @@ func NewPayloadFromReader(r io.Reader, finalHop bool) (*Payload, error) { blindingPoint: blindingPoint, customRecords: customRecords, totalAmtMsat: lnwire.MilliSatoshi(totalAmtMsat), - }, nil + }, nil, nil } // ForwardingInfo returns the basic parameters required for HTLC forwarding, diff --git a/htlcswitch/hop/payload_test.go b/htlcswitch/hop/payload_test.go index 84e1e7d736..301e577166 100644 --- a/htlcswitch/hop/payload_test.go +++ b/htlcswitch/hop/payload_test.go @@ -479,7 +479,7 @@ func testDecodeHopPayloadValidation(t *testing.T, test decodePayloadTest) { testChildIndex = uint32(9) ) - p, err := hop.NewPayloadFromReader( + p, _, err := hop.NewPayloadFromReader( bytes.NewReader(test.payload), test.isFinalHop, ) if !reflect.DeepEqual(test.expErr, err) { From 6d410376288c73f2ed82a5213b47cfa6bf4ae846 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 2 Apr 2024 11:11:35 -0400 Subject: [PATCH 09/16] htlcswitch: set forwarding information from encrypted data If we received a payload with a encrypted data point set, our forwarding information should be set from the information in our encrypted blob. This behavior is the same for introduction and relying nodes in a blinded route. --- htlcswitch/hop/iterator.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index e149547236..df6f5aac72 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -106,10 +106,27 @@ func (r *sphinxHopIterator) HopPayload() (*Payload, error) { // Otherwise, if this is the TLV payload, then we'll make a new stream // to decode only what we need to make routing decisions. case sphinx.PayloadTLV: - payload, _, err := NewPayloadFromReader( + isFinal := r.processedPacket.Action == sphinx.ExitNode + payload, parsed, err := NewPayloadFromReader( bytes.NewReader(r.processedPacket.Payload.Payload), - r.processedPacket.Action == sphinx.ExitNode, + isFinal, ) + if err != nil { + return nil, err + } + + // If we had an encrypted data payload present, pull out our + // forwarding info from the blob. + if payload.encryptedData != nil { + fwdInfo, err := r.blindingKit.DecryptAndValidateFwdInfo( + payload, isFinal, parsed, + ) + if err != nil { + return nil, err + } + + payload.FwdInfo = *fwdInfo + } return payload, err From 7e4f3d3b1dc9881b4c040c2b31b3cb9016d65edc Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Wed, 3 Apr 2024 08:58:53 -0400 Subject: [PATCH 10/16] htlcswitch: reject HTLCs that use use as introduction if disabled Reject any HTLCs that use us as an introduction point in a blinded route if we have disabled route blinding. We have to do this after we've processed the payload, because we only know we're an introduction point once we've processed the payload itself. --- htlcswitch/link.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 45b9a8729a..778e78d700 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -3309,6 +3309,27 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg, fwdInfo := pld.ForwardingInfo() + // Check whether the payload we've just processed uses our + // node as the introduction point (gave us a blinding key in + // the payload itself) and fail it back if we don't support + // route blinding. + if fwdInfo.NextBlinding.IsSome() && + l.cfg.DisallowRouteBlinding { + + failure := lnwire.NewInvalidBlinding( + onionBlob[:], + ) + l.sendHTLCError( + pd, NewLinkError(failure), obfuscator, false, + ) + + l.log.Error("rejected htlc that uses use as an " + + "introduction point when we do not support " + + "route blinding") + + continue + } + switch fwdInfo.NextHop { case hop.Exit: err := l.processExitHop( From a72aaa3d84727d47d98159b85ffe78df8b4c519c Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 13 Dec 2022 14:44:00 -0500 Subject: [PATCH 11/16] lntest: add setup for blinded route forwarding itest Note: the itest is broken up into multiple commits to make it more readable, they can be squashed post-review. --- itest/list_on_test.go | 4 + ...blinding.go => lnd_route_blinding_test.go} | 92 +++++++++++++++++++ 2 files changed, 96 insertions(+) rename itest/{lnd_route_blinding.go => lnd_route_blinding_test.go} (78%) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 7a618402f8..f78601a104 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -558,6 +558,10 @@ var allTestCases = []*lntest.TestCase{ Name: "query blinded route", TestFunc: testQueryBlindedRoutes, }, + { + Name: "forward blinded", + TestFunc: testForwardBlindedRoute, + }, { Name: "removetx", TestFunc: testRemoveTx, diff --git a/itest/lnd_route_blinding.go b/itest/lnd_route_blinding_test.go similarity index 78% rename from itest/lnd_route_blinding.go rename to itest/lnd_route_blinding_test.go index 2104cb1c83..bd053fd463 100644 --- a/itest/lnd_route_blinding.go +++ b/itest/lnd_route_blinding_test.go @@ -10,6 +10,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -310,3 +311,94 @@ func testQueryBlindedRoutes(ht *lntest.HarnessTest) { ht.CloseChannel(alice, chanPointAliceBob) ht.CloseChannel(bob, chanPointBobCarol) } + +type blindedForwardTest struct { + ht *lntest.HarnessTest + carol *node.HarnessNode + dave *node.HarnessNode + channels []*lnrpc.ChannelPoint +} + +func newBlindedForwardTest(ht *lntest.HarnessTest) *blindedForwardTest { + return &blindedForwardTest{ + ht: ht, + } +} + +// setup spins up additional nodes needed for our test and creates a four hop +// network for testing blinded forwarding. +func (b *blindedForwardTest) setup() { + b.carol = b.ht.NewNode("Carol", nil) + b.dave = b.ht.NewNode("Dave", nil) + + b.channels = setupFourHopNetwork(b.ht, b.carol, b.dave) +} + +// cleanup tears down all channels created by the test. +func (b *blindedForwardTest) cleanup() { + b.ht.CloseChannel(b.ht.Alice, b.channels[0]) + b.ht.CloseChannel(b.ht.Bob, b.channels[1]) + b.ht.CloseChannel(b.carol, b.channels[2]) +} + +// setupFourHopNetwork creates a network with the following topology and +// liquidity: +// Alice (100k)----- Bob (100k) ----- Carol (100k) ----- Dave +// +// The funding outpoint for AB / BC / CD are returned in-order. +func setupFourHopNetwork(ht *lntest.HarnessTest, + carol, dave *node.HarnessNode) []*lnrpc.ChannelPoint { + + const chanAmt = btcutil.Amount(100000) + var networkChans []*lnrpc.ChannelPoint + + // Open a channel with 100k satoshis between Alice and Bob with Alice + // being the sole funder of the channel. + chanPointAlice := ht.OpenChannel( + ht.Alice, ht.Bob, lntest.OpenChannelParams{ + Amt: chanAmt, + }, + ) + networkChans = append(networkChans, chanPointAlice) + + // Create a channel between bob and carol. + ht.EnsureConnected(ht.Bob, carol) + chanPointBob := ht.OpenChannel( + ht.Bob, carol, lntest.OpenChannelParams{ + Amt: chanAmt, + }, + ) + networkChans = append(networkChans, chanPointBob) + + // Fund carol and connect her and dave so that she can create a channel + // between them. + ht.FundCoins(btcutil.SatoshiPerBitcoin, carol) + ht.EnsureConnected(carol, dave) + + chanPointCarol := ht.OpenChannel( + carol, dave, lntest.OpenChannelParams{ + Amt: chanAmt, + }, + ) + networkChans = append(networkChans, chanPointCarol) + + // Wait for all nodes to have seen all channels. + nodes := []*node.HarnessNode{ht.Alice, ht.Bob, carol, dave} + for _, chanPoint := range networkChans { + for _, node := range nodes { + ht.AssertTopologyChannelOpen(node, chanPoint) + } + } + + return []*lnrpc.ChannelPoint{ + chanPointAlice, + chanPointBob, + chanPointCarol, + } +} + +// testForwardBlindedRoute tests lnd's ability to forward payments in a blinded +// route. +func testForwardBlindedRoute(ht *lntest.HarnessTest) { + newBlindedForwardTest(ht) +} From 58dda83b22ddece429ec381bcd809a3bb5427a14 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 13 Dec 2022 16:25:03 -0500 Subject: [PATCH 12/16] lntest: add helper to create blinded route --- itest/lnd_route_blinding_test.go | 143 +++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index bd053fd463..e2d5cff089 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -6,11 +6,15 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/chainreg" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/node" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/routing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -397,6 +401,145 @@ func setupFourHopNetwork(ht *lntest.HarnessTest, } } +// createBlindedRoute creates a blinded route to the recipient node provided. +// The set of hops is expected to start at the introduction node and end at +// the recipient. +func (b *blindedForwardTest) createBlindedRoute(hops []*forwardingEdge, + dest *btcec.PublicKey, finalCLTV uint16) *routing.BlindedPayment { + + // Create a path with space for each of our hops + the destination + // node. We include our passed final cltv delta here because blinded + // paths include the delta in the blinded portion (not the invoice). + blindedPayment := &routing.BlindedPayment{ + CltvExpiryDelta: finalCLTV, + } + + pathLength := len(hops) + 1 + blindedPath := make([]*sphinx.HopInfo, pathLength) + + // Run forwards through our hops to create blinded route data for each + // node with the next node's short channel id and our payment + // constraints. + for i := 0; i < len(hops); i++ { + node := hops[i] + scid := node.channelID + + // Set the relay information for this edge based on its policy. + delta := uint16(node.edge.TimeLockDelta) + relayInfo := &record.PaymentRelayInfo{ + BaseFee: uint32(node.edge.FeeBaseMsat), + FeeRate: uint32(node.edge.FeeRateMilliMsat), + CltvExpiryDelta: delta, + } + + // We set our constraints with our edge's actual htlc min, and + // an arbitrary maximum expiry (since it's just an anti-probing + // mechanism). + constraints := &record.PaymentConstraints{ + HtlcMinimumMsat: lnwire.MilliSatoshi(node.edge.MinHtlc), + MaxCltvExpiry: 100000, + } + + // Add CLTV delta of each hop to the blinded payment. + blindedPayment.CltvExpiryDelta += delta + + // Encode the route's blinded data and include it in the + // blinded hop. + payload := record.NewBlindedRouteData( + scid, nil, *relayInfo, constraints, nil, + ) + payloadBytes, err := record.EncodeBlindedRouteData(payload) + require.NoError(b.ht, err) + + blindedPath[i] = &sphinx.HopInfo{ + NodePub: node.pubkey, + PlainText: payloadBytes, + } + } + + // Next, we'll run backwards through our route to build up the aggregate + // fees for the blinded payment as a whole. This is done in a separate + // loop for the sake of readability. + // + // For blinded path aggregated fees, we start at the receiving node + // and add up base an proportional fees *including* the fees that we'll + // charge on accumulated fees. We use the int ceiling to round up so + // that the sender will always over-pay, ensuring that we don't round + // down along the route leaving one forwarding node short of what + // they're expecting. + var ( + hopCount = len(hops) - 1 + currentHopBaseFee = hops[hopCount].edge.FeeBaseMsat + currentHopPropFee = hops[hopCount].edge.FeeRateMilliMsat + feeParts int64 = 1e6 + ) + + // Note: the spec says to iterate backwards, but then uses n / n +1 to + // express the "next" hop in the route going backwards. This works for + // languages where we can iterate backwards and get an increasing + // index, but since we're counting backwards we use n-1 instead. + // + // Specification reference: + //nolint:lll + // https://github.com/lightning/bolts/blob/60de4a09727c20dea330f9ee8313034de6e50594/proposals/route-blinding.md?plain=1#L253-L254 + for i := hopCount; i > 0; i-- { + preceedingBase := hops[i-1].edge.FeeBaseMsat + preceedingProp := hops[i-1].edge.FeeBaseMsat + + // Separate numerator from ceiling division to break up large + // lines. + baseFeeNumerator := preceedingBase*feeParts + + currentHopBaseFee*(feeParts+preceedingProp) + currentHopBaseFee = (baseFeeNumerator + feeParts - 1) / feeParts + + propFeeNumerator := (currentHopPropFee+preceedingProp)* + feeParts + currentHopPropFee*preceedingProp + currentHopPropFee = (propFeeNumerator + feeParts - 1) / feeParts + } + + blindedPayment.BaseFee = uint32(currentHopBaseFee) + blindedPayment.ProportionalFeeRate = uint32(currentHopPropFee) + + // Add our destination node at the end of the path. We don't need to + // add any forwarding parameters because we're at the final hop. + payloadBytes, err := record.EncodeBlindedRouteData( + // TODO: we don't have support for the final hop fields, + // because only forwarding is supported. We add a next + // node ID here so that it _looks like_ a valid + // forwarding hop (though in reality it's the last + // hop). + record.NewBlindedRouteData( + lnwire.NewShortChanIDFromInt(100), nil, + record.PaymentRelayInfo{}, nil, nil, + ), + ) + require.NoError(b.ht, err, "final payload") + + blindedPath[pathLength-1] = &sphinx.HopInfo{ + NodePub: dest, + PlainText: payloadBytes, + } + + // Blind the path. + blindingKey, err := btcec.NewPrivateKey() + require.NoError(b.ht, err) + + blindedPayment.BlindedPath, err = sphinx.BuildBlindedPath( + blindingKey, blindedPath, + ) + require.NoError(b.ht, err, "build blinded path") + + return blindedPayment +} + +// forwardingEdge contains the channel id/source public key for a forwarding +// edge and the policy associated with the channel in that direction. +type forwardingEdge struct { + pubkey *btcec.PublicKey + channelID lnwire.ShortChannelID + edge *lnrpc.RoutingPolicy +} + // testForwardBlindedRoute tests lnd's ability to forward payments in a blinded // route. func testForwardBlindedRoute(ht *lntest.HarnessTest) { From 69e1162dd17355524da43fa5d3610e5ad87141f1 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Tue, 13 Dec 2022 16:41:54 -0500 Subject: [PATCH 13/16] lntest: add route construction to blinded forwarding test --- itest/lnd_route_blinding_test.go | 129 +++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 7 deletions(-) diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index e2d5cff089..556aece84c 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -1,6 +1,7 @@ package itest import ( + "context" "crypto/sha256" "encoding/hex" @@ -321,28 +322,111 @@ type blindedForwardTest struct { carol *node.HarnessNode dave *node.HarnessNode channels []*lnrpc.ChannelPoint + + // cancel will cancel the test's top level context. + cancel func() } -func newBlindedForwardTest(ht *lntest.HarnessTest) *blindedForwardTest { - return &blindedForwardTest{ - ht: ht, +func newBlindedForwardTest(ht *lntest.HarnessTest) (context.Context, + *blindedForwardTest) { + + ctx, cancel := context.WithCancel(context.Background()) + + return ctx, &blindedForwardTest{ + ht: ht, + cancel: cancel, } } // setup spins up additional nodes needed for our test and creates a four hop -// network for testing blinded forwarding. -func (b *blindedForwardTest) setup() { +// network for testing blinded forwarding and returns a blinded route from +// Bob -> Carol -> Dave, with Bob acting as the introduction point. +func (b *blindedForwardTest) setup() *routing.BlindedPayment { b.carol = b.ht.NewNode("Carol", nil) b.dave = b.ht.NewNode("Dave", nil) b.channels = setupFourHopNetwork(b.ht, b.carol, b.dave) + + // Create a blinded route to Dave via Bob --- Carol --- Dave: + bobChan := b.ht.GetChannelByChanPoint(b.ht.Bob, b.channels[1]) + carolChan := b.ht.GetChannelByChanPoint(b.carol, b.channels[2]) + + edges := []*forwardingEdge{ + getForwardingEdge(b.ht, b.ht.Bob, bobChan.ChanId), + getForwardingEdge(b.ht, b.carol, carolChan.ChanId), + } + + davePk, err := btcec.ParsePubKey(b.dave.PubKey[:]) + require.NoError(b.ht, err, "dave pubkey") + + return b.createBlindedRoute(edges, davePk, 50) } -// cleanup tears down all channels created by the test. +// cleanup tears down all channels created by the test and cancels the top +// level context used in the test. func (b *blindedForwardTest) cleanup() { b.ht.CloseChannel(b.ht.Alice, b.channels[0]) b.ht.CloseChannel(b.ht.Bob, b.channels[1]) b.ht.CloseChannel(b.carol, b.channels[2]) + + b.cancel() +} + +// createRouteToBlinded queries for a route from alice to the blinded path +// provided. +// +//nolint:gomnd +func (b *blindedForwardTest) createRouteToBlinded(paymentAmt int64, + route *routing.BlindedPayment) *lnrpc.Route { + + intro := route.BlindedPath.IntroductionPoint.SerializeCompressed() + blinding := route.BlindedPath.BlindingPoint.SerializeCompressed() + + blindedRoute := &lnrpc.BlindedPath{ + IntroductionNode: intro, + BlindingPoint: blinding, + BlindedHops: make( + []*lnrpc.BlindedHop, + len(route.BlindedPath.BlindedHops), + ), + } + + for i, hop := range route.BlindedPath.BlindedHops { + blindedRoute.BlindedHops[i] = &lnrpc.BlindedHop{ + BlindedNode: hop.BlindedNodePub.SerializeCompressed(), + EncryptedData: hop.CipherText, + } + } + blindedPath := &lnrpc.BlindedPaymentPath{ + BlindedPath: blindedRoute, + BaseFeeMsat: uint64( + route.BaseFee, + ), + ProportionalFeeRate: route.ProportionalFeeRate, + TotalCltvDelta: uint32( + route.CltvExpiryDelta, + ), + } + + req := &lnrpc.QueryRoutesRequest{ + AmtMsat: paymentAmt, + // Our fee limit doesn't really matter, we just want to + // be able to make the payment. + FeeLimit: &lnrpc.FeeLimit{ + Limit: &lnrpc.FeeLimit_Percent{ + Percent: 50, + }, + }, + BlindedPaymentPaths: []*lnrpc.BlindedPaymentPath{ + blindedPath, + }, + } + + resp := b.ht.Alice.RPC.QueryRoutes(req) + require.Greater(b.ht, len(resp.Routes), 0, "no routes") + require.Len(b.ht, resp.Routes[0].Hops, 3, "unexpected route length") + + return resp.Routes[0] } // setupFourHopNetwork creates a network with the following topology and @@ -540,8 +624,39 @@ type forwardingEdge struct { edge *lnrpc.RoutingPolicy } +func getForwardingEdge(ht *lntest.HarnessTest, + node *node.HarnessNode, chanID uint64) *forwardingEdge { + + chanInfo := node.RPC.GetChanInfo(&lnrpc.ChanInfoRequest{ + ChanId: chanID, + }) + + pubkey, err := btcec.ParsePubKey(node.PubKey[:]) + require.NoError(ht, err, "%v pubkey", node.Cfg.Name) + + fwdEdge := &forwardingEdge{ + pubkey: pubkey, + channelID: lnwire.NewShortChanIDFromInt(chanID), + } + + if chanInfo.Node1Pub == node.PubKeyStr { + fwdEdge.edge = chanInfo.Node1Policy + } else { + require.Equal(ht, node.PubKeyStr, chanInfo.Node2Pub, + "policy edge sanity check") + + fwdEdge.edge = chanInfo.Node2Policy + } + + return fwdEdge +} + // testForwardBlindedRoute tests lnd's ability to forward payments in a blinded // route. func testForwardBlindedRoute(ht *lntest.HarnessTest) { - newBlindedForwardTest(ht) + _, testCase := newBlindedForwardTest(ht) + defer testCase.cleanup() + + route := testCase.setup() + testCase.createRouteToBlinded(100_000, route) } From 0d9a184df8b242106fd16d398fdc30b36969ef87 Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Wed, 14 Dec 2022 11:38:26 -0500 Subject: [PATCH 14/16] lntest: dispatch and intercept payment to blinded route We don't support receiving blinded in this PR - just intercept and settle instead. The HTLC's arrival on the interceptor indicates that it was successfully forwarded on a blinded hop. --- itest/lnd_route_blinding_test.go | 144 +++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 8 deletions(-) diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index 556aece84c..430b001c6d 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -1,9 +1,11 @@ package itest import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -13,6 +15,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/node" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/routing" @@ -323,6 +326,10 @@ type blindedForwardTest struct { dave *node.HarnessNode channels []*lnrpc.ChannelPoint + carolInterceptor routerrpc.Router_HtlcInterceptorClient + + preimage [32]byte + // cancel will cancel the test's top level context. cancel func() } @@ -333,16 +340,28 @@ func newBlindedForwardTest(ht *lntest.HarnessTest) (context.Context, ctx, cancel := context.WithCancel(context.Background()) return ctx, &blindedForwardTest{ - ht: ht, - cancel: cancel, + ht: ht, + cancel: cancel, + preimage: [32]byte{1, 2, 3}, } } // setup spins up additional nodes needed for our test and creates a four hop // network for testing blinded forwarding and returns a blinded route from -// Bob -> Carol -> Dave, with Bob acting as the introduction point. -func (b *blindedForwardTest) setup() *routing.BlindedPayment { - b.carol = b.ht.NewNode("Carol", nil) +// Bob -> Carol -> Dave, with Bob acting as the introduction point and an +// interceptor on Carol's node to manage HTLCs (as Dave does not yet support +// receiving). +func (b *blindedForwardTest) setup( + ctx context.Context) *routing.BlindedPayment { + + b.carol = b.ht.NewNode("Carol", []string{ + "requireinterceptor", + }) + + var err error + b.carolInterceptor, err = b.carol.RPC.Router.HtlcInterceptor(ctx) + require.NoError(b.ht, err, "interceptor") + b.dave = b.ht.NewNode("Dave", nil) b.channels = setupFourHopNetwork(b.ht, b.carol, b.dave) @@ -429,6 +448,82 @@ func (b *blindedForwardTest) createRouteToBlinded(paymentAmt int64, return resp.Routes[0] } +// sendBlindedPayment dispatches a payment to the route provided. +func (b *blindedForwardTest) sendBlindedPayment(ctx context.Context, + route *lnrpc.Route) { + + hash := sha256.Sum256(b.preimage[:]) + sendReq := &routerrpc.SendToRouteRequest{ + PaymentHash: hash[:], + Route: route, + } + + // Dispatch in a goroutine because this call is blocking - we assume + // that we'll have assertions that this payment is sent by the caller. + go func() { + b.ht.Alice.RPC.SendToRouteV2(sendReq) + }() +} + +// interceptFinalHop launches a goroutine to intercept Carol's htlcs and +// returns a closure that can be used to resolve intercepted htlcs. +// +//nolint:lll +func (b *blindedForwardTest) interceptFinalHop() func(routerrpc.ResolveHoldForwardAction) { + hash := sha256.Sum256(b.preimage[:]) + htlcReceived := make(chan *routerrpc.ForwardHtlcInterceptRequest) + + // Launch a goroutine which will receive from the interceptor and pipe + // it into our request channel. + go func() { + forward, err := b.carolInterceptor.Recv() + if err != nil { + b.ht.Fatalf("intercept receive failed: %v", err) + } + + if !bytes.Equal(forward.PaymentHash, hash[:]) { + b.ht.Fatalf("unexpected payment hash: %v", hash) + } + + select { + case htlcReceived <- forward: + + case <-time.After(lntest.DefaultTimeout): + b.ht.Fatal("timeout waiting to send intercepted htlc") + } + }() + + // Create a closure that will wait for the intercept request and + // resolve the HTLC with the appropriate action. + resolve := func(action routerrpc.ResolveHoldForwardAction) { + select { + case forward := <-htlcReceived: + resp := &routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: forward.IncomingCircuitKey, + } + + switch action { + case routerrpc.ResolveHoldForwardAction_FAIL: + resp.Action = routerrpc.ResolveHoldForwardAction_FAIL + + case routerrpc.ResolveHoldForwardAction_SETTLE: + resp.Action = routerrpc.ResolveHoldForwardAction_SETTLE + resp.Preimage = b.preimage[:] + + case routerrpc.ResolveHoldForwardAction_RESUME: + resp.Action = routerrpc.ResolveHoldForwardAction_RESUME + } + + require.NoError(b.ht, b.carolInterceptor.Send(resp)) + + case <-time.After(lntest.DefaultTimeout): + b.ht.Fatal("timeout waiting for htlc intercept") + } + } + + return resolve +} + // setupFourHopNetwork creates a network with the following topology and // liquidity: // Alice (100k)----- Bob (100k) ----- Carol (100k) ----- Dave @@ -654,9 +749,42 @@ func getForwardingEdge(ht *lntest.HarnessTest, // testForwardBlindedRoute tests lnd's ability to forward payments in a blinded // route. func testForwardBlindedRoute(ht *lntest.HarnessTest) { - _, testCase := newBlindedForwardTest(ht) + ctx, testCase := newBlindedForwardTest(ht) defer testCase.cleanup() - route := testCase.setup() - testCase.createRouteToBlinded(100_000, route) + route := testCase.setup(ctx) + blindedRoute := testCase.createRouteToBlinded(10_000_000, route) + + // Receiving via blinded routes is not yet supported, so Dave won't be + // able to process the payment. + // + // We have an interceptor at our disposal that will catch htlcs as they + // are forwarded (ie, it won't intercept a HTLC that dave is receiving, + // since no forwarding occurs). We initiate this interceptor with + // Carol, so that we can catch it and settle on the outgoing link to + // Dave. Once we hit the outgoing link, we know that we successfully + // parsed the htlc, so this is an acceptable compromise. + // Assert that our interceptor has exited without an error. + resolveHTLC := testCase.interceptFinalHop() + + // Once our interceptor is set up, we can send the blinded payment. + testCase.sendBlindedPayment(ctx, blindedRoute) + + // Wait for the HTLC to be active on Alice's channel. + hash := sha256.Sum256(testCase.preimage[:]) + ht.AssertOutgoingHTLCActive(ht.Alice, testCase.channels[0], hash[:]) + ht.AssertOutgoingHTLCActive(ht.Bob, testCase.channels[1], hash[:]) + + // Intercept and settle the HTLC. + resolveHTLC(routerrpc.ResolveHoldForwardAction_SETTLE) + + // Wait for the HTLC to reflect as settled for Alice. + preimage, err := lntypes.MakePreimage(testCase.preimage[:]) + require.NoError(ht, err) + ht.AssertPaymentStatus(ht.Alice, preimage, lnrpc.Payment_SUCCEEDED) + + // Assert that the HTLC has settled before test cleanup runs so that + // we can cooperatively close all channels. + ht.AssertHLTCNotActive(ht.Bob, testCase.channels[1], hash[:]) + ht.AssertHLTCNotActive(ht.Alice, testCase.channels[0], hash[:]) } From 6e5eead61721dd647074d2f069beee3f8e97dc8b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Fri, 22 Mar 2024 13:39:17 -0400 Subject: [PATCH 15/16] lncfg: disable allowing blinded routes in daemon but not itests This commit turns off route blinding for the daemon while we're waiting on full handling for blinded errors. The feature remains on for itests so that tests covering blinding can run as usual. --- config.go | 5 +++-- lncfg/protocol.go | 8 ++++++++ lncfg/protocol_integration.go | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index f9aec0284d..3849950879 100644 --- a/config.go +++ b/config.go @@ -626,8 +626,9 @@ func DefaultConfig() Config { RejectCacheSize: channeldb.DefaultRejectCacheSize, ChannelCacheSize: channeldb.DefaultChannelCacheSize, }, - Prometheus: lncfg.DefaultPrometheus(), - Watchtower: lncfg.DefaultWatchtowerCfg(defaultTowerDir), + Prometheus: lncfg.DefaultPrometheus(), + Watchtower: lncfg.DefaultWatchtowerCfg(defaultTowerDir), + ProtocolOptions: lncfg.DefaultProtocol(), HealthChecks: &lncfg.HealthCheckConfig{ ChainCheck: &lncfg.CheckConfig{ Interval: defaultChainInterval, diff --git a/lncfg/protocol.go b/lncfg/protocol.go index e98b4dcf88..59027a09b7 100644 --- a/lncfg/protocol.go +++ b/lncfg/protocol.go @@ -59,6 +59,14 @@ type ProtocolOptions struct { NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"` } +// DefaultProtocol returns a protocol config with route blinding turned off, +// temporarily in place until full handling of blinded route errors is merged. +func DefaultProtocol() *ProtocolOptions { + return &ProtocolOptions{ + NoRouteBlindingOption: true, + } +} + // Wumbo returns true if lnd should permit the creation and acceptance of wumbo // channels. func (l *ProtocolOptions) Wumbo() bool { diff --git a/lncfg/protocol_integration.go b/lncfg/protocol_integration.go index 841f8e9eb6..f44aa12469 100644 --- a/lncfg/protocol_integration.go +++ b/lncfg/protocol_integration.go @@ -62,6 +62,13 @@ type ProtocolOptions struct { NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"` } +// DefaultProtocol returns a protocol config with route blinding turned on, +// so that itests can run against route blinding features even while we've +// got it turned off for the daemon (pending completion of error handling). +func DefaultProtocol() *ProtocolOptions { + return &ProtocolOptions{} +} + // Wumbo returns true if lnd should permit the creation and acceptance of wumbo // channels. func (l *ProtocolOptions) Wumbo() bool { From 2188dd9d2bfaf32ea14f28af9f2384778a344dac Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen <kirkcohenc@gmail.com> Date: Wed, 14 Feb 2024 09:21:56 -0500 Subject: [PATCH 16/16] docs: release notes 18.0 --- docs/release-notes/release-notes-0.18.0.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-0.18.0.md b/docs/release-notes/release-notes-0.18.0.md index c4ca138e79..55cd9dd60a 100644 --- a/docs/release-notes/release-notes-0.18.0.md +++ b/docs/release-notes/release-notes-0.18.0.md @@ -187,7 +187,9 @@ call where arguments were swapped. bitcoin peers' feefilter values into account](https://github.com/lightningnetwork/lnd/pull/8418). * [Preparatory work](https://github.com/lightningnetwork/lnd/pull/8159) for - forwarding of blinded routes was added. + forwarding of blinded routes was added, along with [support](https://github.com/lightningnetwork/lnd/pull/8160) + for forwarding blinded payments. Forwarding of blinded payments is disabled + by default, and the feature is not yet advertised to the network. ## RPC Additions