From d8979d3086d6dec1528723ccd1d23dedf5906b5b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Wed, 1 Feb 2023 11:21:07 -0500 Subject: [PATCH] multi: add validation of blinded route encrypted data Co-authored-by: Calvin Zachman --- htlcswitch/hop/payload.go | 74 ++++++++++++++++++ htlcswitch/hop/payload_test.go | 138 +++++++++++++++++++++++++++++++++ lnwire/features.go | 12 +++ 3 files changed, 224 insertions(+) diff --git a/htlcswitch/hop/payload.go b/htlcswitch/hop/payload.go index a49a264fe..cbd8d08a5 100644 --- a/htlcswitch/hop/payload.go +++ b/htlcswitch/hop/payload.go @@ -28,6 +28,10 @@ const ( // RequiredViolation indicates that an unknown even type was found in // the payload that we could not process. RequiredViolation + + // InsufficientViolation indicates that the provided type does + // not satisfy constraints. + InsufficientViolation ) // String returns a human-readable description of the violation as a verb. @@ -42,6 +46,9 @@ func (v PayloadViolation) String() string { case RequiredViolation: return "required" + case InsufficientViolation: + return "insufficient" + default: return "unknown violation" } @@ -410,3 +417,70 @@ func getMinRequiredViolation(set tlv.TypeMap) *tlv.Type { return nil } + +// ValidateBlindedRouteData performs the additional validation that is +// required for payments that rely on data provided in an encrypted blob to +// be forwarded. We enforce the blinded route's maximum expiry height so that +// the route "expires" and a malicious party does not have endless opportunity +// to probe the blinded route and compare it to updated channel policies in +// the network. +// +// Note that this function only validates blinded route data for forwarding +// nodes, as LND does not yet support receiving via a blinded route (which has +// different validation rules). +func ValidateBlindedRouteData(blindedData *record.BlindedRouteData, + incomingAmount lnwire.MilliSatoshi, incomingTimelock uint32) error { + + // Bolt 04 notes that we should enforce payment constraints _if_ they + // are present, so we do not fail if not provided. + var err error + blindedData.Constraints.WhenSome( + func(c tlv.RecordT[tlv.TlvType12, record.PaymentConstraints]) { + // MUST fail if the expiry is greater than + // max_cltv_expiry. + if incomingTimelock > c.Val.MaxCltvExpiry { + err = ErrInvalidPayload{ + Type: record.LockTimeOnionType, + Violation: InsufficientViolation, + } + } + + // MUST fail if the amount is below htlc_minimum_msat. + if incomingAmount < c.Val.HtlcMinimumMsat { + err = ErrInvalidPayload{ + Type: record.AmtOnionType, + Violation: InsufficientViolation, + } + } + }, + ) + if err != nil { + return err + } + + // Fail if we don't understand any features (even or odd), because we + // expect the features to have been set from our announcement. If the + // feature vector TLV is not included, it's interpreted as an empty + // vector (no validation required). + // expect the features to have been set from our announcement. + // + // Note that we do not yet check the features that the blinded payment + // is using against our own features, because there are currently no + // payment-related features that they utilize other than tlv-onion, + // which is implicitly supported. + blindedData.Features.WhenSome( + func(f tlv.RecordT[tlv.TlvType14, lnwire.FeatureVector]) { + if f.Val.UnknownFeatures() { + err = ErrInvalidPayload{ + Type: 14, + Violation: IncludedViolation, + } + } + }, + ) + if err != nil { + return err + } + + return nil +} diff --git a/htlcswitch/hop/payload_test.go b/htlcswitch/hop/payload_test.go index 63c9ceeef..148b806f9 100644 --- a/htlcswitch/hop/payload_test.go +++ b/htlcswitch/hop/payload_test.go @@ -557,3 +557,141 @@ func testDecodeHopPayloadValidation(t *testing.T, test decodePayloadTest) { t.Fatalf("invalid custom records") } } + +// TestValidateBlindedRouteData tests validation of the values provided in a +// blinded route. +func TestValidateBlindedRouteData(t *testing.T) { + scid := lnwire.NewShortChanIDFromInt(1) + + tests := []struct { + name string + data *record.BlindedRouteData + incomingAmount lnwire.MilliSatoshi + incomingTimelock uint32 + err error + }{ + { + name: "max cltv expired", + data: record.NewBlindedRouteData( + scid, + nil, + record.PaymentRelayInfo{}, + &record.PaymentConstraints{ + MaxCltvExpiry: 100, + }, + nil, + ), + incomingTimelock: 200, + err: hop.ErrInvalidPayload{ + Type: record.LockTimeOnionType, + Violation: hop.InsufficientViolation, + }, + }, + { + name: "zero max cltv", + data: record.NewBlindedRouteData( + scid, + nil, + record.PaymentRelayInfo{}, + &record.PaymentConstraints{ + MaxCltvExpiry: 0, + HtlcMinimumMsat: 10, + }, + nil, + ), + incomingAmount: 100, + incomingTimelock: 10, + err: hop.ErrInvalidPayload{ + Type: record.LockTimeOnionType, + Violation: hop.InsufficientViolation, + }, + }, + { + name: "amount below minimum", + data: record.NewBlindedRouteData( + scid, + nil, + record.PaymentRelayInfo{}, + &record.PaymentConstraints{ + HtlcMinimumMsat: 15, + }, + nil, + ), + incomingAmount: 10, + err: hop.ErrInvalidPayload{ + Type: record.AmtOnionType, + Violation: hop.InsufficientViolation, + }, + }, + { + name: "valid, no features", + data: record.NewBlindedRouteData( + scid, + nil, + record.PaymentRelayInfo{}, + &record.PaymentConstraints{ + MaxCltvExpiry: 100, + HtlcMinimumMsat: 20, + }, + nil, + ), + incomingAmount: 40, + incomingTimelock: 80, + }, + { + name: "unknown features", + data: record.NewBlindedRouteData( + scid, + nil, + record.PaymentRelayInfo{}, + &record.PaymentConstraints{ + MaxCltvExpiry: 100, + HtlcMinimumMsat: 20, + }, + lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector( + lnwire.FeatureBit(9999), + ), + lnwire.Features, + ), + ), + incomingAmount: 40, + incomingTimelock: 80, + err: hop.ErrInvalidPayload{ + Type: 14, + Violation: hop.IncludedViolation, + }, + }, + { + name: "valid data", + data: record.NewBlindedRouteData( + scid, + nil, + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + FeeRate: 10, + BaseFee: 100, + }, + &record.PaymentConstraints{ + MaxCltvExpiry: 100, + HtlcMinimumMsat: 20, + }, + nil, + ), + incomingAmount: 40, + incomingTimelock: 80, + }, + } + + for _, testCase := range tests { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + err := hop.ValidateBlindedRouteData( + testCase.data, testCase.incomingAmount, + testCase.incomingTimelock, + ) + require.Equal(t, testCase.err, err) + }) + } +} diff --git a/lnwire/features.go b/lnwire/features.go index 8d603731f..ab6facc75 100644 --- a/lnwire/features.go +++ b/lnwire/features.go @@ -759,6 +759,18 @@ func (fv *FeatureVector) UnknownRequiredFeatures() []FeatureBit { return unknown } +// UnknownFeatures returns a boolean if a feature vector contains *any* +// unknown features (even if they are odd). +func (fv *FeatureVector) UnknownFeatures() bool { + for feature := range fv.features { + if !fv.IsKnown(feature) { + return true + } + } + + return false +} + // Name returns a string identifier for the feature represented by this bit. If // the bit does not represent a known feature, this returns a string indicating // as such.