multi: migrate OnionMessagePayload to lnwire.BlindedPath
What changed, and why it matters
This commit fixes how LND reads the 'reply path' inside onion messages. Previously, the code only accepted reply paths whose first hop was a full 33-byte public key, and silently ignored a valid shorter format allowed by the Lightning spec (a 9-byte short-channel-id plus direction). The change switches to a shared lnwire.BlindedPath type that correctly handles both formats, so onion messages with the shorter reply-path form are no longer dropped or mishandled.
Review the new lnwire.BlindedPath.Record() implementation for correct TLV length-prefix handling of both PubkeyIntro and SciddirIntro variants, and ensure the RPC sciddirResolver TODO is tracked so RPC consumers do not receive ambiguous introduction-node bytes.
Security signals we found
Protocol compliance fix for BOLT4 sciddir_or_pubkey introduction-node encoding
Legacy decoder silently dropped valid reply paths due to 67-byte minimum-length gate
Migration from package-local encoding to shared lnwire.BlindedPath TLV record
New test coverage for sciddir introduction-node reply-path round trip
RPC bridge TODO indicates remaining sciddir resolution work
Evidence from the diff
The patch migrates OnionMessagePayload.ReplyPath from sphinx.BlindedPath to lnwire.BlindedPath. The legacy encode/decode helpers required a minimum length of 67 bytes and decoded the introduction node as a fixed 33-byte pubkey, which means any BOLT4-compliant reply path using the 9-byte sciddir_or_pubkey introduction-node variant was silently rejected. The new lnwire.BlindedPath.Record() encoder/decoder honors the sciddir_or_pubkey form. A new test subtest explicitly exercises a sciddir introduction-node round trip. The change is mostly mechanical across onionmessage, routing/route, and rpcserver, with a TODO noting that sciddir intros still need resolution in the RPC layer.
Changed components
lnwire/onion_msg_payload.golnwire/onion_msg_payload_test.golnwire/test_utils.goonionmessage/onion_endpoint.gorouting/route/blindedroute.gorpcserver.goInspect captured patch +116 / −265
diff --git a/lnwire/onion_msg_payload.go b/lnwire/onion_msg_payload.go
index 64c5aba..f91c650 100644
--- a/lnwire/onion_msg_payload.go
+++ b/lnwire/onion_msg_payload.go
@@ -7,7 +7,6 @@ import (
"io"
"sort"
- sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/tlv"
)
@@ -36,22 +35,15 @@ const (
InvoiceErrorNamespaceType tlv.Type = 68
)
-var (
- // ErrNotFinalPayload is returned when a final hop payload is not
- // within the correct range.
- ErrNotFinalPayload = errors.New("final hop payloads type should be " +
- ">= 64")
-
- // ErrNoHops is returned when we handle a reply path that does not
- // have any hops (this makes no sense).
- ErrNoHops = errors.New("reply path requires hops")
-)
+// ErrNotFinalPayload is returned when a final hop payload is not within the
+// correct range.
+var ErrNotFinalPayload = errors.New("final hop payloads type should be >= 64")
// OnionMessagePayload contains the contents of an onion message payload.
type OnionMessagePayload struct {
// ReplyPath contains a blinded path that can be used to respond to an
// onion message.
- ReplyPath *sphinx.BlindedPath
+ ReplyPath *BlindedPath
// EncryptedData contains encrypted data for the recipient.
EncryptedData []byte
@@ -73,7 +65,7 @@ func (o *OnionMessagePayload) Encode() ([]byte, error) {
var records []tlv.Record
if o.ReplyPath != nil {
- records = append(records, replyPathRecord(o.ReplyPath))
+ records = append(records, o.ReplyPath.Record())
}
if len(o.EncryptedData) != 0 {
@@ -131,11 +123,13 @@ func (o *OnionMessagePayload) Decode(r io.Reader) (map[tlv.Type][]byte, error) {
TLVType: InvoiceRequestNamespaceType,
}
)
- // Create a non-nil entry so that we can directly decode into it.
- o.ReplyPath = &sphinx.BlindedPath{}
+
+ // replyPath is used for decoding, we will later check if it was
+ // actually present and assign it to the message struct.
+ var replyPath BlindedPath
records := []tlv.Record{
- replyPathRecord(o.ReplyPath),
+ replyPath.Record(),
tlv.MakePrimitiveRecord(
encryptedDataTLVType, &o.EncryptedData,
),
@@ -171,9 +165,8 @@ func (o *OnionMessagePayload) Decode(r io.Reader) (map[tlv.Type][]byte, error) {
return tlvMap, fmt.Errorf("decode stream: %w", err)
}
- // If our reply path wasn't populated, replace it with a nil entry.
- if _, ok := tlvMap[replyPathType]; !ok {
- o.ReplyPath = nil
+ if _, ok := tlvMap[replyPathType]; ok {
+ o.ReplyPath = &replyPath
}
// Once we're decoded our message, we want to also include any tlvs
@@ -258,155 +251,3 @@ func (f *FinalHopTLV) Validate() error {
return nil
}
-
-// replyPathRecord produces a tlv record for a reply path.
-func replyPathRecord(r *sphinx.BlindedPath) tlv.Record {
- return tlv.MakeDynamicRecord(
- replyPathType, r, replyPathSize(r), encodeReplyPath,
- decodeReplyPath,
- )
-}
-
-// replyPathSize returns the encoded size of a reply path.
-func replyPathSize(r *sphinx.BlindedPath) func() uint64 {
- return func() uint64 {
- // First node pubkey 33 + blinding point pubkey 33 + 1 byte for
- // uint8 for our hop count.
- size := uint64(33 + 33 + 1)
-
- // Add each hop's size to our total.
- for _, hop := range r.BlindedHops {
- size += blindedHopSize(hop)
- }
-
- return size
- }
-}
-
-// encodeReplyPath encodes a reply path tlv.
-func encodeReplyPath(w io.Writer, val interface{}, buf *[8]byte) error {
- if p, ok := val.(*sphinx.BlindedPath); ok {
- err := tlv.EPubKey(w, &p.IntroductionPoint, buf)
- if err != nil {
- return fmt.Errorf("encode first node id: %w", err)
- }
-
- if err := tlv.EPubKey(w, &p.BlindingPoint, buf); err != nil {
- return fmt.Errorf("encode blinding point: %w", err)
- }
-
- hopCount := uint8(len(p.BlindedHops))
- if hopCount == 0 {
- return ErrNoHops
- }
-
- if err := tlv.EUint8(w, &hopCount, buf); err != nil {
- return fmt.Errorf("encode hop count: %w", err)
- }
-
- for i, hop := range p.BlindedHops {
- if err := encodeBlindedHop(w, hop, buf); err != nil {
- return fmt.Errorf("hop %v: %w", i, err)
- }
- }
-
- return nil
- }
-
- return tlv.NewTypeForEncodingErr(val, "*sphinx.BlindedPath")
-}
-
-// decodeReplyPath decodes a reply path tlv.
-func decodeReplyPath(r io.Reader, val interface{}, buf *[8]byte,
- l uint64) error {
-
- // If we have the correct type, and the length exceeds the fixed header
- // size (first node pubkey (33) + blinding point (33) + hop count (1) =
- // 67 bytes) to accommodate at least one hop, decode the reply path.
- if p, ok := val.(*sphinx.BlindedPath); ok && l > 67 {
- err := tlv.DPubKey(r, &p.IntroductionPoint, buf, 33)
- if err != nil {
- return fmt.Errorf("decode first id: %w", err)
- }
-
- err = tlv.DPubKey(r, &p.BlindingPoint, buf, 33)
- if err != nil {
- return fmt.Errorf("decode blinding point: %w", err)
- }
-
- var hopCount uint8
- if err := tlv.DUint8(r, &hopCount, buf, 1); err != nil {
- return fmt.Errorf("decode hop count: %w", err)
- }
-
- if hopCount == 0 {
- return ErrNoHops
- }
-
- for i := 0; i < int(hopCount); i++ {
- hop := &sphinx.BlindedHopInfo{}
- if err := decodeBlindedHop(r, hop, buf); err != nil {
- return fmt.Errorf("decode hop: %w", err)
- }
-
- p.BlindedHops = append(p.BlindedHops, hop)
- }
-
- return nil
- }
-
- return tlv.NewTypeForDecodingErr(val, "*sphinx.BlindedPath", l, l)
-}
-
-// blindedHopSize returns the encoded size of a blinded hop.
-func blindedHopSize(b *sphinx.BlindedHopInfo) uint64 {
- // 33 byte pubkey + 2 bytes uint16 length + var bytes.
- return uint64(33 + 2 + len(b.CipherText))
-}
-
-// encodeBlindedHop encodes a blinded hop tlv.
-func encodeBlindedHop(w io.Writer, val interface{}, buf *[8]byte) error {
- if b, ok := val.(*sphinx.BlindedHopInfo); ok {
- if err := tlv.EPubKey(w, &b.BlindedNodePub, buf); err != nil {
- return fmt.Errorf("encode blinded id: %w", err)
- }
-
- dataLen := uint16(len(b.CipherText))
- if err := tlv.EUint16(w, &dataLen, buf); err != nil {
- return fmt.Errorf("data len: %w", err)
- }
-
- if err := tlv.EVarBytes(w, &b.CipherText, buf); err != nil {
- return fmt.Errorf("encode encrypted data: %w", err)
- }
-
- return nil
- }
-
- return tlv.NewTypeForEncodingErr(val, "*sphinx.BlindedHopInfo")
-}
-
-// decodeBlindedHop decodes a blinded hop tlv.
-func decodeBlindedHop(r io.Reader, val interface{}, buf *[8]byte) error {
- if b, ok := val.(*sphinx.BlindedHopInfo); ok {
- err := tlv.DPubKey(r, &b.BlindedNodePub, buf, 33)
- if err != nil {
- return fmt.Errorf("decode blinded id: %w", err)
- }
-
- var dataLen uint16
- err = tlv.DUint16(r, &dataLen, buf, 2)
- if err != nil {
- return fmt.Errorf("decode data len: %w", err)
- }
-
- err = tlv.DVarBytes(r, &b.CipherText, buf, uint64(dataLen))
- if err != nil {
- return fmt.Errorf("decode data: %w", err)
- }
-
- return nil
- }
-
- return tlv.NewTypeForDecodingErr(val, "*sphinx.BlindedHopInfo", 0, 0)
-}
diff --git a/lnwire/onion_msg_payload_test.go b/lnwire/onion_msg_payload_test.go
index 3a85ec6..6871f99 100644
--- a/lnwire/onion_msg_payload_test.go
+++ b/lnwire/onion_msg_payload_test.go
@@ -5,15 +5,14 @@ import (
"fmt"
"testing"
- sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/tlv"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
// makeBlindedPath creates a BlindedPath with the given number of hops for
-// testing. Each hop has a random blinded node pub and some cipher text.
-func makeBlindedPath(t *testing.T, numHops int) *sphinx.BlindedPath {
+// testing. Each hop has a random blinded node ID and some cipher text.
+func makeBlindedPath(t *testing.T, numHops int) *BlindedPath {
t.Helper()
introKey, err := randPubKey()
@@ -22,55 +21,48 @@ func makeBlindedPath(t *testing.T, numHops int) *sphinx.BlindedPath {
blindingKey, err := randPubKey()
require.NoError(t, err)
- hops := make([]*sphinx.BlindedHopInfo, numHops)
+ hops := make([]BlindedHop, numHops)
for i := range hops {
nodePub, err := randPubKey()
require.NoError(t, err)
- hops[i] = &sphinx.BlindedHopInfo{
- BlindedNodePub: nodePub,
- CipherText: bytes.Repeat([]byte{byte(i + 1)}, 32),
- }
+ hops[i].BlindedNodeID = nodePub
+ hops[i].EncryptedData = bytes.Repeat([]byte{byte(i + 1)}, 32)
}
- return &sphinx.BlindedPath{
- IntroductionPoint: introKey,
- BlindingPoint: blindingKey,
- BlindedHops: hops,
+ return &BlindedPath{
+ IntroductionNode: PubkeyIntro{Pubkey: introKey},
+ BlindingPoint: blindingKey,
+ Hops: hops,
}
}
-// assertBlindedPathEqual compares two BlindedPaths for equality, checking each
-// field.
-func assertBlindedPathEqual(t *testing.T, expected,
- actual *sphinx.BlindedPath) {
-
+// assertBlindedPathEqual compares two BlindedPaths field-by-field. Direct
+// require.Equal would also work, but the per-field assertions surface
+// localised mismatches for easier triage.
+func assertBlindedPathEqual(t *testing.T, expected, actual *BlindedPath) {
t.Helper()
- require.True(
- t,
- expected.IntroductionPoint.IsEqual(actual.IntroductionPoint),
- "IntroductionPoint mismatch",
+ require.Equal(
+ t, expected.IntroductionNode, actual.IntroductionNode,
+ "IntroductionNode mismatch",
)
- require.True(
- t, expected.BlindingPoint.IsEqual(actual.BlindingPoint),
+ require.Equal(
+ t, expected.BlindingPoint, actual.BlindingPoint,
"BlindingPoint mismatch",
)
- require.Len(t, actual.BlindedHops, len(expected.BlindedHops))
-
- for i, expectedHop := range expected.BlindedHops {
- actualHop := actual.BlindedHops[i]
+ require.Len(t, actual.Hops, len(expected.Hops))
- require.True(
- t,
- expectedHop.BlindedNodePub.IsEqual(
- actualHop.BlindedNodePub,
- ),
- "hop %d: BlindedNodePub mismatch", i,
+ for i := range expected.Hops {
+ require.Equal(
+ t, expected.Hops[i].BlindedNodeID,
+ actual.Hops[i].BlindedNodeID,
+ "hop %d: BlindedNodeID mismatch", i,
)
require.Equal(
- t, expectedHop.CipherText, actualHop.CipherText,
- "hop %d: CipherText mismatch", i,
+ t, expected.Hops[i].EncryptedData,
+ actual.Hops[i].EncryptedData,
+ "hop %d: EncryptedData mismatch", i,
)
}
}
@@ -112,6 +104,29 @@ func TestOnionMessagePayloadRoundTrip(t *testing.T) {
require.Empty(t, decoded.FinalHopTLVs)
})
+ t.Run("sciddir intro reply path", func(t *testing.T) {
+ t.Parallel()
+
+ path := makeBlindedPath(t, 2)
+ path.IntroductionNode = SciddirIntro{
+ Direction: 0x01,
+ SCID: [scidLen]byte{
+ 0x00, 0x11, 0x22, 0x33,
+ 0x44, 0x55, 0x66, 0x77,
+ },
+ }
+
+ original := &OnionMessagePayload{ReplyPath: path}
+
+ decoded := encodeAndDecode(t, original)
+
+ require.NotNil(t, decoded.ReplyPath)
+ require.IsType(
+ t, SciddirIntro{}, decoded.ReplyPath.IntroductionNode,
+ )
+ assertBlindedPathEqual(t, original.ReplyPath, decoded.ReplyPath)
+ })
+
t.Run("only encrypted data", func(t *testing.T) {
t.Parallel()
@@ -351,15 +366,15 @@ func TestOnionMessagePayloadEncodeReplyPathNoHops(t *testing.T) {
require.NoError(t, err)
payload := &OnionMessagePayload{
- ReplyPath: &sphinx.BlindedPath{
- IntroductionPoint: introKey,
- BlindingPoint: blindingKey,
- BlindedHops: nil,
+ ReplyPath: &BlindedPath{
+ IntroductionNode: PubkeyIntro{Pubkey: introKey},
+ BlindingPoint: blindingKey,
+ Hops: nil,
},
}
_, err = payload.Encode()
- require.ErrorIs(t, err, ErrNoHops)
+ require.ErrorIs(t, err, ErrEmptyBlindedPath)
}
// TestOnionMessagePayloadEmpty tests that an empty payload roundtrips
@@ -442,35 +457,9 @@ func TestOnionMessagePayloadRoundTripQuickCheck(t *testing.T) {
require.Nil(t, decoded.ReplyPath)
} else {
require.NotNil(t, decoded.ReplyPath)
- require.True(
- t,
- original.ReplyPath.IntroductionPoint.IsEqual(
- decoded.ReplyPath.IntroductionPoint,
- ),
- )
- require.True(
- t,
- original.ReplyPath.BlindingPoint.IsEqual(
- decoded.ReplyPath.BlindingPoint,
- ),
- )
- require.Len(
- t, decoded.ReplyPath.BlindedHops,
- len(original.ReplyPath.BlindedHops),
+ require.Equal(
+ t, original.ReplyPath, decoded.ReplyPath,
)
- for i, hop := range original.ReplyPath.BlindedHops {
- dHop := decoded.ReplyPath.BlindedHops[i]
- require.True(
- t,
- hop.BlindedNodePub.IsEqual(
- dHop.BlindedNodePub,
- ),
- )
- require.Equal(
- t, hop.CipherText,
- dHop.CipherText,
- )
- }
}
// Verify encrypted data.
diff --git a/lnwire/test_utils.go b/lnwire/test_utils.go
index 602724a..ef0aaf0 100644
--- a/lnwire/test_utils.go
+++ b/lnwire/test_utils.go
@@ -9,7 +9,6 @@ import (
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
- sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
@@ -59,30 +58,48 @@ func RandPubKey(t *rapid.T) *btcec.PublicKey {
return pub
}
-// RandBlindedPath generates a random blinded path with 1-5 hops.
-func RandBlindedPath(t *rapid.T) *sphinx.BlindedPath {
- introKey := RandPubKey(t)
- blindingKey := RandPubKey(t)
+// RandBlindedPath generates a random blinded path with 1-5 hops, alternating
+// between the pubkey and sciddir introduction-node variants per draw.
+func RandBlindedPath(t *rapid.T) *BlindedPath {
+ useSciddir := rapid.Bool().Draw(t, "introIsSciddir")
+
+ var intro IntroductionNode
+ if useSciddir {
+ var scid [scidLen]byte
+ copy(scid[:], rapid.SliceOfN(
+ rapid.Byte(), scidLen, scidLen,
+ ).Draw(t, "introScid"))
+
+ dir := byte(rapid.IntRange(0, 1).Draw(t, "introDir"))
+ sciddir, err := NewSciddirIntro(dir, scid)
+ require.NoError(t, err)
+ intro = sciddir
+ } else {
+ pubkey, err := NewPubkeyIntro(RandPubKey(t))
+ require.NoError(t, err)
+ intro = pubkey
+ }
+
+ blindingPoint := RandPubKey(t)
numHops := rapid.IntRange(1, 5).Draw(t, "numBlindedHops")
- hops := make([]*sphinx.BlindedHopInfo, numHops)
+ hops := make([]BlindedHop, numHops)
for i := range hops {
cipherLen := rapid.IntRange(1, 128).Draw(
t, fmt.Sprintf("cipherLen-%d", i),
)
- hops[i] = &sphinx.BlindedHopInfo{
- BlindedNodePub: RandPubKey(t),
- CipherText: rapid.SliceOfN(
- rapid.Byte(), cipherLen, cipherLen,
- ).Draw(t, fmt.Sprintf("cipherText-%d", i)),
- }
+ hops[i].BlindedNodeID = RandPubKey(t)
+
+ hops[i].EncryptedData = rapid.SliceOfN(
+ rapid.Byte(), cipherLen, cipherLen,
+ ).Draw(t, fmt.Sprintf("cipherText-%d", i))
}
- return &sphinx.BlindedPath{
- IntroductionPoint: introKey,
- BlindingPoint: blindingKey,
- BlindedHops: hops,
+ return &BlindedPath{
+ IntroductionNode: intro,
+ BlindingPoint: blindingPoint,
+ Hops: hops,
}
}
diff --git a/onionmessage/onion_endpoint.go b/onionmessage/onion_endpoint.go
index f6a32d2..0c9829e 100644
--- a/onionmessage/onion_endpoint.go
+++ b/onionmessage/onion_endpoint.go
@@ -1,7 +1,7 @@
package onionmessage
import (
- sphinx "github.com/lightningnetwork/lightning-onion"
+ "github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
)
@@ -26,7 +26,7 @@ type OnionMessageUpdate struct {
CustomRecords record.CustomSet
// ReplyPath contains the reply path information for the onion message.
- ReplyPath *sphinx.BlindedPath
+ ReplyPath *lnwire.BlindedPath
// EncryptedRecipientData contains the encrypted recipient data for the
// onion message, created by the creator of the blinded route. This is
diff --git a/routing/route/blindedroute.go b/routing/route/blindedroute.go
index 2b8120a..35ad070 100644
--- a/routing/route/blindedroute.go
+++ b/routing/route/blindedroute.go
@@ -13,7 +13,7 @@ import (
// payloads used to encoding the routing data for each hop in the route. This
// method also accepts final hop payloads.
func OnionMessageBlindedPathToSphinxPath(blindedPath *sphinx.BlindedPath,
- replyPath *sphinx.BlindedPath, finalHopTLVs []*lnwire.FinalHopTLV) (
+ replyPath *lnwire.BlindedPath, finalHopTLVs []*lnwire.FinalHopTLV) (
*sphinx.PaymentPath, error) {
var path sphinx.PaymentPath
diff --git a/rpcserver.go b/rpcserver.go
index 8b40192..f71f6c5 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -8803,15 +8803,19 @@ func (r *rpcServer) SubscribeOnionMessages(
//nolint:ll
if oMsg.ReplyPath != nil {
- bp.IntroductionNode = oMsg.ReplyPath.IntroductionPoint.SerializeCompressed()
+ // TODO(bolt12): resolve sciddir intros via
+ // sciddirResolver so this field is uniformly a
+ // 33-byte pubkey?
+ bp.IntroductionNode = oMsg.ReplyPath.IntroductionNode.Bytes()
bp.BlindingPoint = oMsg.ReplyPath.BlindingPoint.SerializeCompressed()
- for _, hop := range oMsg.ReplyPath.BlindedHops {
- rpcHop := &lnrpc.BlindedHop{
- BlindedNode: hop.BlindedNodePub.SerializeCompressed(),
- EncryptedData: hop.CipherText,
- }
- bp.BlindedHops = append(bp.BlindedHops, rpcHop)
+ for _, hop := range oMsg.ReplyPath.Hops {
+ bp.BlindedHops = append(
+ bp.BlindedHops, &lnrpc.BlindedHop{
+ BlindedNode: hop.BlindedNodeID.SerializeCompressed(),
+ EncryptedData: hop.EncryptedData,
+ },
+ )
}
}
Why this scored 50/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.