diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go index 19589555f..e550035a1 100644 --- a/htlcswitch/hop/forwarding_info.go +++ b/htlcswitch/hop/forwarding_info.go @@ -48,6 +48,15 @@ func NewChannelNextHop( return fn.NewLeft[lnwire.ShortChannelID, [33]byte](scid) } +// NewNodeNextHop returns a next-hop value that identifies the next hop by the +// next node's compressed public key, as used by blinded routes that set +// next_node_id instead of a short channel ID. +func NewNodeNextHop( + nodeID [33]byte) fn.Either[lnwire.ShortChannelID, [33]byte] { + + return fn.NewRight[lnwire.ShortChannelID, [33]byte](nodeID) +} + // IsExit returns true if this forwarding info denotes the exit hop, i.e. we are // the final recipient of the HTLC. This is the case when the next hop is a // short channel ID equal to hop.Exit. A node-ID next hop (used by some blinded @@ -69,6 +78,13 @@ func (f ForwardingInfo) NextHopChannel() fn.Option[lnwire.ShortChannelID] { return f.NextHop.LeftToSome() } +// NextHopNode returns the next hop's compressed pubkey when it is identified by +// node ID (blinded routes via next_node_id), or None when identified by +// channel. +func (f ForwardingInfo) NextHopNode() fn.Option[[33]byte] { + return f.NextHop.RightToSome() +} + // FinalHtlcValidationResult describes the result of checking a final-hop // HTLC against the onion payload and supported final-hop CLTV range. type FinalHtlcValidationResult uint8 diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go index 284c7c3bd..3ca5fbe3d 100644 --- a/htlcswitch/hop/forwarding_info_test.go +++ b/htlcswitch/hop/forwarding_info_test.go @@ -3,6 +3,7 @@ package hop import ( "testing" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) @@ -135,3 +136,41 @@ func TestValidateFinalHtlc(t *testing.T) { }) } } + +// TestForwardingInfoNextHop asserts the next-hop accessors for both the short +// channel ID (Left) and node ID (Right) representations, including the +// invariant that the zero-value ForwardingInfo denotes the exit hop. +func TestForwardingInfoNextHop(t *testing.T) { + t.Parallel() + + scid := lnwire.NewShortChanIDFromInt(12345) + nodeID := [33]byte{0x02} + + // The zero-value ForwardingInfo must denote the exit hop, since its + // NextHop is a Left equal to hop.Exit. Callers rely on this to detect + // that we are the final recipient. + zero := ForwardingInfo{} + require.True(t, zero.IsExit(), "zero value must be the exit hop") + require.Equal( + t, fn.Some(Exit), zero.NextHopChannel(), + "zero value must expose the Exit channel", + ) + + // An explicit channel next hop equal to Exit is likewise the exit hop. + exit := ForwardingInfo{NextHop: NewChannelNextHop(Exit)} + require.True(t, exit.IsExit()) + + // A channel next hop with a real SCID is a forward, and exposes that + // SCID through NextHopChannel. + channel := ForwardingInfo{NextHop: NewChannelNextHop(scid)} + require.False(t, channel.IsExit()) + require.Equal(t, fn.Some(scid), channel.NextHopChannel()) + + // A node-ID next hop is always a forward and never exposes an outgoing + // channel, since the switch selects one via non-strict forwarding. + node := ForwardingInfo{NextHop: NewNodeNextHop(nodeID)} + require.False(t, node.IsExit()) + require.Equal( + t, fn.None[lnwire.ShortChannelID](), node.NextHopChannel(), + ) +} diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 7240f2d85..6ecd998fc 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg/chainhash" sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/tlv" @@ -231,6 +232,13 @@ func parseAndValidateRecipientData(r *sphinxHopIterator, payload *Payload, return nil, routeRole, err } + // BOLT 4 requires a blinded hop to set exactly one of short_channel_id + // or next_node_id. Reject a hop that sets both here. + if routeData.ShortChannelID.IsSome() && routeData.NextNodeID.IsSome() { + return nil, routeRole, fmt.Errorf("blinded hop sets both " + + "short channel ID and next node ID") + } + // This is the final node in the blinded route. if isFinal { return deriveBlindedRouteFinalHopForwardingInfo( @@ -318,15 +326,35 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator, ) } - nextSCID, err := routeData.ShortChannelID.UnwrapOrErr( - fmt.Errorf("next SCID not set for non-final blinded hop"), - ) - if err != nil { - return nil, routeRole, err + // Determine the next hop. The recipient identifies it either by a short + // channel ID (the common case) or, as some implementations do for + // blinded routes, by the next node's ID (next_node_id). Setting both is + // already rejected upstream, and the dummy hop check above has handled + // a next_node_id that points at us. + var nextHop fn.Either[lnwire.ShortChannelID, [33]byte] + switch { + case routeData.ShortChannelID.IsSome(): + scid := routeData.ShortChannelID.UnwrapOr( + routeData.ShortChannelID.Zero(), + ) + nextHop = NewChannelNextHop(scid.Val) + + case routeData.NextNodeID.IsSome(): + nodeID := routeData.NextNodeID.UnwrapOr( + routeData.NextNodeID.Zero(), + ) + var pubKey [33]byte + copy(pubKey[:], nodeID.Val.SerializeCompressed()) + + nextHop = NewNodeNextHop(pubKey) + + default: + return nil, routeRole, fmt.Errorf("next hop not set for " + + "non-final blinded hop") } payload.FwdInfo = ForwardingInfo{ - NextHop: NewChannelNextHop(nextSCID.Val), + NextHop: nextHop, AmountToForward: fwdAmt, OutgoingCLTV: r.blindingKit.IncomingCltv - uint32( relayInfo.Val.CltvExpiryDelta, diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index 1acd39079..3d30faefc 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/davecgh/go-spew/spew" sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/tlv" @@ -305,3 +306,378 @@ func TestParseAndValidateRecipientData(t *testing.T) { }) } } + +// TestDeriveBlindedRouteNextHop asserts how a non-final blinded hop's next hop +// is derived from the recipient data: a short channel ID becomes a Left, a +// next_node_id becomes a Right, having both set is rejected with an error, and +// the absence of both is also an error. +func TestDeriveBlindedRouteNextHop(t *testing.T) { + t.Parallel() + + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + nextNodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + nextNodePub := nextNodeKey.PubKey() + + var nextNodeRaw [33]byte + copy(nextNodeRaw[:], nextNodePub.SerializeCompressed()) + + scid := lnwire.NewShortChanIDFromInt(1500) + + relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10]( + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + BaseFee: 100, + FeeRate: 0, + }, + )) + constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12]( + record.PaymentConstraints{ + MaxCltvExpiry: 1000, + HtlcMinimumMsat: lnwire.MilliSatoshi(1), + }, + )) + scidRecord := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType2](scid)) + nodeIDRecord := tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nextNodePub), + ) + + tests := []struct { + name string + data *record.BlindedRouteData + expectedHop fn.Either[lnwire.ShortChannelID, [33]byte] + expectedErr string + }{ + { + name: "short channel id only", + data: &record.BlindedRouteData{ + ShortChannelID: scidRecord, + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedHop: NewChannelNextHop(scid), + }, + { + name: "next node id only", + data: &record.BlindedRouteData{ + NextNodeID: nodeIDRecord, + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedHop: NewNodeNextHop(nextNodeRaw), + }, + { + // BOLT 4 requires a non-final blinded hop to set + // exactly one of short_channel_id or next_node_id, so + // setting both must be rejected. + name: "both present is an error", + data: &record.BlindedRouteData{ + ShortChannelID: scidRecord, + NextNodeID: nodeIDRecord, + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedErr: "both short channel ID and next node ID", + }, + { + name: "neither present", + data: &record.BlindedRouteData{ + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedErr: "next hop not set", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + data, err := record.EncodeBlindedRouteData( + testCase.data, + ) + require.NoError(t, err) + + kit := BlindingKit{ + Processor: &mockProcessor{}, + IncomingAmount: 10000, + IncomingCltv: 500, + UpdateAddBlinding: tlv.SomeRecordT( + //nolint:ll + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}), + ), + } + iterator := &sphinxHopIterator{ + blindingKit: kit, + router: sphinx.NewRouter( + &sphinx.PrivKeyECDH{PrivKey: nodeKey}, + sphinx.NewMemoryReplayLog(), + ), + } + + payload, _, err := parseAndValidateRecipientData( + iterator, &Payload{encryptedData: data}, + false, RouteRoleCleartext, + ) + + if testCase.expectedErr != "" { + require.ErrorContains( + t, err, testCase.expectedErr, + ) + + return + } + + require.NoError(t, err) + require.Equal( + t, testCase.expectedHop, + payload.FwdInfo.NextHop, + ) + }) + } +} + +// TestBlindedHopBothNextHopFieldsRejected asserts that a blinded hop setting +// both short_channel_id and next_node_id is rejected for a final hop and for a +// dummy hop (next_node_id == our own pubkey), not just an intermediate hop. The +// mutual-exclusivity check runs before the final-hop and dummy-hop branches, so +// none of them accept a hop that violates BOLT 4. The intermediate case is +// already covered by TestDeriveBlindedRouteNextHop. +func TestBlindedHopBothNextHopFieldsRejected(t *testing.T) { + t.Parallel() + + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + nodePub := nodeKey.PubKey() + + // Route data that sets both short_channel_id and next_node_id. The node + // ID is our own pubkey, which for a non-final hop would otherwise + // signal a dummy hop; the both-set check must still fire first. + bothData := &record.BlindedRouteData{ + ShortChannelID: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2]( + lnwire.NewShortChanIDFromInt(1500), + ), + ), + NextNodeID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub), + ), + RelayInfo: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10]( + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + BaseFee: 100, + FeeRate: 0, + }, + )), + Constraints: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12]( + record.PaymentConstraints{ + MaxCltvExpiry: 1000, + HtlcMinimumMsat: lnwire.MilliSatoshi(1), + }, + )), + } + data, err := record.EncodeBlindedRouteData(bothData) + require.NoError(t, err) + + // Both the dummy/forwarding path (isFinal=false, next_node_id points at + // us) and the final path (isFinal=true) must reject the hop. + for _, isFinal := range []bool{false, true} { + name := "forwarding hop" + if isFinal { + name = "final hop" + } + + t.Run(name, func(t *testing.T) { + kit := BlindingKit{ + Processor: &mockProcessor{}, + IncomingAmount: 10000, + IncomingCltv: 500, + UpdateAddBlinding: tlv.SomeRecordT( + //nolint:ll + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}), + ), + } + iterator := &sphinxHopIterator{ + blindingKit: kit, + router: sphinx.NewRouter( + &sphinx.PrivKeyECDH{PrivKey: nodeKey}, + sphinx.NewMemoryReplayLog(), + ), + } + + _, _, err := parseAndValidateRecipientData( + iterator, &Payload{encryptedData: data}, + isFinal, RouteRoleCleartext, + ) + require.ErrorContains( + t, err, + "both short channel ID and next node ID", + ) + }) + } +} + +// TestBlindedRouteDummyHopPeeledLocally asserts that a blinded route hop where +// next_node_id is our own public key is recognized as a dummy hop and is peeled +// locally rather than falling through to the generic next_node_id forwarding +// branch. +func TestBlindedRouteDummyHopPeeledLocally(t *testing.T) { + t.Parallel() + + // Construct a realistic onion packet that contains a blinded final hop. + // We'll use this to test that we can peel a dummy hop locally and + // extract the forwarding information from the decrypted final hop's + // payload. + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + nodePub := nodeKey.PubKey() + + relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10]( + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + BaseFee: 100, + FeeRate: 0, + }, + )) + constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12]( + record.PaymentConstraints{ + MaxCltvExpiry: 1000, + HtlcMinimumMsat: lnwire.MilliSatoshi(1), + }, + )) + + // Set next_node_id to our own public key. This signals a dummy hop. + nodeIDRecord := tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub), + ) + + // We'll generate a valid, cryptographically blinded final hop's payload + // using sphinx.BuildBlindedPath. This contains the PathID. + secret := make([]byte, 32) + secret[0] = 2 + finalHopData := &record.BlindedRouteData{ + PathID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](secret), + ), + } + finalHopDataBytes, err := record.EncodeBlindedRouteData(finalHopData) + require.NoError(t, err) + + hopInfo := &sphinx.HopInfo{ + NodePub: nodePub, + PlainText: finalHopDataBytes, + } + + blindingSessionKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + blindedPathInfo, err := sphinx.BuildBlindedPath( + blindingSessionKey, []*sphinx.HopInfo{hopInfo}, + ) + require.NoError(t, err) + + // Since we are peeling a dummy hop locally, we want the next blinding + // override to be the blinding point generated for our blinded final + // hop. + dummyHopData := &record.BlindedRouteData{ + NextNodeID: nodeIDRecord, + RelayInfo: relayInfo, + Constraints: constraints, + NextBlindingOverride: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType8]( + blindedPathInfo.Path.BlindingPoint, + ), + ), + } + + data, err := record.EncodeBlindedRouteData(dummyHopData) + require.NoError(t, err) + + // Encode a valid TLV payload for the next hop (which we will peel). + var hop2Buffer bytes.Buffer + amt := uint64(10000) + cltv := uint32(500) + encryptedDataRecord := record.NewEncryptedDataRecord( + &blindedPathInfo.Path.BlindedHops[0].CipherText, + ) + tlvRecords := []tlv.Record{ + record.NewAmtToFwdRecord(&amt), + record.NewLockTimeRecord(&cltv), + encryptedDataRecord, + } + tlvStream, err := tlv.NewStream(tlvRecords...) + require.NoError(t, err) + err = tlvStream.Encode(&hop2Buffer) + require.NoError(t, err) + + hopPayload, err := sphinx.NewTLVHopPayload(hop2Buffer.Bytes()) + require.NoError(t, err) + + // Create a valid 1-hop onion path using our blinded public key. + var paymentPath sphinx.PaymentPath + paymentPath[0] = sphinx.OnionHop{ + NodePub: *blindedPathInfo.Path.BlindedHops[0].BlindedNodePub, + HopPayload: hopPayload, + } + + sessionKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + rHash := [32]byte{1} + + // Generate a cryptographically valid onion packet for this path. + onionPacket, err := sphinx.NewOnionPacket( + &paymentPath, sessionKey, rHash[:], + sphinx.DeterministicPacketFiller, + ) + require.NoError(t, err) + + // Simulate an incoming HTLC with a blinding point and a valid onion + // packet. The blinding point is used to decrypt the dummy hop's + // payload, which contains the blinding point for the next hop (the + // blinded final hop). + kit := BlindingKit{ + Processor: &mockProcessor{}, + IncomingAmount: 12000, + IncomingCltv: 510, + UpdateAddBlinding: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( + nodePub, + ), + ), + } + + iterator := &sphinxHopIterator{ + blindingKit: kit, + rHash: rHash[:], + router: sphinx.NewRouter( + &sphinx.PrivKeyECDH{PrivKey: nodeKey}, + sphinx.NewMemoryReplayLog(), + ), + // Set our valid onion packet to be peeled. + processedPacket: &sphinx.ProcessedPacket{ + NextPacket: onionPacket, + }, + } + + // When we parse and validate the recipient data, it should enter the + // dummy-hop peeling path. Since our onion packet is valid and matches + // our private key, it should be successfully peeled and parsed. + pld, _, err := parseAndValidateRecipientData( + iterator, &Payload{encryptedData: data}, + false, RouteRoleCleartext, + ) + + // Assert that we successfully peeled the dummy hop and extracted the + // decrypted final payload. + require.NoError(t, err) + require.NotNil(t, pld) + + fwdInfo := pld.ForwardingInfo() + require.Equal(t, lnwire.MilliSatoshi(0), fwdInfo.AmountToForward) + require.Equal(t, uint32(0), fwdInfo.OutgoingCLTV) + require.NotNil(t, fwdInfo.PathID) + require.Equal(t, secret, fwdInfo.PathID[:]) +} diff --git a/payments/db/migration1/record/blinded_data.go b/payments/db/migration1/record/blinded_data.go index 22c09672f..52f0e6556 100644 --- a/payments/db/migration1/record/blinded_data.go +++ b/payments/db/migration1/record/blinded_data.go @@ -31,7 +31,9 @@ type BlindedRouteData struct { // NextNodeID is the node ID of the next node on the path. In the // context of blinded path payments, this is used to indicate the - // presence of dummy hops that need to be peeled from the onion. + // presence of dummy hops that need to be peeled from the onion, or to + // identify a real next-node forwarding target when the public key is + // not ours. NextNodeID tlv.OptionalRecordT[tlv.TlvType4, *btcec.PublicKey] // PathID is a secret set of bytes that the blinded path creator will diff --git a/record/blinded_data.go b/record/blinded_data.go index 8bcc0dde0..59929577d 100644 --- a/record/blinded_data.go +++ b/record/blinded_data.go @@ -32,7 +32,9 @@ type BlindedRouteData struct { // NextNodeID is the node ID of the next node on the path. In the // context of blinded path payments, this is used to indicate the - // presence of dummy hops that need to be peeled from the onion. + // presence of dummy hops that need to be peeled from the onion, or to + // identify a real next-node forwarding target when the public key is + // not ours. NextNodeID tlv.OptionalRecordT[tlv.TlvType4, *btcec.PublicKey] // PathID is a secret set of bytes that the blinded path creator will