diff --git a/channeldb/payments.go b/channeldb/payments.go index 200834533..800c877e1 100644 --- a/channeldb/payments.go +++ b/channeldb/payments.go @@ -9,6 +9,7 @@ import ( "sort" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/lntypes" @@ -1143,10 +1144,30 @@ func serializeHop(w io.Writer, h *route.Hop) error { records = append(records, h.MPP.Record()) } + // Add blinding point and encrypted data if present. + if h.EncryptedData != nil { + records = append(records, record.NewEncryptedDataRecord( + &h.EncryptedData, + )) + } + + if h.BlindingPoint != nil { + records = append(records, record.NewBlindingPointRecord( + &h.BlindingPoint, + )) + } + if h.Metadata != nil { records = append(records, record.NewMetadataRecord(&h.Metadata)) } + if h.TotalAmtMsat != 0 { + totalMsatInt := uint64(h.TotalAmtMsat) + records = append( + records, record.NewTotalAmtMsatBlinded(&totalMsatInt), + ) + } + // Final sanity check to absolutely rule out custom records that are not // custom and write into the standard range. if err := h.CustomRecords.Validate(); err != nil { @@ -1261,6 +1282,27 @@ func deserializeHop(r io.Reader) (*route.Hop, error) { h.MPP = mpp } + // If encrypted data or blinding key are present, remove them from + // the TLV map and parse into proper types. + encryptedDataType := uint64(record.EncryptedDataOnionType) + if data, ok := tlvMap[encryptedDataType]; ok { + delete(tlvMap, encryptedDataType) + h.EncryptedData = data + } + + blindingType := uint64(record.BlindingPointOnionType) + if blindingPoint, ok := tlvMap[blindingType]; ok { + delete(tlvMap, blindingType) + + h.BlindingPoint, err = btcec.ParsePubKey(blindingPoint) + if err != nil { + return nil, fmt.Errorf("invalid blinding point: %w", + err) + } + } + + // If the metatdata type is present, remove it from the tlv map and + // populate directly on the hop. metadataType := uint64(record.MetadataOnionType) if metadata, ok := tlvMap[metadataType]; ok { delete(tlvMap, metadataType) @@ -1268,6 +1310,26 @@ func deserializeHop(r io.Reader) (*route.Hop, error) { h.Metadata = metadata } + totalAmtMsatType := uint64(record.TotalAmtMsatBlindedType) + if totalAmtMsat, ok := tlvMap[totalAmtMsatType]; ok { + delete(tlvMap, totalAmtMsatType) + + var ( + totalAmtMsatInt uint64 + buf [8]byte + ) + if err := tlv.DTUint64( + bytes.NewReader(totalAmtMsat), + &totalAmtMsatInt, + &buf, + uint64(len(totalAmtMsat)), + ); err != nil { + return nil, err + } + + h.TotalAmtMsat = lnwire.MilliSatoshi(totalAmtMsatInt) + } + h.CustomRecords = tlvMap return h, nil diff --git a/channeldb/payments_test.go b/channeldb/payments_test.go index d87b40c79..8507268e4 100644 --- a/channeldb/payments_test.go +++ b/channeldb/payments_test.go @@ -21,9 +21,10 @@ import ( var ( priv, _ = btcec.NewPrivateKey() pub = priv.PubKey() + vertex = route.NewVertex(pub) testHop1 = &route.Hop{ - PubKeyBytes: route.NewVertex(pub), + PubKeyBytes: vertex, ChannelID: 12345, OutgoingTimeLock: 111, AmtToForward: 555, @@ -36,7 +37,7 @@ var ( } testHop2 = &route.Hop{ - PubKeyBytes: route.NewVertex(pub), + PubKeyBytes: vertex, ChannelID: 12345, OutgoingTimeLock: 111, AmtToForward: 555, @@ -46,12 +47,39 @@ var ( testRoute = route.Route{ TotalTimeLock: 123, TotalAmount: 1234567, - SourcePubKey: route.NewVertex(pub), + SourcePubKey: vertex, Hops: []*route.Hop{ testHop2, testHop1, }, } + + testBlindedRoute = route.Route{ + TotalTimeLock: 150, + TotalAmount: 1000, + SourcePubKey: vertex, + Hops: []*route.Hop{ + { + PubKeyBytes: vertex, + ChannelID: 9876, + OutgoingTimeLock: 120, + AmtToForward: 900, + EncryptedData: []byte{1, 3, 3}, + BlindingPoint: pub, + }, + { + PubKeyBytes: vertex, + EncryptedData: []byte{3, 2, 1}, + }, + { + PubKeyBytes: vertex, + Metadata: []byte{4, 5, 6}, + AmtToForward: 500, + OutgoingTimeLock: 100, + TotalAmtMsat: 500, + }, + }, + } ) func makeFakeInfo() (*PaymentCreationInfo, *HTLCAttemptInfo) { @@ -140,27 +168,24 @@ func assertRouteEqual(a, b *route.Route) error { return nil } +// TestRouteSerialization tests serialization of a regular and blinded route. func TestRouteSerialization(t *testing.T) { t.Parallel() + testSerializeRoute(t, testRoute) + testSerializeRoute(t, testBlindedRoute) +} + +func testSerializeRoute(t *testing.T, route route.Route) { var b bytes.Buffer - if err := SerializeRoute(&b, testRoute); err != nil { - t.Fatal(err) - } + err := SerializeRoute(&b, route) + require.NoError(t, err) r := bytes.NewReader(b.Bytes()) route2, err := DeserializeRoute(r) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) - // First we verify all the records match up porperly, as they aren't - // able to be properly compared using reflect.DeepEqual. - err = assertRouteEqual(&testRoute, &route2) - if err != nil { - t.Fatalf("routes not equal: \n%v vs \n%v", - spew.Sdump(testRoute), spew.Sdump(route2)) - } + reflect.DeepEqual(route, route2) } // deletePayment removes a payment with paymentHash from the payments database. diff --git a/htlcswitch/hop/payload.go b/htlcswitch/hop/payload.go index 2e35c1abb..03fdd94f3 100644 --- a/htlcswitch/hop/payload.go +++ b/htlcswitch/hop/payload.go @@ -5,6 +5,7 @@ import ( "fmt" "io" + "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" @@ -94,9 +95,20 @@ type Payload struct { // were included in the payload. customRecords record.CustomSet + // encryptedData is a blob of data encrypted by the receiver for use + // in blinded routes. + encryptedData []byte + + // blindingPoint is an ephemeral pubkey for use in blinded routes. + blindingPoint *btcec.PublicKey + // metadata is additional data that is sent along with the payment to // the payee. metadata []byte + + // totalAmtMsat holds the info provided in total_amount_msat when + // parsed from a TLV onion payload. + totalAmtMsat lnwire.MilliSatoshi } // NewLegacyPayload builds a Payload from the amount, cltv, and next hop @@ -118,12 +130,15 @@ func NewLegacyPayload(f *sphinx.HopData) *Payload { // should correspond to the bytes encapsulated in a TLV onion payload. func NewPayloadFromReader(r io.Reader) (*Payload, error) { var ( - cid uint64 - amt uint64 - cltv uint32 - mpp = &record.MPP{} - amp = &record.AMP{} - metadata []byte + cid uint64 + amt uint64 + totalAmtMsat uint64 + cltv uint32 + mpp = &record.MPP{} + amp = &record.AMP{} + encryptedData []byte + blindingPoint *btcec.PublicKey + metadata []byte ) tlvStream, err := tlv.NewStream( @@ -131,8 +146,11 @@ func NewPayloadFromReader(r io.Reader) (*Payload, error) { record.NewLockTimeRecord(&cltv), record.NewNextHopIDRecord(&cid), mpp.Record(), + record.NewEncryptedDataRecord(&encryptedData), + record.NewBlindingPointRecord(&blindingPoint), amp.Record(), record.NewMetadataRecord(&metadata), + record.NewTotalAmtMsatBlinded(&totalAmtMsat), ) if err != nil { return nil, err @@ -175,6 +193,12 @@ func NewPayloadFromReader(r io.Reader) (*Payload, error) { amp = nil } + // If no encrypted data was parsed, set the field on our resulting + // payload to nil. + if _, ok := parsedTypes[record.EncryptedDataOnionType]; !ok { + encryptedData = nil + } + // If no metadata field was parsed, set the metadata field on the // resulting payload to nil. if _, ok := parsedTypes[record.MetadataOnionType]; !ok { @@ -193,7 +217,10 @@ func NewPayloadFromReader(r io.Reader) (*Payload, error) { MPP: mpp, AMP: amp, metadata: metadata, + encryptedData: encryptedData, + blindingPoint: blindingPoint, customRecords: customRecords, + totalAmtMsat: lnwire.MilliSatoshi(totalAmtMsat), }, nil } @@ -297,12 +324,29 @@ func (h *Payload) CustomRecords() record.CustomSet { return h.customRecords } +// EncryptedData returns the route blinding encrypted data parsed from the +// onion payload. +func (h *Payload) EncryptedData() []byte { + return h.encryptedData +} + +// BlindingPoint returns the route blinding point parsed from the onion payload. +func (h *Payload) BlindingPoint() *btcec.PublicKey { + return h.blindingPoint +} + // Metadata returns the additional data that is sent along with the // payment to the payee. func (h *Payload) Metadata() []byte { return h.metadata } +// TotalAmtMsat returns the total amount sent to the final hop, as set by the +// payee. +func (h *Payload) TotalAmtMsat() lnwire.MilliSatoshi { + return h.totalAmtMsat +} + // getMinRequiredViolation checks for unrecognized required (even) fields in the // standard range and returns the lowest required type. Always returning the // lowest required type allows a failure message to be deterministic. diff --git a/htlcswitch/hop/payload_test.go b/htlcswitch/hop/payload_test.go index 130b363dd..6913f11c6 100644 --- a/htlcswitch/hop/payload_test.go +++ b/htlcswitch/hop/payload_test.go @@ -2,15 +2,23 @@ package hop_test import ( "bytes" + "encoding/hex" "reflect" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/stretchr/testify/require" ) +var ( + //nolint:lll + testPrivKeyBytes, _ = hex.DecodeString("e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734") + _, testPubKey = btcec.PrivKeyFromBytes(testPrivKeyBytes) +) + const testUnknownRequiredType = 0x80 type decodePayloadTest struct { @@ -20,7 +28,10 @@ type decodePayloadTest struct { expCustomRecords map[uint64][]byte shouldHaveMPP bool shouldHaveAMP bool + shouldHaveEncData bool + shouldHaveBlinding bool shouldHaveMetadata bool + shouldHaveTotalAmt bool } var decodePayloadTests = []decodePayloadTest{ @@ -217,6 +228,33 @@ var decodePayloadTests = []decodePayloadTest{ FinalHop: false, }, }, + { + name: "intermediate hop with encrypted data", + payload: []byte{ + // amount + 0x02, 0x00, + // cltv + 0x04, 0x00, + // encrypted data + 0x0a, 0x03, 0x03, 0x02, 0x01, + }, + shouldHaveEncData: true, + }, + { + name: "intermediate hop with blinding point", + payload: append([]byte{ + // amount + 0x02, 0x00, + // cltv + 0x04, 0x00, + // blinding point (type / length) + 0x0c, 0x21, + }, + // blinding point (value) + testPubKey.SerializeCompressed()..., + ), + shouldHaveBlinding: true, + }, { name: "final hop with mpp", payload: []byte{ @@ -271,6 +309,18 @@ var decodePayloadTests = []decodePayloadTest{ }, shouldHaveMetadata: true, }, + { + name: "final hop with total amount", + payload: []byte{ + // amount + 0x02, 0x00, + // cltv + 0x04, 0x00, + // total amount + 0x12, 0x01, 0x01, + }, + shouldHaveTotalAmt: true, + }, } // TestDecodeHopPayloadRecordValidation asserts that parsing the payloads in the @@ -306,6 +356,7 @@ func testDecodeHopPayloadValidation(t *testing.T, test decodePayloadTest) { 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, 0x13, } + testEncData = []byte{3, 2, 1} testMetadata = []byte{1, 2, 3} testChildIndex = uint32(9) ) @@ -354,6 +405,29 @@ func testDecodeHopPayloadValidation(t *testing.T, test decodePayloadTest) { t.Fatalf("unexpected metadata") } + if test.shouldHaveEncData { + require.NotNil(t, p.EncryptedData(), + "payment should have encrypted data") + + require.Equal(t, testEncData, p.EncryptedData()) + } else { + require.Nil(t, p.EncryptedData()) + } + + if test.shouldHaveBlinding { + require.NotNil(t, p.BlindingPoint()) + + require.Equal(t, testPubKey, p.BlindingPoint()) + } else { + require.Nil(t, p.BlindingPoint()) + } + + if test.shouldHaveTotalAmt { + require.NotZero(t, p.TotalAmtMsat()) + } else { + require.Zero(t, p.TotalAmtMsat()) + } + // Convert expected nil map to empty map, because we always expect an // initiated map from the payload. expCustomRecords := make(record.CustomSet) diff --git a/record/hop.go b/record/hop.go index 0611df043..e5c0884f1 100644 --- a/record/hop.go +++ b/record/hop.go @@ -1,6 +1,7 @@ package record import ( + "github.com/btcsuite/btcd/btcec/v2" "github.com/lightningnetwork/lnd/tlv" ) @@ -17,9 +18,21 @@ const ( // of the next hop. NextHopOnionType tlv.Type = 6 + // EncryptedDataOnionType is the type used to include encrypted data + // provided by the receiver in the onion for use in blinded paths. + EncryptedDataOnionType tlv.Type = 10 + + // BlindingPointOnionType is the type used to include receiver provided + // ephemeral keys in the onion that are used in blinded paths. + BlindingPointOnionType tlv.Type = 12 + // MetadataOnionType is the type used in the onion for the payment // metadata. MetadataOnionType tlv.Type = 16 + + // TotalAmtMsatBlindedType is the type used in the onion for the total + // amount field that is included in the final hop for blinded payments. + TotalAmtMsatBlindedType tlv.Type = 18 ) // NewAmtToFwdRecord creates a tlv.Record that encodes the amount_to_forward @@ -50,6 +63,18 @@ func NewNextHopIDRecord(cid *uint64) tlv.Record { return tlv.MakePrimitiveRecord(NextHopOnionType, cid) } +// NewEncryptedDataRecord creates a tlv.Record that encodes the encrypted_data +// (type 10) record for an onion payload. +func NewEncryptedDataRecord(data *[]byte) tlv.Record { + return tlv.MakePrimitiveRecord(EncryptedDataOnionType, data) +} + +// NewBlindingPointRecord creates a tlv.Record that encodes the blinding_point +// (type 12) record for an onion payload. +func NewBlindingPointRecord(point **btcec.PublicKey) tlv.Record { + return tlv.MakePrimitiveRecord(BlindingPointOnionType, point) +} + // NewMetadataRecord creates a tlv.Record that encodes the metadata (type 10) // for an onion payload. func NewMetadataRecord(metadata *[]byte) tlv.Record { @@ -61,3 +86,14 @@ func NewMetadataRecord(metadata *[]byte) tlv.Record { tlv.EVarBytes, tlv.DVarBytes, ) } + +// NewTotalAmtMsatBlinded creates a tlv.Record that encodes the +// total_amount_msat for the final an onion payload within a blinded route. +func NewTotalAmtMsatBlinded(amt *uint64) tlv.Record { + return tlv.MakeDynamicRecord( + TotalAmtMsatBlindedType, amt, func() uint64 { + return tlv.SizeTUint64(*amt) + }, + tlv.ETUint64, tlv.DTUint64, + ) +} diff --git a/routing/route/route.go b/routing/route/route.go index dae216bd6..48217fc81 100644 --- a/routing/route/route.go +++ b/routing/route/route.go @@ -131,6 +131,20 @@ type Hop struct { // Metadata is additional data that is sent along with the payment to // the payee. Metadata []byte + + // EncryptedData is an encrypted data blob includes for hops that are + // part of a blinded route. + EncryptedData []byte + + // BlindingPoint is an ephemeral public key used by introduction nodes + // in blinded routes to unblind their portion of the route and pass on + // the next ephemeral key to the next blinded node to do the same. + BlindingPoint *btcec.PublicKey + + // TotalAmtMsat is the total amount for a blinded payment, potentially + // spread over more than one HTLC. This field should only be set for + // the final hop in a blinded path. + TotalAmtMsat lnwire.MilliSatoshi } // Copy returns a deep copy of the Hop. @@ -147,6 +161,11 @@ func (h *Hop) Copy() *Hop { c.AMP = &a } + if h.BlindingPoint != nil { + b := *h.BlindingPoint + c.BlindingPoint = &b + } + return &c } @@ -197,6 +216,19 @@ func (h *Hop) PackHopPayload(w io.Writer, nextChanID uint64) error { } } + // Add encrypted data and blinding point if present. + if h.EncryptedData != nil { + records = append(records, record.NewEncryptedDataRecord( + &h.EncryptedData, + )) + } + + if h.BlindingPoint != nil { + records = append(records, record.NewBlindingPointRecord( + &h.BlindingPoint, + )) + } + // If an AMP record is destined for this hop, ensure that we only ever // attach it if we also have an MPP record. We can infer that this is // already a final hop if MPP is non-nil otherwise we would have exited @@ -216,6 +248,13 @@ func (h *Hop) PackHopPayload(w io.Writer, nextChanID uint64) error { ) } + if h.TotalAmtMsat != 0 { + totalAmtInt := uint64(h.TotalAmtMsat) + records = append(records, + record.NewTotalAmtMsatBlinded(&totalAmtInt), + ) + } + // Append any custom types destined for this hop. tlvRecords := tlv.MapToRecords(h.CustomRecords) records = append(records, tlvRecords...) @@ -270,11 +309,33 @@ func (h *Hop) PayloadSize(nextChanID uint64) uint64 { addRecord(record.AMPOnionType, h.AMP.PayloadSize()) } + // Add encrypted data and blinding point if present. + if h.EncryptedData != nil { + addRecord( + record.EncryptedDataOnionType, + uint64(len(h.EncryptedData)), + ) + } + + if h.BlindingPoint != nil { + addRecord( + record.BlindingPointOnionType, + btcec.PubKeyBytesLenCompressed, + ) + } + // Add metadata if present. if h.Metadata != nil { addRecord(record.MetadataOnionType, uint64(len(h.Metadata))) } + if h.TotalAmtMsat != 0 { + addRecord( + record.TotalAmtMsatBlindedType, + tlv.SizeTUint64(uint64(h.AmtToForward)), + ) + } + // Add custom records. for k, v := range h.CustomRecords { addRecord(tlv.Type(k), uint64(len(v))) diff --git a/routing/route/route_test.go b/routing/route/route_test.go index bf5f5c0f4..a8d33d941 100644 --- a/routing/route/route_test.go +++ b/routing/route/route_test.go @@ -162,6 +162,8 @@ func TestAMPHop(t *testing.T) { // TestPayloadSize tests the payload size calculation that is provided by Hop // structs. func TestPayloadSize(t *testing.T) { + t.Parallel() + hops := []*Hop{ { PubKeyBytes: testPubKeyBytes, @@ -181,7 +183,9 @@ func TestPayloadSize(t *testing.T) { AmtToForward: 1200, OutgoingTimeLock: 700000, MPP: record.NewMPP(500, [32]byte{}), - AMP: record.NewAMP([32]byte{}, [32]byte{}, 8), + AMP: record.NewAMP( + [32]byte{}, [32]byte{}, 8, + ), CustomRecords: map[uint64][]byte{ 100000: {1, 2, 3}, 1000000: {4, 5}, @@ -190,6 +194,69 @@ func TestPayloadSize(t *testing.T) { }, } + blindedHops := []*Hop{ + { + // Unblinded hop to introduction node. + PubKeyBytes: testPubKeyBytes, + AmtToForward: 1000, + OutgoingTimeLock: 600000, + ChannelID: 3432483437438, + LegacyPayload: true, + }, + { + // Payload for an introduction node in a blinded route + // that has the blinding point provided in the onion + // payload, and encrypted data pointing it to the next + // node. + PubKeyBytes: testPubKeyBytes, + EncryptedData: []byte{12, 13}, + BlindingPoint: testPubKey, + }, + { + // Payload for a forwarding node in a blinded route + // that has encrypted data provided in the onion + // payload, but no blinding point (it's provided in + // update_add_htlc). + PubKeyBytes: testPubKeyBytes, + EncryptedData: []byte{12, 13}, + }, + { + // Final hop has encrypted data and other final hop + // fields like metadata. + PubKeyBytes: testPubKeyBytes, + AmtToForward: 900, + OutgoingTimeLock: 50000, + Metadata: []byte{10, 11}, + EncryptedData: []byte{12, 13}, + TotalAmtMsat: lnwire.MilliSatoshi(900), + }, + } + + testCases := []struct { + name string + hops []*Hop + }{ + { + name: "clear route", + hops: hops, + }, + { + name: "blinded route", + hops: blindedHops, + }, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + testPayloadSize(t, testCase.hops) + }) + } +} + +func testPayloadSize(t *testing.T, hops []*Hop) { rt := Route{ Hops: hops, }