routerrpc: require encrypted blinded hop data
What changed, and why it matters
This commit tightens validation in LND's router RPC so that callers cannot supply a 'blinded total amount' for a payment hop unless they also provide the encrypted recipient data that marks the hop as blinded. It also restricts that total amount field to the final hop only. Previously, a route could be accepted with a blinded total amount on a regular hop, which could let inconsistent or partial blinded-payment data enter LND's payment database. The change is defensive and prevents a likely logic/state bug rather than a direct theft-of-funds vulnerability.
Treat as a security-hardening fix and include in release notes. Users running nodes that expose SendToRouteV2 to untrusted callers should upgrade. Review whether any other blinded-payment fields are accepted without their required companion fields.
Security signals we found
Input validation gap in caller-provided route deserialization
Blinded payment fields partially validated (blinding point required encrypted data, but total amount did not)
State/database consistency risk: partial blinded hop data could be persisted
BOLT 4 compliance fix: TotalAmtMsat restricted to final hop payload
Defensive hardening with unit-test coverage for rejected and accepted cases
Evidence from the diff
SendToRouteV2 lets callers pass fully constructed routes. The UnmarshallHopWithPubkey function already required EncryptedData when a BlindingPoint was present, but TotalAmtMsat (a blinded-payment field) was copied independently. This meant a hop could set TotalAmtMsat without EncryptedData, so LND did not classify it as blinded but still stored the blinded total. The patch adds two validation rules: (1) if TotalAmtMsat is non-zero, EncryptedData must be present; (2) in UnmarshallRoute, TotalAmtMsat is only permitted on the last hop, matching BOLT 4’s requirement that only the final payload carries the set-level total. Tests cover the rejected partial-blinded case and the allowed final-hop case.
Changed components
lnrpc/routerrpc/router_backend.golnrpc/routerrpc/router_backend_test.goSendToRouteV2 RPCroute deserialization / UnmarshallRoute and UnmarshallHopWithPubkeyInspect captured patch +122 / −0
diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go
index 5b2389e..ce350ff 100644
--- a/lnrpc/routerrpc/router_backend.go
+++ b/lnrpc/routerrpc/router_backend.go
@@ -783,6 +783,13 @@ func UnmarshallHopWithPubkey(rpcHop *lnrpc.Hop, pubkey route.Vertex) (*route.Hop
"blinding point is provided")
}
+ // TotalAmtMsat is only defined for blinded payments, so it requires
+ // the encrypted recipient data that identifies this as a blinded hop.
+ if rpcHop.TotalAmtMsat != 0 && len(rpcHop.EncryptedData) == 0 {
+ return nil, errors.New("encrypted data should be present if " +
+ "blinded total amount is provided")
+ }
+
return hop, nil
}
@@ -832,6 +839,17 @@ func (r *RouterBackend) UnmarshallRoute(rpcroute *lnrpc.Route) (
hops := make([]*route.Hop, len(rpcroute.Hops))
for i, hop := range rpcroute.Hops {
+ // TotalAmtMsat is the sender-declared target for the blinded
+ // HTLC set. The final node checks that every part declares the
+ // same total and withholds fulfillment until the received parts
+ // reach that amount. Since only the final node performs
+ // this set-level check, BOLT 4 only permits the field in its
+ // payload.
+ if hop.TotalAmtMsat != 0 && i != len(rpcroute.Hops)-1 {
+ return nil, errors.New("blinded total amount can " +
+ "only be provided for the final hop")
+ }
+
routeHop, err := r.UnmarshallHop(hop, prevNodePubKey)
if err != nil {
return nil, err
diff --git a/lnrpc/routerrpc/router_backend_test.go b/lnrpc/routerrpc/router_backend_test.go
index 2ee6978..de3a662 100644
--- a/lnrpc/routerrpc/router_backend_test.go
+++ b/lnrpc/routerrpc/router_backend_test.go
@@ -33,6 +33,110 @@ var (
node2 = route.Vertex{11}
)
+// TestUnmarshallHopBlindedFieldsRequireEncryptedData verifies that callers
+// cannot submit partial blinded-hop data through SendToRouteV2.
+func TestUnmarshallHopBlindedFieldsRequireEncryptedData(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ hop *lnrpc.Hop
+ errText string
+ }{
+ {
+ name: "total amount without encrypted data",
+ hop: &lnrpc.Hop{
+ TotalAmtMsat: 1000,
+ },
+ errText: "encrypted data should be present",
+ },
+ {
+ name: "total amount with encrypted data",
+ hop: &lnrpc.Hop{
+ TotalAmtMsat: 1000,
+ EncryptedData: []byte{1},
+ },
+ },
+ {
+ name: "ordinary hop",
+ hop: &lnrpc.Hop{},
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := UnmarshallHopWithPubkey(test.hop, node1)
+ if test.errText != "" {
+ require.ErrorContains(t, err, test.errText)
+ return
+ }
+
+ require.NoError(t, err)
+ })
+ }
+}
+
+// TestUnmarshallRouteBlindedTotalAmountFinalHop verifies that the blinded
+// total amount is only accepted on the final hop of a caller-provided route.
+func TestUnmarshallRouteBlindedTotalAmountFinalHop(t *testing.T) {
+ t.Parallel()
+
+ blindedHop := func() *lnrpc.Hop {
+ return &lnrpc.Hop{
+ PubKey: destKey,
+ EncryptedData: []byte{1},
+ TotalAmtMsat: 1000,
+ }
+ }
+ regularHop := func() *lnrpc.Hop {
+ return &lnrpc.Hop{
+ PubKey: destKey,
+ }
+ }
+
+ tests := []struct {
+ name string
+ hops []*lnrpc.Hop
+ errText string
+ }{
+ {
+ name: "intermediate hop",
+ hops: []*lnrpc.Hop{
+ blindedHop(), regularHop(),
+ },
+ errText: "blinded total amount can only be provided " +
+ "for the final hop",
+ },
+ {
+ name: "final hop",
+ hops: []*lnrpc.Hop{
+ regularHop(), blindedHop(),
+ },
+ },
+ }
+
+ backend := &RouterBackend{
+ SelfNode: sourceKey,
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := backend.UnmarshallRoute(&lnrpc.Route{
+ Hops: test.hops,
+ })
+ if test.errText != "" {
+ require.ErrorContains(t, err, test.errText)
+ return
+ }
+
+ require.NoError(t, err)
+ })
+ }
+}
+
// TestQueryRoutes asserts that query routes rpc parameters are properly parsed
// and passed onto path finding.
func TestQueryRoutes(t *testing.T) {
Why this scored 59/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.